Skip to content

Commit bd4ffe8

Browse files
committed
PROD-347: Add access token endpoint
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.
1 parent 7f4e895 commit bd4ffe8

14 files changed

+630
-97
lines changed

api/api.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ type AzureProvider interface {
1616
ApplicationsClient
1717
GroupsClient
1818
ServicePrincipalClient
19+
TokenClient
1920

2021
CreateRoleAssignment(
2122
ctx context.Context,

api/auth.go

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
package api
2+
3+
import (
4+
"github.com/Azure/go-autorest/autorest/adal"
5+
"github.com/Azure/go-autorest/autorest/azure/auth"
6+
)
7+
8+
type TokenClient interface {
9+
GetToken(c auth.ClientCredentialsConfig) (adal.Token, error)
10+
}
11+
12+
var _ TokenClient = (*AccessTokenClient)(nil)
13+
14+
type AccessTokenClient struct{}
15+
16+
// GetToken fetches a new Azure OAuth2 bearer token from the given clients
17+
// credentials and tenant.
18+
func (p *AccessTokenClient) GetToken(c auth.ClientCredentialsConfig) (adal.Token, error) {
19+
t, err := c.ServicePrincipalToken()
20+
if err != nil {
21+
return adal.Token{}, err
22+
}
23+
24+
err = t.Refresh()
25+
if err != nil {
26+
return adal.Token{}, err
27+
}
28+
29+
return t.Token(), nil
30+
}

backend.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,7 @@ func backend() *azureSecretBackend {
5050
Paths: framework.PathAppend(
5151
pathsRole(&b),
5252
[]*framework.Path{
53+
pathAccessToken(&b),
5354
pathConfig(&b),
5455
pathServicePrincipal(&b),
5556
pathRotateRoot(&b),

client.go

Lines changed: 8 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -144,7 +144,7 @@ func (c *client) assignRoles(ctx context.Context, spID string, roles []*AzureRol
144144
ra, err := c.provider.CreateRoleAssignment(ctx, role.Scope, assignmentID,
145145
authorization.RoleAssignmentCreateParameters{
146146
RoleAssignmentProperties: &authorization.RoleAssignmentProperties{
147-
RoleDefinitionID: &role.RoleID,
147+
RoleDefinitionID: to.StringPtr(role.RoleID),
148148
PrincipalID: &spID,
149149
},
150150
})
@@ -154,6 +154,7 @@ func (c *client) assignRoles(ctx context.Context, spID string, roles []*AzureRol
154154
return nil, false, nil
155155
}
156156

157+
role.RoleAssignmentID = to.String(ra.ID)
157158
return to.String(ra.ID), true, err
158159
})
159160

@@ -171,11 +172,11 @@ func (c *client) assignRoles(ctx context.Context, spID string, roles []*AzureRol
171172
// This is a clean-up operation that isn't essential to revocation. As such, an
172173
// attempt is made to remove all assignments, and not return immediately if there
173174
// is an error.
174-
func (c *client) unassignRoles(ctx context.Context, roleIDs []string) error {
175+
func (c *client) unassignRoles(ctx context.Context, roles []*AzureRole) error {
175176
var merr *multierror.Error
176177

177-
for _, id := range roleIDs {
178-
if _, err := c.provider.DeleteRoleAssignmentByID(ctx, id); err != nil {
178+
for _, role := range roles {
179+
if _, err := c.provider.DeleteRoleAssignmentByID(ctx, role.RoleAssignmentID); err != nil {
179180
merr = multierror.Append(merr, fmt.Errorf("error unassigning role: %w", err))
180181
}
181182
}
@@ -209,11 +210,11 @@ func (c *client) addGroupMemberships(ctx context.Context, spID string, groups []
209210
// groups. This is a clean-up operation that isn't essential to revocation. As
210211
// such, an attempt is made to remove all memberships, and not return
211212
// immediately if there is an error.
212-
func (c *client) removeGroupMemberships(ctx context.Context, servicePrincipalObjectID string, groupIDs []string) error {
213+
func (c *client) removeGroupMemberships(ctx context.Context, servicePrincipalObjectID string, groups []*AzureGroup) error {
213214
var merr *multierror.Error
214215

215-
for _, id := range groupIDs {
216-
if err := c.provider.RemoveGroupMember(ctx, servicePrincipalObjectID, id); err != nil {
216+
for _, group := range groups {
217+
if err := c.provider.RemoveGroupMember(ctx, servicePrincipalObjectID, group.ObjectID); err != nil {
217218
merr = multierror.Append(merr, fmt.Errorf("error removing group membership: %w", err))
218219
}
219220
}

go.mod

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,13 @@ go 1.19
55
require (
66
github.com/Azure/azure-sdk-for-go v67.0.0+incompatible
77
github.com/Azure/go-autorest/autorest v0.11.28
8+
github.com/Azure/go-autorest/autorest/adal v0.9.18
89
github.com/Azure/go-autorest/autorest/azure/auth v0.5.11
910
github.com/Azure/go-autorest/autorest/date v0.3.0
1011
github.com/Azure/go-autorest/autorest/to v0.4.0
12+
github.com/Azure/go-autorest/autorest/validation v0.2.0 // indirect
13+
github.com/fatih/color v1.13.0 // indirect
14+
github.com/fatih/structs v1.1.0
1115
github.com/go-test/deep v1.0.8
1216
github.com/golang/mock v1.6.0
1317
github.com/hashicorp/go-hclog v1.3.1
@@ -21,17 +25,14 @@ require (
2125

2226
require (
2327
github.com/Azure/go-autorest v14.2.0+incompatible // indirect
24-
github.com/Azure/go-autorest/autorest/adal v0.9.18 // indirect
2528
github.com/Azure/go-autorest/autorest/azure/cli v0.4.5 // indirect
26-
github.com/Azure/go-autorest/autorest/validation v0.2.0 // indirect
2729
github.com/Azure/go-autorest/logger v0.2.1 // indirect
2830
github.com/Azure/go-autorest/tracing v0.6.0 // indirect
2931
github.com/armon/go-metrics v0.3.9 // indirect
3032
github.com/armon/go-radix v1.0.0 // indirect
3133
github.com/cenkalti/backoff/v3 v3.0.0 // indirect
3234
github.com/dimchansky/utfbom v1.1.1 // indirect
3335
github.com/evanphx/json-patch/v5 v5.5.0 // indirect
34-
github.com/fatih/color v1.13.0 // indirect
3536
github.com/golang-jwt/jwt/v4 v4.2.0 // indirect
3637
github.com/golang/protobuf v1.5.2 // indirect
3738
github.com/golang/snappy v0.0.4 // indirect

go.sum

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,7 @@ github.com/fatih/color v1.7.0/go.mod h1:Zm6kSWBoL9eyXnKyktHP6abPY2pDugNf5Kwzbycv
7878
github.com/fatih/color v1.13.0 h1:8LOYc1KYPPmyKMuN8QV2DNRWNbLo6LZ0iLs8+mlH53w=
7979
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
8080
github.com/fatih/structs v1.1.0 h1:Q7juDM0QtcnhCpeyLGQKyg4TOIghuNXrkL32pHAUMxo=
81+
github.com/fatih/structs v1.1.0/go.mod h1:9NiDSp5zOcgEDl+j00MP/WkGVPOlPRLejGD8Ga6PJ7M=
8182
github.com/frankban/quicktest v1.13.0 h1:yNZif1OkDfNoDfb9zZa9aXIpejNR4F23Wely0c+Qdqk=
8283
github.com/ghodss/yaml v1.0.0/go.mod h1:4dBDuWmgqj2HViK6kFavaiC9ZROes6MMH2rRYeMEF04=
8384
github.com/go-kit/kit v0.8.0/go.mod h1:xBxKIO96dXMWWy0MnWVtmwkA9/13aqxPnvrjFYMA2as=

path_access_token.go

Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
package azuresecrets
2+
3+
import (
4+
"context"
5+
"fmt"
6+
"strings"
7+
8+
azureadal "github.com/Azure/go-autorest/autorest/adal"
9+
azureauth "github.com/Azure/go-autorest/autorest/azure/auth"
10+
"github.com/fatih/structs"
11+
"github.com/hashicorp/vault/sdk/framework"
12+
"github.com/hashicorp/vault/sdk/logical"
13+
)
14+
15+
const (
16+
azureAppNotFoundErrCode = 700016
17+
)
18+
19+
func pathAccessToken(b *azureSecretBackend) *framework.Path {
20+
return &framework.Path{
21+
Pattern: fmt.Sprintf("token/%s", framework.GenericNameRegex("role")),
22+
Fields: map[string]*framework.FieldSchema{
23+
"role": {
24+
Type: framework.TypeLowerCaseString,
25+
Description: "Name of the Vault role",
26+
},
27+
"resource": {
28+
Type: framework.TypeString,
29+
Description: "The specific Azure audience of a generated access token",
30+
Default: "https://management.azure.com/",
31+
},
32+
},
33+
Operations: map[logical.Operation]framework.OperationHandler{
34+
logical.ReadOperation: &framework.PathOperation{
35+
Callback: b.pathAccessTokenRead,
36+
},
37+
},
38+
HelpSynopsis: pathAccessTokenHelpSyn,
39+
HelpDescription: pathAccessTokenHelpDesc,
40+
}
41+
}
42+
43+
func (b *azureSecretBackend) pathAccessTokenRead(ctx context.Context, request *logical.Request, data *framework.FieldData) (*logical.Response, error) {
44+
roleName := data.Get("role").(string)
45+
resource := data.Get("resource").(string)
46+
47+
role, err := getRole(ctx, roleName, request.Storage)
48+
if err != nil {
49+
return nil, err
50+
}
51+
52+
if role == nil {
53+
return logical.ErrorResponse("role '%s' does not exist", roleName), nil
54+
}
55+
56+
if role.CredentialType != credentialTypeSP {
57+
return logical.ErrorResponse("role '%s' cannot generate access tokens (has secret type %s)", roleName, role.CredentialType), nil
58+
}
59+
60+
if role.Credentials == nil {
61+
return logical.ErrorResponse("role '%s' configured before plugin supported access tokens (update or recreate role)", roleName), nil
62+
}
63+
64+
return b.secretAccessTokenResponse(ctx, request.Storage, role, resource)
65+
}
66+
67+
func (b *azureSecretBackend) secretAccessTokenResponse(ctx context.Context, storage logical.Storage, role *roleEntry, resource string) (*logical.Response, error) {
68+
client, err := b.getClient(ctx, storage)
69+
if err != nil {
70+
return nil, err
71+
}
72+
73+
cc := azureauth.NewClientCredentialsConfig(role.ApplicationID, role.Credentials.Password, client.settings.TenantID)
74+
cc.Resource = resource
75+
token, err := b.getToken(ctx, client, cc)
76+
if err != nil {
77+
return nil, err
78+
}
79+
80+
// access_tokens are not revocable therefore do not return a framework.Secret (i.e. a lease)
81+
return &logical.Response{Data: structsMap(token)}, nil
82+
}
83+
84+
func structsMap(s interface{}) map[string]interface{} {
85+
t := structs.New(s)
86+
t.TagName = "json"
87+
return t.Map()
88+
}
89+
90+
func (b *azureSecretBackend) getToken(ctx context.Context, client *client, c azureauth.ClientCredentialsConfig) (azureadal.Token, error) {
91+
token, err := retry(ctx, func() (interface{}, bool, error) {
92+
t, err := client.provider.GetToken(c)
93+
94+
if hasAzureErrorCode(err, azureAppNotFoundErrCode) {
95+
return nil, false, nil
96+
} else if err != nil {
97+
return nil, true, err
98+
}
99+
100+
return t, true, nil
101+
})
102+
103+
var t azureadal.Token
104+
if token != nil {
105+
t = token.(azureadal.Token)
106+
}
107+
108+
return t, err
109+
}
110+
111+
func hasAzureErrorCode(e error, code int) bool {
112+
tErr, ok := e.(azureadal.TokenRefreshError)
113+
114+
// use a pattern match as TokenRefreshError is not easily parsable
115+
return ok && tErr != nil && strings.Contains(tErr.Error(), fmt.Sprint(code))
116+
}
117+
118+
const pathAccessTokenHelpSyn = `
119+
Request an access token for a given Vault role.
120+
`
121+
122+
const pathAccessTokenHelpDesc = `
123+
This path creates access token credentials. The associated role must
124+
be created ahead of time with either an existing App/Service Principal or
125+
else a dynamic Service Principal will be created.
126+
`

path_access_token_test.go

Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package azuresecrets
2+
3+
import (
4+
"context"
5+
"testing"
6+
7+
"github.com/hashicorp/vault/sdk/logical"
8+
)
9+
10+
func Test_azureSecretBackend_pathAccessTokenRead(t *testing.T) {
11+
b, s := getTestBackend(t, true)
12+
13+
t.Run("token generated", func(t *testing.T) {
14+
role := generateUUID()
15+
testRoleCreate(t, b, s, role, testStaticSPRole)
16+
17+
resp, err := b.HandleRequest(context.Background(), &logical.Request{
18+
Operation: logical.ReadOperation,
19+
Path: "token/" + role,
20+
Storage: s,
21+
})
22+
23+
assertErrorIsNil(t, err)
24+
25+
if resp.IsError() {
26+
t.Fatalf("receive response error: %v", resp.Error())
27+
}
28+
29+
if _, ok := resp.Data["access_token"]; !ok {
30+
t.Fatalf("access_token not found in response")
31+
}
32+
33+
if _, ok := resp.Data["refresh_token"]; !ok {
34+
t.Fatalf("refresh_token not found in response")
35+
}
36+
37+
if _, ok := resp.Data["expires_in"]; !ok {
38+
t.Fatalf("expires_in not found in response")
39+
}
40+
41+
if _, ok := resp.Data["expires_on"]; !ok {
42+
t.Fatalf("expires_on not found in response")
43+
}
44+
45+
if _, ok := resp.Data["not_before"]; !ok {
46+
t.Fatalf("not_before not found in response")
47+
}
48+
49+
r, ok := resp.Data["resource"]
50+
if !ok {
51+
t.Fatalf("resource not found in response")
52+
}
53+
if r != "https://management.azure.com/" {
54+
t.Fatalf("resource not equal to requested")
55+
}
56+
57+
if _, ok := resp.Data["token_type"]; !ok {
58+
t.Fatalf("token_type not found in response")
59+
}
60+
})
61+
62+
t.Run("non default resource token generated", func(t *testing.T) {
63+
role := generateUUID()
64+
testRoleCreate(t, b, s, role, testStaticSPRole)
65+
66+
resource := "https://resource.endpoint/"
67+
resp, err := b.HandleRequest(context.Background(), &logical.Request{
68+
Operation: logical.ReadOperation,
69+
Path: "token/" + role,
70+
Data: map[string]interface{}{
71+
"resource": resource,
72+
},
73+
Storage: s,
74+
})
75+
76+
assertErrorIsNil(t, err)
77+
78+
if resp.IsError() {
79+
t.Fatalf("receive response error: %v", resp.Error())
80+
}
81+
82+
r, ok := resp.Data["resource"]
83+
if !ok {
84+
t.Fatalf("resource not found in response")
85+
}
86+
if r != resource {
87+
t.Fatalf("resource not equal to requested")
88+
}
89+
})
90+
91+
t.Run("role does not exist", func(t *testing.T) {
92+
role := generateUUID()
93+
resp, err := b.HandleRequest(context.Background(), &logical.Request{
94+
Operation: logical.ReadOperation,
95+
Path: "token/" + role,
96+
Storage: s,
97+
})
98+
99+
assertErrorIsNil(t, err)
100+
101+
if !resp.IsError() {
102+
t.Fatal("expected missing role error")
103+
}
104+
})
105+
}

0 commit comments

Comments
 (0)