Compare commits

...

3 commits

13 changed files with 640 additions and 79 deletions

216
cmd/app_secret.go Normal file
View file

@ -0,0 +1,216 @@
package cmd
import (
"errors"
"fmt"
"io"
"os"
"path/filepath"
"reflect"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/openbao"
"github.com/spf13/cobra"
)
var appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretGrantEnvironment string
var appSecretShared, appSecretDeleteYes bool
var appSecretGrantShared, appSecretGrantSecrets []string
var loadAppSecretConfig = config.Load
var saveAppSecretConfig = config.Save
var storeAppSecret = openbao.StoreManagedSecret
var listAppSecrets = openbao.ListManagedSecrets
var appSecretStatus = openbao.ManagedSecretStatus
var deleteAppSecret = openbao.DeleteManagedSecret
var appSecretCmd = &cobra.Command{
Use: "secret",
Short: "Manage application and shared OpenBao secret values.",
}
var appSecretSetCmd = &cobra.Command{
Use: "set <app-or-group> <secret>",
Short: "Store a value from stdin or --file.",
Args: cobra.ExactArgs(2),
RunE: runAppSecretSet,
}
var appSecretListCmd = &cobra.Command{
Use: "list <app-or-group>",
Short: "List secret names without values.",
Args: cobra.ExactArgs(1),
RunE: runAppSecretList,
}
var appSecretDeleteCmd = &cobra.Command{
Use: "delete <app-or-group> <secret>",
Short: "Permanently delete a secret after explicit confirmation.",
Args: cobra.ExactArgs(2),
RunE: runAppSecretDelete,
}
var appSecretGrantCmd = &cobra.Command{
Use: "grant <app> <build|publish|runtime>",
Short: "Add a declarative SecretGrant to the private bootstrap config.",
Args: cobra.ExactArgs(2),
RunE: runAppSecretGrant,
}
var appSecretStatusCmd = &cobra.Command{
Use: "status <app-or-group> <secret>",
Short: "Report whether a secret exists without reading its value.",
Args: cobra.ExactArgs(2),
RunE: runAppSecretStatus,
}
func init() {
appCmd.AddCommand(appSecretCmd)
appSecretCmd.AddCommand(appSecretSetCmd, appSecretListCmd, appSecretDeleteCmd, appSecretGrantCmd, appSecretStatusCmd)
appSecretCmd.PersistentFlags().StringVar(&appSecretConfigPath, "config", "", "Path to private bootstrap config YAML")
_ = appSecretCmd.MarkPersistentFlagRequired("config")
appSecretCmd.PersistentFlags().StringVar(&appSecretTokenFile, "token-file", "", "Path to restricted OpenBao token file for secret CRUD")
appSecretSetCmd.Flags().StringVar(&appSecretFile, "file", "", "Read the secret value from this file instead of stdin")
for _, command := range []*cobra.Command{appSecretSetCmd, appSecretListCmd, appSecretDeleteCmd, appSecretStatusCmd} {
command.Flags().BoolVar(&appSecretShared, "shared", false, "Use shared/<group>/<secret> instead of apps/<app>/<secret>")
}
appSecretDeleteCmd.Flags().BoolVar(&appSecretDeleteYes, "yes", false, "Confirm permanent deletion")
appSecretGrantCmd.Flags().StringVar(&appSecretGrantEnvironment, "environment", "", "Runtime environment: staging or production")
appSecretGrantCmd.Flags().StringSliceVar(&appSecretGrantSecrets, "secret", nil, "Application secret name granted to this consumer (repeat for each)")
appSecretGrantCmd.Flags().StringSliceVar(&appSecretGrantShared, "shared", nil, "Shared secret group allowed by this grant")
}
func runAppSecretSet(cmd *cobra.Command, args []string) error {
path, kubeconfig, err := appSecretTarget(args)
if err != nil {
return err
}
value, err := readAppSecretValue(cmd)
if err != nil {
return err
}
if err := storeAppSecret(kubeconfig, appSecretTokenFile, path, value); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "stored %s\n", path)
return nil
}
func runAppSecretList(cmd *cobra.Command, args []string) error {
kubeconfig, err := appSecretKubeconfigFromConfig()
if err != nil {
return err
}
values, err := listAppSecrets(kubeconfig, appSecretTokenFile, appSecretShared, args[0])
if err != nil {
return err
}
for _, value := range values {
fmt.Fprintln(cmd.OutOrStdout(), value)
}
return nil
}
func runAppSecretDelete(cmd *cobra.Command, args []string) error {
if !appSecretDeleteYes {
return errors.New("delete requires --yes")
}
path, kubeconfig, err := appSecretTarget(args)
if err != nil {
return err
}
if err := deleteAppSecret(kubeconfig, appSecretTokenFile, path); err != nil {
return err
}
fmt.Fprintf(cmd.OutOrStdout(), "deleted %s\n", path)
return nil
}
func runAppSecretGrant(cmd *cobra.Command, args []string) error {
cfg, err := loadRequiredAppSecretConfig()
if err != nil {
return err
}
grant := config.SecretGrant{Application: args[0], Consumer: args[1], Environment: appSecretGrantEnvironment, Secrets: appSecretGrantSecrets, Shared: appSecretGrantShared}
if err := config.ValidateSecretGrants([]config.SecretGrant{grant}); err != nil {
return err
}
for _, existing := range cfg.SecretGrants {
if existing.Application == grant.Application && existing.Consumer == grant.Consumer && existing.Environment == grant.Environment {
if reflect.DeepEqual(existing, grant) {
fmt.Fprintln(cmd.OutOrStdout(), "grant already declared")
return nil
}
return errors.New("secret grant already exists with a different definition")
}
}
cfg.SecretGrants = append(cfg.SecretGrants, grant)
if err := config.ValidateSecretGrants(cfg.SecretGrants); err != nil {
return err
}
if err := saveAppSecretConfig(appSecretConfigPath, cfg); err != nil {
return fmt.Errorf("save declarative secret grant: %w", err)
}
fmt.Fprintln(cmd.OutOrStdout(), "grant declared; reconcile OpenBao through the reviewed bootstrap workflow")
return nil
}
func runAppSecretStatus(cmd *cobra.Command, args []string) error {
path, kubeconfig, err := appSecretTarget(args)
if err != nil {
return err
}
present, err := appSecretStatus(kubeconfig, appSecretTokenFile, path)
if err != nil {
return err
}
if present {
fmt.Fprintf(cmd.OutOrStdout(), "%s: present\n", path)
} else {
fmt.Fprintf(cmd.OutOrStdout(), "%s: absent\n", path)
}
return nil
}
func appSecretTarget(args []string) (string, string, error) {
if len(args) != 2 {
return "", "", errors.New("secret target requires an application or shared group and a secret name")
}
path, err := openbao.ManagedSecretPath(appSecretShared, args[0], args[1])
if err != nil {
return "", "", err
}
kubeconfig, err := appSecretKubeconfigFromConfig()
return path, kubeconfig, err
}
func loadRequiredAppSecretConfig() (config.Config, error) {
if appSecretConfigPath == "" {
return config.Config{}, errors.New("--config is required")
}
return loadAppSecretConfig(appSecretConfigPath)
}
func appSecretKubeconfigFromConfig() (string, error) {
cfg, err := loadRequiredAppSecretConfig()
if err != nil {
return "", err
}
return filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir, "kubeconfig"), nil
}
func readAppSecretValue(cmd *cobra.Command) ([]byte, error) {
if appSecretFile != "" {
value, err := os.ReadFile(appSecretFile)
if err != nil {
return nil, fmt.Errorf("read secret file: %w", err)
}
return value, nil
}
value, err := io.ReadAll(cmd.InOrStdin())
if err != nil {
return nil, fmt.Errorf("read secret stdin: %w", err)
}
return value, nil
}

