Skip to content

Commit

Permalink
Merge pull request #955 from neicnordic/feature/admin-api-1
Browse files Browse the repository at this point in the history
First stab at creating admin endpoints in the API
  • Loading branch information
jbygdell authored Aug 2, 2024
2 parents b203446 + aee57cc commit f533e4c
Show file tree
Hide file tree
Showing 9 changed files with 998 additions and 44 deletions.
184 changes: 184 additions & 0 deletions sda/cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,13 @@ package main
import (
"context"
"crypto/tls"
"encoding/json"
"fmt"
"net/http"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"

Expand All @@ -15,6 +18,7 @@ import (
"github.com/neicnordic/sensitive-data-archive/internal/broker"
"github.com/neicnordic/sensitive-data-archive/internal/config"
"github.com/neicnordic/sensitive-data-archive/internal/database"
"github.com/neicnordic/sensitive-data-archive/internal/schema"
"github.com/neicnordic/sensitive-data-archive/internal/userauth"
log "github.com/sirupsen/logrus"
)
Expand Down Expand Up @@ -70,6 +74,11 @@ func setup(config *config.Config) *http.Server {
r := gin.Default()
r.GET("/ready", readinessResponse)
r.GET("/files", getFiles)
// admin endpoints below here
r.POST("/file/ingest", isAdmin(), ingestFile) // start ingestion of a file
r.POST("/file/accession", isAdmin(), setAccession) // assign accession ID to a file
r.POST("/dataset/create", isAdmin(), createDataset) // maps a set of files to a dataset
r.POST("/dataset/release/*dataset", isAdmin(), releaseDataset) // Releases a dataset to be accessible

cfg := &tls.Config{MinVersion: tls.VersionTLS12}

Expand Down Expand Up @@ -174,3 +183,178 @@ func getFiles(c *gin.Context) {
// Return response
c.JSON(200, files)
}

func isAdmin() gin.HandlerFunc {
return func(c *gin.Context) {
token, err := auth.Authenticate(c.Request)
if err != nil {
log.Debugln("bad token")
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": err.Error()})

return
}
if !slices.Contains(Conf.API.Admins, token.Subject()) {
log.Debugf("%s is not an admin", token.Subject())
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})

return
}
}
}

func ingestFile(c *gin.Context) {
var ingest schema.IngestionTrigger
if err := c.BindJSON(&ingest); err != nil {
c.AbortWithStatusJSON(
http.StatusBadRequest,
gin.H{
"error": "json decoding : " + err.Error(),
"status": http.StatusBadRequest,
},
)

return
}

ingest.Type = "ingest"
marshaledMsg, _ := json.Marshal(&ingest)
if err := schema.ValidateJSON(fmt.Sprintf("%s/ingestion-trigger.json", Conf.Broker.SchemasPath), marshaledMsg); err != nil {
c.AbortWithStatusJSON(http.StatusBadRequest, err.Error())

return
}

corrID, err := Conf.API.DB.GetCorrID(ingest.User, ingest.FilePath)
if err != nil {
switch {
case corrID == "":
c.AbortWithStatusJSON(http.StatusBadRequest, err.Error())
default:
c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error())
}

return
}

err = Conf.API.MQ.SendMessage(corrID, Conf.Broker.Exchange, "ingest", marshaledMsg)
if err != nil {
c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error())

return
}

c.Status(http.StatusOK)
}

func setAccession(c *gin.Context) {
var accession schema.IngestionAccession
if err := c.BindJSON(&accession); err != nil {
c.AbortWithStatusJSON(
http.StatusBadRequest,
gin.H{
"error": "json decoding : " + err.Error(),
"status": http.StatusBadRequest,
},
)

return
}

corrID, err := Conf.API.DB.GetCorrID(accession.User, accession.FilePath)
if err != nil {
switch {
case corrID == "":
c.AbortWithStatusJSON(http.StatusBadRequest, err.Error())
default:
c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error())
}

return
}

fileInfo, err := Conf.API.DB.GetFileInfo(corrID)
if err != nil {
log.Debugln(err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error())

return
}

accession.DecryptedChecksums = []schema.Checksums{{Type: "sha256", Value: fileInfo.DecryptedChecksum}}
accession.Type = "accession"
marshaledMsg, _ := json.Marshal(&accession)
if err := schema.ValidateJSON(fmt.Sprintf("%s/ingestion-accession.json", Conf.Broker.SchemasPath), marshaledMsg); err != nil {
log.Debugln(err.Error())
c.AbortWithStatusJSON(http.StatusBadRequest, err.Error())

return
}

err = Conf.API.MQ.SendMessage(corrID, Conf.Broker.Exchange, "accession", marshaledMsg)
if err != nil {
log.Debugln(err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error())

return
}

c.Status(http.StatusOK)
}

func createDataset(c *gin.Context) {
var dataset schema.DatasetMapping
if err := c.BindJSON(&dataset); err != nil {
c.AbortWithStatusJSON(
http.StatusBadRequest,
gin.H{
"error": "json decoding : " + err.Error(),
"status": http.StatusBadRequest,
},
)

return
}

dataset.Type = "mapping"
marshaledMsg, _ := json.Marshal(&dataset)
if err := schema.ValidateJSON(fmt.Sprintf("%s/dataset-mapping.json", Conf.Broker.SchemasPath), marshaledMsg); err != nil {
log.Debugln(err.Error())
c.AbortWithStatusJSON(http.StatusBadRequest, err.Error())

return
}

err = Conf.API.MQ.SendMessage("", Conf.Broker.Exchange, "mappings", marshaledMsg)
if err != nil {
log.Debugln(err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error())

return
}

c.Status(http.StatusOK)
}

