Compare commits
4 commits
main
...
fix/local-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6784b9ea0d | ||
|
|
788f30fe11 | ||
|
|
5195702687 | ||
|
|
81d92e971e |
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 |
|
||||
| --- | --- | --- | --- |
|
||||
| `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
|
||||
|
|
@ -26,21 +26,28 @@ secret values and is reviewed with the platform configuration:
|
|||
secretGrants:
|
||||
- application: orders-api
|
||||
consumer: publish
|
||||
secrets:
|
||||
- registry
|
||||
shared:
|
||||
- internal-npm
|
||||
- application: orders-api
|
||||
consumer: runtime
|
||||
environment: staging
|
||||
secrets:
|
||||
- database-staging
|
||||
shared:
|
||||
- rabbitmq
|
||||
- application: orders-api
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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"`
|
||||
}
|
||||
|
||||
|
|
|
|||
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
|
||||
}
|
||||
|
||||
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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue