satellite/console: delete api key by name and project id endpoint

WHAT:
new endpoint to be able to delete apiKey/accessGrant by name and project id

WHY:
it will be called to delete special pregenerated access grant which will be used to generate gateway credentials for file browser component or bucket management

Change-Id: I7467ebaab27a7da33efd062536c6da41e6ed4c30
This commit is contained in:
Vitalii Shpital 2021-03-16 21:43:02 +02:00
parent f19ef4afe5
commit 3e37d1e71c
7 changed files with 270 additions and 1 deletions

View File

@ -0,0 +1,88 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleapi
import (
"encoding/json"
"net/http"
"github.com/zeebo/errs"
"go.uber.org/zap"
"storj.io/common/uuid"
"storj.io/storj/satellite/console"
)
var (
// ErrAPIKeysAPI - console api keys api error type.
ErrAPIKeysAPI = errs.Class("console api keys api error")
)
// APIKeys is an api controller that exposes all api keys related functionality.
type APIKeys struct {
log *zap.Logger
service *console.Service
}
// NewAPIKeys is a constructor for api api keys controller.
func NewAPIKeys(log *zap.Logger, service *console.Service) *APIKeys {
return &APIKeys{
log: log,
service: service,
}
}
// DeleteByNameAndProjectID deletes specific api key by it's name and project ID.
func (keys *APIKeys) DeleteByNameAndProjectID(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
var err error
defer mon.Task()(&ctx)(&err)
name := r.URL.Query().Get("name")
projectIDString := r.URL.Query().Get("projectID")
if name == "" {
keys.serveJSONError(w, http.StatusBadRequest, err)
return
}
projectID, err := uuid.FromString(projectIDString)
if err != nil {
keys.serveJSONError(w, http.StatusBadRequest, err)
return
}
err = keys.service.DeleteAPIKeyByNameAndProjectID(ctx, name, projectID)
if err != nil {
if console.ErrUnauthorized.Has(err) {
keys.serveJSONError(w, http.StatusUnauthorized, err)
return
}
keys.serveJSONError(w, http.StatusInternalServerError, err)
return
}
}
// serveJSONError writes JSON error to response output stream.
func (keys *APIKeys) serveJSONError(w http.ResponseWriter, status int, err error) {
if status == http.StatusInternalServerError {
keys.log.Error("returning internal server error to client", zap.Int("code", status), zap.Error(err))
} else {
keys.log.Debug("returning error to client", zap.Int("code", status), zap.Error(err))
}
w.WriteHeader(status)
var response struct {
Error string `json:"error"`
}
response.Error = err.Error()
err = json.NewEncoder(w).Encode(response)
if err != nil {
keys.log.Error("failed to write json error response", zap.Error(ErrAPIKeysAPI.Wrap(err)))
}
}

View File

@ -0,0 +1,92 @@
// Copyright (C) 2021 Storj Labs, Inc.
// See LICENSE for copying information.
package consoleapi_test
import (
"net/http"
"testing"
"time"
"github.com/stretchr/testify/require"
"go.uber.org/zap"
"storj.io/common/macaroon"
"storj.io/common/testcontext"
"storj.io/storj/private/testplanet"
"storj.io/storj/satellite"
"storj.io/storj/satellite/console"
)
func Test_DeleteAPIKeyByNameAndProjectID(t *testing.T) {
testplanet.Run(t, testplanet.Config{
SatelliteCount: 1, StorageNodeCount: 0, UplinkCount: 1,
Reconfigure: testplanet.Reconfigure{
Satellite: func(log *zap.Logger, index int, config *satellite.Config) {
config.Console.OpenRegistrationEnabled = true
config.Console.RateLimit.Burst = 10
},
},
}, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) {
sat := planet.Satellites[0]
newUser := console.CreateUser{
FullName: "test_name",
ShortName: "",
Email: "apikeytest@test.test",
}
user, err := sat.AddUser(ctx, newUser, 1)
require.NoError(t, err)
project, err := sat.AddProject(ctx, user.ID, "apikeytest")
require.NoError(t, err)
secret, err := macaroon.NewSecret()
require.NoError(t, err)
key, err := macaroon.NewAPIKey(secret)
require.NoError(t, err)
apikey := console.APIKeyInfo{
Name: "test",
ProjectID: project.ID,
Secret: secret,
}
created, err := sat.DB.Console().APIKeys().Create(ctx, key.Head(), apikey)
require.NoError(t, err)
// we are using full name as a password
token, err := sat.API.Console.Service.Token(ctx, user.Email, user.FullName)
require.NoError(t, err)
client := http.Client{}
req, err := http.NewRequest("DELETE", "http://"+planet.Satellites[0].API.Console.Listener.Addr().String()+"/api/v0/api-keys/delete-by-name?name="+apikey.Name+"&projectID="+project.ID.String(), nil)
require.NoError(t, err)
expire := time.Now().AddDate(0, 0, 1)
cookie := http.Cookie{
Name: "_tokenKey",
Path: "/",
Value: token,
Expires: expire,
}
req.AddCookie(&cookie)
result, err := client.Do(req)
require.NoError(t, err)
require.Equal(t, http.StatusOK, result.StatusCode)
keyAfterDelete, err := sat.DB.Console().APIKeys().Get(ctx, created.ID)
require.Error(t, err)
require.Nil(t, keyAfterDelete)
defer func() {
err = result.Body.Close()
require.NoError(t, err)
}()
})
}