func releaseDataset(c *gin.Context) {
datasetMsg := schema.DatasetRelease{
Type: "release",
DatasetID: strings.TrimPrefix(c.Param("dataset"), "/"),
}
marshaledMsg, _ := json.Marshal(&datasetMsg)
if err := schema.ValidateJSON(fmt.Sprintf("%s/dataset-release.json", Conf.Broker.SchemasPath), marshaledMsg); err != nil {
log.Debugln(err.Error())
c.AbortWithStatusJSON(http.StatusBadRequest, err.Error())

return
}

err = Conf.API.MQ.SendMessage("", Conf.Broker.Exchange, "mappings", marshaledMsg)
if err != nil {
log.Debugln(err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error())

return
}

c.Status(http.StatusOK)
}
95 changes: 83 additions & 12 deletions sda/cmd/api/api.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,90 @@
# API

The API service provides data submitters with functionality to control
their submissions. Users are authenticated with a JWT.
their submissions. Users are authenticated with a JWT.

## Service Description

Endpoints:
- `/files`

1. Parses and validates the JWT token against the public keys, either locally provisioned or from OIDC JWK endpoints.
2. The `sub` field from the token is extracted and used as the user's identifier
3. All files belonging to this user are extracted from the database, together with their latest status and creation date

Example:
```bash
$ curl 'https://server/files' -H "Authorization: Bearer $token"
[{"inboxPath":"requester_demo.org/data/file1.c4gh","fileStatus":"uploaded","createAt":"2023-11-13T10:12:43.144242Z"}]

- `/files`
1. Parses and validates the JWT token against the public keys, either locally provisioned or from OIDC JWK endpoints.
2. The `sub` field from the token is extracted and used as the user's identifier
3. All files belonging to this user are extracted from the database, together with their latest status and creation date

Example:

```bash
$ curl 'https://server/files' -H "Authorization: Bearer $token"
[{"inboxPath":"requester_demo.org/data/file1.c4gh","fileStatus":"uploaded","createAt":"2023-11-13T10:12:43.144242Z"}]
```

If the `token` is invalid, 401 is returned.

### Admin endpoints

Admin endpoints are only available to a set of whitelisted users specified in the application config.

- `/file/ingest`
- accepts `POST` requests with JSON data with the format: `{"filepath": "</PATH/TO/FILE/IN/INBOX>", "user": "<USERNAME>"}`
- triggers the ingestion of the file.

- Error codes
- `200` Query execute ok.
- `400` Error due to bad payload i.e. wrong `user` + `filepath` combination.
- `401` User is not in the list of admins.
- `500` Internal error due to DB or MQ failures.

Example:

```bash
curl -H "Authorization: Bearer $token" -H "Content-Type: application/json" -X POST -d '{"filepath": "/uploads/file.c4gh", "user": "testuser"}' https://HOSTNAME/file/ingest
```

- `/file/accession`
- accepts `POST` requests with JSON data with the format: `{"accession_id": "<FILE_ACCESSION>", "filepath": "</PATH/TO/FILE/IN/INBOX>", "user": "<USERNAME>"}`
- assigns accession ID to the file.

- Error codes
- `200` Query execute ok.
- `400` Error due to bad payload i.e. wrong `user` + `filepath` combination.
- `401` User is not in the list of admins.
- `500` Internal error due to DB or MQ failures.

Example:

```bash
curl -H "Authorization: Bearer $token" -H "Content-Type: application/json" -X POST -d '{"accession_id": "my-id-01", "filepath": "/uploads/file.c4gh", "user": "testuser"}' https://HOSTNAME/file/accession
```

- `/dataset/create`
- accepts `POST` requests with JSON data with the format: `{"accession_ids": ["<FILE_ACCESSION_01>", "<FILE_ACCESSION_02>"], "dataset_id": "<DATASET_01>"}`
- creates a datset from the list of accession IDs and the dataset ID.

- Error codes
- `200` Query execute ok.
- `400` Error due to bad payload.
- `401` User is not in the list of admins.
- `500` Internal error due to DB or MQ failures.

Example:

```bash
curl -H "Authorization: Bearer $token" -H "Content-Type: application/json" -X POST -d '{"accession_idd": ["my-id-01", "my-id-02"], "dataset_id": "my-dataset-01"}' https://HOSTNAME/dataset/create
```

- `/dataset/release/*dataset`
- accepts `POST` requests with the dataset name as last part of the path`
- releases a dataset so that it can be downloaded.
- Error codes
- `200` Query execute ok.
- `400` Error due to bad payload.
- `401` User is not in the list of admins.
- `500` Internal error due to DB or MQ failures.
Example:
```bash
curl -H "Authorization: Bearer $token" -X POST https://HOSTNAME/dataset/release/my-dataset-01
```
If the `token` is invalid, 401 is returned.
Loading

0 comments on commit f533e4c

Please sign in to comment.