Skip to content

Commit

Permalink
Merge pull request #1115 from neicnordic/feature/sda-admin-rbac
Browse files Browse the repository at this point in the history
[API] Add a RBAC solution
  • Loading branch information
jbygdell authored Nov 22, 2024
2 parents 7a96059 + c5956f4 commit bcfd30d
Show file tree
Hide file tree
Showing 18 changed files with 749 additions and 249 deletions.
2 changes: 2 additions & 0 deletions .github/integration/scripts/charts/dependencies.sh
Original file line number Diff line number Diff line change
Expand Up @@ -75,3 +75,5 @@ yq -i '
.global.sync.destination.secretKey = strenv(MINIO_SECRET) |
.releasetest.secrets.accessToken = strenv(TEST_TOKEN)
' .github/integration/scripts/charts/values.yaml

kubectl create secret generic api-rbac --from-file=".github/integration/sda/rbac.json"
5 changes: 1 addition & 4 deletions .github/integration/scripts/charts/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -14,12 +14,9 @@ global:
issuer: ""
clusterIssuer: "cert-issuer"
api:
adminsFileSecret:
adminUsers:
- [email protected]
- [email protected]
jwtPubKeyName: jwt.pub
jwtSecret: jwk
rbacFileSecret: api-rbac
archive:
storageType: s3
s3AccessKey: PLACEHOLDER_VALUE
Expand Down
1 change: 1 addition & 0 deletions .github/integration/sda-s3-integration.yml
Original file line number Diff line number Diff line change
Expand Up @@ -299,6 +299,7 @@ services:
restart: always
volumes:
- ./sda/config.yaml:/config.yaml
- ./sda/rbac.json:/rbac.json
- shared:/shared