View File

@ -202,6 +202,11 @@ func NewServer(logger *zap.Logger, config Config, service *console.Service, mail
bucketsRouter.Use(server.withAuth)
bucketsRouter.HandleFunc("/bucket-names", bucketsController.AllBucketNames).Methods(http.MethodGet)
apiKeysController := consoleapi.NewAPIKeys(logger, service)
apiKeysRouter := router.PathPrefix("/api/v0/api-keys").Subrouter()
apiKeysRouter.Use(server.withAuth)
apiKeysRouter.HandleFunc("/delete-by-name", apiKeysController.DeleteByNameAndProjectID).Methods(http.MethodDelete)
if server.config.StaticDir != "" {
router.HandleFunc("/activation/", server.accountActivationHandler)
router.HandleFunc("/password-recovery/", server.passwordRecoveryHandler)

View File

@ -1325,6 +1325,33 @@ func (s *Service) DeleteAPIKeys(ctx context.Context, ids []uuid.UUID) (err error
return Error.Wrap(err)
}
// DeleteAPIKeyByNameAndProjectID deletes api key by name and project ID.
func (s *Service) DeleteAPIKeyByNameAndProjectID(ctx context.Context, name string, projectID uuid.UUID) (err error) {
defer mon.Task()(&ctx)(&err)
auth, err := s.getAuthAndAuditLog(ctx, "delete api key by name and project ID", zap.String("apiKeyName", name), zap.String("projectID", projectID.String()))
if err != nil {
return Error.Wrap(err)
}
_, err = s.isProjectMember(ctx, auth.User.ID, projectID)
if err != nil {
return Error.Wrap(err)
}
key, err := s.store.APIKeys().GetByNameAndProjectID(ctx, name, projectID)
if err != nil {
return Error.Wrap(err)
}
err = s.store.APIKeys().Delete(ctx, key.ID)
if err != nil {
return Error.Wrap(err)
}
return nil
}
// GetAPIKeys returns paged api key list for given Project.
func (s *Service) GetAPIKeys(ctx context.Context, projectID uuid.UUID, cursor APIKeyCursor) (page *APIKeyPage, err error) {
defer mon.Task()(&ctx)(&err)

View File

@ -8,6 +8,7 @@ import (
"github.com/stretchr/testify/require"
"storj.io/common/macaroon"
"storj.io/common/storj"
"storj.io/common/testcontext"
"storj.io/common/testrand"
@ -176,5 +177,37 @@ func TestService(t *testing.T) {
require.Error(t, err)
require.Nil(t, bucketsForUnauthorizedUser)
})
t.Run("TestDeleteAPIKeyByNameAndProjectID", func(t *testing.T) {
secret, err := macaroon.NewSecret()
require.NoError(t, err)
key, err := macaroon.NewAPIKey(secret)
require.NoError(t, err)
apikey := console.APIKeyInfo{
Name: "test",
ProjectID: up2Pro1.ID,
Secret: secret,
}
createdKey, err := sat.DB.Console().APIKeys().Create(ctx, key.Head(), apikey)
require.NoError(t, err)
info, err := sat.DB.Console().APIKeys().Get(ctx, createdKey.ID)
require.NoError(t, err)
require.NotNil(t, info)
// Deleting someone else api keys should not work
err = service.DeleteAPIKeyByNameAndProjectID(authCtx1, apikey.Name, up2Pro1.ID)
require.Error(t, err)
err = service.DeleteAPIKeyByNameAndProjectID(authCtx2, apikey.Name, up2Pro1.ID)
require.NoError(t, err)
info, err = sat.DB.Console().APIKeys().Get(ctx, createdKey.ID)
require.Error(t, err)
require.Nil(t, info)
})
})
}

View File

@ -2,6 +2,7 @@
// See LICENSE for copying information.
import { BaseGql } from '@/api/baseGql';
import { ErrorUnauthorized } from '@/api/errors/ErrorUnauthorized';
import {
AccessGrant,
AccessGrantCursor,
@ -18,6 +19,7 @@ import { MetaUtils } from '@/utils/meta';
*/
export class AccessGrantsApiGql extends BaseGql implements AccessGrantsApi {
private readonly client: HttpClient = new HttpClient();
private readonly ROOT_PATH: string = '/api/v0/api-keys';
/**
* Fetch access grants.
@ -128,6 +130,28 @@ export class AccessGrantsApiGql extends BaseGql implements AccessGrantsApi {
return response.data.deleteAPIKeys;
}
/**
* Used to delete access grant access grant by name and project ID.
*
* @param name - name of the access grant that will be deleted
* @param projectID - id of the project where access grant was created
* @throws Error
*/
public async deleteByNameAndProjectID(name: string, projectID: string): Promise<void> {
const path = `${this.ROOT_PATH}/delete-by-name?name=${name}&projectID=${projectID}`;
const response = await this.client.delete(path);
if (response.ok) {
return;
}
if (response.status === 401) {
throw new ErrorUnauthorized();
}
throw new Error('can not delete access grant');
}
/**
* Used to get gateway credentials using access grant.
*

View File

@ -73,7 +73,7 @@ export class BucketsApiGql extends BaseGql implements BucketsApi {
throw new ErrorUnauthorized();
}
throw new Error('Can not get account balance');
throw new Error('Can not get bucket names');
}
const result = await response.json();