feat: scope application secret grants
This commit is contained in:
parent
7038197757
commit
ac215f40ab
139
docs/secret-grants.md
Normal file
139
docs/secret-grants.md
Normal 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.
|
||||||
|
|
@ -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.
|
- 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.
|
- 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.
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,9 @@ metadata:
|
||||||
namespace: tekton-pipelines
|
namespace: tekton-pipelines
|
||||||
spec:
|
spec:
|
||||||
stepTemplate:
|
stepTemplate:
|
||||||
|
env:
|
||||||
|
- name: HOME
|
||||||
|
value: /tekton/home
|
||||||
securityContext:
|
securityContext:
|
||||||
runAsNonRoot: true
|
runAsNonRoot: true
|
||||||
runAsUser: 1000
|
runAsUser: 1000
|
||||||
|
|
@ -193,6 +196,8 @@ spec:
|
||||||
if [ -e "$app_dir/release.yaml" ]; then
|
if [ -e "$app_dir/release.yaml" ]; then
|
||||||
[ -d "$app_dir" ] && [ ! -L "$app_dir" ] && [ -f "$app_dir/release.yaml" ] && [ ! -L "$app_dir/release.yaml" ] || fail
|
[ -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"
|
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
|
else
|
||||||
[ ! -e "$app_dir" ] || fail
|
[ ! -e "$app_dir" ] || fail
|
||||||
mkdir -p "$app_dir"
|
mkdir -p "$app_dir"
|
||||||
|
|
@ -201,6 +206,7 @@ spec:
|
||||||
kind: HelmRelease
|
kind: HelmRelease
|
||||||
metadata:
|
metadata:
|
||||||
name: $APP_NAME
|
name: $APP_NAME
|
||||||
|
namespace: staging
|
||||||
spec:
|
spec:
|
||||||
interval: 5m
|
interval: 5m
|
||||||
chart:
|
chart:
|
||||||
|
|
@ -247,6 +253,7 @@ spec:
|
||||||
kind: HelmRelease
|
kind: HelmRelease
|
||||||
metadata:
|
metadata:
|
||||||
name: $APP_NAME
|
name: $APP_NAME
|
||||||
|
namespace: production
|
||||||
spec:
|
spec:
|
||||||
interval: 5m
|
interval: 5m
|
||||||
chart:
|
chart:
|
||||||
|
|
@ -351,23 +358,31 @@ spec:
|
||||||
default: {{ quote .ManifestsRepo }}
|
default: {{ quote .ManifestsRepo }}
|
||||||
workspaces:
|
workspaces:
|
||||||
- name: source
|
- name: source
|
||||||
taskRunTemplate:
|
|
||||||
serviceAccountName: tekton-delivery
|
|
||||||
tasks:
|
tasks:
|
||||||
- name: build-and-push
|
- name: clone
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-action)
|
- input: $(params.event-action)
|
||||||
operator: notin
|
operator: notin
|
||||||
values: [closed]
|
values: [closed]
|
||||||
taskRef:
|
taskRef:
|
||||||
name: maidn-node-static-image
|
name: maidn-git-clone
|
||||||
params:
|
params:
|
||||||
- name: url
|
- name: url
|
||||||
value: {{ quote .AppRepoURL }}
|
value: {{ quote .AppRepoURL }}
|
||||||
- name: revision
|
- name: revision
|
||||||
value: $(params.git-revision)
|
value: $(params.git-revision)
|
||||||
- name: image
|
workspaces:
|
||||||
value: $(params.image)
|
- 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
|
- name: output-directory
|
||||||
value: {{ quote .BuildOutputDirectory }}
|
value: {{ quote .BuildOutputDirectory }}
|
||||||
- name: build-configuration
|
- name: build-configuration
|
||||||
|
|
@ -375,8 +390,24 @@ spec:
|
||||||
workspaces:
|
workspaces:
|
||||||
- name: source
|
- name: source
|
||||||
workspace: 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
|
- name: update-preview
|
||||||
runAfter: [build-and-push]
|
runAfter: [push]
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-type)
|
- input: $(params.event-type)
|
||||||
operator: in
|
operator: in
|
||||||
|
|
@ -408,7 +439,7 @@ spec:
|
||||||
- name: app-revision
|
- name: app-revision
|
||||||
value: $(params.git-revision)
|
value: $(params.git-revision)
|
||||||
- name: update-staging
|
- name: update-staging
|
||||||
runAfter: [build-and-push]
|
runAfter: [push]
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-type)
|
- input: $(params.event-type)
|
||||||
operator: in
|
operator: in
|
||||||
|
|
@ -434,7 +465,7 @@ spec:
|
||||||
- name: environment
|
- name: environment
|
||||||
value: staging
|
value: staging
|
||||||
- name: promote-production
|
- name: promote-production
|
||||||
runAfter: [build-and-push]
|
runAfter: [push]
|
||||||
when:
|
when:
|
||||||
- input: $(params.event-type)
|
- input: $(params.event-type)
|
||||||
operator: in
|
operator: in
|
||||||
|
|
|
||||||
|
|
@ -55,6 +55,8 @@ type operationalSecrets struct {
|
||||||
|
|
||||||
var initializeOpenBao = openbao.Initialize
|
var initializeOpenBao = openbao.Initialize
|
||||||
|
|
||||||
|
var configureOpenBaoSecretGrants = openbao.ConfigureSecretGrants
|
||||||
|
|
||||||
var readOpenBaoRecovery = openbao.ReadRecoveryMaterial
|
var readOpenBaoRecovery = openbao.ReadRecoveryMaterial
|
||||||
|
|
||||||
var decryptGeneratedSOPS = decryptSOPSFile
|
var decryptGeneratedSOPS = decryptSOPSFile
|
||||||
|
|
@ -120,11 +122,22 @@ func terraformNoStateFile(stderr []byte) bool {
|
||||||
}
|
}
|
||||||
|
|
||||||
var destroyTalosVMs = func(terraformDir string, environment []string) error {
|
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 runTerraform = utils.RunCommandInDirEnv
|
||||||
|
|
||||||
|
var destroyTalosVMRetryDelay = 10 * time.Second
|
||||||
|
|
||||||
var webhookTargetTimeout = 70 * time.Minute
|
var webhookTargetTimeout = 70 * time.Minute
|
||||||
|
|
||||||
var webhookTargetPollInterval = 2 * time.Second
|
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) {
|
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.
|
// 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 {
|
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)
|
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
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1402,7 +1426,14 @@ func (r Runner) rebuildTalosVMs(terraformDir string, environment []string) error
|
||||||
}
|
}
|
||||||
return err
|
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 {
|
func (r Runner) importConfiguredTalosVMs(terraformDir string, environment []string) error {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestCiliumChartVersion(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "release.yaml")
|
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 {
|
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 {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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) {
|
if !strings.Contains(string(content), expected) {
|
||||||
t.Fatalf("generated delivery does not contain %q", 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) {
|
func TestRebuildTerraformSkipsAlreadyAbsentTalosVM(t *testing.T) {
|
||||||
originalVerify := verifyTalosVMs
|
originalVerify := verifyTalosVMs
|
||||||
originalRun := runTerraform
|
originalRun := runTerraform
|
||||||
|
|
|
||||||
|
|
@ -108,12 +108,11 @@ func publishInitialAppBranches(manager *forgejo.RepoManager, sourceDir, targetUR
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return fmt.Errorf("read target base branch: %w", err)
|
return fmt.Errorf("read target base branch: %w", err)
|
||||||
}
|
}
|
||||||
|
createdMain := mainRevision == ""
|
||||||
if mainRevision == "" {
|
if mainRevision == "" {
|
||||||
if err := manager.PushRef(sourceDir, targetURL, sourceRevision, targetBranch); err != nil {
|
if err := manager.PushRef(sourceDir, targetURL, sourceRevision, targetBranch); err != nil {
|
||||||
return fmt.Errorf("publish source base branch: %w", err)
|
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 {
|
if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil {
|
||||||
return fmt.Errorf("read target production branch: %w", err)
|
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)
|
return fmt.Errorf("create production from source base branch: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mainRevision, err = manager.RemoteBranchRevision(targetURL, targetBranch)
|
if createdMain {
|
||||||
if err != nil {
|
mainRevision, err = manager.RemoteBranchRevision(targetURL, targetBranch)
|
||||||
return fmt.Errorf("verify target base branch: %w", err)
|
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 mainRevision != sourceRevision {
|
||||||
|
return errors.New("target base branch does not match the validated source ref")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil {
|
if productionRevision, err := manager.RemoteBranchRevision(targetURL, productionBranch); err != nil {
|
||||||
return fmt.Errorf("verify target production branch: %w", err)
|
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")
|
registrationPath := filepath.Join(appsDir, cfg.Delivery.AppName+".yaml")
|
||||||
if existing, readErr := os.ReadFile(registrationPath); readErr == nil {
|
if existing, readErr := os.ReadFile(registrationPath); readErr == nil {
|
||||||
info, statErr := os.Lstat(registrationPath)
|
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")
|
return errors.New("Tekton app registration conflicts with unmanaged content")
|
||||||
}
|
}
|
||||||
} else if !os.IsNotExist(readErr) {
|
} else if !os.IsNotExist(readErr) {
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package bootstrap
|
package bootstrap
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
|
"io"
|
||||||
"os"
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
|
@ -9,6 +11,7 @@ import (
|
||||||
|
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
"github.com/Pingu-Studio/MaidnCLI/internal/forgejo"
|
||||||
|
"gopkg.in/yaml.v3"
|
||||||
)
|
)
|
||||||
|
|
||||||
func onboardingGit(t *testing.T, dir string, args ...string) string {
|
func onboardingGit(t *testing.T, dir string, args ...string) string {
|
||||||
|
|
@ -49,9 +52,20 @@ func TestGenerateAppDeliveryReplacesOnlyKnownGeneratedFiles(t *testing.T) {
|
||||||
t.Fatal(err)
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
pipeline, err := os.ReadFile(filepath.Join(tektonDir, "pipeline.yaml"))
|
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)
|
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 {
|
if err := os.WriteFile(filepath.Join(tektonDir, "custom.yaml"), []byte("custom: true\n"), 0644); err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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 {
|
if err := os.WriteFile(filepath.Join(tektonDir, "apps", "web-ui.yaml"), []byte("custom: true\n"), 0644); err != nil {
|
||||||
t.Fatal(err)
|
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 {
|
if got := onboardingGit(t, "", "--git-dir", preservedTarget, "rev-parse", "refs/heads/production"); got != existingProduction {
|
||||||
t.Fatalf("production = %s, want existing %s", 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)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -366,6 +366,9 @@ func Validate(cfg Config) error {
|
||||||
return err
|
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 == "" {
|
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")
|
return errors.New("all template repository URLs and refs are required")
|
||||||
}
|
}
|
||||||
|
|
@ -487,6 +490,39 @@ func Validate(cfg Config) error {
|
||||||
return nil
|
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.
|
// ValidateDelivery requires the complete app-delivery contract before rendering or publishing it.
|
||||||
func ValidateDelivery(cfg Config) error {
|
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 == "" {
|
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 == "" {
|
||||||
|
|
|
||||||
|
|
@ -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) {
|
func TestValidateRequiresDeliveryRepositoryOnForgejoOrigin(t *testing.T) {
|
||||||
cfg := validConfig(t)
|
cfg := validConfig(t)
|
||||||
cfg.Delivery.AppRepoURL = "https://attacker.example.test/test-org/web-ui.git"
|
cfg.Delivery.AppRepoURL = "https://attacker.example.test/test-org/web-ui.git"
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,19 @@ type Config struct {
|
||||||
Cilium CiliumConfig `yaml:"cilium"`
|
Cilium CiliumConfig `yaml:"cilium"`
|
||||||
DemocraticCSI DemocraticCSIConfig `yaml:"democraticCsi"`
|
DemocraticCSI DemocraticCSIConfig `yaml:"democraticCsi"`
|
||||||
Delivery DeliveryConfig `yaml:"delivery"`
|
Delivery DeliveryConfig `yaml:"delivery"`
|
||||||
|
SecretGrants []SecretGrant `yaml:"secretGrants,omitempty"`
|
||||||
SOPS SOPSConfig `yaml:"sops"`
|
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 {
|
type DemocraticCSIConfig struct {
|
||||||
TrueNASAPIKey string `yaml:"truenasApiKey"`
|
TrueNASAPIKey string `yaml:"truenasApiKey"`
|
||||||
TrueNASHost string `yaml:"truenasHost"`
|
TrueNASHost string `yaml:"truenasHost"`
|
||||||
|
|
|
||||||
|
|
@ -502,19 +502,27 @@ func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, d
|
||||||
if sourceDir == "" || sourceBranch == "" || deliveryBranch == "" || deliveryBranch == rm.Branch {
|
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")
|
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-*")
|
temporary, err := os.MkdirTemp("", "maidn-delivery-*")
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
defer os.RemoveAll(temporary)
|
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()
|
cleanupAskPass, environment, err := rm.gitEnvironment()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
defer cleanupAskPass()
|
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 {
|
if err := runGit(temporary, environment, "checkout", "-B", deliveryBranch); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
|
|
@ -535,22 +543,8 @@ func (rm *RepoManager) PublishDeliveryBranch(sourceDir, sourceBranch, repoURL, d
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
hasBranch, err := rm.HasRemoteBranch(repoURL, deliveryBranch)
|
if !changed {
|
||||||
if err != nil {
|
return false, 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 err := rm.PushBranch(temporary, repoURL, deliveryBranch); err != nil {
|
if err := rm.PushBranch(temporary, repoURL, deliveryBranch); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|
@ -598,8 +592,18 @@ func (rm *RepoManager) PublishRepositoryPullRequest(repo, title, branch, base st
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
}
|
}
|
||||||
if !changed && !hasBranch {
|
if !changed {
|
||||||
return false, nil
|
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 {
|
if err := rm.EnsurePullRequest(repo, title, branch, base); err != nil {
|
||||||
return false, err
|
return false, err
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import (
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
"gopkg.in/yaml.v3"
|
"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 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
|
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'
|
cat >/tmp/external-secrets.hcl <<'EOF'
|
||||||
path "secret/data/*" {
|
path "secret/data/platform/*" {
|
||||||
capabilities = ["read"]
|
capabilities = ["read"]
|
||||||
}
|
}
|
||||||
path "secret/metadata/*" {
|
path "secret/data/cicd/*" {
|
||||||
capabilities = ["list", "read"]
|
capabilities = ["read"]
|
||||||
|
}
|
||||||
|
path "secret/metadata/platform/*" {
|
||||||
|
capabilities = ["list", "read"]
|
||||||
|
}
|
||||||
|
path "secret/metadata/cicd/*" {
|
||||||
|
capabilities = ["list", "read"]
|
||||||
}
|
}
|
||||||
EOF
|
EOF
|
||||||
bao policy write external-secrets /tmp/external-secrets.hcl >/dev/null
|
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
|
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 {
|
func refreshExternalSecrets(kubeconfig string) error {
|
||||||
available, err := kubectlOutput(kubeconfig, "-n", "external-secrets", "get", "deployment/external-secrets", "-o=jsonpath={.status.conditions[?(@.type==\"Available\")].status}")
|
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" {
|
if err != nil || strings.TrimSpace(string(available)) != "True" {
|
||||||
|
|
|
||||||
|
|
@ -7,6 +7,8 @@ import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"github.com/Pingu-Studio/MaidnCLI/internal/config"
|
||||||
)
|
)
|
||||||
|
|
||||||
func TestEnsureRecoveryIdentity(t *testing.T) {
|
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) {
|
func TestReadRecoveryMaterialDecryptsAndValidatesBundle(t *testing.T) {
|
||||||
original := decryptRecovery
|
original := decryptRecovery
|
||||||
t.Cleanup(func() { decryptRecovery = original })
|
t.Cleanup(func() { decryptRecovery = original })
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue