diff --git a/satellite/admin/README.md b/satellite/admin/README.md index d1370614b..482c4c765 100644 --- a/satellite/admin/README.md +++ b/satellite/admin/README.md @@ -4,7 +4,9 @@ Satellite Admin package provides API endpoints for administrative tasks. Requires setting `Authorization` header for requests. -## POST /api/user +## User Management + +### POST /api/user Adds a new user. @@ -30,7 +32,7 @@ A successful response body: } ``` -## PUT /api/user/{user-email} +### PUT /api/user/{user-email} Updates the details of existing user found by its email. @@ -55,7 +57,7 @@ Some example request bodies: } ``` -## GET /api/user/{user-email} +### GET /api/user/{user-email} This endpoint returns information about user and their projects. @@ -92,11 +94,13 @@ A successful response body: } ``` -## DELETE /api/user/{user-email} +### DELETE /api/user/{user-email} Deletes the user. -## POST /api/coupon +## Coupon Management + +### POST /api/coupon Adds a coupon for specific user. @@ -119,7 +123,7 @@ A successful response body: } ``` -## GET /api/coupon/{coupon-id} +### GET /api/coupon/{coupon-id} Gets a coupon with the specified id. @@ -138,17 +142,40 @@ A successful response body: } ``` -## DELETE /api/coupon/{coupon-id} +### DELETE /api/coupon/{coupon-id} Deletes the specified coupon. -## GET /api/project/{project-id}/usage +## Project Management + +### POST /api/project + +Adds a project for specific user. + +An example of a required request body: + +```json +{ + "ownerId": "ca7aa0fb-442a-4d4e-aa36-a49abddae837", + "projectName": "My Second Project" +} +``` + +A successful response body: + +```json +{ + "projectId": "ca7aa0fb-442a-4d4e-aa36-a49abddae646" +} +``` + +### GET /api/project/{project-id}/usage This endpoint returns whether the project has outstanding usage or not. A project with not usage returns status code 200 and `{"result":"no project usage exist"}`. -## GET /api/project/{project-id}/limit +### GET /api/project/{project-id}/limit This endpoint returns information about project limits. @@ -170,27 +197,27 @@ A successful response body: } ``` -## POST /api/project/{project-id}/limit?usage={value} +### POST /api/project/{project-id}/limit?usage={value} Updates usage limit for a project. -## POST /api/project/{project-id}/limit?bandwidth={value} +### POST /api/project/{project-id}/limit?bandwidth={value} Updates bandwidth limit for a project. -## POST /api/project/{project-id}/limit?rate={value} +### POST /api/project/{project-id}/limit?rate={value} Updates rate limit for a project. -## POST /api/project/{project-id}/limit?buckets={value} +### POST /api/project/{project-id}/limit?buckets={value} Updates bucket limit for a project. -## GET /api/project/{project-id} +### GET /api/project/{project-id} Gets the common information about a project. -## PUT /api/project/{project-id} +### PUT /api/project/{project-id} Updates project name or description. @@ -201,27 +228,38 @@ Updates project name or description. } ``` -## DELETE /api/project/{project-id} +### DELETE /api/project/{project-id} Deletes the project. -## POST /api/project +### POST /api/project/{project}/apikey -Adds a project for specific user. +Adds an apikey for specific project. An example of a required request body: ```json { - "ownerId": "ca7aa0fb-442a-4d4e-aa36-a49abddae837", - "projectName": "My Second Project" + "name": "My first API Key" } ``` +**Note:** Additionally you can specify `partnerId` to associate it with the given apikey. +If you specify it, it has to be a valid uuid and not an empty string. A successful response body: ```json { - "projectId": "ca7aa0fb-442a-4d4e-aa36-a49abddae837" + "apikey": "13YqdMKxAVBamFsS6Mj3sCQ35HySoA254xmXCCQGJqffLnqrBaQDoTcCiCfbkaFPNewHT79rrFC5XRm4Z2PENtRSBDVNz8zcjS28W5v" } ``` + +### DELETE /api/project/{project}/apikey/{name} + +Deletes the given apikey by its name. + +## APIKey Management + +### DELETE /api/apikey/{apikey} + +Deletes the given apikey. \ No newline at end of file diff --git a/satellite/admin/apikeys.go b/satellite/admin/apikeys.go new file mode 100644 index 000000000..e7cff1713 --- /dev/null +++ b/satellite/admin/apikeys.go @@ -0,0 +1,183 @@ +// Copyright (C) 2020 Storj Labs, Inc. +// See LICENSE for copying information. + +package admin + +import ( + "encoding/json" + "io/ioutil" + "net/http" + + "github.com/gorilla/mux" + + "storj.io/common/macaroon" + "storj.io/common/uuid" + "storj.io/storj/satellite/console" +) + +func (server *Server) addAPIKey(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + projectUUIDString, ok := vars["project"] + if !ok { + httpJSONError(w, "project-uuid missing", + "", http.StatusBadRequest) + return + } + + projectUUID, err := uuid.FromString(projectUUIDString) + if err != nil { + httpJSONError(w, "invalid project-uuid", + err.Error(), http.StatusBadRequest) + return + } + + body, err := ioutil.ReadAll(r.Body) + if err != nil { + httpJSONError(w, "failed to read body", + err.Error(), http.StatusInternalServerError) + return + } + + var input struct { + PartnerID uuid.UUID `json:"partnerId"` + Name string `json:"name"` + } + + var output struct { + APIKey string `json:"apikey"` + } + + err = json.Unmarshal(body, &input) + if err != nil { + httpJSONError(w, "failed to unmarshal request", + err.Error(), http.StatusBadRequest) + return + } + + if input.Name == "" { + httpJSONError(w, "Name is not set", + "", http.StatusBadRequest) + return + } + + _, err = server.db.Console().APIKeys().GetByNameAndProjectID(ctx, input.Name, projectUUID) + if err == nil { + httpJSONError(w, "api-key with given name already exists", + "", http.StatusConflict) + return + } + + secret, err := macaroon.NewSecret() + if err != nil { + httpJSONError(w, "could not create macaroon secret", + err.Error(), http.StatusInternalServerError) + return + } + + key, err := macaroon.NewAPIKey(secret) + if err != nil { + httpJSONError(w, "could not create api-key", + err.Error(), http.StatusInternalServerError) + return + } + + apikey := console.APIKeyInfo{ + Name: input.Name, + ProjectID: projectUUID, + Secret: secret, + PartnerID: input.PartnerID, + } + + _, err = server.db.Console().APIKeys().Create(ctx, key.Head(), apikey) + if err != nil { + httpJSONError(w, "unable to add api-key to database", + err.Error(), http.StatusInternalServerError) + return + } + + output.APIKey = key.Serialize() + data, err := json.Marshal(output) + if err != nil { + httpJSONError(w, "json encoding failed", + err.Error(), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write(data) // nothing to do with the error response, probably the client requesting disappeared +} + +func (server *Server) deleteAPIKey(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + apikeyString, ok := vars["apikey"] + if !ok { + httpJSONError(w, "apikey missing", + "", http.StatusBadRequest) + return + } + + apikey, err := macaroon.ParseAPIKey(apikeyString) + if err != nil { + httpJSONError(w, "invalid apikey format", + err.Error(), http.StatusBadRequest) + return + } + + info, err := server.db.Console().APIKeys().GetByHead(ctx, apikey.Head()) + if err != nil { + httpJSONError(w, "could not get apikey id", + err.Error(), http.StatusInternalServerError) + return + } + + err = server.db.Console().APIKeys().Delete(ctx, info.ID) + if err != nil { + httpJSONError(w, "unable to delete apikey", + err.Error(), http.StatusInternalServerError) + return + } +} + +func (server *Server) deleteAPIKeyByName(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + vars := mux.Vars(r) + projectUUIDString, ok := vars["project"] + if !ok { + httpJSONError(w, "project-uuid missing", + "", http.StatusBadRequest) + return + } + + projectUUID, err := uuid.FromString(projectUUIDString) + if err != nil { + httpJSONError(w, "invalid project-uuid", + err.Error(), http.StatusBadRequest) + return + } + + apikeyName, ok := vars["name"] + if !ok { + httpJSONError(w, "apikey name missing", + "", http.StatusBadRequest) + return + } + + info, err := server.db.Console().APIKeys().GetByNameAndProjectID(ctx, apikeyName, projectUUID) + if err != nil { + httpJSONError(w, "could not get apikey id", + err.Error(), http.StatusInternalServerError) + return + } + + err = server.db.Console().APIKeys().Delete(ctx, info.ID) + if err != nil { + httpJSONError(w, "unable to delete apikey", + err.Error(), http.StatusInternalServerError) + return + } +} diff --git a/satellite/admin/apikeys_test.go b/satellite/admin/apikeys_test.go new file mode 100644 index 000000000..1d8303c67 --- /dev/null +++ b/satellite/admin/apikeys_test.go @@ -0,0 +1,146 @@ +// Copyright (C) 2020 Storj Labs, Inc. +// See LICENSE for copying information. + +package admin_test + +import ( + "encoding/json" + "fmt" + "io/ioutil" + "net/http" + "strings" + "testing" + + "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 TestAddApiKey(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.Admin.Address = "127.0.0.1:0" + }, + }, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + address := planet.Satellites[0].Admin.Admin.Listener.Addr() + projectID := planet.Uplinks[0].Projects[0].ID + + keys, err := planet.Satellites[0].DB.Console().APIKeys().GetPagedByProjectID(ctx, projectID, console.APIKeyCursor{Page: 1, Limit: 10}) + require.NoError(t, err) + require.Len(t, keys.APIKeys, 1) + + body := strings.NewReader(`{"name":"Default"}`) + req, err := http.NewRequest(http.MethodPost, fmt.Sprintf("http://"+address.String()+"/api/project/%s/apikey", projectID.String()), body) + require.NoError(t, err) + req.Header.Set("Authorization", planet.Satellites[0].Config.Console.AuthToken) + + response, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + responseBody, err := ioutil.ReadAll(response.Body) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + + var output struct { + APIKey string `json:"apikey"` + } + + err = json.Unmarshal(responseBody, &output) + require.NoError(t, err) + + apikey, err := macaroon.ParseAPIKey(output.APIKey) + require.NoError(t, err) + require.NotNil(t, apikey) + + keys, err = planet.Satellites[0].DB.Console().APIKeys().GetPagedByProjectID(ctx, projectID, console.APIKeyCursor{Page: 1, Limit: 10}) + require.NoError(t, err) + require.Len(t, keys.APIKeys, 2) + + key, err := planet.Satellites[0].DB.Console().APIKeys().GetByHead(ctx, apikey.Head()) + require.NoError(t, err) + require.Equal(t, "Default", key.Name) + }) +} + +func TestDeleteApiKey(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.Admin.Address = "127.0.0.1:0" + }, + }, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + address := planet.Satellites[0].Admin.Admin.Listener.Addr() + projectID := planet.Uplinks[0].Projects[0].ID + + keys, err := planet.Satellites[0].DB.Console().APIKeys().GetPagedByProjectID(ctx, projectID, console.APIKeyCursor{Page: 1, Limit: 10}) + require.NoError(t, err) + require.Len(t, keys.APIKeys, 1) + + apikey := planet.Uplinks[0].APIKey[planet.Satellites[0].ID()].Serialize() + req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("http://"+address.String()+"/api/apikey/%s", apikey), nil) + require.NoError(t, err) + req.Header.Set("Authorization", planet.Satellites[0].Config.Console.AuthToken) + + response, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + responseBody, err := ioutil.ReadAll(response.Body) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + require.Len(t, responseBody, 0) + + keys, err = planet.Satellites[0].DB.Console().APIKeys().GetPagedByProjectID(ctx, projectID, console.APIKeyCursor{Page: 1, Limit: 10}) + require.NoError(t, err) + require.Len(t, keys.APIKeys, 0) + }) +} + +func TestDeleteApiKeyByName(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.Admin.Address = "127.0.0.1:0" + }, + }, + }, func(t *testing.T, ctx *testcontext.Context, planet *testplanet.Planet) { + address := planet.Satellites[0].Admin.Admin.Listener.Addr() + projectID := planet.Uplinks[0].Projects[0].ID + + keys, err := planet.Satellites[0].DB.Console().APIKeys().GetPagedByProjectID(ctx, projectID, console.APIKeyCursor{Page: 1, Limit: 10}) + require.NoError(t, err) + require.Len(t, keys.APIKeys, 1) + + req, err := http.NewRequest(http.MethodDelete, fmt.Sprintf("http://"+address.String()+"/api/project/%s/apikey/%s", projectID.String(), keys.APIKeys[0].Name), nil) + require.NoError(t, err) + req.Header.Set("Authorization", planet.Satellites[0].Config.Console.AuthToken) + + response, err := http.DefaultClient.Do(req) + require.NoError(t, err) + require.Equal(t, http.StatusOK, response.StatusCode) + responseBody, err := ioutil.ReadAll(response.Body) + require.NoError(t, err) + require.NoError(t, response.Body.Close()) + require.Len(t, responseBody, 0) + + keys, err = planet.Satellites[0].DB.Console().APIKeys().GetPagedByProjectID(ctx, projectID, console.APIKeyCursor{Page: 1, Limit: 10}) + require.NoError(t, err) + require.Len(t, keys.APIKeys, 0) + }) +} diff --git a/satellite/admin/server.go b/satellite/admin/server.go index d795bc8df..7df6f8d30 100644 --- a/satellite/admin/server.go +++ b/satellite/admin/server.go @@ -84,13 +84,16 @@ func NewServer(log *zap.Logger, listener net.Listener, db DB, accounts payments. server.mux.HandleFunc("/api/coupon", server.addCoupon).Methods("POST") server.mux.HandleFunc("/api/coupon/{couponid}", server.couponInfo).Methods("GET") server.mux.HandleFunc("/api/coupon/{couponid}", server.deleteCoupon).Methods("DELETE") + server.mux.HandleFunc("/api/project", server.addProject).Methods("POST") server.mux.HandleFunc("/api/project/{project}/usage", server.checkProjectUsage).Methods("GET") server.mux.HandleFunc("/api/project/{project}/limit", server.getProjectLimit).Methods("GET") server.mux.HandleFunc("/api/project/{project}/limit", server.putProjectLimit).Methods("PUT", "POST") server.mux.HandleFunc("/api/project/{project}", server.getProject).Methods("GET") server.mux.HandleFunc("/api/project/{project}", server.renameProject).Methods("PUT") server.mux.HandleFunc("/api/project/{project}", server.deleteProject).Methods("DELETE") - server.mux.HandleFunc("/api/project", server.addProject).Methods("POST") + server.mux.HandleFunc("/api/project/{project}/apikey", server.addAPIKey).Methods("POST") + server.mux.HandleFunc("/api/project/{project}/apikey/{name}", server.deleteAPIKeyByName).Methods("DELETE") + server.mux.HandleFunc("/api/apikey/{apikey}", server.deleteAPIKey).Methods("DELETE") return server }