diff --git a/satellite/console/consoleweb/consoleapi/apikeys.go b/satellite/console/consoleweb/consoleapi/apikeys.go new file mode 100644 index 000000000..1975fca49 --- /dev/null +++ b/satellite/console/consoleweb/consoleapi/apikeys.go @@ -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))) + } +} diff --git a/satellite/console/consoleweb/consoleapi/apikeys_test.go b/satellite/console/consoleweb/consoleapi/apikeys_test.go new file mode 100644 index 000000000..dce0ca512 --- /dev/null +++ b/satellite/console/consoleweb/consoleapi/apikeys_test.go @@ -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) + }() + }) +} diff --git a/satellite/console/consoleweb/server.go b/satellite/console/consoleweb/server.go index 5c6e914be..cffbd8e44 100644 --- a/satellite/console/consoleweb/server.go +++ b/satellite/console/consoleweb/server.go @@ -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) diff --git a/satellite/console/service.go b/satellite/console/service.go index 04d840ae4..bf8546560 100644 --- a/satellite/console/service.go +++ b/satellite/console/service.go @@ -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) diff --git a/satellite/console/service_test.go b/satellite/console/service_test.go index edffb71f2..05b48b3f9 100644 --- a/satellite/console/service_test.go +++ b/satellite/console/service_test.go @@ -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) + }) }) } diff --git a/web/satellite/src/api/accessGrants.ts b/web/satellite/src/api/accessGrants.ts index 3de470e32..22cc6f3d3 100644 --- a/web/satellite/src/api/accessGrants.ts +++ b/web/satellite/src/api/accessGrants.ts @@ -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 { + 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. * diff --git a/web/satellite/src/api/buckets.ts b/web/satellite/src/api/buckets.ts index 468fece7f..e8c1980ca 100644 --- a/web/satellite/src/api/buckets.ts +++ b/web/satellite/src/api/buckets.ts @@ -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();