From f6826d9b7e5704998eae3ce335bc5768da2d7f57 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 23 Jul 2026 12:14:42 +0200 Subject: [PATCH 1/8] feat: add secrets plan model and computation --- internal/cac/secrets/secrets.go | 104 +++++++++++++++++++++++++++ internal/cac/secrets/secrets_test.go | 66 +++++++++++++++++ 2 files changed, 170 insertions(+) create mode 100644 internal/cac/secrets/secrets.go create mode 100644 internal/cac/secrets/secrets_test.go diff --git a/internal/cac/secrets/secrets.go b/internal/cac/secrets/secrets.go new file mode 100644 index 0000000..e0a7135 --- /dev/null +++ b/internal/cac/secrets/secrets.go @@ -0,0 +1,104 @@ +package secrets + +import ( + "fmt" + "regexp" + "slices" + "strings" +) + +// Secret is the local, on-disk representation of a workspace secret. +// Value is only populated by rendered reads (push); it must never be logged. +type Secret struct { + ID string `json:"id" yaml:"id"` + Value string `json:"value" yaml:"value"` +} + +// Plan describes what a push would do. Delete holds remote-only IDs and is +// only populated when pruning. +type Plan struct { + Create []Secret + Update []Secret + Delete []string +} + +func ComputePlan(local []Secret, remoteIDs []string, prune bool) Plan { + var ( + plan Plan + remote = map[string]bool{} + seen = map[string]bool{} + ) + + for _, id := range remoteIDs { + remote[id] = true + } + + sorted := slices.Clone(local) + slices.SortFunc(sorted, func(a, b Secret) int { return strings.Compare(a.ID, b.ID) }) + + for _, s := range sorted { + seen[s.ID] = true + + if remote[s.ID] { + plan.Update = append(plan.Update, s) + } else { + plan.Create = append(plan.Create, s) + } + } + + if prune { + for _, id := range remoteIDs { + if !seen[id] { + plan.Delete = append(plan.Delete, id) + } + } + + slices.Sort(plan.Delete) + } + + return plan +} + +func (p Plan) Empty() bool { + return len(p.Create) == 0 && len(p.Update) == 0 && len(p.Delete) == 0 +} + +// Summary renders the plan with secret IDs only — never values. +func (p Plan) Summary() string { + var b strings.Builder + + writeSection := func(action string, ids []string) { + fmt.Fprintf(&b, "%s (%d):\n", action, len(ids)) + for _, id := range ids { + fmt.Fprintf(&b, " - %s\n", id) + } + } + + writeSection("create", ids(p.Create)) + writeSection("update", ids(p.Update)) + writeSection("delete", p.Delete) + + return b.String() +} + +func ids(ss []Secret) []string { + out := make([]string, 0, len(ss)) + for _, s := range ss { + out = append(out, s.ID) + } + return out +} + +var nonAlnum = regexp.MustCompile(`[^a-zA-Z0-9_-]+`) + +// NormalizeFileName converts a secret ID into a safe file name (without extension). +func NormalizeFileName(id string) string { + return nonAlnum.ReplaceAllString(id, "_") +} + +var nonEnv = regexp.MustCompile(`[^A-Z0-9]+`) + +// EnvVarName derives the conventional environment variable holding a secret's value. +func EnvVarName(id string) string { + return "CAC_SECRET_" + nonEnv.ReplaceAllString(strings.ToUpper(id), "_") +} diff --git a/internal/cac/secrets/secrets_test.go b/internal/cac/secrets/secrets_test.go new file mode 100644 index 0000000..79b2c55 --- /dev/null +++ b/internal/cac/secrets/secrets_test.go @@ -0,0 +1,66 @@ +package secrets_test + +import ( + "testing" + + "github.com/cloudentity/cac/internal/cac/secrets" + "github.com/stretchr/testify/require" +) + +func TestComputePlan(t *testing.T) { + local := []secrets.Secret{ + {ID: "a", Value: "va"}, + {ID: "b", Value: "vb"}, + } + remote := []string{"b", "c"} + + t.Run("without prune", func(t *testing.T) { + plan := secrets.ComputePlan(local, remote, false) + + require.Equal(t, []secrets.Secret{{ID: "a", Value: "va"}}, plan.Create) + require.Equal(t, []secrets.Secret{{ID: "b", Value: "vb"}}, plan.Update) + require.Empty(t, plan.Delete) + require.False(t, plan.Empty()) + }) + + t.Run("with prune", func(t *testing.T) { + plan := secrets.ComputePlan(local, remote, true) + + require.Equal(t, []string{"c"}, plan.Delete) + }) + + t.Run("empty plan", func(t *testing.T) { + plan := secrets.ComputePlan(nil, nil, true) + require.True(t, plan.Empty()) + }) + + t.Run("deterministic order", func(t *testing.T) { + plan := secrets.ComputePlan( + []secrets.Secret{{ID: "z"}, {ID: "a"}}, + nil, false) + require.Equal(t, "a", plan.Create[0].ID) + require.Equal(t, "z", plan.Create[1].ID) + }) +} + +func TestSummaryContainsIDsOnly(t *testing.T) { + plan := secrets.ComputePlan( + []secrets.Secret{{ID: "new_one", Value: "SUPERSECRET"}}, + []string{"gone"}, true) + + s := plan.Summary() + + require.Contains(t, s, "new_one") + require.Contains(t, s, "gone") + require.NotContains(t, s, "SUPERSECRET") +} + +func TestEnvVarName(t *testing.T) { + require.Equal(t, "CAC_SECRET_SMTP_PASSWORD", secrets.EnvVarName("smtp-password")) + require.Equal(t, "CAC_SECRET_MY_SECRET_1", secrets.EnvVarName("my secret.1")) +} + +func TestNormalizeFileName(t *testing.T) { + require.Equal(t, "my_secret_1", secrets.NormalizeFileName("my secret.1")) + require.Equal(t, "smtp-password", secrets.NormalizeFileName("smtp-password")) +} From 9f7a1bfbf6332339343336a5cd5436afc2727957 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 23 Jul 2026 12:16:53 +0200 Subject: [PATCH 2/8] feat: add secrets directory store with env-injected stubs --- internal/cac/secrets/dir_store.go | 170 +++++++++++++++++++++++++ internal/cac/secrets/dir_store_test.go | 118 +++++++++++++++++ 2 files changed, 288 insertions(+) create mode 100644 internal/cac/secrets/dir_store.go create mode 100644 internal/cac/secrets/dir_store_test.go diff --git a/internal/cac/secrets/dir_store.go b/internal/cac/secrets/dir_store.go new file mode 100644 index 0000000..f0db132 --- /dev/null +++ b/internal/cac/secrets/dir_store.go @@ -0,0 +1,170 @@ +package secrets + +import ( + "fmt" + "os" + "path/filepath" + "slices" + "strings" + + "github.com/cloudentity/cac/internal/cac/templates" + ccyaml "github.com/goccy/go-yaml" + "github.com/pkg/errors" +) + +// DirStore reads and writes secret definition files under +// /workspaces//secrets/. Reads consult all dirs; the first dir wins +// on duplicate IDs (matching MultiStorage precedence). Writes go to the first dir. +type DirStore struct { + Dirs []string +} + +func NewDirStore(dirs []string) *DirStore { + return &DirStore{Dirs: dirs} +} + +func (d *DirStore) secretsPath(dir string, wid string) string { + return filepath.Join(dir, "workspaces", wid, "secrets") +} + +// ListIDs returns the IDs of all locally defined secrets without rendering +// templates, so it needs no environment variables set. +func (d *DirStore) ListIDs(wid string) ([]string, error) { + var out []string + + err := d.walk(wid, func(path string) error { + var s Secret + + bts, err := os.ReadFile(path) + if err != nil { + return errors.Wrapf(err, "failed to read secret file %s", path) + } + + if err = ccyaml.Unmarshal(bts, &s); err != nil { + return errors.Wrapf(err, "failed to unmarshal secret file %s", path) + } + + if s.ID == "" { + return errors.Errorf("missing id in secret file %s", path) + } + + if !slices.Contains(out, s.ID) { + out = append(out, s.ID) + } + + return nil + }) + if err != nil { + return nil, err + } + + slices.Sort(out) + + return out, nil +} + +// List reads all local secrets with templates rendered ({{ env }} resolved) +// and requires every secret to have a non-empty value. +func (d *DirStore) List(wid string) ([]Secret, error) { + var out []Secret + + err := d.walk(wid, func(path string) error { + var s Secret + + bts, err := templates.New(path).Render() + if err != nil { + return errors.Wrapf(err, "failed to render secret file %s", path) + } + + if err = ccyaml.Unmarshal(bts, &s); err != nil { + return errors.Wrapf(err, "failed to unmarshal secret file %s", path) + } + + if s.ID == "" { + return errors.Errorf("missing id in secret file %s", path) + } + + if s.Value == "" { + return errors.Errorf("secret %q has an empty value (file %s)", s.ID, path) + } + + if !slices.ContainsFunc(out, func(o Secret) bool { return o.ID == s.ID }) { + out = append(out, s) + } + + return nil + }) + if err != nil { + return nil, err + } + + slices.SortFunc(out, func(a, b Secret) int { return strings.Compare(a.ID, b.ID) }) + + return out, nil +} + +// walk invokes fn for every secret YAML file across all dirs, first dir first. +func (d *DirStore) walk(wid string, fn func(path string) error) error { + for _, dir := range d.Dirs { + path := d.secretsPath(dir, wid) + + files, err := os.ReadDir(path) + if err != nil { + if os.IsNotExist(err) { + continue + } + + return errors.Wrapf(err, "failed to read secrets directory %s", path) + } + + for _, f := range files { + ext := filepath.Ext(f.Name()) + if f.IsDir() || (ext != ".yaml" && ext != ".yml") { + continue + } + + if err := fn(filepath.Join(path, f.Name())); err != nil { + return err + } + } + } + + return nil +} + +// WriteStubs creates a template stub file in the first dir for every ID that +// has no file yet. Existing files are never modified. +func (d *DirStore) WriteStubs(wid string, ids []string) (created []string, skipped []string, err error) { + if len(d.Dirs) == 0 { + return nil, nil, errors.New("no storage directories configured") + } + + path := d.secretsPath(d.Dirs[0], wid) + + if err = os.MkdirAll(path, 0755); err != nil { + return nil, nil, errors.Wrapf(err, "failed to create secrets directory %s", path) + } + + for _, id := range ids { + file := filepath.Join(path, NormalizeFileName(id)+".yaml") + + if _, err = os.Stat(file); err == nil { + skipped = append(skipped, id) + continue + } else if !os.IsNotExist(err) { + return created, skipped, errors.Wrapf(err, "failed to stat %s", file) + } + + // single-quoted YAML so the file parses as raw YAML (ListIDs) while the + // template action keeps the plain double quotes text/template requires + stub := fmt.Sprintf("id: %s\nvalue: '{{ env \"%s\" }}'\n", id, EnvVarName(id)) + + if err = os.WriteFile(file, []byte(stub), 0644); err != nil { + return created, skipped, errors.Wrapf(err, "failed to write secret stub %s", file) + } + + created = append(created, id) + } + + return created, skipped, nil +} diff --git a/internal/cac/secrets/dir_store_test.go b/internal/cac/secrets/dir_store_test.go new file mode 100644 index 0000000..9c173a1 --- /dev/null +++ b/internal/cac/secrets/dir_store_test.go @@ -0,0 +1,118 @@ +package secrets_test + +import ( + "os" + "path/filepath" + "testing" + + "github.com/cloudentity/cac/internal/cac/secrets" + "github.com/stretchr/testify/require" +) + +func TestWriteStubs(t *testing.T) { + dir := t.TempDir() + store := secrets.NewDirStore([]string{dir}) + + created, skipped, err := store.WriteStubs("demo", []string{"smtp password", "api-key"}) + require.NoError(t, err) + require.ElementsMatch(t, []string{"smtp password", "api-key"}, created) + require.Empty(t, skipped) + + bts, err := os.ReadFile(filepath.Join(dir, "workspaces/demo/secrets/smtp_password.yaml")) + require.NoError(t, err) + require.Equal(t, "id: smtp password\nvalue: '{{ env \"CAC_SECRET_SMTP_PASSWORD\" }}'\n", string(bts)) + + t.Run("never overwrites existing files", func(t *testing.T) { + custom := []byte("id: api-key\nvalue: '{{ env \"MY_CUSTOM_VAR\" }}'\n") + require.NoError(t, os.WriteFile(filepath.Join(dir, "workspaces/demo/secrets/api-key.yaml"), custom, 0644)) + + created, skipped, err := store.WriteStubs("demo", []string{"smtp password", "api-key"}) + require.NoError(t, err) + require.Empty(t, created) + require.ElementsMatch(t, []string{"smtp password", "api-key"}, skipped) + + bts, err := os.ReadFile(filepath.Join(dir, "workspaces/demo/secrets/api-key.yaml")) + require.NoError(t, err) + require.Equal(t, custom, bts) + }) +} + +func TestListIDs(t *testing.T) { + dir1, dir2 := t.TempDir(), t.TempDir() + store := secrets.NewDirStore([]string{dir1, dir2}) + + _, _, err := store.WriteStubs("demo", []string{"a"}) + require.NoError(t, err) + + // same id in second dir plus one extra — no env vars set anywhere + require.NoError(t, os.MkdirAll(filepath.Join(dir2, "workspaces/demo/secrets"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir2, "workspaces/demo/secrets/a.yaml"), + []byte("id: a\nvalue: '{{ env \"X\" }}'\n"), 0644)) + require.NoError(t, os.WriteFile(filepath.Join(dir2, "workspaces/demo/secrets/b.yaml"), + []byte("id: b\nvalue: '{{ env \"X\" }}'\n"), 0644)) + + ids, err := store.ListIDs("demo") + require.NoError(t, err) + require.Equal(t, []string{"a", "b"}, ids) +} + +func TestList(t *testing.T) { + dir := t.TempDir() + store := secrets.NewDirStore([]string{dir}) + + _, _, err := store.WriteStubs("demo", []string{"smtp-password"}) + require.NoError(t, err) + + t.Run("renders env values", func(t *testing.T) { + t.Setenv("CAC_SECRET_SMTP_PASSWORD", "s3cret") + + out, err := store.List("demo") + require.NoError(t, err) + require.Equal(t, []secrets.Secret{{ID: "smtp-password", Value: "s3cret"}}, out) + }) + + t.Run("errors when env var missing", func(t *testing.T) { + _, err := store.List("demo") + require.Error(t, err) + require.Contains(t, err.Error(), "smtp-password") + }) + + t.Run("errors on empty value", func(t *testing.T) { + require.NoError(t, os.WriteFile(filepath.Join(dir, "workspaces/demo/secrets/empty.yaml"), + []byte("id: empty\nvalue: \"\"\n"), 0644)) + t.Setenv("CAC_SECRET_SMTP_PASSWORD", "s3cret") + + _, err := store.List("demo") + require.Error(t, err) + require.Contains(t, err.Error(), "empty") + + require.NoError(t, os.Remove(filepath.Join(dir, "workspaces/demo/secrets/empty.yaml"))) + }) + + t.Run("first dir wins on duplicate ids", func(t *testing.T) { + dir2 := t.TempDir() + multi := secrets.NewDirStore([]string{dir, dir2}) + + require.NoError(t, os.MkdirAll(filepath.Join(dir2, "workspaces/demo/secrets"), 0755)) + require.NoError(t, os.WriteFile(filepath.Join(dir2, "workspaces/demo/secrets/smtp-password.yaml"), + []byte("id: smtp-password\nvalue: \"other\"\n"), 0644)) + + t.Setenv("CAC_SECRET_SMTP_PASSWORD", "s3cret") + + out, err := multi.List("demo") + require.NoError(t, err) + require.Equal(t, []secrets.Secret{{ID: "smtp-password", Value: "s3cret"}}, out) + }) +} + +func TestListMissingDirIsEmpty(t *testing.T) { + store := secrets.NewDirStore([]string{t.TempDir()}) + + ids, err := store.ListIDs("demo") + require.NoError(t, err) + require.Empty(t, ids) + + out, err := store.List("demo") + require.NoError(t, err) + require.Empty(t, out) +} From 13a4c6099cec3e2c7c30337faf57354ee761108d Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 23 Jul 2026 12:18:18 +0200 Subject: [PATCH 3/8] feat: add secrets API store over system secrets endpoints --- internal/cac/client/mock_server_test.go | 29 +++++++++++ internal/cac/client/secrets_api.go | 69 +++++++++++++++++++++++++ internal/cac/client/secrets_api_test.go | 57 ++++++++++++++++++++ 3 files changed, 155 insertions(+) create mode 100644 internal/cac/client/secrets_api.go create mode 100644 internal/cac/client/secrets_api_test.go diff --git a/internal/cac/client/mock_server_test.go b/internal/cac/client/mock_server_test.go index 6458057..3921310 100644 --- a/internal/cac/client/mock_server_test.go +++ b/internal/cac/client/mock_server_test.go @@ -80,6 +80,35 @@ func CreateMockServer(t *testing.T) *httptest.Server { return } + if req.URL.Path == "/api/system/postmance/servers/demo/secrets" && req.Method == http.MethodGet { + res.Header().Set("Content-Type", "application/json") + res.WriteHeader(http.StatusOK) + _, err := res.Write([]byte(`{"secrets":[{"id":"existing","secret":"","server_id":"demo","tenant_id":"postmance"},{"id":"gone","secret":"","server_id":"demo","tenant_id":"postmance"}]}`)) + require.NoError(t, err) + return + } + + if req.URL.Path == "/api/system/postmance/servers/demo/secrets" && req.Method == http.MethodPost { + res.Header().Set("Content-Type", "application/json") + res.WriteHeader(http.StatusCreated) + _, err := res.Write([]byte(`{"id":"new","secret":"","server_id":"demo","tenant_id":"postmance"}`)) + require.NoError(t, err) + return + } + + if req.URL.Path == "/api/system/postmance/servers/demo/secrets/existing" && req.Method == http.MethodPut { + res.Header().Set("Content-Type", "application/json") + res.WriteHeader(http.StatusCreated) + _, err := res.Write([]byte(`{"id":"existing","secret":"","server_id":"demo","tenant_id":"postmance"}`)) + require.NoError(t, err) + return + } + + if req.URL.Path == "/api/system/postmance/servers/demo/secrets/gone" && req.Method == http.MethodDelete { + res.WriteHeader(http.StatusNoContent) + return + } + res.Header().Set("Content-Type", "application/json") res.WriteHeader(http.StatusOK) js, err := json.Marshal(models.TreeServer{ diff --git a/internal/cac/client/secrets_api.go b/internal/cac/client/secrets_api.go new file mode 100644 index 0000000..1d2efeb --- /dev/null +++ b/internal/cac/client/secrets_api.go @@ -0,0 +1,69 @@ +package client + +import ( + "context" + + acpclient "github.com/cloudentity/acp-client-go" + sclient "github.com/cloudentity/acp-client-go/clients/system/client/secrets" + smodels "github.com/cloudentity/acp-client-go/clients/system/models" + "github.com/cloudentity/cac/internal/cac/secrets" + "github.com/pkg/errors" +) + +// SecretsAPIStore talks to the system Secrets API for a single workspace. +type SecretsAPIStore struct { + acp *acpclient.Client +} + +func (c *Client) SecretsStore() *SecretsAPIStore { + return &SecretsAPIStore{acp: c.acp} +} + +func (s *SecretsAPIStore) ListIDs(ctx context.Context, wid string) ([]string, error) { + ok, err := s.acp.System.Secrets.ListSecrets( + sclient.NewListSecretsParamsWithContext(ctx).WithWid(wid), nil) + if err != nil { + return nil, errors.Wrapf(err, "failed to list secrets for workspace %s", wid) + } + + ids := make([]string, 0, len(ok.Payload.Secrets)) + for _, sec := range ok.Payload.Secrets { + ids = append(ids, sec.ID) + } + + return ids, nil +} + +// Apply executes a plan: creates, updates, then deletes. It stops at the first +// API error, reporting the failing secret by ID only. +func (s *SecretsAPIStore) Apply(ctx context.Context, wid string, plan secrets.Plan) error { + for _, sec := range plan.Create { + if _, err := s.acp.System.Secrets.CreateSecret( + sclient.NewCreateSecretParamsWithContext(ctx). + WithWid(wid). + WithSecret(&smodels.Secret{ID: sec.ID, Secret: sec.Value, ServerID: wid}), nil); err != nil { + return errors.Wrapf(err, "failed to create secret %s", sec.ID) + } + } + + for _, sec := range plan.Update { + if _, err := s.acp.System.Secrets.UpdateSecret( + sclient.NewUpdateSecretParamsWithContext(ctx). + WithWid(wid). + WithSid(sec.ID). + WithSecret(&smodels.Secret{ID: sec.ID, Secret: sec.Value, ServerID: wid}), nil); err != nil { + return errors.Wrapf(err, "failed to update secret %s", sec.ID) + } + } + + for _, id := range plan.Delete { + if _, err := s.acp.System.Secrets.DeleteSecret( + sclient.NewDeleteSecretParamsWithContext(ctx). + WithWid(wid). + WithSid(id), nil); err != nil { + return errors.Wrapf(err, "failed to delete secret %s", id) + } + } + + return nil +} diff --git a/internal/cac/client/secrets_api_test.go b/internal/cac/client/secrets_api_test.go new file mode 100644 index 0000000..624befc --- /dev/null +++ b/internal/cac/client/secrets_api_test.go @@ -0,0 +1,57 @@ +package client_test + +import ( + "context" + "fmt" + "net/url" + "testing" + + acpclient "github.com/cloudentity/acp-client-go" + "github.com/cloudentity/cac/internal/cac/client" + "github.com/cloudentity/cac/internal/cac/secrets" + "github.com/stretchr/testify/require" +) + +func initSecretsStore(t *testing.T) *client.SecretsAPIStore { + testServer := CreateMockServer(t) + t.Cleanup(testServer.Close) + + issuer, err := url.Parse(fmt.Sprintf("%s/postmance/system", testServer.URL)) + require.NoError(t, err) + + c, err := client.InitClient(&client.Configuration{ + Insecure: true, + Config: acpclient.Config{ + IssuerURL: issuer, + TenantID: "postmance", + ClientID: "fb346c287c4d4e378cbae39aa0c3fe52", + ClientSecret: "valid_secret", + }, + }) + require.NoError(t, err) + + return c.SecretsStore() +} + +func TestSecretsListIDs(t *testing.T) { + store := initSecretsStore(t) + + ids, err := store.ListIDs(context.Background(), "demo") + require.NoError(t, err) + require.Equal(t, []string{"existing", "gone"}, ids) +} + +func TestSecretsApply(t *testing.T) { + store := initSecretsStore(t) + + plan := secrets.ComputePlan( + []secrets.Secret{ + {ID: "new", Value: "v1"}, + {ID: "existing", Value: "v2"}, + }, + []string{"existing", "gone"}, + true, + ) + + require.NoError(t, store.Apply(context.Background(), "demo", plan)) +} From 45d102b2b28165a3d21af6b68e0d6ed3aa401826 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 23 Jul 2026 12:19:36 +0200 Subject: [PATCH 4/8] feat: add pull --workspace-secrets writing env-injected stubs --- cmd/pull.go | 52 +++++++++++++++++++++++++++++++++++++++++++++ cmd/root.go | 19 +++++++++++------ internal/cac/app.go | 2 ++ 3 files changed, 67 insertions(+), 6 deletions(-) diff --git a/cmd/pull.go b/cmd/pull.go index 6bde4f7..048df67 100644 --- a/cmd/pull.go +++ b/cmd/pull.go @@ -4,6 +4,8 @@ import ( "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/secrets" + "github.com/pkg/errors" "github.com/spf13/cobra" "golang.org/x/exp/slog" ) @@ -34,6 +36,10 @@ Examples: err error ) + if rootConfig.WorkspaceSecrets != "" { + return pullSecrets(cmd) + } + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } @@ -67,6 +73,52 @@ Examples: } ) +func pullSecrets(cmd *cobra.Command) error { + var ( + app *cac.Application + err error + ) + + if len(pullConfig.Filters) > 0 { + return errors.New("--filter cannot be combined with --workspace-secrets") + } + + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { + return err + } + + dirStore, err := secretsDirStore(app) + if err != nil { + return err + } + + wid := rootConfig.WorkspaceSecrets + + slog.With("workspace", wid).Info("Pulling secrets") + + ids, err := app.Secrets.ListIDs(cmd.Context(), wid) + if err != nil { + return err + } + + created, skipped, err := dirStore.WriteStubs(wid, ids) + if err != nil { + return err + } + + slog.Info("Pulled secrets", "workspace", wid, "created", len(created), "skipped_existing", len(skipped)) + + return nil +} + +func secretsDirStore(app *cac.Application) (*secrets.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 secrets.NewDirStore(app.Config.Storage.DirPath), nil +} + func init() { pullCmd.PersistentFlags().BoolVar(&pullConfig.WithSecrets, "with-secrets", false, `Include secret fields (client secrets, signing keys, etc.) in the pulled configuration. Example: --with-secrets`) diff --git a/cmd/root.go b/cmd/root.go index 2652205..c9d25ba 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -28,10 +28,11 @@ Examples: ) type RootConfig struct { - ConfigPath string - Profile string - Workspace string - Tenant bool + ConfigPath string + Profile string + Workspace string + Tenant bool + WorkspaceSecrets string } func init() { @@ -45,13 +46,19 @@ Example: --tenant`) rootCmd.PersistentFlags().StringVar(&rootConfig.Workspace, "workspace", "", `Workspace identifier to operate on. Mutually exclusive with --tenant. Example: --workspace demo`) + rootCmd.PersistentFlags().StringVar(&rootConfig.WorkspaceSecrets, "workspace-secrets", "", `Operate exclusively on the secrets of the given workspace (system Secrets API). +Mutually exclusive with --workspace, --tenant, and --filter. +Examples: + cac --config ./cac.yaml --profile dev pull --workspace-secrets demo + cac --config ./cac.yaml --profile dev push --workspace-secrets demo --prune + cac --config ./cac.yaml --profile dev diff --workspace-secrets demo`) rootCmd.AddCommand(pullCmd) rootCmd.AddCommand(pushCmd) rootCmd.AddCommand(diffCmd) - rootCmd.MarkFlagsMutuallyExclusive("workspace", "tenant") - rootCmd.MarkFlagsOneRequired("workspace", "tenant") + rootCmd.MarkFlagsMutuallyExclusive("workspace", "tenant", "workspace-secrets") + rootCmd.MarkFlagsOneRequired("workspace", "tenant", "workspace-secrets") } func Execute() error { diff --git a/internal/cac/app.go b/internal/cac/app.go index f6b9f02..b16fef7 100644 --- a/internal/cac/app.go +++ b/internal/cac/app.go @@ -17,6 +17,7 @@ type Application struct { Client api.Source Storage storage.Storage Validator data.ValidatorApi + Secrets *client.SecretsAPIStore } func InitApp(configPath string, profile string, tenant bool) (app *Application, err error) { @@ -43,6 +44,7 @@ func InitApp(configPath string, profile string, tenant bool) (app *Application, } app.Client = c + app.Secrets = c.SecretsStore() if tenant { app.Client = c.Tenant() From 025947bfcee4e30679b88b06afed3f54f4518544 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 23 Jul 2026 12:19:47 +0200 Subject: [PATCH 5/8] chore: gofmt app.go --- internal/cac/app.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/internal/cac/app.go b/internal/cac/app.go index b16fef7..a78e07a 100644 --- a/internal/cac/app.go +++ b/internal/cac/app.go @@ -124,7 +124,7 @@ func (a *Application) PickSource(source string, tenant bool) (api.Source, error) c *client.Client err error ) - + ms, err := storage.InitMultiStorage(conf.Storage, constructor) if err != nil { From 432d0d12c67bb0c4a265495c31ef31691c719e5b Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 23 Jul 2026 12:20:48 +0200 Subject: [PATCH 6/8] feat: add push --workspace-secrets with prune and dry-run plan --- cmd/push.go | 86 ++++++++++++++++++++++++++++++++++++++++++++++++----- 1 file changed, 79 insertions(+), 7 deletions(-) diff --git a/cmd/push.go b/cmd/push.go index 32e4e36..c792528 100644 --- a/cmd/push.go +++ b/cmd/push.go @@ -1,9 +1,12 @@ package cmd import ( + "os" + "github.com/cloudentity/acp-client-go/clients/hub/models" "github.com/cloudentity/cac/internal/cac" "github.com/cloudentity/cac/internal/cac/api" + "github.com/cloudentity/cac/internal/cac/secrets" "github.com/cloudentity/cac/internal/cac/storage" "github.com/pkg/errors" "github.com/spf13/cobra" @@ -42,6 +45,14 @@ Examples: err error ) + if rootConfig.WorkspaceSecrets != "" { + return pushSecrets(cmd) + } + + if pushConfig.Method == "" { + return errors.New(`required flag(s) "method" not set`) + } + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, rootConfig.Tenant); err != nil { return err } @@ -99,15 +110,76 @@ Examples: }, } pushConfig struct { - DryRun bool - Out string - Mode string - Method string - Filters []string + DryRun bool + Out string + Mode string + Method string + Filters []string NoLocalValidate bool + Prune bool } ) +func pushSecrets(cmd *cobra.Command) error { + var ( + app *cac.Application + err error + ) + + if len(pushConfig.Filters) > 0 { + return errors.New("--filter cannot be combined with --workspace-secrets") + } + + if pushConfig.Method != "" { + return errors.New("--method does not apply to --workspace-secrets; secrets are always reconciled (create/update, delete with --prune)") + } + + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { + return err + } + + dirStore, err := secretsDirStore(app) + if err != nil { + return err + } + + wid := rootConfig.WorkspaceSecrets + + local, err := dirStore.List(wid) + if err != nil { + return errors.Wrap(err, "failed to read local secrets") + } + + remoteIDs, err := app.Secrets.ListIDs(cmd.Context(), wid) + if err != nil { + return err + } + + plan := secrets.ComputePlan(local, remoteIDs, pushConfig.Prune) + + if plan.Empty() { + slog.Info("No secret changes to push", "workspace", wid) + return nil + } + + if pushConfig.DryRun { + if _, err = os.Stdout.WriteString(plan.Summary()); err != nil { + return errors.Wrap(err, "failed to write plan to stdout") + } + + return nil + } + + if err = app.Secrets.Apply(cmd.Context(), wid, plan); err != nil { + return err + } + + slog.Info("Pushed secrets", "workspace", wid, + "created", len(plan.Create), "updated", len(plan.Update), "deleted", len(plan.Delete)) + + 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. @@ -132,6 +204,8 @@ Example: --method patch`) pushCmd.PersistentFlags().BoolVar(&pushConfig.NoLocalValidate, "no-validate", false, `Skip client-side validation before pushing. Workaround for cases where local validation rejects a configuration the server accepts. Example: --no-validate`) + pushCmd.PersistentFlags().BoolVar(&pushConfig.Prune, "prune", false, `Secrets mode only: delete remote secrets that have no local definition. +Example: cac push --workspace-secrets demo --prune`) pushCmd.PersistentFlags().StringSliceVar(&pushConfig.Filters, "filter", []string{}, `Restrict the push to selected top-level resources (comma-separated or repeated). Workspace resources: clients, idps, claims, custom_apps, gateways, policies, policy_execution_points, pools, scopes (alias of scopes_without_service), scripts, script_execution_points, @@ -145,6 +219,4 @@ Examples: --filter scopes --filter pools --filter root --filter root,clients`) - - mustMarkRequired(pushCmd, "method") } From 6aac4bdaf0ceae79696ea6d4f3fcf22eed150d7f Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 23 Jul 2026 12:21:59 +0200 Subject: [PATCH 7/8] feat: add diff --workspace-secrets comparing local ids against remote --- cmd/diff.go | 104 +++++++++++++++++++++++++++++++++++++-- cmd/diff_secrets_test.go | 20 ++++++++ cmd/flags.go | 13 ----- 3 files changed, 121 insertions(+), 16 deletions(-) create mode 100644 cmd/diff_secrets_test.go delete mode 100644 cmd/flags.go diff --git a/cmd/diff.go b/cmd/diff.go index a0ad937..a76aa0d 100644 --- a/cmd/diff.go +++ b/cmd/diff.go @@ -1,13 +1,16 @@ package cmd import ( + "fmt" + "os" + "strings" + "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 ( @@ -47,6 +50,14 @@ Examples: err error ) + if rootConfig.WorkspaceSecrets != "" { + return diffSecrets(cmd) + } + + if diffConfig.Source == "" || diffConfig.Target == "" { + return errors.New(`required flag(s) "source", "target" not set`) + } + slog. With("workspace", rootConfig.Workspace). With("config", rootConfig.ConfigPath). @@ -110,6 +121,95 @@ Examples: } ) +func diffSecrets(cmd *cobra.Command) error { + var ( + app *cac.Application + err error + ) + + if len(diffConfig.Filters) > 0 { + return errors.New("--filter cannot be combined with --workspace-secrets") + } + + if diffConfig.Source != "" || diffConfig.Target != "" { + return errors.New("--source/--target do not apply to --workspace-secrets; local files are always compared against the remote workspace") + } + + if app, err = cac.InitApp(rootConfig.ConfigPath, rootConfig.Profile, false); err != nil { + return err + } + + dirStore, err := secretsDirStore(app) + if err != nil { + return err + } + + wid := rootConfig.WorkspaceSecrets + + localIDs, err := dirStore.ListIDs(wid) + if err != nil { + return errors.Wrap(err, "failed to read local secrets") + } + + remoteIDs, err := app.Secrets.ListIDs(cmd.Context(), wid) + if err != nil { + return err + } + + result := secretsDiffReport(wid, localIDs, remoteIDs) + + if diffConfig.Out != "-" { + return os.WriteFile(diffConfig.Out, []byte(result), 0644) + } + + _, err = os.Stdout.WriteString(result) + + return err +} + +func secretsDiffReport(wid string, localIDs []string, remoteIDs []string) string { + var ( + b strings.Builder + remote = map[string]bool{} + local = map[string]bool{} + + onlyLocal, onlyRemote, both []string + ) + + for _, id := range remoteIDs { + remote[id] = true + } + for _, id := range localIDs { + local[id] = true + + if remote[id] { + both = append(both, id) + } else { + onlyLocal = append(onlyLocal, id) + } + } + for _, id := range remoteIDs { + if !local[id] { + onlyRemote = append(onlyRemote, id) + } + } + + fmt.Fprintf(&b, "secrets diff for workspace %s\n", wid) + + section := func(title string, ids []string) { + fmt.Fprintf(&b, "%s:\n", title) + for _, id := range ids { + fmt.Fprintf(&b, " - %s\n", id) + } + } + + section("only local (would create on push)", onlyLocal) + section("only remote (deleted on push --prune)", onlyRemote) + section("in both (values not comparable)", both) + + return b.String() +} + func init() { diffCmd.PersistentFlags().StringVar(&diffConfig.Source, "source", "", `Source of the comparison (required). Format: [profile@]source-type Source types: local, remote, merged @@ -148,6 +248,4 @@ Examples: Example: --with-secrets`) diffCmd.PersistentFlags().BoolVar(&diffConfig.FilterVolatile, "no-volatile", false, `Ignore volatile fields (e.g. timestamps, generated IDs) when comparing. Example: --no-volatile`) - - mustMarkRequired(diffCmd, "source", "target") } diff --git a/cmd/diff_secrets_test.go b/cmd/diff_secrets_test.go new file mode 100644 index 0000000..bdbd453 --- /dev/null +++ b/cmd/diff_secrets_test.go @@ -0,0 +1,20 @@ +package cmd + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSecretsDiffReport(t *testing.T) { + out := secretsDiffReport("demo", []string{"a", "c"}, []string{"b", "c"}) + + require.Equal(t, `secrets diff for workspace demo +only local (would create on push): + - a +only remote (deleted on push --prune): + - b +in both (values not comparable): + - c +`, out) +} diff --git a/cmd/flags.go b/cmd/flags.go deleted file mode 100644 index 40b67bb..0000000 --- a/cmd/flags.go +++ /dev/null @@ -1,13 +0,0 @@ -package cmd - -import "github.com/spf13/cobra" - -func mustMarkRequired(cmd *cobra.Command, flags ...string) { - for _, flag := range flags { - err := cmd.MarkPersistentFlagRequired(flag) - - if err != nil { - panic(err) - } - } -} From 9f14bc888a0b4fa166bbac155100510a01203243 Mon Sep 17 00:00:00 2001 From: Piotr Janus Date: Thu, 23 Jul 2026 12:23:12 +0200 Subject: [PATCH 8/8] docs: document workspace secrets management --- README.md | 35 +++++++++++++++++++++++++++++++++++ 1 file changed, 35 insertions(+) diff --git a/README.md b/README.md index 10998be..52f2fd0 100644 --- a/README.md +++ b/README.md @@ -239,6 +239,41 @@ map[string]any{ - "ciba_authentication_service": map[string]any{"type": string("mock")}, ``` +### Managing secrets + +Workspace secrets live behind a dedicated system API and are managed with the +secrets-exclusive `--workspace-secrets ` flag (mutually exclusive with +`--workspace`, `--tenant`, and `--filter`). Secret *values* are never stored in +the repository and never returned by the server — each secret file references an +environment variable that is resolved at push time: + +```yaml +# workspaces/demo/secrets/smtp_password.yaml +id: smtp_password +value: '{{ env "CAC_SECRET_SMTP_PASSWORD" }}' +``` + +```bash +# create stub files for remote secrets that have no local definition +cac --config ./cac.yaml --profile dev pull --workspace-secrets demo + +# preview what a push would change (secret ids only, never values) +cac --config ./cac.yaml --profile dev push --workspace-secrets demo --dry-run + +# create + update remote secrets from local definitions +CAC_SECRET_SMTP_PASSWORD=... cac --config ./cac.yaml --profile dev push --workspace-secrets demo + +# also delete remote secrets that have no local definition +cac --config ./cac.yaml --profile dev push --workspace-secrets demo --prune + +# compare local definitions against the remote workspace +cac --config ./cac.yaml --profile dev diff --workspace-secrets demo +``` + +> **Note:** `pull --workspace-secrets` never overwrites existing secret files, and +> a push fails before any API call if a referenced environment variable is unset +> or a secret resolves to an empty value. + ## Templates Templates are used to generate configuration files. They are using [Go template language](https://golang.org/pkg/text/template/).