From 0c3397ceaec02f4c087d658cba19fbb6c67028eb Mon Sep 17 00:00:00 2001 From: Kevin Franklin Kim Date: Thu, 7 Aug 2025 16:43:34 +0200 Subject: [PATCH 1/2] fix(azure/az): support service principals --- azure/az/checker.go | 16 +++++++- azure/az/command.go | 41 +++++++++++++++++--- azure/az/config.go | 25 ++++++++++++ azure/az/config.yaml | 5 +++ pulumi/pulumi/azure/command.go | 71 +++++++++++++++++----------------- 5 files changed, 115 insertions(+), 43 deletions(-) diff --git a/azure/az/checker.go b/azure/az/checker.go index 2477bc3..cc27f1b 100644 --- a/azure/az/checker.go +++ b/azure/az/checker.go @@ -2,6 +2,8 @@ package az import ( "context" + "encoding/json" + "fmt" "strings" "github.com/foomo/posh/pkg/log" @@ -11,11 +13,21 @@ import ( func AuthChecker(ctx context.Context, l log.Logger) []check.Info { name := "Azure" - out, err := shell.New(ctx, l, "az", "account", "list", "--output", "none").Quiet().CombinedOutput() + out, err := shell.New(ctx, l, "az", "account", "list", "--output", "json").Quiet().CombinedOutput() if err != nil { return []check.Info{check.NewFailureInfo(name, "Error: "+err.Error())} } else if strings.Contains(string(out), "az login") { return []check.Info{check.NewNoteInfo(name, "Unauthenticated")} } - return []check.Info{check.NewSuccessInfo(name, "Authenticated")} + + var res []map[string]any + note := "Authenticated" + if err := json.Unmarshal(out, &res); err == nil { + if len(res) > 0 && res[0]["user"] != nil { + if user, ok := res[0]["user"].(map[string]any); ok { + note += fmt.Sprintf(" as %s: %s", user["type"], user["name"]) + } + } + } + return []check.Info{check.NewSuccessInfo(name, note)} } diff --git a/azure/az/command.go b/azure/az/command.go index 051e71b..5d949a7 100644 --- a/azure/az/command.go +++ b/azure/az/command.go @@ -69,7 +69,14 @@ func NewCommand(l log.Logger, az *AZ, kubectl *kubectl.Kubectl, opts ...CommandO { Name: "login", Description: "Log in to Azure", - Execute: inst.login, + Flags: func(ctx context.Context, r *readline.Readline, fs *readline.FlagSets) error { + fs.Internal().String("service-principal", "", "Service principal to use for authentication") + if err := fs.Internal().SetValues("service-principal", inst.az.Config().ServicePrincipalNames()...); err != nil { + return err + } + return nil + }, + Execute: inst.login, }, { Name: "logout", @@ -240,10 +247,34 @@ func (c *Command) exec(ctx context.Context, r *readline.Readline) error { func (c *Command) login(ctx context.Context, r *readline.Readline) error { fs := r.FlagSets().Default() - return shell.New(ctx, c.l, "az", "login", - "--allow-no-subscriptions", - "--tenant", c.az.Config().TenantID, - ). + ifs := r.FlagSets().Internal() + + servicePricipal, err := ifs.GetString("service-principal") + if err != nil { + return err + } + + var args []string + if servicePricipal != "" { + sp, err := c.az.cfg.ServicePrincipal(servicePricipal) + if err != nil { + return err + } + args = append(args, + "--service-principal", + "--username", sp.ClientID, + "--password", sp.ClientSecret, + "--tenant", sp.TenantID, + ) + } else { + args = append(args, + "--allow-no-subscriptions", + "--tenant", c.az.Config().TenantID, + ) + } + + return shell.New(ctx, c.l, "az", "login"). + Args(args...). Args(fs.Visited().Args()...). Args(r.AdditionalArgs()...). Args(r.AdditionalFlags()...). diff --git a/azure/az/config.go b/azure/az/config.go index 3ee92ae..8a8e2af 100644 --- a/azure/az/config.go +++ b/azure/az/config.go @@ -14,6 +14,17 @@ type Config struct { TenantID string `json:"tenantId" yaml:"tenantId"` // Subscription configurations Subscriptions map[string]Subscription `json:"subscriptions" yaml:"subscriptions"` + // Authentication service principals + ServicePrincipals map[string]ServicePrincipal `json:"servicePrincipals" yaml:"servicePrincipals"` +} + +type ServicePrincipal struct { + // Tenant id + TenantID string `json:"tenantId" yaml:"tenantId"` + // Application client id + ClientID string `json:"clientId" yaml:"clientId"` + // Application password + ClientSecret string `json:"clientSecret" yaml:"clientSecret"` } func (c Config) Subscription(name string) (Subscription, error) { @@ -29,3 +40,17 @@ func (c Config) SubscriptionNames() []string { sort.Strings(keys) return keys } + +func (c Config) ServicePrincipal(name string) (ServicePrincipal, error) { + value, ok := c.ServicePrincipals[name] + if !ok { + return ServicePrincipal{}, errors.Errorf("service principal not found: %s", name) + } + return value, nil +} + +func (c Config) ServicePrincipalNames() []string { + keys := lo.Keys(c.ServicePrincipals) + sort.Strings(keys) + return keys +} diff --git a/azure/az/config.yaml b/azure/az/config.yaml index 9760201..083ea66 100644 --- a/azure/az/config.yaml +++ b/azure/az/config.yaml @@ -1,6 +1,11 @@ # yaml-language-server: $schema=config.schema.json configPath: .posh/config/azure tenantId: xxxx-xx-xx-xx-xxxx +servicePrincipals: + dev: + tenantId: xxxx-xx-xx-xx-xxxx + clientId: xxxx-xx-xx-xx-xxxx + clientSecret: xxxx-xx-xx-xx-xxxx subscriptions: development: name: xxxx-xx-xx-xx-xxxx diff --git a/pulumi/pulumi/azure/command.go b/pulumi/pulumi/azure/command.go index 977a65c..d59eb3f 100644 --- a/pulumi/pulumi/azure/command.go +++ b/pulumi/pulumi/azure/command.go @@ -104,7 +104,7 @@ func NewCommand(l log.Logger, az *az.AZ, op *onepassword.OnePassword, cache cach return nil }, Execute: func(ctx context.Context, r *readline.Readline) error { - be, err := inst.cfg.Backend(r.Args().At(0)) + backend, err := inst.cfg.Backend(r.Args().At(0)) if err != nil { return err } @@ -123,11 +123,11 @@ func NewCommand(l log.Logger, az *az.AZ, op *onepassword.OnePassword, cache cach storageArgs = strings.Trim(strings.Trim(storageArgs, "\""), "'") // Create a new resource group - inst.l.Info("creating resource group:", be.ResourceGroup) + inst.l.Info("creating resource group:", backend.ResourceGroup) if err := shell.New(ctx, inst.l, "az", "group", "create"). - Args("--resource-group", be.ResourceGroup). - Args("--subscription", be.Subscription). - Args("--location", be.Location). + Args("--resource-group", backend.ResourceGroup). + Args("--subscription", backend.Subscription). + Args("--location", backend.Location). Args(strings.Split(groupArgs, " ")...). Args(r.FlagSets().Default().Visited().Args()...). Run(); err != nil { @@ -135,12 +135,12 @@ func NewCommand(l log.Logger, az *az.AZ, op *onepassword.OnePassword, cache cach } // Create a new resource group - inst.l.Info("creating storage account:", be.StorageAccount) + inst.l.Info("creating storage account:", backend.StorageAccount) if err := shell.New(ctx, inst.l, "az", "storage", "account", "create"). - Args("--name", be.StorageAccount). - Args("--resource-group", be.ResourceGroup). - Args("--subscription", be.Subscription). - Args("--location", be.Location). + Args("--name", backend.StorageAccount). + Args("--resource-group", backend.ResourceGroup). + Args("--subscription", backend.Subscription). + Args("--location", backend.Location). Args(strings.Split(storageArgs, " ")...). Args(r.FlagSets().Default().Visited().Args()...). Run(); err != nil { @@ -148,23 +148,16 @@ func NewCommand(l log.Logger, az *az.AZ, op *onepassword.OnePassword, cache cach } // retrieve storage key - inst.l.Info("retrieving storage key") - sk, err := shell.New(ctx, inst.l, "az", "storage", "account", "keys", "list"). - Args("--resource-group", be.ResourceGroup). - Args("--subscription", be.Subscription). - Args("--account-name", be.StorageAccount). - Args("-o", "tsv", "--query", "'[0].value'"). - Output() + storageAccountKey, err := inst.getStorageAccountKey(ctx, backend) if err != nil { return err } - sks := strings.ReplaceAll(strings.TrimSpace(string(sk)), "\n", "") - inst.l.Info("creating storage container:", be.Container) + inst.l.Info("creating storage container:", backend.Container) return shell.New(ctx, inst.l, "az", "storage", "container", "create"). - Args("--account-name", be.StorageAccount). - Args("--account-key", sks). - Args("--name", be.Container). + Args("--account-name", backend.StorageAccount). + Args("--account-key", storageAccountKey). + Args("--name", backend.Container). Run() }, }, @@ -172,15 +165,21 @@ func NewCommand(l log.Logger, az *az.AZ, op *onepassword.OnePassword, cache cach Name: "login", Description: "Log into your object storage backend", Execute: func(ctx context.Context, r *readline.Readline) error { - be, sks, err := inst.backendKey(ctx, r.Args().At(0)) + backend, err := inst.cfg.Backend(r.Args().At(0)) if err != nil { return err } - return shell.New(ctx, inst.l, "pulumi", "login", fmt.Sprintf("azblob://%s", be.Container)). - Env("AZURE_STORAGE_ACCOUNT=" + be.StorageAccount). - Env("AZURE_STORAGE_KEY=" + sks). - Env("ARM_SUBSCRIPTION_ID=" + be.Subscription). + storageAccountKey, err := inst.getStorageAccountKey(ctx, backend) + if err != nil { + return err + } + + return shell.New(ctx, inst.l, "pulumi", "login", fmt.Sprintf("azblob://%s", backend.Container)). + Env("AZURE_STORAGE_ACCOUNT=" + backend.StorageAccount). + Env("AZURE_STORAGE_KEY=" + storageAccountKey). + Env("ARM_SUBSCRIPTION_ID=" + backend.Subscription). + Debug(). Run() }, }, @@ -503,7 +502,12 @@ func (c *Command) executeStack(ctx context.Context, r *readline.Readline) error proj := r.Args().At(2) stack := r.Args().At(3) - be, storageAccountKey, err := c.backendKey(ctx, e) + be, err := c.cfg.Backend(r.Args().At(0)) + if err != nil { + return err + } + + storageAccountKey, err := c.getStorageAccountKey(ctx, be) if err != nil { return err } @@ -551,12 +555,7 @@ func (c *Command) completeProjects(ctx context.Context, t tree.Root, r *readline }).([]goprompt.Suggest) } -func (c *Command) backendKey(ctx context.Context, env string) (Backend, string, error) { - be, err := c.cfg.Backend(env) - if err != nil { - return Backend{}, "", err - } - +func (c *Command) getStorageAccountKey(ctx context.Context, be Backend) (string, error) { // retrieve storage key c.l.Info("retrieving storage key") sk, err := shell.New(ctx, c.l, "az", "storage", "account", "keys", "list"). @@ -566,10 +565,10 @@ func (c *Command) backendKey(ctx context.Context, env string) (Backend, string, Args("-o", "tsv", "--query", "'[0].value'"). Output() if err != nil { - return Backend{}, "", err + return "", err } - return be, strings.ReplaceAll(strings.TrimSpace(string(sk)), "\n", ""), nil + return strings.ReplaceAll(strings.TrimSpace(string(sk)), "\n", ""), nil } func (c *Command) completeStacks(ctx context.Context, t tree.Root, r *readline.Readline) []goprompt.Suggest { From 694cb738c6bfa28dd9488fef0274e7101083460d Mon Sep 17 00:00:00 2001 From: Kevin Franklin Kim Date: Thu, 7 Aug 2025 16:44:27 +0200 Subject: [PATCH 2/2] feat(azure/az): update schema --- azure/az/config.schema.json | 33 ++++++++++++++++++++++++++++++++- 1 file changed, 32 insertions(+), 1 deletion(-) diff --git a/azure/az/config.schema.json b/azure/az/config.schema.json index 87cf81e..86624b0 100644 --- a/azure/az/config.schema.json +++ b/azure/az/config.schema.json @@ -53,6 +53,13 @@ }, "type": "object", "description": "Subscription configurations" + }, + "servicePrincipals": { + "additionalProperties": { + "$ref": "#/$defs/ServicePrincipal" + }, + "type": "object", + "description": "Authentication service principals" } }, "additionalProperties": false, @@ -60,7 +67,31 @@ "required": [ "configPath", "tenantId", - "subscriptions" + "subscriptions", + "servicePrincipals" + ] + }, + "ServicePrincipal": { + "properties": { + "tenantId": { + "type": "string", + "description": "Tenant id" + }, + "clientId": { + "type": "string", + "description": "Application client id" + }, + "clientSecret": { + "type": "string", + "description": "Application password" + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "tenantId", + "clientId", + "clientSecret" ] }, "Subscription": {