Merge pull request 'feat: scope application secret grants' (#33) from fix/fresh-delivery-contract into main

Reviewed-on: #33
This commit is contained in:
eding 2026-09-08 15:34:16 +02:00
commit 461e4a02ab
13 changed files with 502 additions and 48 deletions

139
docs/secret-grants.md Normal file
View file

@ -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/<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 |
| 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
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-<app>-build
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.
## 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.

View file

@ -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.

View file

@ -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

View file

@ -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 {

View file

@ -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

View file

@ -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) {

View file

@ -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)
}
}

View file

@ -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 == "" {

View file

@ -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"

View file

@ -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"`

View file

@ -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

View file

@ -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" {

View file

@ -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 })