Skip to content

Commit

Permalink
WIP: Support OIDC in Azure Pipelines
Browse files Browse the repository at this point in the history
This change teaches `azd` how to login using a service connection for
an OIDC like experience when running in Azure Pipelines using service
connections and then updates our pipelines to use this authentication
strategy.

Contributes To Azure#4341
  • Loading branch information
ellismg committed Sep 18, 2024
1 parent 6992411 commit ba39a27
Show file tree
Hide file tree
Showing 7 changed files with 160 additions and 32 deletions.
3 changes: 3 additions & 0 deletions cli/azd/.vscode/cspell.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@ dictionaryDefinitions:
dictionaries:
- azdProjectDictionary
overrides:
- filename: cmd/auth_login.go
words:
- ACCESSTOKEN
- filename: internal/tracing/fields/domains.go
words:
- azmk
Expand Down
43 changes: 31 additions & 12 deletions cli/azd/cmd/auth_login.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,18 +46,20 @@ func newAuthLoginFlags(cmd *cobra.Command, global *internal.GlobalCommandOptions
}

type loginFlags struct {
onlyCheckStatus bool
browser bool
managedIdentity bool
useDeviceCode boolPtr
tenantID string
clientID string
clientSecret stringPtr
clientCertificate string
federatedTokenProvider string
scopes []string
redirectPort int
global *internal.GlobalCommandOptions
onlyCheckStatus bool
browser bool
managedIdentity bool
useDeviceCode boolPtr
tenantID string
clientID string
clientSecret stringPtr
clientCertificate string
serviceConnectionID string
systemAccessTokenEnvVar string
federatedTokenProvider string
scopes []string
redirectPort int
global *internal.GlobalCommandOptions
}

