From bd4ffe858ea0ab344ff4de0baddf496adcc950f2 Mon Sep 17 00:00:00 2001 From: Matt Greenfield Date: Mon, 22 Jun 2020 19:14:13 -0600 Subject: [PATCH] PROD-347: Add access token endpoint Adds the /token/ endpoint to return an Oauth access token. This access token is not leased because these tokens have a TTL of 60m and are not revokable upstream. Caveats: - The /roles/ backend will create a separate App/SP with the same logic as the /roles/ creds. So, a unified App/Service Principal is not used between the various endpoints for a given role. - No changes were made to how deleting a role revokes the cloud resources used by the /creds/ endpoint. - An "existing Service Principal" still creates an App password as opposed to a service principal password. --- api/api.go | 1 + api/auth.go | 30 +++++ backend.go | 1 + client.go | 15 ++- go.mod | 7 +- go.sum | 1 + path_access_token.go | 126 ++++++++++++++++++ path_access_token_test.go | 105 +++++++++++++++ path_roles.go | 232 ++++++++++++++++++++++++++++++--- path_roles_test.go | 1 + path_service_principal.go | 167 ++++++++++++++---------- path_service_principal_test.go | 6 +- provider.go | 8 ++ provider_mock_test.go | 27 ++++ 14 files changed, 630 insertions(+), 97 deletions(-) create mode 100644 api/auth.go create mode 100644 path_access_token.go create mode 100644 path_access_token_test.go diff --git a/api/api.go b/api/api.go index 09040aff..0c951422 100644 --- a/api/api.go +++ b/api/api.go @@ -16,6 +16,7 @@ type AzureProvider interface { ApplicationsClient GroupsClient ServicePrincipalClient + TokenClient CreateRoleAssignment( ctx context.Context, diff --git a/api/auth.go b/api/auth.go new file mode 100644 index 00000000..1eaf4de1 --- /dev/null +++ b/api/auth.go @@ -0,0 +1,30 @@ +package api + +import ( + "github.com/Azure/go-autorest/autorest/adal" + "github.com/Azure/go-autorest/autorest/azure/auth" +) + +type TokenClient interface { + GetToken(c auth.ClientCredentialsConfig) (adal.Token, error) +} + +var _ TokenClient = (*AccessTokenClient)(nil) + +type AccessTokenClient struct{} + +// GetToken fetches a new Azure OAuth2 bearer token from the given clients +// credentials and tenant. +func (p *AccessTokenClient) GetToken(c auth.ClientCredentialsConfig) (adal.Token, error) { + t, err := c.ServicePrincipalToken() + if err != nil { + return adal.Token{}, err + } + + err = t.Refresh() + if err != nil { + return adal.Token{}, err + } + + return t.Token(), nil +} diff --git a/backend.go b/backend.go index 919809dd..67f926dc 100644 --- a/backend.go +++ b/backend.go @@ -50,6 +50,7 @@ func backend() *azureSecretBackend { Paths: framework.PathAppend( pathsRole(&b), []*framework.Path{ + pathAccessToken(&b), pathConfig(&b), pathServicePrincipal(&b), pathRotateRoot(&b), diff --git a/client.go b/client.go index d9fe108f..806d59a2 100644 --- a/client.go +++ b/client.go @@ -144,7 +144,7 @@ func (c *client) assignRoles(ctx context.Context, spID string, roles []*AzureRol ra, err := c.provider.CreateRoleAssignment(ctx, role.Scope, assignmentID, authorization.RoleAssignmentCreateParameters{ RoleAssignmentProperties: &authorization.RoleAssignmentProperties{ - RoleDefinitionID: &role.RoleID, + RoleDefinitionID: to.StringPtr(role.RoleID), PrincipalID: &spID, }, }) @@ -154,6 +154,7 @@ func (c *client) assignRoles(ctx context.Context, spID string, roles []*AzureRol return nil, false, nil } + role.RoleAssignmentID = to.String(ra.ID) return to.String(ra.ID), true, err }) @@ -171,11 +172,11 @@ func (c *client) assignRoles(ctx context.Context, spID string, roles []*AzureRol // This is a clean-up operation that isn't essential to revocation. As such, an // attempt is made to remove all assignments, and not return immediately if there // is an error. -func (c *client) unassignRoles(ctx context.Context, roleIDs []string) error { +func (c *client) unassignRoles(ctx context.Context, roles []*AzureRole) error { var merr *multierror.Error - for _, id := range roleIDs { - if _, err := c.provider.DeleteRoleAssignmentByID(ctx, id); err != nil { + for _, role := range roles { + if _, err := c.provider.DeleteRoleAssignmentByID(ctx, role.RoleAssignmentID); err != nil { merr = multierror.Append(merr, fmt.Errorf("error unassigning role: %w", err)) } } @@ -209,11 +210,11 @@ func (c *client) addGroupMemberships(ctx context.Context, spID string, groups [] // groups. This is a clean-up operation that isn't essential to revocation. As // such, an attempt is made to remove all memberships, and not return // immediately if there is an error. -func (c *client) removeGroupMemberships(ctx context.Context, servicePrincipalObjectID string, groupIDs []string) error { +func (c *client) removeGroupMemberships(ctx context.Context, servicePrincipalObjectID string, groups []*AzureGroup) error { var merr *multierror.Error - for _, id := range groupIDs { - if err := c.provider.RemoveGroupMember(ctx, servicePrincipalObjectID, id); err != nil { + for _, group := range groups { + if err := c.provider.RemoveGroupMember(ctx, servicePrincipalObjectID, group.ObjectID); err != nil { merr = multierror.Append(merr, fmt.Errorf("error removing group membership: %w", err)) } } diff --git a/go.mod b/go.mod index 49b5e77b..d9bfbd71 100644 --- a/go.mod +++ b/go.mod @@ -5,9 +5,13 @@ go 1.19 require ( github.com/Azure/azure-sdk-for-go v67.0.0+incompatible github.com/Azure/go-autorest/autorest v0.11.28 + github.com/Azure/go-autorest/autorest/adal v0.9.18 github.com/Azure/go-autorest/autorest/azure/auth v0.5.11 github.com/Azure/go-autorest/autorest/date v0.3.0 github.com/Azure/go-autorest/autorest/to v0.4.0 + github.com/Azure/go-autorest/autorest/validation v0.2.0 // indirect + github.com/fatih/color v1.13.0 // indirect + github.com/fatih/structs v1.1.0 github.com/go-test/deep v1.0.8 github.com/golang/mock v1.6.0 github.com/hashicorp/go-hclog v1.3.1 @@ -21,9 +25,7 @@ require ( require ( github.com/Azure/go-autorest v14.2.0+incompatible // indirect - github.com/Azure/go-autorest/autorest/adal v0.9.18 // indirect github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect - github.com/Azure/go-autorest/autorest/validation v0.2.0 // indirect github.com/Azure/go-autorest/logger v0.2.1 // indirect github.com/Azure/go-autorest/tracing v0.6.0 // indirect github.com/armon/go-metrics v0.3.9 // indirect @@ -31,7 +33,6 @@ require ( github.com/cenkalti/backoff/v3 v3.0.0 // indirect github.com/dimchansky/utfbom v1.1.1 // indirect github.com/evanphx/json-patch/v5 v5.5.0 // indirect - github.com/fatih/color v1.13.0 // indirect github.com/golang-jwt/jwt/v4 v4.2.0 // indirect github.com/golang/protobuf v1.5.2 // indirect github.com/golang/snappy v0.0.4 // indirect diff --git a/go.sum b/go.sum index 1f66f660..4083dd64 100644 --- a/go.sum +++ b/go.sum @@ -78,6 +78,7 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w= github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk= github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo= +github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M= github.com/frankban/quicktest v1.13.0 h1:yNZif1OkDfNoDfb9zZa9aXIpejNR4F23Wely0c+Qdqk= github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04= github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as= diff --git a/path_access_token.go b/path_access_token.go new file mode 100644 index 00000000..0cb03d7e --- /dev/null +++ b/path_access_token.go @@ -0,0 +1,126 @@ +package azuresecrets + +import ( + "context" + "fmt" + "strings" + + azureadal "github.com/Azure/go-autorest/autorest/adal" + azureauth "github.com/Azure/go-autorest/autorest/azure/auth" + "github.com/fatih/structs" + "github.com/hashicorp/vault/sdk/framework" + "github.com/hashicorp/vault/sdk/logical" +) + +const ( + azureAppNotFoundErrCode = 700016 +) + +func pathAccessToken(b *azureSecretBackend) *framework.Path { + return &framework.Path{ + Pattern: fmt.Sprintf("token/%s", framework.GenericNameRegex("role")), + Fields: map[string]*framework.FieldSchema{ + "role": { + Type: framework.TypeLowerCaseString, + Description: "Name of the Vault role", + }, + "resource": { + Type: framework.TypeString, + Description: "The specific Azure audience of a generated access token", + Default: "https://management.azure.com/", + }, + }, + Operations: map[logical.Operation]framework.OperationHandler{ + logical.ReadOperation: &framework.PathOperation{ + Callback: b.pathAccessTokenRead, + }, + }, + HelpSynopsis: pathAccessTokenHelpSyn, + HelpDescription: pathAccessTokenHelpDesc, + } +} + +func (b *azureSecretBackend) pathAccessTokenRead(ctx context.Context, request *logical.Request, data *framework.FieldData) (*logical.Response, error) { + roleName := data.Get("role").(string) + resource := data.Get("resource").(string) + + role, err := getRole(ctx, roleName, request.Storage) + if err != nil { + return nil, err + } + + if role == nil { + return logical.ErrorResponse("role '%s' does not exist", roleName), nil + } + + if role.CredentialType != credentialTypeSP { + return logical.ErrorResponse("role '%s' cannot generate access tokens (has secret type %s)", roleName, role.CredentialType), nil + } + + if role.Credentials == nil { + return logical.ErrorResponse("role '%s' configured before plugin supported access tokens (update or recreate role)", roleName), nil + } + + return b.secretAccessTokenResponse(ctx, request.Storage, role, resource) +} + +func (b *azureSecretBackend) secretAccessTokenResponse(ctx context.Context, storage logical.Storage, role *roleEntry, resource string) (*logical.Response, error) { + client, err := b.getClient(ctx, storage) + if err != nil { + return nil, err + } + + cc := azureauth.NewClientCredentialsConfig(role.ApplicationID, role.Credentials.Password, client.settings.TenantID) + cc.Resource = resource + token, err := b.getToken(ctx, client, cc) + if err != nil { + return nil, err + } + + // access_tokens are not revocable therefore do not return a framework.Secret (i.e. a lease) + return &logical.Response{Data: structsMap(token)}, nil +} + +func structsMap(s interface{}) map[string]interface{} { + t := structs.New(s) + t.TagName = "json" + return t.Map() +} + +func (b *azureSecretBackend) getToken(ctx context.Context, client *client, c azureauth.ClientCredentialsConfig) (azureadal.Token, error) { + token, err := retry(ctx, func() (interface{}, bool, error) { + t, err := client.provider.GetToken(c) + + if hasAzureErrorCode(err, azureAppNotFoundErrCode) { + return nil, false, nil + } else if err != nil { + return nil, true, err + } + + return t, true, nil + }) + + var t azureadal.Token + if token != nil { + t = token.(azureadal.Token) + } + + return t, err +} + +func hasAzureErrorCode(e error, code int) bool { + tErr, ok := e.(azureadal.TokenRefreshError) + + // use a pattern match as TokenRefreshError is not easily parsable + return ok && tErr != nil && strings.Contains(tErr.Error(), fmt.Sprint(code)) +} + +const pathAccessTokenHelpSyn = ` +Request an access token for a given Vault role. +` + +const pathAccessTokenHelpDesc = ` +This path creates access token credentials. The associated role must +be created ahead of time with either an existing App/Service Principal or +else a dynamic Service Principal will be created. +` diff --git a/path_access_token_test.go b/path_access_token_test.go new file mode 100644 index 00000000..7f893b55 --- /dev/null +++ b/path_access_token_test.go @@ -0,0 +1,105 @@ +package azuresecrets + +import ( + "context" + "testing" + + "github.com/hashicorp/vault/sdk/logical" +) + +func Test_azureSecretBackend_pathAccessTokenRead(t *testing.T) { + b, s := getTestBackend(t, true) + + t.Run("token generated", func(t *testing.T) { + role := generateUUID() + testRoleCreate(t, b, s, role, testStaticSPRole) + + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: "token/" + role, + Storage: s, + }) + + assertErrorIsNil(t, err) + + if resp.IsError() { + t.Fatalf("receive response error: %v", resp.Error()) + } + + if _, ok := resp.Data["access_token"]; !ok { + t.Fatalf("access_token not found in response") + } + + if _, ok := resp.Data["refresh_token"]; !ok { + t.Fatalf("refresh_token not found in response") + } + + if _, ok := resp.Data["expires_in"]; !ok { + t.Fatalf("expires_in not found in response") + } + + if _, ok := resp.Data["expires_on"]; !ok { + t.Fatalf("expires_on not found in response") + } + + if _, ok := resp.Data["not_before"]; !ok { + t.Fatalf("not_before not found in response") + } + + r, ok := resp.Data["resource"] + if !ok { + t.Fatalf("resource not found in response") + } + if r != "https://management.azure.com/" { + t.Fatalf("resource not equal to requested") + } + + if _, ok := resp.Data["token_type"]; !ok { + t.Fatalf("token_type not found in response") + } + }) + + t.Run("non default resource token generated", func(t *testing.T) { + role := generateUUID() + testRoleCreate(t, b, s, role, testStaticSPRole) + + resource := "https://resource.endpoint/" + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: "token/" + role, + Data: map[string]interface{}{ + "resource": resource, + }, + Storage: s, + }) + + assertErrorIsNil(t, err) + + if resp.IsError() { + t.Fatalf("receive response error: %v", resp.Error()) + } + + r, ok := resp.Data["resource"] + if !ok { + t.Fatalf("resource not found in response") + } + if r != resource { + t.Fatalf("resource not equal to requested") + } + }) + + t.Run("role does not exist", func(t *testing.T) { + role := generateUUID() + resp, err := b.HandleRequest(context.Background(), &logical.Request{ + Operation: logical.ReadOperation, + Path: "token/" + role, + Storage: s, + }) + + assertErrorIsNil(t, err) + + if !resp.IsError() { + t.Fatal("expected missing role error") + } + }) +} diff --git a/path_roles.go b/path_roles.go index 6ee7626c..6f1d755d 100644 --- a/path_roles.go +++ b/path_roles.go @@ -12,12 +12,19 @@ import ( "github.com/hashicorp/vault-plugin-secrets-azure/api" "github.com/hashicorp/vault/sdk/framework" "github.com/hashicorp/vault/sdk/helper/jsonutil" + "github.com/hashicorp/vault/sdk/helper/locksutil" "github.com/hashicorp/vault/sdk/logical" ) const ( rolesStoragePath = "roles" + // applicationTypeStatic for when a role is configured with an application_object_id (i.e. the application is managed externally) + applicationTypeStatic = "static" + + // applicationTypeDynamic for when a role is configured without an application_object_id + applicationTypeDynamic = "dynamic" + credentialTypeSP = 0 ) @@ -31,6 +38,15 @@ type roleEntry struct { TTL time.Duration `json:"ttl"` MaxTTL time.Duration `json:"max_ttl"` PermanentlyDelete bool `json:"permanently_delete"` + + ApplicationType string `json:"application_type"` + ServicePrincipalID string `json:"service_principal_id"` + Credentials *ClientCredentials `json:"credentials"` +} + +type ClientCredentials struct { + KeyId string `json:"key_id"` + Password string `json:"password"` } // AzureRole is an Azure Role (https://docs.microsoft.com/en-us/azure/role-based-access-control/overview) applied @@ -40,6 +56,8 @@ type AzureRole struct { RoleName string `json:"role_name"` // e.g. Owner RoleID string `json:"role_id"` // e.g. /subscriptions/e0a207b2-.../providers/Microsoft.Authorization/roleDefinitions/de139f84-... Scope string `json:"scope"` // e.g. /subscriptions/e0a207b2-... + + RoleAssignmentID string `json:"role_assignment_id,omitempty"` // e.g. /subscriptions/e0a207b2-.../providers/Microsoft.Authorization/roleAssignments/de139f84-... } // AzureGroup is an Azure Active Directory Group @@ -147,17 +165,43 @@ func (b *azureSecretBackend) pathRoleUpdate(ctx context.Context, req *logical.Re // load or create role name := d.Get("name").(string) + + lock := locksutil.LockForKey(b.appLocks, name) + lock.Lock() + defer lock.Unlock() + role, err := getRole(ctx, name, req.Storage) if err != nil { return nil, fmt.Errorf("error reading role: %w", err) } + var appObjectID string + appObjectIDRaw, appObjectIDRawOk := d.GetOk("application_object_id") + if appObjectIDRawOk { + appObjectID = appObjectIDRaw.(string) + } + if role == nil { if req.Operation == logical.UpdateOperation { return nil, errors.New("role entry not found during update operation") } role = &roleEntry{ - CredentialType: credentialTypeSP, + ApplicationObjectID: appObjectID, + AzureGroups: []*AzureGroup{}, + AzureRoles: []*AzureRole{}, + CredentialType: credentialTypeSP, + } + + if role.ApplicationObjectID == "" { + role.ApplicationType = applicationTypeDynamic + } else { + role.ApplicationType = applicationTypeStatic + } + } else { + // Ensure the application_object_id doesn't change. Effectively also ensure that static and dynamic + // roles remain as static or dynamic, respectively. + if appObjectIDRawOk && appObjectID != role.ApplicationObjectID { + return logical.ErrorResponse("the role's application_object_id cannot be updated/removed (recreate role)"), nil } } @@ -203,6 +247,7 @@ func (b *azureSecretBackend) pathRoleUpdate(ctx context.Context, req *logical.Re } // Parse the Azure roles + var requestedRoles []*AzureRole if roles, ok := d.GetOk("azure_roles"); ok { parsedRoles := make([]*AzureRole, 0) // non-nil to avoid a "missing roles" error later @@ -210,10 +255,11 @@ func (b *azureSecretBackend) pathRoleUpdate(ctx context.Context, req *logical.Re if err != nil { return logical.ErrorResponse("error parsing Azure roles '%s': %s", roles.(string), err.Error()), nil } - role.AzureRoles = parsedRoles + requestedRoles = parsedRoles } // Parse the Azure groups + var requestedGroups []*AzureGroup if groups, ok := d.GetOk("azure_groups"); ok { parsedGroups := make([]*AzureGroup, 0) // non-nil to avoid a "missing groups" error later @@ -221,12 +267,12 @@ func (b *azureSecretBackend) pathRoleUpdate(ctx context.Context, req *logical.Re if err != nil { return logical.ErrorResponse("error parsing Azure groups '%s': %s", groups.(string), err.Error()), nil } - role.AzureGroups = parsedGroups + requestedGroups = parsedGroups } // update and verify Azure roles, including looking up each role by ID or name. roleSet := make(map[string]bool) - for _, r := range role.AzureRoles { + for _, r := range requestedRoles { var roleDef authorization.RoleDefinition if r.RoleID != "" { roleDef, err = client.provider.GetRoleDefinitionByID(ctx, r.RoleID) @@ -263,7 +309,7 @@ func (b *azureSecretBackend) pathRoleUpdate(ctx context.Context, req *logical.Re // update and verify Azure groups, including looking up each group by ID or name. groupSet := make(map[string]bool) - for _, r := range role.AzureGroups { + for _, r := range requestedGroups { var groupDef api.Group if r.ObjectID != "" { groupDef, err = client.provider.GetGroup(ctx, r.ObjectID) @@ -295,10 +341,57 @@ func (b *azureSecretBackend) pathRoleUpdate(ctx context.Context, req *logical.Re groupSet[r.ObjectID] = true } - if role.ApplicationObjectID == "" && len(role.AzureRoles) == 0 && len(role.AzureGroups) == 0 { + if role.ApplicationObjectID == "" && len(requestedRoles) == 0 && len(requestedGroups) == 0 { return logical.ErrorResponse("either Azure role definitions, group definitions, or an Application Object ID must be provided"), nil } + if role.ApplicationType == applicationTypeStatic && role.ApplicationID == "" { + app, err := client.provider.GetApplication(ctx, role.ApplicationObjectID) + if err != nil { + return nil, fmt.Errorf("error loading Application: %w", err) + } + role.ApplicationID = to.String(app.AppID) + } + + if role.ApplicationType == applicationTypeDynamic { + if role.Credentials == nil { + walID, err := b.createSPSecret(ctx, req.Storage, client, role) + if err != nil { + return nil, err + } + + // SP is fully created so delete the WAL + if err := framework.DeleteWAL(ctx, req.Storage, walID); err != nil { + return nil, fmt.Errorf("error deleting WAL: %w", err) + } + } + + err, warn := b.configureRoles(ctx, client, role, requestedRoles) + if err != nil { + return nil, err + } + if warn != nil { + resp.AddWarning(warn.Error()) + } + + err, warn = b.configureGroups(ctx, client, role, requestedGroups) + if err != nil { + return nil, err + } + if warn != nil { + resp.AddWarning(warn.Error()) + } + } else if role.ApplicationType == applicationTypeStatic { + if role.Credentials == nil { + err = b.createStaticSPSecret(ctx, client, role) + if err != nil { + return nil, err + } + } + } else { + return nil, fmt.Errorf("unknown role ApplicationType \"%v\"", role.ApplicationType) + } + // save role err = saveRole(ctx, req.Storage, role, name) if err != nil { @@ -308,7 +401,45 @@ func (b *azureSecretBackend) pathRoleUpdate(ctx context.Context, req *logical.Re return resp, nil } +func (b *azureSecretBackend) configureGroups(ctx context.Context, client *client, role *roleEntry, requestedGroups []*AzureGroup) (err error, warn error) { + groupsToAdd := groupSetDifference(requestedGroups, role.AzureGroups) + groupsToRemove := groupSetDifference(role.AzureGroups, requestedGroups) + + err = client.addGroupMemberships(ctx, role.ServicePrincipalID, groupsToAdd) + if err != nil { + return + } + + warn = client.removeGroupMemberships(ctx, role.ServicePrincipalID, groupsToRemove) + if warn != nil { + return + } + + role.AzureGroups = requestedGroups + return +} + +func (b *azureSecretBackend) configureRoles(ctx context.Context, client *client, role *roleEntry, requestedRoles []*AzureRole) (err error, warn error) { + rolesToAdd := roleSetDifference(requestedRoles, role.AzureRoles) + rolesToRemove := roleSetDifference(role.AzureRoles, requestedRoles) + + _, err = client.assignRoles(ctx, role.ServicePrincipalID, rolesToAdd) + if err != nil { + return + } + + warn = client.unassignRoles(ctx, rolesToRemove) + if warn != nil { + return + } + + role.AzureRoles = requestedRoles + return +} + func (b *azureSecretBackend) pathRoleRead(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { + var data = make(map[string]interface{}) + name := d.Get("name").(string) config, err := b.getConfig(ctx, req.Storage) @@ -329,15 +460,22 @@ func (b *azureSecretBackend) pathRoleRead(ctx context.Context, req *logical.Requ return nil, nil } + data["ttl"] = r.TTL / time.Second + data["max_ttl"] = r.MaxTTL / time.Second + for _, ar := range r.AzureRoles { + ar.RoleAssignmentID = "" + } + data["azure_roles"] = r.AzureRoles + data["azure_groups"] = r.AzureGroups + aoid := "" + if r.ApplicationType == applicationTypeStatic { + aoid = r.ApplicationObjectID + } + data["application_object_id"] = aoid + data["permanently_delete"] = r.PermanentlyDelete + resp := &logical.Response{ - Data: map[string]interface{}{ - "ttl": r.TTL / time.Second, - "max_ttl": r.MaxTTL / time.Second, - "azure_roles": r.AzureRoles, - "azure_groups": r.AzureGroups, - "application_object_id": r.ApplicationObjectID, - "permanently_delete": r.PermanentlyDelete, - }, + Data: data, } return resp, nil } @@ -354,12 +492,40 @@ func (b *azureSecretBackend) pathRoleList(ctx context.Context, req *logical.Requ func (b *azureSecretBackend) pathRoleDelete(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { name := d.Get("name").(string) - err := req.Storage.Delete(ctx, fmt.Sprintf("%s/%s", rolesStoragePath, name)) + lock := locksutil.LockForKey(b.appLocks, name) + lock.Lock() + defer lock.Unlock() + + role, err := getRole(ctx, name, req.Storage) + if err != nil { + return nil, fmt.Errorf("unable to get role %s: %w", name, err) + } + if role == nil { + return nil, nil + } + + var resp *logical.Response + switch role.ApplicationType { + case applicationTypeStatic: + resp, err = b.staticSPRemove(ctx, req, role) + if err != nil { + return &logical.Response{Warnings: []string{"error removing existing Azure app password"}}, err + } + case applicationTypeDynamic: + resp, err = b.spRemove(ctx, req, role, role.PermanentlyDelete) + if err != nil { + return &logical.Response{Warnings: []string{"error removing dynamic Azure service principal"}}, err + } + default: + return nil, fmt.Errorf("unable to delete role, unknown role ApplicationType \"%v\"", role.ApplicationType) + } + + err = req.Storage.Delete(ctx, fmt.Sprintf("%s/%s", rolesStoragePath, name)) if err != nil { return nil, fmt.Errorf("error deleting role: %w", err) } - return nil, nil + return resp, nil } func (b *azureSecretBackend) pathRoleExistenceCheck(ctx context.Context, req *logical.Request, d *framework.FieldData) (bool, error) { @@ -399,6 +565,40 @@ func getRole(ctx context.Context, name string, s logical.Storage) (*roleEntry, e return role, nil } +func groupSetDifference(a []*AzureGroup, b []*AzureGroup) []*AzureGroup { + difference := []*AzureGroup{} + + m := make(map[AzureGroup]bool) + for _, bVal := range b { + m[*bVal] = true + } + + for _, aVal := range a { + if _, ok := m[*aVal]; !ok { + difference = append(difference, aVal) + } + } + + return difference +} + +func roleSetDifference(a []*AzureRole, b []*AzureRole) []*AzureRole { + difference := []*AzureRole{} + + m := make(map[AzureRole]bool) + for _, bVal := range b { + m[*bVal] = true + } + + for _, aVal := range a { + if _, ok := m[*aVal]; !ok { + difference = append(difference, aVal) + } + } + + return difference +} + const roleHelpSyn = "Manage the Vault roles used to generate Azure credentials." const roleHelpDesc = ` This path allows you to read and write roles that are used to generate Azure login diff --git a/path_roles_test.go b/path_roles_test.go index 295c62fc..6f8f61a5 100644 --- a/path_roles_test.go +++ b/path_roles_test.go @@ -85,6 +85,7 @@ func TestRoleCreate(t *testing.T) { assertErrorIsNil(t, err) convertRespTypes(resp.Data) + spRole2["application_object_id"] = "" equal(t, spRole2, resp.Data) }) diff --git a/path_service_principal.go b/path_service_principal.go index 7a6be5ff..f120fe17 100644 --- a/path_service_principal.go +++ b/path_service_principal.go @@ -76,18 +76,51 @@ func (b *azureSecretBackend) pathSPRead(ctx context.Context, req *logical.Reques return logical.ErrorResponse(fmt.Sprintf("role '%s' does not exist", roleName)), nil } - var resp *logical.Response + var secretType string + var raIDs []string + if role.ApplicationType == applicationTypeDynamic { + secretType = SecretTypeSP + walID, err := b.createSPSecret(ctx, req.Storage, client, role) + if err != nil { + return nil, err + } + + raIDs, err = client.assignRoles(ctx, role.ServicePrincipalID, role.AzureRoles) + if err != nil { + return nil, err + } - if role.ApplicationObjectID != "" { - resp, err = b.createStaticSPSecret(ctx, client, roleName, role) + err = client.addGroupMemberships(ctx, role.ServicePrincipalID, role.AzureGroups) + if err != nil { + return nil, err + } + // SP is fully created so delete the WAL + if err := framework.DeleteWAL(ctx, req.Storage, walID); err != nil { + return nil, fmt.Errorf("error deleting WAL: %w", err) + } + } else if role.ApplicationType == applicationTypeStatic { + secretType = SecretTypeStaticSP + err = b.createStaticSPSecret(ctx, client, role) + if err != nil { + return nil, err + } } else { - resp, err = b.createSPSecret(ctx, req.Storage, client, roleName, role) + return nil, fmt.Errorf("unknown role ApplicationType \"%v\"", role.ApplicationType) } - if err != nil { - return nil, err + data := map[string]interface{}{ + "client_id": role.ApplicationID, + "client_secret": role.Credentials.Password, } - + internalData := map[string]interface{}{ + "app_object_id": role.ApplicationObjectID, + "key_id": role.Credentials.KeyId, + "sp_object_id": role.ServicePrincipalID, + "role_assignment_ids": raIDs, + "group_membership_ids": groupObjectIDs(role.AzureGroups), + "role": roleName, + } + resp := b.Secret(secretType).Response(data, internalData) resp.Secret.TTL = role.TTL resp.Secret.MaxTTL = role.MaxTTL @@ -95,13 +128,13 @@ func (b *azureSecretBackend) pathSPRead(ctx context.Context, req *logical.Reques } // createSPSecret generates a new App/Service Principal. -func (b *azureSecretBackend) createSPSecret(ctx context.Context, s logical.Storage, c *client, roleName string, role *roleEntry) (*logical.Response, error) { +func (b *azureSecretBackend) createSPSecret(ctx context.Context, s logical.Storage, c *client, role *roleEntry) (string, error) { // Create the App, which is the top level object to be tracked in the secret // and deleted upon revocation. If any subsequent step fails, the App will be // deleted as part of WAL rollback. app, err := c.createApp(ctx) if err != nil { - return nil, err + return "", err } appID := to.String(app.AppID) appObjID := to.String(app.ID) @@ -113,69 +146,42 @@ func (b *azureSecretBackend) createSPSecret(ctx context.Context, s logical.Stora Expiration: time.Now().Add(maxWALAge), }) if err != nil { - return nil, fmt.Errorf("error writing WAL: %w", err) + return "", fmt.Errorf("error writing WAL: %w", err) } // Create a service principal associated with the new App spID, password, err := c.createSP(ctx, app, spExpiration) if err != nil { - return nil, err - } - - // Assign Azure roles to the new SP - raIDs, err := c.assignRoles(ctx, spID, role.AzureRoles) - if err != nil { - return nil, err + return "", err } - // Assign Azure group memberships to the new SP - if err := c.addGroupMemberships(ctx, spID, role.AzureGroups); err != nil { - return nil, err - } - - // SP is fully created so delete the WAL - if err := framework.DeleteWAL(ctx, s, walID); err != nil { - return nil, fmt.Errorf("error deleting WAL: %w", err) - } - - data := map[string]interface{}{ - "client_id": appID, - "client_secret": password, - } - internalData := map[string]interface{}{ - "app_object_id": appObjID, - "sp_object_id": spID, - "role_assignment_ids": raIDs, - "group_membership_ids": groupObjectIDs(role.AzureGroups), - "role": roleName, - "permanently_delete": role.PermanentlyDelete, + role.ApplicationID = appID + role.ApplicationObjectID = appObjID + role.ServicePrincipalID = spID + role.Credentials = &ClientCredentials{ + Password: password, } - return b.Secret(SecretTypeSP).Response(data, internalData), nil + return walID, nil } // createStaticSPSecret adds a new password to the App associated with the role. -func (b *azureSecretBackend) createStaticSPSecret(ctx context.Context, c *client, roleName string, role *roleEntry) (*logical.Response, error) { +func (b *azureSecretBackend) createStaticSPSecret(ctx context.Context, c *client, role *roleEntry) error { lock := locksutil.LockForKey(b.appLocks, role.ApplicationObjectID) lock.Lock() defer lock.Unlock() keyID, password, err := c.addAppPassword(ctx, role.ApplicationObjectID, spExpiration) if err != nil { - return nil, err + return err } - data := map[string]interface{}{ - "client_id": role.ApplicationID, - "client_secret": password, - } - internalData := map[string]interface{}{ - "app_object_id": role.ApplicationObjectID, - "key_id": keyID, - "role": roleName, + role.Credentials = &ClientCredentials{ + KeyId: keyID, + Password: password, } - return b.Secret(SecretTypeStaticSP).Response(data, internalData), nil + return nil } func (b *azureSecretBackend) spRenew(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { @@ -201,8 +207,6 @@ func (b *azureSecretBackend) spRenew(ctx context.Context, req *logical.Request, } func (b *azureSecretBackend) spRevoke(ctx context.Context, req *logical.Request, d *framework.FieldData) (*logical.Response, error) { - resp := new(logical.Response) - appObjectIDRaw, ok := req.Secret.InternalData["app_object_id"] if !ok { return nil, errors.New("internal data 'app_object_id' not found") @@ -224,22 +228,37 @@ func (b *azureSecretBackend) spRevoke(ctx context.Context, req *logical.Request, permanentlyDelete = permanentlyDeleteRaw.(bool) } - var raIDs []string + var roles []*AzureRole if req.Secret.InternalData["role_assignment_ids"] != nil { for _, v := range req.Secret.InternalData["role_assignment_ids"].([]interface{}) { - raIDs = append(raIDs, v.(string)) + roles = append(roles, &AzureRole{ + RoleAssignmentID: v.(string), + }) } } - var gmIDs []string + var groups []*AzureGroup if req.Secret.InternalData["group_membership_ids"] != nil { for _, v := range req.Secret.InternalData["group_membership_ids"].([]interface{}) { - gmIDs = append(gmIDs, v.(string)) + groups = append(groups, &AzureGroup{ + ObjectID: v.(string), + }) } } - if len(gmIDs) != 0 && spObjectID == "" { - return nil, errors.New("internal data 'sp_object_id' not found") + r := &roleEntry{ + AzureRoles: roles, + AzureGroups: groups, + ApplicationObjectID: appObjectID, + ServicePrincipalID: spObjectID, + } + + return b.spRemove(ctx, req, r, permanentlyDelete) +} + +func (b *azureSecretBackend) spRemove(ctx context.Context, req *logical.Request, role *roleEntry, permanentlyDelete bool) (*logical.Response, error) { + if len(role.AzureGroups) != 0 && role.ServicePrincipalID == "" { + return nil, errors.New("service principal ID not found") } c, err := b.getClient(ctx, req.Storage) @@ -247,27 +266,28 @@ func (b *azureSecretBackend) spRevoke(ctx context.Context, req *logical.Request, return nil, fmt.Errorf("error during revoke: %w", err) } + resp := new(logical.Response) // unassigning roles is effectively a garbage collection operation. Errors will be noted but won't fail the // revocation process. Deleting the app, however, *is* required to consider the secret revoked. - if err := c.unassignRoles(ctx, raIDs); err != nil { + if err := c.unassignRoles(ctx, role.AzureRoles); err != nil { resp.AddWarning(err.Error()) } // removing group membership is effectively a garbage collection // operation. Errors will be noted but won't fail the revocation process. // Deleting the app, however, *is* required to consider the secret revoked. - if err := c.removeGroupMemberships(ctx, spObjectID, gmIDs); err != nil { + if err := c.removeGroupMemberships(ctx, role.ServicePrincipalID, role.AzureGroups); err != nil { resp.AddWarning(err.Error()) } // removing the service principal is effectively a garbage collection // operation. Errors will be noted but won't fail the revocation process. // Deleting the app, however, *is* required to consider the secret revoked. - if err := c.deleteServicePrincipal(ctx, spObjectID, permanentlyDelete); err != nil { + if err := c.deleteServicePrincipal(ctx, role.ServicePrincipalID, permanentlyDelete); err != nil { resp.AddWarning(err.Error()) } - err = c.deleteApp(ctx, appObjectID, permanentlyDelete) + err = c.deleteApp(ctx, role.ApplicationObjectID, permanentlyDelete) return resp, err } @@ -279,11 +299,6 @@ func (b *azureSecretBackend) staticSPRevoke(ctx context.Context, req *logical.Re appObjectID := appObjectIDRaw.(string) - c, err := b.getClient(ctx, req.Storage) - if err != nil { - return nil, fmt.Errorf("error during revoke: %w", err) - } - keyIDRaw, ok := req.Secret.InternalData["key_id"] if !ok { return nil, errors.New("internal data 'key_id' not found") @@ -293,7 +308,23 @@ func (b *azureSecretBackend) staticSPRevoke(ctx context.Context, req *logical.Re lock.Lock() defer lock.Unlock() - return nil, c.deleteAppPassword(ctx, appObjectID, keyIDRaw.(string)) + r := &roleEntry{ + ApplicationObjectID: appObjectID, + Credentials: &ClientCredentials{ + KeyId: keyIDRaw.(string), + }, + } + + return b.staticSPRemove(ctx, req, r) +} + +func (b *azureSecretBackend) staticSPRemove(ctx context.Context, req *logical.Request, role *roleEntry) (*logical.Response, error) { + c, err := b.getClient(ctx, req.Storage) + if err != nil { + return nil, fmt.Errorf("error during revoke: %w", err) + } + + return nil, c.deleteAppPassword(ctx, role.ApplicationObjectID, role.Credentials.KeyId) } const pathServicePrincipalHelpSyn = ` diff --git a/path_service_principal_test.go b/path_service_principal_test.go index 711169d7..890ac40e 100644 --- a/path_service_principal_test.go +++ b/path_service_principal_test.go @@ -85,7 +85,6 @@ func TestSP_WAL_Cleanup(t *testing.T) { // verify basic cred issuance t.Run("Role assign fail", func(t *testing.T) { name := generateUUID() - testRoleCreate(t, b, s, name, testRole) // create a short timeout to short-circuit the retry process and trigger the // deadline error @@ -93,8 +92,9 @@ func TestSP_WAL_Cleanup(t *testing.T) { defer cancel() resp, err := b.HandleRequest(ctx, &logical.Request{ - Operation: logical.ReadOperation, - Path: "creds/" + name, + Operation: logical.CreateOperation, + Path: fmt.Sprintf("roles/%s", name), + Data: testRole, Storage: s, }) diff --git a/provider.go b/provider.go index ec1749ba..ab8f8cf4 100644 --- a/provider.go +++ b/provider.go @@ -6,6 +6,7 @@ import ( "github.com/Azure/azure-sdk-for-go/services/preview/authorization/mgmt/2018-01-01-preview/authorization" "github.com/Azure/go-autorest/autorest" + azureadal "github.com/Azure/go-autorest/autorest/adal" "github.com/Azure/go-autorest/autorest/azure/auth" "github.com/hashicorp/vault-plugin-secrets-azure/api" "github.com/hashicorp/vault/sdk/helper/useragent" @@ -24,6 +25,7 @@ type provider struct { groupsClient api.GroupsClient raClient *authorization.RoleAssignmentsClient rdClient *authorization.RoleDefinitionsClient + tokenClient api.TokenClient } // newAzureProvider creates an azureProvider, backed by Azure client objects for underlying services. @@ -76,6 +78,7 @@ func newAzureProvider(settings *clientSettings, passwords api.Passwords) (api.Az groupsClient: groupsClient, raClient: &raClient, rdClient: &rdClient, + tokenClient: &api.AccessTokenClient{}, } return p, nil @@ -206,3 +209,8 @@ func (p *provider) GetGroup(ctx context.Context, objectID string) (result api.Gr func (p *provider) ListGroups(ctx context.Context, filter string) (result []api.Group, err error) { return p.groupsClient.ListGroups(ctx, filter) } + +// GetToken gets an oauth access token for the current credential configuration. +func (p *provider) GetToken(c auth.ClientCredentialsConfig) (azureadal.Token, error) { + return p.tokenClient.GetToken(c) +} diff --git a/provider_mock_test.go b/provider_mock_test.go index dfe32705..19aa57f7 100644 --- a/provider_mock_test.go +++ b/provider_mock_test.go @@ -2,6 +2,8 @@ package azuresecrets import ( "context" + "encoding/base64" + "encoding/json" "errors" "fmt" "regexp" @@ -10,11 +12,18 @@ import ( "time" "github.com/Azure/azure-sdk-for-go/services/preview/authorization/mgmt/2018-01-01-preview/authorization" + azureadal "github.com/Azure/go-autorest/autorest/adal" + "github.com/Azure/go-autorest/autorest/azure/auth" "github.com/Azure/go-autorest/autorest/date" "github.com/Azure/go-autorest/autorest/to" "github.com/hashicorp/vault-plugin-secrets-azure/api" ) +var _ api.ApplicationsClient = (*mockProvider)(nil) +var _ api.GroupsClient = (*mockProvider)(nil) +var _ api.ServicePrincipalClient = (*mockProvider)(nil) +var _ api.TokenClient = (*mockProvider)(nil) + // mockProvider is a Provider that provides stubs and simple, deterministic responses. type mockProvider struct { subscriptionID string @@ -260,6 +269,24 @@ func (m *mockProvider) ListGroups(_ context.Context, filter string) ([]api.Group return []api.Group{}, nil } +func (m *mockProvider) GetToken(c auth.ClientCredentialsConfig) (azureadal.Token, error) { + expires := time.Now().Add(1 * time.Minute) + + jwt := []byte(fmt.Sprintf("{\"exp\":%v,\"aud\":\"audience\"}", expires.Unix())) + header := base64.StdEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte("header")) + payload := base64.StdEncoding.WithPadding(base64.NoPadding).EncodeToString(jwt) + signature := base64.StdEncoding.WithPadding(base64.NoPadding).EncodeToString([]byte("signature")) + return azureadal.Token{ + AccessToken: fmt.Sprintf("%v.%v.%v", header, payload, signature), + RefreshToken: "theRefreshToken", + ExpiresIn: json.Number(fmt.Sprintf("%v", expires.Sub(time.Now()).Truncate(time.Second))), + ExpiresOn: json.Number(fmt.Sprintf("%v", expires.Unix())), + NotBefore: "theNotBefore", + Resource: c.Resource, + Type: "theType", + }, nil +} + // errMockProvider simulates a normal provider which fails to associate a role, // returning an error type errMockProvider struct {