Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[CLOUDGA-25136] Support to associate allow list with API keys #288

Merged
merged 3 commits into from
Jan 17, 2025
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
70 changes: 67 additions & 3 deletions cmd/api_key/api_key.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/yugabyte/ybm-cli/cmd/util"
ybmAuthClient "github.com/yugabyte/ybm-cli/internal/client"
"github.com/yugabyte/ybm-cli/internal/formatter"
ybmclient "github.com/yugabyte/yugabytedb-managed-go-client-internal"
)

var ApiKeyCmd = &cobra.Command{
Expand Down Expand Up @@ -56,8 +57,19 @@ var listApiKeysCmd = &cobra.Command{
apiKeyListRequest = apiKeyListRequest.ApiKeyName(name)
}

isNameSpecified := cmd.Flags().Changed("name")
keyStatus := "ACTIVE"
bhupendray-yb marked this conversation as resolved.
Show resolved Hide resolved
// If --name arg is specified, don't set default filter for key-status
// because if an API key is revoked/expired and user do $ ybm api-key list --name <key-name>
// it will lead to empty response if we filter key by ACTIVE status.
if isNameSpecified {
keyStatus = ""
}

// if user filters by key status, add it to the request
keyStatus, _ := cmd.Flags().GetString("status")
if cmd.Flags().Changed("status") {
keyStatus, _ = cmd.Flags().GetString("status")
}
if keyStatus != "" {
validStatus := false
for _, v := range GetKeyStatusFilters() {
Expand Down Expand Up @@ -88,10 +100,41 @@ var listApiKeysCmd = &cobra.Command{
return
}

formatter.ApiKeyWrite(apiKeyCtx, resp.GetData())
apiKeyOutputList := *enrichApiKeyDataWithAllowListInfo(&resp.Data, authApi)
bhupendray-yb marked this conversation as resolved.
Show resolved Hide resolved
formatter.ApiKeyWrite(apiKeyCtx, apiKeyOutputList)
},
}

func enrichApiKeyDataWithAllowListInfo(apiKeys *[]ybmclient.ApiKeyData, authApi *ybmAuthClient.AuthApiClient) *[]formatter.ApiKeyDataAllowListInfo {
bhupendray-yb marked this conversation as resolved.
Show resolved Hide resolved
apiKeyOutputList := make([]formatter.ApiKeyDataAllowListInfo, 0)

// For each API key, fetch the allow list(s) associated with it
for _, apiKey := range *apiKeys {
apiKeyId := apiKey.GetInfo().Id
allowListsNames := make([]string, 0)

if util.IsFeatureFlagEnabled(util.API_KEY_ALLOW_LIST) {
allowListIds := apiKey.GetSpec().AllowListInfo
if allowListIds != nil && len(*allowListIds) > 0 {
apiKeyAllowLists, resp, err := authApi.ListApiKeyNetworkAllowLists(apiKeyId).Execute()
if err != nil {
logrus.Debugf("Full HTTP response: %v", resp)
logrus.Fatalf(ybmAuthClient.GetApiErrorDetails(err))
}
for _, allowList := range apiKeyAllowLists.GetData() {
allowListsNames = append(allowListsNames, allowList.GetSpec().Name)
}
}
}

apiKeyOutputList = append(apiKeyOutputList, formatter.ApiKeyDataAllowListInfo{
ApiKey: &apiKey,
AllowLists: allowListsNames,
})
}
return &apiKeyOutputList
}

func GetKeyStatusFilters() []string {
return []string{"ACTIVE", "EXPIRED", "REVOKED"}
}
Expand Down Expand Up @@ -164,6 +207,22 @@ var createApiKeyCmd = &cobra.Command{
apiKeySpec.SetRoleId(roleId)
}

if util.IsFeatureFlagEnabled(util.API_KEY_ALLOW_LIST) && cmd.Flags().Changed("network-allow-lists") {
allowLists, _ := cmd.Flags().GetString("network-allow-lists")

allowListNames := strings.Split(allowLists, ",")
allowListIds := make([]string, 0)

for _, allowList := range allowListNames {
allowListId, err := authApi.GetNetworkAllowListIdByName(strings.TrimSpace(allowList))
if err != nil {
logrus.Fatalln(err)
}
allowListIds = append(allowListIds, allowListId)
}
apiKeySpec.SetAllowListInfo(allowListIds)
}

resp, r, err := authApi.CreateApiKey().ApiKeySpec(*apiKeySpec).Execute()
if err != nil {
logrus.Debugf("Full HTTP response: %v", r)
Expand All @@ -175,7 +234,8 @@ var createApiKeyCmd = &cobra.Command{
Format: formatter.NewApiKeyFormat(viper.GetString("output")),
}

formatter.SingleApiKeyWrite(apiKeyCtx, resp.GetData())
apiKeyOutput := *enrichApiKeyDataWithAllowListInfo(&[]ybmclient.ApiKeyData{resp.GetData()}, authApi)
formatter.ApiKeyWrite(apiKeyCtx, apiKeyOutput)

fmt.Printf("\nAPI Key: %s \n", formatter.Colorize(resp.GetJwt(), formatter.GREEN_COLOR))
fmt.Printf("\nThe API key is only shown once after creation. Copy and store it securely.\n")
Expand Down Expand Up @@ -233,6 +293,10 @@ func init() {
createApiKeyCmd.Flags().String("unit", "", "[REQUIRED] The time units for which the API Key will be valid. Available options are Hours, Days, and Months.")
createApiKeyCmd.MarkFlagRequired("unit")
createApiKeyCmd.Flags().String("description", "", "[OPTIONAL] Description of the API Key to be created.")

if util.IsFeatureFlagEnabled(util.API_KEY_ALLOW_LIST) {
createApiKeyCmd.Flags().String("network-allow-lists", "", "[OPTIONAL] The network allow lists(comma separated names) to assign to the API key.")
}
createApiKeyCmd.Flags().String("role-name", "", "[OPTIONAL] The name of the role to be assigned to the API Key. If not provided, an Admin API Key will be generated.")
createApiKeyCmd.Flags().BoolP("force", "f", false, "Bypass the prompt for non-interactive usage")

Expand Down
209 changes: 209 additions & 0 deletions cmd/api_key_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,209 @@
package cmd_test

import (
"fmt"
"net/http"
"os"
"os/exec"

. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/onsi/gomega/gbytes"
"github.com/onsi/gomega/gexec"
"github.com/onsi/gomega/ghttp"
openapi "github.com/yugabyte/yugabytedb-managed-go-client-internal"
)

var _ = Describe("API Key Management", func() {

var (
server *ghttp.Server
statusCode int
args []string
responseAccount openapi.AccountListResponse
responseProject openapi.AccountListResponse
apiKeyListResponse openapi.ApiKeyListResponse
apiKeyResponse openapi.CreateApiKeyResponse
responseNetworkAllowList openapi.NetworkAllowListListResponse
)

BeforeEach(func() {
args = os.Args
os.Args = []string{}
var err error
server, err = newGhttpServer(responseAccount, responseProject)
Expect(err).ToNot(HaveOccurred())
os.Setenv("YBM_HOST", fmt.Sprintf("http://%s", server.Addr()))
os.Setenv("YBM_APIKEY", "test-token")
statusCode = 200
})

AfterEach(func() {
os.Args = args
server.Close()
})

Describe("When listing API keys with allow list FF disabled", func() {
It("should hide allow list column", func() {
err := loadJson("./test/fixtures/list-api-keys.json", &apiKeyListResponse)
Expect(err).ToNot(HaveOccurred())

server.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodGet, "/api/public/v1/accounts/340af43a-8a7c-4659-9258-4876fd6a207b/api-keys"),
ghttp.RespondWithJSONEncodedPtr(&statusCode, apiKeyListResponse),
),
)
os.Setenv("YBM_FF_API_KEY_ALLOW_LIST", "false")
cmd := exec.Command(compiledCLIPath, "api-key", "list")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)

Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(session.Out).Should(gbytes.Say(`Name Role Status Created By Date Created Last Used Expiration
apikey-1 Admin ACTIVE [email protected] 2025-01-13T13:55:14.024Z 2025-01-13T14:21:40.713599Z 2099-12-31T00:00Z
apikey-2 Admin ACTIVE [email protected] 2025-01-08T09:06:35.077Z Not yet used 2025-02-07T09:06:35.077071Z`))
session.Kill()
})
})

Describe("When listing API keys with allow list FF enabled", func() {
It("should show allow list column", func() {
err := loadJson("./test/fixtures/list-api-keys-with-allow-list.json", &apiKeyListResponse)
Expect(err).ToNot(HaveOccurred())
err = loadJson("./test/fixtures/allow-list.json", &responseNetworkAllowList)
Expect(err).ToNot(HaveOccurred())

server.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodGet, "/api/public/v1/accounts/340af43a-8a7c-4659-9258-4876fd6a207b/api-keys"),
ghttp.RespondWithJSONEncodedPtr(&statusCode, apiKeyListResponse),
),
ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodGet, "/api/public/v1/accounts/340af43a-8a7c-4659-9258-4876fd6a207b/projects/78d4459c-0f45-47a5-899a-45ddf43eba6e/allow-lists"),
ghttp.RespondWithJSONEncodedPtr(&statusCode, responseNetworkAllowList),
),
func(w http.ResponseWriter, req *http.Request) {
queryParams := req.URL.Query()
Expect(queryParams.Get("status")).To(Equal("ACTIVE"))
},
)

