diff --git a/cmd/bootstrap.go b/cmd/bootstrap.go index 7cba1e4..ae56638 100644 --- a/cmd/bootstrap.go +++ b/cmd/bootstrap.go @@ -1,7 +1,9 @@ package cmd import ( + "errors" "fmt" + "strings" "github.com/Pingu-Studio/MaidnCLI/internal/bootstrap" "github.com/Pingu-Studio/MaidnCLI/internal/config" @@ -21,6 +23,7 @@ var bootstrapPromptOperationalSecrets bool var bootstrapInitializeOpenBaoRecovery bool var bootstrapInitializeOpenBao bool var bootstrapCreateForgejoRegistryToken bool +var bootstrapCreateForgejoDeliveryStatusToken bool var bootstrapRegisterWebhook bool var bootstrapRotateWebhookAuthorization bool var bootstrapMergeBootstrapPR bool @@ -29,6 +32,10 @@ var bootstrapEnableDelivery bool var bootstrapDestroyDemocraticCSIStorage bool var upsertOperationalSecret = bootstrap.UpsertOperationalSecret +var readOperationalSecrets = bootstrap.ReadOperationalSecrets +var initializeOpenBao = bootstrap.InitializeOpenBao +var createForgejoDeliveryStatusToken = forgejo.CreateDeliveryStatusToken +var promptForgejoDeliveryStatusToken = ui.PromptForgejoDeliveryStatusToken var bootstrapCmd = &cobra.Command{ Use: "bootstrap", 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(&bootstrapInitializeOpenBao, "initialize-openbao", false, "Initialize OpenBao and seed encrypted operational secrets for --config") bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoRegistryToken, "create-forgejo-registry-token", false, "Create a least-privilege Forgejo package registry token and seed it through OpenBao") + bootstrapCmd.Flags().BoolVar(&bootstrapCreateForgejoDeliveryStatusToken, "create-forgejo-delivery-status-token", false, "Create or reuse the Forgejo delivery-status token and seed it through OpenBao") bootstrapCmd.Flags().BoolVar(&bootstrapRegisterWebhook, "register-webhook", false, "Seed OpenBao secrets and register the Forgejo webhook") bootstrapCmd.Flags().BoolVar(&bootstrapRotateWebhookAuthorization, "rotate-webhook-authorization", false, "Replace the Forgejo webhook authorization and reconcile it through OpenBao") bootstrapCmd.Flags().BoolVar(&bootstrapMergeBootstrapPR, "merge-bootstrap-pr", false, "Merge the generated Flux repository migration PR before bootstrapping") @@ -75,6 +83,23 @@ func runBootstrap(cmd *cobra.Command, args []string) error { } 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 bootstrapConfigPath == "" { return fmt.Errorf("--rotate-webhook-authorization requires --config") @@ -239,3 +264,44 @@ func createForgejoRegistryToken(cfg config.Config) error { } 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) +} diff --git a/cmd/bootstrap_test.go b/cmd/bootstrap_test.go index 3342ea5..247031f 100644 --- a/cmd/bootstrap_test.go +++ b/cmd/bootstrap_test.go @@ -62,3 +62,72 @@ func TestSeedForgejoOperationalCredentialsHidesTokenOnUpsertFailure(t *testing.T 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) + } +} diff --git a/docs/delivery-feedback.md b/docs/delivery-feedback.md index 88bdc5e..e7b7e94 100644 --- a/docs/delivery-feedback.md +++ b/docs/delivery-feedback.md @@ -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 origin to add a PipelineRun link. -Before enabling delivery feedback, store a separate Forgejo token at -`cicd/forgejo-delivery-status.token` in encrypted operational secrets. Scope it -only to the onboarded application repositories and to creating/updating issue -comments; do not reuse the Git clone/push token. The generated task never -prints the token or Forgejo API responses. +Before enabling delivery feedback, create the separate Forgejo token with +`bootstrap --config --create-forgejo-delivery-status-token`. +It creates or reuses `maidn-delivery-status` at +`cicd/forgejo-delivery-status.token`, with only `write:issue` and +`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 and HTTPRoute, then performs a bounded HTTPS check. A production event reports diff --git a/docs/secrets.md b/docs/secrets.md index 8d6d237..4a7b497 100644 --- a/docs/secrets.md +++ b/docs/secrets.md @@ -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. -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 diff --git a/internal/forgejo/repo.go b/internal/forgejo/repo.go index df2a3a3..3b66932 100644 --- a/internal/forgejo/repo.go +++ b/internal/forgejo/repo.go @@ -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) { + 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) == "" { 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 { return "", err } @@ -148,10 +162,10 @@ func createRegistryToken(client *http.Client, baseURL, username, password, otp, } var token accessToken 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 == "" { - return "", fmt.Errorf("Forgejo did not return a registry token") + return "", fmt.Errorf("Forgejo did not return a token") } return token.SHA1, nil } diff --git a/internal/forgejo/repo_test.go b/internal/forgejo/repo_test.go index eb7f8b7..94b72ed 100644 --- a/internal/forgejo/repo_test.go +++ b/internal/forgejo/repo_test.go @@ -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) { original := copyGit t.Cleanup(func() { copyGit = original }) diff --git a/internal/ui/prompts.go b/internal/ui/prompts.go index d36a711..c8a5d0a 100644 --- a/internal/ui/prompts.go +++ b/internal/ui/prompts.go @@ -98,6 +98,17 @@ func PromptForgejoRegistryToken() (password, otp, name string, err error) { 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) { token := promptSecret(reader, prompt, "") if token == "" {