77
cmd/app_secret_test.go Normal file
View file

@ -0,0 +1,77 @@
package cmd
import (
"bytes"
"errors"
"path/filepath"
"strings"
"testing"
"github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/spf13/cobra"
)
func TestAppSecretSetReadsValueFromStdinWithoutOutput(t *testing.T) {
originalLoad, originalStore := loadAppSecretConfig, storeAppSecret
originalConfig, originalFile, originalToken, originalShared := appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretShared
t.Cleanup(func() {
loadAppSecretConfig, storeAppSecret = originalLoad, originalStore
appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretShared = originalConfig, originalFile, originalToken, originalShared
})
appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretShared = "private.yaml", "", "restricted-token", false
loadAppSecretConfig = func(string) (config.Config, error) {
return config.Config{Git: config.GitConfig{CloneParent: "checkouts"}, Talos: config.TalosConfig{RepoDirName: "talos", GeneratedDir: "generated"}, SOPS: config.SOPSConfig{RecoveryIdentityPath: "must-not-pass", RecoveryBundlePath: "must-not-pass"}}, nil
}
const value = "do-not-print"
storeAppSecret = func(kubeconfig, tokenPath, path string, got []byte) error {
if kubeconfig != filepath.Join("checkouts", "talos", "generated", "kubeconfig") || tokenPath != "restricted-token" || path != "apps/orders-api/publish" || string(got) != value {
t.Fatal("set did not pass only kubeconfig, token path, target, and stdin value")
}
return nil
}
output := new(bytes.Buffer)
command := &cobra.Command{}
command.SetIn(strings.NewReader(value))
command.SetOut(output)
if err := runAppSecretSet(command, []string{"orders-api", "publish"}); err != nil || strings.Contains(output.String(), value) {
t.Fatal("set leaked its value or failed")
}
}
func TestAppSecretDeleteRequiresExplicitConfirmation(t *testing.T) {
originalYes := appSecretDeleteYes
t.Cleanup(func() { appSecretDeleteYes = originalYes })
appSecretDeleteYes = false
if err := runAppSecretDelete(&cobra.Command{}, []string{"orders-api", "publish"}); err == nil || !strings.Contains(err.Error(), "--yes") {
t.Fatalf("delete confirmation error = %v", err)
}
}
func TestAppSecretGrantOnlySavesNewDeclarativeDefinition(t *testing.T) {
originalLoad, originalSave := loadAppSecretConfig, saveAppSecretConfig
originalConfig, originalEnvironment, originalSecrets, originalShared := appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared
t.Cleanup(func() {
loadAppSecretConfig, saveAppSecretConfig = originalLoad, originalSave
appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared = originalConfig, originalEnvironment, originalSecrets, originalShared
})
appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared = "private.yaml", "production", []string{"database"}, []string{"rabbitmq"}
loadAppSecretConfig = func(string) (config.Config, error) { return config.Config{}, nil }
saved := false
saveAppSecretConfig = func(path string, cfg config.Config) error {
saved = path == "private.yaml" && len(cfg.SecretGrants) == 1 && cfg.SecretGrants[0].Application == "orders-api" && cfg.SecretGrants[0].Consumer == "runtime" && cfg.SecretGrants[0].Environment == "production" && len(cfg.SecretGrants[0].Secrets) == 1 && cfg.SecretGrants[0].Secrets[0] == "database" && len(cfg.SecretGrants[0].Shared) == 1 && cfg.SecretGrants[0].Shared[0] == "rabbitmq"
return nil
}
command := &cobra.Command{}
command.SetOut(new(bytes.Buffer))
if err := runAppSecretGrant(command, []string{"orders-api", "runtime"}); err != nil || !saved {
t.Fatalf("runAppSecretGrant() = %v, saved = %t", err, saved)
}
loadAppSecretConfig = func(string) (config.Config, error) {
return config.Config{SecretGrants: []config.SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"other"}}}}, nil
}
saveAppSecretConfig = func(string, config.Config) error { return errors.New("must not save ambiguous grant") }
if err := runAppSecretGrant(command, []string{"orders-api", "runtime"}); err == nil || !strings.Contains(err.Error(), "different definition") {
t.Fatalf("ambiguous grant error = %v", err)
}
}

