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

[API] Add a RBAC solution #1115

Merged
merged 9 commits into from
Nov 22, 2024
Merged
Show file tree
Hide file tree
Changes from all 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
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 {
jbygdell marked this conversation as resolved.
Show resolved Hide resolved
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
Loading