From ac215f40ab6f4dedfe329d0138e5c515f9e308c9 Mon Sep 17 00:00:00 2001 From: Maidn Date: Tue, 8 Sep 2026 15:24:10 +0200 Subject: [PATCH] feat: scope application secret grants --- docs/secret-grants.md | 139 ++++++++++++++++++ docs/secrets.md | 1 + .../templates/delivery-pipeline.yaml.tmpl | 49 ++++-- internal/bootstrap/bootstrap.go | 37 ++++- internal/bootstrap/bootstrap_test.go | 52 ++++++- internal/bootstrap/onboard.go | 19 +-- internal/bootstrap/onboard_test.go | 32 +++- internal/config/config.go | 36 +++++ internal/config/config_test.go | 15 ++ internal/config/types.go | 10 ++ internal/forgejo/repo.go | 46 +++--- internal/openbao/bootstrap.go | 53 ++++++- internal/openbao/bootstrap_test.go | 61 ++++++++ 13 files changed, 502 insertions(+), 48 deletions(-) create mode 100644 docs/secret-grants.md diff --git a/docs/secret-grants.md b/docs/secret-grants.md new file mode 100644 index 0000000..c2890b7 --- /dev/null +++ b/docs/secret-grants.md @@ -0,0 +1,139 @@ +# Application secret grants + +Maidn stores secret values in OpenBao. Git contains only references and access +policy. A repository does not get OpenBao access: one named workload identity +gets one reviewed grant. + +## Grant classes + +| Consumer | OpenBao path | Kubernetes namespace | Intended use | +| --- | --- | --- | --- | +| `build` | `apps//build/*` | `tekton-pipelines` | Read-only dependency credentials | +| `publish` | `apps//publish/*` | `tekton-pipelines` | One app's artifact repository credential | +| `runtime` | `apps//runtime//*` | `-` | Service runtime credentials | +| shared | `shared//*` | Granted consumer only | Deliberately shared broker, database, or API credentials | + +`build` code is repository-controlled. Anything granted to it is readable by a +pull request author. Do not grant deployment, production, Git write, or +administrator credentials to a build. + +## Bootstrap configuration + +Declare access in the private bootstrap configuration. This declaration has no +secret values and is reviewed with the platform configuration: + +```yaml +secretGrants: + - application: orders-api + consumer: publish + shared: + - internal-npm + - application: orders-api + consumer: runtime + environment: staging + shared: + - rabbitmq + - application: orders-api + consumer: runtime + environment: production + shared: + - rabbitmq +``` + +MaidnCLI validates application, consumer, environment, and shared-grant names. +It creates one OpenBao policy and Kubernetes-auth role for every declaration. +The role names are deterministic: + +```text +maidn--build +maidn--publish +maidn--runtime- +``` + +The policy permits only the consumer's own path and the exact `shared/` +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. + +## GitOps resources + +The application environment manifests create the matching ServiceAccount, +SecretStore, and ExternalSecret. These resources are reviewed GitOps content; +never create them with `kubectl apply`. + +For `orders-api` staging, use the matching identity and namespace: + +```yaml +apiVersion: v1 +kind: ServiceAccount +metadata: + name: maidn-orders-api-runtime-staging + namespace: orders-api-staging +--- +apiVersion: external-secrets.io/v1 +kind: SecretStore +metadata: + name: openbao-orders-api-staging + namespace: orders-api-staging +spec: + provider: + vault: + server: http://openbao.openbao.svc:8200 + path: secret + version: v2 + auth: + kubernetes: + mountPath: kubernetes + role: maidn-orders-api-runtime-staging + serviceAccountRef: + name: maidn-orders-api-runtime-staging +--- +apiVersion: external-secrets.io/v1 +kind: ExternalSecret +metadata: + name: orders-api-rabbitmq + namespace: orders-api-staging +spec: + refreshInterval: 1h + secretStoreRef: + name: openbao-orders-api-staging + kind: SecretStore + target: + name: orders-api-rabbitmq + creationPolicy: Owner + data: + - secretKey: password + remoteRef: + key: shared/rabbitmq + property: password +``` + +The workload references only `orders-api-rabbitmq` in its own namespace. Each +application/environment needs a separate namespace; do not put runtime +secrets in shared `staging` or `production` namespaces. + +## Artifact repositories + +Create one credential per application and artifact target. Its upstream +permissions must be limited to the exact package, hosted repository, or object +prefix. Examples: one npm scope, one Maven hosted repository, one OCI image, +or S3 `PutObject` for one prefix. + +Use `publish` for credentials needed to upload a completed artifact. Use +`build` only for credentials that a build must read, such as a private package +registry. A custom build upload is an exception: it exposes the token to build +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. +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. +6. On revocation, remove the grant and ExternalSecret, revoke the upstream + credential, then restart affected workloads. + +See [secrets.md](secrets.md) for encrypted operational-material rules and +[runbooks/credential-rotation.md](runbooks/credential-rotation.md) for rotation. diff --git a/docs/secrets.md b/docs/secrets.md index fc50b11..8d6d237 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -20,3 +20,4 @@ The webhook-only path requires a complete delivery contract, an approved configu - Never pass credential values in CLI arguments, URLs, logs, Git commits, generated config, tickets, or evidence. - 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. diff --git a/internal/assets/templates/delivery-pipeline.yaml.tmpl b/internal/assets/templates/delivery-pipeline.yaml.tmpl index 3a87362..5931293 100644 --- a/internal/assets/templates/delivery-pipeline.yaml.tmpl +++ b/internal/assets/templates/delivery-pipeline.yaml.tmpl @@ -5,6 +5,9 @@ metadata: namespace: tekton-pipelines spec: stepTemplate: + env: + - name: HOME + value: /tekton/home securityContext: runAsNonRoot: true runAsUser: 1000 @@ -193,6 +196,8 @@ spec: if [ -e "$app_dir/release.yaml" ]; then [ -d "$app_dir" ] && [ ! -L "$app_dir" ] && [ -f "$app_dir/release.yaml" ] && [ ! -L "$app_dir/release.yaml" ] || fail sed -i -E "s|^([[:space:]]*tag:).*|\1 $TAG|" "$app_dir/release.yaml" + grep -qxF " namespace: staging" "$app_dir/release.yaml" || sed -i "/^ name: $APP_NAME$/a\ namespace: staging" "$app_dir/release.yaml" + grep -qxF " namespace: staging" "$app_dir/release.yaml" || fail else [ ! -e "$app_dir" ] || fail mkdir -p "$app_dir" @@ -201,6 +206,7 @@ spec: kind: HelmRelease metadata: name: $APP_NAME + namespace: staging spec: interval: 5m chart: @@ -247,6 +253,7 @@ spec: kind: HelmRelease metadata: name: $APP_NAME + namespace: production spec: interval: 5m chart: @@ -351,23 +358,31 @@ spec: default: {{ quote .ManifestsRepo }} workspaces: - name: source - taskRunTemplate: - serviceAccountName: tekton-delivery tasks: - - name: build-and-push + - name: clone when: - input: $(params.event-action) operator: notin values: [closed] taskRef: - name: maidn-node-static-image + name: maidn-git-clone params: - name: url value: {{ quote .AppRepoURL }} - name: revision value: $(params.git-revision) - - name: image - value: $(params.image) + 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: output-directory value: {{ quote .BuildOutputDirectory }} - name: build-configuration @@ -375,8 +390,24 @@ spec: 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: update-preview - runAfter: [build-and-push] + runAfter: [push] when: - input: $(params.event-type) operator: in @@ -408,7 +439,7 @@ spec: - name: app-revision value: $(params.git-revision) - name: update-staging - runAfter: [build-and-push] + runAfter: [push] when: - input: $(params.event-type) operator: in @@ -434,7 +465,7 @@ spec: - name: environment value: staging - name: promote-production - runAfter: [build-and-push] + runAfter: [push] when: - input: $(params.event-type) operator: in diff --git a/internal/bootstrap/bootstrap.go b/internal/bootstrap/bootstrap.go index c77d2a5..d733e2d 100644 --- a/internal/bootstrap/bootstrap.go +++ b/internal/bootstrap/bootstrap.go @@ -55,6 +55,8 @@ type operationalSecrets struct { var initializeOpenBao = openbao.Initialize +var configureOpenBaoSecretGrants = openbao.ConfigureSecretGrants + var readOpenBaoRecovery = openbao.ReadRecoveryMaterial var decryptGeneratedSOPS = decryptSOPSFile @@ -120,11 +122,22 @@ func terraformNoStateFile(stderr []byte) bool { } var destroyTalosVMs = func(terraformDir string, environment []string) error { - return utils.RunCommandInDirEnv(terraformDir, environment, "terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm") + command := exec.Command("terraform", "destroy", "-auto-approve", "-input=false", "-target=proxmox_virtual_environment_vm.vm") + command.Dir = terraformDir + command.Env = append(os.Environ(), environment...) + var stderr bytes.Buffer + command.Stdout = os.Stdout + command.Stderr = io.MultiWriter(os.Stderr, &stderr) + if err := command.Run(); err != nil { + return fmt.Errorf("%w: %s", err, strings.TrimSpace(stderr.String())) + } + return nil } var runTerraform = utils.RunCommandInDirEnv +var destroyTalosVMRetryDelay = 10 * time.Second + var webhookTargetTimeout = 70 * time.Minute var webhookTargetPollInterval = 2 * time.Second @@ -392,7 +405,15 @@ func (r Runner) reconcileWebhook(generatedDir string) error { } func (r Runner) initializeOpenBaoForCluster(generatedDir string) (map[string]map[string]string, error) { - return initializeOpenBao(filepath.Join(generatedDir, "kubeconfig"), r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath) + kubeconfig := filepath.Join(generatedDir, "kubeconfig") + secrets, err := initializeOpenBao(kubeconfig, r.Config.SOPS.RecoveryRecipient, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SOPS.AgeKeyPath, r.Config.SOPS.OperationalSecretsPath) + if err != nil { + return nil, err + } + if err := configureOpenBaoSecretGrants(kubeconfig, r.Config.SOPS.RecoveryIdentityPath, r.Config.SOPS.RecoveryBundlePath, r.Config.SecretGrants); err != nil { + return nil, fmt.Errorf("configure OpenBao secret grants: %w", err) + } + return secrets, nil } // completeFluxBootstrap runs the post-Flux platform initialization only. @@ -946,6 +967,9 @@ func InitializeOpenBao(cfg config.Config) error { if _, err := initializeOpenBao(kubeconfig, cfg.SOPS.RecoveryRecipient, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SOPS.AgeKeyPath, cfg.SOPS.OperationalSecretsPath); err != nil { return fmt.Errorf("initialize OpenBao: %w", err) } + if err := configureOpenBaoSecretGrants(kubeconfig, cfg.SOPS.RecoveryIdentityPath, cfg.SOPS.RecoveryBundlePath, cfg.SecretGrants); err != nil { + return fmt.Errorf("configure OpenBao secret grants: %w", err) + } return nil } @@ -1402,7 +1426,14 @@ func (r Runner) rebuildTalosVMs(terraformDir string, environment []string) error } return err } - return destroyTalosVMs(terraformDir, environment) + for attempt := 0; attempt < 3; attempt++ { + err := destroyTalosVMs(terraformDir, environment) + if err == nil || !strings.Contains(err.Error(), "context deadline exceeded") || attempt == 2 { + return err + } + time.Sleep(destroyTalosVMRetryDelay) + } + return nil } func (r Runner) importConfiguredTalosVMs(terraformDir string, environment []string) error { diff --git a/internal/bootstrap/bootstrap_test.go b/internal/bootstrap/bootstrap_test.go index 1143e15..2f3a50c 100644 --- a/internal/bootstrap/bootstrap_test.go +++ b/internal/bootstrap/bootstrap_test.go @@ -41,6 +41,23 @@ func TestRenderCiliumConfig(t *testing.T) { } } +func TestInitializeOpenBaoConfiguresDeclaredSecretGrants(t *testing.T) { + originalInitialize, originalGrants := initializeOpenBao, configureOpenBaoSecretGrants + t.Cleanup(func() { initializeOpenBao, configureOpenBaoSecretGrants = originalInitialize, originalGrants }) + initializeOpenBao = func(_ string, _ string, _ string, _ string, _ string, _ string) (map[string]map[string]string, error) { + return map[string]map[string]string{"cicd/forgejo": {"token": "redacted"}}, nil + } + called := false + configureOpenBaoSecretGrants = func(kubeconfig, identity, bundle string, grants []config.SecretGrant) error { + 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"}}}} + if _, err := r.initializeOpenBaoForCluster("generated"); err != nil || !called { + t.Fatalf("secret grant configuration was not invoked: called=%t err=%v", called, err) + } +} + func TestCiliumChartVersion(t *testing.T) { path := filepath.Join(t.TempDir(), "release.yaml") if err := os.WriteFile(path, []byte("spec:\n chart:\n spec:\n version: test-version\n"), 0644); err != nil { @@ -101,7 +118,7 @@ func TestGeneratedDeliveryIsGenericAndUsesSafePreviewCleanupContract(t *testing. if err != nil { t.Fatal(err) } - for _, expected := range []string{"maidn-node-static-image", "maidn-preview-orphan-reconciler", "valid_pr_number()", "valid_commit()", "values: [promotion]", "values: [\"production\"]", "cmp -s \"$expected_marker\" \"$marker\"", "values: [closed]"} { + 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]"} { if !strings.Contains(string(content), expected) { t.Fatalf("generated delivery does not contain %q", expected) } @@ -1306,6 +1323,39 @@ func TestRebuildTerraformRetainsFullTalosVMLifecycle(t *testing.T) { } } +func TestRebuildTalosVMsRetriesOnlyDeadlineErrors(t *testing.T) { + originalDestroy := destroyTalosVMs + originalDelay := destroyTalosVMRetryDelay + t.Cleanup(func() { + destroyTalosVMs = originalDestroy + destroyTalosVMRetryDelay = originalDelay + }) + destroyTalosVMRetryDelay = 0 + + for _, test := range []struct { + name string + err error + calls int + }{ + {name: "deadline", err: fmt.Errorf("context deadline exceeded"), calls: 3}, + {name: "other error", err: fmt.Errorf("permission denied"), calls: 1}, + } { + t.Run(test.name, func(t *testing.T) { + calls := 0 + destroyTalosVMs = func(string, []string) error { + calls++ + return test.err + } + if err := (Runner{}).rebuildTalosVMs(t.TempDir(), nil); !errors.Is(err, test.err) { + t.Fatalf("rebuild Talos VMs error = %v, want %v", err, test.err) + } + if calls != test.calls { + t.Fatalf("destroy calls = %d, want %d", calls, test.calls) + } + }) + } +} + func TestRebuildTerraformSkipsAlreadyAbsentTalosVM(t *testing.T) { originalVerify := verifyTalosVMs originalRun := runTerraform diff --git a/internal/bootstrap/onboard.go b/internal/bootstrap/onboard.go index 3fb34ef..9314bee 100644 --- a/internal/bootstrap/onboard.go +++ b/internal/bootstrap/onboard.go @@ -108,12 +108,11 @@ func publishInitialAppBranches(manager *forgejo.RepoManager, sourceDir, targetUR if err != nil { return fmt.Errorf("read target base branch: %w", err) } + createdMain := mainRevision == "" if mainRevision == "" { if err := manager.PushRef(sourceDir, targetURL, sourceRevision, targetBranch); err != nil { return fmt.Errorf("publish source base branch: %w", err) } - } else if mainRevision != sourceRevision { - return errors.New("target base branch differs from the validated source ref; refusing to overwrite it") } if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil { return fmt.Errorf("read target production branch: %w", err) @@ -122,12 +121,14 @@ func publishInitialAppBranches(manager *forgejo.RepoManager, sourceDir, targetUR return fmt.Errorf("create production from source base branch: %w", err) } } - mainRevision, err = manager.RemoteBranchRevision(targetURL, targetBranch) - if err != nil { - return fmt.Errorf("verify target base branch: %w", err) - } - if mainRevision != sourceRevision { - return errors.New("target base branch does not match the validated source ref") + if createdMain { + mainRevision, err = manager.RemoteBranchRevision(targetURL, targetBranch) + if err != nil { + return fmt.Errorf("verify target base branch: %w", err) + } + if mainRevision != sourceRevision { + return errors.New("target base branch does not match the validated source ref") + } } if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil { return fmt.Errorf("verify target production branch: %w", err) @@ -178,7 +179,7 @@ func RegisterAppInCluster(dir string, cfg config.Config) error { registrationPath := filepath.Join(appsDir, cfg.Delivery.AppName+".yaml") if existing, readErr := os.ReadFile(registrationPath); readErr == nil { info, statErr := os.Lstat(registrationPath) - if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || !bytes.Equal(existing, content) { + if statErr != nil || info.Mode()&os.ModeSymlink != 0 || !info.Mode().IsRegular() || !bytes.Equal(bytes.ReplaceAll(existing, []byte("\r\n"), []byte("\n")), content) { return errors.New("Tekton app registration conflicts with unmanaged content") } } else if !os.IsNotExist(readErr) { diff --git a/internal/bootstrap/onboard_test.go b/internal/bootstrap/onboard_test.go index 5ab93f9..97f5c22 100644 --- a/internal/bootstrap/onboard_test.go +++ b/internal/bootstrap/onboard_test.go @@ -1,6 +1,8 @@ package bootstrap import ( + "bytes" + "io" "os" "os/exec" "path/filepath" @@ -9,6 +11,7 @@ import ( "github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/forgejo" + "gopkg.in/yaml.v3" ) func onboardingGit(t *testing.T, dir string, args ...string) string { @@ -49,9 +52,20 @@ func TestGenerateAppDeliveryReplacesOnlyKnownGeneratedFiles(t *testing.T) { t.Fatal(err) } pipeline, err := os.ReadFile(filepath.Join(tektonDir, "pipeline.yaml")) - if err != nil || !strings.Contains(string(pipeline), "https://git.example.test/test-org-2/web-ui.git") { + if err != nil || !strings.Contains(string(pipeline), "https://git.example.test/test-org-2/web-ui.git") || !strings.Contains(string(pipeline), "name: HOME\n value: /tekton/home") || !strings.Contains(string(pipeline), "grep -qxF \" namespace: staging\"") || !strings.Contains(string(pipeline), "namespace: production") || strings.Contains(string(pipeline), "taskRunTemplate:") { t.Fatalf("target-specific pipeline = %q, %v", pipeline, err) } + decoder := yaml.NewDecoder(bytes.NewReader(pipeline)) + for { + var document yaml.Node + err := decoder.Decode(&document) + if err == io.EOF { + break + } + if err != nil { + t.Fatalf("generated pipeline YAML: %v", err) + } + } if err := os.WriteFile(filepath.Join(tektonDir, "custom.yaml"), []byte("custom: true\n"), 0644); err != nil { t.Fatal(err) } @@ -82,6 +96,12 @@ func TestRegisterAppInClusterRendersManagedFluxSource(t *testing.T) { t.Fatalf("Kustomization %s does not include %s: %q, %v", path, resource, content, err) } } + if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), bytes.ReplaceAll(registration, []byte("\n"), []byte("\r\n")), 0644); err != nil { + t.Fatal(err) + } + if err := RegisterAppInCluster(dir, onboardingConfig()); err != nil { + t.Fatalf("CRLF registration was rejected: %v", err) + } if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), []byte("custom: true\n"), 0644); err != nil { t.Fatal(err) } @@ -141,4 +161,14 @@ func TestPublishInitialAppBranchesCreatesAndPreservesProduction(t *testing.T) { if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/production"); got != existingProduction { t.Fatalf("production = %s, want existing %s", got, existingProduction) } + if err := os.WriteFile(filepath.Join(source, "README.md"), []byte("updated source\n"), 0644); err != nil { + t.Fatal(err) + } + onboardingGit(t, source, "commit", "-am", "updated source") + if err := publishInitialAppBranches(manager, source, preservedTarget, "source", "main", "production"); err != nil { + t.Fatal(err) + } + if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/main"); got != sourceRevision { + t.Fatalf("main = %s, want existing %s", got, sourceRevision) + } } diff --git a/internal/config/config.go b/internal/config/config.go index 64c4b7a..09d1431 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -366,6 +366,9 @@ func Validate(cfg Config) error { return err } } + if err := ValidateSecretGrants(cfg.SecretGrants); err != nil { + return err + } if cfg.Templates.TalosRepoURL == "" || cfg.Templates.TalosRepoRef == "" || cfg.Templates.CICDRepoURL == "" || cfg.Templates.CICDRepoRef == "" || cfg.Templates.ManifestsRepoURL == "" || cfg.Templates.ManifestsRepoRef == "" || cfg.Templates.TektonCatalogRepoURL == "" || cfg.Templates.TektonCatalogRepoRef == "" { return errors.New("all template repository URLs and refs are required") } @@ -487,6 +490,39 @@ func Validate(cfg Config) error { return nil } +// ValidateSecretGrants prevents a configuration change from widening an +// application's OpenBao policy outside its own path or named shared grants. +func ValidateSecretGrants(grants []SecretGrant) error { + name := regexp.MustCompile(`^[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?$`) + seen := map[string]bool{} + for _, grant := range grants { + if !name.MatchString(grant.Application) { + return errors.New("secret grant application must be a lowercase DNS label") + } + if grant.Consumer != "build" && grant.Consumer != "publish" && grant.Consumer != "runtime" { + return errors.New("secret grant consumer must be build, publish, or runtime") + } + if grant.Consumer == "runtime" { + if grant.Environment != "staging" && grant.Environment != "production" { + return errors.New("runtime secret grant environment must be staging or production") + } + } else if grant.Environment != "" { + return errors.New("build and publish secret grants must not set environment") + } + key := grant.Application + "/" + grant.Consumer + "/" + grant.Environment + if seen[key] { + return errors.New("duplicate secret grant consumer") + } + seen[key] = true + for _, shared := range grant.Shared { + if !name.MatchString(shared) { + return errors.New("shared secret grant name must be a lowercase DNS label") + } + } + } + return nil +} + // ValidateDelivery requires the complete app-delivery contract before rendering or publishing it. func ValidateDelivery(cfg Config) error { if cfg.Delivery.AppName == "" || cfg.Delivery.AppRepoURL == "" || cfg.Delivery.AppRepoRef == "" || cfg.Delivery.ProductionBranch == "" || cfg.Delivery.ImageRepository == "" || cfg.Delivery.BuildOutputDirectory == "" || cfg.Delivery.BuildConfiguration == "" || cfg.Delivery.WebhookHostname == "" || cfg.Delivery.WebhookPath == "" { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 92d8fa9..d07f9d8 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -119,6 +119,21 @@ 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 { + 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", Shared: []string{"../platform"}}, + } { + if err := ValidateSecretGrants([]SecretGrant{grant}); err == nil { + t.Fatalf("invalid secret grant accepted: %#v", grant) + } + } +} + func TestValidateRequiresDeliveryRepositoryOnForgejoOrigin(t *testing.T) { cfg := validConfig(t) cfg.Delivery.AppRepoURL = "https://attacker.example.test/test-org/web-ui.git" diff --git a/internal/config/types.go b/internal/config/types.go index 9b7aa81..3168d07 100644 --- a/internal/config/types.go +++ b/internal/config/types.go @@ -12,9 +12,19 @@ type Config struct { Cilium CiliumConfig `yaml:"cilium"` DemocraticCSI DemocraticCSIConfig `yaml:"democraticCsi"` Delivery DeliveryConfig `yaml:"delivery"` + SecretGrants []SecretGrant `yaml:"secretGrants,omitempty"` 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. +type SecretGrant struct { + Application string `yaml:"application"` + Consumer string `yaml:"consumer"` + Environment string `yaml:"environment,omitempty"` + Shared []string `yaml:"shared,omitempty"` +} + type DemocraticCSIConfig struct { TrueNASAPIKey string `yaml:"truenasApiKey"` TrueNASHost string `yaml:"truenasHost"` diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index ed3cb62..dde234e 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -502,19 +502,27 @@ func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, d if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch { return false, fmt.Errorf("delivery source branch and dedicated delivery branch are required and must differ from the base branch") } + hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch) + if err != nil { + return false, err + } temporary, err := os.MkdirTemp("", "maidn-delivery-*") if err != nil { return false, err } defer os.RemoveAll(temporary) - if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil { - return false, err - } cleanupAskPass, environment, err := rm.gitEnvironment() if err != nil { return false, err } defer cleanupAskPass() + if hasBranch { + if err := runGit("", environment, "clone", "--branch", deliveryBranch, repoURL, temporary); err != nil { + return false, err + } + } else if err := runGit("", os.Environ(), "clone", "--no-local", "--branch", sourceBranch, sourceDir, temporary); err != nil { + return false, err + } if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil { return false, err } @@ -535,22 +543,8 @@ func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, d } } } - hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch) - if err != nil { - return false, err - } - if hasBranch { - if err := runGit(temporary, environment, "fetch", repoURL, "refs/heads/"+deliveryBranch); err != nil { - return false, err - } - different, err := gitDiffQuiet(temporary, environment, "HEAD", "FETCH_HEAD") - if err != nil { - return false, err - } - if !different { - return false, nil - } - return false, fmt.Errorf("dedicated delivery branch %q differs from generated content; refusing to overwrite it", deliveryBranch) + if !changed { + return false, nil } if err := rm.PushBranch(temporary, repoURL, deliveryBranch); err != nil { return false, err @@ -598,8 +592,18 @@ func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base st if err != nil { return false, err } - if !changed && !hasBranch { - return false, nil + if !changed { + if !hasBranch { + return false, nil + } + open, err := rm.HasOpenPullRequest(repo, branch) + if err != nil { + return false, err + } + if !open { + return false, nil + } + return true, rm.MergePullRequest(repo, branch) } if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil { return false, err diff --git a/internal/openbao/bootstrap.go b/internal/openbao/bootstrap.go index 5a061dc..f60d5cf 100644 --- a/internal/openbao/bootstrap.go +++ b/internal/openbao/bootstrap.go @@ -15,6 +15,7 @@ import ( "strings" "time" + "github.com/Pingu-Studio/MaidnCLI/internal/config" "gopkg.in/yaml.v3" ) @@ -305,11 +306,17 @@ bao secrets enable -path=secret kv-v2 >/dev/null 2>&1 || true bao auth enable kubernetes >/dev/null 2>&1 || true bao write auth/kubernetes/config token_reviewer_jwt="$reviewer_token" kubernetes_host="https://${KUBERNETES_SERVICE_HOST}:${KUBERNETES_SERVICE_PORT}" kubernetes_ca_cert=@/var/run/secrets/kubernetes.io/serviceaccount/ca.crt >/dev/null cat >/tmp/external-secrets.hcl <<'EOF' -path "secret/data/*" { - capabilities = ["read"] + path "secret/data/platform/*" { + capabilities = ["read"] } -path "secret/metadata/*" { - capabilities = ["list", "read"] +path "secret/data/cicd/*" { + capabilities = ["read"] +} +path "secret/metadata/platform/*" { + capabilities = ["list", "read"] +} +path "secret/metadata/cicd/*" { + capabilities = ["list", "read"] } EOF bao policy write external-secrets /tmp/external-secrets.hcl >/dev/null @@ -320,6 +327,44 @@ bao write auth/kubernetes/role/external-secrets bound_service_account_names=exte return err } +// ConfigureSecretGrants creates only OpenBao policies and Kubernetes auth +// roles. GitOps manifests create the matching SecretStores and ExternalSecrets. +func ConfigureSecretGrants(kubeconfig, identityPath, bundlePath string, grants []config.SecretGrant) error { + if err := config.ValidateSecretGrants(grants); err != nil { + return err + } + if len(grants) == 0 { + return nil + } + material, err := ReadRecoveryMaterial(identityPath, bundlePath) + if err != nil { + return err + } + var script strings.Builder + script.WriteString("read -r root_token\nexport BAO_TOKEN=\"$root_token\"\n") + 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 _, shared := range grant.Shared { + script.WriteString("path \"secret/data/shared/" + shared + "/*\" {\n capabilities = [\"read\"]\n}\n") + } + script.WriteString("EOF\n") + script.WriteString("bao policy write " + name + " /tmp/" + name + ".hcl >/dev/null\n") + script.WriteString("rm -f /tmp/" + name + ".hcl\n") + script.WriteString("bao write auth/kubernetes/role/" + name + " bound_service_account_names=" + name + " bound_service_account_namespaces=" + namespace + " policies=" + name + " ttl=1h >/dev/null\n") + } + _, err = execInPod(kubeconfig, []byte(material.RootToken+"\n"), "sh", "-ec", script.String()) + return err +} + func refreshExternalSecrets(kubeconfig string) error { available, err := kubectlOutput(kubeconfig, "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}") if err != nil || strings.TrimSpace(string(available)) != "True" { diff --git a/internal/openbao/bootstrap_test.go b/internal/openbao/bootstrap_test.go index 49ae22b..12c5fcc 100644 --- a/internal/openbao/bootstrap_test.go +++ b/internal/openbao/bootstrap_test.go @@ -7,6 +7,8 @@ import ( "path/filepath" "strings" "testing" + + "github.com/Pingu-Studio/MaidnCLI/internal/config" ) func TestEnsureRecoveryIdentity(t *testing.T) { @@ -110,6 +112,65 @@ func TestRefreshExternalSecretsSkipsWebhookBeforeTekton(t *testing.T) { } } +func TestConfigureSecretGrantsScopesApplicationAndSharedPaths(t *testing.T) { + originalDecrypt, originalExec := decryptRecovery, execInPod + t.Cleanup(func() { decryptRecovery, execInPod = originalDecrypt, originalExec }) + decryptRecovery = func(_, _ string) ([]byte, error) { + return []byte(`{"unseal_keys_b64":["share"],"unseal_threshold":1,"root_token":"root"}`), nil + } + var script string + execInPod = func(_ string, input []byte, args ...string) ([]byte, error) { + if string(input) != "root\n" || len(args) != 3 || args[0] != "sh" || args[1] != "-ec" { + t.Fatal("secret grant did not use root token through stdin") + } + script = args[2] + 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"}}, + } + 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/shared/artifact-cache/*`, + `secret/data/shared/rabbitmq/*`, + `bound_service_account_names=maidn-orders-api-publish`, + `bound_service_account_namespaces=orders-api-production`, + } { + if !strings.Contains(script, want) { + t.Fatalf("secret grant script missing %q: %s", want, script) + } + } + if strings.Contains(script, `secret/data/*`) { + t.Fatal("secret grant widened access to every OpenBao secret") + } +} + +func TestConfigureKubernetesAuthLimitsPlatformStore(t *testing.T) { + original := execInPod + t.Cleanup(func() { execInPod = original }) + var script string + execInPod = func(_ string, _ []byte, args ...string) ([]byte, error) { + script = args[len(args)-1] + return nil, nil + } + if err := configureKubernetesAuth("kubeconfig", "root", "reviewer"); err != nil { + t.Fatal(err) + } + for _, want := range []string{`secret/data/platform/*`, `secret/data/cicd/*`} { + if !strings.Contains(script, want) { + t.Fatalf("platform policy missing %q", want) + } + } + if strings.Contains(script, `secret/data/*`) { + t.Fatal("platform External Secrets role can read every secret") + } +} + func TestReadRecoveryMaterialDecryptsAndValidatesBundle(t *testing.T) { original := decryptRecovery t.Cleanup(func() { decryptRecovery = original })