diff --git a/internal/cli/admin.go b/internal/cli/admin.go index 5f083dd0f..92e3b4df5 100644 --- a/internal/cli/admin.go +++ b/internal/cli/admin.go @@ -79,9 +79,15 @@ func validateOrgName(org string) error { if org == "" { return fmt.Errorf("organization name cannot be empty") } + if len(org) > 39 { + return fmt.Errorf("organization name too long (max 39 characters)") + } if strings.HasPrefix(org, "-") || strings.HasSuffix(org, "-") { return fmt.Errorf("organization name cannot start or end with a hyphen") } + if strings.Contains(org, "--") { + return fmt.Errorf("organization name cannot contain consecutive hyphens") + } for _, c := range org { if !((c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') || (c >= '0' && c <= '9') || c == '-') { return fmt.Errorf("organization name contains invalid character: %c", c) diff --git a/internal/cli/mint.go b/internal/cli/mint.go new file mode 100644 index 000000000..d38cd21e0 --- /dev/null +++ b/internal/cli/mint.go @@ -0,0 +1,1022 @@ +package cli + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strconv" + "strings" + + "github.com/spf13/cobra" + "golang.org/x/term" + + "github.com/fullsend-ai/fullsend/internal/config" + "github.com/fullsend-ai/fullsend/internal/dispatch/gcf" + "github.com/fullsend-ai/fullsend/internal/ui" +) + +// defaultMintRoles returns the default roles for mint enrollment. +// The "fix" role is an alias for "coder" (same app, same PEM) and is +// not a separate enrollment target. +func defaultMintRoles() []string { + return config.DefaultAgentRoles() +} + +// roleAlias maps role aliases to their canonical names. +// The fix role reuses the coder app — same PEM, same app ID. +var roleAlias = map[string]string{ + "fix": "coder", +} + +// resolveRole returns the canonical role name, resolving aliases. +func resolveRole(role string) string { + if canonical, ok := roleAlias[role]; ok { + return canonical + } + return role +} + +func newMintCmd() *cobra.Command { + cmd := &cobra.Command{ + Use: "mint", + Short: "Manage token mint infrastructure (requires GCP access)", + Long: `Manage the GCP Cloud Function that mints GitHub App installation tokens. + +These commands require GCP project access but do NOT require a GitHub token. +Use 'fullsend admin install' for GitHub-side setup.`, + } + cmd.AddCommand(newMintDeployCmd()) + cmd.AddCommand(newMintEnrollCmd()) + cmd.AddCommand(newMintUnenrollCmd()) + cmd.AddCommand(newMintStatusCmd()) + return cmd +} + +func newMintDeployCmd() *cobra.Command { + var project string + var region string + var sourceDir string + var skipDeploy bool + var dryRun bool + + cmd := &cobra.Command{ + Use: "deploy", + Short: "Deploy or update the token mint Cloud Function", + Long: `Deploys the fullsend-mint Cloud Function and supporting GCP infrastructure +(service account, WIF pool/provider). Does NOT enroll any org — use +'fullsend mint enroll' after deployment.`, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + if project == "" { + return fmt.Errorf("--project is required") + } + if !gcf.ValidateProjectID(project) { + return fmt.Errorf("invalid GCP project ID: %q", project) + } + if !gcf.ValidateRegion(region) { + return fmt.Errorf("invalid GCP region: %q", region) + } + + printer := ui.New(os.Stdout) + ctx := cmd.Context() + + printer.Banner() + printer.Blank() + printer.Header("Deploying token mint") + printer.Blank() + + if dryRun { + printer.StepInfo("Dry run — no changes will be made") + printer.Blank() + printer.StepInfo(fmt.Sprintf("Would deploy mint to project %s, region %s", project, region)) + if sourceDir != "" { + printer.StepInfo(fmt.Sprintf("Source directory: %s", sourceDir)) + } else { + printer.StepInfo("Source: embedded mint function") + } + if skipDeploy { + printer.StepInfo("Would skip code deployment (--skip-deploy)") + } + return nil + } + + gcpClient := gcf.NewLiveGCFClient() + + if sourceDir == "" { + sourceDir = gcf.DefaultFunctionSourceDir() + } + + deployMode := gcf.DeployAuto + if skipDeploy { + deployMode = gcf.DeploySkip + } + + // Deploy requires at least a placeholder org for the WIF condition. + // The actual orgs are registered via 'mint enroll'. + provisioner := gcf.NewProvisioner(gcf.Config{ + ProjectID: project, + Region: region, + GitHubOrgs: []string{gcf.PlaceholderOrg}, + AgentAppIDs: map[string]string{gcf.PlaceholderOrg: "0"}, + FunctionSourceDir: sourceDir, + DeployMode: deployMode, + }, gcpClient) + + printer.StepStart("Provisioning mint infrastructure") + result, err := provisioner.Provision(ctx) + if err != nil { + printer.StepFail("Mint deployment failed") + return fmt.Errorf("deploying mint: %w", err) + } + + mintURL := result["FULLSEND_MINT_URL"] + printer.StepDone(fmt.Sprintf("Mint deployed at %s", mintURL)) + printer.Blank() + printer.Summary("Deployment complete", []string{ + fmt.Sprintf("Project: %s", project), + fmt.Sprintf("Region: %s", region), + fmt.Sprintf("URL: %s", mintURL), + "Next: fullsend mint enroll --project=" + project, + }) + + return nil + }, + } + + cmd.Flags().StringVar(&project, "project", "", "GCP project ID (required)") + cmd.Flags().StringVar(®ion, "region", "us-central1", "GCP region for the Cloud Function") + cmd.Flags().StringVar(&sourceDir, "source-dir", "", "path to local mint source (default: embedded)") + cmd.Flags().BoolVar(&skipDeploy, "skip-deploy", false, "skip code upload, reuse existing function") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without making them") + + return cmd +} + +func newMintEnrollCmd() *cobra.Command { + var project string + var region string + var sourceOrg string + var roleAppIDs string + var roles string + var dryRun bool + + cmd := &cobra.Command{ + Use: "enroll ", + Short: "Enroll an org or repo in the token mint", + Long: `Performs full enrollment of an organization or per-repo into an existing mint. + +Per-org enrollment (fullsend mint enroll acme): + - Copies PEM secrets from the source org + - Registers the org in ALLOWED_ORGS and ROLE_APP_IDS + - Re-derives ALLOWED_ROLES + +Per-repo enrollment (fullsend mint enroll acme/widget): + - Same as per-org plus: + - Adds repo to PER_REPO_WIF_REPOS + - Creates a dedicated WIF provider for the repo`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if project == "" { + return fmt.Errorf("--project is required") + } + if !gcf.ValidateProjectID(project) { + return fmt.Errorf("invalid GCP project ID: %q", project) + } + if !gcf.ValidateRegion(region) { + return fmt.Errorf("invalid GCP region: %q", region) + } + + arg := args[0] + printer := ui.New(os.Stdout) + ctx := cmd.Context() + + // Parse roles. + roleList, err := parseAndResolveRoles(roles) + if err != nil { + return err + } + + printer.Banner() + printer.Blank() + + if strings.Contains(arg, "/") { + return runMintEnrollRepo(ctx, printer, arg, project, region, sourceOrg, roleAppIDs, roleList, dryRun) + } + return runMintEnrollOrg(ctx, printer, arg, project, region, sourceOrg, roleAppIDs, roleList, dryRun) + }, + } + + cmd.Flags().StringVar(&project, "project", "", "GCP project ID (required)") + cmd.Flags().StringVar(®ion, "region", "us-central1", "GCP region") + cmd.Flags().StringVar(&sourceOrg, "source-org", "fullsend-ai", "org to copy PEMs and app IDs from") + cmd.Flags().StringVar(&roleAppIDs, "role-app-ids", "", "explicit JSON map of role app IDs (overrides --source-org)") + cmd.Flags().StringVar(&roles, "roles", strings.Join(defaultMintRoles(), ","), "comma-separated roles to enroll") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without making them") + + return cmd +} + +// parseAndResolveRoles splits a comma-separated roles string, validates, +// and resolves aliases (e.g., fix -> coder). Deduplicates after resolution. +func parseAndResolveRoles(rolesStr string) ([]string, error) { + raw, err := parseAgentRoles(rolesStr) + if err != nil { + return nil, err + } + seen := make(map[string]bool) + var resolved []string + for _, role := range raw { + canonical := resolveRole(role) + if !seen[canonical] { + seen[canonical] = true + resolved = append(resolved, canonical) + } + } + sort.Strings(resolved) + return resolved, nil +} + +func runMintEnrollOrg(ctx context.Context, printer *ui.Printer, org, project, region, sourceOrg, roleAppIDsJSON string, roleList []string, dryRun bool) error { + org = strings.ToLower(org) + sourceOrg = strings.ToLower(sourceOrg) + if err := validateOrgName(org); err != nil { + return err + } + if org == gcf.PlaceholderOrg { + return fmt.Errorf("cannot enroll reserved placeholder org %q", org) + } + if err := validateOrgName(sourceOrg); err != nil { + return fmt.Errorf("invalid --source-org: %w", err) + } + if org == sourceOrg { + return fmt.Errorf("target org %q is the same as --source-org; nothing to enroll", org) + } + + printer.Header("Enrolling org " + org + " in mint") + printer.Blank() + + gcpClient := gcf.NewLiveGCFClient() + provisioner := gcf.NewProvisioner(gcf.Config{ + ProjectID: project, + Region: region, + GitHubOrgs: []string{org}, + }, gcpClient) + + // Step 1: Discover existing mint. + printer.StepStart("Discovering mint infrastructure") + discovery, err := provisioner.DiscoverMint(ctx) + if err != nil { + printer.StepFail("Mint discovery failed") + return fmt.Errorf("mint not found in project %s region %s: %w", project, region, err) + } + printer.StepDone(fmt.Sprintf("Found mint at %s", discovery.URL)) + + // Step 2: Resolve role->app-id mappings. + appIDs, err := resolveEnrollAppIDs(roleAppIDsJSON, discovery.RoleAppIDs, sourceOrg, org, roleList) + if err != nil { + return fmt.Errorf("resolving app IDs: %w", err) + } + + if dryRun { + printer.Blank() + printer.StepInfo("Dry run — no changes will be made") + printer.Blank() + for _, role := range roleList { + key := org + "/" + role + if id, ok := appIDs[key]; ok { + printer.StepInfo(fmt.Sprintf(" Would set ROLE_APP_IDS[%s] = %s", key, id)) + } + } + printer.StepInfo(fmt.Sprintf(" Would add %s to ALLOWED_ORGS", org)) + printer.StepInfo(fmt.Sprintf(" Would copy PEMs from %s for %d roles", sourceOrg, len(roleList))) + printer.StepInfo(fmt.Sprintf(" Would grant roles/aiplatform.user to %s/.fullsend", org)) + printer.StepInfo(fmt.Sprintf(" Would update WIF condition to include %s", org)) + return nil + } + + // Step 3: Copy PEM secrets from source org. + for _, role := range roleList { + exists, existsErr := provisioner.SecretExists(ctx, org, role) + if existsErr != nil { + return fmt.Errorf("checking PEM for %s/%s: %w", org, role, existsErr) + } + if exists { + printer.StepDone(fmt.Sprintf("PEM exists: %s/%s", org, role)) + continue + } + printer.StepStart(fmt.Sprintf("Copying PEM for %s/%s from %s", org, role, sourceOrg)) + if err := provisioner.CopyAgentPEM(ctx, sourceOrg, org, role); err != nil { + printer.StepFail(fmt.Sprintf("Failed to copy PEM for %s", role)) + return fmt.Errorf("copying PEM for %s/%s: %w", org, role, err) + } + printer.StepDone(fmt.Sprintf("Copied PEM for %s/%s", org, role)) + } + + // Step 4: Register org in mint env vars. + printer.StepStart("Registering org in mint") + if err := provisioner.EnsureOrgInMint(ctx, discovery.URL, org, appIDs); err != nil { + printer.StepFail("Failed to register org") + return fmt.Errorf("registering org: %w", err) + } + printer.StepDone("Org registered in mint") + + // Step 5: Grant Vertex AI access. + printer.StepStart("Granting Vertex AI access") + if err := provisioner.GrantOrgVertexAIAccess(ctx, org); err != nil { + printer.StepFail("Failed to grant Vertex AI access") + return fmt.Errorf("granting Vertex AI access: %w", err) + } + printer.StepDone("Vertex AI access granted (propagation may take several minutes)") + + // Step 6: Update WIF provider condition to include this org. + printer.StepStart("Updating WIF provider condition") + if err := provisioner.EnsureOrgInWIFCondition(ctx, org); err != nil { + printer.StepFail("Failed to update WIF condition") + return fmt.Errorf("updating WIF condition: %w", err) + } + printer.StepDone("WIF condition updated") + + printer.Blank() + printer.Summary("Enrollment complete", []string{ + fmt.Sprintf("Organization: %s", org), + fmt.Sprintf("Roles: %s", strings.Join(roleList, ", ")), + fmt.Sprintf("Mint URL: %s", discovery.URL), + fmt.Sprintf("Next: fullsend admin install %s --mint-url=%s --skip-mint-check", org, discovery.URL), + }) + + return nil +} + +func runMintEnrollRepo(ctx context.Context, printer *ui.Printer, repoFullName, project, region, sourceOrg, roleAppIDsJSON string, roleList []string, dryRun bool) error { + sourceOrg = strings.ToLower(sourceOrg) + if err := validateOrgName(sourceOrg); err != nil { + return fmt.Errorf("invalid --source-org: %w", err) + } + repoFullName = strings.ToLower(repoFullName) + parts := strings.SplitN(repoFullName, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("repo must be in owner/repo format, got %q", repoFullName) + } + owner, repo := parts[0], parts[1] + if err := validateOrgName(owner); err != nil { + return fmt.Errorf("invalid owner: %w", err) + } + if owner == gcf.PlaceholderOrg { + return fmt.Errorf("cannot enroll reserved placeholder org %q", owner) + } + if !gcf.ValidateRepoSlug(repo) { + return fmt.Errorf("invalid repo name: %q", repo) + } + + printer.Header("Enrolling repo " + repoFullName + " in mint") + printer.Blank() + + gcpClient := gcf.NewLiveGCFClient() + provisioner := gcf.NewProvisioner(gcf.Config{ + ProjectID: project, + Region: region, + GitHubOrgs: []string{owner}, + Repo: repoFullName, + }, gcpClient) + + // Step 1: Discover existing mint. + printer.StepStart("Discovering mint infrastructure") + discovery, err := provisioner.DiscoverMint(ctx) + if err != nil { + printer.StepFail("Mint discovery failed") + return fmt.Errorf("mint not found in project %s region %s: %w", project, region, err) + } + printer.StepDone(fmt.Sprintf("Found mint at %s", discovery.URL)) + + // Step 2: Resolve role->app-id mappings. + appIDs, err := resolveEnrollAppIDs(roleAppIDsJSON, discovery.RoleAppIDs, sourceOrg, owner, roleList) + if err != nil { + return fmt.Errorf("resolving app IDs: %w", err) + } + + if dryRun { + printer.Blank() + printer.StepInfo("Dry run — no changes will be made") + printer.Blank() + for _, role := range roleList { + key := owner + "/" + role + if id, ok := appIDs[key]; ok { + printer.StepInfo(fmt.Sprintf(" Would set ROLE_APP_IDS[%s] = %s", key, id)) + } + } + printer.StepInfo(fmt.Sprintf(" Would add %s to ALLOWED_ORGS", owner)) + printer.StepInfo(fmt.Sprintf(" Would copy PEMs from %s for %d roles", sourceOrg, len(roleList))) + printer.StepInfo(fmt.Sprintf(" Would add %s to PER_REPO_WIF_REPOS", repoFullName)) + printer.StepInfo(fmt.Sprintf(" Would create WIF provider: %s", gcf.BuildRepoProviderID(owner, repo))) + return nil + } + + // Step 3: Copy PEM secrets. + for _, role := range roleList { + exists, existsErr := provisioner.SecretExists(ctx, owner, role) + if existsErr != nil { + return fmt.Errorf("checking PEM for %s/%s: %w", owner, role, existsErr) + } + if exists { + printer.StepDone(fmt.Sprintf("PEM exists: %s/%s", owner, role)) + continue + } + printer.StepStart(fmt.Sprintf("Copying PEM for %s/%s from %s", owner, role, sourceOrg)) + if err := provisioner.CopyAgentPEM(ctx, sourceOrg, owner, role); err != nil { + printer.StepFail(fmt.Sprintf("Failed to copy PEM for %s", role)) + return fmt.Errorf("copying PEM for %s/%s: %w", owner, role, err) + } + printer.StepDone(fmt.Sprintf("Copied PEM for %s/%s", owner, role)) + } + + // Step 4: Register org in mint env vars. + printer.StepStart("Registering org in mint") + if err := provisioner.EnsureOrgInMint(ctx, discovery.URL, owner, appIDs); err != nil { + printer.StepFail("Failed to register org") + return fmt.Errorf("registering org: %w", err) + } + printer.StepDone("Org registered in mint") + + // Step 5: Register per-repo WIF. + printer.StepStart("Registering per-repo WIF") + if err := provisioner.RegisterPerRepoWIF(ctx, repoFullName); err != nil { + printer.StepFail("Failed to register per-repo WIF") + return fmt.Errorf("registering per-repo WIF: %w", err) + } + printer.StepDone("Per-repo WIF registered") + + // Step 6: Provision per-repo WIF provider. + printer.StepStart("Provisioning WIF provider for " + repoFullName) + wifProvider, err := provisioner.ProvisionWIF(ctx) + if err != nil { + printer.StepFail("WIF provisioning failed") + return fmt.Errorf("provisioning WIF for %s: %w", repoFullName, err) + } + printer.StepDone("WIF provider created") + + printer.Blank() + printer.Summary("Enrollment complete", []string{ + fmt.Sprintf("Repository: %s", repoFullName), + fmt.Sprintf("Roles: %s", strings.Join(roleList, ", ")), + fmt.Sprintf("Mint URL: %s", discovery.URL), + fmt.Sprintf("WIF provider: %s", wifProvider), + }) + + return nil +} + +// resolveEnrollAppIDs builds the org-scoped ROLE_APP_IDS map for enrollment. +// If roleAppIDsJSON is provided, it is used directly. Otherwise, app IDs are +// resolved from the existing mint's ROLE_APP_IDS using the source org. +func resolveEnrollAppIDs(roleAppIDsJSON string, existingIDs map[string]string, sourceOrg, targetOrg string, roleList []string) (map[string]string, error) { + result := make(map[string]string, len(roleList)) + + if roleAppIDsJSON != "" { + // Explicit JSON map provided. + var explicit map[string]string + if err := json.Unmarshal([]byte(roleAppIDsJSON), &explicit); err != nil { + return nil, fmt.Errorf("parsing --role-app-ids: %w", err) + } + // Build org-scoped keys from explicit map, resolving aliases. + // Detect duplicate canonical roles (e.g., both "fix" and "coder" resolve to "coder"). + seen := make(map[string]string) // canonical -> original key + for role, appID := range explicit { + if appID == "" { + return nil, fmt.Errorf("--role-app-ids: empty app ID for role %q", role) + } + n, err := strconv.Atoi(appID) + if err != nil || n <= 0 { + return nil, fmt.Errorf("--role-app-ids: app ID for role %q must be a positive integer, got %q", role, appID) + } + canonical := resolveRole(role) + if prev, dup := seen[canonical]; dup && prev != role { + a, b := prev, role + if a > b { + a, b = b, a + } + return nil, fmt.Errorf("--role-app-ids has conflicting entries: %q and %q both resolve to %q", a, b, canonical) + } + seen[canonical] = role + result[targetOrg+"/"+canonical] = appID + } + // Validate that every requested role has an app ID entry. + for _, role := range roleList { + key := targetOrg + "/" + role + if _, ok := result[key]; !ok { + return nil, fmt.Errorf("--role-app-ids missing entry for required role %q", role) + } + } + // Reject extra roles not in roleList to prevent silent ALLOWED_ROLES expansion. + roleSet := make(map[string]bool, len(roleList)) + for _, r := range roleList { + roleSet[r] = true + } + for canonical := range seen { + if !roleSet[canonical] { + return nil, fmt.Errorf("--role-app-ids contains unexpected role %q not in --roles", canonical) + } + } + return result, nil + } + + // Resolve from existing ROLE_APP_IDS using the source org. + if len(existingIDs) == 0 { + return nil, fmt.Errorf("no existing ROLE_APP_IDS found in mint — use --role-app-ids to provide explicitly") + } + + for _, role := range roleList { + // Check if the target org already has this role registered. + targetKey := targetOrg + "/" + role + if appID, ok := existingIDs[targetKey]; ok { + result[targetKey] = appID + continue + } + + // Look up the source org's app ID for this role. + sourceKey := sourceOrg + "/" + role + appID, ok := existingIDs[sourceKey] + if !ok { + return nil, fmt.Errorf("role %q not found in source org %q's ROLE_APP_IDS — use --role-app-ids to provide explicitly", role, sourceOrg) + } + result[targetKey] = appID + } + + return result, nil +} + +func newMintUnenrollCmd() *cobra.Command { + var project string + var region string + var deleteSecrets bool + var deleteProvider bool + var dryRun bool + var yolo bool + + cmd := &cobra.Command{ + Use: "unenroll ", + Short: "Remove an org or repo from the token mint", + Long: `Reverses enrollment by removing the org/repo from mint env vars. + +By default, PEM secrets are disabled (not deleted) and WIF providers are +disabled (not deleted). Use --delete-secrets or --delete-provider for +permanent removal. + +Requires typing the org/repo name to confirm (unless --dry-run or --yolo).`, + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if project == "" { + return fmt.Errorf("--project is required") + } + if !gcf.ValidateProjectID(project) { + return fmt.Errorf("invalid GCP project ID: %q", project) + } + if !gcf.ValidateRegion(region) { + return fmt.Errorf("invalid GCP region: %q", region) + } + + arg := args[0] + isRepo := strings.Contains(arg, "/") + + if isRepo && deleteSecrets { + return fmt.Errorf("--delete-secrets applies to org unenroll, not repo unenroll") + } + if !isRepo && deleteProvider { + return fmt.Errorf("--delete-provider applies to repo unenroll, not org unenroll") + } + + printer := ui.New(os.Stdout) + ctx := cmd.Context() + + printer.Banner() + printer.Blank() + + if isRepo { + return runMintUnenrollRepo(ctx, printer, arg, project, region, deleteProvider, dryRun, yolo, os.Stdin) + } + return runMintUnenrollOrg(ctx, printer, arg, project, region, deleteSecrets, dryRun, yolo, os.Stdin) + }, + } + + cmd.Flags().StringVar(&project, "project", "", "GCP project ID (required)") + cmd.Flags().StringVar(®ion, "region", "us-central1", "GCP region") + cmd.Flags().BoolVar(&deleteSecrets, "delete-secrets", false, "permanently delete PEM secrets (default: disable only)") + cmd.Flags().BoolVar(&deleteProvider, "delete-provider", false, "permanently delete WIF provider (default: disable only)") + cmd.Flags().BoolVar(&dryRun, "dry-run", false, "preview changes without making them") + cmd.Flags().BoolVar(&yolo, "yolo", false, "skip confirmation prompt") + + return cmd +} + +// confirmUnenroll prompts the user to type the target name to confirm. +// reader is the input source (os.Stdin in production, a buffer in tests). +func confirmUnenroll(printer *ui.Printer, target string, reader *bufio.Reader, isTerminal bool) error { + if !isTerminal { + return fmt.Errorf("stdin is not a terminal; use --yolo to skip confirmation") + } + + printer.StepWarn(fmt.Sprintf("This will remove %s from the mint.", target)) + printer.StepInfo(fmt.Sprintf("Type '%s' to confirm:", target)) + + line, err := reader.ReadString('\n') + if err != nil { + return fmt.Errorf("reading confirmation: %w", err) + } + if strings.TrimSpace(line) != target { + return fmt.Errorf("confirmation did not match; aborting unenroll") + } + return nil +} + +func runMintUnenrollOrg(ctx context.Context, printer *ui.Printer, org, project, region string, deleteSecrets, dryRun, yolo bool, stdin *os.File) error { + org = strings.ToLower(org) + if err := validateOrgName(org); err != nil { + return err + } + if org == gcf.PlaceholderOrg { + return fmt.Errorf("cannot unenroll reserved placeholder org %q", org) + } + + printer.Header("Unenrolling org " + org + " from mint") + printer.Blank() + + gcpClient := gcf.NewLiveGCFClient() + provisioner := gcf.NewProvisioner(gcf.Config{ + ProjectID: project, + Region: region, + GitHubOrgs: []string{org}, + }, gcpClient) + + // Step 1: Discover enrolled roles for this org from ROLE_APP_IDS. + printer.StepStart("Discovering enrolled roles") + discovery, err := provisioner.DiscoverMint(ctx) + if err != nil { + if errors.Is(err, gcf.ErrFunctionNotFound) { + printer.StepFail("Mint not installed") + return fmt.Errorf("mint not found in project %s region %s — nothing to unenroll", project, region) + } + printer.StepFail("Mint discovery failed") + return fmt.Errorf("discovering mint: %w", err) + } + + // Extract enrolled roles before dry-run so both paths have the full picture. + var roles []string + prefix := org + "/" + for key := range discovery.RoleAppIDs { + if strings.HasPrefix(strings.ToLower(key), strings.ToLower(prefix)) { + roles = append(roles, strings.TrimPrefix(strings.ToLower(key), strings.ToLower(prefix))) + } + } + sort.Strings(roles) + if len(roles) == 0 { + roles = defaultMintRoles() + printer.StepWarn(fmt.Sprintf("No roles found in ROLE_APP_IDS for %s; falling back to defaults: %s", org, strings.Join(roles, ", "))) + } else { + printer.StepDone(fmt.Sprintf("Found enrolled roles: %s", strings.Join(roles, ", "))) + } + + if dryRun { + printer.Blank() + printer.StepInfo("Dry run — no changes will be made") + printer.Blank() + printer.StepInfo(fmt.Sprintf(" Would remove %s from ALLOWED_ORGS and ROLE_APP_IDS", org)) + printer.StepInfo(fmt.Sprintf(" Would remove %s from WIF provider condition", org)) + if deleteSecrets { + printer.StepInfo(fmt.Sprintf(" Would delete PEM secrets for %s (roles: %s)", org, strings.Join(roles, ", "))) + } else { + printer.StepInfo(fmt.Sprintf(" Would disable PEM secrets for %s (roles: %s)", org, strings.Join(roles, ", "))) + } + return nil + } + + // Confirmation. + if !yolo { + reader := bufio.NewReader(stdin) + isTerminal := term.IsTerminal(int(stdin.Fd())) + if err := confirmUnenroll(printer, org, reader, isTerminal); err != nil { + return err + } + printer.Blank() + } + + // Step 2: Remove org from ROLE_APP_IDS and ALLOWED_ORGS. + printer.StepStart("Removing org from mint env vars") + if err := provisioner.RemoveOrgFromMint(ctx, org); err != nil { + printer.StepFail("Failed to remove org from mint") + return fmt.Errorf("removing org from mint: %w", err) + } + printer.StepDone("Org removed from mint env vars") + + // Step 3: Remove org from WIF provider condition. + printer.StepStart("Updating WIF provider condition") + if err := provisioner.RemoveOrgFromWIFCondition(ctx, org); err != nil { + printer.StepFail("Failed to update WIF condition") + return fmt.Errorf("updating WIF condition: %w", err) + } + printer.StepDone("WIF condition updated") + + // Step 4: Disable or delete PEM secrets. + if deleteSecrets { + printer.StepStart("Deleting PEM secrets") + if err := provisioner.DeletePEMSecrets(ctx, org, roles); err != nil { + printer.StepFail("Failed to delete PEM secrets") + return fmt.Errorf("deleting PEM secrets: %w", err) + } + printer.StepDone("PEM secrets deleted") + } else { + printer.StepStart("Disabling PEM secrets") + if err := provisioner.DisablePEMSecrets(ctx, org, roles); err != nil { + printer.StepFail("Failed to disable PEM secrets") + return fmt.Errorf("disabling PEM secrets: %w", err) + } + printer.StepDone("PEM secrets disabled (use --delete-secrets to permanently delete)") + } + + printer.Blank() + printer.Summary("Unenrollment complete", []string{ + fmt.Sprintf("Organization: %s", org), + "Org removed from ALLOWED_ORGS and ROLE_APP_IDS", + }) + + return nil +} + +func runMintUnenrollRepo(ctx context.Context, printer *ui.Printer, repoFullName, project, region string, deleteProvider, dryRun, yolo bool, stdin *os.File) error { + repoFullName = strings.ToLower(repoFullName) + parts := strings.SplitN(repoFullName, "/", 2) + if len(parts) != 2 || parts[0] == "" || parts[1] == "" { + return fmt.Errorf("repo must be in owner/repo format, got %q", repoFullName) + } + owner, repo := parts[0], parts[1] + if err := validateOrgName(owner); err != nil { + return fmt.Errorf("invalid owner: %w", err) + } + if !gcf.ValidateRepoSlug(repo) { + return fmt.Errorf("invalid repo name: %q", repo) + } + if owner == gcf.PlaceholderOrg { + return fmt.Errorf("cannot unenroll reserved placeholder org %q", owner) + } + + printer.Header("Unenrolling repo " + repoFullName + " from mint") + printer.Blank() + + gcpClient := gcf.NewLiveGCFClient() + provisioner := gcf.NewProvisioner(gcf.Config{ + ProjectID: project, + Region: region, + GitHubOrgs: []string{owner}, + }, gcpClient) + + // Verify mint exists before proceeding. + printer.StepStart("Verifying mint infrastructure") + if _, err := provisioner.DiscoverMint(ctx); err != nil { + if errors.Is(err, gcf.ErrFunctionNotFound) { + printer.StepFail("Mint not installed") + return fmt.Errorf("mint not found in project %s region %s — nothing to unenroll", project, region) + } + printer.StepFail("Mint discovery failed") + return fmt.Errorf("discovering mint: %w", err) + } + printer.StepDone("Mint verified") + + if dryRun { + providerID := gcf.BuildRepoProviderID(owner, repo) + printer.Blank() + printer.StepInfo("Dry run — no changes will be made") + printer.Blank() + printer.StepInfo(fmt.Sprintf(" Would remove %s from PER_REPO_WIF_REPOS", repoFullName)) + if deleteProvider { + printer.StepInfo(fmt.Sprintf(" Would delete WIF provider %s", providerID)) + } else { + printer.StepInfo(fmt.Sprintf(" Would disable WIF provider %s", providerID)) + } + return nil + } + + // Confirmation. + if !yolo { + reader := bufio.NewReader(stdin) + isTerminal := term.IsTerminal(int(stdin.Fd())) + if err := confirmUnenroll(printer, repoFullName, reader, isTerminal); err != nil { + return err + } + printer.Blank() + } + + // Step 1: Remove repo from PER_REPO_WIF_REPOS. + printer.StepStart("Removing repo from PER_REPO_WIF_REPOS") + if err := provisioner.RemoveRepoFromMint(ctx, repoFullName); err != nil { + printer.StepFail("Failed to remove repo from mint") + return fmt.Errorf("removing repo from mint: %w", err) + } + printer.StepDone("Repo removed from PER_REPO_WIF_REPOS") + + // Step 2: Disable or delete WIF provider. + providerID := gcf.BuildRepoProviderID(owner, repo) + if deleteProvider { + printer.StepStart("Deleting WIF provider " + providerID) + if err := provisioner.DeleteWIFProvider(ctx, providerID); err != nil { + printer.StepFail("Failed to delete WIF provider") + return fmt.Errorf("deleting WIF provider: %w", err) + } + printer.StepDone("WIF provider deleted") + } else { + printer.StepStart("Disabling WIF provider " + providerID) + if err := provisioner.DisableWIFProvider(ctx, providerID); err != nil { + printer.StepFail("Failed to disable WIF provider") + return fmt.Errorf("disabling WIF provider: %w", err) + } + printer.StepDone("WIF provider disabled (use --delete-provider to permanently delete)") + } + + printer.Blank() + printer.Summary("Unenrollment complete", []string{ + fmt.Sprintf("Repository: %s", repoFullName), + "Repo removed from PER_REPO_WIF_REPOS", + }) + + return nil +} + +func newMintStatusCmd() *cobra.Command { + var project string + var region string + + cmd := &cobra.Command{ + Use: "status [org]", + Short: "Show mint state, enrolled orgs, and PEM health", + Long: `Read-only health check of the token mint infrastructure. + +Shows function info, enrolled orgs, role-app-id mappings, per-repo WIF +repos, and overall health status. If an org argument is provided, drills +into that org's PEM secret status.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + if project == "" { + return fmt.Errorf("--project is required") + } + if !gcf.ValidateProjectID(project) { + return fmt.Errorf("invalid GCP project ID: %q", project) + } + if !gcf.ValidateRegion(region) { + return fmt.Errorf("invalid GCP region: %q", region) + } + + var org string + if len(args) == 1 { + org = strings.ToLower(args[0]) + if err := validateOrgName(org); err != nil { + return err + } + } + + printer := ui.New(os.Stdout) + ctx := cmd.Context() + + return runMintStatus(ctx, printer, project, region, org) + }, + } + + cmd.Flags().StringVar(&project, "project", "", "GCP project ID (required)") + cmd.Flags().StringVar(®ion, "region", "us-central1", "GCP region") + + return cmd +} + +func runMintStatus(ctx context.Context, printer *ui.Printer, project, region, org string) error { + printer.Banner() + printer.Blank() + printer.Header("Mint Status") + printer.Blank() + + gcpClient := gcf.NewLiveGCFClient() + provisioner := gcf.NewProvisioner(gcf.Config{ + ProjectID: project, + Region: region, + GitHubOrgs: []string{}, + }, gcpClient) + + // Step 1: Discover mint. + printer.StepStart("Discovering mint infrastructure") + discovery, err := provisioner.DiscoverMint(ctx) + if err != nil { + if errors.Is(err, gcf.ErrFunctionNotFound) { + printer.StepFail("Mint not installed") + printer.Blank() + printer.Summary("Status", []string{ + "Health: not-installed", + fmt.Sprintf("Project: %s", project), + fmt.Sprintf("Region: %s", region), + }) + return nil + } + printer.StepFail("Mint discovery failed") + return fmt.Errorf("discovering mint: %w", err) + } + printer.StepDone("Mint discovered") + + // Step 2: Print function info. + printer.Blank() + printer.KeyValue("URL", discovery.URL) + printer.KeyValue("Project", project) + printer.KeyValue("Region", region) + + // Parse enrolled orgs from ROLE_APP_IDS. + var enrolledOrgs []string + orgSet := make(map[string]bool) + for key := range discovery.RoleAppIDs { + parts := strings.SplitN(key, "/", 2) + if len(parts) == 2 && !orgSet[parts[0]] && parts[0] != gcf.PlaceholderOrg { + orgSet[parts[0]] = true + enrolledOrgs = append(enrolledOrgs, parts[0]) + } + } + sort.Strings(enrolledOrgs) + + printer.Blank() + printer.Header("Enrolled Organizations") + if len(enrolledOrgs) == 0 { + printer.StepInfo(" (none)") + } else { + for _, o := range enrolledOrgs { + printer.StepInfo(" " + o) + } + } + + printer.Blank() + printer.Header("Role App IDs") + roleKeys := make([]string, 0, len(discovery.RoleAppIDs)) + for k := range discovery.RoleAppIDs { + if strings.HasPrefix(k, gcf.PlaceholderOrg+"/") { + continue + } + roleKeys = append(roleKeys, k) + } + sort.Strings(roleKeys) + if len(roleKeys) == 0 { + printer.StepInfo(" (none)") + } else { + for _, k := range roleKeys { + printer.StepInfo(fmt.Sprintf(" %s = %s", k, discovery.RoleAppIDs[k])) + } + } + + printer.Blank() + printer.Header("Per-Repo WIF Repos") + if len(discovery.PerRepoWIFRepos) == 0 { + printer.StepInfo(" (none)") + } else { + for _, r := range discovery.PerRepoWIFRepos { + printer.StepInfo(" " + r) + } + } + + // Step 3: Drill into specific org if provided. + if org != "" { + printer.Blank() + printer.Header("PEM Status for " + org) + + // Find all roles for this org. + var orgRoles []string + for key := range discovery.RoleAppIDs { + parts := strings.SplitN(key, "/", 2) + if len(parts) == 2 && parts[0] == org { + orgRoles = append(orgRoles, parts[1]) + } + } + sort.Strings(orgRoles) + + if len(orgRoles) == 0 { + printer.StepWarn(fmt.Sprintf("No roles found for %s in ROLE_APP_IDS", org)) + } else { + for _, role := range orgRoles { + exists, existsErr := provisioner.SecretExists(ctx, org, role) + if existsErr != nil { + printer.StepWarn(fmt.Sprintf(" %s: error checking (%v)", role, existsErr)) + } else if exists { + printer.StepDone(fmt.Sprintf(" %s: present", role)) + } else { + printer.StepFail(fmt.Sprintf(" %s: missing", role)) + } + } + } + } + + // Step 4: Determine health. + health := "healthy" + if len(enrolledOrgs) == 0 { + health = "degraded" + } + + printer.Blank() + printer.Summary("Status", []string{ + fmt.Sprintf("Health: %s", health), + fmt.Sprintf("Enrolled orgs: %d", len(enrolledOrgs)), + }) + + return nil +} diff --git a/internal/cli/mint_test.go b/internal/cli/mint_test.go new file mode 100644 index 000000000..f07cc6eba --- /dev/null +++ b/internal/cli/mint_test.go @@ -0,0 +1,354 @@ +package cli + +import ( + "sort" + "strings" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/fullsend-ai/fullsend/internal/config" +) + +func TestMintCommand_HasSubcommands(t *testing.T) { + cmd := newMintCmd() + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Use] = true + } + assert.True(t, names["deploy"], "expected deploy subcommand") + assert.True(t, names["enroll "], "expected enroll subcommand") + assert.True(t, names["unenroll "], "expected unenroll subcommand") + assert.True(t, names["status [org]"], "expected status subcommand") +} + +func TestMintCommand_RegisteredInRoot(t *testing.T) { + cmd := newRootCmd() + names := make(map[string]bool) + for _, sub := range cmd.Commands() { + names[sub.Name()] = true + } + assert.True(t, names["mint"], "expected mint command registered in root") +} + +// --- deploy command tests --- + +func TestMintDeployCmd_Flags(t *testing.T) { + cmd := newMintDeployCmd() + + projectFlag := cmd.Flags().Lookup("project") + require.NotNil(t, projectFlag, "expected --project flag") + assert.Equal(t, "", projectFlag.DefValue) + + regionFlag := cmd.Flags().Lookup("region") + require.NotNil(t, regionFlag, "expected --region flag") + assert.Equal(t, "us-central1", regionFlag.DefValue) + + sourceDirFlag := cmd.Flags().Lookup("source-dir") + require.NotNil(t, sourceDirFlag, "expected --source-dir flag") + + skipDeployFlag := cmd.Flags().Lookup("skip-deploy") + require.NotNil(t, skipDeployFlag, "expected --skip-deploy flag") + + dryRunFlag := cmd.Flags().Lookup("dry-run") + require.NotNil(t, dryRunFlag, "expected --dry-run flag") +} + +func TestMintDeployCmd_RequiresProject(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--project is required") +} + +func TestMintDeployCmd_InvalidProject(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--project=BAD"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid GCP project ID") +} + +func TestMintDeployCmd_InvalidRegion(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--project=my-project-id", "--region=invalid"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid GCP region") +} + +func TestMintDeployCmd_DryRun(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--project=my-project-id", "--dry-run"}) + err := cmd.Execute() + require.NoError(t, err) +} + +func TestMintDeployCmd_NoArgs(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "deploy", "--project=my-project-id", "--dry-run", "extra"}) + err := cmd.Execute() + require.Error(t, err) +} + +// --- enroll command tests --- + +func TestMintEnrollCmd_Flags(t *testing.T) { + cmd := newMintEnrollCmd() + + projectFlag := cmd.Flags().Lookup("project") + require.NotNil(t, projectFlag, "expected --project flag") + + regionFlag := cmd.Flags().Lookup("region") + require.NotNil(t, regionFlag, "expected --region flag") + assert.Equal(t, "us-central1", regionFlag.DefValue) + + sourceOrgFlag := cmd.Flags().Lookup("source-org") + require.NotNil(t, sourceOrgFlag, "expected --source-org flag") + assert.Equal(t, "fullsend-ai", sourceOrgFlag.DefValue) + + roleAppIDsFlag := cmd.Flags().Lookup("role-app-ids") + require.NotNil(t, roleAppIDsFlag, "expected --role-app-ids flag") + + rolesFlag := cmd.Flags().Lookup("roles") + require.NotNil(t, rolesFlag, "expected --roles flag") + assert.Equal(t, strings.Join(config.DefaultAgentRoles(), ","), rolesFlag.DefValue) + + dryRunFlag := cmd.Flags().Lookup("dry-run") + require.NotNil(t, dryRunFlag, "expected --dry-run flag") +} + +func TestMintEnrollCmd_RequiresArg(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "enroll"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "accepts 1 arg(s)") +} + +func TestMintEnrollCmd_RequiresProject(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "enroll", "acme"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--project is required") +} + +func TestMintEnrollCmd_InvalidProject(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "enroll", "acme", "--project=BAD"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid GCP project ID") +} + +// --- unenroll command tests --- + +func TestMintUnenrollCmd_Flags(t *testing.T) { + cmd := newMintUnenrollCmd() + + projectFlag := cmd.Flags().Lookup("project") + require.NotNil(t, projectFlag, "expected --project flag") + + regionFlag := cmd.Flags().Lookup("region") + require.NotNil(t, regionFlag, "expected --region flag") + + deleteSecretsFlag := cmd.Flags().Lookup("delete-secrets") + require.NotNil(t, deleteSecretsFlag, "expected --delete-secrets flag") + assert.Equal(t, "false", deleteSecretsFlag.DefValue) + + deleteProviderFlag := cmd.Flags().Lookup("delete-provider") + require.NotNil(t, deleteProviderFlag, "expected --delete-provider flag") + assert.Equal(t, "false", deleteProviderFlag.DefValue) + + yoloFlag := cmd.Flags().Lookup("yolo") + require.NotNil(t, yoloFlag, "expected --yolo flag") +} + +func TestMintUnenrollCmd_RequiresArg(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "unenroll"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "accepts 1 arg(s)") +} + +func TestMintUnenrollCmd_RequiresProject(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "unenroll", "acme"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--project is required") +} + +// --- status command tests --- + +func TestMintStatusCmd_Flags(t *testing.T) { + cmd := newMintStatusCmd() + + projectFlag := cmd.Flags().Lookup("project") + require.NotNil(t, projectFlag, "expected --project flag") + + regionFlag := cmd.Flags().Lookup("region") + require.NotNil(t, regionFlag, "expected --region flag") +} + +func TestMintStatusCmd_RequiresProject(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "status"}) + err := cmd.Execute() + require.Error(t, err) + assert.Contains(t, err.Error(), "--project is required") +} + +func TestMintStatusCmd_InvalidOrg(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "status", "-org", "--project=my-project-id"}) + err := cmd.Execute() + require.Error(t, err) +} + +func TestMintStatusCmd_TooManyArgs(t *testing.T) { + cmd := newRootCmd() + cmd.SetArgs([]string{"mint", "status", "org1", "org2", "--project=my-project-id"}) + err := cmd.Execute() + require.Error(t, err) +} + +// --- role aliasing tests --- + +func TestResolveRole(t *testing.T) { + assert.Equal(t, "coder", resolveRole("fix")) + assert.Equal(t, "coder", resolveRole("coder")) + assert.Equal(t, "triage", resolveRole("triage")) + assert.Equal(t, "review", resolveRole("review")) +} + +func TestParseAndResolveRoles_FixAlias(t *testing.T) { + roles, err := parseAndResolveRoles("triage,fix,coder,review") + require.NoError(t, err) + + // "fix" should be resolved to "coder" and deduplicated. + assert.NotContains(t, roles, "fix") + assert.Contains(t, roles, "coder") + assert.Contains(t, roles, "triage") + assert.Contains(t, roles, "review") + + // No duplicates. + seen := make(map[string]bool) + for _, r := range roles { + assert.False(t, seen[r], "duplicate role: %s", r) + seen[r] = true + } +} + +func TestParseAndResolveRoles_Sorted(t *testing.T) { + roles, err := parseAndResolveRoles("review,triage,coder") + require.NoError(t, err) + + sorted := make([]string, len(roles)) + copy(sorted, roles) + sort.Strings(sorted) + assert.Equal(t, sorted, roles, "roles should be sorted") +} + +func TestParseAndResolveRoles_InvalidRole(t *testing.T) { + _, err := parseAndResolveRoles("INVALID") + require.Error(t, err) + assert.Contains(t, err.Error(), "invalid role name") +} + +func TestDefaultMintRoles(t *testing.T) { + roles := defaultMintRoles() + assert.Equal(t, config.DefaultAgentRoles(), roles) +} + +// --- resolveEnrollAppIDs tests --- + +func TestResolveEnrollAppIDs_ExplicitJSON(t *testing.T) { + result, err := resolveEnrollAppIDs( + `{"coder":"111","triage":"222"}`, + nil, + "source-org", + "target-org", + []string{"coder", "triage"}, + ) + require.NoError(t, err) + assert.Equal(t, "111", result["target-org/coder"]) + assert.Equal(t, "222", result["target-org/triage"]) +} + +func TestResolveEnrollAppIDs_ExplicitJSON_InvalidJSON(t *testing.T) { + _, err := resolveEnrollAppIDs( + `{invalid`, + nil, + "source-org", + "target-org", + []string{"coder"}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "parsing --role-app-ids") +} + +func TestResolveEnrollAppIDs_FromSourceOrg(t *testing.T) { + existing := map[string]string{ + "source-org/coder": "111", + "source-org/triage": "222", + } + result, err := resolveEnrollAppIDs( + "", + existing, + "source-org", + "target-org", + []string{"coder", "triage"}, + ) + require.NoError(t, err) + assert.Equal(t, "111", result["target-org/coder"]) + assert.Equal(t, "222", result["target-org/triage"]) +} + +func TestResolveEnrollAppIDs_TargetAlreadyRegistered(t *testing.T) { + existing := map[string]string{ + "source-org/coder": "111", + "target-org/coder": "999", + } + result, err := resolveEnrollAppIDs( + "", + existing, + "source-org", + "target-org", + []string{"coder"}, + ) + require.NoError(t, err) + assert.Equal(t, "999", result["target-org/coder"], "should use target org's existing entry") +} + +func TestResolveEnrollAppIDs_NoExistingIDs(t *testing.T) { + _, err := resolveEnrollAppIDs( + "", + nil, + "source-org", + "target-org", + []string{"coder"}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "no existing ROLE_APP_IDS") +} + +func TestResolveEnrollAppIDs_RoleMissingFromSource(t *testing.T) { + existing := map[string]string{ + "source-org/coder": "111", + } + _, err := resolveEnrollAppIDs( + "", + existing, + "source-org", + "target-org", + []string{"coder", "unknown-role"}, + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "unknown-role") + assert.Contains(t, err.Error(), "not found in source org") +} diff --git a/internal/cli/root.go b/internal/cli/root.go index d77ea02d5..9f2d7c48a 100644 --- a/internal/cli/root.go +++ b/internal/cli/root.go @@ -21,6 +21,7 @@ func newRootCmd() *cobra.Command { Version: version, } cmd.AddCommand(newAdminCmd()) + cmd.AddCommand(newMintCmd()) cmd.AddCommand(newRunCmd()) cmd.AddCommand(newScanCmd()) cmd.AddCommand(newPostReviewCmd()) diff --git a/internal/dispatch/gcf/gcp.go b/internal/dispatch/gcf/gcp.go index f5fca9b3c..4ac518e8f 100644 --- a/internal/dispatch/gcf/gcp.go +++ b/internal/dispatch/gcf/gcp.go @@ -67,12 +67,16 @@ type GCFClient interface { CreateWIFProvider(ctx context.Context, projectNumber, poolID, providerID string, cfg OIDCProviderConfig) error GetWIFProvider(ctx context.Context, projectNumber, poolID, providerID string) (*WIFProviderInfo, error) UpdateWIFProvider(ctx context.Context, projectNumber, poolID, providerID string, cfg OIDCProviderConfig) error + DisableWIFProvider(ctx context.Context, projectNumber, poolID, providerID string) error + DeleteWIFProvider(ctx context.Context, projectNumber, poolID, providerID string) error // Secret Manager GetSecret(ctx context.Context, projectID, secretID string) error CreateSecret(ctx context.Context, projectID, secretID string) error AddSecretVersion(ctx context.Context, projectID, secretID string, data []byte) error AccessSecretVersion(ctx context.Context, projectID, secretID string) ([]byte, error) + DisableSecretVersion(ctx context.Context, projectID, secretID string) error + DeleteSecret(ctx context.Context, projectID, secretID string) error // IAM bindings SetSecretIAMBinding(ctx context.Context, resource, member, role string) error @@ -207,10 +211,11 @@ func (c *LiveGCFClient) CreateWIFProvider(ctx context.Context, projectNumber, po if resp.StatusCode == http.StatusConflict { io.Copy(io.Discard, io.LimitReader(resp.Body, 1<<20)) - if err := c.undeleteWIFProvider(ctx, projectNumber, poolID, providerID); err == nil { - return c.UpdateWIFProvider(ctx, projectNumber, poolID, providerID, cfg) + _ = c.undeleteWIFProvider(ctx, projectNumber, poolID, providerID) + if err := c.UpdateWIFProvider(ctx, projectNumber, poolID, providerID, cfg); err != nil { + return err } - return c.UpdateWIFProvider(ctx, projectNumber, poolID, providerID, cfg) + return c.enableWIFProvider(ctx, projectNumber, poolID, providerID) } if resp.StatusCode != http.StatusOK { body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) @@ -426,6 +431,125 @@ func (c *LiveGCFClient) AccessSecretVersion(ctx context.Context, projectID, secr return data, nil } +// DisableSecretVersion disables the latest version of a Secret Manager secret. +func (c *LiveGCFClient) DisableSecretVersion(ctx context.Context, projectID, secretID string) error { + reqURL := fmt.Sprintf("https://secretmanager.googleapis.com/v1/projects/%s/secrets/%s/versions/latest:disable", + url.PathEscape(projectID), url.PathEscape(secretID)) + + resp, err := c.Client.DoRequest(ctx, http.MethodPost, reqURL, "{}") + if err != nil { + return fmt.Errorf("disabling secret version: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil // No versions to disable. + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return fmt.Errorf("unexpected status %d disabling secret version: %s", resp.StatusCode, gcp.ExtractErrorMessage(body)) + } + return nil +} + +// DeleteSecret permanently deletes a Secret Manager secret and all its versions. +func (c *LiveGCFClient) DeleteSecret(ctx context.Context, projectID, secretID string) error { + reqURL := fmt.Sprintf("https://secretmanager.googleapis.com/v1/projects/%s/secrets/%s", + url.PathEscape(projectID), url.PathEscape(secretID)) + + resp, err := c.Client.DoRequest(ctx, http.MethodDelete, reqURL, "") + if err != nil { + return fmt.Errorf("deleting secret: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil // Already deleted. + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return fmt.Errorf("unexpected status %d deleting secret: %s", resp.StatusCode, gcp.ExtractErrorMessage(body)) + } + return nil +} + +// DisableWIFProvider sets a WIF provider's disabled state to true. +func (c *LiveGCFClient) DisableWIFProvider(ctx context.Context, projectNumber, poolID, providerID string) error { + patchURL := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/locations/global/workloadIdentityPools/%s/providers/%s?updateMask=disabled", + url.PathEscape(projectNumber), url.PathEscape(poolID), url.PathEscape(providerID)) + + payloadBytes, err := json.Marshal(map[string]interface{}{ + "disabled": true, + }) + if err != nil { + return fmt.Errorf("marshaling disable payload: %w", err) + } + + resp, err := c.Client.DoRequest(ctx, http.MethodPatch, patchURL, string(payloadBytes)) + if err != nil { + return fmt.Errorf("disabling WIF provider: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil // Already deleted or never existed. + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return fmt.Errorf("unexpected status %d disabling WIF provider: %s", resp.StatusCode, gcp.ExtractErrorMessage(body)) + } + + return c.waitForIAMOperation(ctx, resp.Body) +} + +// enableWIFProvider sets a WIF provider's disabled state to false. +func (c *LiveGCFClient) enableWIFProvider(ctx context.Context, projectNumber, poolID, providerID string) error { + patchURL := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/locations/global/workloadIdentityPools/%s/providers/%s?updateMask=disabled", + url.PathEscape(projectNumber), url.PathEscape(poolID), url.PathEscape(providerID)) + + payloadBytes, err := json.Marshal(map[string]interface{}{ + "disabled": false, + }) + if err != nil { + return fmt.Errorf("marshaling enable payload: %w", err) + } + + resp, err := c.Client.DoRequest(ctx, http.MethodPatch, patchURL, string(payloadBytes)) + if err != nil { + return fmt.Errorf("enabling WIF provider: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return fmt.Errorf("unexpected status %d enabling WIF provider: %s", resp.StatusCode, gcp.ExtractErrorMessage(body)) + } + + return c.waitForIAMOperation(ctx, resp.Body) +} + +// DeleteWIFProvider permanently deletes a WIF provider. +func (c *LiveGCFClient) DeleteWIFProvider(ctx context.Context, projectNumber, poolID, providerID string) error { + deleteURL := fmt.Sprintf("https://iam.googleapis.com/v1/projects/%s/locations/global/workloadIdentityPools/%s/providers/%s", + url.PathEscape(projectNumber), url.PathEscape(poolID), url.PathEscape(providerID)) + + resp, err := c.Client.DoRequest(ctx, http.MethodDelete, deleteURL, "") + if err != nil { + return fmt.Errorf("deleting WIF provider: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode == http.StatusNotFound { + return nil // Already deleted. + } + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(io.LimitReader(resp.Body, 1<<20)) + return fmt.Errorf("unexpected status %d deleting WIF provider: %s", resp.StatusCode, gcp.ExtractErrorMessage(body)) + } + + return c.waitForIAMOperation(ctx, resp.Body) +} + // SetSecretIAMBinding sets an IAM binding on a Secret Manager resource. // Uses read-modify-write with retry on 409 Conflict (etag mismatch). func (c *LiveGCFClient) SetSecretIAMBinding(ctx context.Context, resource, member, role string) error { diff --git a/internal/dispatch/gcf/provisioner.go b/internal/dispatch/gcf/provisioner.go index d9368b715..0f17bcb06 100644 --- a/internal/dispatch/gcf/provisioner.go +++ b/internal/dispatch/gcf/provisioner.go @@ -254,10 +254,11 @@ func (p *Provisioner) ensureSecretIAM(ctx context.Context, secretName string) er } // MintDiscovery holds the results of a single GetFunction call, providing -// both the URL and existing role-to-app-ID mappings. +// the URL, existing role-to-app-ID mappings, and per-repo WIF repos. type MintDiscovery struct { - URL string - RoleAppIDs map[string]string + URL string + RoleAppIDs map[string]string + PerRepoWIFRepos []string } // DiscoverMint fetches the mint function once and returns its URL and @@ -283,6 +284,15 @@ func (p *Provisioner) DiscoverMint(ctx context.Context) (*MintDiscovery, error) result.RoleAppIDs = m } } + if raw := fn.EnvVars["PER_REPO_WIF_REPOS"]; raw != "" { + for _, entry := range strings.Split(raw, ",") { + entry = strings.TrimSpace(entry) + if entry != "" { + result.PerRepoWIFRepos = append(result.PerRepoWIFRepos, entry) + } + } + sort.Strings(result.PerRepoWIFRepos) + } } return result, nil } @@ -371,12 +381,13 @@ func (p *Provisioner) EnsureOrgInMint(ctx context.Context, expectedURL string, o updated[k] = v } - // Build desired ALLOWED_ORGS including the new org. + // Build desired ALLOWED_ORGS including the new org, stripping the + // deploy-time placeholder (PlaceholderOrg) if present. desired := map[string]string{ "ALLOWED_ORGS": org, } mergeAllowedOrgs(updated, desired) - updated["ALLOWED_ORGS"] = desired["ALLOWED_ORGS"] + updated["ALLOWED_ORGS"] = stripPlaceholderOrg(desired["ALLOWED_ORGS"]) // Build desired ROLE_APP_IDS including the new entries. newRoleAppIDs, err := json.Marshal(roleAppIDs) @@ -387,6 +398,9 @@ func (p *Provisioner) EnsureOrgInMint(ctx context.Context, expectedURL string, o mergeRoleAppIDs(updated, desired) updated["ROLE_APP_IDS"] = desired["ROLE_APP_IDS"] + // Strip deploy-time placeholder entries from ROLE_APP_IDS. + updated["ROLE_APP_IDS"] = stripPlaceholderRoleAppIDs(updated["ROLE_APP_IDS"]) + // Recompute ALLOWED_ROLES from the merged ROLE_APP_IDS. updated["ALLOWED_ROLES"] = deriveAllowedRoles(updated["ROLE_APP_IDS"]) @@ -539,6 +553,10 @@ func (p *Provisioner) provisionWithExistingMint(ctx context.Context) (map[string } for _, org := range p.cfg.GitHubOrgs { + if org == PlaceholderOrg { + continue + } + // Store new PEMs (per-org with fresh apps). for _, role := range sortedByteMapKeys(p.cfg.AgentPEMs) { if err := p.StoreAgentPEM(ctx, org, role, p.cfg.AgentPEMs[role]); err != nil { @@ -674,14 +692,19 @@ func (p *Provisioner) provisionSelfManaged(ctx context.Context) (map[string]stri // Step 3: Grant Vertex AI access to each installing org's .fullsend repo // at the project level (direct WIF — no intermediate service account). // IAM policy changes can take up to 7 minutes to propagate. + iamGrantCount := 0 for _, org := range installingOrgs { + if org == PlaceholderOrg { + continue + } principal := fmt.Sprintf("principalSet://iam.googleapis.com/projects/%s/locations/global/workloadIdentityPools/%s/attribute.repository/%s/.fullsend", projectNumber, p.cfg.WIFPoolName, org) if err := p.gcpAPI.SetProjectIAMBinding(ctx, p.cfg.ProjectID, principal, "roles/aiplatform.user"); err != nil { return nil, fmt.Errorf("granting Vertex AI access for org %s: %w", org, err) } + iamGrantCount++ } - log.Printf("granted roles/aiplatform.user to %d org(s) (propagation may take several minutes)", len(installingOrgs)) + log.Printf("granted roles/aiplatform.user to %d org(s) (propagation may take several minutes)", iamGrantCount) // Determine if code deployment is needed. When the function already // exists and is active with the same source hash, skip the code deploy @@ -728,7 +751,11 @@ func (p *Provisioner) provisionSelfManaged(ctx context.Context) (map[string]stri } // Step 5a: Store new agent PEMs only for installing orgs. + // Skip for the deploy-time placeholder org which has no real PEMs. for _, org := range installingOrgs { + if org == PlaceholderOrg { + continue + } for _, role := range sortedByteMapKeys(p.cfg.AgentPEMs) { if err := p.StoreAgentPEM(ctx, org, role, p.cfg.AgentPEMs[role]); err != nil { return nil, fmt.Errorf("storing PEM for %s/%s: %w", org, role, err) @@ -737,8 +764,12 @@ func (p *Provisioner) provisionSelfManaged(ctx context.Context) (map[string]stri } // Step 5b: Verify secrets exist for roles without PEMs (re-install, - // only for installing orgs). + // only for installing orgs). Skip for the deploy-time placeholder org + // which has no real PEMs. for _, org := range installingOrgs { + if org == PlaceholderOrg { + continue + } for _, role := range sortedStringMapKeys(p.cfg.AgentAppIDs) { if _, hasPEM := p.cfg.AgentPEMs[role]; hasPEM { continue @@ -964,6 +995,42 @@ func mergeRoleAppIDs(existing, desired map[string]string) { desired["ROLE_APP_IDS"] = string(merged) } +// PlaceholderOrg is the deploy-time placeholder used in the WIF condition +// and env vars before any real orgs are enrolled. Must pass githubOrgPattern +// validation (used by Provision), but should not collide with any real +// GitHub org. The CLI rejects this value at enrollment time. +const PlaceholderOrg = "x0fullsend0placeholder" + +// stripPlaceholderOrg removes the deploy-time placeholder org from a +// comma-separated ALLOWED_ORGS value. Called during enrollment so the +// placeholder doesn't persist after real orgs are added. +func stripPlaceholderOrg(orgs string) string { + var filtered []string + for _, o := range strings.Split(orgs, ",") { + o = strings.TrimSpace(o) + if o != "" && o != PlaceholderOrg { + filtered = append(filtered, o) + } + } + return strings.Join(filtered, ",") +} + +// stripPlaceholderRoleAppIDs removes placeholder entries from ROLE_APP_IDS JSON. +func stripPlaceholderRoleAppIDs(roleAppIDsJSON string) string { + var m map[string]string + if err := json.Unmarshal([]byte(roleAppIDsJSON), &m); err != nil { + return roleAppIDsJSON + } + prefix := PlaceholderOrg + "/" + for key := range m { + if strings.HasPrefix(key, prefix) { + delete(m, key) + } + } + out, _ := json.Marshal(m) + return string(out) +} + // deriveAllowedRoles extracts unique role names from org-scoped ROLE_APP_IDS // keys (format: "org/role") and returns them as a sorted comma-separated string. func deriveAllowedRoles(roleAppIDsJSON string) string { @@ -1110,6 +1177,115 @@ func (p *Provisioner) ensureWIFPoolAndProvider(ctx context.Context, installingOr return &wifMergeResult{projectNumber: projectNumber, allOrgs: allOrgs}, nil } +// GrantOrgVertexAIAccess grants roles/aiplatform.user to an org's .fullsend +// repo principal so that enrolled org workflows can call Vertex AI. +func (p *Provisioner) GrantOrgVertexAIAccess(ctx context.Context, org string) error { + org = strings.ToLower(org) + + projectNumber, err := p.gcpAPI.GetProjectNumber(ctx, p.cfg.ProjectID) + if err != nil { + return fmt.Errorf("getting project number: %w", err) + } + + principal := fmt.Sprintf("principalSet://iam.googleapis.com/projects/%s/locations/global/workloadIdentityPools/%s/attribute.repository/%s/.fullsend", + projectNumber, p.cfg.WIFPoolName, org) + if err := p.gcpAPI.SetProjectIAMBinding(ctx, p.cfg.ProjectID, principal, "roles/aiplatform.user"); err != nil { + return fmt.Errorf("granting Vertex AI access for org %s: %w", org, err) + } + return nil +} + +// EnsureOrgInWIFCondition adds an org to the org-level WIF provider's +// attribute condition. Reads the existing condition, merges, and updates. +// Strips the deploy-time placeholder (PlaceholderOrg) if present. +// WARNING: read-modify-write without locking — concurrent calls may race. +func (p *Provisioner) EnsureOrgInWIFCondition(ctx context.Context, org string) error { + org = strings.ToLower(org) + + projectNumber, err := p.gcpAPI.GetProjectNumber(ctx, p.cfg.ProjectID) + if err != nil { + return fmt.Errorf("getting project number: %w", err) + } + + existing, err := p.gcpAPI.GetWIFProvider(ctx, projectNumber, p.cfg.WIFPoolName, p.cfg.WIFProvider) + if err != nil { + return fmt.Errorf("reading WIF provider: %w", err) + } + if existing == nil { + return fmt.Errorf("WIF provider %s not found — run 'mint deploy' first", p.cfg.WIFProvider) + } + + existingOrgs := parseConditionOrgs(existing.AttributeCondition) + merged := make(map[string]bool) + for _, o := range existingOrgs { + if o != PlaceholderOrg { + merged[o] = true + } + } + merged[org] = true + + allOrgs := make([]string, 0, len(merged)) + for o := range merged { + allOrgs = append(allOrgs, o) + } + sort.Strings(allOrgs) + + newCondition := buildAttributeCondition(allOrgs) + if newCondition == existing.AttributeCondition { + return nil + } + + audiences := []string{oidcAudience, iamAudience(projectNumber, p.cfg.WIFPoolName, p.cfg.WIFProvider)} + return p.gcpAPI.UpdateWIFProvider(ctx, projectNumber, p.cfg.WIFPoolName, p.cfg.WIFProvider, OIDCProviderConfig{ + AttributeCondition: newCondition, + AllowedAudiences: audiences, + }) +} + +// RemoveOrgFromWIFCondition removes an org from the org-level WIF provider's +// attribute condition. +// WARNING: read-modify-write without locking — concurrent calls may race. +func (p *Provisioner) RemoveOrgFromWIFCondition(ctx context.Context, org string) error { + org = strings.ToLower(org) + + projectNumber, err := p.gcpAPI.GetProjectNumber(ctx, p.cfg.ProjectID) + if err != nil { + return fmt.Errorf("getting project number: %w", err) + } + + existing, err := p.gcpAPI.GetWIFProvider(ctx, projectNumber, p.cfg.WIFPoolName, p.cfg.WIFProvider) + if err != nil { + return fmt.Errorf("reading WIF provider: %w", err) + } + if existing == nil { + return nil + } + + existingOrgs := parseConditionOrgs(existing.AttributeCondition) + var filtered []string + for _, o := range existingOrgs { + if o != org { + filtered = append(filtered, o) + } + } + + if len(filtered) == len(existingOrgs) { + return nil + } + + if len(filtered) == 0 { + filtered = []string{PlaceholderOrg} + } + sort.Strings(filtered) + + newCondition := buildAttributeCondition(filtered) + audiences := []string{oidcAudience, iamAudience(projectNumber, p.cfg.WIFPoolName, p.cfg.WIFProvider)} + return p.gcpAPI.UpdateWIFProvider(ctx, projectNumber, p.cfg.WIFPoolName, p.cfg.WIFProvider, OIDCProviderConfig{ + AttributeCondition: newCondition, + AllowedAudiences: audiences, + }) +} + // waitForReady polls the function until it responds with 200 OK, ensuring // the Cloud Run backing service is warm and the function code is healthy. // Uses exponential backoff starting at 2s, doubling each attempt up to 30s. @@ -1264,6 +1440,181 @@ func (p *Provisioner) ProvisionWIF(ctx context.Context) (wifProvider string, err return wifProvider, nil } +// ValidateProjectID checks if a string is a valid GCP project ID. +func ValidateProjectID(id string) bool { + return gcpProjectIDPattern.MatchString(id) +} + +// ValidateRegion checks if a string is a valid GCP region. +func ValidateRegion(region string) bool { + return gcpRegionPattern.MatchString(region) +} + +// ValidateRepoSlug checks if a string is a valid GitHub repository name. +func ValidateRepoSlug(slug string) bool { + if !githubRepoSlugPattern.MatchString(slug) { + return false + } + if strings.HasPrefix(slug, ".") { + return false + } + if strings.HasSuffix(slug, ".git") { + return false + } + return true +} + +// RemoveOrgFromMint removes an org from ROLE_APP_IDS, ALLOWED_ORGS, +// and re-derives ALLOWED_ROLES. Uses read-modify-write via +// UpdateFunctionEnvVars (never --set-env-vars). +func (p *Provisioner) RemoveOrgFromMint(ctx context.Context, org string) error { + org = strings.ToLower(org) + + fn, err := p.gcpAPI.GetFunction(ctx, p.cfg.ProjectID, p.cfg.Region, functionName) + if err != nil { + return fmt.Errorf("getting mint function: %w", err) + } + if fn == nil { + return fmt.Errorf("mint function %q not found in project %s region %s", functionName, p.cfg.ProjectID, p.cfg.Region) + } + + updated := make(map[string]string, len(fn.EnvVars)) + for k, v := range fn.EnvVars { + updated[k] = v + } + + // Remove org from ALLOWED_ORGS. + var filteredOrgs []string + for _, o := range strings.Split(fn.EnvVars["ALLOWED_ORGS"], ",") { + o = strings.TrimSpace(o) + if o != "" && !strings.EqualFold(o, org) { + filteredOrgs = append(filteredOrgs, o) + } + } + sort.Strings(filteredOrgs) + updated["ALLOWED_ORGS"] = strings.Join(filteredOrgs, ",") + + // Remove org entries from ROLE_APP_IDS. + existingRoleAppIDs := make(map[string]string) + if raw := fn.EnvVars["ROLE_APP_IDS"]; raw != "" { + if err := json.Unmarshal([]byte(raw), &existingRoleAppIDs); err != nil { + return fmt.Errorf("parsing existing ROLE_APP_IDS: %w", err) + } + } + + prefix := org + "/" + for key := range existingRoleAppIDs { + if strings.HasPrefix(strings.ToLower(key), prefix) { + delete(existingRoleAppIDs, key) + } + } + + roleAppIDsJSON, err := json.Marshal(existingRoleAppIDs) + if err != nil { + return fmt.Errorf("marshaling updated ROLE_APP_IDS: %w", err) + } + updated["ROLE_APP_IDS"] = string(roleAppIDsJSON) + + // Re-derive ALLOWED_ROLES. + updated["ALLOWED_ROLES"] = deriveAllowedRoles(updated["ROLE_APP_IDS"]) + + opName, err := p.gcpAPI.UpdateFunctionEnvVars(ctx, p.cfg.ProjectID, p.cfg.Region, functionName, updated) + if err != nil { + return fmt.Errorf("updating mint env vars: %w", err) + } + return p.gcpAPI.WaitForOperation(ctx, opName) +} + +// RemoveRepoFromMint removes a repo from PER_REPO_WIF_REPOS. +// Uses read-modify-write via UpdateFunctionEnvVars. +func (p *Provisioner) RemoveRepoFromMint(ctx context.Context, repo string) error { + repo = strings.ToLower(repo) + + fn, err := p.gcpAPI.GetFunction(ctx, p.cfg.ProjectID, p.cfg.Region, functionName) + if err != nil { + return fmt.Errorf("getting mint function: %w", err) + } + if fn == nil { + return fmt.Errorf("mint function not found") + } + + existing := fn.EnvVars["PER_REPO_WIF_REPOS"] + var filtered []string + for _, entry := range strings.Split(existing, ",") { + entry = strings.TrimSpace(entry) + if entry != "" && strings.ToLower(entry) != repo { + filtered = append(filtered, entry) + } + } + + updated := make(map[string]string, len(fn.EnvVars)) + for k, v := range fn.EnvVars { + updated[k] = v + } + updated["PER_REPO_WIF_REPOS"] = strings.Join(filtered, ",") + + opName, err := p.gcpAPI.UpdateFunctionEnvVars(ctx, p.cfg.ProjectID, p.cfg.Region, functionName, updated) + if err != nil { + return fmt.Errorf("updating PER_REPO_WIF_REPOS: %w", err) + } + return p.gcpAPI.WaitForOperation(ctx, opName) +} + +// DisablePEMSecrets disables the latest version of each PEM secret for an +// org's roles. This is reversible — the secrets can be re-enabled. +// Skips secrets that do not exist (already cleaned up). +func (p *Provisioner) DisablePEMSecrets(ctx context.Context, org string, roles []string) error { + for _, role := range roles { + sid := secretID(org, role) + if err := p.gcpAPI.GetSecret(ctx, p.cfg.ProjectID, sid); err != nil { + if errors.Is(err, ErrSecretNotFound) { + continue // Already gone, skip. + } + return fmt.Errorf("checking secret %s: %w", sid, err) + } + if err := p.gcpAPI.DisableSecretVersion(ctx, p.cfg.ProjectID, sid); err != nil { + return fmt.Errorf("disabling secret %s: %w", sid, err) + } + } + return nil +} + +// DeletePEMSecrets permanently deletes PEM secrets for an org's roles. +// Skips secrets that do not exist. +func (p *Provisioner) DeletePEMSecrets(ctx context.Context, org string, roles []string) error { + for _, role := range roles { + sid := secretID(org, role) + if err := p.gcpAPI.GetSecret(ctx, p.cfg.ProjectID, sid); err != nil { + if errors.Is(err, ErrSecretNotFound) { + continue // Already gone, skip. + } + return fmt.Errorf("checking secret %s: %w", sid, err) + } + if err := p.gcpAPI.DeleteSecret(ctx, p.cfg.ProjectID, sid); err != nil { + return fmt.Errorf("deleting secret %s: %w", sid, err) + } + } + return nil +} + +// DisableWIFProvider sets a WIF provider's disabled field to true. +func (p *Provisioner) DisableWIFProvider(ctx context.Context, providerID string) error { + projectNumber, err := p.gcpAPI.GetProjectNumber(ctx, p.cfg.ProjectID) + if err != nil { + return fmt.Errorf("getting project number: %w", err) + } + return p.gcpAPI.DisableWIFProvider(ctx, projectNumber, p.cfg.WIFPoolName, providerID) +} + +// DeleteWIFProvider permanently deletes a WIF provider. +func (p *Provisioner) DeleteWIFProvider(ctx context.Context, providerID string) error { + projectNumber, err := p.gcpAPI.GetProjectNumber(ctx, p.cfg.ProjectID) + if err != nil { + return fmt.Errorf("getting project number: %w", err) + } + return p.gcpAPI.DeleteWIFProvider(ctx, projectNumber, p.cfg.WIFPoolName, providerID) +} + func (p *Provisioner) zeroPEMs() { for role, pem := range p.cfg.AgentPEMs { for i := range pem { diff --git a/internal/dispatch/gcf/provisioner_test.go b/internal/dispatch/gcf/provisioner_test.go index 8dace013e..5f2792079 100644 --- a/internal/dispatch/gcf/provisioner_test.go +++ b/internal/dispatch/gcf/provisioner_test.go @@ -159,6 +159,23 @@ func (f *fakeGCFClient) AccessSecretVersion(_ context.Context, _ string, sid str } return nil, fmt.Errorf("secret %s: %w", sid, ErrSecretNotFound) } +func (f *fakeGCFClient) DisableSecretVersion(_ context.Context, _ string, sid string) error { + f.calls = append(f.calls, "DisableSecretVersion") + return f.errs["DisableSecretVersion"] +} +func (f *fakeGCFClient) DeleteSecret(_ context.Context, _ string, sid string) error { + f.calls = append(f.calls, "DeleteSecret") + if f.secrets != nil { + delete(f.secrets, sid) + } + return f.errs["DeleteSecret"] +} +func (f *fakeGCFClient) DisableWIFProvider(_ context.Context, _, _, _ string) error { + return f.record("DisableWIFProvider") +} +func (f *fakeGCFClient) DeleteWIFProvider(_ context.Context, _, _, _ string) error { + return f.record("DeleteWIFProvider") +} func (f *fakeGCFClient) SetSecretIAMBinding(_ context.Context, _, _, _ string) error { return f.record("SetSecretIAMBinding") } @@ -2663,3 +2680,282 @@ func TestRegisterPerRepoWIF_GetFunctionError(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "getting mint function") } + +// --- RemoveOrgFromMint tests --- + +func TestRemoveOrgFromMint_RemovesOrgAndRoles(t *testing.T) { + fake := newFakeGCFClient() + fake.functionInfo = &FunctionInfo{ + URI: "https://mint.example.com", + EnvVars: map[string]string{ + "ALLOWED_ORGS": "acme,other-org", + "ROLE_APP_IDS": `{"acme/coder":"111","acme/triage":"222","other-org/coder":"333"}`, + "ALLOWED_ROLES": "coder,triage", + }, + } + + p := NewProvisioner(Config{ProjectID: "proj1", Region: "us-central1"}, fake) + err := p.RemoveOrgFromMint(context.Background(), "acme") + require.NoError(t, err) + + assert.Contains(t, fake.calls, "UpdateFunctionEnvVars") + assert.Contains(t, fake.calls, "WaitForOperation") + + // acme should be removed from ALLOWED_ORGS. + assert.Equal(t, "other-org", fake.lastUpdateFunctionEnvVars["ALLOWED_ORGS"]) + + // acme entries should be removed from ROLE_APP_IDS. + var roleAppIDs map[string]string + require.NoError(t, json.Unmarshal([]byte(fake.lastUpdateFunctionEnvVars["ROLE_APP_IDS"]), &roleAppIDs)) + assert.NotContains(t, roleAppIDs, "acme/coder") + assert.NotContains(t, roleAppIDs, "acme/triage") + assert.Equal(t, "333", roleAppIDs["other-org/coder"]) + + // ALLOWED_ROLES should be re-derived. + assert.Equal(t, "coder", fake.lastUpdateFunctionEnvVars["ALLOWED_ROLES"]) +} + +func TestRemoveOrgFromMint_FunctionNotFound(t *testing.T) { + fake := newFakeGCFClient() + fake.functionInfo = nil + + p := NewProvisioner(Config{ProjectID: "proj1", Region: "us-central1"}, fake) + err := p.RemoveOrgFromMint(context.Background(), "acme") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +func TestRemoveOrgFromMint_GetFunctionError(t *testing.T) { + fake := newFakeGCFClient() + fake.errs["GetFunction"] = fmt.Errorf("permission denied") + + p := NewProvisioner(Config{ProjectID: "proj1", Region: "us-central1"}, fake) + err := p.RemoveOrgFromMint(context.Background(), "acme") + require.Error(t, err) + assert.Contains(t, err.Error(), "getting mint function") +} + +func TestRemoveOrgFromMint_LowercasesOrg(t *testing.T) { + fake := newFakeGCFClient() + fake.functionInfo = &FunctionInfo{ + URI: "https://mint.example.com", + EnvVars: map[string]string{ + "ALLOWED_ORGS": "acme", + "ROLE_APP_IDS": `{"acme/coder":"111"}`, + }, + } + + p := NewProvisioner(Config{ProjectID: "proj1", Region: "us-central1"}, fake) + err := p.RemoveOrgFromMint(context.Background(), "ACME") + require.NoError(t, err) + + assert.Equal(t, "", fake.lastUpdateFunctionEnvVars["ALLOWED_ORGS"]) +} + +func TestRemoveOrgFromMint_UpdateFails(t *testing.T) { + fake := newFakeGCFClient() + fake.functionInfo = &FunctionInfo{ + URI: "https://mint.example.com", + EnvVars: map[string]string{ + "ALLOWED_ORGS": "acme", + "ROLE_APP_IDS": `{"acme/coder":"111"}`, + }, + } + fake.errs["UpdateFunctionEnvVars"] = fmt.Errorf("permission denied") + + p := NewProvisioner(Config{ProjectID: "proj1", Region: "us-central1"}, fake) + err := p.RemoveOrgFromMint(context.Background(), "acme") + require.Error(t, err) + assert.Contains(t, err.Error(), "updating mint env vars") +} + +// --- RemoveRepoFromMint tests --- + +func TestRemoveRepoFromMint_RemovesRepo(t *testing.T) { + fake := newFakeGCFClient() + fake.functionInfo = &FunctionInfo{ + URI: "https://mint.example.com", + EnvVars: map[string]string{ + "PER_REPO_WIF_REPOS": "acme/first,acme/second", + }, + } + + p := NewProvisioner(Config{ProjectID: "proj1", Region: "us-central1"}, fake) + err := p.RemoveRepoFromMint(context.Background(), "acme/first") + require.NoError(t, err) + + assert.Contains(t, fake.calls, "UpdateFunctionEnvVars") + assert.Equal(t, "acme/second", fake.lastUpdateFunctionEnvVars["PER_REPO_WIF_REPOS"]) +} + +func TestRemoveRepoFromMint_LastRepo(t *testing.T) { + fake := newFakeGCFClient() + fake.functionInfo = &FunctionInfo{ + URI: "https://mint.example.com", + EnvVars: map[string]string{ + "PER_REPO_WIF_REPOS": "acme/only", + }, + } + + p := NewProvisioner(Config{ProjectID: "proj1", Region: "us-central1"}, fake) + err := p.RemoveRepoFromMint(context.Background(), "acme/only") + require.NoError(t, err) + + assert.Equal(t, "", fake.lastUpdateFunctionEnvVars["PER_REPO_WIF_REPOS"]) +} + +func TestRemoveRepoFromMint_FunctionNotFound(t *testing.T) { + fake := newFakeGCFClient() + fake.functionInfo = nil + + p := NewProvisioner(Config{ProjectID: "proj1", Region: "us-central1"}, fake) + err := p.RemoveRepoFromMint(context.Background(), "acme/repo") + require.Error(t, err) + assert.Contains(t, err.Error(), "mint function not found") +} + +func TestRemoveRepoFromMint_LowercasesRepo(t *testing.T) { + fake := newFakeGCFClient() + fake.functionInfo = &FunctionInfo{ + URI: "https://mint.example.com", + EnvVars: map[string]string{ + "PER_REPO_WIF_REPOS": "acme/widget", + }, + } + + p := NewProvisioner(Config{ProjectID: "proj1", Region: "us-central1"}, fake) + err := p.RemoveRepoFromMint(context.Background(), "Acme/Widget") + require.NoError(t, err) + + assert.Equal(t, "", fake.lastUpdateFunctionEnvVars["PER_REPO_WIF_REPOS"]) +} + +// --- DisablePEMSecrets tests --- + +func TestDisablePEMSecrets_DisablesExistingSecrets(t *testing.T) { + fake := newFakeGCFClient() + fake.secrets = map[string]bool{ + "fullsend-acme--coder-app-pem": true, + "fullsend-acme--triage-app-pem": true, + } + + p := NewProvisioner(Config{ProjectID: "proj1"}, fake) + err := p.DisablePEMSecrets(context.Background(), "acme", []string{"coder", "triage"}) + require.NoError(t, err) + + disableCount := 0 + for _, call := range fake.calls { + if call == "DisableSecretVersion" { + disableCount++ + } + } + assert.Equal(t, 2, disableCount) +} + +func TestDisablePEMSecrets_SkipsMissingSecrets(t *testing.T) { + fake := newFakeGCFClient() + fake.secrets = map[string]bool{} // All missing. + + p := NewProvisioner(Config{ProjectID: "proj1"}, fake) + err := p.DisablePEMSecrets(context.Background(), "acme", []string{"coder"}) + require.NoError(t, err) + + assert.NotContains(t, fake.calls, "DisableSecretVersion") +} + +func TestDisablePEMSecrets_GetSecretError(t *testing.T) { + fake := newFakeGCFClient() + fake.errs["GetSecret"] = fmt.Errorf("permission denied") + + p := NewProvisioner(Config{ProjectID: "proj1"}, fake) + err := p.DisablePEMSecrets(context.Background(), "acme", []string{"coder"}) + require.Error(t, err) + assert.Contains(t, err.Error(), "checking secret") +} + +// --- DeletePEMSecrets tests --- + +func TestDeletePEMSecrets_DeletesExistingSecrets(t *testing.T) { + fake := newFakeGCFClient() + fake.secrets = map[string]bool{ + "fullsend-acme--coder-app-pem": true, + } + + p := NewProvisioner(Config{ProjectID: "proj1"}, fake) + err := p.DeletePEMSecrets(context.Background(), "acme", []string{"coder"}) + require.NoError(t, err) + + assert.Contains(t, fake.calls, "DeleteSecret") +} + +func TestDeletePEMSecrets_SkipsMissingSecrets(t *testing.T) { + fake := newFakeGCFClient() + fake.secrets = map[string]bool{} + + p := NewProvisioner(Config{ProjectID: "proj1"}, fake) + err := p.DeletePEMSecrets(context.Background(), "acme", []string{"coder"}) + require.NoError(t, err) + + assert.NotContains(t, fake.calls, "DeleteSecret") +} + +// --- DisableWIFProvider tests --- + +func TestDisableWIFProvider_Success(t *testing.T) { + fake := newFakeGCFClient() + p := NewProvisioner(Config{ProjectID: "proj1"}, fake) + err := p.DisableWIFProvider(context.Background(), "gh-acme-widget") + require.NoError(t, err) + + assert.Contains(t, fake.calls, "GetProjectNumber") + assert.Contains(t, fake.calls, "DisableWIFProvider") +} + +func TestDisableWIFProvider_GetProjectNumberError(t *testing.T) { + fake := newFakeGCFClient() + fake.errs["GetProjectNumber"] = fmt.Errorf("permission denied") + + p := NewProvisioner(Config{ProjectID: "proj1"}, fake) + err := p.DisableWIFProvider(context.Background(), "gh-acme-widget") + require.Error(t, err) + assert.Contains(t, err.Error(), "getting project number") +} + +// --- DeleteWIFProvider tests --- + +func TestDeleteWIFProvider_Success(t *testing.T) { + fake := newFakeGCFClient() + p := NewProvisioner(Config{ProjectID: "proj1"}, fake) + err := p.DeleteWIFProvider(context.Background(), "gh-acme-widget") + require.NoError(t, err) + + assert.Contains(t, fake.calls, "GetProjectNumber") + assert.Contains(t, fake.calls, "DeleteWIFProvider") +} + +func TestDeleteWIFProvider_GetProjectNumberError(t *testing.T) { + fake := newFakeGCFClient() + fake.errs["GetProjectNumber"] = fmt.Errorf("permission denied") + + p := NewProvisioner(Config{ProjectID: "proj1"}, fake) + err := p.DeleteWIFProvider(context.Background(), "gh-acme-widget") + require.Error(t, err) + assert.Contains(t, err.Error(), "getting project number") +} + +// --- ValidateProjectID and ValidateRegion tests --- + +func TestValidateProjectID(t *testing.T) { + assert.True(t, ValidateProjectID("my-project-id")) + assert.True(t, ValidateProjectID("project-123456")) + assert.False(t, ValidateProjectID("BAD")) + assert.False(t, ValidateProjectID("")) + assert.False(t, ValidateProjectID("ab")) // too short +} + +func TestValidateRegion(t *testing.T) { + assert.True(t, ValidateRegion("us-central1")) + assert.True(t, ValidateRegion("europe-west4")) + assert.False(t, ValidateRegion("invalid")) + assert.False(t, ValidateRegion("")) +}