Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <workspace>` flag (mutually
exclusive with `--workspace`, `--tenant`, and `--filter`). Plain `pull`, `push`, and
`diff` never read or write these settings. They are stored in
`workspaces/<workspace-id>/key_rotation.yaml`. With several storage directories, the
file is taken whole from the first directory that has it; files are not merged across
directories:

```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/).
Expand Down
138 changes: 122 additions & 16 deletions cmd/diff.go
Original file line number Diff line number Diff line change
@@ -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 (
Expand Down Expand Up @@ -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).
Expand Down Expand Up @@ -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 {
Expand All @@ -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
Expand Down Expand Up @@ -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")
}
91 changes: 91 additions & 0 deletions cmd/diff_key_rotation_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
13 changes: 0 additions & 13 deletions cmd/flags.go

This file was deleted.

Loading
Loading