Skip to content

Commit

Permalink
feat(LH-86969): Generate API token for user in MSP managed tenant (#149)
Browse files Browse the repository at this point in the history
* feat(lh-86969): add the ability to read users from an MSP tenant when creating

This commit reads the users in an MSP-managed tenant after creating, and before deleting or updating
them.

BREAKING CHANGE: The `role` field has been changed to a list field called `roles`.

* chore(lh-86969): clean up example

* feat(lh-86969): add a new resource called `cdo_msp_managed_tenant_user_api_token`

Add a new resource to generate an API token for a user in a MSP-managed tenant.

* test(lh-86969): add tests

* docs(lh-86969): fix documentation for msp_managed_tenant_user_api_token

* docs(lh-86969): fix capitalization of error message

* fix(lh-86969): remove fake JWT tokens in unit tests because it freaks GitGuardian out

* fix(lh-86969): add gitleaks config to ignore false positives

* fix(lh-86969): use toml instead of yaml

* try fix gitleaks config
  • Loading branch information
siddhuwarrier authored Nov 1, 2024
1 parent 978abd0 commit 1a60bda
Show file tree
Hide file tree
Showing 16 changed files with 406 additions and 14 deletions.
10 changes: 10 additions & 0 deletions .gitleaks.toml
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
[[rules]]
description = "Ignore ApiToken in tests"
regex = '''(?i)ApiToken:\s?"fake-api-token"'''
path = '''^.*_test\.go$'''

[rules.allowlist]
description = "Allow fake ApiToken in test files"
commits = []
files = []
paths = ["^.*_test\\.go$"]
8 changes: 8 additions & 0 deletions client/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -303,3 +303,11 @@ func (c *Client) ReadUsersInMspManagedTenant(ctx context.Context, readInput user
func (c *Client) DeleteUsersInMspManagedTenant(ctx context.Context, deleteInput users.MspDeleteUsersInput) (interface{}, error) {
return users.Delete(ctx, c.client, deleteInput)
}

func (c *Client) GenerateApiTokenForUserInMspManagedTenant(ctx context.Context, generateApiTokenInput users.MspGenerateApiTokenInput) (*users.MspGenerateApiTokenOutput, error) {
return users.GenerateApiToken(ctx, c.client, generateApiTokenInput)
}

func (c *Client) RevokeApiTokenForUserInMspManagedTenant(ctx context.Context, revokeApiTokenInput users.MspRevokeApiTokenInput) (interface{}, error) {
return users.RevokeApiToken(ctx, c.client, revokeApiTokenInput)
}
25 changes: 14 additions & 11 deletions client/internal/http/request.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,19 +54,22 @@ func NewRequest(config cdo.Config, httpClient *http.Client, logger *log.Logger,
}
}

func (r *Request) Send(output any) error {
return r.SendWithToken(output, &r.config.ApiToken)
}

// Send wrap send() with retry & delay & timeout... stuff
// TODO: cancel retry when context done
// output: if given, will unmarshal response body into this object, should be a pointer for it to be useful
func (r *Request) Send(output any) error {
func (r *Request) SendWithToken(output any, token *string) error {
err := retry.Do(
// context.Background() will never cancel according to documentation
// we do not want to cancel here because this retry mechanism is intended to overcome
// the flaky CDO api, built into every request, and we probably do not want to cancel
// and fail due to flaky-ness, but if we want to cancel, there is no obvious bad side effect.
context.Background(),
func() (bool, error) {

err := r.send(output)
err := r.send(output, token)
if err != nil {
return false, err
}
Expand All @@ -83,13 +86,13 @@ func (r *Request) Send(output any) error {
return err
}

func (r *Request) send(output any) error {
func (r *Request) send(output any, token *string) error {
// clear prev response
r.Response = nil
r.Error = nil

// build net/http.Request
req, err := r.build()
req, err := r.build(token)
if err != nil {
r.Error = err
return err
Expand Down Expand Up @@ -147,7 +150,7 @@ func (r *Request) OverrideApiToken(apiToken string) {
}

// build the net/http.Request
func (r *Request) build() (*http.Request, error) {
func (r *Request) build(token *string) (*http.Request, error) {

bodyReader, err := toReader(r.body)
if err != nil {
Expand All @@ -162,7 +165,7 @@ func (r *Request) build() (*http.Request, error) {
req = req.WithContext(r.ctx)
}

r.addHeaders(req)
r.addHeaders(req, token)
r.addQueryParams(req)
return req, nil
}
Expand All @@ -177,8 +180,8 @@ func (r *Request) addQueryParams(req *http.Request) {
req.URL.RawQuery = q.Encode()
}

func (r *Request) addHeaders(req *http.Request) {
r.addAuthHeader(req)
func (r *Request) addHeaders(req *http.Request, token *string) {
r.addAuthHeader(req, token)
r.addOtherHeader(req)
r.addJsonContentTypeHeaderIfNotPresent(req)
r.addUserAgentHeader(req)
Expand All @@ -192,8 +195,8 @@ func (r *Request) addJsonContentTypeHeaderIfNotPresent(req *http.Request) {
}
}

func (r *Request) addAuthHeader(req *http.Request) {
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", r.config.ApiToken))
func (r *Request) addAuthHeader(req *http.Request, token *string) {
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", *token))
}

func (r *Request) addUserAgentHeader(req *http.Request) {
Expand Down
8 changes: 8 additions & 0 deletions client/internal/url/url.go
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,10 @@ func RevokeApiToken(baseUrl string, tokenId string) string {
return fmt.Sprintf("%s/anubis/rest/v1/oauth/revoke/%s", baseUrl, tokenId)
}

func RevokeApiTokenUsingPublicApi(baseUrl string) string {
return fmt.Sprintf("%s/api/rest/v1/token/revoke", baseUrl)
}

func ReadTokenInfo(baseUrl string) string {
return fmt.Sprintf("%s/anubis/rest/v1/oauth/check_token", baseUrl)
}
Expand Down Expand Up @@ -229,3 +233,7 @@ func GetUsersInMspManagedTenant(baseUrl string, tenantUid string, limit int, off
func DeleteUsersInMspManagedTenant(baseUrl string, tenantUid string) string {
return fmt.Sprintf("%s/api/rest/v1/msp/tenants/%s/users/delete", baseUrl, tenantUid)
}

func GenerateApiTokenForUserInMspManagedTenant(baseUrl string, tenantUid string, userUid string) string {
return fmt.Sprintf("%s/api/rest/v1/msp/tenants/%s/users/%s/token", baseUrl, tenantUid, userUid)
}
15 changes: 13 additions & 2 deletions client/msp/users/create.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,13 +47,24 @@ func Create(ctx context.Context, client http.Client, createInp MspUsersInput) (*
}
}

readUserDetrails, err := ReadCreatedUsersInTenant(ctx, client, createInp)
readUserDetails, err := ReadCreatedUsersInTenant(ctx, client, createInp)
if err != nil {
client.Logger.Println("Failed to read users from tenant after creation")
return nil, &CreateError{
Err: err,
CreatedResourceId: &transaction.EntityUid,
}
}
return readUserDetrails, nil
return readUserDetails, nil
}

func GenerateApiToken(ctx context.Context, client http.Client, generateApiTokenInp MspGenerateApiTokenInput) (*MspGenerateApiTokenOutput, error) {
client.Logger.Printf("Generating API token for user %s in tenant %s", generateApiTokenInp.UserUid, generateApiTokenInp.TenantUid)
genApiTokenUrl := url.GenerateApiTokenForUserInMspManagedTenant(client.BaseUrl(), generateApiTokenInp.TenantUid, generateApiTokenInp.UserUid)
var mspApiTokenOutput MspGenerateApiTokenOutput
req := client.NewPost(ctx, genApiTokenUrl, nil)
if err := req.Send(&mspApiTokenOutput); err != nil {
return nil, err
}
return &mspApiTokenOutput, nil
}
61 changes: 61 additions & 0 deletions client/msp/users/create_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ import (
"github.com/jarcoal/httpmock"
"github.com/stretchr/testify/assert"
netHttp "net/http"
"sort"
"strconv"
"testing"
"time"
Expand All @@ -38,6 +39,60 @@ func generateUsers(num int) []users.UserDetails {
return createdUsers
}

func TestGenerateApiToken(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()

t.Run("generate API token successfully", func(t *testing.T) {
httpmock.Reset()
managedTenantUid := uuid.New().String()
userUid := uuid.New().String()
generateTokenApiTokenInput := users.MspGenerateApiTokenInput{
UserUid: userUid,
TenantUid: managedTenantUid,
}
generateTokenApiOutput := users.MspGenerateApiTokenOutput{
ApiToken: "fake-api-token",
}

httpmock.RegisterResponder(
netHttp.MethodPost,
fmt.Sprintf("/api/rest/v1/msp/tenants/%s/users/%s/token", managedTenantUid, userUid),
httpmock.NewJsonResponderOrPanic(200, generateTokenApiOutput),
)

actual, err := users.GenerateApiToken(context.Background(),
*http.MustNewWithConfig(baseUrl, "valid_token", 0, 0, time.Minute),
generateTokenApiTokenInput)

assert.Nil(t, err)
assert.Equal(t, generateTokenApiOutput, *actual, "Token not returned as expected")
})

t.Run("fail to generate API token", func(t *testing.T) {
httpmock.Reset()
managedTenantUid := uuid.New().String()
userUid := uuid.New().String()
generateTokenApiTokenInput := users.MspGenerateApiTokenInput{
UserUid: userUid,
TenantUid: managedTenantUid,
}

httpmock.RegisterResponder(
netHttp.MethodPost,
fmt.Sprintf("/api/rest/v1/msp/tenants/%s/users/%s/token", managedTenantUid, userUid),
httpmock.NewJsonResponderOrPanic(500, nil),
)

actual, err := users.GenerateApiToken(context.Background(),
*http.MustNewWithConfig(baseUrl, "valid_token", 0, 0, time.Minute),
generateTokenApiTokenInput)

assert.Nil(t, actual)
assert.NotNil(t, err)
})
}

// the create test also tests read!
func TestCreate(t *testing.T) {
httpmock.Activate()
Expand Down Expand Up @@ -117,6 +172,12 @@ func TestCreate(t *testing.T) {

assert.NotNil(t, actual, "Created users should have not been nil")
assert.Nil(t, err, "Created users operation should have not been an error")
sort.Slice(usersWithIds, func(i, j int) bool {
return usersWithIds[i].Uid < usersWithIds[j].Uid
})
sort.Slice(*actual, func(i, j int) bool {
return (*actual)[i].Uid < (*actual)[j].Uid
})
assert.Equal(t, usersWithIds, *actual, "Created users operation should have been the same as the created tenant")
})

Expand Down
13 changes: 13 additions & 0 deletions client/msp/users/delete.go
Original file line number Diff line number Diff line change
Expand Up @@ -33,3 +33,16 @@ func Delete(ctx context.Context, client http.Client, deleteInp MspDeleteUsersInp

return nil, nil
}

func RevokeApiToken(ctx context.Context, client http.Client, revokeInput MspRevokeApiTokenInput) (interface{}, error) {
revokeTokenUrl := url.RevokeApiTokenUsingPublicApi(client.BaseUrl())
client.Logger.Printf("Revoking api token at %s\n", revokeTokenUrl)
req := client.NewPost(ctx, revokeTokenUrl, nil)
// overwrite token in header with API token for the user that we are revoking
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", revokeInput.ApiToken))
if err := req.SendWithToken(&struct{}{}, &revokeInput.ApiToken); err != nil {
return nil, err
}

return nil, nil
}
24 changes: 24 additions & 0 deletions client/msp/users/delete_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,30 @@ import (
"time"
)

func TestRevokeApiToken(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()

t.Run("successfully revoke API token for user in MSP-managed tenant", func(t *testing.T) {
httpmock.Reset()
revokeInput := users.MspRevokeApiTokenInput{
ApiToken: "fake-api-token" +
"",
}
httpmock.RegisterResponder(
netHttp.MethodPost,
"/api/rest/v1/token/revoke",
httpmock.NewJsonResponderOrPanic(200, nil),
)
response, err := users.RevokeApiToken(context.Background(),
*http.MustNewWithConfig(baseUrl, "valid_token", 0, 0, time.Minute),
revokeInput)

assert.Nil(t, err)
assert.Nil(t, response)
})
}

func TestDelete(t *testing.T) {
httpmock.Activate()
defer httpmock.DeactivateAndReset()
Expand Down
13 changes: 13 additions & 0 deletions client/msp/users/models.go
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,19 @@ type UserPage struct {
Items []UserDetails `json:"items"`
}

type MspGenerateApiTokenInput struct {
TenantUid string `json:"tenantUid"`
UserUid string `json:"userUid"`
}

type MspRevokeApiTokenInput struct {
ApiToken string `json:"apiToken"`
}

type MspGenerateApiTokenOutput struct {
ApiToken string `json:"apiToken"`
}

type CreateError struct {
Err error
CreatedResourceId *string
Expand Down
25 changes: 25 additions & 0 deletions docs/resources/msp_managed_tenant_user_api_token.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
---
# generated by https://github.com/hashicorp/terraform-plugin-docs
page_title: "cdo_msp_managed_tenant_user_api_token Resource - cdo"
subcategory: ""
description: |-
Provides a resource to manage an API token for a user in an MSP-managed tenant.
---

# cdo_msp_managed_tenant_user_api_token (Resource)

Provides a resource to manage an API token for a user in an MSP-managed tenant.



<!-- schema generated by tfplugindocs -->
## Schema

### Required

- `tenant_uid` (String) Universally unique identifier of the tenant in which the API token for the user should be generated.
- `user_uid` (String) Universally unique identifier of the user for whom the API token should be generated.

### Read-Only

- `api_token` (String, Sensitive) The generated API token for the user.
3 changes: 2 additions & 1 deletion provider/examples/resources/msp/users/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,4 +10,5 @@ You need access to an MSP Portal, and API token for the MSP portal.
- Modify `providers.tf` accordingly.
- Paste CDO API token for an MSP portal into `api_token.txt`
- see https://docs.defenseorchestrator.com/#!c-api-tokens.html for how to generate this.
- Specify the name of a tenant managed by the MSP Portal. You can get the tenant name by going to Settings in the MSP portal.
- Specify the name of a tenant managed by the MSP Portal. You can get the tenant name by going to Settings in the MSP portal.
- To see the generated API token for the created user, run `terraform show -json | jq -r ".values.outputs.api_token.value"`
15 changes: 15 additions & 0 deletions provider/examples/resources/msp/users/main.tf
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,21 @@ resource "cdo_msp_managed_tenant_users" "example" {
username = "[email protected]",
roles = ["ROLE_ADMIN"]
api_only_user = false
},
{
username = "api-only-user",
roles = ["ROLE_SUPER_ADMIN"]
api_only_user = true
}
]
}

resource "cdo_msp_managed_tenant_user_api_token" "user_token" {
tenant_uid = data.cdo_msp_managed_tenant.tenant.id
user_uid = cdo_msp_managed_tenant_users.example.users[2].id
}

output "api_token" {
value = cdo_msp_managed_tenant_user_api_token.user_token.api_token
sensitive = true
}
11 changes: 11 additions & 0 deletions provider/internal/msp/msp_tenant_user_api_token/models.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package msp_tenant_user_api_token

import (
"github.com/hashicorp/terraform-plugin-framework/types"
)

type MspManagedTenantUserApiTokenResourceModel struct {
TenantUid types.String `tfsdk:"tenant_uid"`
UserUid types.String `tfsdk:"user_uid"`
ApiToken types.String `tfsdk:"api_token"` // Additional field
}
Loading

0 comments on commit 1a60bda

Please sign in to comment.