diff --git a/cmd/iam/workloadidentityfederation/bootstrap.go b/cmd/iam/workloadidentityfederation/bootstrap.go index c7f104f..be4410d 100644 --- a/cmd/iam/workloadidentityfederation/bootstrap.go +++ b/cmd/iam/workloadidentityfederation/bootstrap.go @@ -14,7 +14,8 @@ import ( // Persistent flags shared by all bootstrap subcommands. var ( - flagRole string + flagRoles []string + flagPolicies []string flagTrustedAudiences []string flagScopes []string flagProviderName string @@ -28,7 +29,9 @@ var bootstrapCmd = &cobra.Command{ Use: "bootstrap", Short: "Provision workload identity for GitHub, GitLab, or Kubernetes", Long: fmt.Sprintf(`Creates (when missing) a federated OIDC identity provider, a Thalassa service account, -a role binding to your organisation role, and a federated identity for the workload JWT subject. +bindings to organisation role(s) and/or IAM policy(ies), and a federated identity for the workload JWT subject. + +Provide at least one --role or --policy (both may be repeated and combined). Resources are labelled %s=%s and %s=. @@ -41,7 +44,11 @@ Subcommands: } func executeBootstrap(cmd *cobra.Command, opts BootstrapOptions) error { - opts.RoleRef = strings.TrimSpace(flagRole) + opts.RoleRefs = normalizeStringRefs(flagRoles) + opts.PolicyRefs = normalizeStringRefs(flagPolicies) + if len(opts.RoleRefs) == 0 && len(opts.PolicyRefs) == 0 { + return fmt.Errorf("at least one --role or --policy is required") + } opts.ProviderDisplayName = strings.TrimSpace(flagProviderName) opts.ProviderDescription = strings.TrimSpace(flagProviderDesc) opts.ResourceName = strings.TrimSpace(flagBootstrapName) @@ -82,6 +89,30 @@ func executeBootstrap(cmd *cobra.Command, opts BootstrapOptions) error { return nil } +// normalizeStringRefs trims and de-duplicates flag values (case-insensitive on the raw ref). +func normalizeStringRefs(refs []string) []string { + seen := make(map[string]struct{}, len(refs)) + out := make([]string, 0, len(refs)) + for _, ref := range refs { + ref = strings.TrimSpace(ref) + if ref == "" { + continue + } + key := strings.ToLower(ref) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + out = append(out, ref) + } + return out +} + +// normalizeRoleRefs is kept for tests; prefer normalizeStringRefs. +func normalizeRoleRefs(refs []string) []string { + return normalizeStringRefs(refs) +} + // parseGitHubRefKind parses --ref-kind for the github bootstrap subcommand. func parseGitHubRefKind(s string) (RefKind, error) { s = strings.ToLower(strings.TrimSpace(s)) @@ -98,7 +129,8 @@ func parseGitHubRefKind(s string) (RefKind, error) { func init() { p := bootstrapCmd.PersistentFlags() - p.StringVar(&flagRole, "role", "", "Organisation role identity, slug, or name (required)") + p.StringSliceVar(&flagRoles, "role", nil, "Organisation role identity, slug, or name (repeatable; at least one --role or --policy required)") + p.StringSliceVar(&flagPolicies, "policy", nil, "IAM policy identity, slug, or name (repeatable; at least one --role or --policy required)") p.StringSliceVar(&flagTrustedAudiences, "trusted-audience", nil, "JWT aud values to trust (repeatable; default: current context API URL, e.g. https://api.thalassa.cloud)") p.StringSliceVar(&flagScopes, "scope", nil, "Federated identity allowed scopes: api:read, api:write, kubernetes, objectStorage (default: api:read,api:write)") p.StringVar(&flagProviderName, "provider-name", "", "Optional display name when creating the federated identity provider") @@ -107,8 +139,8 @@ func init() { p.BoolVar(&flagDryRun, "dry-run", false, "Print planned changes without calling the API") p.BoolVar(&flagNoHints, "no-hints", false, "Do not print platform hints after bootstrap") - _ = bootstrapCmd.MarkPersistentFlagRequired("role") _ = bootstrapCmd.RegisterFlagCompletionFunc("role", completion.CompleteIAMOrganisationRoleIdentityFlag) + _ = bootstrapCmd.RegisterFlagCompletionFunc("policy", completion.CompleteIAMPolicyIdentityFlag) bootstrapCmd.AddCommand(bootstrapGitHubCmd, bootstrapGitLabCmd, bootstrapKubernetesCmd) } diff --git a/cmd/iam/workloadidentityfederation/bootstrap_github.go b/cmd/iam/workloadidentityfederation/bootstrap_github.go index d921a86..f13af4a 100644 --- a/cmd/iam/workloadidentityfederation/bootstrap_github.go +++ b/cmd/iam/workloadidentityfederation/bootstrap_github.go @@ -26,6 +26,12 @@ The JWT issuer is https://token.actions.githubusercontent.com. Match subjects wi Example: ` # Main branch (JWT aud defaults to context API URL) tcloud iam workload-identity-federation bootstrap github --repository acme/api --ref main --role deployer + # Bind IAM policies instead of (or in addition to) organisation roles + tcloud iam workload-identity-federation bootstrap github --repository acme/api --ref main --policy ci-deploy --policy ci-read + + # Multiple organisation roles + tcloud iam workload-identity-federation bootstrap github --repository acme/api --ref main --role deployer --role reader + # Specific ref kind tcloud iam workload-identity-federation bootstrap github --repository acme/api --ref-kind branch --ref main --role deployer diff --git a/cmd/iam/workloadidentityfederation/bootstrap_gitlab.go b/cmd/iam/workloadidentityfederation/bootstrap_gitlab.go index c42696a..1eccd6a 100644 --- a/cmd/iam/workloadidentityfederation/bootstrap_gitlab.go +++ b/cmd/iam/workloadidentityfederation/bootstrap_gitlab.go @@ -26,6 +26,12 @@ The GitLab id_token sub uses project_path::ref_type::ref:: to a Thalassa service account via a federated identity. Thalassa clusters: pass --cluster to resolve the cluster and use the platform-managed federated identity @@ -32,6 +32,14 @@ creates the federated identity provider if it does not exist yet.`, tcloud iam workload-identity-federation bootstrap kubernetes --cluster my-cluster-slug \ --namespace default --service-account my-app --role deployer + # Bind IAM policies + tcloud iam workload-identity-federation bootstrap kubernetes --cluster my-cluster-slug \ + --namespace default --service-account my-app --policy ci-deploy + + # Multiple organisation roles + tcloud iam workload-identity-federation bootstrap kubernetes --cluster my-cluster-slug \ + --namespace default --service-account my-app --role deployer --role reader + # Self-managed / custom issuer tcloud iam workload-identity-federation bootstrap kubernetes --issuer https://k8s.example.com \ --namespace cicd --service-account terraform --role deployer`, diff --git a/cmd/iam/workloadidentityfederation/bootstrap_output.go b/cmd/iam/workloadidentityfederation/bootstrap_output.go index b49363a..1214dbf 100644 --- a/cmd/iam/workloadidentityfederation/bootstrap_output.go +++ b/cmd/iam/workloadidentityfederation/bootstrap_output.go @@ -39,18 +39,39 @@ func termDim(s string) string { return "\x1b[2m" + s + "\x1b[0m" } -func printBootstrapOutcome(vcs string, res *BootstrapResult, dry bool) { - check := termGreen("✔") - would := "○" +func bootstrapOutcomeMarkers() (check, would string) { + check = termGreen("✔") + would = "○" if stdoutIsTTY() { would = "\x1b[33m○\x1b[0m" // amber for planned } + return check, would +} + +func printBootstrapOutcome(vcs string, res *BootstrapResult, dry bool) { + check, would := bootstrapOutcomeMarkers() fmt.Printf("%s Bootstrap workload identity (%s)\n\n", termCyan("►"), termBold(vcs)) - fmt.Printf("%s Organisation role - %s %s\n", check, res.RoleSlug, termDim("("+res.RoleIdentity+")")) + for _, role := range res.Roles { + fmt.Printf("%s Organisation role - %s %s\n", check, role.Slug, termDim("("+role.Identity+")")) + } + for _, policy := range res.Policies { + fmt.Printf("%s IAM policy - %s %s\n", check, policy.Slug, termDim("("+policy.Identity+")")) + } + + printBootstrapProviderOutcome(check, would, res, dry) + printBootstrapServiceAccountOutcome(check, would, res, dry) + printBootstrapFederatedIdentityOutcome(check, would, res, dry) + printBootstrapRoleBindingOutcomes(check, would, res.Roles, dry) + printBootstrapPolicyBindingOutcomes(check, would, res.Policies, dry) + + fmt.Println() + fmt.Printf(" %s %s\n", termDim("issuer:"), res.Issuer) + fmt.Printf(" %s %s\n", termDim("JWT sub:"), res.ProviderSubject) +} - // Federated identity provider (OIDC issuer registration) +func printBootstrapProviderOutcome(check, would string, res *BootstrapResult, dry bool) { switch { case dry && res.WouldCreateProvider: fmt.Printf("%s Federated identity provider - would create %s\n", would, termDim("("+res.Issuer+")")) @@ -61,8 +82,9 @@ func printBootstrapOutcome(vcs string, res *BootstrapResult, dry bool) { default: fmt.Printf("%s Federated identity provider - already present %s\n", check, res.ProviderIdentity) } +} - // Thalassa service account +func printBootstrapServiceAccountOutcome(check, would string, res *BootstrapResult, dry bool) { switch { case dry && res.WouldCreateServiceAccount: fmt.Printf("%s Service account - would create\n", would) @@ -73,8 +95,9 @@ func printBootstrapOutcome(vcs string, res *BootstrapResult, dry bool) { default: fmt.Printf("%s Service account - already present %s %s\n", check, res.ServiceAccountIdentity, termDim("("+res.ServiceAccountSlug+")")) } +} - // Federated identity (JWT subject → service account) +func printBootstrapFederatedIdentityOutcome(check, would string, res *BootstrapResult, dry bool) { switch { case dry && res.WouldCreateFederatedIdentity: fmt.Printf("%s Federated identity - would create %s\n", would, termDim("("+res.ProviderSubject+")")) @@ -89,20 +112,37 @@ func printBootstrapOutcome(vcs string, res *BootstrapResult, dry bool) { default: fmt.Printf("%s Federated identity - already present %s\n", check, res.FederatedIdentityIdentity) } +} - // Organisation role binding +func printBootstrapBindingLine(check, would, kind, label string, dry, wouldCreate, created bool) { switch { - case dry && res.WouldCreateRoleBinding: - fmt.Printf("%s Organisation role binding - would create\n", would) + case dry && wouldCreate: + fmt.Printf("%s %s (%s) - would create\n", would, kind, label) case dry: - fmt.Printf("%s Organisation role binding - already present\n", check) - case res.CreatedRoleBinding: - fmt.Printf("%s Organisation role binding - created\n", check) + fmt.Printf("%s %s (%s) - already present\n", check, kind, label) + case created: + fmt.Printf("%s %s (%s) - created\n", check, kind, label) default: - fmt.Printf("%s Organisation role binding - already present\n", check) + fmt.Printf("%s %s (%s) - already present\n", check, kind, label) } +} - fmt.Println() - fmt.Printf(" %s %s\n", termDim("issuer:"), res.Issuer) - fmt.Printf(" %s %s\n", termDim("JWT sub:"), res.ProviderSubject) +func printBootstrapRoleBindingOutcomes(check, would string, roles []BootstrapRoleResult, dry bool) { + for _, role := range roles { + label := role.Slug + if label == "" { + label = role.Identity + } + printBootstrapBindingLine(check, would, "Organisation role binding", label, dry, role.WouldCreateBinding, role.CreatedBinding) + } +} + +func printBootstrapPolicyBindingOutcomes(check, would string, policies []BootstrapPolicyResult, dry bool) { + for _, policy := range policies { + label := policy.Slug + if label == "" { + label = policy.Identity + } + printBootstrapBindingLine(check, would, "IAM policy binding", label, dry, policy.WouldCreateBinding, policy.CreatedBinding) + } } diff --git a/cmd/iam/workloadidentityfederation/bootstrap_run.go b/cmd/iam/workloadidentityfederation/bootstrap_run.go index 945ebab..72df343 100644 --- a/cmd/iam/workloadidentityfederation/bootstrap_run.go +++ b/cmd/iam/workloadidentityfederation/bootstrap_run.go @@ -227,6 +227,64 @@ func resolveOrganisationRole(ctx context.Context, c *clientiam.Client, ref strin return nil, fmt.Errorf("organisation role not found: %s", ref) } +// resolveOrganisationRoles resolves each --role ref and de-duplicates by role identity. +// An empty refs slice returns an empty result (roles are optional when --policy is set). +func resolveOrganisationRoles(ctx context.Context, c *clientiam.Client, refs []string) ([]*clientiam.OrganisationRole, error) { + out := make([]*clientiam.OrganisationRole, 0, len(refs)) + seen := make(map[string]struct{}, len(refs)) + for _, ref := range refs { + role, err := resolveOrganisationRole(ctx, c, ref) + if err != nil { + return nil, err + } + if _, ok := seen[role.Identity]; ok { + continue + } + seen[role.Identity] = struct{}{} + out = append(out, role) + } + return out, nil +} + +func resolveIamPolicy(ctx context.Context, c *clientiam.Client, ref string) (*clientiam.IamPolicy, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return nil, fmt.Errorf("policy is required") + } + if policy, err := c.GetIamPolicy(ctx, ref); err == nil && policy != nil { + return policy, nil + } + policies, err := c.ListIamPolicies(ctx, &clientiam.ListIamPoliciesRequest{}) + if err != nil { + return nil, fmt.Errorf("list IAM policies: %w", err) + } + for i := range policies { + p := &policies[i] + if strings.EqualFold(p.Identity, ref) || strings.EqualFold(p.Slug, ref) || strings.EqualFold(p.Name, ref) { + return p, nil + } + } + return nil, fmt.Errorf("IAM policy not found: %s", ref) +} + +// resolveIamPolicies resolves each --policy ref and de-duplicates by policy identity. +func resolveIamPolicies(ctx context.Context, c *clientiam.Client, refs []string) ([]*clientiam.IamPolicy, error) { + out := make([]*clientiam.IamPolicy, 0, len(refs)) + seen := make(map[string]struct{}, len(refs)) + for _, ref := range refs { + policy, err := resolveIamPolicy(ctx, c, ref) + if err != nil { + return nil, err + } + if _, ok := seen[policy.Identity]; ok { + continue + } + seen[policy.Identity] = struct{}{} + out = append(out, policy) + } + return out, nil +} + func hasRoleBindingForServiceAccount(ctx context.Context, c *clientiam.Client, roleIdentity, saIdentity string) (bool, error) { bindings, err := c.ListRoleBindings(ctx, roleIdentity, &clientiam.ListRoleBindingsRequest{}) if err != nil { @@ -240,6 +298,19 @@ func hasRoleBindingForServiceAccount(ctx context.Context, c *clientiam.Client, r return false, nil } +func hasPolicyBindingForServiceAccount(ctx context.Context, c *clientiam.Client, policyIdentity, saIdentity string) (bool, error) { + bindings, err := c.ListIamPolicyBindings(ctx, policyIdentity, &clientiam.ListIamPolicyBindingsRequest{}) + if err != nil { + return false, err + } + for _, b := range bindings { + if b.ServiceAccount != nil && b.ServiceAccount.Identity == saIdentity { + return true, nil + } + } + return false, nil +} + func createRoleBindingForSA(ctx context.Context, c *clientiam.Client, role *clientiam.OrganisationRole, sa *clientiam.ServiceAccount, vcs, key string) (*clientiam.OrganisationRoleBinding, error) { name := fmt.Sprintf("wif-%s-%s", vcs, key) if len(name) > 63 { @@ -258,6 +329,24 @@ func createRoleBindingForSA(ctx context.Context, c *clientiam.Client, role *clie return binding, nil } +func createPolicyBindingForSA(ctx context.Context, c *clientiam.Client, policy *clientiam.IamPolicy, sa *clientiam.ServiceAccount, vcs, key string) (*clientiam.IamPolicyBinding, error) { + name := fmt.Sprintf("wif-%s-%s", vcs, key) + if len(name) > 63 { + name = name[:63] + } + saID := sa.Identity + binding, err := c.CreateIamPolicyBinding(ctx, policy.Identity, clientiam.CreateIamPolicyBindingRequest{ + Name: name, + Description: fmt.Sprintf("Workload identity federation (%s) for Thalassa service account %s", vcs, sa.Slug), + Labels: bootstrapLabels(vcs, key), + ServiceAccountIdentity: &saID, + }) + if err != nil { + return nil, err + } + return binding, nil +} + func findFederatedIdentity(ctx context.Context, c *clientiam.Client, vcs, key string) (*clientiam.FederatedIdentity, error) { want := bootstrapLabels(vcs, key) list, err := c.ListFederatedIdentities(ctx, &clientiam.ListFederatedIdentitiesRequest{ @@ -318,12 +407,33 @@ func RunBootstrap(ctx context.Context, client thalassa.Client, opts BootstrapOpt scopes := defaultBootstrapScopes(opts) - role, err := resolveOrganisationRole(ctx, iamc, opts.RoleRef) + if len(opts.RoleRefs) == 0 && len(opts.PolicyRefs) == 0 { + return nil, fmt.Errorf("at least one --role or --policy is required") + } + + roles, err := resolveOrganisationRoles(ctx, iamc, opts.RoleRefs) if err != nil { return nil, err } - res.RoleIdentity = role.Identity - res.RoleSlug = role.Slug + res.Roles = make([]BootstrapRoleResult, len(roles)) + for i, role := range roles { + res.Roles[i] = BootstrapRoleResult{ + Identity: role.Identity, + Slug: role.Slug, + } + } + + policies, err := resolveIamPolicies(ctx, iamc, opts.PolicyRefs) + if err != nil { + return nil, err + } + res.Policies = make([]BootstrapPolicyResult, len(policies)) + for i, policy := range policies { + res.Policies[i] = BootstrapPolicyResult{ + Identity: policy.Identity, + Slug: policy.Slug, + } + } provider, err := ensureBootstrapProvider(ctx, iamc, opts, issuerSubject.issuer, issuerSubject.k8sClusterBoundProvider, res) if err != nil { @@ -341,7 +451,10 @@ func RunBootstrap(ctx context.Context, client thalassa.Client, opts BootstrapOpt return nil, err } - if err := ensureBootstrapRoleBinding(ctx, iamc, opts, role, sa, key, res); err != nil { + if err := ensureBootstrapRoleBindings(ctx, iamc, opts, roles, sa, key, res); err != nil { + return nil, err + } + if err := ensureBootstrapPolicyBindings(ctx, iamc, opts, policies, sa, key, res); err != nil { return nil, err } diff --git a/cmd/iam/workloadidentityfederation/bootstrap_run_helpers.go b/cmd/iam/workloadidentityfederation/bootstrap_run_helpers.go index 3852446..bbe15c3 100644 --- a/cmd/iam/workloadidentityfederation/bootstrap_run_helpers.go +++ b/cmd/iam/workloadidentityfederation/bootstrap_run_helpers.go @@ -242,6 +242,27 @@ func ensureBootstrapFederatedIdentity( return fi, nil } +func ensureBootstrapRoleBindings( + ctx context.Context, + iamc *clientiam.Client, + opts BootstrapOptions, + roles []*clientiam.OrganisationRole, + sa *clientiam.ServiceAccount, + key string, + res *BootstrapResult, +) error { + for i, role := range roles { + if i >= len(res.Roles) { + return fmt.Errorf("internal: role result missing for %s", role.Identity) + } + outcome := &res.Roles[i] + if err := ensureBootstrapRoleBinding(ctx, iamc, opts, role, sa, key, outcome, res.WouldCreateServiceAccount); err != nil { + return err + } + } + return nil +} + func ensureBootstrapRoleBinding( ctx context.Context, iamc *clientiam.Client, @@ -249,19 +270,20 @@ func ensureBootstrapRoleBinding( role *clientiam.OrganisationRole, sa *clientiam.ServiceAccount, key string, - res *BootstrapResult, + outcome *BootstrapRoleResult, + wouldCreateServiceAccount bool, ) error { if opts.DryRun { if sa != nil { ok, err := hasRoleBindingForServiceAccount(ctx, iamc, role.Identity, sa.Identity) if err != nil { - return fmt.Errorf("list role bindings: %w", err) + return fmt.Errorf("list role bindings for role %s: %w", role.Slug, err) } if !ok { - res.WouldCreateRoleBinding = true + outcome.WouldCreateBinding = true } - } else if res.WouldCreateServiceAccount { - res.WouldCreateRoleBinding = true + } else if wouldCreateServiceAccount { + outcome.WouldCreateBinding = true } return nil } @@ -272,7 +294,7 @@ func ensureBootstrapRoleBinding( ok, err := hasRoleBindingForServiceAccount(ctx, iamc, role.Identity, sa.Identity) if err != nil { - return fmt.Errorf("list role bindings: %w", err) + return fmt.Errorf("list role bindings for role %s: %w", role.Slug, err) } if ok { return nil @@ -280,9 +302,79 @@ func ensureBootstrapRoleBinding( _, err = createRoleBindingForSA(ctx, iamc, role, sa, opts.VCS, key) if err != nil { - return fmt.Errorf("create role binding: %w", err) + return fmt.Errorf("create role binding for role %s: %w", role.Slug, err) + } + outcome.CreatedBinding = true + return nil +} + +func ensureBootstrapPolicyBindings( + ctx context.Context, + iamc *clientiam.Client, + opts BootstrapOptions, + policies []*clientiam.IamPolicy, + sa *clientiam.ServiceAccount, + key string, + res *BootstrapResult, +) error { + for i, policy := range policies { + if i >= len(res.Policies) { + return fmt.Errorf("internal: policy result missing for %s", policy.Identity) + } + outcome := &res.Policies[i] + if err := ensureBootstrapPolicyBinding(ctx, iamc, opts, policy, sa, key, outcome, res.WouldCreateServiceAccount); err != nil { + return err + } + } + return nil +} + +func ensureBootstrapPolicyBinding( + ctx context.Context, + iamc *clientiam.Client, + opts BootstrapOptions, + policy *clientiam.IamPolicy, + sa *clientiam.ServiceAccount, + key string, + outcome *BootstrapPolicyResult, + wouldCreateServiceAccount bool, +) error { + label := policy.Slug + if label == "" { + label = policy.Identity + } + if opts.DryRun { + if sa != nil { + ok, err := hasPolicyBindingForServiceAccount(ctx, iamc, policy.Identity, sa.Identity) + if err != nil { + return fmt.Errorf("list policy bindings for policy %s: %w", label, err) + } + if !ok { + outcome.WouldCreateBinding = true + } + } else if wouldCreateServiceAccount { + outcome.WouldCreateBinding = true + } + return nil + } + + if sa == nil { + return nil + } + + ok, err := hasPolicyBindingForServiceAccount(ctx, iamc, policy.Identity, sa.Identity) + if err != nil { + return fmt.Errorf("list policy bindings for policy %s: %w", label, err) + } + if ok { + return nil + } + + _, err = createPolicyBindingForSA(ctx, iamc, policy, sa, opts.VCS, key) + if err != nil { + return fmt.Errorf("create policy binding for policy %s: %w", label, err) } - res.CreatedRoleBinding = true + outcome.CreatedBinding = true return nil } diff --git a/cmd/iam/workloadidentityfederation/bootstrap_run_test.go b/cmd/iam/workloadidentityfederation/bootstrap_run_test.go index acc5709..d89cb64 100644 --- a/cmd/iam/workloadidentityfederation/bootstrap_run_test.go +++ b/cmd/iam/workloadidentityfederation/bootstrap_run_test.go @@ -60,3 +60,32 @@ func TestScopesEqual(t *testing.T) { assert.True(t, scopesEqual(nil, []clientiam.AccessCredentialsScope{})) assert.False(t, scopesEqual(a, c)) } + +func TestNormalizeRoleRefs(t *testing.T) { + tests := []struct { + name string + in []string + want []string + }{ + { + name: "trims and drops empties", + in: []string{" deployer ", "", "reader"}, + want: []string{"deployer", "reader"}, + }, + { + name: "dedupes case-insensitively preserving first casing", + in: []string{"Deployer", "deployer", "READER", "reader"}, + want: []string{"Deployer", "READER"}, + }, + { + name: "nil input", + in: nil, + want: []string{}, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + assert.Equal(t, tt.want, normalizeRoleRefs(tt.in)) + }) + } +} diff --git a/cmd/iam/workloadidentityfederation/bootstrap_types.go b/cmd/iam/workloadidentityfederation/bootstrap_types.go index 808dcf5..b69ebd0 100644 --- a/cmd/iam/workloadidentityfederation/bootstrap_types.go +++ b/cmd/iam/workloadidentityfederation/bootstrap_types.go @@ -7,9 +7,10 @@ type BootstrapOptions struct { VCS string // ValueVCSGitHub, ValueVCSGitLab, or ValueVCSKubernetes Repository string // owner/repo (GitHub), group/project (GitLab), or namespace/sa (Kubernetes) RefKind RefKind - Ref string // branch name, tag name, or environment name (GitHub) - GitLabRefType string // branch, tag, etc. (GitLab id_token sub ref_type segment) - RoleRef string // organisation role identity, slug, or name + Ref string // branch name, tag name, or environment name (GitHub) + GitLabRefType string // branch, tag, etc. (GitLab id_token sub ref_type segment) + RoleRefs []string // organisation role identities, slugs, or names + PolicyRefs []string // IAM policy identities, slugs, or names TrustedAudiences []string AllowedScopes []clientiam.AccessCredentialsScope @@ -31,6 +32,22 @@ type BootstrapOptions struct { DryRun bool } +// BootstrapRoleResult is one organisation role involved in bootstrap and its binding outcome. +type BootstrapRoleResult struct { + Identity string + Slug string + CreatedBinding bool + WouldCreateBinding bool +} + +// BootstrapPolicyResult is one IAM policy involved in bootstrap and its binding outcome. +type BootstrapPolicyResult struct { + Identity string + Slug string + CreatedBinding bool + WouldCreateBinding bool +} + // BootstrapResult summarises what bootstrap did or would do. type BootstrapResult struct { WIFKey string @@ -39,18 +56,16 @@ type BootstrapResult struct { ProviderIdentity string ServiceAccountIdentity string ServiceAccountSlug string - RoleIdentity string - RoleSlug string + Roles []BootstrapRoleResult + Policies []BootstrapPolicyResult FederatedIdentityIdentity string CreatedProvider bool CreatedServiceAccount bool - CreatedRoleBinding bool CreatedFederatedIdentity bool UpdatedFederatedIdentity bool // existing FI reconciled to bootstrap (scopes, audiences, labels, etc.) WouldCreateProvider bool WouldCreateServiceAccount bool - WouldCreateRoleBinding bool WouldCreateFederatedIdentity bool WouldUpdateFederatedIdentity bool // dry-run: existing FI would be reconciled } diff --git a/docs/tcloud/iam/workload-identity-federation/_index.md b/docs/tcloud/iam/workload-identity-federation/_index.md index 555c3ff..95eba5d 100644 --- a/docs/tcloud/iam/workload-identity-federation/_index.md +++ b/docs/tcloud/iam/workload-identity-federation/_index.md @@ -3,7 +3,7 @@ linkTitle: "tcloud iam workload-identity-federation" title: "iam workload-identity-federation" slug: tcloud_iam_workload-identity-federation url: /docs/tcloud/iam/workload-identity-federation/ -weight: 9871 +weight: 9868 cascade: type: docs --- @@ -31,6 +31,7 @@ and federated identities for GitHub Actions, GitLab CI, and Kubernetes service a --client-secret string OIDC client secret for OIDC authentication (overrides context) -c, --context string Context name --debug Debug mode + --ignore-dir-config Ignore directory-local .thalassa defaults -O, --organisation string Organisation slug or identity (overrides context) -P, --project string Project identity (overrides context; slug is resolved to identity; use "root" for organisation scope) --token string Personal access token (overrides context) diff --git a/docs/tcloud/iam/workload-identity-federation_bootstrap/_index.md b/docs/tcloud/iam/workload-identity-federation_bootstrap/_index.md index f29b5e8..5de6ec7 100644 --- a/docs/tcloud/iam/workload-identity-federation_bootstrap/_index.md +++ b/docs/tcloud/iam/workload-identity-federation_bootstrap/_index.md @@ -3,7 +3,7 @@ linkTitle: "tcloud iam workload-identity-federation bootstrap" title: "iam workload-identity-federation bootstrap" slug: tcloud_iam_workload-identity-federation_bootstrap url: /docs/tcloud/iam/workload-identity-federation_bootstrap/ -weight: 9872 +weight: 9869 cascade: type: docs --- @@ -14,7 +14,9 @@ Provision workload identity for GitHub, GitLab, or Kubernetes ### Synopsis Creates (when missing) a federated OIDC identity provider, a Thalassa service account, -a role binding to your organisation role, and a federated identity for the workload JWT subject. +bindings to organisation role(s) and/or IAM policy(ies), and a federated identity for the workload JWT subject. + +Provide at least one --role or --policy (both may be repeated and combined). Resources are labelled thalassa.cloud/managed-by=workload-identity-bootstrap and thalassa.cloud/wif-vcs=. @@ -31,9 +33,10 @@ Subcommands: -h, --help help for bootstrap --name string Base name for the Thalassa service account and federated identity (federated identity becomes -fi; default: wif--) --no-hints Do not print platform hints after bootstrap + --policy strings IAM policy identity, slug, or name (repeatable; at least one --role or --policy required) --provider-description string Optional description when creating the federated identity provider --provider-name string Optional display name when creating the federated identity provider - --role string Organisation role identity, slug, or name (required) + --role strings Organisation role identity, slug, or name (repeatable; at least one --role or --policy required) --scope strings Federated identity allowed scopes: api:read, api:write, kubernetes, objectStorage (default: api:read,api:write) --trusted-audience strings JWT aud values to trust (repeatable; default: current context API URL, e.g. https://api.thalassa.cloud) ``` @@ -47,6 +50,7 @@ Subcommands: --client-secret string OIDC client secret for OIDC authentication (overrides context) -c, --context string Context name --debug Debug mode + --ignore-dir-config Ignore directory-local .thalassa defaults -O, --organisation string Organisation slug or identity (overrides context) -P, --project string Project identity (overrides context; slug is resolved to identity; use "root" for organisation scope) --token string Personal access token (overrides context) diff --git a/docs/tcloud/iam/workload-identity-federation_bootstrap_github/_index.md b/docs/tcloud/iam/workload-identity-federation_bootstrap_github/_index.md index 342589d..f2d9757 100644 --- a/docs/tcloud/iam/workload-identity-federation_bootstrap_github/_index.md +++ b/docs/tcloud/iam/workload-identity-federation_bootstrap_github/_index.md @@ -3,7 +3,7 @@ linkTitle: "tcloud iam workload-identity-federation bootstrap github" title: "iam workload-identity-federation bootstrap github" slug: tcloud_iam_workload-identity-federation_bootstrap_github url: /docs/tcloud/iam/workload-identity-federation_bootstrap_github/ -weight: 9875 +weight: 9872 cascade: type: docs --- @@ -29,6 +29,12 @@ tcloud iam workload-identity-federation bootstrap github [flags] # Main branch (JWT aud defaults to context API URL) tcloud iam workload-identity-federation bootstrap github --repository acme/api --ref main --role deployer + # Bind IAM policies instead of (or in addition to) organisation roles + tcloud iam workload-identity-federation bootstrap github --repository acme/api --ref main --policy ci-deploy --policy ci-read + + # Multiple organisation roles + tcloud iam workload-identity-federation bootstrap github --repository acme/api --ref main --role deployer --role reader + # Specific ref kind tcloud iam workload-identity-federation bootstrap github --repository acme/api --ref-kind branch --ref main --role deployer @@ -58,13 +64,15 @@ tcloud iam workload-identity-federation bootstrap github [flags] -c, --context string Context name --debug Debug mode --dry-run Print planned changes without calling the API + --ignore-dir-config Ignore directory-local .thalassa defaults --name string Base name for the Thalassa service account and federated identity (federated identity becomes -fi; default: wif--) --no-hints Do not print platform hints after bootstrap -O, --organisation string Organisation slug or identity (overrides context) + --policy strings IAM policy identity, slug, or name (repeatable; at least one --role or --policy required) -P, --project string Project identity (overrides context; slug is resolved to identity; use "root" for organisation scope) --provider-description string Optional description when creating the federated identity provider --provider-name string Optional display name when creating the federated identity provider - --role string Organisation role identity, slug, or name (required) + --role strings Organisation role identity, slug, or name (repeatable; at least one --role or --policy required) --scope strings Federated identity allowed scopes: api:read, api:write, kubernetes, objectStorage (default: api:read,api:write) --token string Personal access token (overrides context) --trusted-audience strings JWT aud values to trust (repeatable; default: current context API URL, e.g. https://api.thalassa.cloud) diff --git a/docs/tcloud/iam/workload-identity-federation_bootstrap_gitlab/_index.md b/docs/tcloud/iam/workload-identity-federation_bootstrap_gitlab/_index.md index 9410da4..311d747 100644 --- a/docs/tcloud/iam/workload-identity-federation_bootstrap_gitlab/_index.md +++ b/docs/tcloud/iam/workload-identity-federation_bootstrap_gitlab/_index.md @@ -3,7 +3,7 @@ linkTitle: "tcloud iam workload-identity-federation bootstrap gitlab" title: "iam workload-identity-federation bootstrap gitlab" slug: tcloud_iam_workload-identity-federation_bootstrap_gitlab url: /docs/tcloud/iam/workload-identity-federation_bootstrap_gitlab/ -weight: 9874 +weight: 9871 cascade: type: docs --- @@ -28,6 +28,12 @@ tcloud iam workload-identity-federation bootstrap gitlab [flags] # GitLab.com, branch main tcloud iam workload-identity-federation bootstrap gitlab --repository mygroup/myproject --ref main --role deployer + # Bind IAM policies + tcloud iam workload-identity-federation bootstrap gitlab --repository mygroup/myproject --ref main --policy ci-deploy + + # Multiple organisation roles + tcloud iam workload-identity-federation bootstrap gitlab --repository mygroup/myproject --ref main --role deployer --role reader + # Tag pipeline tcloud iam workload-identity-federation bootstrap gitlab --repository mygroup/myproject --ref v1.0.0 --ref-type tag --role deployer @@ -55,13 +61,15 @@ tcloud iam workload-identity-federation bootstrap gitlab [flags] -c, --context string Context name --debug Debug mode --dry-run Print planned changes without calling the API + --ignore-dir-config Ignore directory-local .thalassa defaults --name string Base name for the Thalassa service account and federated identity (federated identity becomes -fi; default: wif--) --no-hints Do not print platform hints after bootstrap -O, --organisation string Organisation slug or identity (overrides context) + --policy strings IAM policy identity, slug, or name (repeatable; at least one --role or --policy required) -P, --project string Project identity (overrides context; slug is resolved to identity; use "root" for organisation scope) --provider-description string Optional description when creating the federated identity provider --provider-name string Optional display name when creating the federated identity provider - --role string Organisation role identity, slug, or name (required) + --role strings Organisation role identity, slug, or name (repeatable; at least one --role or --policy required) --scope strings Federated identity allowed scopes: api:read, api:write, kubernetes, objectStorage (default: api:read,api:write) --token string Personal access token (overrides context) --trusted-audience strings JWT aud values to trust (repeatable; default: current context API URL, e.g. https://api.thalassa.cloud) diff --git a/docs/tcloud/iam/workload-identity-federation_bootstrap_kubernetes/_index.md b/docs/tcloud/iam/workload-identity-federation_bootstrap_kubernetes/_index.md index d95f627..fa787e7 100644 --- a/docs/tcloud/iam/workload-identity-federation_bootstrap_kubernetes/_index.md +++ b/docs/tcloud/iam/workload-identity-federation_bootstrap_kubernetes/_index.md @@ -3,7 +3,7 @@ linkTitle: "tcloud iam workload-identity-federation bootstrap kubernetes" title: "iam workload-identity-federation bootstrap kubernetes" slug: tcloud_iam_workload-identity-federation_bootstrap_kubernetes url: /docs/tcloud/iam/workload-identity-federation_bootstrap_kubernetes/ -weight: 9873 +weight: 9870 cascade: type: docs --- @@ -33,6 +33,14 @@ tcloud iam workload-identity-federation bootstrap kubernetes [flags] tcloud iam workload-identity-federation bootstrap kubernetes --cluster my-cluster-slug \ --namespace default --service-account my-app --role deployer + # Bind IAM policies + tcloud iam workload-identity-federation bootstrap kubernetes --cluster my-cluster-slug \ + --namespace default --service-account my-app --policy ci-deploy + + # Multiple organisation roles + tcloud iam workload-identity-federation bootstrap kubernetes --cluster my-cluster-slug \ + --namespace default --service-account my-app --role deployer --role reader + # Self-managed / custom issuer tcloud iam workload-identity-federation bootstrap kubernetes --issuer https://k8s.example.com \ --namespace cicd --service-account terraform --role deployer @@ -58,13 +66,15 @@ tcloud iam workload-identity-federation bootstrap kubernetes [flags] -c, --context string Context name --debug Debug mode --dry-run Print planned changes without calling the API + --ignore-dir-config Ignore directory-local .thalassa defaults --name string Base name for the Thalassa service account and federated identity (federated identity becomes -fi; default: wif--) --no-hints Do not print platform hints after bootstrap -O, --organisation string Organisation slug or identity (overrides context) + --policy strings IAM policy identity, slug, or name (repeatable; at least one --role or --policy required) -P, --project string Project identity (overrides context; slug is resolved to identity; use "root" for organisation scope) --provider-description string Optional description when creating the federated identity provider --provider-name string Optional display name when creating the federated identity provider - --role string Organisation role identity, slug, or name (required) + --role strings Organisation role identity, slug, or name (repeatable; at least one --role or --policy required) --scope strings Federated identity allowed scopes: api:read, api:write, kubernetes, objectStorage (default: api:read,api:write) --token string Personal access token (overrides context) --trusted-audience strings JWT aud values to trust (repeatable; default: current context API URL, e.g. https://api.thalassa.cloud) diff --git a/go.mod b/go.mod index 39fe10b..0171183 100644 --- a/go.mod +++ b/go.mod @@ -11,7 +11,7 @@ require ( github.com/olekukonko/tablewriter v0.0.5 github.com/spf13/cobra v1.10.2 github.com/stretchr/testify v1.12.1 - github.com/thalassa-cloud/client-go v0.37.0 + github.com/thalassa-cloud/client-go v0.38.0 github.com/zalando/go-keyring v0.2.8 golang.org/x/oauth2 v0.36.0 gopkg.in/yaml.v3 v3.0.1 diff --git a/go.sum b/go.sum index a4c9462..d3c28ed 100644 --- a/go.sum +++ b/go.sum @@ -55,8 +55,8 @@ github.com/stretchr/objx v0.5.3/go.mod h1:rDQraq+vQZU7Fde9LOZLr8Tax6zZvy4kuNKF+Q github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= github.com/stretchr/testify v1.12.1 h1:EuwCh5fleGS7H32xRwO3wRGT7DxrDhLAT6FF8MpWDWE= github.com/stretchr/testify v1.12.1/go.mod h1:MDEgiDPPsNp5cuIrHPPCyornHKgEVbtFUmoNlxoYthg= -github.com/thalassa-cloud/client-go v0.37.0 h1:ernAJLcZefgkjDiZIP/BgyZl5uIU6II8/Iahcvd0xW4= -github.com/thalassa-cloud/client-go v0.37.0/go.mod h1:RMXRSvI0u2Ff4qnU0evsZWaCvrmaal+2vOcDC4p+9yM= +github.com/thalassa-cloud/client-go v0.38.0 h1:ThXFta+kGwjXBMRdnPre12gV7mLossJDNm3jyQ2KywE= +github.com/thalassa-cloud/client-go v0.38.0/go.mod h1:RMXRSvI0u2Ff4qnU0evsZWaCvrmaal+2vOcDC4p+9yM= github.com/x448/float16 v0.8.4 h1:qLwI1I70+NjRFUR3zs1JPUCgaCXSh3SW62uAKT1mSBM= github.com/x448/float16 v0.8.4/go.mod h1:14CWIYCyZA/cWjXOioeEpHeN/83MdbZDRQHoFcYsOfg= github.com/zalando/go-keyring v0.2.8 h1:6sD/Ucpl7jNq10rM2pgqTs0sZ9V3qMrqfIIy5YPccHs= diff --git a/internal/completion/iam.go b/internal/completion/iam.go index 6332ce8..6378d85 100644 --- a/internal/completion/iam.go +++ b/internal/completion/iam.go @@ -190,6 +190,37 @@ func CompleteIAMOrganisationRoleIdentityFlag(cmd *cobra.Command, args []string, return completeIAMOrganisationRoleIdentities(cmd) } +func completeIAMPolicyIdentities(cmd *cobra.Command) ([]string, cobra.ShellCompDirective) { + client, err := thalassaclient.GetThalassaClient() + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + policies, err := client.IAM().ListIamPolicies(cmd.Context(), &clientiam.ListIamPoliciesRequest{}) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + out := make([]string, 0, len(policies)*3) + for _, p := range policies { + desc := p.Name + if desc == "" { + desc = p.Slug + } + out = append(out, p.Identity+"\t"+desc) + if p.Slug != "" && p.Slug != p.Identity { + out = append(out, p.Slug+"\t"+desc) + } + if p.Name != "" && p.Name != p.Identity && p.Name != p.Slug { + out = append(out, p.Name+"\t"+desc) + } + } + return out, cobra.ShellCompDirectiveNoFileComp +} + +// CompleteIAMPolicyIdentityFlag completes IAM policies for flag values (ignores positional args). +func CompleteIAMPolicyIdentityFlag(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return completeIAMPolicyIdentities(cmd) +} + func completeIAMServiceAccountIdentities(cmd *cobra.Command) ([]string, cobra.ShellCompDirective) { client, err := thalassaclient.GetThalassaClient() if err != nil { diff --git a/internal/iamresolve/policy.go b/internal/iamresolve/policy.go new file mode 100644 index 0000000..ceb5456 --- /dev/null +++ b/internal/iamresolve/policy.go @@ -0,0 +1,42 @@ +package iamresolve + +import ( + "context" + "fmt" + "strings" + + clientiam "github.com/thalassa-cloud/client-go/iam" + tcclient "github.com/thalassa-cloud/client-go/pkg/client" +) + +// IamPolicyAPI is implemented by *iam.Client. +type IamPolicyAPI interface { + GetIamPolicy(ctx context.Context, identity string) (*clientiam.IamPolicy, error) + ListIamPolicies(ctx context.Context, req *clientiam.ListIamPoliciesRequest) ([]clientiam.IamPolicy, error) +} + +// ResolveIamPolicyRef resolves a user-supplied IAM policy identity, slug, or display name. +func ResolveIamPolicyRef(ctx context.Context, api IamPolicyAPI, ref string) (*clientiam.IamPolicy, error) { + ref = strings.TrimSpace(ref) + if ref == "" { + return nil, fmt.Errorf("policy reference is empty") + } + policy, err := api.GetIamPolicy(ctx, ref) + if err == nil { + return policy, nil + } + if !tcclient.IsNotFound(err) { + return nil, fmt.Errorf("get IAM policy: %w", err) + } + policies, err := api.ListIamPolicies(ctx, &clientiam.ListIamPoliciesRequest{}) + if err != nil { + return nil, fmt.Errorf("list IAM policies: %w", err) + } + for i := range policies { + p := &policies[i] + if strings.EqualFold(p.Name, ref) || strings.EqualFold(p.Identity, ref) || strings.EqualFold(p.Slug, ref) { + return p, nil + } + } + return nil, fmt.Errorf("IAM policy not found: %s", ref) +} diff --git a/internal/iamresolve/policy_test.go b/internal/iamresolve/policy_test.go new file mode 100644 index 0000000..4fb454b --- /dev/null +++ b/internal/iamresolve/policy_test.go @@ -0,0 +1,126 @@ +package iamresolve + +import ( + "context" + "errors" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + clientiam "github.com/thalassa-cloud/client-go/iam" + tcclient "github.com/thalassa-cloud/client-go/pkg/client" +) + +type fakeIamPolicyAPI struct { + getFn func(ctx context.Context, identity string) (*clientiam.IamPolicy, error) + listFn func(ctx context.Context, req *clientiam.ListIamPoliciesRequest) ([]clientiam.IamPolicy, error) +} + +func (f *fakeIamPolicyAPI) GetIamPolicy(ctx context.Context, identity string) (*clientiam.IamPolicy, error) { + return f.getFn(ctx, identity) +} + +func (f *fakeIamPolicyAPI) ListIamPolicies(ctx context.Context, req *clientiam.ListIamPoliciesRequest) ([]clientiam.IamPolicy, error) { + return f.listFn(ctx, req) +} + +func TestResolveIamPolicyRef(t *testing.T) { + ctx := context.Background() + notFound := fmt.Errorf("policy missing: %w", tcclient.ErrNotFound) + + tests := []struct { + name string + api *fakeIamPolicyAPI + ref string + wantID string + wantErr string + }{ + { + name: "direct get", + api: &fakeIamPolicyAPI{ + getFn: func(_ context.Context, id string) (*clientiam.IamPolicy, error) { + if id == "pid" { + return &clientiam.IamPolicy{Identity: "pid", Name: "Deploy", Slug: "deploy"}, nil + } + return nil, notFound + }, + listFn: func(context.Context, *clientiam.ListIamPoliciesRequest) ([]clientiam.IamPolicy, error) { + return nil, errors.New("list should not be called") + }, + }, + ref: "pid", + wantID: "pid", + }, + { + name: "resolve by slug", + api: &fakeIamPolicyAPI{ + getFn: func(context.Context, string) (*clientiam.IamPolicy, error) { + return nil, notFound + }, + listFn: func(context.Context, *clientiam.ListIamPoliciesRequest) ([]clientiam.IamPolicy, error) { + return []clientiam.IamPolicy{ + {Identity: "pid", Name: "Deploy", Slug: "deploy"}, + }, nil + }, + }, + ref: "deploy", + wantID: "pid", + }, + { + name: "resolve by name", + api: &fakeIamPolicyAPI{ + getFn: func(context.Context, string) (*clientiam.IamPolicy, error) { + return nil, notFound + }, + listFn: func(context.Context, *clientiam.ListIamPoliciesRequest) ([]clientiam.IamPolicy, error) { + return []clientiam.IamPolicy{ + {Identity: "pid", Name: "Deploy Access", Slug: "deploy"}, + }, nil + }, + }, + ref: "Deploy Access", + wantID: "pid", + }, + { + name: "empty ref", + api: &fakeIamPolicyAPI{ + getFn: func(context.Context, string) (*clientiam.IamPolicy, error) { + return nil, errors.New("should not be called") + }, + listFn: func(context.Context, *clientiam.ListIamPoliciesRequest) ([]clientiam.IamPolicy, error) { + return nil, errors.New("should not be called") + }, + }, + ref: " ", + wantErr: "policy reference is empty", + }, + { + name: "not found", + api: &fakeIamPolicyAPI{ + getFn: func(context.Context, string) (*clientiam.IamPolicy, error) { + return nil, notFound + }, + listFn: func(context.Context, *clientiam.ListIamPoliciesRequest) ([]clientiam.IamPolicy, error) { + return []clientiam.IamPolicy{}, nil + }, + }, + ref: "missing", + wantErr: "IAM policy not found: missing", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ResolveIamPolicyRef(ctx, tt.api, tt.ref) + if tt.wantErr != "" { + require.Error(t, err) + assert.Contains(t, err.Error(), tt.wantErr) + return + } + require.NoError(t, err) + require.NotNil(t, got) + assert.Equal(t, tt.wantID, got.Identity) + }) + } +}