View file

@ -8,9 +8,9 @@ gets one reviewed grant.
| Consumer | OpenBao path | Kubernetes namespace | Intended use |
| --- | --- | --- | --- |
| `build` | `apps/<app>/build/*` | `tekton-pipelines` | Read-only dependency credentials |
| `publish` | `apps/<app>/publish/*` | `tekton-pipelines` | One app's artifact repository credential |
| `runtime` | `apps/<app>/runtime/<environment>/*` | `<app>-<environment>` | Service runtime credentials |
| `build` | declared `apps/<app>/<secret>` entries | `tekton-pipelines` | Read-only dependency credentials |
| `publish` | declared `apps/<app>/<secret>` entries | `tekton-pipelines` | One app's artifact repository credential |
| `runtime` | declared `apps/<app>/<secret>` entries | `<app>-<environment>` | Service runtime credentials |
| shared | `shared/<name>/*` | Granted consumer only | Deliberately shared broker, database, or API credentials |
`build` code is repository-controlled. Anything granted to it is readable by a
@ -24,23 +24,30 @@ secret values and is reviewed with the platform configuration:
```yaml
secretGrants:
- application: orders-api
consumer: publish
secrets:
- registry
shared:
- internal-npm
- application: orders-api
consumer: publish
shared:
- internal-npm
- application: orders-api
consumer: runtime
environment: staging
consumer: runtime
environment: staging
secrets:
- database-staging
shared:
- rabbitmq
- application: orders-api
consumer: runtime
environment: production
consumer: runtime
environment: production
secrets:
- database-production
shared:
- rabbitmq
```
MaidnCLI validates application, consumer, environment, and shared-grant names.
MaidnCLI validates application, consumer, environment, application-secret, and
shared-grant names.
It creates one OpenBao policy and Kubernetes-auth role for every declaration.
The role names are deterministic:
@ -50,10 +57,12 @@ maidn-<app>-publish
maidn-<app>-runtime-<environment>
```
The policy permits only the consumer's own path and the exact `shared/<name>`
paths listed in its declaration. A shared value is stored once, for example at
`shared/rabbitmq`, and each service requiring it declares that same shared
grant. Do not copy it into application paths.
The policy permits only the exact `apps/<app>/<secret>` paths and
`shared/<name>/*` paths listed in its declaration. The CLI stores one property
named `value` at each `apps/<app>/<secret>` or `shared/<group>/<secret>` path.
A shared value is stored once, for example at `shared/rabbitmq/password`, and
each service requiring it declares that shared grant. Do not copy it into
application paths.
## GitOps resources
@ -102,10 +111,10 @@ spec:
name: orders-api-rabbitmq
creationPolicy: Owner
data:
- secretKey: password
- secretKey: value
remoteRef:
key: shared/rabbitmq
property: password
key: shared/rabbitmq/password
property: value
```
The workload references only `orders-api-rabbitmq` in its own namespace. Each
@ -127,8 +136,9 @@ code and therefore requires a narrowly scoped, disposable credential.
## Operations
1. Create the least-privilege upstream credential.
2. Write its value to the declared OpenBao path through a secure stdin-based
operator workflow. Never put it in YAML, a URL, a command argument, or Git.
2. Write its value to the declared OpenBao path with `cicd-tool app secret set`
using stdin or `--file` and a least-privilege `--token-file`. Never put a
value or token in YAML, a URL, a command argument, output, or Git.
3. Add the reviewed grant and GitOps resources.
4. Bootstrap or reconcile to create the OpenBao role and policy.
5. Verify the target ExternalSecret becomes Ready without printing its Secret.

View file

@ -357,57 +357,31 @@ spec:
- name: manifests-repo
default: {{ quote .ManifestsRepo }}
workspaces:
- name: source
- name: artifact
tasks:
- name: clone
- name: build-layer
when:
- input: $(params.event-action)
operator: notin
values: [closed]
taskRef:
name: maidn-git-clone
name: maidn-node-static-image
params:
- name: url
value: {{ quote .AppRepoURL }}
- name: revision
value: $(params.git-revision)
workspaces:
- name: source
workspace: source
- name: build-layer
runAfter: [clone]
when:
- input: $(params.event-action)
operator: notin
values: [closed]
taskRef:
name: maidn-node-static-build
params:
- name: image
value: $(params.image)
- name: output-directory
value: {{ quote .BuildOutputDirectory }}
- name: build-configuration
value: {{ quote .BuildConfiguration }}
workspaces:
- name: source
workspace: source
- name: push
runAfter: [build-layer]
when:
- input: $(params.event-action)
operator: notin
values: [closed]
taskRef:
name: maidn-node-static-push
params:
- name: image
value: $(params.image)
- name: revision
value: $(params.git-revision)
workspaces:
- name: source
workspace: source
- name: artifact
workspace: artifact
- name: update-preview
runAfter: [push]
runAfter: [build-layer]
when:
- input: $(params.event-type)
operator: in
@ -439,7 +413,7 @@ spec:
- name: app-revision
value: $(params.git-revision)
- name: update-staging
runAfter: [push]
runAfter: [build-layer]
when:
- input: $(params.event-type)
operator: in
@ -465,7 +439,7 @@ spec:
- name: environment
value: staging
- name: promote-production
runAfter: [push]
runAfter: [build-layer]
when:
- input: $(params.event-type)
operator: in

View file

@ -668,7 +668,7 @@ func renderAppDelivery(cfg config.Config) ([]byte, error) {
if err := tmpl.Execute(&rendered, values); err != nil {
return nil, err
}
return rendered.Bytes(), nil
return bytes.ReplaceAll(rendered.Bytes(), []byte("\r\n"), []byte("\n")), nil
}
func deliveryRepository(baseURL, repositoryURL string) (string, error) {

View file

@ -52,7 +52,7 @@ func TestInitializeOpenBaoConfiguresDeclaredSecretGrants(t *testing.T) {
called = kubeconfig == filepath.Join("generated", "kubeconfig") && identity == "identity" && bundle == "bundle" && len(grants) == 1 && grants[0].Application == "orders-api"
return nil
}
r := Runner{Config: config.Config{SOPS: config.SOPSConfig{RecoveryIdentityPath: "identity", RecoveryBundlePath: "bundle", RecoveryRecipient: "recipient", AgeKeyPath: "age", OperationalSecretsPath: "secrets"}, SecretGrants: []config.SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "staging"}}}}
r := Runner{Config: config.Config{SOPS: config.SOPSConfig{RecoveryIdentityPath: "identity", RecoveryBundlePath: "bundle", RecoveryRecipient: "recipient", AgeKeyPath: "age", OperationalSecretsPath: "secrets"}, SecretGrants: []config.SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "staging", Secrets: []string{"database"}}}}}
if _, err := r.initializeOpenBaoForCluster("generated"); err != nil || !called {
t.Fatalf("secret grant configuration was not invoked: called=%t err=%v", called, err)
}
@ -108,7 +108,7 @@ func TestRemoveDuplicateAppDeliverySource(t *testing.T) {
}
}
func TestGeneratedDeliveryIsGenericAndUsesSafePreviewCleanupContract(t *testing.T) {
func TestGeneratedDeliveryUsesCombinedStaticBuildArtifactContract(t *testing.T) {
cfg := config.Config{
Git: config.GitConfig{BaseURL: "https://git.example.test", Owner: "platform"},
Flux: config.FluxConfig{Branch: "main", ManifestsRepo: "manifests"},
@ -118,13 +118,15 @@ func TestGeneratedDeliveryIsGenericAndUsesSafePreviewCleanupContract(t *testing.
if err != nil {
t.Fatal(err)
}
for _, expected := range []string{"maidn-git-clone", "maidn-node-static-build", "maidn-node-static-push", "runAfter: [clone]", "runAfter: [build-layer]", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} {
for _, expected := range []string{"name: build-layer", "maidn-node-static-image", "name: url\n value: \"https://git.example.test/apps/web-ui.git\"", "name: revision\n value: $(params.git-revision)", "name: image\n value: $(params.image)", "name: artifact\n workspace: artifact", "runAfter: [build-layer]", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} {
if !strings.Contains(string(content), expected) {
t.Fatalf("generated delivery does not contain %q", expected)
}
}
if strings.Contains(string(content), "easycsr") || strings.Contains(string(content), "test-org") || strings.Contains(string(content), "git rm -r") {
t.Fatal("generated delivery contains a non-generic or unsafe literal")
for _, unexpected := range []string{"maidn-git-clone", "maidn-node-static-build", "maidn-node-static-push", "runAfter: [clone]", "name: source", "workspace: source", "easycsr", "test-org", "git rm -r"} {
if strings.Contains(string(content), unexpected) {
t.Fatalf("generated delivery contains unexpected %q", unexpected)
}
}
}
@ -148,8 +150,14 @@ func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.
t.Fatalf("generated delivery does not contain %q", expected)
}
}
production := rendered[strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`):]
production = production[:strings.Index(production, "\n else\n")]
start := strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`)
if start < 0 {
t.Fatal("generated delivery does not contain a production manifest path")
}
production := rendered[start:]
if end := strings.Index(production, "\n else\n"); end >= 0 {
production = production[:end]
}
if strings.Contains(production, `git push origin "$MANIFESTS_BRANCH"`) {
t.Fatal("production path pushes directly to manifests main")
}

View file

@ -514,6 +514,16 @@ func ValidateSecretGrants(grants []SecretGrant) error {
return errors.New("duplicate secret grant consumer")
}
seen[key] = true
if len(grant.Secrets) == 0 {
return errors.New("secret grant requires at least one application secret")
}
secretNames := map[string]bool{}
for _, secret := range grant.Secrets {
if !name.MatchString(secret) || secretNames[secret] {
return errors.New("secret grant application secret names must be unique lowercase DNS labels")
}
secretNames[secret] = true
}
for _, shared := range grant.Shared {
if !name.MatchString(shared) {
return errors.New("shared secret grant name must be a lowercase DNS label")

View file

@ -120,13 +120,15 @@ func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) {
}
func TestValidateSecretGrants(t *testing.T) {
if err := ValidateSecretGrants([]SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Shared: []string{"rabbitmq"}}}); err != nil {
if err := ValidateSecretGrants([]SecretGrant{{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}}}); err != nil {
t.Fatal(err)
}
for _, grant := range []SecretGrant{
{Application: "orders-api", Consumer: "runtime", Environment: "preview"},
{Application: "orders-api", Consumer: "build", Environment: "staging"},
{Application: "orders-api", Consumer: "publish"},
{Application: "orders-api", Consumer: "publish", Shared: []string{"../platform"}},
{Application: "orders-api", Consumer: "publish", Secrets: []string{"database", "database"}},
} {
if err := ValidateSecretGrants([]SecretGrant{grant}); err == nil {
t.Fatalf("invalid secret grant accepted: %#v", grant)

View file

@ -16,12 +16,13 @@ type Config struct {
SOPS SOPSConfig `yaml:"sops"`
}
// SecretGrant gives one application consumer access to its own OpenBao path
// and explicitly named shared paths. It contains references, never values.
// SecretGrant gives one application consumer access to named application and
// shared OpenBao paths. It contains references, never values.
type SecretGrant struct {
Application string `yaml:"application"`
Consumer string `yaml:"consumer"`
Environment string `yaml:"environment,omitempty"`
Secrets []string `yaml:"secrets"`
Shared []string `yaml:"shared,omitempty"`
}

View file

@ -0,0 +1,138 @@
package openbao
import (
"encoding/json"
"errors"
"os"
"regexp"
"strings"
)
var managedSecretPart = regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`)
// ManagedSecretPath limits application-secret operations to the declared app
// and shared OpenBao namespaces.
func ManagedSecretPath(shared bool, owner, name string) (string, error) {
if !managedSecretPart.MatchString(owner) || !managedSecretPart.MatchString(name) {
return "", errors.New("application, shared group, and secret names must be lowercase DNS labels")
}
if shared {
return "shared/" + owner + "/" + name, nil
}
return "apps/" + owner + "/" + name, nil
}
func managedSecretScope(shared bool, owner string) (string, error) {
if !managedSecretPart.MatchString(owner) {
return "", errors.New("application and shared group names must be lowercase DNS labels")
}
if shared {
return "shared/" + owner, nil
}
return "apps/" + owner, nil
}
// StoreManagedSecret keeps the token and value on stdin all the way to OpenBao.
func StoreManagedSecret(kubeconfig, tokenPath, path string, value []byte) error {
if _, err := managedSecretPathParts(path); err != nil {
return err
}
if len(value) == 0 {
return errors.New("secret value must not be empty")
}
token, err := readRestrictedToken(tokenPath)
if err != nil {
return err
}
return writeSecret(kubeconfig, token, path, map[string]string{"value": string(value)})
}
// ListManagedSecrets returns only secret names from KV metadata.
func ListManagedSecrets(kubeconfig, tokenPath string, shared bool, owner string) ([]string, error) {
scope, err := managedSecretScope(shared, owner)
if err != nil {
return nil, err
}
token, err := readRestrictedToken(tokenPath)
if err != nil {
return nil, err
}
output, err := execInPod(kubeconfig, []byte(token+"\n"), "sh", "-ec", "read -r token\nexport BAO_TOKEN=\"$token\"\nbao kv list -format=json secret/metadata/"+scope)
if err != nil {
if strings.Contains(strings.ToLower(string(output)), "no value found") {
return nil, nil
}
return nil, errors.New("list OpenBao secret metadata")
}
var response struct {
Data struct {
Keys []string `json:"keys"`
} `json:"data"`
}
if err := json.Unmarshal(output, &response); err != nil {
return nil, errors.New("parse OpenBao secret metadata")
}
return response.Data.Keys, nil
}
// ManagedSecretStatus checks KV metadata without reading the secret value.
func ManagedSecretStatus(kubeconfig, tokenPath, path string) (bool, error) {
if _, err := managedSecretPathParts(path); err != nil {
return false, err
}
token, err := readRestrictedToken(tokenPath)
if err != nil {
return false, err
}
output, err := execInPod(kubeconfig, []byte(token+"\n"), "sh", "-ec", "read -r token\nexport BAO_TOKEN=\"$token\"\nbao kv metadata get -format=json secret/"+path)
if err != nil {
if strings.Contains(strings.ToLower(string(output)), "no value found") {
return false, nil
}
return false, errors.New("read OpenBao secret metadata")
}
var response struct {
Data json.RawMessage `json:"data"`
}
if err := json.Unmarshal(output, &response); err != nil || len(response.Data) == 0 {
return false, errors.New("parse OpenBao secret metadata")
}
return true, nil
}
func DeleteManagedSecret(kubeconfig, tokenPath, path string) error {
if _, err := managedSecretPathParts(path); err != nil {
return err
}
token, err := readRestrictedToken(tokenPath)
if err != nil {
return err
}
if _, err := execInPodMutation(kubeconfig, []byte(token+"\n"), "sh", "-ec", "read -r token\nexport BAO_TOKEN=\"$token\"\nbao kv metadata delete secret/"+path+" >/dev/null"); err != nil {
return errors.New("delete OpenBao secret")
}
return nil
}
func readRestrictedToken(path string) (string, error) {
if path == "" {
return "", errors.New("--token-file is required")
}
contents, err := os.ReadFile(path)
if err != nil {
return "", errors.New("read OpenBao token file")
}
token := strings.TrimSpace(string(contents))
if token == "" || strings.ContainsAny(token, " \t\r\n") {
return "", errors.New("OpenBao token file must contain one token")
}
return token, nil
}
func managedSecretPathParts(path string) ([]string, error) {
parts := strings.Split(path, "/")
if len(parts) != 3 || (parts[0] != "apps" && parts[0] != "shared") || !managedSecretPart.MatchString(parts[1]) || !managedSecretPart.MatchString(parts[2]) {
return nil, errors.New("invalid managed OpenBao secret path")
}
return parts, nil
}

View file

@ -0,0 +1,122 @@
package openbao
import (
"encoding/base64"
"errors"
"os"
"path/filepath"
"strings"
"testing"
)
func restrictedTokenFile(t *testing.T) string {
t.Helper()
path := filepath.Join(t.TempDir(), "openbao-token")
if err := os.WriteFile(path, []byte("test-restricted-token\n"), 0600); err != nil {
t.Fatal(err)
}
return path
}
func TestManagedSecretPathOnlyAllowsAppAndSharedNamespaces(t *testing.T) {
for _, test := range []struct {
shared bool
owner string
name string
want string
}{
{false, "orders-api", "publish", "apps/orders-api/publish"},
{true, "rabbitmq", "password", "shared/rabbitmq/password"},
} {
got, err := ManagedSecretPath(test.shared, test.owner, test.name)
if err != nil || got != test.want {
t.Fatalf("ManagedSecretPath(%t, %q, %q) = %q, %v", test.shared, test.owner, test.name, got, err)
}
}
if _, err := ManagedSecretPath(false, "orders-api", "../root"); err == nil {
t.Fatal("ManagedSecretPath accepted an unsafe path")
}
}
func TestStoreManagedSecretUsesRestrictedTokenStdinWithoutRecoveryInputs(t *testing.T) {
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
decryptRecovery = func(_, _ string) ([]byte, error) {
t.Fatal("application secret CRUD must not read recovery material")
return nil, nil
}
const value = "must-not-appear-in-command"
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
if string(input) != "test-restricted-token\n"+base64.StdEncoding.EncodeToString([]byte(value))+"\n" {
t.Fatal("secret token and value were not framed on stdin")
}
command := strings.Join(args, " ")
if !strings.Contains(command, "bao kv put secret/apps/orders-api/publish value=\"$value0\"") || strings.Contains(command, value) || strings.Contains(command, "test-restricted-token") {
t.Fatal("secret command exposed a value or token")
}
return nil, nil
}
if err := StoreManagedSecret("kubeconfig", restrictedTokenFile(t), "apps/orders-api/publish", []byte(value)); err != nil {
t.Fatal(err)
}
}
func TestListAndStatusReadMetadataOnly(t *testing.T) {
originalDecrypt, originalExec := decryptRecovery, execInPod
t.Cleanup(func() { decryptRecovery, execInPod = originalDecrypt, originalExec })
decryptRecovery = func(_, _ string) ([]byte, error) {
t.Fatal("application secret CRUD must not read recovery material")
return nil, nil
}
calls := 0
execInPod = func(_ string, input []byte, args ...string) ([]byte, error) {
calls++
if string(input) != "test-restricted-token\n" || strings.Contains(strings.Join(args, " "), " kv get ") || strings.Contains(strings.Join(args, " "), "test-restricted-token") {
t.Fatal("metadata query used an unsafe secret-read boundary")
}
if strings.Contains(strings.Join(args, " "), "kv list") {
return []byte(`{"data":{"keys":["publish"]}}`), nil
}
return []byte(`{"data":{"created_time":"2026-01-01T00:00:00Z"}}`), nil
}
tokenPath := restrictedTokenFile(t)
values, err := ListManagedSecrets("kubeconfig", tokenPath, false, "orders-api")
if err != nil || len(values) != 1 || values[0] != "publish" {
t.Fatalf("ListManagedSecrets() = %q, %v", values, err)
}
present, err := ManagedSecretStatus("kubeconfig", tokenPath, "apps/orders-api/publish")
if err != nil || !present || calls != 2 {
t.Fatalf("ManagedSecretStatus() = %t, %v; calls = %d", present, err, calls)
}
}
func TestDeleteManagedSecretUsesMetadataDelete(t *testing.T) {
originalDecrypt, originalExec := decryptRecovery, execInPodMutation
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalExec })
decryptRecovery = func(_, _ string) ([]byte, error) {
t.Fatal("application secret CRUD must not read recovery material")
return nil, nil
}
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
if string(input) != "test-restricted-token\n" || !strings.Contains(strings.Join(args, " "), "bao kv metadata delete secret/shared/rabbitmq/password") || strings.Contains(strings.Join(args, " "), "test-restricted-token") {
t.Fatal("delete did not use a metadata delete with token on stdin")
}
return nil, nil
}
if err := DeleteManagedSecret("kubeconfig", restrictedTokenFile(t), "shared/rabbitmq/password"); err != nil {
t.Fatal(err)
}
}
func TestStoreManagedSecretRedactsTokenAndValueFromFailures(t *testing.T) {
original := execInPodMutation
t.Cleanup(func() { execInPodMutation = original })
const value = "must-not-leak-value"
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
return []byte("test-restricted-token " + value), errors.New("failed")
}
err := StoreManagedSecret("kubeconfig", restrictedTokenFile(t), "apps/orders-api/publish", []byte(value))
if err == nil || strings.Contains(err.Error(), "test-restricted-token") || strings.Contains(err.Error(), value) {
t.Fatal("secret operation leaked a token or value")
}
}

View file

@ -236,7 +236,7 @@ func parseRecoveryMaterial(plaintext []byte) (RecoveryMaterial, error) {
return material, nil
}
func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]string) error {
func writeSecret(kubeconfig, token, secretPath string, values map[string]string) error {
if !regexp.MustCompile(`^[a-z0-9][a-z0-9/_-]*$`).MatchString(secretPath) || len(values) == 0 {
return fmt.Errorf("invalid OpenBao secret path %q", secretPath)
}
@ -250,7 +250,7 @@ func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]str
sort.Strings(keys)
arguments := make([]string, 0, len(keys))
input := strings.Builder{}
input.WriteString(rootToken)
input.WriteString(token)
input.WriteByte('\n')
for _, key := range keys {
arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments)))
@ -263,7 +263,7 @@ func writeSecret(kubeconfig, rootToken, secretPath string, values map[string]str
reads = append(reads, fmt.Sprintf("read -r value%d_b64", index))
decodes = append(decodes, fmt.Sprintf("value%d=$(printf '%%s' \"$value%d_b64\" | base64 -d; printf x)\nvalue%d=${value%d%%x}", index, index, index, index))
}
script := "read -r root_token\n" + strings.Join(reads, "\n") + "\n" + strings.Join(decodes, "\n") + "\nexport BAO_TOKEN=\"$root_token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null"
script := "read -r token\n" + strings.Join(reads, "\n") + "\n" + strings.Join(decodes, "\n") + "\nexport BAO_TOKEN=\"$token\"\nbao kv put secret/" + secretPath + " " + strings.Join(arguments, " ") + " >/dev/null"
_, err := execInPodMutation(kubeconfig, []byte(input.String()), "sh", "-ec", script)
if err != nil {
return fmt.Errorf("write OpenBao secret %q", secretPath)
@ -393,14 +393,14 @@ func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants [
for _, grant := range grants {
name := "maidn-" + grant.Application + "-" + grant.Consumer
namespace := "tekton-pipelines"
path := "apps/" + grant.Application + "/" + grant.Consumer
if grant.Consumer == "runtime" {
name += "-" + grant.Environment
namespace = grant.Application + "-" + grant.Environment
path += "/" + grant.Environment
}
script.WriteString("cat >/tmp/" + name + ".hcl <<'EOF'\n")
script.WriteString("path \"secret/data/" + path + "/*\" {\n capabilities = [\"read\"]\n}\n")
for _, secret := range grant.Secrets {
script.WriteString("path \"secret/data/apps/" + grant.Application + "/" + secret + "\" {\n capabilities = [\"read\"]\n}\n")
}
for _, shared := range grant.Shared {
script.WriteString("path \"secret/data/shared/" + shared + "/*\" {\n capabilities = [\"read\"]\n}\n")
}

View file

@ -141,15 +141,15 @@ func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) {
return nil, nil
}
grants := []config.SecretGrant{
{Application: "orders-api", Consumer: "publish", Shared: []string{"artifact-cache"}},
{Application: "orders-api", Consumer: "runtime", Environment: "production", Shared: []string{"rabbitmq"}},
{Application: "orders-api", Consumer: "publish", Secrets: []string{"registry"}, Shared: []string{"artifact-cache"}},
{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}},
}
if err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", grants); err != nil {
t.Fatal(err)
}
for _, want := range []string{
`secret/data/apps/orders-api/publish/*`,
`secret/data/apps/orders-api/runtime/production/*`,
`secret/data/apps/orders-api/registry`,
`secret/data/apps/orders-api/database`,
`secret/data/shared/artifact-cache/*`,
`secret/data/shared/rabbitmq/*`,
`bound_service_account_names=maidn-orders-api-publish`,
@ -162,6 +162,9 @@ func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) {
if strings.Contains(script, `secret/data/*`) {
t.Fatal("secret grant widened access to every OpenBao secret")
}
if strings.Contains(script, `secret/data/apps/orders-api/registry/*`) || strings.Contains(script, `secret/data/apps/orders-api/database/*`) {
t.Fatal("secret grant widened access beyond declared application secrets")
}
}
func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) {
@ -174,7 +177,7 @@ func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) {
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
return []byte("policy write denied for " + rootToken), errors.New("exit status 1")
}
err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", []config.SecretGrant{{Application: "orders-api", Consumer: "publish"}})
err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", []config.SecretGrant{{Application: "orders-api", Consumer: "publish", Secrets: []string{"registry"}}})
if err == nil || !strings.Contains(err.Error(), "policy write denied") || strings.Contains(err.Error(), rootToken) {
t.Fatalf("policy diagnostics were not useful and redacted: %v", err)
}