feat: automate restricted app-secret identities
This commit is contained in:
parent
d97bc041f9
commit
20381ebddf
|
|
@ -1,20 +1,24 @@
|
|||
package cmd
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
|
||||
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
|
||||
"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 appSecretConfigPath, appSecretFile, appSecretTokenFile, appSecretIdentity, appSecretGrantEnvironment string
|
||||
var appSecretShared, appSecretDeleteYes, appSecretGenerate bool
|
||||
var appSecretGrantShared, appSecretGrantSecrets []string
|
||||
|
||||
var loadAppSecretConfig = config.Load
|
||||
|
|
@ -70,8 +74,10 @@ func init() {
|
|||
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")
|
||||
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().BoolVar(&appSecretGenerate, "generate", false, "Generate a random secret value without printing it")
|
||||
for _, command := range []*cobra.Command{appSecretSetCmd, appSecretListCmd, appSecretDeleteCmd, appSecretStatusCmd} {
|
||||
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 {
|
||||
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
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "stored %s\n", path)
|
||||
|
|
@ -102,7 +113,12 @@ func runAppSecretList(cmd *cobra.Command, args []string) error {
|
|||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
@ -120,7 +136,12 @@ func runAppSecretDelete(cmd *cobra.Command, args []string) error {
|
|||
if err != nil {
|
||||
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
|
||||
}
|
||||
fmt.Fprintf(cmd.OutOrStdout(), "deleted %s\n", path)
|
||||
|
|
@ -161,7 +182,12 @@ func runAppSecretStatus(cmd *cobra.Command, args []string) error {
|
|||
if err != nil {
|
||||
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 {
|
||||
return err
|
||||
}
|
||||
|
|
@ -201,6 +227,16 @@ func appSecretKubeconfigFromConfig() (string, 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 != "" {
|
||||
value, err := os.ReadFile(appSecretFile)
|
||||
if err != nil {
|
||||
|
|
@ -214,3 +250,46 @@ func readAppSecretValue(cmd *cobra.Command) ([]byte, error) {
|
|||
}
|
||||
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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
originalLoad, originalSave := loadAppSecretConfig, saveAppSecretConfig
|
||||
originalConfig, originalEnvironment, originalSecrets, originalShared := appSecretConfigPath, appSecretGrantEnvironment, appSecretGrantSecrets, appSecretGrantShared
|
||||
|
|
|
|||
|
|
@ -24,6 +24,8 @@ var bootstrapInitializeOpenBaoRecovery bool
|
|||
var bootstrapInitializeOpenBao bool
|
||||
var bootstrapCreateForgejoRegistryToken bool
|
||||
var bootstrapCreateForgejoDeliveryStatusToken bool
|
||||
var bootstrapProvisionAppSecretIdentities bool
|
||||
var bootstrapE2EApp string
|
||||
var bootstrapRegisterWebhook bool
|
||||
var bootstrapRotateWebhookAuthorization 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(&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(&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(&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")
|
||||
|
|
@ -66,6 +70,19 @@ func init() {
|
|||
func runBootstrap(cmd *cobra.Command, args []string) error {
|
||||
var cfg config.Config
|
||||
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 bootstrapConfigPath == "" {
|
||||
return fmt.Errorf("--create-forgejo-registry-token requires --config")
|
||||
|
|
|
|||
|
|
@ -136,9 +136,10 @@ 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 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.
|
||||
2. Write its value with `cicd-tool app secret set` using stdin, `--file`, or
|
||||
`--generate`. Use a least-privilege `--token-file` or a bootstrap-provisioned
|
||||
`--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.
|
||||
4. Bootstrap or reconcile to create the OpenBao role and policy.
|
||||
5. Verify the target ExternalSecret becomes Ready without printing its Secret.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
- 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.
|
||||
|
||||
## 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.
|
||||
|
|
|
|||
|
|
@ -985,6 +985,23 @@ func InitializeOpenBao(cfg config.Config) error {
|
|||
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) {
|
||||
value := make([]byte, 32)
|
||||
if _, err := rand.Read(value); err != nil {
|
||||
|
|
|
|||
|
|
@ -20,6 +20,11 @@ import (
|
|||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
type AppSecretIdentityTokens struct {
|
||||
Admin string
|
||||
E2E string
|
||||
}
|
||||
|
||||
type status struct {
|
||||
Initialized bool `json:"initialized"`
|
||||
Sealed bool `json:"sealed"`
|
||||
|
|
@ -416,6 +421,61 @@ func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants [
|
|||
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 {
|
||||
diagnostic := redactOpenBaoDiagnostic(strings.TrimSpace(string(output)), sensitive...)
|
||||
if diagnostic == "" && err != nil {
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
original := execInPodMutation
|
||||
t.Cleanup(func() { execInPodMutation = original })
|
||||
|
|
|
|||
Loading…
Reference in a new issue