feat: manage app OpenBao secrets #43
216
cmd/app_secret.go
Normal file
216
cmd/app_secret.go
Normal 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
77
cmd/app_secret_test.go
Normal 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -8,9 +8,9 @@ gets one reviewed grant.
|
||||||
|
|
||||||
| Consumer | OpenBao path | Kubernetes namespace | Intended use |
|
| Consumer | OpenBao path | Kubernetes namespace | Intended use |
|
||||||
| --- | --- | --- | --- |
|
| --- | --- | --- | --- |
|
||||||
| `build` | `apps/<app>/build/*` | `tekton-pipelines` | Read-only dependency credentials |
|
| `build` | declared `apps/<app>/<secret>` entries | `tekton-pipelines` | Read-only dependency credentials |
|
||||||
| `publish` | `apps/<app>/publish/*` | `tekton-pipelines` | One app's artifact repository credential |
|
| `publish` | declared `apps/<app>/<secret>` entries | `tekton-pipelines` | One app's artifact repository credential |
|
||||||
| `runtime` | `apps/<app>/runtime/<environment>/*` | `<app>-<environment>` | Service runtime credentials |
|
| `runtime` | declared `apps/<app>/<secret>` entries | `<app>-<environment>` | Service runtime credentials |
|
||||||
| shared | `shared/<name>/*` | Granted consumer only | Deliberately shared broker, database, or API 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
|
`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
|
```yaml
|
||||||
secretGrants:
|
secretGrants:
|
||||||
|
- application: orders-api
|
||||||
|
consumer: publish
|
||||||
|
secrets:
|
||||||
|
- registry
|
||||||
|
shared:
|
||||||
|
- internal-npm
|
||||||
- application: orders-api
|
- application: orders-api
|
||||||
consumer: publish
|
consumer: runtime
|
||||||
shared:
|
environment: staging
|
||||||
- internal-npm
|
secrets:
|
||||||
- application: orders-api
|
- database-staging
|
||||||
consumer: runtime
|
|
||||||
environment: staging
|
|
||||||
shared:
|
shared:
|
||||||
- rabbitmq
|
- rabbitmq
|
||||||
- application: orders-api
|
- application: orders-api
|
||||||
consumer: runtime
|
consumer: runtime
|
||||||
environment: production
|
environment: production
|
||||||
|
secrets:
|
||||||
|
- database-production
|
||||||
shared:
|
shared:
|
||||||
- rabbitmq
|
- 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.
|
It creates one OpenBao policy and Kubernetes-auth role for every declaration.
|
||||||
The role names are deterministic:
|
The role names are deterministic:
|
||||||
|
|
||||||
|
|
@ -50,10 +57,12 @@ maidn-<app>-publish
|
||||||
maidn-<app>-runtime-<environment>
|
maidn-<app>-runtime-<environment>
|
||||||
```
|
```
|
||||||
|
|
||||||
The policy permits only the consumer's own path and the exact `shared/<name>`
|
The policy permits only the exact `apps/<app>/<secret>` paths and
|
||||||
paths listed in its declaration. A shared value is stored once, for example at
|
`shared/<name>/*` paths listed in its declaration. The CLI stores one property
|
||||||
`shared/rabbitmq`, and each service requiring it declares that same shared
|
named `value` at each `apps/<app>/<secret>` or `shared/<group>/<secret>` path.
|
||||||
grant. Do not copy it into application paths.
|
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
|
## GitOps resources
|
||||||
|
|
||||||
|
|
@ -102,10 +111,10 @@ spec:
|
||||||
name: orders-api-rabbitmq
|
name: orders-api-rabbitmq
|
||||||
creationPolicy: Owner
|
creationPolicy: Owner
|
||||||
data:
|
data:
|
||||||
- secretKey: password
|
- secretKey: value
|
||||||
remoteRef:
|
remoteRef:
|
||||||
key: shared/rabbitmq
|
key: shared/rabbitmq/password
|
||||||
property: password
|
property: value
|
||||||
```
|
```
|
||||||
|
|
||||||
The workload references only `orders-api-rabbitmq` in its own namespace. Each
|
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
|
## Operations
|
||||||
|
|
||||||
1. Create the least-privilege upstream credential.
|
1. Create the least-privilege upstream credential.
|
||||||
2. Write its value to the declared OpenBao path through a secure stdin-based
|
2. Write its value to the declared OpenBao path with `cicd-tool app secret set`
|
||||||
operator workflow. Never put it in YAML, a URL, a command argument, or Git.
|
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.
|
3. Add the reviewed grant and GitOps resources.
|
||||||
4. Bootstrap or reconcile to create the OpenBao role and policy.
|
4. Bootstrap or reconcile to create the OpenBao role and policy.
|
||||||
5. Verify the target ExternalSecret becomes Ready without printing its Secret.
|
5. Verify the target ExternalSecret becomes Ready without printing its Secret.
|
||||||
|
|
|
||||||
|
|
@ -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"
|
called = kubeconfig == filepath.Join("generated", "kubeconfig") && identity == "identity" && bundle == "bundle" && len(grants) == 1 && grants[0].Application == "orders-api"
|
||||||
return nil
|
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 {
|
if _, err := r.initializeOpenBaoForCluster("generated"); err != nil || !called {
|
||||||
t.Fatalf("secret grant configuration was not invoked: called=%t err=%v", called, err)
|
t.Fatalf("secret grant configuration was not invoked: called=%t err=%v", called, err)
|
||||||
}
|
}
|
||||||
|
|
@ -148,8 +148,14 @@ func TestGeneratedDeliveryInitializesStagingAndPromotesByPullRequest(t *testing.
|
||||||
t.Fatalf("generated delivery does not contain %q", expected)
|
t.Fatalf("generated delivery does not contain %q", expected)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
production := rendered[strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`):]
|
start := strings.Index(rendered, `if [ "$ENVIRONMENT" = production ]; then`)
|
||||||
production = production[:strings.Index(production, "\n else\n")]
|
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"`) {
|
if strings.Contains(production, `git push origin "$MANIFESTS_BRANCH"`) {
|
||||||
t.Fatal("production path pushes directly to manifests main")
|
t.Fatal("production path pushes directly to manifests main")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -514,6 +514,16 @@ func ValidateSecretGrants(grants []SecretGrant) error {
|
||||||
return errors.New("duplicate secret grant consumer")
|
return errors.New("duplicate secret grant consumer")
|
||||||
}
|
}
|
||||||
seen[key] = true
|
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 {
|
for _, shared := range grant.Shared {
|
||||||
if !name.MatchString(shared) {
|
if !name.MatchString(shared) {
|
||||||
return errors.New("shared secret grant name must be a lowercase DNS label")
|
return errors.New("shared secret grant name must be a lowercase DNS label")
|
||||||
|
|
|
||||||
|
|
@ -120,13 +120,15 @@ func TestValidateRejectsCredentialBearingDeliveryURLs(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestValidateSecretGrants(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)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, grant := range []SecretGrant{
|
for _, grant := range []SecretGrant{
|
||||||
{Application: "orders-api", Consumer: "runtime", Environment: "preview"},
|
{Application: "orders-api", Consumer: "runtime", Environment: "preview"},
|
||||||
{Application: "orders-api", Consumer: "build", Environment: "staging"},
|
{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", Shared: []string{"../platform"}},
|
||||||
|
{Application: "orders-api", Consumer: "publish", Secrets: []string{"database", "database"}},
|
||||||
} {
|
} {
|
||||||
if err := ValidateSecretGrants([]SecretGrant{grant}); err == nil {
|
if err := ValidateSecretGrants([]SecretGrant{grant}); err == nil {
|
||||||
t.Fatalf("invalid secret grant accepted: %#v", grant)
|
t.Fatalf("invalid secret grant accepted: %#v", grant)
|
||||||
|
|
|
||||||
|
|
@ -16,12 +16,13 @@ type Config struct {
|
||||||
SOPS SOPSConfig `yaml:"sops"`
|
SOPS SOPSConfig `yaml:"sops"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// SecretGrant gives one application consumer access to its own OpenBao path
|
// SecretGrant gives one application consumer access to named application and
|
||||||
// and explicitly named shared paths. It contains references, never values.
|
// shared OpenBao paths. It contains references, never values.
|
||||||
type SecretGrant struct {
|
type SecretGrant struct {
|
||||||
Application string `yaml:"application"`
|
Application string `yaml:"application"`
|
||||||
Consumer string `yaml:"consumer"`
|
Consumer string `yaml:"consumer"`
|
||||||
Environment string `yaml:"environment,omitempty"`
|
Environment string `yaml:"environment,omitempty"`
|
||||||
|
Secrets []string `yaml:"secrets"`
|
||||||
Shared []string `yaml:"shared,omitempty"`
|
Shared []string `yaml:"shared,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
138
internal/openbao/app_secret.go
Normal file
138
internal/openbao/app_secret.go
Normal 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
|
||||||
|
}
|
||||||
122
internal/openbao/app_secret_test.go
Normal file
122
internal/openbao/app_secret_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -236,7 +236,7 @@ func parseRecoveryMaterial(plaintext []byte) (RecoveryMaterial, error) {
|
||||||
return material, nil
|
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 {
|
if !regexp.MustCompile(`^[a-z0-9][a-z0-9/_-]*$`).MatchString(secretPath) || len(values) == 0 {
|
||||||
return fmt.Errorf("invalid OpenBao secret path %q", secretPath)
|
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)
|
sort.Strings(keys)
|
||||||
arguments := make([]string, 0, len(keys))
|
arguments := make([]string, 0, len(keys))
|
||||||
input := strings.Builder{}
|
input := strings.Builder{}
|
||||||
input.WriteString(rootToken)
|
input.WriteString(token)
|
||||||
input.WriteByte('\n')
|
input.WriteByte('\n')
|
||||||
for _, key := range keys {
|
for _, key := range keys {
|
||||||
arguments = append(arguments, fmt.Sprintf("%s=\"$value%d\"", key, len(arguments)))
|
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))
|
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))
|
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)
|
_, err := execInPodMutation(kubeconfig, []byte(input.String()), "sh", "-ec", script)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("write OpenBao secret %q", secretPath)
|
return fmt.Errorf("write OpenBao secret %q", secretPath)
|
||||||
|
|
@ -393,14 +393,14 @@ func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants [
|
||||||
for _, grant := range grants {
|
for _, grant := range grants {
|
||||||
name := "maidn-" + grant.Application + "-" + grant.Consumer
|
name := "maidn-" + grant.Application + "-" + grant.Consumer
|
||||||
namespace := "tekton-pipelines"
|
namespace := "tekton-pipelines"
|
||||||
path := "apps/" + grant.Application + "/" + grant.Consumer
|
|
||||||
if grant.Consumer == "runtime" {
|
if grant.Consumer == "runtime" {
|
||||||
name += "-" + grant.Environment
|
name += "-" + grant.Environment
|
||||||
namespace = grant.Application + "-" + grant.Environment
|
namespace = grant.Application + "-" + grant.Environment
|
||||||
path += "/" + grant.Environment
|
|
||||||
}
|
}
|
||||||
script.WriteString("cat >/tmp/" + name + ".hcl <<'EOF'\n")
|
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 {
|
for _, shared := range grant.Shared {
|
||||||
script.WriteString("path \"secret/data/shared/" + shared + "/*\" {\n capabilities = [\"read\"]\n}\n")
|
script.WriteString("path \"secret/data/shared/" + shared + "/*\" {\n capabilities = [\"read\"]\n}\n")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -141,15 +141,15 @@ func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
grants := []config.SecretGrant{
|
grants := []config.SecretGrant{
|
||||||
{Application: "orders-api", Consumer: "publish", Shared: []string{"artifact-cache"}},
|
{Application: "orders-api", Consumer: "publish", Secrets: []string{"registry"}, Shared: []string{"artifact-cache"}},
|
||||||
{Application: "orders-api", Consumer: "runtime", Environment: "production", Shared: []string{"rabbitmq"}},
|
{Application: "orders-api", Consumer: "runtime", Environment: "production", Secrets: []string{"database"}, Shared: []string{"rabbitmq"}},
|
||||||
}
|
}
|
||||||
if err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", grants); err != nil {
|
if err := ConfigureSecretGrants("kubeconfig", "identity", "bundle", grants); err != nil {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
for _, want := range []string{
|
for _, want := range []string{
|
||||||
`secret/data/apps/orders-api/publish/*`,
|
`secret/data/apps/orders-api/registry`,
|
||||||
`secret/data/apps/orders-api/runtime/production/*`,
|
`secret/data/apps/orders-api/database`,
|
||||||
`secret/data/shared/artifact-cache/*`,
|
`secret/data/shared/artifact-cache/*`,
|
||||||
`secret/data/shared/rabbitmq/*`,
|
`secret/data/shared/rabbitmq/*`,
|
||||||
`bound_service_account_names=maidn-orders-api-publish`,
|
`bound_service_account_names=maidn-orders-api-publish`,
|
||||||
|
|
@ -162,6 +162,9 @@ func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) {
|
||||||
if strings.Contains(script, `secret/data/*`) {
|
if strings.Contains(script, `secret/data/*`) {
|
||||||
t.Fatal("secret grant widened access to every OpenBao secret")
|
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) {
|
func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) {
|
||||||
|
|
@ -174,7 +177,7 @@ func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) {
|
||||||
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
|
execInPodMutation = func(_ string, _ []byte, _ ...string) ([]byte, error) {
|
||||||
return []byte("policy write denied for " + rootToken), errors.New("exit status 1")
|
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) {
|
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)
|
t.Fatalf("policy diagnostics were not useful and redacted: %v", err)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue