Skip to content

Commit

Permalink
PROD-347: Add access token endpoint
Browse files Browse the repository at this point in the history
Adds the <mountPath>/token/<role> 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 <mountPath>/roles/<role> backend will create a separate App/SP
  with the same logic as the <mountPath>/roles/<role> 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 <mountPath>/creds/<role> endpoint.
- An "existing Service Principal" still creates an App password as
  opposed to a service principal password.
  • Loading branch information
mdgreenfield committed Apr 5, 2022
1 parent 6ac0282 commit b479907
Show file tree
Hide file tree
Showing 13 changed files with 633 additions and 114 deletions.
1 change: 1 addition & 0 deletions api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ type AzureProvider interface {
ApplicationsClient
GroupsClient
ServicePrincipalClient
TokenClient

CreateRoleAssignment(
ctx context.Context,
Expand Down
30 changes: 30 additions & 0 deletions api/auth.go
Original file line number Diff line number Diff line change
@@ -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
}
1 change: 1 addition & 0 deletions backend.go
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ func backend() *azureSecretBackend {
Paths: framework.PathAppend(
pathsRole(&b),
[]*framework.Path{
pathAccessToken(&b),
pathConfig(&b),
pathServicePrincipal(&b),
pathRotateRoot(&b),
Expand Down
15 changes: 8 additions & 7 deletions client.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,7 +139,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,
},
})
Expand All @@ -149,6 +149,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
})

Expand All @@ -166,11 +167,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))
}
}
Expand Down Expand Up @@ -204,11 +205,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))
}
}
Expand Down
2 changes: 2 additions & 0 deletions go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,13 @@ go 1.12
require (
github.com/Azure/azure-sdk-for-go v58.3.0+incompatible
github.com/Azure/go-autorest/autorest v0.11.21
github.com/Azure/go-autorest/autorest/adal v0.9.14
github.com/Azure/go-autorest/autorest/azure/auth v0.5.8
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.9.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.0.0
Expand Down
126 changes: 126 additions & 0 deletions path_access_token.go
Original file line number Diff line number Diff line change
@@ -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.
`
105 changes: 105 additions & 0 deletions path_access_token_test.go
Original file line number Diff line number Diff line change
@@ -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")
}
})
}
Loading

0 comments on commit b479907

Please sign in to comment.