os.Setenv("YBM_FF_API_KEY_ALLOW_LIST", "true")
cmd := exec.Command(compiledCLIPath, "api-key", "list")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)

Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(session.Out).Should(gbytes.Say(`Name Role Status Created By Date Created Last Used Expiration Allow List
apikey-1 Admin ACTIVE [email protected] 2025-01-13T13:55:14.024Z 2025-01-13T14:21:40.713599Z 2099-12-31T00:00Z device-ip-gween
apikey-2 Admin ACTIVE [email protected] 2025-01-08T09:06:35.077Z Not yet used 2025-02-07T09:06:35.077071Z N/A`))
session.Kill()
})

})

Describe("When creating an API key", func() {
Context("with allow list feature flag disabled", func() {
os.Setenv("YBM_FF_API_KEY_ALLOW_LIST", "false")

It("should error when passing allow list flag", func() {
os.Setenv("YBM_FF_API_KEY_ALLOW_LIST", "false")
cmd := exec.Command(compiledCLIPath, "api-key", "create", "--name", "apikey-1", "--duration", "30", "--unit", "DAYS", "--network-allow-lists", "device-ip")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)

Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(session.Err).Should(gbytes.Say(`\bError: unknown flag: --network-allow-lists\b`))
session.Kill()
})

It("should create API key", func() {
err := loadJson("./test/fixtures/get-or-create-api-key.json", &apiKeyResponse)
Expect(err).ToNot(HaveOccurred())

server.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodPost, "/api/public/v1/accounts/340af43a-8a7c-4659-9258-4876fd6a207b/api-keys"),
ghttp.RespondWithJSONEncodedPtr(&statusCode, apiKeyResponse),
),
)

cmd := exec.Command(compiledCLIPath, "api-key", "create", "--name", "apikey-1", "--duration", "30", "--unit", "DAYS")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)

Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(session.Out).Should(gbytes.Say(`Name Role Status Created By Date Created Last Used Expiration
apikey-1 Admin ACTIVE admin 2025-01-13T15:55:55.825Z Not yet used 2025-02-12T15:55:55.824833Z

API Key: test-jwt`))
session.Kill()
})
})

