Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

First stab at creating admin endpoints in the API #955

Merged
merged 16 commits into from
Aug 2, 2024
Merged
Show file tree
Hide file tree
Changes from 12 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
193 changes: 193 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,187 @@ 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()) {
jbygdell marked this conversation as resolved.
Show resolved Hide resolved
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 {
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 {
log.Debugln(err.Error())
c.AbortWithStatusJSON(http.StatusBadRequest, 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, err := json.Marshal(&accession)
if err != nil {
log.Debugln(err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error())

return
}
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, err := json.Marshal(&dataset)
if err != nil {
log.Debugln(err.Error())
c.AbortWithStatusJSON(http.StatusConflict, err.Error())

return
}
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, err := json.Marshal(&datasetMsg)
if err != nil {
log.Debugln(err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, err.Error())

return
}
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)
}
71 changes: 59 additions & 12 deletions sda/cmd/api/api.md
Original file line number Diff line number Diff line change
@@ -1,19 +1,66 @@
# 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.
jbygdell marked this conversation as resolved.
Show resolved Hide resolved

- `/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.

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.

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.

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.

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
Loading