satellite/admin: add apikey endpoints

This change allows the creation and deletion of api keys via the admin API.
It adds two methods for deletion, one via the name and projectID and the
second one via the serialized apikey directly.

Change-Id: Ida8aa729e716db58c671a901e5f7e39253e89a0d
This commit is contained in:
Stefan Benten 2020-10-20 01:35:54 +02:00
parent c6415406a1
commit 334ae5b164
4 changed files with 392 additions and 22 deletions

View File

@ -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.

183
satellite/admin/apikeys.go Normal file
View File

@ -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
}
}

View File

@ -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)
})
}

View File

@ -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
}