Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 37 additions & 5 deletions cmd/iam/workloadidentityfederation/bootstrap.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,8 @@ import (

// Persistent flags shared by all bootstrap <platform> subcommands.
var (
flagRole string
flagRoles []string
flagPolicies []string
flagTrustedAudiences []string
flagScopes []string
flagProviderName string
Expand All @@ -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=<github|gitlab|kubernetes>.

Expand All @@ -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)
Expand Down Expand Up @@ -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))
Expand All @@ -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")
Expand All @@ -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)
}
6 changes: 6 additions & 0 deletions cmd/iam/workloadidentityfederation/bootstrap_github.go
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
6 changes: 6 additions & 0 deletions cmd/iam/workloadidentityfederation/bootstrap_gitlab.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,12 @@ The GitLab id_token sub uses project_path:<group/project>:ref_type:<type>:ref:<r
Example: ` # 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

Expand Down
10 changes: 9 additions & 1 deletion cmd/iam/workloadidentityfederation/bootstrap_kubernetes.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ var (
var bootstrapKubernetesCmd = &cobra.Command{
Use: "kubernetes",
Short: "Bootstrap workload identity for Kubernetes service accounts",
Aliases: []string{"k8s"},
Aliases: []string{"k8s", "kubectl", "kubernetes"},
Long: `Binds system:serviceaccount:<namespace>:<name> to a Thalassa service account via a federated identity.

Thalassa clusters: pass --cluster to resolve the cluster and use the platform-managed federated identity
Expand All @@ -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`,
Expand Down
74 changes: 57 additions & 17 deletions cmd/iam/workloadidentityfederation/bootstrap_output.go
Original file line number Diff line number Diff line change
Expand Up @@ -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+")"))
Expand All @@ -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)
Expand All @@ -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+")"))
Expand All @@ -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)
}
}
Loading
Loading