feat: automate restricted app-secret identities

This commit is contained in:
eding 2026-09-13 11:46:08 +02:00
parent d97bc041f9
commit 20381ebddf
8 changed files with 223 additions and 9 deletions

View file

@ -1,20 +1,24 @@
package cmd package cmd
import ( import (
"crypto/rand"
"encoding/base64"
"errors" "errors"
"fmt" "fmt"
"io" "io"
"os" "os"
"path/filepath" "path/filepath"
"reflect" "reflect"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/config"
"github.com/Pingu-Studio/MaidnCLI/internal/openbao" "github.com/Pingu-Studio/MaidnCLI/internal/openbao"
"github.com/spf13/cobra" "github.com/spf13/cobra"
) )
var appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretGrantEnvironment string var appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretIdentity, appSecretGrantEnvironment string
var appSecretShared, appSecretDeleteYes bool var appSecretShared, appSecretDeleteYes, appSecretGenerate bool
var appSecretGrantShared, appSecretGrantSecrets []string var appSecretGrantShared, appSecretGrantSecrets []string
var loadAppSecretConfig = config.Load var loadAppSecretConfig = config.Load
@ -70,8 +74,10 @@ func init() {
appSecretCmd.PersistentFlags().StringVar(&appSecretConfigPath, "config", "", "Path to private bootstrap config YAML") appSecretCmd.PersistentFlags().StringVar(&appSecretConfigPath, "config", "", "Path to private bootstrap config YAML")
_ = appSecretCmd.MarkPersistentFlagRequired("config") _ = appSecretCmd.MarkPersistentFlagRequired("config")
appSecretCmd.PersistentFlags().StringVar(&appSecretTokenFile, "token-file", "", "Path to restricted OpenBao token file for secret CRUD") appSecretCmd.PersistentFlags().StringVar(&appSecretTokenFile, "token-file", "", "Path to restricted OpenBao token file for secret CRUD")
appSecretCmd.PersistentFlags().StringVar(&appSecretIdentity, "identity", "", "Encrypted operational identity: admin or e2e:<app>")
appSecretSetCmd.Flags().StringVar(&appSecretFile, "file", "", "Read the secret value from this file instead of stdin") appSecretSetCmd.Flags().StringVar(&appSecretFile, "file", "", "Read the secret value from this file instead of stdin")
appSecretSetCmd.Flags().BoolVar(&appSecretGenerate, "generate", false, "Generate a random secret value without printing it")
for _, command := range []*cobra.Command{appSecretSetCmd, appSecretListCmd, appSecretDeleteCmd, appSecretStatusCmd} { for _, command := range []*cobra.Command{appSecretSetCmd, appSecretListCmd, appSecretDeleteCmd, appSecretStatusCmd} {
command.Flags().BoolVar(&appSecretShared, "shared", false, "Use shared/<group>/<secret> instead of apps/<app>/<secret>") command.Flags().BoolVar(&appSecretShared, "shared", false, "Use shared/<group>/<secret> instead of apps/<app>/<secret>")
} }
@ -90,7 +96,12 @@ func runAppSecretSet(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return err return err
} }
if err := storeAppSecret(kubeconfig, appSecretTokenFile, path, value); err != nil { tokenFile, cleanup, err := appSecretTokenPath()
if err != nil {
return err
}
defer cleanup()
if err := storeAppSecret(kubeconfig, tokenFile, path, value); err != nil {
return err return err
} }
fmt.Fprintf(cmd.OutOrStdout(), "stored %s\n", path) fmt.Fprintf(cmd.OutOrStdout(), "stored %s\n", path)
@ -102,7 +113,12 @@ func runAppSecretList(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return err return err
} }
values, err := listAppSecrets(kubeconfig, appSecretTokenFile, appSecretShared, args[0]) tokenFile, cleanup, err := appSecretTokenPath()
if err != nil {
return err
}
defer cleanup()
values, err := listAppSecrets(kubeconfig, tokenFile, appSecretShared, args[0])
if err != nil { if err != nil {
return err return err
} }
@ -120,7 +136,12 @@ func runAppSecretDelete(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return err return err
} }
if err := deleteAppSecret(kubeconfig, appSecretTokenFile, path); err != nil { tokenFile, cleanup, err := appSecretTokenPath()
if err != nil {
return err
}
defer cleanup()
if err := deleteAppSecret(kubeconfig, tokenFile, path); err != nil {
return err return err
} }
fmt.Fprintf(cmd.OutOrStdout(), "deleted %s\n", path) fmt.Fprintf(cmd.OutOrStdout(), "deleted %s\n", path)
@ -161,7 +182,12 @@ func runAppSecretStatus(cmd *cobra.Command, args []string) error {
if err != nil { if err != nil {
return err return err
} }
present, err := appSecretStatus(kubeconfig, appSecretTokenFile, path) tokenFile, cleanup, err := appSecretTokenPath()
if err != nil {
return err
}
defer cleanup()
present, err := appSecretStatus(kubeconfig, tokenFile, path)
if err != nil { if err != nil {
return err return err
} }
@ -201,6 +227,16 @@ func appSecretKubeconfigFromConfig() (string, error) {
} }
func readAppSecretValue(cmd *cobra.Command) ([]byte, error) { func readAppSecretValue(cmd *cobra.Command) ([]byte, error) {
if appSecretGenerate {
if appSecretFile != "" {
return nil, errors.New("--generate and --file cannot be used together")
}
value := make([]byte, 32)
if _, err := rand.Read(value); err != nil {
return nil, err
}
return []byte(base64.RawURLEncoding.EncodeToString(value)), nil
}
if appSecretFile != "" { if appSecretFile != "" {
value, err := os.ReadFile(appSecretFile) value, err := os.ReadFile(appSecretFile)
if err != nil { if err != nil {
@ -214,3 +250,46 @@ func readAppSecretValue(cmd *cobra.Command) ([]byte, error) {
} }
return value, nil return value, nil
} }
func appSecretTokenPath() (string, func(), error) {
if appSecretTokenFile != "" {
return appSecretTokenFile, func() {}, nil
}
if appSecretIdentity == "" {
return "", nil, errors.New("--token-file or --identity is required")
}
cfg, err := loadRequiredAppSecretConfig()
if err != nil {
return "", nil, err
}
path := ""
if appSecretIdentity == "admin" {
path = "cicd/app-secret-admin"
} else if strings.HasPrefix(appSecretIdentity, "e2e:") {
path = "cicd/e2e-" + strings.TrimPrefix(appSecretIdentity, "e2e:")
} else {
return "", nil, errors.New("--identity must be admin or e2e:<app>")
}
secrets, err := bootstrap.ReadOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath)
if err != nil {
return "", nil, errors.New("read encrypted app-secret identity")
}
token := secrets[path]["token"]
if token == "" {
return "", nil, errors.New("configured app-secret identity is absent")
}
file, err := os.CreateTemp("", "maidn-openbao-token-*")
if err != nil {
return "", nil, err
}
if _, err := file.WriteString(token + "\n"); err != nil {
file.Close()
os.Remove(file.Name())
return "", nil, err
}
if err := file.Close(); err != nil {
os.Remove(file.Name())
return "", nil, err
}
return file.Name(), func() { _ = os.Remove(file.Name()) }, nil
}

View file

@ -47,6 +47,16 @@ func TestAppSecretDeleteRequiresExplicitConfirmation(t *testing.T) {
} }
} }
func TestAppSecretGenerateDoesNotReadOrPrintValue(t *testing.T) {
originalGenerate, originalFile := appSecretGenerate, appSecretFile
t.Cleanup(func() { appSecretGenerate, appSecretFile = originalGenerate, originalFile })
appSecretGenerate, appSecretFile = true, ""
value, err := readAppSecretValue(&cobra.Command{})
if err != nil || len(value) < 40 || strings.Contains(string(value), "\n") {
t.Fatal("generated app secret is not a bounded opaque value")
}
}
func TestAppSecretGrantOnlySavesNewDeclarativeDefinition(t *testing.T) { func TestAppSecretGrantOnlySavesNewDeclarativeDefinition(t *testing.T) {
originalLoad, originalSave := loadAppSecretConfig, saveAppSecretConfig originalLoad, originalSave := loadAppSecretConfig, saveAppSecretConfig
originalConfig, originalEnvironment, originalSecrets, originalShared := appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared originalConfig, originalEnvironment, originalSecrets, originalShared := appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared

View file

@ -24,6 +24,8 @@ var bootstrapInitializeOpenBaoRecovery bool
var bootstrapInitializeOpenBao bool var bootstrapInitializeOpenBao bool
var bootstrapCreateForgejoRegistryToken bool var bootstrapCreateForgejoRegistryToken bool
var bootstrapCreateForgejoDeliveryStatusToken bool var bootstrapCreateForgejoDeliveryStatusToken bool
var bootstrapProvisionAppSecretIdentities bool
var bootstrapE2EApp string
var bootstrapRegisterWebhook bool var bootstrapRegisterWebhook bool
var bootstrapRotateWebhookAuthorization bool var bootstrapRotateWebhookAuthorization bool
var bootstrapMergeBootstrapPR bool var bootstrapMergeBootstrapPR bool
@ -55,6 +57,8 @@ func init() {
bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBao, "initialize-openbao", false, "Initialize OpenBao and seed encrypted operational secrets for --config") bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBao, "initialize-openbao", false, "Initialize OpenBao and seed encrypted operational secrets for --config")
bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao") bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao")
bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoDeliveryStatusToken, "create-forgejo-delivery-status-token", false, "Create or reuse the Forgejo delivery-status token and seed it through OpenBao") bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoDeliveryStatusToken, "create-forgejo-delivery-status-token", false, "Create or reuse the Forgejo delivery-status token and seed it through OpenBao")
bootstrapCmd.Flags().BoolVar(&bootstrapProvisionAppSecretIdentities, "provision-app-secret-identities", false, "Create restricted app-secret and E2E OpenBao identities")
bootstrapCmd.Flags().StringVar(&bootstrapE2EApp, "e2e-app", "", "Fixture app granted an E2E probe identity")
bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook") bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook")
bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao") bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao")
bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping") bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping")
@ -66,6 +70,19 @@ func init() {
func runBootstrap(cmd *cobra.Command, args []string) error { func runBootstrap(cmd *cobra.Command, args []string) error {
var cfg config.Config var cfg config.Config
var err error var err error
if bootstrapProvisionAppSecretIdentities {
if bootstrapConfigPath == "" || bootstrapE2EApp == "" {
return errors.New("--provision-app-secret-identities requires --config and --e2e-app")
}
cfg, err = config.Load(bootstrapConfigPath)
if err != nil {
return err
}
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
return err
}
return bootstrap.ProvisionAppSecretIdentities(cfg, bootstrapE2EApp)
}
if bootstrapCreateForgejoRegistryToken { if bootstrapCreateForgejoRegistryToken {
if bootstrapConfigPath == "" { if bootstrapConfigPath == "" {
return fmt.Errorf("--create-forgejo-registry-token requires --config") return fmt.Errorf("--create-forgejo-registry-token requires --config")

View file

@ -136,9 +136,10 @@ 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 with `cicd-tool app secret set` 2. Write its value with `cicd-tool app secret set` using stdin, `--file`, or
using stdin or `--file` and a least-privilege `--token-file`. Never put a `--generate`. Use a least-privilege `--token-file` or a bootstrap-provisioned
value or token in YAML, a URL, a command argument, output, or Git. `--identity`; 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.

View file

@ -21,3 +21,14 @@ The webhook-only path requires a complete delivery contract, an approved configu
- Do not revoke a previous credential until OpenBao, External Secrets, and every listed consumer have passed validation. - Do not revoke a previous credential until OpenBao, External Secrets, and every listed consumer have passed validation.
- Use the sanitized procedure in [runbooks/credential-rotation.md](runbooks/credential-rotation.md) for any live rotation. - Use the sanitized procedure in [runbooks/credential-rotation.md](runbooks/credential-rotation.md) for any live rotation.
- Application, artifact, and shared-secret access is documented in [secret-grants.md](secret-grants.md). Secret values remain outside that declaration. - Application, artifact, and shared-secret access is documented in [secret-grants.md](secret-grants.md). Secret values remain outside that declaration.
## Automated app-secret identities
Use `bootstrap --provision-app-secret-identities --e2e-app <app>` to create a
short-lived non-root `admin` identity and an exact-path E2E probe identity.
Bootstrap reads root recovery material only through its encrypted recovery
bundle, writes the generated tokens only into encrypted operational state, and
never prints either value. `app secret --identity admin` and
`app secret --identity e2e:<app>` create a temporary local token file only for
the command lifetime. Root tokens, recovery bundles, and unseal shares are not
valid app-secret identities.

View file

@ -985,6 +985,23 @@ func InitializeOpenBao(cfg config.Config) error {
return nil return nil
} }
// ProvisionAppSecretIdentities rotates short-lived restricted tokens and
// immediately reseeds encrypted operational state through OpenBao.
func ProvisionAppSecretIdentities(cfg config.Config, app string) error {
kubeconfig := filepath.Join(cfg.Git.CloneParent, cfg.Talos.RepoDirName, cfg.Talos.GeneratedDir, "kubeconfig")
tokens, err := openbao.ProvisionAppSecretIdentities(kubeconfig, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, app)
if err != nil {
return err
}
if err := UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/app-secret-admin", "token", tokens.Admin); err != nil {
return errors.New("save app-secret admin token")
}
if err := UpsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/e2e-"+app, "token", tokens.E2E); err != nil {
return errors.New("save E2E app-secret token")
}
return InitializeOpenBao(cfg)
}
func NewWebhookAuthorization() (string, error) { func NewWebhookAuthorization() (string, error) {
value := make([]byte, 32) value := make([]byte, 32)
if _, err := rand.Read(value); err != nil { if _, err := rand.Read(value); err != nil {

View file

@ -20,6 +20,11 @@ import (
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type AppSecretIdentityTokens struct {
Admin string
E2E string
}
type status struct { type status struct {
Initialized bool `json:"initialized"` Initialized bool `json:"initialized"`
Sealed bool `json:"sealed"` Sealed bool `json:"sealed"`
@ -416,6 +421,61 @@ func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants [
return nil return nil
} }
// ProvisionAppSecretIdentities creates short-lived non-root tokens for secret
// administration and one fixture probe. Tokens are returned only to be placed
// into encrypted operational state by the caller.
func ProvisionAppSecretIdentities(kubeconfig, identityPath, bundlePath, app string) (AppSecretIdentityTokens, error) {
if !managedSecretPart.MatchString(app) {
return AppSecretIdentityTokens{}, errors.New("E2E application must be a lowercase DNS label")
}
material, err := ReadRecoveryMaterial(identityPath, bundlePath)
if err != nil {
return AppSecretIdentityTokens{}, err
}
script := `read -r root_token
export BAO_TOKEN="$root_token"
cat >/tmp/maidn-app-secret-admin.hcl <<'EOF'
path "secret/data/apps/*" { capabilities = ["create", "update"] }
path "secret/metadata/apps/*" { capabilities = ["list", "read", "delete"] }
path "secret/data/shared/*" { capabilities = ["create", "update"] }
path "secret/metadata/shared/*" { capabilities = ["list", "read", "delete"] }
EOF
cat >/tmp/maidn-e2e.hcl <<'EOF'
path "secret/data/apps/` + app + `/E2E_PROBE" { capabilities = ["create", "update"] }
path "secret/metadata/apps/` + app + `" { capabilities = ["list"] }
path "secret/metadata/apps/` + app + `/E2E_PROBE" { capabilities = ["read", "delete"] }
EOF
bao policy write maidn-app-secret-admin /tmp/maidn-app-secret-admin.hcl >/dev/null
bao policy write maidn-e2e-` + app + ` /tmp/maidn-e2e.hcl >/dev/null
bao token create -orphan -policy=maidn-app-secret-admin -ttl=1h -explicit-max-ttl=1h -format=json
bao token create -orphan -policy=maidn-e2e-` + app + ` -ttl=1h -explicit-max-ttl=1h -format=json
rm -f /tmp/maidn-app-secret-admin.hcl /tmp/maidn-e2e.hcl`
output, err := execInPodMutation(kubeconfig, []byte(material.RootToken+"\n"), "sh", "-ec", script)
if err != nil {
return AppSecretIdentityTokens{}, openBaoMutationError("provision app-secret identities", err, output, material.RootToken)
}
var responses []struct {
Auth struct {
ClientToken string `json:"client_token"`
} `json:"auth"`
}
for _, line := range bytes.Split(bytes.TrimSpace(output), []byte("\n")) {
var response struct {
Auth struct {
ClientToken string `json:"client_token"`
} `json:"auth"`
}
if err := json.Unmarshal(line, &response); err != nil || response.Auth.ClientToken == "" {
return AppSecretIdentityTokens{}, errors.New("parse provisioned app-secret identity")
}
responses = append(responses, response)
}
if len(responses) != 2 {
return AppSecretIdentityTokens{}, errors.New("provision app-secret identities returned an incomplete result")
}
return AppSecretIdentityTokens{Admin: responses[0].Auth.ClientToken, E2E: responses[1].Auth.ClientToken}, nil
}
func openBaoMutationError(action string, err error, output []byte, sensitive ...string) error { func openBaoMutationError(action string, err error, output []byte, sensitive ...string) error {
diagnostic := redactOpenBaoDiagnostic(strings.TrimSpace(string(output)), sensitive...) diagnostic := redactOpenBaoDiagnostic(strings.TrimSpace(string(output)), sensitive...)
if diagnostic == "" && err != nil { if diagnostic == "" && err != nil {

View file

@ -183,6 +183,25 @@ func TestConfigureSecretGrantsPreservesRedactedPolicyDiagnostics(t *testing.T) {
} }
} }
func TestProvisionAppSecretIdentitiesScopesFixtureWithoutRootLeak(t *testing.T) {
originalDecrypt, originalMutation := decryptRecovery, execInPodMutation
t.Cleanup(func() { decryptRecovery, execInPodMutation = originalDecrypt, originalMutation })
decryptRecovery = func(string, string) ([]byte, error) {
return []byte(`{"root_token":"root-token","unseal_keys_b64":["share"],"unseal_threshold":1}`), nil
}
execInPodMutation = func(_ string, input []byte, args ...string) ([]byte, error) {
command := strings.Join(args, " ")
if string(input) != "root-token\n" || !strings.Contains(command, `secret/data/apps/maidn-e2e-web/E2E_PROBE`) || strings.Contains(command, `secret/data/apps/maidn-e2e-web/*`) {
t.Fatal("fixture identity policy scope is incorrect")
}
return []byte("{\"auth\":{\"client_token\":\"admin-token\"}}\n{\"auth\":{\"client_token\":\"e2e-token\"}}\n"), nil
}
tokens, err := ProvisionAppSecretIdentities("kubeconfig", "identity", "bundle", "maidn-e2e-web")
if err != nil || tokens.Admin != "admin-token" || tokens.E2E != "e2e-token" {
t.Fatalf("ProvisionAppSecretIdentities() = %#v, %v", tokens, err)
}
}
func TestConfigureKubernetesAuthLimitsPlatformStore(t *testing.T) { func TestConfigureKubernetesAuthLimitsPlatformStore(t *testing.T) {
original := execInPodMutation original := execInPodMutation
t.Cleanup(func() { execInPodMutation = original }) t.Cleanup(func() { execInPodMutation = original })