Context("with allow list feature flag enabled", func() {
It("should create API key", func() {
err := loadJson("./test/fixtures/get-or-create-api-key.json", &apiKeyResponse)
Expect(err).ToNot(HaveOccurred())
err = loadJson("./test/fixtures/allow-list.json", &responseNetworkAllowList)
Expect(err).ToNot(HaveOccurred())

server.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodGet, "/api/public/v1/accounts/340af43a-8a7c-4659-9258-4876fd6a207b/projects/78d4459c-0f45-47a5-899a-45ddf43eba6e/allow-lists"),
ghttp.RespondWithJSONEncodedPtr(&statusCode, responseNetworkAllowList),
),
ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodPost, "/api/public/v1/accounts/340af43a-8a7c-4659-9258-4876fd6a207b/api-keys"),
ghttp.RespondWithJSONEncodedPtr(&statusCode, apiKeyResponse),
),
ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodGet, "/api/public/v1/accounts/340af43a-8a7c-4659-9258-4876fd6a207b/projects/78d4459c-0f45-47a5-899a-45ddf43eba6e/allow-lists"),
ghttp.RespondWithJSONEncodedPtr(&statusCode, responseNetworkAllowList),
),
)

os.Setenv("YBM_FF_API_KEY_ALLOW_LIST", "true")
cmd := exec.Command(compiledCLIPath, "api-key", "create", "--name", "apikey-1", "--duration", "30", "--unit", "DAYS", "--network-allow-lists", "device-ip-gween")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)

Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(session.Out).Should(gbytes.Say(`Name Role Status Created By Date Created Last Used Expiration Allow List
apikey-1 Admin ACTIVE admin 2025-01-13T15:55:55.825Z Not yet used 2025-02-12T15:55:55.824833Z device-ip-gween

API Key: test-jwt`))
session.Kill()
})
})

})