reencrypt:
Expand Down
4 changes: 2 additions & 2 deletions .github/integration/sda/config.yaml
Original file line number Diff line number Diff line change
@@ -1,8 +1,8 @@
log:
format: "json"
level: "debug"
admin:
users: "[email protected]"
api:
rbacFile: /rbac.json
archive:
type: s3
url: "http://s3"
Expand Down
44 changes: 44 additions & 0 deletions .github/integration/sda/rbac.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
{
"policy": [
{
"role": "admin",
"path": "/c4gh-keys/*",
"action": "(GET)|(POST)|(PUT)"
},
{
"role": "submission",
"path": "/file/ingest",
"action": "POST"
},
{
"role": "submission",
"path": "/file/accession",
"action": "POST"
},
{
"role": "submission",
"path": "/users",
"action": "GET"
},
{
"role": "submission",
"path": "/users/:username/files",
"action": "GET"
},
{
"role": "*",
"path": "/files",
"action": "GET"
}
],
"roles": [
{
"role": "admin",
"rolebinding": "submission"
},
{
"role": "[email protected]",
"rolebinding": "admin"
}
]
}
2 changes: 1 addition & 1 deletion charts/sda-svc/Chart.yaml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
apiVersion: v2
name: sda-svc
version: 0.28.15
version: 0.29.0
appVersion: v0.3.170
kubeVersion: '>= 1.26.0'
description: Components for Sensitive Data Archive (SDA) installation
Expand Down
3 changes: 1 addition & 2 deletions charts/sda-svc/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -76,10 +76,9 @@ Parameter | Description | Default
`global.backupArchive.volumePath` | Path to the mounted `posix` volume. |`/backup`
`global.backupArchive.nfsServer` | URL or IP address to a NFS server. |`""`
`global.backupArchive.nfsPath` | Path on the NFS server for the backup archive. |`""`
`global.api.adminFileSecret` | A secret holding a JSON file named `admin.json` containg a list of identifiers |``
`global.api.adminUsers` | A list of identifiers of the users with admin privileges |``
`global.api.jwtPubKeyName` | Public key used to verify the JWT. |``
`global.api.jwtSecret` | The name of the secret holding the JWT public key |``
`global.api.rbacFileSecret` | A secret holding a JSON file named `rbac.json` containg the RBAC policies, see example in the [api.md](https://github.com/neicnordic/sensitive-data-archive/blob/main/sda/cmd/api/api.md#configure-rbac) |``
`global.auth.jwtAlg` | Key type to sign the JWT, available options are RS265 & ES256, Must match the key type |`"ES256"`
`global.auth.jwtKey` | Private key used to sign the JWT. |`""`
`global.auth.jwtPub` | Public key ues to verify the JWT. |`""`
Expand Down
21 changes: 7 additions & 14 deletions charts/sda-svc/templates/api-deploy.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -72,8 +72,8 @@ spec:
command: ["sda-api"]
env:
{{- if not .Values.global.vaultSecrets }}
- name: API_ADMINFILE
value: {{ template "secretsPath" . }}/admins.json
- name: API_RBACFILE
value: {{ template "secretsPath" . }}/rbac.json
{{- if .Values.global.tls.enabled }}
- name: API_SERVERCERT
value: {{ template "tlsPath" . }}/tls.crt
Expand Down Expand Up @@ -198,7 +198,7 @@ spec:
mountPath: {{ include "jwtPath" . }}
{{- end }}
{{- if not .Values.global.vaultSecrets }}
- name: admins
- name: rbac
mountPath: {{ template "secretsPath" . }}
{{- end }}
{{- if .Values.global.tls.enabled }}
Expand All @@ -207,21 +207,14 @@ spec:
{{- end }}
volumes:
{{- if not .Values.global.vaultSecrets }}
- name: admins
- name: rbac
projected:
sources:
- secret:
{{- if .Values.global.api.adminsFileSecret }}
name: {{ .Values.global.api.adminsFileSecret }}
name: {{ required "a secret containing the RBAC policy is required" .Values.global.api.rbacFileSecret }}
items:
- key: admins.json
path: admins.json
{{- else }}
name: {{ template "sda.fullname" . }}-api-admins
items:
- key: admins.json
path: admins.json
{{- end }}
- key: rbac.json
path: rbac.json
{{- if .Values.global.api.jwtPubKeyName }}
- name: jwt
projected:
Expand Down
3 changes: 1 addition & 2 deletions charts/sda-svc/values.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -108,10 +108,9 @@ global:

# global configurations
api:
adminsFileSecret: ""
adminUsers:
jwtPubKeyName:
jwtSecret:
rbacFileSecret: ""
archive:
storageType: "" # s3 or posix
# The six lines below is only used with S3 backend
Expand Down
81 changes: 48 additions & 33 deletions sda/cmd/api/api.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,17 +11,19 @@ import (
"net/http"
"os"
"os/signal"
"slices"
"strings"
"syscall"
"time"

"github.com/casbin/casbin/v2"
"github.com/casbin/casbin/v2/model"
"github.com/gin-gonic/gin"
"github.com/lestrrat-go/jwx/v2/jwk"
"github.com/neicnordic/crypt4gh/keys"
"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/jsonadapter"
"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 @@ -55,8 +57,8 @@ func main() {

if err := setupJwtAuth(); err != nil {
log.Fatalf("error when setting up JWT auth, reason %s", err.Error())

}

sigc := make(chan os.Signal, 5)
signal.Notify(sigc, syscall.SIGHUP, syscall.SIGINT, syscall.SIGTERM, syscall.SIGQUIT)
go func() {
Expand All @@ -83,22 +85,27 @@ func main() {
}

func setup(config *config.Config) *http.Server {
model, _ := model.NewModelFromString(jsonadapter.Model)
e, err := casbin.NewEnforcer(model, jsonadapter.NewAdapter(&Conf.API.RBACpolicy))
if err != nil {
shutdown()
log.Fatalf("error when setting up RBAC enforcer, reason %s", err.Error())
}

r := gin.Default()
r.GET("/ready", readinessResponse)
r.GET("/files", getFiles)
r.GET("/files", rbac(e), getFiles)
// admin endpoints below here
if len(config.API.Admins) > 0 {
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
r.POST("/c4gh-keys/add", isAdmin(), addC4ghHash) // Adds a key hash to the database
r.POST("/c4gh-keys/deprecate/*keyHash", isAdmin(), deprecateC4ghHash) // Deprecate a given key hash
r.GET("/c4gh-keys/list", isAdmin(), listC4ghHashes) // Lists key hashes in the database
r.GET("/users", isAdmin(), listActiveUsers) // Lists all users
r.GET("/users/:username/files", isAdmin(), listUserFiles) // Lists all unmapped files for a user
}

r.POST("/c4gh-keys/add", rbac(e), addC4ghHash) // Adds a key hash to the database
r.GET("/c4gh-keys/list", rbac(e), listC4ghHashes) // Lists key hashes in the database
r.POST("/c4gh-keys/deprecate/*keyHash", rbac(e), deprecateC4ghHash) // Deprecate a given key hash
// submission endpoints below here
r.POST("/file/ingest", rbac(e), ingestFile) // start ingestion of a file
r.POST("/file/accession", rbac(e), setAccession) // assign accession ID to a file
r.POST("/dataset/create", rbac(e), createDataset) // maps a set of files to a dataset
r.POST("/dataset/release/*dataset", rbac(e), releaseDataset) // Releases a dataset to be accessible
r.GET("/users", rbac(e), listActiveUsers) // Lists all users
r.GET("/users/:username/files", rbac(e), listUserFiles) // Lists all unmapped files for a user
cfg := &tls.Config{MinVersion: tls.VersionTLS12}

srv := &http.Server{
Expand Down Expand Up @@ -179,6 +186,32 @@ func checkDB(database *database.SDAdb, timeout time.Duration) error {
return database.DB.PingContext(ctx)
}

func rbac(e *casbin.Enforcer) 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
}

ok, err := e.Enforce(token.Subject(), c.Request.URL.String(), c.Request.Method)
if err != nil {
log.Debugf("rbac enforcement failed, reason: %s\n", err.Error())
c.AbortWithStatusJSON(http.StatusInternalServerError, gin.H{"error": err.Error()})

return
}
if !ok {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authorized"})

return
}
log.Debugln("authoriozed")
}
}

// getFiles returns the files from the database for a specific user
func getFiles(c *gin.Context) {
c.Writer.Header().Set("Content-Type", "application/json")
Expand All @@ -203,24 +236,6 @@ func getFiles(c *gin.Context) {
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 {
Expand Down
69 changes: 61 additions & 8 deletions sda/cmd/api/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -136,16 +136,69 @@ Admin endpoints are only available to a set of whitelisted users specified in th
curl -H "Authorization: Bearer $token" -H "Content-Type: application/json" -X POST -d '{"pubkey": "'"$( base64 -w0 /PATH/TO/c4gh.pub)"'", "description": "this is the key description"}' https://HOSTNAME/c4gh-keys/add
```

#### Configure Admin users
#### Configure RBAC

The users that should have administrative access can be set in two ways:
RBAC is configured according to the JSON schema below.
The path to the JSON file containing the RBAC policies needs to be passed through the `api.rbacFile` config definition.

- As a comma separated list of user identifiers assigned to: `admin.users`.
- As a JSON file containg a list of the user identities, the path to the file is assigned to: `admin.usersFile`. This is the recommended way.
The `policy` section will configure access to the defined endpoints. Unless specific rules are set, an endpoint will not be accessible.

- `action`: can be single string value i,e `GET` or a regex string with `|` as separator i.e. `(GET)|(POST)|(PUT)`. In the later case all actions in the list are allowed.
- `path`: the endpoint. Should be a string value with two different wildcard notations: `*`, matches any value and `:` that matches a specific named value
- `role`: the role that will be able to access the path, `"*"` will match any role or user.

The `roles` section defines the available roles

- `role`: rolename or username from the accesstoken
- `roleBinding`: maps a user/role to another role, this makes roles work as groups which simplifies the policy definitions.

```json
[
"[email protected]",
"[email protected]"
]
{
"policy": [
{
"role": "admin",
"path": "/c4gh-keys/*",
"action": "(GET)|(POST)|(PUT)"
},
{
"role": "submission",
"path": "/file/ingest",
"action": "POST"
},
{
"role": "submission",
"path": "/file/accession",
"action": "POST"
},
{
"role": "submission",
"path": "/users",
"action": "GET"
},
{
"role": "submission",
"path": "/users/:username/files",
"action": "GET"
},
{
"role": "*",
"path": "/files",
"action": "GET"
}
],
"roles": [
{
"role": "admin",
"rolebinding": "submission"
},
{
"role": "[email protected]",
"rolebinding": "admin"
},
{
"role": "[email protected]",
"rolebinding": "submission"
}
]
}
```
Loading

0 comments on commit bcfd30d

Please sign in to comment.