Merge pull request 'fix: provision Forgejo delivery status token' (#46) from fix/delivery-status-token into main

This commit is contained in:
eding 2026-09-13 01:20:42 +02:00
commit c9a8e38914
7 changed files with 199 additions and 9 deletions

View file

@ -1,7 +1,9 @@
package cmd package cmd
import ( import (
"errors"
"fmt" "fmt"
"strings"
"github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap"
"github.com/Pingu-Studio/MaidnCLI/internal/config" "github.com/Pingu-Studio/MaidnCLI/internal/config"
@ -21,6 +23,7 @@ var bootstrapPromptOperationalSecrets bool
var bootstrapInitializeOpenBaoRecovery bool var bootstrapInitializeOpenBaoRecovery bool
var bootstrapInitializeOpenBao bool var bootstrapInitializeOpenBao bool
var bootstrapCreateForgejoRegistryToken bool var bootstrapCreateForgejoRegistryToken bool
var bootstrapCreateForgejoDeliveryStatusToken bool
var bootstrapRegisterWebhook bool var bootstrapRegisterWebhook bool
var bootstrapRotateWebhookAuthorization bool var bootstrapRotateWebhookAuthorization bool
var bootstrapMergeBootstrapPR bool var bootstrapMergeBootstrapPR bool
@ -29,6 +32,10 @@ var bootstrapEnableDelivery bool
var bootstrapDestroyDemocraticCSIStorage bool var bootstrapDestroyDemocraticCSIStorage bool
var upsertOperationalSecret = bootstrap.UpsertOperationalSecret var upsertOperationalSecret = bootstrap.UpsertOperationalSecret
var readOperationalSecrets = bootstrap.ReadOperationalSecrets
var initializeOpenBao = bootstrap.InitializeOpenBao
var createForgejoDeliveryStatusToken = forgejo.CreateDeliveryStatusToken
var promptForgejoDeliveryStatusToken = ui.PromptForgejoDeliveryStatusToken
var bootstrapCmd = &cobra.Command{ var bootstrapCmd = &cobra.Command{
Use: "bootstrap", Use: "bootstrap",
Short: "Bootstrap Talos and Flux from config or an interactive wizard.", Short: "Bootstrap Talos and Flux from config or an interactive wizard.",
@ -47,6 +54,7 @@ func init() {
bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBaoRecovery, "initialize-openbao-recovery", false, "Create and save a separate OpenBao recovery age identity for --config") bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBaoRecovery, "initialize-openbao-recovery", false, "Create and save a separate OpenBao recovery age identity for --config")
bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBao, "initialize-openbao", false, "Initialize OpenBao and seed encrypted operational secrets for --config") bootstrapCmd.Flags().BoolVar(&bootstrapInitializeOpenBao, "initialize-openbao", false, "Initialize OpenBao and seed encrypted operational secrets for --config")
bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao") bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao")
bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoDeliveryStatusToken, "create-forgejo-delivery-status-token", false, "Create or reuse the Forgejo delivery-status token and seed it through OpenBao")
bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook") bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook")
bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao") bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao")
bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping") bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping")
@ -75,6 +83,23 @@ func runBootstrap(cmd *cobra.Command, args []string) error {
} }
return createForgejoRegistryToken(cfg) return createForgejoRegistryToken(cfg)
} }
if bootstrapCreateForgejoDeliveryStatusToken {
if bootstrapConfigPath == "" {
return fmt.Errorf("--create-forgejo-delivery-status-token requires --config")
}
cfg, err = config.Load(bootstrapConfigPath)
if err != nil {
return err
}
cfg, err = config.ResolveDelivery(cfg)
if err != nil {
return err
}
if err := bootstrap.EnsureTemplateRevisions(cfg); err != nil {
return err
}
return createOrReuseForgejoDeliveryStatusToken(cfg)
}
if bootstrapRotateWebhookAuthorization { if bootstrapRotateWebhookAuthorization {
if bootstrapConfigPath == "" { if bootstrapConfigPath == "" {
return fmt.Errorf("--rotate-webhook-authorization requires --config") return fmt.Errorf("--rotate-webhook-authorization requires --config")
@ -239,3 +264,44 @@ func createForgejoRegistryToken(cfg config.Config) error {
} }
return nil return nil
} }
func createOrReuseForgejoDeliveryStatusToken(cfg config.Config) error {
secrets, err := readOperationalSecrets(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath)
if err != nil {
return err
}
values, found := secrets["cicd/forgejo-delivery-status"]
var token string
if found {
if len(values) != 1 || values["token"] == "" {
return errors.New("operational SOPS secrets has ambiguous cicd/forgejo-delivery-status state; refusing to create another token")
}
token = values["token"]
} else {
password, otp, err := promptForgejoDeliveryStatusToken()
if err != nil {
return err
}
token, err = createForgejoDeliveryStatusToken(cfg.Git.BaseURL, cfg.Git.Username, password, otp)
if err != nil {
return fmt.Errorf("create Forgejo delivery-status token: %w", redactCredentialError(err, password, otp))
}
if err := upsertOperationalSecret(cfg.SOPS.OperationalSecretsPath, cfg.SOPS.AgeKeyPath, "cicd/forgejo-delivery-status", "token", token); err != nil {
return errors.New("Forgejo delivery-status token was created but could not be saved; revoke the new token in Forgejo and retry")
}
}
if err := initializeOpenBao(cfg); err != nil {
return fmt.Errorf("Forgejo delivery-status token is in encrypted operational secrets but OpenBao seeding failed; rerun bootstrap with --config and --create-forgejo-delivery-status-token: %w", redactCredentialError(err, token))
}
return nil
}
func redactCredentialError(err error, sensitive ...string) error {
message := err.Error()
for _, value := range sensitive {
if value != "" {
message = strings.ReplaceAll(message, value, "[REDACTED]")
}
}
return errors.New(message)
}

View file

@ -62,3 +62,72 @@ func TestSeedForgejoOperationalCredentialsHidesTokenOnUpsertFailure(t *testing.T
t.Fatal("credential upsert failure was not clear and token-safe") t.Fatal("credential upsert failure was not clear and token-safe")
} }
} }
func TestCreateOrReuseForgejoDeliveryStatusTokenCreatesSeedsAndRedacts(t *testing.T) {
originalRead, originalPrompt, originalCreate, originalUpsert, originalInitialize := readOperationalSecrets, promptForgejoDeliveryStatusToken, createForgejoDeliveryStatusToken, upsertOperationalSecret, initializeOpenBao
t.Cleanup(func() {
readOperationalSecrets, promptForgejoDeliveryStatusToken, createForgejoDeliveryStatusToken, upsertOperationalSecret, initializeOpenBao = originalRead, originalPrompt, originalCreate, originalUpsert, originalInitialize
})
cfg := config.Config{Git: config.GitConfig{BaseURL: "https://git.example.test", Username: "delivery-bot"}, SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets.sops.yaml", AgeKeyPath: "age-key.txt"}}
readOperationalSecrets = func(_, _ string) (map[string]map[string]string, error) { return map[string]map[string]string{}, nil }
promptForgejoDeliveryStatusToken = func() (string, string, error) { return "password", "otp", nil }
createForgejoDeliveryStatusToken = func(baseURL, username, password, otp string) (string, error) {
if baseURL != cfg.Git.BaseURL || username != cfg.Git.Username || password != "password" || otp != "otp" {
t.Fatal("delivery token creation crossed an unexpected credential boundary")
}
return "delivery-token", nil
}
seeded := false
upsertOperationalSecret = func(path, agePath, secretPath, key, value string) error {
seeded = path == cfg.SOPS.OperationalSecretsPath && agePath == cfg.SOPS.AgeKeyPath && secretPath == "cicd/forgejo-delivery-status" && key == "token" && value == "delivery-token"
return nil
}
initializeOpenBao = func(config.Config) error { return errors.New("OpenBao rejected delivery-token") }
err := createOrReuseForgejoDeliveryStatusToken(cfg)
if !seeded || err == nil || strings.Contains(err.Error(), "delivery-token") {
t.Fatalf("delivery token create/seed failure leaked or skipped a credential: seeded=%t err=%v", seeded, err)
}
}
func TestCreateOrReuseForgejoDeliveryStatusTokenReusesAndReseeds(t *testing.T) {
originalRead, originalPrompt, originalCreate, originalUpsert, originalInitialize := readOperationalSecrets, promptForgejoDeliveryStatusToken, createForgejoDeliveryStatusToken, upsertOperationalSecret, initializeOpenBao
t.Cleanup(func() {
readOperationalSecrets, promptForgejoDeliveryStatusToken, createForgejoDeliveryStatusToken, upsertOperationalSecret, initializeOpenBao = originalRead, originalPrompt, originalCreate, originalUpsert, originalInitialize
})
cfg := config.Config{SOPS: config.SOPSConfig{OperationalSecretsPath: "secrets.sops.yaml", AgeKeyPath: "age-key.txt"}}
readOperationalSecrets = func(_, _ string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo-delivery-status": {"token": "existing-token"}}, nil
}
promptForgejoDeliveryStatusToken = func() (string, string, error) { t.Fatal("existing delivery token must not prompt"); return "", "", nil }
createForgejoDeliveryStatusToken = func(string, string, string, string) (string, error) {
t.Fatal("existing delivery token must not be recreated")
return "", nil
}
upsertOperationalSecret = func(string, string, string, string, string) error {
t.Fatal("existing delivery token must not be rewritten")
return nil
}
seeded := false
initializeOpenBao = func(received config.Config) error {
seeded = received.SOPS.OperationalSecretsPath == cfg.SOPS.OperationalSecretsPath
return nil
}
if err := createOrReuseForgejoDeliveryStatusToken(cfg); err != nil || !seeded {
t.Fatalf("delivery token reuse did not reseed OpenBao: seeded=%t err=%v", seeded, err)
}
}
func TestCreateOrReuseForgejoDeliveryStatusTokenRejectsAmbiguousState(t *testing.T) {
originalRead, originalPrompt := readOperationalSecrets, promptForgejoDeliveryStatusToken
t.Cleanup(func() { readOperationalSecrets, promptForgejoDeliveryStatusToken = originalRead, originalPrompt })
readOperationalSecrets = func(_, _ string) (map[string]map[string]string, error) {
return map[string]map[string]string{"cicd/forgejo-delivery-status": {"token": "", "legacy": "value"}}, nil
}
promptForgejoDeliveryStatusToken = func() (string, string, error) {
t.Fatal("ambiguous state must not create a replacement token")
return "", "", nil
}
if err := createOrReuseForgejoDeliveryStatusToken(config.Config{}); err == nil || !strings.Contains(err.Error(), "ambiguous") {
t.Fatalf("ambiguous delivery token state = %v", err)
}
}

View file

@ -6,11 +6,13 @@ URL, a redacted task-status summary, and the PipelineRun name. Set the optional
`delivery.tektonDashboardUrl` to a credential-free HTTPS Tekton Dashboard `delivery.tektonDashboardUrl` to a credential-free HTTPS Tekton Dashboard
origin to add a PipelineRun link. origin to add a PipelineRun link.
Before enabling delivery feedback, store a separate Forgejo token at Before enabling delivery feedback, create the separate Forgejo token with
`cicd/forgejo-delivery-status.token` in encrypted operational secrets. Scope it `bootstrap --config <private-bootstrap-config> --create-forgejo-delivery-status-token`.
only to the onboarded application repositories and to creating/updating issue It creates or reuses `maidn-delivery-status` at
comments; do not reuse the Git clone/push token. The generated task never `cicd/forgejo-delivery-status.token`, with only `write:issue` and
prints the token or Forgejo API responses. `write:repository` for pull-request comments and commit statuses. It does not
reuse the Git clone/push token. The generated task never prints the token or
Forgejo API responses.
Preview and staging feedback waits up to ten minutes for the app Deployment Preview and staging feedback waits up to ten minutes for the app Deployment
and HTTPRoute, then performs a bounded HTTPS check. A production event reports and HTTPRoute, then performs a bounded HTTPS check. A production event reports

View file

@ -12,7 +12,7 @@
`bootstrap` resolves and validates the configuration before reconciliation. Any bootstrap requires a readable SOPS age identity. `talos.autoBootstrapFlux: true` additionally requires an existing encrypted operational-secrets file and a nonempty recovery recipient. A non-webhook bootstrap additionally requires configured, existing recovery-identity and recovery-bundle files. `bootstrap` resolves and validates the configuration before reconciliation. Any bootstrap requires a readable SOPS age identity. `talos.autoBootstrapFlux: true` additionally requires an existing encrypted operational-secrets file and a nonempty recovery recipient. A non-webhook bootstrap additionally requires configured, existing recovery-identity and recovery-bundle files.
The webhook-only path requires a complete delivery contract, an approved configuration, and a readable SOPS age identity. It must reseed OpenBao and observe the refreshed `forgejo-webhook` target Secret plus the EventListener and Pipeline before Forgejo is changed. The registry-token and webhook-authorization flows also require an explicit configuration; they are live credential operations and are not offline-safe. The webhook-only path requires a complete delivery contract, an approved configuration, and a readable SOPS age identity. It must reseed OpenBao and observe the refreshed `forgejo-webhook` target Secret plus the EventListener and Pipeline before Forgejo is changed. The registry-token, delivery-status-token, and webhook-authorization flows also require an explicit configuration; they are live credential operations and are not offline-safe.
## Rules ## Rules

View file

@ -121,10 +121,24 @@ func CreateRegistryToken(baseURL, username, password, otp, name string) (string,
} }
func createRegistryToken(client *http.Client, baseURL, username, password, otp, name string) (string, error) { func createRegistryToken(client *http.Client, baseURL, username, password, otp, name string) (string, error) {
return createToken(client, baseURL, username, password, otp, name, []string{"read:package", "write:package"})
}
// CreateDeliveryStatusToken creates the dedicated token used only to publish
// commit statuses and pull-request comments.
func CreateDeliveryStatusToken(baseURL, username, password, otp string) (string, error) {
return createDeliveryStatusToken(&http.Client{Timeout: 15 * time.Second}, baseURL, username, password, otp)
}
func createDeliveryStatusToken(client *http.Client, baseURL, username, password, otp string) (string, error) {
return createToken(client, baseURL, username, password, otp, "maidn-delivery-status", []string{"write:issue", "write:repository"})
}
func createToken(client *http.Client, baseURL, username, password, otp, name string, scopes []string) (string, error) {
if strings.TrimSpace(baseURL) == "" || username == "" || password == "" || strings.TrimSpace(name) == "" { if strings.TrimSpace(baseURL) == "" || username == "" || password == "" || strings.TrimSpace(name) == "" {
return "", fmt.Errorf("Forgejo base URL, username, password, and token name are required") return "", fmt.Errorf("Forgejo base URL, username, password, and token name are required")
} }
body, err := json.Marshal(createTokenRequest{Name: name, Scopes: []string{"read:package", "write:package"}}) body, err := json.Marshal(createTokenRequest{Name: name, Scopes: scopes})
if err != nil { if err != nil {
return "", err return "", err
} }
@ -148,10 +162,10 @@ func createRegistryToken(client *http.Client, baseURL, username, password, otp,
} }
var token accessToken var token accessToken
if err := json.NewDecoder(response.Body).Decode(&token); err != nil { if err := json.NewDecoder(response.Body).Decode(&token); err != nil {
return "", fmt.Errorf("parse Forgejo registry token response: %w", err) return "", fmt.Errorf("parse Forgejo token response: %w", err)
} }
if token.SHA1 == "" { if token.SHA1 == "" {
return "", fmt.Errorf("Forgejo did not return a registry token") return "", fmt.Errorf("Forgejo did not return a token")
} }
return token.SHA1, nil return token.SHA1, nil
} }