Describe("When revoking an API key", func() {
Context("with valid request", func() {
It("should revoke the API key", func() {
err := loadJson("./test/fixtures/get-or-create-api-key.json", &apiKeyResponse)
Expect(err).ToNot(HaveOccurred())

server.AppendHandlers(
ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodGet, "/api/public/v1/accounts/340af43a-8a7c-4659-9258-4876fd6a207b/api-keys"),
ghttp.RespondWithJSONEncodedPtr(&statusCode, apiKeyResponse),
),
ghttp.CombineHandlers(
ghttp.VerifyRequest(http.MethodPost, "/api/public/v1/accounts/340af43a-8a7c-4659-9258-4876fd6a207b/api-keys/440af43a-8a7c-4659-9258-4876fd6a207b/revoke"),
ghttp.RespondWith(http.StatusOK, nil),
),
)

cmd := exec.Command(compiledCLIPath, "api-key", "revoke", "--name", "apikey-1", "-f")
session, err := gexec.Start(cmd, GinkgoWriter, GinkgoWriter)
Expect(err).NotTo(HaveOccurred())
session.Wait(2)
Expect(server.ReceivedRequests()).Should(HaveLen(3))
session.Kill()
})
})
})
})
51 changes: 51 additions & 0 deletions cmd/test/fixtures/get-or-create-api-key.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"data": {
"spec": {
"name": "apikey-1",
"description": "",
"expire_after_hours": 720,
"role_id": "36ec6789-6dbf-4d12-91b4-243587d6882b",
"allow_list_info": ["1eaf5552-e2f3-4a28-b215-106667c05178"]
},
"info": {
"id": "4f32f91e-b5fc-462f-8111-134eb813f6d0",
"issuer": "admin",
"metadata": {
"created_on": "2025-01-13T15:55:55.825Z",
"updated_on": "2025-01-13T15:55:55.825Z"
},
"expiry_time": "2025-02-12T15:55:55.824833Z",
"status": "ACTIVE",
"last_used_time": "Not yet used",
"usage_count": 0,
"account_id": "531af27a-614c-472f-8edb-a0f9322f0838",
"revoked_by_user_id": null,
"revoked_by_user_email": null,
"revoked_by_api_key_id": null,
"revoked_by_api_key_name": null,
"revoked_at_time": null,
"role": {
"spec": {
"name": "account_admin",
"description": "Full access to all operations, including billing and user management",
"permissions": []
},
"info": {
"id": "36ec6789-6dbf-4d12-91b4-243587d6882b",
"metadata": {
"created_on": "2021-10-19T00:00Z",
"updated_on": "2025-01-13T13:54:21.660Z"
},
"is_active": true,
"is_user_defined": false,
"account_id": "531af27a-614c-472f-8edb-a0f9322f0838",
"effective_permissions": [],
"users": null,
"api_keys": null,
"display_name": "Admin"
}
}
}
},
"jwt": "test-jwt"
}
Loading
Loading