Skip to content

Commit

Permalink
feat: Azure OIDC Support (OctopusDeploy#219)
Browse files Browse the repository at this point in the history
Co-authored-by: Ben Pearce <[email protected]>
  • Loading branch information
veochen-octopus and benPearce1 authored Nov 27, 2023
1 parent e5978db commit 8e99237
Show file tree
Hide file tree
Showing 13 changed files with 343 additions and 5 deletions.
1 change: 1 addition & 0 deletions api/spec.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"enum": [
"AmazonWebServicesAccount",
"AzureServicePrincipal",
"AzureOIDC",
"AzureSubscription",
"None",
"SshKeyPair",
Expand Down
79 changes: 79 additions & 0 deletions examples/accounts/create_azure_oidc.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
package examples

import (
"fmt"
"net/url"

"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/accounts"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client"
uuid "github.com/google/uuid"
)

func CreateAzureOIDCExample() {
var (
apiKey string = "API-YOUR_API_KEY"
octopusURL string = "https://your_octopus_url"
spaceID string = "space-id"

// Azure-specific values
azureApplicationID uuid.UUID = uuid.MustParse("client-UUID")
azureSubscriptionID uuid.UUID = uuid.MustParse("subscription-UUID")
azureTenantID uuid.UUID = uuid.MustParse("tenant-UUID")

// Subject claims
deploymentSubjectKeys []string = nil
healthCheckSubjectKeys []string = nil
accountTestSubjectKeys []string = nil

// Other claims
audience string = ""

// account values
accountName string = "Azure Account"
accountDescription string = "My Azure Account"
tenantTags []string = nil
tenantIDs []string = nil
environmentIDs []string = nil
)

apiURL, err := url.Parse(octopusURL)
if err != nil {
_ = fmt.Errorf("error parsing URL for Octopus API: %v", err)
return
}

client, err := client.NewClient(nil, apiURL, apiKey, spaceID)
if err != nil {
_ = fmt.Errorf("error creating API client: %v", err)
return
}

azureAccount, err := accounts.NewAzureOIDCAccount(accountName, azureSubscriptionID, azureTenantID, azureApplicationID)
if err != nil {
_ = fmt.Errorf("error creating Azure service principal account: %v", err)
return
}

// fill in claims
azureAccount.DeploymentSubjectKeys = deploymentSubjectKeys
azureAccount.HealthCheckSubjectKeys = healthCheckSubjectKeys
azureAccount.AccountTestSubjectKeys = accountTestSubjectKeys
azureAccount.Audience = audience

// fill in account details
azureAccount.Description = accountDescription
azureAccount.TenantTags = tenantTags
azureAccount.TenantIDs = tenantIDs
azureAccount.EnvironmentIDs = environmentIDs

// create account
createdAccount, err := accounts.Add(client, azureAccount)
if err != nil {
_ = fmt.Errorf("error adding account: %v", err)
}

azureAccount = createdAccount.(*accounts.AzureOIDCAccount)

// work with created account
fmt.Printf("account created: (%s)\n", azureAccount.GetID())
}
2 changes: 1 addition & 1 deletion pkg/accounts/account.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ type IAccount interface {

// account is the embedded struct used for all accounts.
type account struct {
AccountType AccountType `json:"AccountType" validate:"required,oneof=None UsernamePassword SshKeyPair AzureSubscription AzureServicePrincipal AmazonWebServicesAccount AmazonWebServicesRoleAccount GoogleCloudAccount Token"`
AccountType AccountType `json:"AccountType" validate:"required,oneof=None UsernamePassword SshKeyPair AzureSubscription AzureServicePrincipal AzureOidc AmazonWebServicesAccount AmazonWebServicesRoleAccount GoogleCloudAccount Token"`
Description string `json:"Description,omitempty"`
EnvironmentIDs []string `json:"EnvironmentIds,omitempty"`
Name string `json:"Name" validate:"required,notblank,notall"`
Expand Down
6 changes: 5 additions & 1 deletion pkg/accounts/account_resource.go
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import (
// username/password, tokens, Azure and AWS credentials, and SSH key pairs.
type AccountResource struct {
AccessKey string `json:"AccessKey,omitempty"`
AccountType AccountType `json:"AccountType" validate:"required,oneof=None UsernamePassword SshKeyPair AzureSubscription AzureServicePrincipal AmazonWebServicesAccount AmazonWebServicesRoleAccount GoogleCloudAccount Token"`
AccountType AccountType `json:"AccountType" validate:"required,oneof=None UsernamePassword SshKeyPair AzureSubscription AzureServicePrincipal AzureOidc AmazonWebServicesAccount AmazonWebServicesRoleAccount GoogleCloudAccount Token"`
ApplicationID *uuid.UUID `json:"ClientId,omitempty"`
ApplicationPassword *core.SensitiveValue `json:"Password,omitempty"`
AuthenticationEndpoint string `json:"ActiveDirectoryEndpointBaseUri,omitempty"`
Expand All @@ -38,6 +38,10 @@ type AccountResource struct {
TenantTags []string `json:"TenantTags,omitempty"`
Token *core.SensitiveValue `json:"Token,omitempty"`
Username string `json:"Username,omitempty"`
Audience string `json:"Audience,omitempty"`
DeploymentSubjectKeys []string `json:"DeploymentSubjectKeys,omitempty"`
HealthCheckSubjectKeys []string `json:"HealthCheckSubjectKeys,omitempty"`
AccountTestSubjectKeys []string `json:"AccountTestSubjectKeys,omitempty"`

resources.Resource
}
Expand Down
4 changes: 4 additions & 0 deletions pkg/accounts/account_service_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,10 @@ func TestAccountServiceUpdateWithEmptyAccount(t *testing.T) {
require.Error(t, err)
require.Nil(t, account)

account, err = service.Update(&AzureOIDCAccount{})
require.Error(t, err)
require.Nil(t, account)

account, err = service.Update(&AzureSubscriptionAccount{})
require.Error(t, err)
require.Nil(t, account)
Expand Down
1 change: 1 addition & 0 deletions pkg/accounts/account_types.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ const (
AccountTypeNone = AccountType("None")
AccountTypeAmazonWebServicesAccount = AccountType("AmazonWebServicesAccount")
AccountTypeAzureServicePrincipal = AccountType("AzureServicePrincipal")
AccountTypeAzureOIDC = AccountType("AzureOidc")
AccountTypeAzureSubscription = AccountType("AzureSubscription")
AccountTypeGoogleCloudPlatformAccount = AccountType("GoogleCloudAccount")
AccountTypeSSHKeyPair = AccountType("SshKeyPair")
Expand Down
25 changes: 25 additions & 0 deletions pkg/accounts/account_utilities.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,19 @@ func ToAccount(accountResource *AccountResource) (IAccount, error) {
azureServicePrincipalAccount.AzureEnvironment = accountResource.AzureEnvironment
azureServicePrincipalAccount.ResourceManagerEndpoint = accountResource.ResourceManagerEndpoint
account = azureServicePrincipalAccount
case AccountTypeAzureOIDC:
azureOIDCAccount, err := NewAzureOIDCAccount(accountResource.GetName(), *accountResource.SubscriptionID, *accountResource.TenantID, *accountResource.ApplicationID)
if err != nil {
return nil, err
}
azureOIDCAccount.AuthenticationEndpoint = accountResource.AuthenticationEndpoint
azureOIDCAccount.AzureEnvironment = accountResource.AzureEnvironment
azureOIDCAccount.ResourceManagerEndpoint = accountResource.ResourceManagerEndpoint
azureOIDCAccount.Audience = accountResource.Audience
azureOIDCAccount.DeploymentSubjectKeys = accountResource.DeploymentSubjectKeys
azureOIDCAccount.AccountTestSubjectKeys = accountResource.AccountTestSubjectKeys
azureOIDCAccount.HealthCheckSubjectKeys = accountResource.HealthCheckSubjectKeys
account = azureOIDCAccount
case AccountTypeAzureSubscription:
azureSubscriptionAccount, err := NewAzureSubscriptionAccount(accountResource.GetName(), *accountResource.SubscriptionID)
if err != nil {
Expand Down Expand Up @@ -122,6 +135,18 @@ func ToAccountResource(account IAccount) (*AccountResource, error) {
accountResource.ResourceManagerEndpoint = azureServicePrincipalAccount.ResourceManagerEndpoint
accountResource.SubscriptionID = azureServicePrincipalAccount.SubscriptionID
accountResource.TenantID = azureServicePrincipalAccount.TenantID
case AccountTypeAzureOIDC:
azureOIDCAccount := account.(*AzureOIDCAccount)
accountResource.ApplicationID = azureOIDCAccount.ApplicationID
accountResource.AuthenticationEndpoint = azureOIDCAccount.AuthenticationEndpoint
accountResource.AzureEnvironment = azureOIDCAccount.AzureEnvironment
accountResource.ResourceManagerEndpoint = azureOIDCAccount.ResourceManagerEndpoint
accountResource.SubscriptionID = azureOIDCAccount.SubscriptionID
accountResource.TenantID = azureOIDCAccount.TenantID
accountResource.Audience = azureOIDCAccount.Audience
accountResource.DeploymentSubjectKeys = azureOIDCAccount.DeploymentSubjectKeys
accountResource.AccountTestSubjectKeys = azureOIDCAccount.AccountTestSubjectKeys
accountResource.HealthCheckSubjectKeys = azureOIDCAccount.HealthCheckSubjectKeys
case AccountTypeAzureSubscription:
azureSubscriptionAccount := account.(*AzureSubscriptionAccount)
accountResource.AzureEnvironment = azureSubscriptionAccount.AzureEnvironment
Expand Down
7 changes: 7 additions & 0 deletions pkg/accounts/accounts.go
Original file line number Diff line number Diff line change
Expand Up @@ -85,6 +85,13 @@ func (a *Accounts) UnmarshalJSON(b []byte) error {
return err
}
a.Items = append(a.Items, azureServicePrincipalAccount)
case AccountTypeAzureOIDC:
var azureOIDCAccount *AzureOIDCAccount
err := json.Unmarshal(*account, &azureOIDCAccount)
if err != nil {
return err
}
a.Items = append(a.Items, azureOIDCAccount)
case AccountTypeAzureSubscription:
var azureSubscriptionAccount *AzureSubscriptionAccount
err := json.Unmarshal(*account, &azureSubscriptionAccount)
Expand Down
7 changes: 4 additions & 3 deletions pkg/accounts/azure/azurewebapp.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,12 @@ package azure

import (
"fmt"
"strings"

"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/accounts"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/client"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/constants"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/services/api"
"strings"
)

type AzureWebApp struct {
Expand All @@ -22,7 +23,7 @@ type AzureWebAppSlot struct {
ResourceGroup string `json:"ResourceGroup,omitempty"`
}

func GetWebSites(client client.Client, account *accounts.AzureServicePrincipalAccount) ([]*AzureWebApp, error) {
func GetWebSites(client client.Client, account accounts.IAccount) ([]*AzureWebApp, error) {
path := account.GetLinks()[constants.LinkWebSites]
if path == "" {
return nil, fmt.Errorf("cannot get websites for account '%s' (%s)", account.GetName(), account.GetID())
Expand All @@ -38,7 +39,7 @@ func GetWebSites(client client.Client, account *accounts.AzureServicePrincipalAc
return items, nil
}

func GetWebSiteSlots(client client.Client, spAccount *accounts.AzureServicePrincipalAccount, app *AzureWebApp) ([]*AzureWebAppSlot, error) {
func GetWebSiteSlots(client client.Client, spAccount accounts.IAccount, app *AzureWebApp) ([]*AzureWebAppSlot, error) {
path := spAccount.GetLinks()[constants.LinkWebSiteSlots]
if path == "" {
return nil, fmt.Errorf("cannot get websites for account '%s' (%s)", spAccount.GetName(), spAccount.GetID())
Expand Down
61 changes: 61 additions & 0 deletions pkg/accounts/azure_oidc_account.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package accounts

import (
"github.com/OctopusDeploy/go-octopusdeploy/v2/internal"
validation "github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/validation"
"github.com/go-playground/validator/v10"
"github.com/go-playground/validator/v10/non-standard/validators"
uuid "github.com/google/uuid"
)

// AzureOIDCAccount represents an Azure OIDC account.
type AzureOIDCAccount struct {
ApplicationID *uuid.UUID `json:"ClientId" validate:"required"`
AuthenticationEndpoint string `json:"ActiveDirectoryEndpointBaseUri,omitempty" validate:"required_with=AzureEnvironment,omitempty,uri"`
AzureEnvironment string `json:"AzureEnvironment,omitempty" validate:"omitempty,oneof=AzureCloud AzureChinaCloud AzureGermanCloud AzureUSGovernment"`
ResourceManagerEndpoint string `json:"ResourceManagementEndpointBaseUri" validate:"required_with=AzureEnvironment,omitempty,uri"`
SubscriptionID *uuid.UUID `json:"SubscriptionNumber" validate:"required"`
TenantID *uuid.UUID `json:"TenantId" validate:"required"`
Audience string `json:"Audience,omitempty"`
DeploymentSubjectKeys []string `json:"DeploymentSubjectKeys,omitempty" validate:"omitempty,dive,oneof=space environment project tenant runbook account type'"`
HealthCheckSubjectKeys []string `json:"HealthCheckSubjectKeys,omitempty" validate:"omitempty,dive,oneof=space account target type'"`
AccountTestSubjectKeys []string `json:"AccountTestSubjectKeys,omitempty" validate:"omitempty,dive,oneof=space account type'"`

account
}

// NewAzureOIDCAccount creates and initializes an Azure OIDC account.
func NewAzureOIDCAccount(name string, subscriptionID uuid.UUID, tenantID uuid.UUID, applicationID uuid.UUID) (*AzureOIDCAccount, error) {
if internal.IsEmpty(name) {
return nil, internal.CreateRequiredParameterIsEmptyOrNilError("name")
}

account := AzureOIDCAccount{
ApplicationID: &applicationID,
SubscriptionID: &subscriptionID,
TenantID: &tenantID,
account: *newAccount(name, AccountTypeAzureOIDC),
}

// validate to ensure that all expectations are met
err := account.Validate()
if err != nil {
return nil, err
}

return &account, nil
}

// Validate checks the state of this account and returns an error if invalid.
func (a *AzureOIDCAccount) Validate() error {
v := validator.New()
err := v.RegisterValidation("notblank", validators.NotBlank)
if err != nil {
return err
}
err = v.RegisterValidation("notall", validation.NotAll)
if err != nil {
return err
}
return v.Struct(a)
}
111 changes: 111 additions & 0 deletions pkg/accounts/azure_oidc_account_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
package accounts

import (
"testing"

"github.com/OctopusDeploy/go-octopusdeploy/v2/internal"
"github.com/OctopusDeploy/go-octopusdeploy/v2/pkg/core"
uuid "github.com/google/uuid"
"github.com/stretchr/testify/require"
)

func TestAzureOIDCAccount(t *testing.T) {
applicationID := uuid.New()
authenticationEndpoint := "https://login.microsoftonline.com/"
azureEnvironment := "AzureCloud"
invalidURI := "***"
name := internal.GetRandomName()
resourceManagerEndpoint := "https://management.azure.com/"
spaceID := "space-id"
subscriptionID := uuid.New()
tenantedDeploymentMode := core.TenantedDeploymentMode("Untenanted")
tenantID := uuid.New()
audience := "api://AzureADTokenExchange"
deploymentSubjectKeys := []string{"space", "project", "tenant", "environment"}
healthCheckSubjectKeys := []string{"space", "target"}
accountTestSubjectKeys := []string{"space", "account"}
invalidDeploymentSubjectKeys := []string{"space", "target"}
invalidHealthCheckSubjectKeys := []string{"space", "project"}
invalidAccountTestSubjectKeys := []string{"space", "project"}

testCases := []struct {
TestName string
IsError bool
ApplicationID *uuid.UUID
AuthenticationEndpoint string
AzureEnvironment string
Name string
ResourceManagerEndpoint string
SpaceID string
SubscriptionID *uuid.UUID
TenantedDeploymentMode core.TenantedDeploymentMode
TenantID *uuid.UUID
Audience string
DeploymentSubjectKeys []string
HealthCheckSubjectKeys []string
AccountTestSubjectKeys []string
}{
{"Valid", false, &applicationID, authenticationEndpoint, azureEnvironment, name, resourceManagerEndpoint, spaceID, &subscriptionID, tenantedDeploymentMode, &tenantID, audience, deploymentSubjectKeys, healthCheckSubjectKeys, accountTestSubjectKeys},
{"EmptyName", true, &applicationID, authenticationEndpoint, azureEnvironment, "", resourceManagerEndpoint, spaceID, &subscriptionID, tenantedDeploymentMode, &tenantID, audience, deploymentSubjectKeys, healthCheckSubjectKeys, accountTestSubjectKeys},
{"WhitespaceName", true, &applicationID, authenticationEndpoint, azureEnvironment, " ", resourceManagerEndpoint, spaceID, &subscriptionID, tenantedDeploymentMode, &tenantID, audience, deploymentSubjectKeys, healthCheckSubjectKeys, accountTestSubjectKeys},
{"EmptySpaceID", false, &applicationID, authenticationEndpoint, azureEnvironment, name, resourceManagerEndpoint, "", &subscriptionID, tenantedDeploymentMode, &tenantID, audience, deploymentSubjectKeys, healthCheckSubjectKeys, accountTestSubjectKeys},
{"WhitespaceSpaceID", false, &applicationID, authenticationEndpoint, azureEnvironment, name, resourceManagerEndpoint, " ", &subscriptionID, tenantedDeploymentMode, &tenantID, audience, deploymentSubjectKeys, healthCheckSubjectKeys, accountTestSubjectKeys},
{"NilApplicationID", true, nil, authenticationEndpoint, azureEnvironment, name, resourceManagerEndpoint, spaceID, &subscriptionID, tenantedDeploymentMode, &tenantID, audience, deploymentSubjectKeys, healthCheckSubjectKeys, accountTestSubjectKeys},
{"NilSubscriptionID", true, &applicationID, authenticationEndpoint, azureEnvironment, name, resourceManagerEndpoint, spaceID, nil, tenantedDeploymentMode, &tenantID, audience, deploymentSubjectKeys, healthCheckSubjectKeys, accountTestSubjectKeys},
{"NilTenantID", true, &applicationID, authenticationEndpoint, azureEnvironment, name, resourceManagerEndpoint, spaceID, &subscriptionID, tenantedDeploymentMode, nil, audience, deploymentSubjectKeys, healthCheckSubjectKeys, accountTestSubjectKeys},
{"InvalidAuthenticationEndpoint", true, &applicationID, invalidURI, azureEnvironment, name, resourceManagerEndpoint, spaceID, &subscriptionID, tenantedDeploymentMode, &tenantID, audience, deploymentSubjectKeys, healthCheckSubjectKeys, accountTestSubjectKeys},
{"InvalidResourceManagerEndpoint", true, &applicationID, authenticationEndpoint, azureEnvironment, name, invalidURI, spaceID, &subscriptionID, tenantedDeploymentMode, &tenantID, audience, deploymentSubjectKeys, healthCheckSubjectKeys, accountTestSubjectKeys},
{"NilSubjectKeys", false, &applicationID, authenticationEndpoint, azureEnvironment, name, resourceManagerEndpoint, spaceID, &subscriptionID, tenantedDeploymentMode, &tenantID, "", nil, nil, nil},
{"InvalidDeploymentSubjectKeys", true, &applicationID, authenticationEndpoint, azureEnvironment, name, resourceManagerEndpoint, spaceID, &subscriptionID, tenantedDeploymentMode, &tenantID, "", invalidDeploymentSubjectKeys, healthCheckSubjectKeys, accountTestSubjectKeys},
{"InvalidHealthCheckSubjectKeys", true, &applicationID, authenticationEndpoint, azureEnvironment, name, resourceManagerEndpoint, spaceID, &subscriptionID, tenantedDeploymentMode, &tenantID, "", deploymentSubjectKeys, invalidHealthCheckSubjectKeys, invalidAccountTestSubjectKeys},
{"InvalidAccountTestSubjectKeys", true, &applicationID, authenticationEndpoint, azureEnvironment, name, resourceManagerEndpoint, spaceID, &subscriptionID, tenantedDeploymentMode, &tenantID, "", deploymentSubjectKeys, healthCheckSubjectKeys, invalidAccountTestSubjectKeys},
}
for _, tc := range testCases {
t.Run(tc.TestName, func(t *testing.T) {
azureOIDCAccount := &AzureOIDCAccount{
ApplicationID: tc.ApplicationID,
AuthenticationEndpoint: tc.AuthenticationEndpoint,
AzureEnvironment: tc.AzureEnvironment,
ResourceManagerEndpoint: tc.ResourceManagerEndpoint,
SubscriptionID: tc.SubscriptionID,
TenantID: tc.TenantID,
Audience: tc.Audience,
DeploymentSubjectKeys: tc.DeploymentSubjectKeys,
HealthCheckSubjectKeys: tc.HealthCheckSubjectKeys,
AccountTestSubjectKeys: tc.AccountTestSubjectKeys,
}
azureOIDCAccount.AccountType = AccountTypeAzureOIDC
azureOIDCAccount.Name = tc.Name
azureOIDCAccount.SpaceID = tc.SpaceID
azureOIDCAccount.TenantedDeploymentMode = tc.TenantedDeploymentMode
if tc.IsError {
require.Error(t, azureOIDCAccount.Validate())
} else {
require.NoError(t, azureOIDCAccount.Validate())

require.Equal(t, AccountTypeAzureOIDC, azureOIDCAccount.GetAccountType())
require.Equal(t, tc.Name, azureOIDCAccount.GetName())
}
azureOIDCAccount.SetName(tc.Name)
if tc.IsError {
require.Error(t, azureOIDCAccount.Validate())
} else {
require.NoError(t, azureOIDCAccount.Validate())
require.Equal(t, tc.Name, azureOIDCAccount.GetName())
}
})
}
}

func TestAzureOIDCAccountNew(t *testing.T) {
applicationID := uuid.New()
name := internal.GetRandomName()
subscriptionID := uuid.New()
tenantID := uuid.New()

account, err := NewAzureOIDCAccount(name, subscriptionID, tenantID, applicationID)

require.NotNil(t, account)
require.NoError(t, err)
require.NoError(t, account.Validate())
}
Loading

0 comments on commit 8e99237

Please sign in to comment.