diff --git a/README.md b/README.md index aa75c9a..a9e6b3a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,8 @@ client: - read_configuration # alternative scope that can be used only to read configuration storage: dir_path: "/tmp/data" # path to local configuration; default: "data" +features: # optional behaviour of plain pull, push and diff; all default to false + key_rotation: false # also handle workspace key rotation, see "Managing key rotation" profiles: # an optional map of profiles available for use, especially helpful when you want to compare multiple configurations stage: # each profile support same configuration as root (aka default profile) @@ -44,10 +46,19 @@ profiles: # an optional map of profiles available for use, especially helpful wh client_id: fb346c287c4d4e378cbae39aa0cxxxxx tenant_id: postmance-stage client_secret: invalid_secret + scopes: # client replaces the top-level client as a whole, so key rotation needs its scopes here too + - manage_configuration + - manage_servers storage: dir_path: "/tmp/other" + features: # like client and storage, replaces the top-level features for this profile + key_rotation: true ``` +A profile that does not define `client`, `storage`, `logging` or `features` uses the +top-level section instead. A profile that inherits a top-level `features.key_rotation: true` +must either set it to `false` or carry the `manage_servers` scope in its own `client.scopes`. + ## Commands ### Help @@ -242,12 +253,7 @@ map[string]any{ ### Managing key rotation Automatic key rotation settings of a workspace live behind a dedicated API and are -managed with the `key-rotation` subcommand of `pull`, `push`, and `diff`, for the -workspace selected with `--workspace` (`--tenant` is rejected). The subcommands do not -take the parent commands' own flags such as `--filter`, `--method`, or `--source`; only -`push key-rotation` keeps `--dry-run` and `--out`, and `diff key-rotation` keeps `--out` -and `--colors`. Plain `pull`, `push`, and `diff` never read or write these settings. -They are stored in `workspaces//key_rotation.yaml`. With several storage +stored in `workspaces//key_rotation.yaml`. With several storage directories, the file is taken whole from the first directory that has it; files are not merged across directories: @@ -262,6 +268,15 @@ enc: cron: "0 0 1 1 *" # required even when disabled ``` +The plain `pull`, `push`, and `diff` handle the file when +[`features.key_rotation`](#key-rotation-in-plain-pull-push-and-diff) is enabled. The +`key-rotation` subcommand of each is the same as the plain command with +`--filter key_rotation` for the selected workspace, whether or not the feature is +enabled; `diff key-rotation` compares `local` against `remote`. The subcommands reject +`--tenant` and do not take the parent commands' own flags such as `--filter`, +`--method`, or `--source`; only `push key-rotation` keeps `--dry-run` and `--out`, and +`diff key-rotation` keeps `--out` and `--colors`. + ```bash # write the remote settings to workspaces/demo/key_rotation.yaml cac --config ./cac.yaml --profile dev --workspace demo pull key-rotation @@ -276,9 +291,14 @@ cac --config ./cac.yaml --profile dev --workspace demo push key-rotation cac --config ./cac.yaml --profile dev --workspace demo diff key-rotation ``` -The system workspace client used by cac needs the `manage_servers` scope for these -subcommands, in addition to `manage_configuration`: add it to `client.scopes` in the -[configuration](#configuration). +The system workspace client used by cac needs the `manage_servers` scope for key +rotation, in addition to `manage_configuration`: add it to `client.scopes` in the +[configuration](#configuration). With `features.key_rotation: true`, cac refuses to start +without it: `features.key_rotation requires the manage_servers scope in client.scopes`. +`manage_servers` is broad: it also allows creating and deleting workspaces, and there is +no read-only alternative today. Every call cac makes in a run uses the same token, so the +whole run holds that scope. If your main CI client should stay narrow, manage key +rotation from a dedicated profile with its own client. Constraints: @@ -293,10 +313,82 @@ Constraints: `@hourly` and the `L`, `W`, and `#` modifiers are supported; `@every` is not. - `starting_from` is optional and write-only: SecureAuth never returns it, so `pull` never writes it and `diff` ignores it. It is only honored when it is in the future. +- `push` reads the remote settings first and skips a use whose `enabled` and `cron` + already match. A use that sets `starting_from` is always sent: SecureAuth never returns + it, so cac cannot tell whether it is already applied. - `scheduled_at` is read-only and rejected in the file. -> **Note:** `push key-rotation` validates every cron before any API call, -> and `--dry-run` prints the settings that would be sent instead of pushing them. +#### Key rotation in plain pull, push and diff + +With `features.key_rotation: true`, at the top level or in the selected profile, the +plain `pull`, `push`, and `diff` handle key rotation too, so a single `cac diff` or +`cac push`, for example in CI, covers it: + +```yaml +client: + scopes: + - manage_configuration + - manage_servers +features: + key_rotation: true +``` + +- Key rotation is handled when `--filter` is not given or includes `key_rotation`. + `--filter key_rotation` alone handles only key rotation and skips the rest of the + configuration; `--filter clients,key_rotation` handles both. +- `pull` writes `key_rotation.yaml` after the configuration. With `--tenant` it does so + for every workspace of the tenant. +- `push` reads and validates `key_rotation.yaml` before pushing anything, pushes it after + the configuration, and skips it when the file is absent. With `--tenant` it pushes the + file of every workspace directory that has one, including stale directories without + `server.yaml`: their key rotation request fails with a clear 404 after the import, and + rerunning the push is idempotent. `--method` and `--mode` do not apply to it: every key use + in the file that differs from the server is replaced. +- `push --dry-run` writes the settings that would be sent after the configuration. With + `--out -` (stdout) or a file, each workspace is a separate YAML document; an `--out` + file is emptied first, so it holds only this run's output: + + ```yaml + # ... configuration ... + --- + # key_rotation (demo) + sig: + enabled: true + cron: 0 0 1 * * + ``` + + With `--out` set to an existing directory, the settings go to + `/workspaces//key_rotation.yaml`. +- `diff` appends a section for each workspace whose settings differ, after the + configuration diff and in the same format, honoring `--out` and `--colors`: + + ``` + --- key_rotation (demo) + models.Rfc7396PatchOperation{ + "sig": map[string]any{ + - "cron": string("@monthly"), + + "cron": string("0 0 1 * *"), + "enabled": bool(true), + }, + } + ``` + + `--source` and `--target` map to key rotation as follows: `local` reads + `key_rotation.yaml`, `remote` reads the server, and `merged` reads the local file, + falling back to the server when the workspace has none. A workspace missing on the + target server has no settings there; one missing on the source server fails the diff. With `--tenant` the source's workspaces are compared: the local + workspace directories that have the file for `local`, the workspaces of the tenant for + `remote`, and both for `merged`. + + A `remote` or `merged` side of another profile without `features.key_rotation` fails + the diff when `--filter` names `key_rotation`. Otherwise the diff shows + `--- key_rotation skipped: profile has features.key_rotation disabled` + in place of the key rotation sections and still exits 0. A side of another profile + that enables the feature without the `manage_servers` scope always fails the diff. + +With the feature disabled, `--filter key_rotation` fails with +`--filter key_rotation requires features.key_rotation: true`, and plain `pull`, `push`, +and `diff` ignore key rotation. ## Templates diff --git a/cmd/diff.go b/cmd/diff.go index 46fd9bf..cf0d606 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -1,13 +1,15 @@ package cmd import ( + "context" + "os" + "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" "github.com/cloudentity/cac/internal/cac/diff" "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/exp/slog" - "os" ) var ( @@ -41,9 +43,8 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { var ( app *cac.Application + s scope result string - source api.Source - target api.Source err error ) @@ -63,23 +64,11 @@ Examples: return err } - if source, err = app.PickSource(diffConfig.Source, rootConfig.Tenant); err != nil { - return err - } - - if target, err = app.PickSource(diffConfig.Target, rootConfig.Tenant); err != nil { + if s, err = scopeOf(app.Config.KeyRotationEnabled(), diffConfig.Filters); err != nil { return err } - slog.Info("Comparing configurations", "source", source, "target", target) - - if result, err = diff.Diff(cmd.Context(), source, target, rootConfig.Workspace, - diff.Colorize(diffConfig.Colors), - diff.OnlyPresent(diffConfig.OnlyPresent), - diff.Filters(diffConfig.Filters...), - diff.WithSecrets(diffConfig.WithSecrets), - diff.FilterVolatileFields(diffConfig.FilterVolatile), - ); err != nil { + if result, err = runDiff(cmd.Context(), app, s, diffConfig.Source, diffConfig.Target); err != nil { return err } @@ -98,6 +87,48 @@ Examples: } ) +// runDiff compares the configuration and the key rotation of the source and target, as scoped. +func runDiff(ctx context.Context, app *cac.Application, s scope, sourceSpec, targetSpec string) (string, error) { + var ( + result string + kr string + source api.Source + target api.Source + err error + ) + + if s.regular { + if source, err = app.PickSource(sourceSpec, rootConfig.Tenant); err != nil { + return "", err + } + + if target, err = app.PickSource(targetSpec, rootConfig.Tenant); err != nil { + return "", err + } + + slog.Info("Comparing configurations", "source", source, "target", target) + + if result, err = diff.Diff(ctx, source, target, rootConfig.Workspace, + diff.Colorize(diffConfig.Colors), + diff.OnlyPresent(diffConfig.OnlyPresent), + diff.Filters(diffConfig.Filters...), + diff.WithSecrets(diffConfig.WithSecrets), + diff.FilterVolatileFields(diffConfig.FilterVolatile), + ); err != nil { + return "", err + } + } + + if s.keyRotation { + if kr, err = diffKeyRotations(ctx, app, s, sourceSpec, targetSpec); err != nil { + return "", err + } + } + + // a non-empty configuration diff ends with a newline, so the key rotation lines start their own + return result + kr, nil +} + func writeDiffResult(result string) error { if diffConfig.Out != "-" { if err := os.WriteFile(diffConfig.Out, []byte(result), 0644); err != nil { @@ -139,6 +170,7 @@ Workspace resources: clients, idps, claims, custom_apps, gateways, policies, pol ciba (alias of ciba_authentication_service) Tenant resources: pools, schemas, mfa_methods, themes, servers Reserved: root (only root-level tenant/workspace config, excluding nested resources) +Key rotation: key_rotation (workspace key rotation settings; requires features.key_rotation: true) Examples: --filter clients --filter clients,idps,policies diff --git a/cmd/key_rotation.go b/cmd/key_rotation.go index 8ba0948..13feb9f 100644 --- a/cmd/key_rotation.go +++ b/cmd/key_rotation.go @@ -1,21 +1,18 @@ package cmd import ( - "os" + "context" - "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" - "github.com/cloudentity/cac/internal/cac/diff" - "github.com/cloudentity/cac/internal/cac/keyrotation" - "github.com/cloudentity/cac/internal/cac/utils" + "github.com/cloudentity/cac/internal/cac/api" "github.com/pkg/errors" "github.com/spf13/cobra" - "golang.org/x/exp/slog" ) -// newKeyRotationCmd builds the key-rotation subcommand of pull, push or diff. It operates on the -// workspace selected with the root --workspace flag. -func newKeyRotationCmd(short string, run func(cmd *cobra.Command, wid string) error) *cobra.Command { +// newKeyRotationCmd builds the key-rotation subcommand of pull, push or diff: the parent command with +// --filter key_rotation, for the workspace selected with the root --workspace flag, whether or not +// features.key_rotation is enabled. +func newKeyRotationCmd(short string, run func(ctx context.Context, app *cac.Application, s scope) error) *cobra.Command { return &cobra.Command{ Use: "key-rotation", Short: short, @@ -25,183 +22,26 @@ func newKeyRotationCmd(short string, run func(cmd *cobra.Command, wid string) er return errors.New("--tenant does not apply to key-rotation; select a workspace with --workspace") } - return run(cmd, rootConfig.Workspace) + app, err := cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false) + if err != nil { + return err + } + + return run(cmd.Context(), app, scope{keyRotation: true, explicit: true}) }, } } func init() { - pullCmd.AddCommand(newKeyRotationCmd("Pull the automatic key rotation settings of the workspace", pullKeyRotation)) - pushCmd.AddCommand(newKeyRotationCmd("Push the automatic key rotation settings of the workspace", pushKeyRotation)) - diffCmd.AddCommand(newKeyRotationCmd("Compare local and remote automatic key rotation settings of the workspace", diffKeyRotation)) -} - -func pullKeyRotation(cmd *cobra.Command, wid string) error { - var ( - app *cac.Application - cfg *keyrotation.Config - err error - ) - - if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { - return err - } - - dirStore, err := keyRotationDirStore(app) - if err != nil { - return err - } - - slog.With("workspace", wid).Info("Pulling key rotation") - - if cfg, err = app.KeyRotation.Read(cmd.Context(), wid); err != nil { - return err - } - - if cfg == nil { - slog.Info("No key rotation configured", "workspace", wid) - return nil - } - - if err = dirStore.Write(wid, cfg); err != nil { - return err - } - - slog.Info("Pulled key rotation", "workspace", wid) - - return nil -} - -func keyRotationDirStore(app *cac.Application) (*keyrotation.DirStore, error) { - if app.Config.Storage == nil || len(app.Config.Storage.DirPath) == 0 { - return nil, errors.New("no storage directories configured for the selected profile") - } - - return keyrotation.NewDirStore(app.Config.Storage.DirPath), nil -} - -func pushKeyRotation(cmd *cobra.Command, wid string) error { - var ( - app *cac.Application - cfg *keyrotation.Config - err error - ) - - if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { - return err - } - - dirStore, err := keyRotationDirStore(app) - if err != nil { - return err - } - - if cfg, err = dirStore.Read(wid); err != nil { - return errors.Wrap(err, "failed to read local key rotation") - } - - if cfg == nil { - slog.Info("No key rotation configuration to push", "workspace", wid) - return nil - } - - if err = cfg.Validate(); err != nil { - return errors.Wrap(err, "failed to validate key rotation") - } - - if pushConfig.DryRun { - bts, err := utils.ToYaml(cfg) - if err != nil { - return errors.Wrap(err, "failed to marshal key rotation") - } - - if pushConfig.Out != "-" { - return errors.Wrap(os.WriteFile(pushConfig.Out, bts, 0644), "failed to write key rotation to file") - } - - if _, err = os.Stdout.Write(bts); err != nil { - return errors.Wrap(err, "failed to write key rotation to stdout") - } - - return nil - } - - if err = app.KeyRotation.Write(cmd.Context(), wid, cfg); err != nil { - return err - } - - slog.Info("Pushed key rotation", "workspace", wid, "uses", len(cfg.Uses())) - - return nil -} - -func diffKeyRotation(cmd *cobra.Command, wid string) error { - var ( - app *cac.Application - local, remote *keyrotation.Config - sourcePatch, targetPatch models.Rfc7396PatchOperation - result string - err error - ) - - if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { - return err - } - - dirStore, err := keyRotationDirStore(app) - if err != nil { - return err - } - - if local, err = dirStore.Read(wid); err != nil { - return errors.Wrap(err, "failed to read local key rotation") - } - - if remote, err = app.KeyRotation.Read(cmd.Context(), wid); err != nil { - return err - } - - source, target := keyRotationDiffConfigs(local, remote) - - if sourcePatch, err = utils.FromModelToPatch(source); err != nil { - return errors.Wrap(err, "failed to convert local key rotation") - } - - if targetPatch, err = utils.FromModelToPatch(target); err != nil { - return errors.Wrap(err, "failed to convert remote key rotation") - } - - if result, err = diff.Tree(sourcePatch, targetPatch, diff.Colorize(diffConfig.Colors)); err != nil { - return err - } - - return writeDiffResult(result) -} - -// keyRotationDiffConfigs prepares the local and remote configurations for comparison. It modifies -// its arguments and replaces nil with an empty configuration. -func keyRotationDiffConfigs(local, remote *keyrotation.Config) (source, target *keyrotation.Config) { - if local == nil { - local = &keyrotation.Config{} - } - - if remote == nil { - remote = &keyrotation.Config{} - } - - // the server never echoes starting_from back, so it would always show up as a difference - for _, use := range local.Uses() { - use.Rotation.StartingFrom = nil - } - - // push never removes a use that is absent locally, so diff previews only what push would change - if local.Sig == nil { - remote.Sig = nil - } - - if local.Enc == nil { - remote.Enc = nil - } + pullCmd.AddCommand(newKeyRotationCmd("Pull the automatic key rotation settings of the workspace", runPull)) + pushCmd.AddCommand(newKeyRotationCmd("Push the automatic key rotation settings of the workspace", runPush)) + diffCmd.AddCommand(newKeyRotationCmd("Compare local and remote automatic key rotation settings of the workspace", + func(ctx context.Context, app *cac.Application, s scope) error { + result, err := runDiff(ctx, app, s, string(api.SourceLocal), string(api.SourceRemote)) + if err != nil { + return err + } - return local, remote + return writeDiffResult(result) + })) } diff --git a/cmd/key_rotation_feature.go b/cmd/key_rotation_feature.go new file mode 100644 index 0000000..4c6fa0c --- /dev/null +++ b/cmd/key_rotation_feature.go @@ -0,0 +1,221 @@ +package cmd + +// Key rotation handling of pull, push and diff: with features.key_rotation or --filter key_rotation, +// and in their key-rotation subcommands. + +import ( + "context" + "fmt" + "os" + "slices" + + "github.com/cloudentity/acp-client-go/clients/hub/models" + "github.com/cloudentity/cac/internal/cac" + "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/client" + "github.com/cloudentity/cac/internal/cac/config" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/pkg/errors" + "golang.org/x/exp/slog" +) + +// keyRotationFilter is the --filter value that selects key rotation in pull, push and diff. +const keyRotationFilter = "key_rotation" + +// scope is what a pull, push or diff run handles. +type scope struct { + regular bool // the workspace or tenant configuration + keyRotation bool // workspaces//key_rotation.yaml + explicit bool // key rotation is asked for rather than included by features.key_rotation +} + +// scopeOf decides the scope of a plain pull, push or diff. With the feature on, key rotation is +// handled when --filter is empty or names key_rotation. --filter key_rotation alone skips the regular +// configuration, whose empty result is not a no-op: a pull rewrites server.yaml, an import resets the +// workspace settings, and a diff still reads and renders the whole configuration. Otherwise the +// regular pipeline ignores the key_rotation filter, as it does any filter naming no configuration key. +func scopeOf(enabled bool, filters []string) (scope, error) { + named := slices.Contains(filters, keyRotationFilter) + + if named && !enabled { + return scope{}, errors.New("--filter key_rotation requires features.key_rotation: true") + } + + only := named && !slices.ContainsFunc(filters, func(f string) bool { return f != keyRotationFilter }) + + return scope{ + regular: !only, + keyRotation: enabled && (len(filters) == 0 || named), + explicit: named, + }, nil +} + +// keyRotationDir returns the key rotation files of the profile. +func keyRotationDir(conf *config.Configuration) (*keyrotation.DirStore, error) { + if conf.Storage == nil || len(conf.Storage.DirPath) == 0 { + return nil, errors.Errorf("no storage directories configured for profile %s", conf.Name) + } + + return keyrotation.NewDirStore(conf.Storage.DirPath), nil +} + +// pullKeyRotations pulls key rotation after the configuration: for the selected workspace, or in tenant +// mode for every workspace of the tenant. data is what the regular pull read, nil when it was skipped. +func pullKeyRotations(ctx context.Context, app *cac.Application, data models.Rfc7396PatchOperation) error { + var ( + wids = []string{rootConfig.Workspace} + dir *keyrotation.DirStore + err error + ) + + if dir, err = keyRotationDir(app.Config); err != nil { + return err + } + + if rootConfig.Tenant { + if wids, err = keyrotation.TenantWorkspaces(ctx, app.Client, data); err != nil { + return err + } + } + + return keyrotation.Pull(ctx, app.KeyRotation, dir, wids) +} + +// readKeyRotations reads and validates the local key rotation settings a push sends, before anything +// is pushed: of the selected workspace, or in tenant mode of every workspace dir with a key rotation +// file. +func readKeyRotations(app *cac.Application) ([]keyrotation.WorkspaceConfig, error) { + var ( + wids = []string{rootConfig.Workspace} + dir *keyrotation.DirStore + items []keyrotation.WorkspaceConfig + err error + ) + + if dir, err = keyRotationDir(app.Config); err != nil { + return nil, err + } + + if rootConfig.Tenant { + if wids, err = dir.Workspaces(); err != nil { + return nil, err + } + } + + items, err = keyrotation.ReadAll(dir, wids) + if err != nil { + return nil, err + } + + if len(items) == 0 { + slog.Info("No key rotation configuration to push") + } + + return items, nil +} + +// pushKeyRotations pushes key rotation, or with --dry-run writes it after the regular dry-run output. +func pushKeyRotations(ctx context.Context, app *cac.Application, items []keyrotation.WorkspaceConfig) error { + if pushConfig.DryRun { + return keyrotation.WriteDryRun(pushConfig.Out, os.Stdout, items) + } + + return keyrotation.Push(ctx, app.KeyRotation, items) +} + +// sideSpec is a resolved --source or --target value of a diff. +type sideSpec struct { + conf *config.Configuration + source api.SourceType +} + +// gate decides whether the key rotation of the side can be compared. A side reading the server of a +// profile other than the selected one needs that profile to enable key rotation and to be valid: the +// selected profile was checked when the run was scoped and validated by InitApp. When the profile does +// not enable key rotation, gate fails if key rotation was asked for explicitly and otherwise returns +// the marker the diff shows instead. +func (sp sideSpec) gate(app *cac.Application, s scope) (skipped string, err error) { + if sp.source == api.SourceLocal || sp.conf.Name == app.Config.Name { + return "", nil + } + + if !sp.conf.KeyRotationEnabled() { + if s.explicit { + return "", errors.Errorf("profile %s cannot diff key rotation: features.key_rotation is not enabled", sp.conf.Name) + } + + return keyrotation.Skipped(fmt.Sprintf("profile %s has features.key_rotation disabled", sp.conf.Name)), nil + } + + return "", errors.Wrapf(sp.conf.Validate(), "profile %s", sp.conf.Name) +} + +// side builds the key rotation side: local reads the profile's storage dirs, remote the profile's +// server, and merged the local file, falling back to the server when the workspace has none. +func (sp sideSpec) side(app *cac.Application) (*keyrotation.Side, error) { + var ( + side = &keyrotation.Side{} + err error + ) + + if sp.source != api.SourceRemote { + if side.Local, err = keyRotationDir(sp.conf); err != nil { + return nil, err + } + } + + if sp.source == api.SourceLocal { + return side, nil + } + + // the selected profile's client is already initialized; in tenant mode it is a tenant client + if sp.conf.Name == app.Config.Name { + side.Remote, side.Tenant = app.KeyRotation, app.Client + return side, nil + } + + c, err := client.InitClient(sp.conf.Client) + if err != nil { + return nil, err + } + + side.Remote, side.Tenant = c.KeyRotationStore(), c.Tenant() + + return side, nil +} + +// diffKeyRotations renders the key rotation diff of the selected workspace, or in tenant mode of every +// workspace the source has. Target uses absent from the source are ignored, as in diff key-rotation. +func diffKeyRotations(ctx context.Context, app *cac.Application, s scope, sourceSpec, targetSpec string) (string, error) { + var ( + wids = []string{rootConfig.Workspace} + specs [2]sideSpec + sides [2]*keyrotation.Side + err error + ) + + // both sides are gated before either is built, so a skipped diff needs no storage or client + for i, raw := range []string{sourceSpec, targetSpec} { + if specs[i].conf, specs[i].source, err = app.ResolveSource(raw); err != nil { + return "", err + } + + if skipped, err := specs[i].gate(app, s); err != nil || skipped != "" { + return skipped, err + } + } + + for i := range specs { + if sides[i], err = specs[i].side(app); err != nil { + return "", err + } + } + + if rootConfig.Tenant { + if wids, err = sides[0].Workspaces(ctx); err != nil { + return "", err + } + } + + return keyrotation.Diff(ctx, sides[0], sides[1], wids, diffConfig.Colors) +} diff --git a/cmd/key_rotation_feature_test.go b/cmd/key_rotation_feature_test.go new file mode 100644 index 0000000..5178453 --- /dev/null +++ b/cmd/key_rotation_feature_test.go @@ -0,0 +1,146 @@ +package cmd + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/cloudentity/cac/internal/cac" + "github.com/cloudentity/cac/internal/cac/client" + "github.com/cloudentity/cac/internal/cac/config" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/cloudentity/cac/internal/cac/storage" + "github.com/stretchr/testify/require" +) + +func TestScopeOf(t *testing.T) { + tcs := []struct { + name string + enabled bool + filters []string + want scope + err string + }{ + {name: "disabled without filters", want: scope{regular: true}}, + {name: "disabled with key_rotation", filters: []string{"key_rotation"}, err: "--filter key_rotation requires features.key_rotation: true"}, + {name: "disabled with other filters", filters: []string{"clients"}, want: scope{regular: true}}, + {name: "disabled with mixed filters", filters: []string{"clients", "key_rotation"}, err: "--filter key_rotation requires features.key_rotation: true"}, + {name: "enabled without filters", enabled: true, want: scope{regular: true, keyRotation: true}}, + {name: "enabled with key_rotation", enabled: true, filters: []string{"key_rotation"}, want: scope{keyRotation: true, explicit: true}}, + {name: "enabled with key_rotation twice", enabled: true, filters: []string{"key_rotation", "key_rotation"}, want: scope{keyRotation: true, explicit: true}}, + {name: "enabled with other filters", enabled: true, filters: []string{"clients"}, want: scope{regular: true}}, + {name: "enabled with mixed filters", enabled: true, filters: []string{"key_rotation", "clients"}, want: scope{regular: true, keyRotation: true, explicit: true}}, + } + + for _, tc := range tcs { + t.Run(tc.name, func(t *testing.T) { + got, err := scopeOf(tc.enabled, tc.filters) + + if tc.err != "" { + require.EqualError(t, err, tc.err) + return + } + + require.NoError(t, err) + require.Equal(t, tc.want, got) + }) + } +} + +// keyRotationApp builds an application whose selected profile keeps its files in a temp dir and has +// key rotation on, next to the given other profile, so that diffs between local sides need no server. +func keyRotationApp(t *testing.T, other config.Configuration) *cac.Application { + selected := config.Configuration{ + Name: "default", + Storage: &storage.MultiStorageConfiguration{DirPath: []string{t.TempDir()}}, + Features: &config.Features{KeyRotation: true}, + } + other.Name = "other" + + return &cac.Application{ + Config: &selected, + RootConfig: &config.RootConfiguration{Default: selected, Profiles: map[string]config.Configuration{"other": other}}, + } +} + +// setFlags selects the demo workspace and turns diff colors off, restoring the flags after the test. +func setFlags(t *testing.T) { + savedDiff, savedPush, savedRoot := diffConfig, pushConfig, rootConfig + t.Cleanup(func() { diffConfig, pushConfig, rootConfig = savedDiff, savedPush, savedRoot }) + + diffConfig.Colors = false + rootConfig.Workspace, rootConfig.Tenant = "demo", false +} + +// fakeKeyRotation is a key rotation server holding the settings of each workspace. +type fakeKeyRotation map[string]*keyrotation.Config + +func (f fakeKeyRotation) Read(_ context.Context, wid string) (*keyrotation.Config, error) { + return f[wid], nil +} + +func (f fakeKeyRotation) Write(_ context.Context, wid string, cfg *keyrotation.Config) error { + f[wid] = cfg + return nil +} + +func monthly(enabled bool) *keyrotation.Config { + return &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: enabled, Cron: "0 0 1 * *"}} +} + +func TestRunDiffKeyRotationOnly(t *testing.T) { + otherDir := t.TempDir() + app := keyRotationApp(t, config.Configuration{ + Storage: &storage.MultiStorageConfiguration{DirPath: []string{otherDir}}, + Features: &config.Features{KeyRotation: true}, + }) + dir := app.Config.Storage.DirPath[0] + + // a broken template fails the regular diff of the workspace + require.NoError(t, os.MkdirAll(filepath.Join(dir, "workspaces", "demo"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir, "workspaces", "demo", "server.yaml"), []byte("{{ broken"), 0644)) + require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("demo", monthly(true))) + require.NoError(t, keyrotation.NewDirStore([]string{otherDir}).Write("demo", monthly(false))) + + want, err := keyrotation.TreeDiff(monthly(true), monthly(false), false) + require.NoError(t, err) + + setFlags(t) + + result, err := runDiff(context.Background(), app, scope{keyRotation: true, explicit: true}, "local", "other@local") + require.NoError(t, err) + require.Equal(t, "--- key_rotation (demo)\n"+want, result) + + _, err = runDiff(context.Background(), app, scope{regular: true, keyRotation: true, explicit: true}, "local", "other@local") + require.ErrorContains(t, err, "failed to render template") +} + +func TestDiffKeyRotationsOtherProfile(t *testing.T) { + app := keyRotationApp(t, config.Configuration{Features: &config.Features{}}) + + t.Run("without --filter the diff carries a marker", func(t *testing.T) { + setFlags(t) + + for _, sides := range [][2]string{{"local", "other@remote"}, {"other@merged", "local"}} { + result, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true}, sides[0], sides[1]) + require.NoError(t, err, sides) + require.Equal(t, "--- key_rotation skipped: profile other has features.key_rotation disabled\n", result, sides) + } + }) + + t.Run("--filter key_rotation fails", func(t *testing.T) { + setFlags(t) + + _, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true, explicit: true}, "local", "other@remote") + require.EqualError(t, err, "profile other cannot diff key rotation: features.key_rotation is not enabled") + }) + + t.Run("an enabled profile without the manage_servers scope fails", func(t *testing.T) { + app := keyRotationApp(t, config.Configuration{Features: &config.Features{KeyRotation: true}, Client: client.DefaultConfig()}) + setFlags(t) + + _, err := diffKeyRotations(context.Background(), app, scope{regular: true, keyRotation: true}, "local", "other@remote") + require.EqualError(t, err, "profile other: features.key_rotation requires the manage_servers scope in client.scopes") + }) +} diff --git a/cmd/key_rotation_test.go b/cmd/key_rotation_test.go index 8a8baa0..b0ef788 100644 --- a/cmd/key_rotation_test.go +++ b/cmd/key_rotation_test.go @@ -1,76 +1,17 @@ package cmd import ( + "context" "io" + "os" + "path/filepath" "testing" - "time" - "github.com/cloudentity/cac/internal/cac/diff" + "github.com/cloudentity/cac/internal/cac/config" "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)) - }) -} - // TestKeyRotationFlagScoping checks that the required flags of push and diff still apply to them, while // the key-rotation subcommands neither require nor accept the parent commands' own flags. func TestKeyRotationFlagScoping(t *testing.T) { @@ -99,3 +40,85 @@ func TestKeyRotationFlagScoping(t *testing.T) { require.EqualError(t, rootCmd.Execute(), tc.err, tc.args) } } + +// TestKeyRotationSubcommands runs pull, push and diff with the scope of their key-rotation +// subcommands, with the feature off. +func TestKeyRotationSubcommands(t *testing.T) { + var ( + ctx = context.Background() + app = keyRotationApp(t, config.Configuration{}) + remote = fakeKeyRotation{"demo": monthly(false)} + dir = keyrotation.NewDirStore(app.Config.Storage.DirPath) + s = scope{keyRotation: true, explicit: true} + ) + + app.Config.Features.KeyRotation = false + app.KeyRotation = remote + setFlags(t) + + t.Run("pull", func(t *testing.T) { + require.NoError(t, runPull(ctx, app, s)) + + got, err := dir.Read("demo") + require.NoError(t, err) + require.Equal(t, monthly(false), got) + }) + + require.NoError(t, dir.Write("demo", monthly(true))) + + t.Run("diff", func(t *testing.T) { + want, err := keyrotation.TreeDiff(monthly(true), monthly(false), false) + require.NoError(t, err) + + result, err := runDiff(ctx, app, s, "local", "remote") + require.NoError(t, err) + require.Equal(t, "--- key_rotation (demo)\n"+want, result) + }) + + t.Run("push --dry-run to an existing directory", func(t *testing.T) { + out := t.TempDir() + pushConfig.DryRun, pushConfig.Out = true, out + + require.NoError(t, runPush(ctx, app, s)) + + got, err := keyrotation.NewDirStore([]string{out}).Read("demo") + require.NoError(t, err) + require.Equal(t, monthly(true), got) + }) + + t.Run("push --dry-run to a file, replacing it", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "out.yaml") + require.NoError(t, os.WriteFile(out, []byte("stale: true\n"), 0644)) + pushConfig.DryRun, pushConfig.Out = true, out + + require.NoError(t, runPush(ctx, app, s)) + + section, err := keyrotation.DryRunSection("demo", monthly(true)) + require.NoError(t, err) + + bts, err := os.ReadFile(out) + require.NoError(t, err) + require.Equal(t, string(section), string(bts)) + }) + + t.Run("push --dry-run with nothing to push empties the file", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "out.yaml") + require.NoError(t, os.WriteFile(out, []byte("stale: true\n"), 0644)) + pushConfig.DryRun, pushConfig.Out = true, out + rootConfig.Workspace = "unconfigured" + t.Cleanup(func() { rootConfig.Workspace = "demo" }) + + require.NoError(t, runPush(ctx, app, s)) + + bts, err := os.ReadFile(out) + require.NoError(t, err) + require.Empty(t, bts) + }) + + t.Run("push", func(t *testing.T) { + pushConfig.DryRun = false + + require.NoError(t, runPush(ctx, app, s)) + require.Equal(t, monthly(true), remote["demo"]) + }) +} diff --git a/cmd/pull.go b/cmd/pull.go index 982cec1..06a2651 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -1,6 +1,8 @@ package cmd import ( + "context" + "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" @@ -29,36 +31,20 @@ Examples: cac --config ./cac.yaml --profile dev --tenant pull`, RunE: func(cmd *cobra.Command, args []string) error { var ( - app *cac.Application - data models.Rfc7396PatchOperation - err error + app *cac.Application + s scope + err error ) if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } - slog. - With("workspace", rootConfig.Workspace). - With("tenant", rootConfig.Tenant). - With("filters", pullConfig.Filters). - With("config", rootConfig.ConfigPath). - Info("Pulling configuration") - - if data, err = app.Client.Read( - cmd.Context(), - api.WithWorkspace(rootConfig.Workspace), - api.WithSecrets(pullConfig.WithSecrets), - api.WithFilters(pullConfig.Filters), - ); err != nil { - return err - } - - if err = app.Storage.Write(cmd.Context(), data, api.WithWorkspace(rootConfig.Workspace)); err != nil { + if s, err = scopeOf(app.Config.KeyRotationEnabled(), pullConfig.Filters); err != nil { return err } - return nil + return runPull(cmd.Context(), app, s) }, } pullConfig struct { @@ -67,6 +53,42 @@ Examples: } ) +// runPull pulls the configuration and the key rotation, as scoped. +func runPull(ctx context.Context, app *cac.Application, s scope) error { + var ( + data models.Rfc7396PatchOperation + err error + ) + + if s.regular { + slog. + With("workspace", rootConfig.Workspace). + With("tenant", rootConfig.Tenant). + With("filters", pullConfig.Filters). + With("config", rootConfig.ConfigPath). + Info("Pulling configuration") + + if data, err = app.Client.Read( + ctx, + api.WithWorkspace(rootConfig.Workspace), + api.WithSecrets(pullConfig.WithSecrets), + api.WithFilters(pullConfig.Filters), + ); err != nil { + return err + } + + if err = app.Storage.Write(ctx, data, api.WithWorkspace(rootConfig.Workspace)); err != nil { + return err + } + } + + if s.keyRotation { + return pullKeyRotations(ctx, app, data) + } + + return nil +} + func init() { pullCmd.Flags().BoolVar(&pullConfig.WithSecrets, "with-secrets", false, `Include secret fields (client secrets, signing keys, etc.) in the pulled configuration. Example: --with-secrets`) @@ -77,6 +99,7 @@ Workspace resources: clients, idps, claims, custom_apps, gateways, policies, pol ciba (alias of ciba_authentication_service) Tenant resources: pools, schemas, mfa_methods, themes, servers Reserved: root (only root-level tenant/workspace config, excluding nested resources) +Key rotation: key_rotation (workspace key rotation settings; requires features.key_rotation: true) Examples: --filter clients --filter clients,idps,policies diff --git a/cmd/push.go b/cmd/push.go index 6bc194d..b0f26c6 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -1,9 +1,13 @@ package cmd import ( + "context" + "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/pkg/errors" "github.com/spf13/cobra" @@ -37,65 +41,20 @@ Examples: cac --config ./cac.yaml --profile dev --workspace demo push --method patch --filter clients,services`, RunE: func(cmd *cobra.Command, args []string) error { var ( - app *cac.Application - data models.Rfc7396PatchOperation - err error + app *cac.Application + s scope + err error ) if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } - if data, err = app.Storage.Read( - cmd.Context(), - api.WithWorkspace(rootConfig.Workspace), - api.WithFilters(pushConfig.Filters), - ); err != nil { + if s, err = scopeOf(app.Config.KeyRotationEnabled(), pushConfig.Filters); err != nil { return err } - if !pushConfig.NoLocalValidate { - if err = app.Validator.Validate(&data); err != nil { - return errors.Wrap(err, "failed to validate configuration") - } - } - - if pushConfig.DryRun { - slog.Info("dry run enabled, storing files to disk instead of pushing to server") - - var ( - dryStorage storage.Storage - constr = storage.InitServerStorage - ) - - if rootConfig.Tenant { - constr = storage.InitTenantStorage - } - - if dryStorage, err = storage.InitDryStorage(pushConfig.Out, constr); err != nil { - return errors.Wrap(err, "failed to initialize dry storage") - } - - if err = dryStorage.Write(cmd.Context(), data, api.WithWorkspace(rootConfig.Workspace)); err != nil { - return errors.Wrap(err, "failed to write configuration") - } - - return nil - } - - if err = app.Client.Write( - cmd.Context(), - data, - api.WithWorkspace(rootConfig.Workspace), - api.WithMode(pushConfig.Mode), - api.WithMethod(pushConfig.Method), - ); err != nil { - return errors.Wrap(err, "failed to push configuration") - } - - slog.Info("pushed configuration") - - return nil + return runPush(cmd.Context(), app, s) }, } pushConfig struct { @@ -108,6 +67,112 @@ Examples: } ) +// runPush pushes the configuration and the key rotation, as scoped. +func runPush(ctx context.Context, app *cac.Application, s scope) error { + var ( + krs []keyrotation.WorkspaceConfig + err error + ) + + // key rotation is read and validated up front, so an invalid file fails before anything is pushed + if s.keyRotation { + if krs, err = readKeyRotations(app); err != nil { + return err + } + } + + if pushConfig.DryRun { + if err = resetDryRunOut(pushConfig.Out); err != nil { + return err + } + } + + if s.regular { + if err = pushConfiguration(ctx, app, pushConfig.Filters); err != nil { + return err + } + } + + if s.keyRotation { + return pushKeyRotations(ctx, app, krs) + } + + return nil +} + +// resetDryRunOut empties a flat --out file at the start of a dry run, so that each part of the run +// writes after the previous one and a run with nothing to write leaves no stale output behind. +func resetDryRunOut(out string) error { + if out == "-" { + return nil + } + + if info, err := os.Stat(out); err == nil && info.IsDir() { + return nil + } + + return errors.Wrap(os.WriteFile(out, nil, 0644), "failed to reset dry-run output file") +} + +// pushConfiguration pushes the regular configuration, or writes it to --out with --dry-run. +func pushConfiguration(ctx context.Context, app *cac.Application, filters []string) error { + var ( + data models.Rfc7396PatchOperation + err error + ) + + if data, err = app.Storage.Read( + ctx, + api.WithWorkspace(rootConfig.Workspace), + api.WithFilters(filters), + ); err != nil { + return err + } + + if !pushConfig.NoLocalValidate { + if err = app.Validator.Validate(&data); err != nil { + return errors.Wrap(err, "failed to validate configuration") + } + } + + if pushConfig.DryRun { + slog.Info("dry run enabled, storing files to disk instead of pushing to server") + + var ( + dryStorage storage.Storage + constr = storage.InitServerStorage + ) + + if rootConfig.Tenant { + constr = storage.InitTenantStorage + } + + if dryStorage, err = storage.InitDryStorage(pushConfig.Out, constr); err != nil { + return errors.Wrap(err, "failed to initialize dry storage") + } + + if err = dryStorage.Write(ctx, data, api.WithWorkspace(rootConfig.Workspace)); err != nil { + return errors.Wrap(err, "failed to write configuration") + } + + return nil + } + + if err = app.Client.Write( + ctx, + data, + api.WithWorkspace(rootConfig.Workspace), + api.WithMode(pushConfig.Mode), + api.WithMethod(pushConfig.Method), + ); err != nil { + return errors.Wrap(err, "failed to push configuration") + } + + slog.Info("pushed configuration") + + return nil +} + func init() { pushCmd.PersistentFlags().BoolVar(&pushConfig.DryRun, "dry-run", false, `Write the resolved configuration to disk or stdout instead of pushing to the server. Use with --out to control the destination. @@ -139,6 +204,7 @@ Workspace resources: clients, idps, claims, custom_apps, gateways, policies, pol ciba (alias of ciba_authentication_service) Tenant resources: pools, schemas, mfa_methods, themes, servers Reserved: root (only root-level tenant/workspace config, excluding nested resources) +Key rotation: key_rotation (workspace key rotation settings; requires features.key_rotation: true) Examples: --filter clients --filter clients,idps,policies diff --git a/internal/cac/app.go b/internal/cac/app.go index 507f5c6..42f5434 100644 --- a/internal/cac/app.go +++ b/internal/cac/app.go @@ -5,6 +5,7 @@ import ( "github.com/cloudentity/cac/internal/cac/client" "github.com/cloudentity/cac/internal/cac/config" "github.com/cloudentity/cac/internal/cac/data" + "github.com/cloudentity/cac/internal/cac/keyrotation" "github.com/cloudentity/cac/internal/cac/logging" "github.com/cloudentity/cac/internal/cac/storage" "golang.org/x/exp/slog" @@ -17,7 +18,7 @@ type Application struct { Client api.Source Storage storage.Storage Validator data.ValidatorApi - KeyRotation *client.KeyRotationAPIStore + KeyRotation keyrotation.Remote } func InitApp(configPath string, profile string, tenant bool) (app *Application, err error) { @@ -31,6 +32,10 @@ func InitApp(configPath string, profile string, tenant bool) (app *Application, return app, err } + if err = app.Config.Validate(); err != nil { + return app, err + } + if err = logging.InitLogging(app.Config.Logging); err != nil { return app, err } @@ -71,13 +76,15 @@ func InitApp(configPath string, profile string, tenant bool) (app *Application, return app, nil } -func (a *Application) PickSource(source string, tenant bool) (api.Source, error) { +// ResolveSource resolves a --source or --target value, "[profile@]source-type", to the profile's +// configuration and the source type. Without a profile it refers to the selected one. +func (a *Application) ResolveSource(spec string) (*config.Configuration, api.SourceType, error) { var ( conf *config.Configuration sourceType api.SourceType err error - profile, sourceS, found = strings.Cut(source, "@") + profile, sourceS, found = strings.Cut(spec, "@") ) if !found { @@ -88,10 +95,19 @@ func (a *Application) PickSource(source string, tenant bool) (api.Source, error) } if conf, err = a.RootConfig.ForProfile(profile); err != nil { - return nil, err + return nil, "", err } if sourceType, err = api.SourceFromString(sourceS); err != nil { + return nil, "", err + } + + return conf, sourceType, nil +} + +func (a *Application) PickSource(source string, tenant bool) (api.Source, error) { + conf, sourceType, err := a.ResolveSource(source) + if err != nil { return nil, err } @@ -124,7 +140,7 @@ func (a *Application) PickSource(source string, tenant bool) (api.Source, error) c *client.Client err error ) - + ms, err := storage.InitMultiStorage(conf.Storage, constructor) if err != nil { diff --git a/internal/cac/app_test.go b/internal/cac/app_test.go index e47d1a3..53f14f0 100644 --- a/internal/cac/app_test.go +++ b/internal/cac/app_test.go @@ -1,9 +1,12 @@ package cac_test import ( + "testing" + "github.com/cloudentity/cac/internal/cac" + "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/config" "github.com/stretchr/testify/require" - "testing" ) func TestInitApp(t *testing.T) { @@ -17,3 +20,29 @@ func TestInitApp(t *testing.T) { require.NotNil(t, app.Storage) }) } + +func TestResolveSource(t *testing.T) { + app := &cac.Application{ + Config: &config.Configuration{Name: "dev"}, + RootConfig: &config.RootConfiguration{ + Default: config.Configuration{Name: "default"}, + Profiles: map[string]config.Configuration{"dev": {Name: "dev"}, "prod": {Name: "prod"}}, + }, + } + + conf, source, err := app.ResolveSource("remote") + require.NoError(t, err) + require.Equal(t, "dev", conf.Name) + require.Equal(t, api.SourceRemote, source) + + conf, source, err = app.ResolveSource("prod@merged") + require.NoError(t, err) + require.Equal(t, "prod", conf.Name) + require.Equal(t, api.SourceMerged, source) + + _, _, err = app.ResolveSource("missing@local") + require.ErrorIs(t, err, config.ErrUnknownProfile) + + _, _, err = app.ResolveSource("prod@unknown") + require.ErrorIs(t, err, api.ErrUnknownSource) +} diff --git a/internal/cac/config/config.go b/internal/cac/config/config.go index 7b9d125..5490d3c 100644 --- a/internal/cac/config/config.go +++ b/internal/cac/config/config.go @@ -2,6 +2,7 @@ package config import ( "os" + "slices" "strings" "github.com/cloudentity/cac/internal/cac/client" @@ -17,13 +18,23 @@ import ( var ( DefaultConfig = func() Configuration { return Configuration{ - Client: client.DefaultConfig(), - Storage: storage.DefaultMultiStorageConfig(), - Logging: logging.DefaultLoggingConfig(), + Client: client.DefaultConfig(), + Storage: storage.DefaultMultiStorageConfig(), + Logging: logging.DefaultLoggingConfig(), + Features: &Features{}, } } ) +// manageServersScope is the scope the system keys API behind key rotation requires. +const manageServersScope = "manage_servers" + +// Features toggles optional behaviour of the plain pull, push and diff commands. +type Features struct { + // KeyRotation makes plain pull, push and diff also handle workspaces//key_rotation.yaml. + KeyRotation bool `json:"key_rotation"` +} + type RootConfiguration struct { // nolint Default Configuration `json:",inline,squash"` // default profile @@ -46,10 +57,25 @@ func (c *RootConfiguration) ForProfile(profile string) (*Configuration, error) { } type Configuration struct { - Name string `json:"name"` - Logging *logging.Configuration `json:"logging"` - Client *client.Configuration `json:"client"` - Storage *storage.MultiStorageConfiguration `json:"storage"` + Name string `json:"name"` + Logging *logging.Configuration `json:"logging"` + Client *client.Configuration `json:"client"` + Storage *storage.MultiStorageConfiguration `json:"storage"` + Features *Features `json:"features"` +} + +// KeyRotationEnabled reports whether features.key_rotation is on. +func (c *Configuration) KeyRotationEnabled() bool { + return c.Features != nil && c.Features.KeyRotation +} + +// Validate checks settings that only make sense together. +func (c *Configuration) Validate() error { + if c.KeyRotationEnabled() && (c.Client == nil || !slices.Contains(c.Client.Scopes, manageServersScope)) { + return errors.New("features.key_rotation requires the manage_servers scope in client.scopes") + } + + return nil } func (c *Configuration) SetImplicitValues(name string, defaultConfig Configuration) { @@ -59,9 +85,9 @@ func (c *Configuration) SetImplicitValues(name string, defaultConfig Configurati // Fall back to the default config, but as an independent copy so that // profiles never share (and accidentally mutate) the default's objects. - // Logging only holds value fields, so a plain struct copy is enough; the - // client and storage configs carry reference fields and are deep-copied - // via their Clone methods. + // Logging and features only hold value fields, so a plain struct copy is + // enough; the client and storage configs carry reference fields and are + // deep-copied via their Clone methods. if c.Logging == nil && defaultConfig.Logging != nil { clone := *defaultConfig.Logging c.Logging = &clone @@ -74,6 +100,11 @@ func (c *Configuration) SetImplicitValues(name string, defaultConfig Configurati if c.Storage == nil && defaultConfig.Storage != nil { c.Storage = defaultConfig.Storage.Clone() } + + if c.Features == nil && defaultConfig.Features != nil { + clone := *defaultConfig.Features + c.Features = &clone + } } func InitConfig(path string) (_ *RootConfiguration, err error) { diff --git a/internal/cac/config/config_test.go b/internal/cac/config/config_test.go index b533051..dac275e 100644 --- a/internal/cac/config/config_test.go +++ b/internal/cac/config/config_test.go @@ -175,4 +175,137 @@ func TestReadingConfiguration(t *testing.T) { require.NotNil(t, conf.Client.IssuerURL) require.Equal(t, "https://postmance.eu.authz.cloudentity.io/postmance/system", conf.Client.IssuerURL.String()) }) + + t.Run("features are off by default", func(t *testing.T) { + rootConf, err := config.InitConfig("./../../../examples/e2e/config.yaml") + require.NoError(t, err) + + conf, err := rootConf.ForProfile("") + require.NoError(t, err) + require.NotNil(t, conf.Features) + require.False(t, conf.KeyRotationEnabled()) + + stage, err := rootConf.ForProfile("stage") + require.NoError(t, err) + require.False(t, stage.KeyRotationEnabled()) + }) + + t.Run("profile features override the default features", func(t *testing.T) { + rootConf, err := config.InitConfig("./testdata/features.yaml") + require.NoError(t, err) + + defaultConf, err := rootConf.ForProfile("") + require.NoError(t, err) + require.True(t, defaultConf.Features.KeyRotation) + + off, err := rootConf.ForProfile("off") + require.NoError(t, err) + require.False(t, off.Features.KeyRotation) + + // a profile without features falls back to an independent copy of the default + bare, err := rootConf.ForProfile("bare") + require.NoError(t, err) + require.True(t, bare.Features.KeyRotation) + require.NotSame(t, defaultConf.Features, bare.Features) + + bare.Features.KeyRotation = false + require.True(t, defaultConf.Features.KeyRotation, + "mutating the profile features must not affect the default features") + }) + + t.Run("profile enables a feature the default leaves off", func(t *testing.T) { + rootConf, err := config.InitConfig("./testdata/features_profile.yaml") + require.NoError(t, err) + + defaultConf, err := rootConf.ForProfile("") + require.NoError(t, err) + require.False(t, defaultConf.KeyRotationEnabled()) + + on, err := rootConf.ForProfile("on") + require.NoError(t, err) + require.True(t, on.KeyRotationEnabled()) + + bare, err := rootConf.ForProfile("bare") + require.NoError(t, err) + require.False(t, bare.KeyRotationEnabled()) + }) + + t.Run("features from env", func(t *testing.T) { + t.Setenv("FEATURES_KEY_ROTATION", "true") + + rootConf, err := config.InitConfig("./testdata/profile_env_conflict.yaml") + require.NoError(t, err) + + defaultConf, err := rootConf.ForProfile("") + require.NoError(t, err) + require.True(t, defaultConf.KeyRotationEnabled()) + + // stage declares no features, so it inherits the default + stage, err := rootConf.ForProfile("stage") + require.NoError(t, err) + require.True(t, stage.KeyRotationEnabled()) + }) + + t.Run("profile features from env", func(t *testing.T) { + t.Setenv("PROFILES_ON_FEATURES_KEY_ROTATION", "false") + + rootConf, err := config.InitConfig("./testdata/features_profile.yaml") + require.NoError(t, err) + + on, err := rootConf.ForProfile("on") + require.NoError(t, err) + require.False(t, on.KeyRotationEnabled()) + }) +} + +func TestValidateConfiguration(t *testing.T) { + const scopeErr = "features.key_rotation requires the manage_servers scope in client.scopes" + + t.Run("key rotation with the default scopes is rejected", func(t *testing.T) { + rootConf, err := config.InitConfig("./testdata/features.yaml") + require.NoError(t, err) + + conf, err := rootConf.ForProfile("") + require.NoError(t, err) + require.NoError(t, conf.Validate()) + + // the default client scopes are only manage_configuration + conf.Client.Scopes = config.DefaultConfig().Client.Scopes + require.EqualError(t, conf.Validate(), scopeErr) + }) + + t.Run("key rotation without manage_servers in the profile scopes is rejected", func(t *testing.T) { + rootConf, err := config.InitConfig("./testdata/features_profile.yaml") + require.NoError(t, err) + + on, err := rootConf.ForProfile("on") + require.NoError(t, err) + require.NoError(t, on.Validate()) + + on.Client.Scopes = []string{"manage_configuration"} + require.EqualError(t, on.Validate(), scopeErr) + + on.Client = nil + require.EqualError(t, on.Validate(), scopeErr) + }) + + t.Run("a profile inheriting key rotation with its own client without scopes is rejected", func(t *testing.T) { + rootConf, err := config.InitConfig("./testdata/features.yaml") + require.NoError(t, err) + + conf, err := rootConf.ForProfile("own_client") + require.NoError(t, err) + require.True(t, conf.KeyRotationEnabled()) + require.EqualError(t, conf.Validate(), scopeErr) + }) + + t.Run("scopes are not checked when key rotation is off", func(t *testing.T) { + rootConf, err := config.InitConfig("./testdata/features_profile.yaml") + require.NoError(t, err) + + conf, err := rootConf.ForProfile("") + require.NoError(t, err) + require.NotContains(t, conf.Client.Scopes, "manage_servers") + require.NoError(t, conf.Validate()) + }) } diff --git a/internal/cac/config/testdata/features.yaml b/internal/cac/config/testdata/features.yaml new file mode 100644 index 0000000..db7c8aa --- /dev/null +++ b/internal/cac/config/testdata/features.yaml @@ -0,0 +1,18 @@ +client: + scopes: + - manage_configuration + - manage_servers +features: + key_rotation: true + +profiles: + # overrides the default features + off: + features: + key_rotation: false + # declares nothing of its own, so it falls back to (a copy of) the default features + bare: {} + # inherits key rotation but replaces the whole client, dropping the default scopes + own_client: + client: + tenant_id: other diff --git a/internal/cac/config/testdata/features_profile.yaml b/internal/cac/config/testdata/features_profile.yaml new file mode 100644 index 0000000..e7eaa15 --- /dev/null +++ b/internal/cac/config/testdata/features_profile.yaml @@ -0,0 +1,10 @@ +# the default config leaves features out, a profile turns key rotation on +profiles: + on: + client: + scopes: + - manage_configuration + - manage_servers + features: + key_rotation: true + bare: {} diff --git a/internal/cac/keyrotation/diff.go b/internal/cac/keyrotation/diff.go new file mode 100644 index 0000000..b165f66 --- /dev/null +++ b/internal/cac/keyrotation/diff.go @@ -0,0 +1,156 @@ +package keyrotation + +import ( + "context" + "fmt" + "slices" + "strings" + + "github.com/cloudentity/acp-client-go/clients/hub/models" + "github.com/cloudentity/acp-client-go/clients/system/client/keys" + "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/diff" + "github.com/cloudentity/cac/internal/cac/utils" + "github.com/pkg/errors" +) + +// TreeDiff renders the difference between the source and target settings the way the configuration +// diff does, after diffConfigs. It modifies its arguments. +func TreeDiff(source, target *Config, colors bool) (string, error) { + var ( + sourcePatch, targetPatch models.Rfc7396PatchOperation + err error + ) + + source, target = diffConfigs(source, target) + + if sourcePatch, err = utils.FromModelToPatch(source); err != nil { + return "", errors.Wrap(err, "failed to convert source key rotation") + } + + if targetPatch, err = utils.FromModelToPatch(target); err != nil { + return "", errors.Wrap(err, "failed to convert target key rotation") + } + + return diff.Tree(sourcePatch, targetPatch, diff.Colorize(colors)) +} + +// diffConfigs prepares the local and remote configurations for comparison. It modifies its +// arguments and replaces nil with an empty configuration. +func diffConfigs(local, remote *Config) (source, target *Config) { + if local == nil { + local = &Config{} + } + + if remote == nil { + remote = &Config{} + } + + // the server never echoes starting_from back, so it would always show up as a difference + for _, use := range slices.Concat(local.Uses(), remote.Uses()) { + use.Rotation.StartingFrom = nil + } + + // push never removes a use that is absent locally, so diff previews only what push would change + if local.Sig == nil { + remote.Sig = nil + } + + if local.Enc == nil { + remote.Enc = nil + } + + return local, remote +} + +// Side reads key rotation for one side of a plain diff. With Local only it reads the local files, +// with Remote only the server, and with both the local file, falling back to the server when the +// workspace has none. +type Side struct { + Local *DirStore + Remote Remote + // Tenant lists the workspaces of a remote side in tenant mode + Tenant api.Source +} + +func (s *Side) Read(ctx context.Context, wid string) (*Config, error) { + if s.Local != nil { + cfg, err := s.Local.Read(wid) + if err != nil { + return nil, errors.Wrap(err, "failed to read local key rotation") + } + + if cfg != nil || s.Remote == nil { + return cfg, nil + } + } + + return s.Remote.Read(ctx, wid) +} + +// Workspaces lists the workspaces the side has key rotation for, in tenant mode. +func (s *Side) Workspaces(ctx context.Context) ([]string, error) { + var wids []string + + if s.Local != nil { + local, err := s.Local.Workspaces() + if err != nil { + return nil, err + } + + wids = append(wids, local...) + } + + if s.Remote != nil { + remote, err := TenantWorkspaces(ctx, s.Tenant, nil) + if err != nil { + return nil, err + } + + wids = append(wids, remote...) + } + + slices.Sort(wids) + + return slices.Compact(wids), nil +} + +// Diff renders the key rotation diff of each workspace, each non-empty one under a +// "--- key_rotation ()" line. Target uses absent from the source are ignored. +func Diff(ctx context.Context, source, target *Side, wids []string, colors bool) (string, error) { + var out strings.Builder + + for _, wid := range wids { + var ( + sourceCfg, targetCfg *Config + d string + err error + ) + + if sourceCfg, err = source.Read(ctx, wid); err != nil { + return "", err + } + + // a workspace the target lacks has no settings, so it diffs as all-source like the configuration + var notFound *keys.GetAutomaticKeyRotationNotFound + if targetCfg, err = target.Read(ctx, wid); err != nil && !errors.As(err, ¬Found) { + return "", err + } + + if d, err = TreeDiff(sourceCfg, targetCfg, colors); err != nil { + return "", err + } + + if d != "" { + fmt.Fprintf(&out, "--- key_rotation (%s)\n%s", wid, d) + } + } + + return out.String(), nil +} + +// Skipped renders a "--- key_rotation skipped: " line, so that key rotation that could not be +// compared shows up in the diff rather than being silently left out. +func Skipped(reason string) string { + return fmt.Sprintf("--- key_rotation skipped: %s\n", reason) +} diff --git a/internal/cac/keyrotation/diff_internal_test.go b/internal/cac/keyrotation/diff_internal_test.go new file mode 100644 index 0000000..d650c29 --- /dev/null +++ b/internal/cac/keyrotation/diff_internal_test.go @@ -0,0 +1,36 @@ +package keyrotation + +import ( + "testing" + "time" + + "github.com/go-openapi/strfmt" + "github.com/stretchr/testify/require" +) + +func TestDiffConfigs(t *testing.T) { + t.Run("starting_from is cleared on both sides", func(t *testing.T) { + startingFrom := strfmt.DateTime(time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)) + + source, target := diffConfigs( + &Config{Sig: &Rotation{Enabled: true, Cron: "0 0 1 * *", StartingFrom: &startingFrom}}, + &Config{Sig: &Rotation{Enabled: true, Cron: "0 0 1 * *", StartingFrom: &startingFrom}}, + ) + + require.Equal(t, &Config{Sig: &Rotation{Enabled: true, Cron: "0 0 1 * *"}}, source) + require.Equal(t, &Config{Sig: &Rotation{Enabled: true, Cron: "0 0 1 * *"}}, target) + }) + + t.Run("remote use absent locally is dropped", func(t *testing.T) { + source, target := diffConfigs( + &Config{Sig: &Rotation{Enabled: true, Cron: "0 0 1 * *"}}, + &Config{ + Sig: &Rotation{Enabled: false, Cron: "0 0 1 * *"}, + Enc: &Rotation{Enabled: true, Cron: "0 0 1 1 *"}, + }, + ) + + require.Equal(t, &Config{Sig: &Rotation{Enabled: true, Cron: "0 0 1 * *"}}, source) + require.Equal(t, &Config{Sig: &Rotation{Enabled: false, Cron: "0 0 1 * *"}}, target) + }) +} diff --git a/internal/cac/keyrotation/diff_test.go b/internal/cac/keyrotation/diff_test.go new file mode 100644 index 0000000..ac6afd1 --- /dev/null +++ b/internal/cac/keyrotation/diff_test.go @@ -0,0 +1,204 @@ +package keyrotation_test + +import ( + "context" + "testing" + + "github.com/cloudentity/acp-client-go/clients/hub/models" + kclient "github.com/cloudentity/acp-client-go/clients/system/client/keys" + "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func treeDiff(t *testing.T, source, target *keyrotation.Config) string { + result, err := keyrotation.TreeDiff(source, target, false) + require.NoError(t, err) + + return result +} + +func TestTreeDiff(t *testing.T) { + t.Run("nil local and configured remote give an empty diff", func(t *testing.T) { + require.Empty(t, treeDiff(t, nil, &keyrotation.Config{ + Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}, + Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}, + })) + }) + + t.Run("nil configs give an empty diff", func(t *testing.T) { + require.Empty(t, treeDiff(t, nil, nil)) + }) + + t.Run("local use missing remotely shows up", func(t *testing.T) { + require.NotEmpty(t, treeDiff(t, &keyrotation.Config{ + Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}, + }, nil)) + }) +} + +// fakeRemote serves configs per workspace and records writes. errs fail reads and writes, writeErrs +// writes only. +type fakeRemote struct { + configs map[string]*keyrotation.Config + errs map[string]error + writeErrs map[string]error + writes []keyrotation.WorkspaceConfig +} + +func (f *fakeRemote) Read(_ context.Context, wid string) (*keyrotation.Config, error) { + return f.configs[wid], f.errs[wid] +} + +func (f *fakeRemote) Write(_ context.Context, wid string, cfg *keyrotation.Config) error { + if err := f.errs[wid]; err != nil { + return err + } + + if err := f.writeErrs[wid]; err != nil { + return err + } + + f.writes = append(f.writes, keyrotation.WorkspaceConfig{WID: wid, Config: cfg}) + + return nil +} + +func TestSideRead(t *testing.T) { + t.Run("remote errors fail, including a missing workspace", func(t *testing.T) { + side := &keyrotation.Side{Remote: &fakeRemote{errs: map[string]error{ + "missing": kclient.NewGetAutomaticKeyRotationNotFound(), + "demo": kclient.NewGetAutomaticKeyRotationForbidden(), + }}} + + _, err := side.Read(context.Background(), "missing") + require.Error(t, err) + + _, err = side.Read(context.Background(), "demo") + require.Error(t, err) + }) + + t.Run("merged prefers the local file and falls back to the server", func(t *testing.T) { + dir := t.TempDir() + local := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + remote := &keyrotation.Config{Enc: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 1 *"}} + require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("a", local)) + + side := &keyrotation.Side{ + Local: keyrotation.NewDirStore([]string{dir}), + Remote: &fakeRemote{configs: map[string]*keyrotation.Config{"a": remote, "b": remote}}, + } + + cfg, err := side.Read(context.Background(), "a") + require.NoError(t, err) + require.Equal(t, local, cfg) + + cfg, err = side.Read(context.Background(), "b") + require.NoError(t, err) + require.Equal(t, remote, cfg) + }) + + t.Run("local only does not fall back", func(t *testing.T) { + cfg, err := (&keyrotation.Side{Local: keyrotation.NewDirStore([]string{t.TempDir()})}).Read(context.Background(), "a") + require.NoError(t, err) + require.Nil(t, cfg) + }) +} + +func TestSideWorkspaces(t *testing.T) { + dir := t.TempDir() + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("c", cfg)) + require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("a", cfg)) + + side := &keyrotation.Side{ + Local: keyrotation.NewDirStore([]string{dir}), + Remote: &fakeRemote{}, + Tenant: &fakeSource{data: models.Rfc7396PatchOperation{"servers": map[string]any{"b": map[string]any{}, "a": map[string]any{}}}}, + } + + wids, err := side.Workspaces(context.Background()) + require.NoError(t, err) + require.Equal(t, []string{"a", "b", "c"}, wids) +} + +func TestDiff(t *testing.T) { + dir := t.TempDir() + require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("a", &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}})) + require.NoError(t, keyrotation.NewDirStore([]string{dir}).Write("b", &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}})) + + source := &keyrotation.Side{Local: keyrotation.NewDirStore([]string{dir})} + target := &keyrotation.Side{Remote: &fakeRemote{ + configs: map[string]*keyrotation.Config{ + // same settings: no section + "b": {Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, + }, + errs: map[string]error{"a": errors.Wrap(kclient.NewGetAutomaticKeyRotationNotFound(), "failed to read key rotation")}, + }} + + result, err := keyrotation.Diff(context.Background(), source, target, []string{"a", "b"}, false) + require.NoError(t, err) + require.Equal(t, "--- key_rotation (a)\n"+treeDiff(t, &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}}, nil), result) + + _, err = keyrotation.Diff(context.Background(), source, &keyrotation.Side{Remote: &fakeRemote{ + errs: map[string]error{"a": kclient.NewGetAutomaticKeyRotationForbidden()}, + }}, []string{"a"}, false) + require.Error(t, err) + + // only the target may lack the workspace + _, err = keyrotation.Diff(context.Background(), &keyrotation.Side{Remote: &fakeRemote{ + errs: map[string]error{"a": kclient.NewGetAutomaticKeyRotationNotFound()}, + }}, source, []string{"a"}, false) + require.Error(t, err) +} + +func TestSkipped(t *testing.T) { + require.Equal(t, "--- key_rotation skipped: why\n", keyrotation.Skipped("why")) +} + +type fakeSource struct { + data models.Rfc7396PatchOperation + reads int +} + +func (f *fakeSource) Read(context.Context, ...api.SourceOpt) (models.Rfc7396PatchOperation, error) { + f.reads++ + return f.data, nil +} + +func (f *fakeSource) Write(context.Context, models.Rfc7396PatchOperation, ...api.SourceOpt) error { + return nil +} + +func (f *fakeSource) String() string { + return "fake" +} + +func TestTenantWorkspaces(t *testing.T) { + t.Run("from the pulled configuration", func(t *testing.T) { + source := &fakeSource{} + + wids, err := keyrotation.TenantWorkspaces(context.Background(), source, models.Rfc7396PatchOperation{ + "servers": map[string]any{"b": map[string]any{}, "a": map[string]any{}}, + }) + require.NoError(t, err) + require.Equal(t, []string{"a", "b"}, wids) + require.Zero(t, source.reads) + }) + + t.Run("read from the source when servers are missing", func(t *testing.T) { + source := &fakeSource{data: models.Rfc7396PatchOperation{"servers": map[string]any{"a": map[string]any{}}}} + + wids, err := keyrotation.TenantWorkspaces(context.Background(), source, models.Rfc7396PatchOperation{"pools": map[string]any{}}) + require.NoError(t, err) + require.Equal(t, []string{"a"}, wids) + require.Equal(t, 1, source.reads) + }) + + t.Run("null servers", func(t *testing.T) { + wids, err := keyrotation.TenantWorkspaces(context.Background(), &fakeSource{}, models.Rfc7396PatchOperation{"servers": nil}) + require.NoError(t, err) + require.Empty(t, wids) + }) +} diff --git a/internal/cac/keyrotation/dir_store.go b/internal/cac/keyrotation/dir_store.go index 492a17d..f205d0c 100644 --- a/internal/cac/keyrotation/dir_store.go +++ b/internal/cac/keyrotation/dir_store.go @@ -1,8 +1,10 @@ package keyrotation import ( + "maps" "os" "path/filepath" + "slices" "github.com/cloudentity/cac/internal/cac/templates" "github.com/cloudentity/cac/internal/cac/utils" @@ -101,6 +103,34 @@ func (d *DirStore) Write(wid string, cfg *Config) error { return nil } +// Workspaces returns the sorted ids of the workspaces that have a key rotation file in any of the +// dirs. +func (d *DirStore) Workspaces() ([]string, error) { + ids := map[string]struct{}{} + + for _, dir := range d.Dirs { + entries, err := os.ReadDir(filepath.Join(dir, "workspaces")) + if os.IsNotExist(err) { + continue + } else if err != nil { + return nil, err + } + + for _, entry := range entries { + if entry.IsDir() && d.has(dir, entry.Name()) { + ids[entry.Name()] = struct{}{} + } + } + } + + return slices.Sorted(maps.Keys(ids)), nil +} + +func (d *DirStore) has(dir string, wid string) bool { + _, err := os.Stat(filepath.Join(d.dirPath(dir, wid), FileName)) + return err == nil +} + func decodeStrict(raw map[string]any) (*Config, error) { var ( config Config diff --git a/internal/cac/keyrotation/dir_store_test.go b/internal/cac/keyrotation/dir_store_test.go index 74fd633..354ff14 100644 --- a/internal/cac/keyrotation/dir_store_test.go +++ b/internal/cac/keyrotation/dir_store_test.go @@ -201,3 +201,20 @@ enc: cron: '@monthly' `, string(bts)) } + +func TestDirStoreWorkspaces(t *testing.T) { + first, second := t.TempDir(), t.TempDir() + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + + require.NoError(t, keyrotation.NewDirStore([]string{first}).Write("b", cfg)) + require.NoError(t, keyrotation.NewDirStore([]string{second}).Write("a", cfg)) + require.NoError(t, keyrotation.NewDirStore([]string{second}).Write("b", cfg)) + // a workspace dir without the file is not listed + require.NoError(t, os.MkdirAll(filepath.Join(first, "workspaces", "c"), 0755)) + + store := keyrotation.NewDirStore([]string{first, second, filepath.Join(first, "missing")}) + + wids, err := store.Workspaces() + require.NoError(t, err) + require.Equal(t, []string{"a", "b"}, wids) +} diff --git a/internal/cac/keyrotation/dry_run.go b/internal/cac/keyrotation/dry_run.go new file mode 100644 index 0000000..233f65e --- /dev/null +++ b/internal/cac/keyrotation/dry_run.go @@ -0,0 +1,69 @@ +package keyrotation + +import ( + "fmt" + "io" + "os" + + "github.com/cloudentity/cac/internal/cac/utils" + "github.com/pkg/errors" +) + +// WriteDryRun writes the settings of each workspace instead of pushing them: to +// /workspaces//key_rotation.yaml when out is an existing directory, otherwise as YAML +// documents headed by a "# key_rotation ()" comment, to stdout when out is "-" or appended to +// the out file after the regular dry-run output. +func WriteDryRun(out string, stdout io.Writer, items []WorkspaceConfig) error { + if out != "-" { + if info, err := os.Stat(out); err == nil && info.IsDir() { + dir := NewDirStore([]string{out}) + + for _, item := range items { + if err = dir.Write(item.WID, item.Config); err != nil { + return err + } + } + + return nil + } + } + + var stream []byte + + for _, item := range items { + section, err := DryRunSection(item.WID, item.Config) + if err != nil { + return err + } + + stream = append(stream, section...) + } + + if out == "-" { + _, err := stdout.Write(stream) + return errors.Wrap(err, "failed to write key rotation to stdout") + } + + file, err := os.OpenFile(out, os.O_WRONLY|os.O_CREATE|os.O_APPEND, 0644) + if err != nil { + return errors.Wrap(err, "failed to open dry-run output file") + } + + if _, err = file.Write(stream); err != nil { + _ = file.Close() + return errors.Wrap(err, "failed to write key rotation to file") + } + + return errors.Wrap(file.Close(), "failed to write key rotation to file") +} + +// DryRunSection renders the settings as a separate YAML document, so that the combined dry-run +// output stays a valid YAML stream. +func DryRunSection(wid string, cfg *Config) ([]byte, error) { + bts, err := utils.ToYaml(cfg) + if err != nil { + return nil, errors.Wrap(err, "failed to marshal key rotation") + } + + return append(fmt.Appendf(nil, "---\n# key_rotation (%s)\n", wid), bts...), nil +} diff --git a/internal/cac/keyrotation/dry_run_test.go b/internal/cac/keyrotation/dry_run_test.go new file mode 100644 index 0000000..c006182 --- /dev/null +++ b/internal/cac/keyrotation/dry_run_test.go @@ -0,0 +1,53 @@ +package keyrotation_test + +import ( + "bytes" + "os" + "path/filepath" + "testing" + + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/stretchr/testify/require" +) + +func TestDryRun(t *testing.T) { + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + section := "---\n# key_rotation (demo)\nsig:\n enabled: true\n cron: 0 0 1 * *\n" + + t.Run("section", func(t *testing.T) { + bts, err := keyrotation.DryRunSection("demo", cfg) + require.NoError(t, err) + require.Equal(t, section, string(bts)) + }) + + t.Run("stdout", func(t *testing.T) { + var stdout bytes.Buffer + + require.NoError(t, keyrotation.WriteDryRun("-", &stdout, []keyrotation.WorkspaceConfig{{WID: "demo", Config: cfg}})) + require.Equal(t, section, stdout.String()) + }) + + t.Run("directory", func(t *testing.T) { + dir := t.TempDir() + + require.NoError(t, keyrotation.WriteDryRun(dir, nil, []keyrotation.WorkspaceConfig{{WID: "demo", Config: cfg}})) + + got, err := keyrotation.NewDirStore([]string{dir}).Read("demo") + require.NoError(t, err) + require.Equal(t, cfg, got) + }) + + t.Run("file is appended to", func(t *testing.T) { + out := filepath.Join(t.TempDir(), "out.yaml") + require.NoError(t, os.WriteFile(out, []byte("name: demo\n"), 0644)) + + require.NoError(t, keyrotation.WriteDryRun(out, nil, []keyrotation.WorkspaceConfig{{WID: "demo", Config: cfg}, {WID: "other", Config: cfg}})) + + other, err := keyrotation.DryRunSection("other", cfg) + require.NoError(t, err) + + bts, err := os.ReadFile(out) + require.NoError(t, err) + require.Equal(t, "name: demo\n"+section+string(other), string(bts)) + }) +} diff --git a/internal/cac/keyrotation/keyrotation.go b/internal/cac/keyrotation/keyrotation.go index f216a2b..3ae28cc 100644 --- a/internal/cac/keyrotation/keyrotation.go +++ b/internal/cac/keyrotation/keyrotation.go @@ -1,6 +1,7 @@ // Package keyrotation holds the automatic key rotation configuration of a workspace. It is not part -// of models.TreeServer: it lives in its own workspaces//key_rotation.yaml file and is only -// handled by the dedicated pull/push/diff key-rotation subcommands. +// of models.TreeServer: it lives in its own workspaces//key_rotation.yaml file, handled by the +// dedicated pull/push/diff key-rotation subcommands and, with features.key_rotation on, by the plain +// pull, push and diff commands. package keyrotation import ( diff --git a/internal/cac/keyrotation/sync.go b/internal/cac/keyrotation/sync.go new file mode 100644 index 0000000..0bb13de --- /dev/null +++ b/internal/cac/keyrotation/sync.go @@ -0,0 +1,139 @@ +package keyrotation + +import ( + "context" + "maps" + "slices" + + "github.com/cloudentity/acp-client-go/clients/hub/models" + "github.com/cloudentity/cac/internal/cac/api" + "github.com/pkg/errors" + "golang.org/x/exp/slog" +) + +// Remote reads and writes the key rotation settings of a workspace on the server. +type Remote interface { + Read(ctx context.Context, wid string) (*Config, error) + Write(ctx context.Context, wid string, cfg *Config) error +} + +// WorkspaceConfig pairs a workspace id with its key rotation settings. +type WorkspaceConfig struct { + WID string + Config *Config +} + +// Pull writes the remote key rotation settings of each workspace to dir. It writes nothing for a +// workspace that has none configured. +func Pull(ctx context.Context, remote Remote, dir *DirStore, wids []string) error { + for _, wid := range wids { + slog.With("workspace", wid).Info("Pulling key rotation") + + cfg, err := remote.Read(ctx, wid) + if err != nil { + return err + } + + if cfg == nil { + slog.Info("No key rotation configured", "workspace", wid) + continue + } + + if err = dir.Write(wid, cfg); err != nil { + return err + } + + slog.Info("Pulled key rotation", "workspace", wid) + } + + return nil +} + +// ReadAll reads and validates the local key rotation settings of each workspace, so that an invalid +// file fails before anything is pushed. Workspaces without settings are left out. +func ReadAll(dir *DirStore, wids []string) ([]WorkspaceConfig, error) { + var out []WorkspaceConfig + + for _, wid := range wids { + cfg, err := dir.Read(wid) + if err != nil { + return nil, errors.Wrap(err, "failed to read local key rotation") + } + + if cfg == nil { + continue + } + + if err = cfg.Validate(); err != nil { + return nil, errors.Wrapf(err, "failed to validate key rotation of workspace %s", wid) + } + + out = append(out, WorkspaceConfig{WID: wid, Config: cfg}) + } + + return out, nil +} + +// Push writes the key rotation settings of each workspace to the server. A use whose enabled and cron +// already match the server is skipped, unless it sets starting_from: the server never returns it, so +// such a use is always written in case it reschedules the rotation. +func Push(ctx context.Context, remote Remote, items []WorkspaceConfig) error { + for _, item := range items { + current, err := remote.Read(ctx, item.WID) + if err != nil { + return err + } + + if current == nil { + current = &Config{} + } + + changed := &Config{ + Sig: changedRotation(item.WID, UseSig, item.Config.Sig, current.Sig), + Enc: changedRotation(item.WID, UseEnc, item.Config.Enc, current.Enc), + } + + if len(changed.Uses()) == 0 { + continue + } + + if err = remote.Write(ctx, item.WID, changed); err != nil { + return err + } + + slog.Info("Pushed key rotation", "workspace", item.WID, "uses", len(changed.Uses())) + } + + return nil +} + +// changedRotation returns the local rotation of the use unless the server already has it. +func changedRotation(wid, use string, local, remote *Rotation) *Rotation { + if local == nil { + return nil + } + + if local.StartingFrom == nil && remote != nil && local.Enabled == remote.Enabled && local.Cron == remote.Cron { + slog.Info("Key rotation unchanged", "workspace", wid, "use", use) + return nil + } + + return local +} + +// TenantWorkspaces returns the sorted ids of the workspaces in a tenant configuration. When data has +// no servers, because the regular pull was skipped or filtered them out, they are read from source. +func TenantWorkspaces(ctx context.Context, source api.Source, data models.Rfc7396PatchOperation) ([]string, error) { + var err error + + if _, ok := data["servers"]; !ok { + if data, err = source.Read(ctx, api.WithFilters([]string{"servers"})); err != nil { + return nil, errors.Wrap(err, "failed to list tenant workspaces") + } + } + + // the tenant export is decoded into a generic map, so servers is either a map or null + servers, _ := data["servers"].(map[string]any) + + return slices.Sorted(maps.Keys(servers)), nil +} diff --git a/internal/cac/keyrotation/sync_test.go b/internal/cac/keyrotation/sync_test.go new file mode 100644 index 0000000..1222651 --- /dev/null +++ b/internal/cac/keyrotation/sync_test.go @@ -0,0 +1,101 @@ +package keyrotation_test + +import ( + "context" + "testing" + "time" + + "github.com/cloudentity/cac/internal/cac/keyrotation" + "github.com/go-openapi/strfmt" + "github.com/pkg/errors" + "github.com/stretchr/testify/require" +) + +func TestPull(t *testing.T) { + dir := keyrotation.NewDirStore([]string{t.TempDir()}) + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + remote := &fakeRemote{configs: map[string]*keyrotation.Config{"a": cfg}} + + require.NoError(t, keyrotation.Pull(context.Background(), remote, dir, []string{"a", "unconfigured"})) + + wids, err := dir.Workspaces() + require.NoError(t, err) + require.Equal(t, []string{"a"}, wids) + + got, err := dir.Read("a") + require.NoError(t, err) + require.Equal(t, cfg, got) + + remote = &fakeRemote{errs: map[string]error{"a": errors.New("boom")}} + require.EqualError(t, keyrotation.Pull(context.Background(), remote, dir, []string{"a"}), "boom") +} + +func TestReadAll(t *testing.T) { + dir := keyrotation.NewDirStore([]string{t.TempDir()}) + cfg := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *"}} + require.NoError(t, dir.Write("a", cfg)) + + items, err := keyrotation.ReadAll(dir, []string{"a", "missing"}) + require.NoError(t, err) + require.Equal(t, []keyrotation.WorkspaceConfig{{WID: "a", Config: cfg}}, items) + + items, err = keyrotation.ReadAll(dir, []string{"missing"}) + require.NoError(t, err) + require.Empty(t, items) + + require.NoError(t, dir.Write("invalid", &keyrotation.Config{Enc: &keyrotation.Rotation{Enabled: true, Cron: "bad"}})) + + _, err = keyrotation.ReadAll(dir, []string{"a", "invalid"}) + require.ErrorContains(t, err, "failed to validate key rotation of workspace invalid: invalid cron for enc") +} + +func TestPush(t *testing.T) { + monthly := func(enabled bool) *keyrotation.Rotation { + return &keyrotation.Rotation{Enabled: enabled, Cron: "0 0 1 * *"} + } + startingFrom := strfmt.DateTime(time.Date(2030, 1, 1, 0, 0, 0, 0, time.UTC)) + + t.Run("writes only the uses that differ from the server", func(t *testing.T) { + remote := &fakeRemote{configs: map[string]*keyrotation.Config{ + "same": {Sig: monthly(true), Enc: monthly(false)}, + "enabled": {Sig: monthly(false), Enc: monthly(false)}, + "cron": {Sig: &keyrotation.Rotation{Enabled: true, Cron: "@weekly"}}, + }} + + require.NoError(t, keyrotation.Push(context.Background(), remote, []keyrotation.WorkspaceConfig{ + {WID: "same", Config: &keyrotation.Config{Sig: monthly(true), Enc: monthly(false)}}, + {WID: "enabled", Config: &keyrotation.Config{Sig: monthly(true), Enc: monthly(false)}}, + {WID: "cron", Config: &keyrotation.Config{Sig: monthly(true)}}, + {WID: "unconfigured", Config: &keyrotation.Config{Enc: monthly(false)}}, + })) + require.Equal(t, []keyrotation.WorkspaceConfig{ + {WID: "enabled", Config: &keyrotation.Config{Sig: monthly(true)}}, + {WID: "cron", Config: &keyrotation.Config{Sig: monthly(true)}}, + {WID: "unconfigured", Config: &keyrotation.Config{Enc: monthly(false)}}, + }, remote.writes) + }) + + t.Run("always writes a use with starting_from, which the server never returns", func(t *testing.T) { + local := &keyrotation.Config{Sig: &keyrotation.Rotation{Enabled: true, Cron: "0 0 1 * *", StartingFrom: &startingFrom}} + remote := &fakeRemote{configs: map[string]*keyrotation.Config{"a": {Sig: monthly(true)}}} + + require.NoError(t, keyrotation.Push(context.Background(), remote, []keyrotation.WorkspaceConfig{{WID: "a", Config: local}})) + require.Equal(t, []keyrotation.WorkspaceConfig{{WID: "a", Config: local}}, remote.writes) + }) + + t.Run("a read error fails before anything is written", func(t *testing.T) { + remote := &fakeRemote{errs: map[string]error{"a": errors.New("boom")}} + items := []keyrotation.WorkspaceConfig{{WID: "a", Config: &keyrotation.Config{Sig: monthly(true)}}} + + require.EqualError(t, keyrotation.Push(context.Background(), remote, items), "boom") + require.Empty(t, remote.writes) + }) + + t.Run("a write error fails", func(t *testing.T) { + remote := &fakeRemote{writeErrs: map[string]error{"a": errors.New("boom")}} + items := []keyrotation.WorkspaceConfig{{WID: "a", Config: &keyrotation.Config{Sig: monthly(true)}}} + + require.EqualError(t, keyrotation.Push(context.Background(), remote, items), "boom") + require.Empty(t, remote.writes) + }) +}