// stringPtr implements a pflag.Value and allows us to distinguish between a flag value being explicitly set to the empty
Expand Down Expand Up @@ -139,6 +141,16 @@ func (lf *loginFlags) Bind(local *pflag.FlagSet, global *internal.GlobalCommandO
cClientCertificateFlagName,
"",
"The path to the client certificate for the service principal to authenticate with.")
local.StringVar(
&lf.serviceConnectionID,
"service-connection-id",
"",
"The service connection id to authenticate with.")
local.StringVar(
&lf.systemAccessTokenEnvVar,
"system-access-token-environment-variable-name",
"SYSTEM_ACCESSTOKEN",
"The name of the environment variable that contains the system access token to authenticate with.")
local.StringVar(
&lf.federatedTokenProvider,
cFederatedCredentialProviderFlagName,
Expand Down Expand Up @@ -407,6 +419,7 @@ func (la *loginAction) login(ctx context.Context) error {
if countTrue(
la.flags.clientSecret.ptr != nil,
la.flags.clientCertificate != "",
la.flags.serviceConnectionID != "",
la.flags.federatedTokenProvider != "",
) != 1 {
return fmt.Errorf(
Expand Down Expand Up @@ -457,6 +470,12 @@ func (la *loginAction) login(ctx context.Context) error {
); err != nil {
return fmt.Errorf("logging in: %w", err)
}
case la.flags.serviceConnectionID != "":
if _, err := la.authManager.LoginWithServiceConnection(
ctx, la.flags.tenantID, la.flags.clientID, la.flags.serviceConnectionID, la.flags.systemAccessTokenEnvVar,
); err != nil {
return fmt.Errorf("logging in: %w", err)
}
}

return nil
Expand Down
104 changes: 94 additions & 10 deletions cli/azd/pkg/auth/manager.go
Original file line number Diff line number Diff line change
Expand Up @@ -312,11 +312,6 @@ func (m *Manager) CredentialForCurrentUser(
}
return m.newCredentialFromManagedIdentity(clientID)
} else if currentUser.TenantID != nil && currentUser.ClientID != nil {
ps, err := m.loadSecret(*currentUser.TenantID, *currentUser.ClientID)
if err != nil {
return nil, fmt.Errorf("loading secret: %w: %w", err, ErrNoCurrentUser)
}

// by default we used the stored tenant (i.e. the one provided with the tenant id parameter when a user ran
// `azd auth login`), but we allow an override using the options bag, when
// TenantID is non-empty and PreferFallbackTenant is not true.
Expand All @@ -326,6 +321,16 @@ func (m *Manager) CredentialForCurrentUser(
tenantID = options.TenantID
}

if currentUser.ServiceConnectionID != nil && currentUser.SystemAccessTokenName != nil {
return m.newCredentialFromServiceConnection(
tenantID, *currentUser.ClientID, *currentUser.ServiceConnectionID, *currentUser.SystemAccessTokenName)
}

ps, err := m.loadSecret(*currentUser.TenantID, *currentUser.ClientID)
if err != nil {
return nil, fmt.Errorf("loading secret: %w: %w", err, ErrNoCurrentUser)
}

if ps.ClientSecret != nil {
return m.newCredentialFromClientSecret(tenantID, *currentUser.ClientID, *ps.ClientSecret)
} else if ps.ClientCertificate != nil {
Expand Down Expand Up @@ -481,6 +486,36 @@ func (m *Manager) newCredentialFromClientSecret(
return cred, nil
}

func (m *Manager) newCredentialFromServiceConnection(
tenantID string,
clientID string,
serviceConnectionID string,
systemAccessTokenEnvVar string,
) (azcore.TokenCredential, error) {
options := &azidentity.AzurePipelinesCredentialOptions{
ClientOptions: azcore.ClientOptions{
Transport: m.httpClient,
// TODO: Inject client options instead? this can be done if we're OK
// using the default user agent string.
Cloud: m.cloud.Configuration,
},
}

systemAccessToken := os.Getenv(systemAccessTokenEnvVar)
if systemAccessToken == "" {
// nolint:lll
return nil, fmt.Errorf("system access token not found, ensure the System.AccessToken value is mapped to an environment variable named %s", systemAccessTokenEnvVar)
}

cred, err := azidentity.NewAzurePipelinesCredential(
tenantID, clientID, serviceConnectionID, systemAccessToken, options)
if err != nil {
return nil, fmt.Errorf("creating credential: %w: %w", err, ErrNoCurrentUser)
}

return cred, nil
}

func (m *Manager) newCredentialFromClientCertificate(
tenantID string,
clientID string,
Expand Down Expand Up @@ -688,6 +723,37 @@ func (m *Manager) LoginWithDeviceCode(

}

func (m *Manager) LoginWithServiceConnection(
ctx context.Context, tenantID string, clientID string, serviceConnectionID string, systemAccessTokenEnvVar string,
) (azcore.TokenCredential, error) {
systemAccessToken := os.Getenv(systemAccessTokenEnvVar)

if systemAccessToken == "" {
// nolint:lll
return nil, fmt.Errorf("system access token not found, ensure the System.AccessToken value is mapped to an environment variable named %s", systemAccessTokenEnvVar)
}

options := &azidentity.AzurePipelinesCredentialOptions{
ClientOptions: azcore.ClientOptions{
Transport: m.httpClient,
// TODO: Inject client options instead? this can be done if we're OK
// using the default user agent string.
Cloud: m.cloud.Configuration,
},
}

cred, err := azidentity.NewAzurePipelinesCredential(tenantID, clientID, serviceConnectionID, systemAccessToken, options)
if err != nil {
return nil, fmt.Errorf("creating credential: %w", err)
}

if err := m.saveLoginForServiceConnection(tenantID, clientID, serviceConnectionID, systemAccessTokenEnvVar); err != nil {
return nil, err
}

return cred, nil
}

func (m *Manager) LoginWithManagedIdentity(ctx context.Context, clientID string) (azcore.TokenCredential, error) {
options := &azidentity.ManagedIdentityCredentialOptions{}
if clientID != "" {
Expand Down Expand Up @@ -848,6 +914,22 @@ func (m *Manager) saveLoginForManagedIdentity(clientID string) error {
return nil
}

func (m *Manager) saveLoginForServiceConnection(
tenantID, clientID, serviceConnectionID, systemAccessTokenEnvVar string,
) error {
props := &userProperties{
ClientID: &clientID,
TenantID: &tenantID,
ServiceConnectionID: &serviceConnectionID,
SystemAccessTokenName: &systemAccessTokenEnvVar,
}
if err := m.saveUserProperties(props); err != nil {
return err
}

return nil
}

func (m *Manager) saveLoginForServicePrincipal(tenantId, clientId string, secret *persistedSecret) error {
if err := m.saveSecret(tenantId, clientId, secret); err != nil {
return err
Expand Down Expand Up @@ -1033,11 +1115,13 @@ type federatedAuth struct {
// either an home account id (when logging in using a public client) or a client and tenant id (when using a confidential
// client).
type userProperties struct {
ManagedIdentity bool `json:"managedIdentity,omitempty"`
HomeAccountID *string `json:"homeAccountId,omitempty"`
FromOneAuth bool `json:"fromOneAuth,omitempty"`
ClientID *string `json:"clientId,omitempty"`
TenantID *string `json:"tenantId,omitempty"`
ManagedIdentity bool `json:"managedIdentity,omitempty"`
HomeAccountID *string `json:"homeAccountId,omitempty"`
FromOneAuth bool `json:"fromOneAuth,omitempty"`
ClientID *string `json:"clientId,omitempty"`
TenantID *string `json:"tenantId,omitempty"`
ServiceConnectionID *string `json:"serviceConnectionId,omitempty"`
SystemAccessTokenName *string `json:"systemAccessTokenName,omitempty"`
}

func readUserProperties(cfg config.Config) (*userProperties, error) {
Expand Down
1 change: 1 addition & 0 deletions eng/pipelines/templates/jobs/build-cli.yml
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ jobs:
ARM_TENANT_ID: $(arm-tenant-id)
# Code Coverage: Generate junit report to publish results
GOTESTSUM_JUNITFILE: junitTestReport.xml
SYSTEM_ACCESSTOKEN: $(System.AccessToken)

- task: PublishTestResults@2
inputs:
Expand Down
9 changes: 7 additions & 2 deletions eng/pipelines/templates/steps/azd-login.yml
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
parameters:
SubscriptionConfiguration: $(sub-config-azure-cloud-test-resources)
AzdDirectory: ""
ServiceConnectionId: "3d79cc98-46f2-428c-bdd5-861414f85602"

steps:
- pwsh: |
Expand All @@ -14,8 +15,10 @@ steps:
${{ parameters.SubscriptionConfiguration }}
'@ | ConvertFrom-Json -AsHashtable;
# Delegate auth to az CLI which supports federated auth in AzDo
& $azdCmd config set auth.useAzCliAuth true
& $azdCmd login `
--client-id "$($subscriptionConfiguration.TestApplicationId)" `
--tenant-id "$($subscriptionConfiguration.TenantId)" `
--service-connection-id "${{ parameters.ServiceConnectionId }}"
if ($LASTEXITCODE -ne 0) { exit $LASTEXITCODE }
Expand All @@ -32,3 +35,5 @@ steps:
condition: and(succeeded(), ne(variables['Skip.LiveTest'], 'true'))
displayName: Azure Dev Login
env:
SYSTEM_ACCESSTOKEN: $(System.AccessToken)
16 changes: 8 additions & 8 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ go 1.23

require (
github.com/AlecAivazis/survey/v2 v2.3.2
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement v1.0.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appconfiguration/armappconfiguration v1.0.0
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appcontainers/armappcontainers/v3 v3.0.0-beta.1
Expand Down Expand Up @@ -67,14 +67,14 @@ require (
go.opentelemetry.io/otel/trace v1.8.0
go.uber.org/atomic v1.9.0
go.uber.org/multierr v1.8.0
golang.org/x/sys v0.21.0
golang.org/x/sys v0.25.0
gopkg.in/dnaeon/go-vcr.v3 v3.1.2
gopkg.in/yaml.v3 v3.0.1
)

require (
github.com/Azure/azure-pipeline-go v0.2.1 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.9.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 // indirect
github.com/Azure/azure-sdk-for-go/sdk/security/keyvault/internal v0.8.0 // indirect
github.com/cenkalti/backoff/v4 v4.1.3 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
Expand All @@ -99,10 +99,10 @@ require (
go.opentelemetry.io/otel/exporters/otlp/internal/retry v1.8.0 // indirect
go.opentelemetry.io/otel/exporters/otlp/otlptrace v1.8.0 // indirect
go.opentelemetry.io/proto/otlp v0.18.0 // indirect
golang.org/x/crypto v0.24.0 // indirect
golang.org/x/net v0.26.0 // indirect
golang.org/x/term v0.21.0 // indirect
golang.org/x/text v0.16.0 // indirect
golang.org/x/crypto v0.27.0 // indirect
golang.org/x/net v0.29.0 // indirect
golang.org/x/term v0.24.0 // indirect
golang.org/x/text v0.18.0 // indirect
google.golang.org/genproto v0.0.0-20230410155749-daa745c078e1 // indirect
google.golang.org/grpc v1.56.3 // indirect
google.golang.org/protobuf v1.33.0 // indirect
Expand Down
16 changes: 16 additions & 0 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -53,10 +53,16 @@ github.com/Azure/azure-pipeline-go v0.2.1 h1:OLBdZJ3yvOn2MezlWvbrBMTEUQC72zAftRZ
github.com/Azure/azure-pipeline-go v0.2.1/go.mod h1:UGSo8XybXnIGZ3epmeBw7Jdz+HiUVpqIlpz/HKHylF4=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0 h1:1nGuui+4POelzDwI7RG56yfQJHCnKvwfMoU7VsEp+Zg=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.12.0/go.mod h1:99EvauvlcJ1U06amZiksfYz/3aFGyIhWGHVyiZXtBAI=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0 h1:nyQWyZvwGTvunIMxi1Y9uXkcyr+I7TeNrr/foo4Kpk8=
github.com/Azure/azure-sdk-for-go/sdk/azcore v1.14.0/go.mod h1:l38EPgmsp71HHLq9j7De57JcKOWPyhrsW1Awm1JS6K0=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0 h1:U2rTu3Ef+7w9FHKIAXM6ZyqF3UOWJZ12zIm8zECAFfg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.6.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0 h1:tfLQ34V6F7tVSwoTf/4lH5sE0o6eCJuNDTmH09nDpbc=
github.com/Azure/azure-sdk-for-go/sdk/azidentity v1.7.0/go.mod h1:9kIvujWAA58nmPmWB1m23fyWic1kYZMxD9CxaWn4Qpg=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.9.0 h1:H+U3Gk9zY56G3u872L82bk4thcsy2Gghb9ExT4Zvm1o=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.9.0/go.mod h1:mgrmMSgaLp9hmax62XQTd0N4aAqSE5E0DulSpVYK7vc=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0 h1:ywEEhmNahHBihViHepv3xPBn1663uRv2t2q/ESv9seY=
github.com/Azure/azure-sdk-for-go/sdk/internal v1.10.0/go.mod h1:iZDifYGJTIgIIkYRNWPENUnqx6bJ2xnSDFI2tjwZNuY=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement v1.0.0 h1:Ai3+BE11JvwQ2PxLGNKAfMNSceYXjeijReLJiCouO6o=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/apimanagement/armapimanagement v1.0.0/go.mod h1:gr6fiHmIii3Zw3riWMSr+P0tWTz4hfqTVcFttdi2JBo=
github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/appconfiguration/armappconfiguration v1.0.0 h1:5reBX+9pzc5xp9VrjSUoPrE8Wl/3y7wjfHzGjXzJbNk=
Expand Down Expand Up @@ -566,6 +572,8 @@ golang.org/x/crypto v0.0.0-20200622213623-75b288015ac9/go.mod h1:LzIPMQfyMNhhGPh
golang.org/x/crypto v0.0.0-20210817164053-32db794688a5/go.mod h1:GvvjBRRGRdwPK5ydBHafDWAxML/pGHZbMvKqRZ5+Abc=
golang.org/x/crypto v0.24.0 h1:mnl8DM0o513X8fdIkmyFE/5hTYxbwYOjDS/+rK6qpRI=
golang.org/x/crypto v0.24.0/go.mod h1:Z1PMYSOR5nyMcyAVAIQSKCDwalqy85Aqn1x3Ws4L5DM=
golang.org/x/crypto v0.27.0 h1:GXm2NjJrPaiv/h1tb2UH8QfgC/hOf/+z0p6PT8o1w7A=
golang.org/x/crypto v0.27.0/go.mod h1:1Xngt8kV6Dvbssa53Ziq6Eqn0HqbZi5Z6R0ZpwQzt70=
golang.org/x/exp v0.0.0-20190121172915-509febef88a4/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190306152737-a1d7652674e8/go.mod h1:CJ0aWSM057203Lf6IL+f9T1iT9GByDxfZKAQTCR3kQA=
golang.org/x/exp v0.0.0-20190510132918-efd6b22b2522/go.mod h1:ZjyILWgesfNpC6sMxTJOJm9Kp84zZh5NQWvqDGG3Qr8=
Expand Down Expand Up @@ -646,6 +654,8 @@ golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qx
golang.org/x/net v0.0.0-20210813160813-60bc85c4be6d/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y=
golang.org/x/net v0.26.0 h1:soB7SVo0PWrY4vPW/+ay0jKDNScG2X9wFeYlXIvJsOQ=
golang.org/x/net v0.26.0/go.mod h1:5YKkiSynbBIh3p6iOc/vibscux0x38BZDkn8sCUPxHE=
golang.org/x/net v0.29.0 h1:5ORfpBpCs4HzDYoodCDBbwHzdR5UrLBZ3sOnUJmFoHo=
golang.org/x/net v0.29.0/go.mod h1:gLkgy8jTGERgjzMic6DS9+SP0ajcu6Xu3Orq/SpETg0=
golang.org/x/oauth2 v0.0.0-20180821212333-d2e6202438be/go.mod h1:N/0e6XlmueqKjAGxoOufVs8QHGRruUQn6yWY3a++T0U=
golang.org/x/oauth2 v0.0.0-20190226205417-e64efc72b421/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
golang.org/x/oauth2 v0.0.0-20190604053449-0f29369cfe45/go.mod h1:gOpvHmFTYa4IltrdGE7lF6nIHvwfUNPOp7c8zoXwtLw=
Expand Down Expand Up @@ -749,10 +759,14 @@ golang.org/x/sys v0.1.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.20.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.21.0 h1:rF+pYz3DAGSQAxAu1CbC7catZg4ebC4UIeIhKxBZvws=
golang.org/x/sys v0.21.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.25.0 h1:r+8e+loiHxRqhXVl6ML1nO3l1+oFoWbnlu2Ehimmi34=
golang.org/x/sys v0.25.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/term v0.0.0-20201126162022-7de9c90e9dd1/go.mod h1:bj7SfCRtBDWHUb9snDiAeCFNEtKQo2Wmx5Cou7ajbmo=
golang.org/x/term v0.0.0-20210503060354-a79de5458b56/go.mod h1:tfny5GFUkzUvx4ps4ajbZsCe5lw1metzhBm9T3x7oIY=
golang.org/x/term v0.21.0 h1:WVXCp+/EBEHOj53Rvu+7KiT/iElMrO8ACK16SMZ3jaA=
golang.org/x/term v0.21.0/go.mod h1:ooXLefLobQVslOqselCNF4SxFAaoS6KujMbsGzSDmX0=
golang.org/x/term v0.24.0 h1:Mh5cbb+Zk2hqqXNO7S1iTjEphVL+jb8ZWaqh/g+JWkM=
golang.org/x/term v0.24.0/go.mod h1:lOBK/LVxemqiMij05LGJ0tzNr8xlmwBRJ81PX6wVLH8=
golang.org/x/text v0.0.0-20170915032832-14c0d48ead0c/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.0/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
golang.org/x/text v0.3.1-0.20180807135948-17ff2d5776d2/go.mod h1:NqM8EUOU14njkJ3fqMW+pc6Ldnwhi/IjpwHt7yyuwOQ=
Expand All @@ -764,6 +778,8 @@ golang.org/x/text v0.3.6/go.mod h1:5Zoc/QRtKVWzQhOtBMvqHzDpF6irO9z98xDceosuGiQ=
golang.org/x/text v0.3.7/go.mod h1:u+2+/6zg+i71rQMx5EYifcz6MCKuco9NR6JIITiCfzQ=
golang.org/x/text v0.16.0 h1:a94ExnEXNtEwYLGJSIUxnWoxoRz/ZcCsV63ROupILh4=
golang.org/x/text v0.16.0/go.mod h1:GhwF1Be+LQoKShO3cGOHzqOgRrGaYc9AvblQOmPVHnI=
golang.org/x/text v0.18.0 h1:XvMDiNzPAl0jr17s6W9lcaIhGUfUORdGCNsuLmPG224=
golang.org/x/text v0.18.0/go.mod h1:BuEKDfySbSR4drPmRPG/7iBdf8hvFMuRexcpahXilzY=
golang.org/x/time v0.0.0-20181108054448-85acf8d2951c/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20190308202827-9d24e82272b4/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
golang.org/x/time v0.0.0-20191024005414-555d28b269f0/go.mod h1:tRJNPiyCQ0inRvYxbN9jk5I+vvW/OXSQhTDSoE431IQ=
Expand Down

0 comments on commit ba39a27

Please sign in to comment.