View file

@ -41,6 +41,34 @@ func TestCreateRegistryTokenUsesBasicAuthAndPackageScopes(t *testing.T) {
} }
} }
func TestCreateDeliveryStatusTokenUsesOnlyStatusAndCommentScopes(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) {
if request.Method != http.MethodPost || request.URL.Path != "/api/v1/users/delivery-bot/tokens" {
t.Fatal("unexpected Forgejo delivery-token request")
}
username, password, ok := request.BasicAuth()
if !ok || username != "delivery-bot" || password != "password" || request.Header.Get("X-Forgejo-OTP") != "123456" {
t.Fatal("delivery token request did not use the supplied credentials through HTTP authentication")
}
var body createTokenRequest
if err := json.NewDecoder(request.Body).Decode(&body); err != nil {
t.Fatal(err)
}
if body.Name != "maidn-delivery-status" || !reflect.DeepEqual(body.Scopes, []string{"write:issue", "write:repository"}) {
t.Fatalf("delivery token request has unexpected privileges: %#v", body)
}
writer.Header().Set("Content-Type", "application/json")
writer.WriteHeader(http.StatusCreated)
_, _ = writer.Write([]byte(`{"sha1":"delivery-token"}`))
}))
defer server.Close()
token, err := createDeliveryStatusToken(server.Client(), server.URL, "delivery-bot", "password", "123456")
if err != nil || token != "delivery-token" {
t.Fatal("CreateDeliveryStatusToken() did not return the Forgejo token")
}
}
func TestEnsureRepositoryCopyUsesAskPassAndCredentialFreeGitArguments(t *testing.T) { func TestEnsureRepositoryCopyUsesAskPassAndCredentialFreeGitArguments(t *testing.T) {
original := copyGit original := copyGit
t.Cleanup(func() { copyGit = original }) t.Cleanup(func() { copyGit = original })

View file

@ -98,6 +98,17 @@ func PromptForgejoRegistryToken() (password, otp, name string, err error) {
return password, otp, name, nil return password, otp, name, nil
} }
// PromptForgejoDeliveryStatusToken collects credentials used only to create
// the fixed-purpose delivery-status token.
func PromptForgejoDeliveryStatusToken() (password, otp string, err error) {
password, err = promptHiddenRequired("Forgejo account password")
if err != nil {
return "", "", err
}
otp, err = promptHiddenOptional("Forgejo OTP (optional)")
return password, otp, err
}
func promptForToken(reader *bufio.Reader, prompt string) (string, error) { func promptForToken(reader *bufio.Reader, prompt string) (string, error) {
token := promptSecret(reader, prompt, "") token := promptSecret(reader, prompt, "")
if token == "" { if token == "" {