fix: provision Forgejo delivery status token #46

Merged
eding merged 1 commit from fix/delivery-status-token into main 2026-09-13 01:20:42 +02:00
7 changed files with 199 additions and 9 deletions

View file

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

View file

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

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

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

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

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) {
original := copyGit
t.Cleanup(func() { copyGit = original })

View file

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