diff --git a/.github/integration/scripts/charts/dependencies.sh b/.github/integration/scripts/charts/dependencies.sh index 101888e9a..45c08d37e 100644 --- a/.github/integration/scripts/charts/dependencies.sh +++ b/.github/integration/scripts/charts/dependencies.sh @@ -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" \ No newline at end of file diff --git a/.github/integration/scripts/charts/values.yaml b/.github/integration/scripts/charts/values.yaml index 6f4524ac1..1787615bc 100644 --- a/.github/integration/scripts/charts/values.yaml +++ b/.github/integration/scripts/charts/values.yaml @@ -14,12 +14,9 @@ global: issuer: "" clusterIssuer: "cert-issuer" api: - adminsFileSecret: - adminUsers: - - dummy@example.com - - requester@demo.org jwtPubKeyName: jwt.pub jwtSecret: jwk + rbacFileSecret: api-rbac archive: storageType: s3 s3AccessKey: PLACEHOLDER_VALUE diff --git a/.github/integration/sda-s3-integration.yml b/.github/integration/sda-s3-integration.yml index bedb2577e..ae0565c8e 100644 --- a/.github/integration/sda-s3-integration.yml +++ b/.github/integration/sda-s3-integration.yml @@ -299,6 +299,7 @@ services: restart: always volumes: - ./sda/config.yaml:/config.yaml + - ./sda/rbac.json:/rbac.json - shared:/shared reencrypt: diff --git a/.github/integration/sda/config.yaml b/.github/integration/sda/config.yaml index b89f0d4a3..4d849f153 100644 --- a/.github/integration/sda/config.yaml +++ b/.github/integration/sda/config.yaml @@ -1,8 +1,8 @@ log: format: "json" level: "debug" -admin: - users: "requester@demo.org" +api: + rbacFile: /rbac.json archive: type: s3 url: "http://s3" diff --git a/.github/integration/sda/rbac.json b/.github/integration/sda/rbac.json new file mode 100644 index 000000000..8d8362814 --- /dev/null +++ b/.github/integration/sda/rbac.json @@ -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": "requester@demo.org", + "rolebinding": "admin" + } + ] + } \ No newline at end of file diff --git a/charts/sda-svc/Chart.yaml b/charts/sda-svc/Chart.yaml index 1fb49f936..472878aad 100644 --- a/charts/sda-svc/Chart.yaml +++ b/charts/sda-svc/Chart.yaml @@ -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 diff --git a/charts/sda-svc/README.md b/charts/sda-svc/README.md index 1cdc7c89b..a1d38b365 100644 --- a/charts/sda-svc/README.md +++ b/charts/sda-svc/README.md @@ -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. |`""` diff --git a/charts/sda-svc/templates/api-deploy.yaml b/charts/sda-svc/templates/api-deploy.yaml index 6bf9a67e1..ea68efe54 100644 --- a/charts/sda-svc/templates/api-deploy.yaml +++ b/charts/sda-svc/templates/api-deploy.yaml @@ -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 @@ -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 }} @@ -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: diff --git a/charts/sda-svc/values.yaml b/charts/sda-svc/values.yaml index 4fa92756a..f102bd84f 100644 --- a/charts/sda-svc/values.yaml +++ b/charts/sda-svc/values.yaml @@ -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 diff --git a/sda/cmd/api/api.go b/sda/cmd/api/api.go index 269956873..e448085f4 100644 --- a/sda/cmd/api/api.go +++ b/sda/cmd/api/api.go @@ -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" @@ -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() { @@ -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{ @@ -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") @@ -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 { diff --git a/sda/cmd/api/api.md b/sda/cmd/api/api.md index 2c921dacb..1adefda7b 100644 --- a/sda/cmd/api/api.md +++ b/sda/cmd/api/api.md @@ -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 -[ -"foo-user@example.com", -"bar-user@example.com" -] +{ + "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": "dummy@example.org", + "rolebinding": "admin" + }, + { + "role": "test@example.org", + "rolebinding": "submission" + } + ] +} ``` diff --git a/sda/cmd/api/api_test.go b/sda/cmd/api/api_test.go index 3658753cd..fb444a0bd 100644 --- a/sda/cmd/api/api_test.go +++ b/sda/cmd/api/api_test.go @@ -9,7 +9,6 @@ import ( "io" "net/http" "net/http/httptest" - "net/url" "os" "path" "runtime" @@ -18,6 +17,8 @@ import ( "testing" "time" + "github.com/casbin/casbin/v2" + "github.com/casbin/casbin/v2/model" "github.com/gin-gonic/gin" "github.com/google/uuid" _ "github.com/lib/pq" @@ -25,6 +26,7 @@ import ( "github.com/neicnordic/sensitive-data-archive/internal/config" "github.com/neicnordic/sensitive-data-archive/internal/database" "github.com/neicnordic/sensitive-data-archive/internal/helper" + "github.com/neicnordic/sensitive-data-archive/internal/jsonadapter" "github.com/neicnordic/sensitive-data-archive/internal/schema" "github.com/ory/dockertest/v3" "github.com/ory/dockertest/v3/docker" @@ -233,6 +235,7 @@ type TestSuite struct { PublicPath string PrivatePath string KeyName string + RBAC []byte Token string User string } @@ -378,6 +381,16 @@ func (suite *TestSuite) SetupSuite() { Conf.API.MQ, err = broker.NewMQ(Conf.Broker) assert.NoError(suite.T(), err) + suite.RBAC = []byte(`{"policy":[{"role":"admin","path":"/c4gh-keys/*","action":"(GET)|(POST)|(PUT)"}, + {"role":"submission","path":"/dataset/create","action":"POST"}, + {"role":"submission","path":"/dataset/release/*dataset","action":"POST"}, + {"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":"dummy","rolebinding":"admin"}]}`) } func (suite *TestSuite) TearDownSuite() { assert.NoError(suite.T(), os.RemoveAll(suite.Path)) @@ -418,43 +431,21 @@ func (suite *TestSuite) TestDatabasePingCheck() { assert.NoError(suite.T(), checkDB(db, 1*time.Second), "ping should succeed") } -func (suite *TestSuite) TestAPIAuthenticate() { - gin.SetMode(gin.ReleaseMode) - r := gin.Default() - r.GET("/files", func(c *gin.Context) { - getFiles(c) - }) - ts := httptest.NewServer(r) - defer ts.Close() - filesURL := ts.URL + "/files" - client := &http.Client{} - - assert.NoError(suite.T(), setupJwtAuth()) - - requestURL, err := url.Parse(filesURL) - assert.NoError(suite.T(), err) - - // No credentials - resp, err := http.Get(requestURL.String()) - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), http.StatusUnauthorized, resp.StatusCode) - defer resp.Body.Close() - - // Valid credentials - - req, err := http.NewRequest("GET", filesURL, nil) - assert.NoError(suite.T(), err) - req.Header.Add("Authorization", "Bearer "+suite.Token) - resp, err = client.Do(req) - assert.Equal(suite.T(), http.StatusOK, resp.StatusCode) - assert.NoError(suite.T(), err) - defer resp.Body.Close() -} - func (suite *TestSuite) TestAPIGetFiles() { gin.SetMode(gin.ReleaseMode) r := gin.Default() - r.GET("/files", func(c *gin.Context) { + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } + + r.GET("/files", rbac(e), func(c *gin.Context) { getFiles(c) }) ts := httptest.NewServer(r) @@ -551,68 +542,134 @@ func testEndpoint(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) } -func (suite *TestSuite) TestIsAdmin_NoToken() { +func (suite *TestSuite) TestRBAC() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } // Mock request and response holders w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/", nil) + r := httptest.NewRequest("GET", "/c4gh-keys/list", nil) + r.Header.Add("Authorization", "Bearer "+suite.Token) + _, router := gin.CreateTestContext(w) - router.GET("/", isAdmin(), testEndpoint) + router.GET("/c4gh-keys/list", rbac(e), testEndpoint) - // no token should not be allowed router.ServeHTTP(w, r) - badResponse := w.Result() - defer badResponse.Body.Close() - b, _ := io.ReadAll(badResponse.Body) - assert.Equal(suite.T(), http.StatusUnauthorized, badResponse.StatusCode) - assert.Contains(suite.T(), string(b), "no access token supplied") + okResponse := w.Result() + defer okResponse.Body.Close() + b, _ := io.ReadAll(okResponse.Body) + assert.Equal(suite.T(), http.StatusOK, okResponse.StatusCode) + assert.Contains(suite.T(), string(b), "ok") } -func (suite *TestSuite) TestIsAdmin_BadUser() { + +func (suite *TestSuite) TestRBAC_badUser() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"foo", "bar"} + Conf.API.RBACpolicy = []byte(`{"policy":[{"role":"admin","path":"/admin/*","action":"(GET)|(POST)|(PUT)"}], + "roles":[{"role":"admin","rolebinding":"submission"}, + {"role":"dummy","rolebinding":"submission"}]}`) + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&Conf.API.RBACpolicy)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } + // Mock request and response holders + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/admin/list-users", nil) + r.Header.Add("Authorization", "Bearer "+suite.Token) + + _, router := gin.CreateTestContext(w) + router.GET("/admin/list-users", rbac(e), testEndpoint) + router.ServeHTTP(w, r) + okResponse := w.Result() + defer okResponse.Body.Close() + assert.Equal(suite.T(), http.StatusUnauthorized, okResponse.StatusCode) +} + +func (suite *TestSuite) TestRBAC_noToken() { + gin.SetMode(gin.ReleaseMode) + assert.NoError(suite.T(), setupJwtAuth()) + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&[]byte{})) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } // Mock request and response holders w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) + _, router := gin.CreateTestContext(w) - router.GET("/", isAdmin(), testEndpoint) + router.GET("/", rbac(e), testEndpoint) - // non admin user should not be allowed - r.Header.Add("Authorization", "Bearer "+suite.Token) router.ServeHTTP(w, r) - notAdmin := w.Result() - defer notAdmin.Body.Close() - b, _ := io.ReadAll(notAdmin.Body) - assert.Equal(suite.T(), http.StatusUnauthorized, notAdmin.StatusCode) - assert.Contains(suite.T(), string(b), "not authorized") + okResponse := w.Result() + defer okResponse.Body.Close() + b, _ := io.ReadAll(okResponse.Body) + assert.Equal(suite.T(), http.StatusUnauthorized, okResponse.StatusCode) + assert.Contains(suite.T(), string(b), "no access token supplied") } -func (suite *TestSuite) TestIsAdmin() { + +func (suite *TestSuite) TestRBAC_emptyPolicy() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"foo", "bar", "dummy"} - + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&[]byte{})) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } // Mock request and response holders w := httptest.NewRecorder() - r := httptest.NewRequest("GET", "/", nil) + r := httptest.NewRequest("GET", "/files", nil) r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.GET("/", isAdmin(), testEndpoint) + router.GET("/files", rbac(e), testEndpoint) router.ServeHTTP(w, r) okResponse := w.Result() defer okResponse.Body.Close() b, _ := io.ReadAll(okResponse.Body) - assert.Equal(suite.T(), http.StatusOK, okResponse.StatusCode) - assert.Contains(suite.T(), string(b), "ok") + assert.Equal(suite.T(), http.StatusUnauthorized, okResponse.StatusCode) + assert.Contains(suite.T(), string(b), "not authorized") } - func (suite *TestSuite) TestIngestFile() { user := "dummy" filePath := "/inbox/dummy/file10.c4gh" + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } fileID, err := Conf.API.DB.RegisterFile(filePath, user) assert.NoError(suite.T(), err, "failed to register file in database") @@ -621,7 +678,6 @@ func (suite *TestSuite) TestIngestFile() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" type ingest struct { @@ -632,9 +688,10 @@ func (suite *TestSuite) TestIngestFile() { // Mock request and response holders w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodPost, "/file/ingest", bytes.NewBuffer(ingestMsg)) + r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/file/ingest", ingestFile) + router.POST("/file/ingest", rbac(e), ingestFile) router.ServeHTTP(w, r) okResponse := w.Result() @@ -670,7 +727,7 @@ func (suite *TestSuite) TestIngestFile_NoUser() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.Broker.SchemasPath = "../../schemas/isolated" type ingest struct { @@ -701,7 +758,7 @@ func (suite *TestSuite) TestIngestFile_WrongUser() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.Broker.SchemasPath = "../../schemas/isolated" type ingest struct { @@ -735,7 +792,7 @@ func (suite *TestSuite) TestIngestFile_WrongFilePath() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.Broker.SchemasPath = "../../schemas/isolated" type ingest struct { @@ -749,7 +806,7 @@ func (suite *TestSuite) TestIngestFile_WrongFilePath() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/file/ingest", isAdmin(), ingestFile) + router.POST("/file/ingest", ingestFile) router.ServeHTTP(w, r) okResponse := w.Result() @@ -791,8 +848,17 @@ func (suite *TestSuite) TestSetAccession() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } type accession struct { AccessionID string `json:"accession_id"` @@ -807,7 +873,7 @@ func (suite *TestSuite) TestSetAccession() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/file/accession", isAdmin(), setAccession) + router.POST("/file/accession", rbac(e), setAccession) router.ServeHTTP(w, r) okResponse := w.Result() @@ -835,8 +901,17 @@ func (suite *TestSuite) TestSetAccession() { func (suite *TestSuite) TestSetAccession_WrongUser() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } type accession struct { AccessionID string `json:"accession_id"` @@ -851,36 +926,28 @@ func (suite *TestSuite) TestSetAccession_WrongUser() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/file/accession", isAdmin(), setAccession) + router.POST("/file/accession", rbac(e), setAccession) router.ServeHTTP(w, r) okResponse := w.Result() defer okResponse.Body.Close() assert.Equal(suite.T(), http.StatusBadRequest, okResponse.StatusCode) - - // verify that the message shows up in the queue - time.Sleep(10 * time.Second) // this is needed to ensure we don't get any false negatives - client := http.Client{Timeout: 5 * time.Second} - req, _ := http.NewRequest(http.MethodGet, "http://"+BrokerAPI+"/api/queues/sda/accession", http.NoBody) - req.SetBasicAuth("guest", "guest") - res, err := client.Do(req) - assert.NoError(suite.T(), err, "failed to query broker") - var data struct { - MessagesReady int `json:"messages_ready"` - } - body, err := io.ReadAll(res.Body) - res.Body.Close() - assert.NoError(suite.T(), err, "failed to read response from broker") - err = json.Unmarshal(body, &data) - assert.NoError(suite.T(), err, "failed to unmarshal response") - assert.Equal(suite.T(), 1, data.MessagesReady) } func (suite *TestSuite) TestSetAccession_WrongFormat() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/federated" + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } type accession struct { AccessionID string `json:"accession_id"` @@ -895,29 +962,12 @@ func (suite *TestSuite) TestSetAccession_WrongFormat() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/file/accession", isAdmin(), setAccession) + router.POST("/file/accession", rbac(e), setAccession) router.ServeHTTP(w, r) okResponse := w.Result() defer okResponse.Body.Close() assert.Equal(suite.T(), http.StatusBadRequest, okResponse.StatusCode) - - // verify that the message shows up in the queue - time.Sleep(10 * time.Second) // this is needed to ensure we don't get any false negatives - client := http.Client{Timeout: 5 * time.Second} - req, _ := http.NewRequest(http.MethodGet, "http://"+BrokerAPI+"/api/queues/sda/accession", http.NoBody) - req.SetBasicAuth("guest", "guest") - res, err := client.Do(req) - assert.NoError(suite.T(), err, "failed to query broker") - var data struct { - MessagesReady int `json:"messages_ready"` - } - body, err := io.ReadAll(res.Body) - res.Body.Close() - assert.NoError(suite.T(), err, "failed to read response from broker") - err = json.Unmarshal(body, &data) - assert.NoError(suite.T(), err, "failed to unmarshal response") - assert.Equal(suite.T(), 1, data.MessagesReady) } func (suite *TestSuite) TestCreateDataset() { @@ -955,8 +1005,18 @@ func (suite *TestSuite) TestCreateDataset() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.Broker.SchemasPath = "../../schemas/isolated" + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } accessionMsg, _ := json.Marshal(dataset{AccessionIDs: []string{"API:accession-id-11"}, DatasetID: "API:dataset-01", User: "dummy"}) // Mock request and response holders @@ -965,7 +1025,7 @@ func (suite *TestSuite) TestCreateDataset() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/dataset/create", isAdmin(), createDataset) + router.POST("/dataset/create", rbac(e), createDataset) router.ServeHTTP(w, r) okResponse := w.Result() @@ -1030,8 +1090,18 @@ func (suite *TestSuite) TestCreateDataset_BadFormat() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.Broker.SchemasPath = "../../schemas/federated" + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } accessionMsg, _ := json.Marshal(dataset{AccessionIDs: []string{"API:accession-id-11"}, DatasetID: "API:dataset-01", User: "dummy"}) // Mock request and response holders @@ -1040,7 +1110,7 @@ func (suite *TestSuite) TestCreateDataset_BadFormat() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/dataset/create", isAdmin(), createDataset) + router.POST("/dataset/create", rbac(e), createDataset) router.ServeHTTP(w, r) response := w.Result() @@ -1055,7 +1125,7 @@ func (suite *TestSuite) TestCreateDataset_BadFormat() { func (suite *TestSuite) TestCreateDataset_MissingAccessionIDs() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.Broker.SchemasPath = "../../schemas/isolated" accessionMsg, _ := json.Marshal(dataset{AccessionIDs: []string{}, DatasetID: "failure", User: "dummy"}) @@ -1065,7 +1135,7 @@ func (suite *TestSuite) TestCreateDataset_MissingAccessionIDs() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/dataset/create", isAdmin(), createDataset) + router.POST("/dataset/create", createDataset) router.ServeHTTP(w, r) response := w.Result() @@ -1080,7 +1150,6 @@ func (suite *TestSuite) TestCreateDataset_MissingAccessionIDs() { func (suite *TestSuite) TestCreateDataset_WrongIDs() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" accessionMsg, _ := json.Marshal(dataset{AccessionIDs: []string{"API:accession-id-11"}, DatasetID: "API:dataset-01", User: "dummy"}) @@ -1090,7 +1159,7 @@ func (suite *TestSuite) TestCreateDataset_WrongIDs() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/dataset/create", isAdmin(), createDataset) + router.POST("/dataset/create", createDataset) router.ServeHTTP(w, r) response := w.Result() @@ -1140,7 +1209,6 @@ func (suite *TestSuite) TestCreateDataset_WrongUser() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" accessionMsg, _ := json.Marshal(dataset{AccessionIDs: []string{"API:accession-id-11"}, DatasetID: "API:dataset-01", User: "tester"}) @@ -1150,7 +1218,7 @@ func (suite *TestSuite) TestCreateDataset_WrongUser() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/dataset/create", isAdmin(), createDataset) + router.POST("/dataset/create", createDataset) router.ServeHTTP(w, r) response := w.Result() @@ -1200,16 +1268,25 @@ func (suite *TestSuite) TestReleaseDataset() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} - Conf.Broker.SchemasPath = "../../schemas/isolated" + Conf.Broker.SchemasPath = "../../schemas/isolated" + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } // Mock request and response holders w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodPost, "/dataset/release/API:dataset-01", http.NoBody) r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/dataset/release/*dataset", isAdmin(), releaseDataset) + router.POST("/dataset/release/*dataset", rbac(e), releaseDataset) router.ServeHTTP(w, r) okResponse := w.Result() @@ -1245,8 +1322,18 @@ func (suite *TestSuite) TestReleaseDataset_NoDataset() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.Broker.SchemasPath = "../../schemas/isolated" + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } // Mock request and response holders w := httptest.NewRecorder() @@ -1254,7 +1341,7 @@ func (suite *TestSuite) TestReleaseDataset_NoDataset() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/dataset/release/*dataset", isAdmin(), releaseDataset) + router.POST("/dataset/release/*dataset", rbac(e), releaseDataset) router.ServeHTTP(w, r) okResponse := w.Result() @@ -1265,8 +1352,18 @@ func (suite *TestSuite) TestReleaseDataset_NoDataset() { func (suite *TestSuite) TestReleaseDataset_BadDataset() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.Broker.SchemasPath = "../../schemas/isolated" + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } // Mock request and response holders w := httptest.NewRecorder() @@ -1274,7 +1371,7 @@ func (suite *TestSuite) TestReleaseDataset_BadDataset() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/dataset/release/*dataset", isAdmin(), releaseDataset) + router.POST("/dataset/release/*dataset", rbac(e), releaseDataset) router.ServeHTTP(w, r) response := w.Result() @@ -1314,8 +1411,18 @@ func (suite *TestSuite) TestReleaseDataset_DeprecatedDataset() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.Broker.SchemasPath = "../../schemas/isolated" + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } // Mock request and response holders w := httptest.NewRecorder() @@ -1323,7 +1430,7 @@ func (suite *TestSuite) TestReleaseDataset_DeprecatedDataset() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/dataset/release/*dataset", isAdmin(), releaseDataset) + router.POST("/dataset/release/*dataset", rbac(e), releaseDataset) router.ServeHTTP(w, r) response := w.Result() @@ -1360,7 +1467,16 @@ func (suite *TestSuite) TestListActiveUsers() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } // Mock request and response holders w := httptest.NewRecorder() @@ -1368,7 +1484,7 @@ func (suite *TestSuite) TestListActiveUsers() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.GET("/users", isAdmin(), listActiveUsers) + router.GET("/users", rbac(e), listActiveUsers) router.ServeHTTP(w, r) okResponse := w.Result() @@ -1376,7 +1492,7 @@ func (suite *TestSuite) TestListActiveUsers() { assert.Equal(suite.T(), http.StatusOK, okResponse.StatusCode) var users []string - err := json.NewDecoder(okResponse.Body).Decode(&users) + err = json.NewDecoder(okResponse.Body).Decode(&users) assert.NoError(suite.T(), err, "failed to list users from DB") assert.Equal(suite.T(), []string{"User-B", "User-C"}, users) } @@ -1410,7 +1526,16 @@ func (suite *TestSuite) TestListUserFiles() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } // Mock request and response holders w := httptest.NewRecorder() @@ -1418,7 +1543,7 @@ func (suite *TestSuite) TestListUserFiles() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.GET("/users/:username/files", isAdmin(), listUserFiles) + router.GET("/users/:username/files", rbac(e), listUserFiles) router.ServeHTTP(w, r) okResponse := w.Result() @@ -1434,10 +1559,19 @@ func (suite *TestSuite) TestListUserFiles() { func (suite *TestSuite) TestAddC4ghHash() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } r := gin.Default() - r.POST("/c4gh-keys/add", isAdmin(), addC4ghHash) + r.POST("/c4gh-keys/add", rbac(e), addC4ghHash) ts := httptest.NewServer(r) defer ts.Close() @@ -1472,10 +1606,19 @@ func (suite *TestSuite) TestAddC4ghHash() { func (suite *TestSuite) TestAddC4ghHash_emptyJson() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } r := gin.Default() - r.POST("/c4gh-keys/add", isAdmin(), addC4ghHash) + r.POST("/c4gh-keys/add", rbac(e), addC4ghHash) ts := httptest.NewServer(r) defer ts.Close() @@ -1499,10 +1642,18 @@ func (suite *TestSuite) TestAddC4ghHash_emptyJson() { func (suite *TestSuite) TestAddC4ghHash_notBase64() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} - + m, err := model.NewModelFromString(jsonadapter.Model) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC model") + } + e, err := casbin.NewEnforcer(m, jsonadapter.NewAdapter(&suite.RBAC)) + if err != nil { + suite.T().Logf("failure: %v", err) + suite.FailNow("failed to setup RBAC enforcer") + } r := gin.Default() - r.POST("/c4gh-keys/add", isAdmin(), addC4ghHash) + r.POST("/c4gh-keys/add", rbac(e), addC4ghHash) ts := httptest.NewServer(r) defer ts.Close() @@ -1535,10 +1686,9 @@ func (suite *TestSuite) TestListC4ghHashes() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} r := gin.Default() - r.GET("/c4gh-keys/list", isAdmin(), listC4ghHashes) + r.GET("/c4gh-keys/list", listC4ghHashes) ts := httptest.NewServer(r) defer ts.Close() @@ -1571,10 +1721,9 @@ func (suite *TestSuite) TestDeprecateC4ghHash() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} r := gin.Default() - r.POST("/c4gh-keys/deprecate/*keyHash", isAdmin(), deprecateC4ghHash) + r.POST("/c4gh-keys/deprecate/*keyHash", deprecateC4ghHash) ts := httptest.NewServer(r) defer ts.Close() @@ -1602,10 +1751,9 @@ func (suite *TestSuite) TestDeprecateC4ghHash_wrongHash() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} r := gin.Default() - r.POST("/c4gh-keys/deprecate/*keyHash", isAdmin(), deprecateC4ghHash) + r.POST("/c4gh-keys/deprecate/*keyHash", deprecateC4ghHash) ts := httptest.NewServer(r) defer ts.Close() diff --git a/sda/go.mod b/sda/go.mod index 7333bd710..0c4d501aa 100644 --- a/sda/go.mod +++ b/sda/go.mod @@ -9,6 +9,7 @@ require ( github.com/aws/aws-sdk-go-v2/feature/s3/manager v1.17.38 github.com/aws/aws-sdk-go-v2/service/s3 v1.67.0 github.com/aws/smithy-go v1.22.1 + github.com/casbin/casbin/v2 v2.100.0 github.com/coreos/go-oidc/v3 v3.11.0 github.com/gin-gonic/gin v1.10.0 github.com/google/uuid v1.6.0 @@ -62,8 +63,10 @@ require ( github.com/aws/aws-sdk-go-v2/service/ssooidc v1.28.5 // indirect github.com/aws/aws-sdk-go-v2/service/sts v1.33.1 // indirect github.com/aymerick/douceur v0.2.0 // indirect + github.com/bmatcuk/doublestar/v4 v4.6.1 // indirect github.com/bytedance/sonic v1.12.2 // indirect github.com/bytedance/sonic/loader v0.2.0 // indirect + github.com/casbin/govaluate v1.2.0 // indirect github.com/cenkalti/backoff v2.2.1+incompatible // indirect github.com/cenkalti/backoff/v4 v4.3.0 // indirect github.com/cloudwego/base64x v0.1.4 // indirect diff --git a/sda/go.sum b/sda/go.sum index 4da43edab..f212f5d48 100644 --- a/sda/go.sum +++ b/sda/go.sum @@ -64,11 +64,17 @@ github.com/aws/smithy-go v1.22.1 h1:/HPHZQ0g7f4eUeK6HKglFz8uwVfZKgoI25rb/J+dnro= github.com/aws/smithy-go v1.22.1/go.mod h1:irrKGvNn1InZwb2d7fkIRNucdfwR8R+Ts3wxYa/cJHg= github.com/aymerick/douceur v0.2.0 h1:Mv+mAeH1Q+n9Fr+oyamOlAkUNPWPlA8PPGR0QAaYuPk= github.com/aymerick/douceur v0.2.0/go.mod h1:wlT5vV2O3h55X9m7iVYN0TBM0NH/MmbLnd30/FjWUq4= +github.com/bmatcuk/doublestar/v4 v4.6.1 h1:FH9SifrbvJhnlQpztAx++wlkk70QBf0iBWDwNy7PA4I= +github.com/bmatcuk/doublestar/v4 v4.6.1/go.mod h1:xBQ8jztBU6kakFMg+8WGxn0c6z1fTSPVIjEY1Wr7jzc= github.com/bytedance/sonic v1.12.2 h1:oaMFuRTpMHYLpCntGca65YWt5ny+wAceDERTkT2L9lg= github.com/bytedance/sonic v1.12.2/go.mod h1:B8Gt/XvtZ3Fqj+iSKMypzymZxw/FVwgIGKzMzT9r/rk= github.com/bytedance/sonic/loader v0.1.1/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= github.com/bytedance/sonic/loader v0.2.0 h1:zNprn+lsIP06C/IqCHs3gPQIvnvpKbbxyXQP1iU4kWM= github.com/bytedance/sonic/loader v0.2.0/go.mod h1:ncP89zfokxS5LZrJxl5z0UJcsk4M4yY2JpfqGeCtNLU= +github.com/casbin/casbin/v2 v2.100.0 h1:aeugSNjjHfCrgA22nHkVvw2xsscboHv5r0a13ljQKGQ= +github.com/casbin/casbin/v2 v2.100.0/go.mod h1:LO7YPez4dX3LgoTCqSQAleQDo0S0BeZBDxYnPUl95Ng= +github.com/casbin/govaluate v1.2.0 h1:wXCXFmqyY+1RwiKfYo3jMKyrtZmOL3kHwaqDyCPOYak= +github.com/casbin/govaluate v1.2.0/go.mod h1:G/UnbIjZk/0uMNaLwZZmFQrR72tYRZWQkO70si/iR7A= github.com/cenkalti/backoff v2.2.1+incompatible h1:tNowT99t7UNflLxfYYSlKYsBpXdEet03Pg2g16Swow4= github.com/cenkalti/backoff v2.2.1+incompatible/go.mod h1:90ReRw6GdpyfrHakVjL/QHaoyV4aDUVVkXQJJJ3NXXM= github.com/cenkalti/backoff/v4 v4.3.0 h1:MyRJ/UdXutAwSAT+s3wNd7MfTIcy71VQueUuFK343L8= @@ -140,6 +146,8 @@ github.com/gogo/protobuf v1.3.2 h1:Ov1cvc58UF3b5XjBnZv7+opcTcQFZebYjWzi34vdm4Q= github.com/gogo/protobuf v1.3.2/go.mod h1:P1XiOD3dCwIKUDQYPy72D8LYyHL2YPYrpS2s69NZV8Q= github.com/golang-jwt/jwt/v5 v5.2.1 h1:OuVbFODueb089Lh128TAcimifWaLhJwVflnrgM17wHk= github.com/golang-jwt/jwt/v5 v5.2.1/go.mod h1:pqrtFR0X4osieyHYxtmOUWsAWrfe1Q5UVIyoH402zdk= +github.com/golang/mock v1.4.4 h1:l75CXGRSwbaYNpl/Z2X1XIIAMSCquvXgpVZDhwEIJsc= +github.com/golang/mock v1.4.4/go.mod h1:l3mdAwkq5BuhzHwde/uurv3sEJeZMXNpwsxVWU71h+4= github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= github.com/golang/snappy v0.0.4 h1:yAGX7huGHXlcLOEtBnF4w7FQwA26wojNCwOYAEhLjQM= @@ -460,6 +468,7 @@ golang.org/x/time v0.6.0 h1:eTDhh4ZXt5Qf0augr54TN6suAUudPcawVZeIAPU7D4U= golang.org/x/time v0.6.0/go.mod h1:3BpzKBy/shNhVucY/MWOyx10tF3SFh9QdLuxbVysPQM= golang.org/x/tools v0.0.0-20180917221912-90fa682c2a6e/go.mod h1:n7NCudcB/nEzxVGmLbDWY5pfWTLqBcC2KZ6jyYvM4mQ= golang.org/x/tools v0.0.0-20190328211700-ab21143f2384/go.mod h1:LCzVGOaR6xXOjkQ3onu1FJEFr0SW1gC7cKk1uF8kGRs= +golang.org/x/tools v0.0.0-20190425150028-36563e24a262/go.mod h1:RgjU9mgBXZiqYHBnxXauZ1Gv1EHHAz9KjViQ78xBX0Q= golang.org/x/tools v0.0.0-20191119224855-298f0cb1881e/go.mod h1:b+2E5dAYhXwXZwtnZ6UAqBI28+e2cm9otk0dWdXHAEo= golang.org/x/tools v0.0.0-20200619180055-7c47624df98f/go.mod h1:EkVYQZoAsY45+roYkvgYkIh4xh/qjgUK9TdY2XT94GE= golang.org/x/tools v0.0.0-20210106214847-113979e3529a/go.mod h1:emZCQorbCU4vsT4fOWvOPXz4eW1wZW4PmDk9uLelYpA= diff --git a/sda/internal/config/config.go b/sda/internal/config/config.go index dc326fa75..7c53d4ff7 100644 --- a/sda/internal/config/config.go +++ b/sda/internal/config/config.go @@ -3,7 +3,6 @@ package config import ( "crypto/tls" "crypto/x509" - "encoding/json" "fmt" "os" "strings" @@ -75,7 +74,7 @@ type SyncAPIConf struct { } type APIConf struct { - Admins []string + RBACpolicy []byte CACert string ServerCert string ServerKey string @@ -204,6 +203,7 @@ func NewConfig(app string) (*Config, error) { switch app { case "api": requiredConfVars = []string{ + "api.rbacFile", "broker.host", "broker.port", "broker.user", @@ -467,20 +467,9 @@ func NewConfig(app string) (*Config, error) { if err != nil { return nil, err } - if viper.IsSet("admin.usersFile") { - admins, err := os.ReadFile(viper.GetString("admin.usersFile")) - if err != nil { - return nil, err - } - - if err := json.Unmarshal(admins, &c.API.Admins); err != nil { - return nil, err - } - } - - // This is mainly for convenience when testing stuff - if viper.IsSet("admin.users") { - c.API.Admins = append(c.API.Admins, strings.Split(string(viper.GetString("admin.users")), ",")...) + c.API.RBACpolicy, err = os.ReadFile(viper.GetString("api.rbacFile")) + if err != nil { + return nil, err } c.configSchemas() case "auth": diff --git a/sda/internal/config/config_test.go b/sda/internal/config/config_test.go index 0d33516cf..3b877cb1b 100644 --- a/sda/internal/config/config_test.go +++ b/sda/internal/config/config_test.go @@ -33,6 +33,21 @@ func (suite *ConfigTestSuite) SetupTest() { certPath, _ = os.MkdirTemp("", "gocerts") helper.MakeCerts(certPath) + rbacFile, err := os.CreateTemp(certPath, "admins") + assert.NoError(suite.T(), err) + rbac := []byte(`{"policy":[ +{"role":"admin","path":"/c4gh-keys/*","action":"(GET)|(POST)|(PUT)"}, +{"role":"submission","path":"/dataset/create","action":"POST"}, +{"role":"submission","path":"/dataset/release/*dataset","action":"POST"}, +{"role":"submission","path":"/file/ingest","action":"POST"}, +{"role":"submission","path":"/file/accession","action":"POST"}], +"roles":[{"role":"admin","rolebinding":"submission"}, +{"role":"dummy@example.org","rolebinding":"admin"}, +{"role":"foo@example.org","rolebinding":"submission"}]}`) + _, err = rbacFile.Write(rbac) + assert.NoError(suite.T(), err) + + viper.Set("api.rbacFile", rbacFile.Name()) viper.Set("broker.host", "testhost") viper.Set("broker.port", 123) viper.Set("broker.user", "testuser") @@ -224,6 +239,8 @@ func (suite *ConfigTestSuite) TestAPIConfiguration() { assert.Equal(suite.T(), true, config.API.Session.HTTPOnly) assert.Equal(suite.T(), "api_session_key", config.API.Session.Name) assert.Equal(suite.T(), -1*time.Second, config.API.Session.Expiration) + rbac, _ := os.ReadFile(viper.GetString("api.rbacFile")) + assert.Equal(suite.T(), rbac, config.API.RBACpolicy) viper.Reset() suite.SetupTest() @@ -242,27 +259,6 @@ func (suite *ConfigTestSuite) TestAPIConfiguration() { assert.Equal(suite.T(), false, config.API.Session.Secure) assert.Equal(suite.T(), "test", config.API.Session.Domain) assert.Equal(suite.T(), 60*time.Second, config.API.Session.Expiration) - - viper.Reset() - suite.SetupTest() - adminFile, err := os.CreateTemp("", "admins") - assert.NoError(suite.T(), err) - _, err = adminFile.Write([]byte(`["foo@example.com","bar@example.com","baz@example.com"]`)) - assert.NoError(suite.T(), err) - - viper.Set("admin.usersFile", adminFile.Name()) - cFile, err := NewConfig("api") - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), []string{"foo@example.com", "bar@example.com", "baz@example.com"}, cFile.API.Admins) - - os.Remove(adminFile.Name()) - - viper.Reset() - suite.SetupTest() - viper.Set("admin.users", "foo@bar.com,bar@foo.com") - cList, err := NewConfig("api") - assert.NoError(suite.T(), err) - assert.Equal(suite.T(), []string{"foo@bar.com", "bar@foo.com"}, cList.API.Admins) } func (suite *ConfigTestSuite) TestNotifyConfiguration() { diff --git a/sda/internal/jsonadapter/jsonadapter.go b/sda/internal/jsonadapter/jsonadapter.go new file mode 100644 index 000000000..970fbc9e9 --- /dev/null +++ b/sda/internal/jsonadapter/jsonadapter.go @@ -0,0 +1,135 @@ +// Custom JOSN parser for the RBAC library used by the api + +package jsonadapter + +import ( + "encoding/json" + "errors" + "fmt" + + "github.com/casbin/casbin/v2/model" + "github.com/casbin/casbin/v2/persist" +) + +type Adapter struct { + source *[]byte + policy []CasbinRule +} + +type CasbinRule struct { + PType string + V0 string + V1 string + V2 string +} + +type jsonPolicy struct { + Policy []policy + Roles []roles +} + +type policy struct { + Action string `json:"action"` + Path string `json:"path"` + Role string `json:"role"` +} + +type roles struct { + Role string `json:"role"` + RoleBinding string `json:"rolebinding"` +} + +const Model = `[request_definition] +r = sub, obj, act +[policy_definition] +p = sub, obj, act +[role_definition] +g = _, _ +[policy_effect] +e = some(where (p.eft == allow)) +[matchers] +m = (g(r.sub, p.sub) || p.sub == "*") && (keyMatch(r.obj, p.obj)||keyMatch2(r.obj, p.obj)) && regexMatch(r.act, p.act)` + +func NewAdapter(source *[]byte) *Adapter { + return &Adapter{policy: []CasbinRule{}, source: source} +} + +func (a *Adapter) LoadPolicy(model model.Model) error { + err := a.loadFromBuffer(model) + if err != nil { + return err + } + + return nil +} + +func (a *Adapter) loadFromBuffer(model model.Model) error { + if len(*a.source) == 0 { + return nil + } + + var input jsonPolicy + err := json.Unmarshal(*a.source, &input) + if err != nil { + return err + } + + for _, p := range input.Policy { + if err := persist.LoadPolicyLine(fmt.Sprintf("p,%s,%s,%s", p.Role, p.Path, p.Action), model); err != nil { + return err + } + } + + for _, r := range input.Roles { + if err := persist.LoadPolicyLine(fmt.Sprintf("g,%s,%s", r.Role, r.RoleBinding), model); err != nil { + return err + } + } + + return nil +} + +// AddPolicy adds a policy rule to the storage. +func (a *Adapter) AddPolicy(_ string, _ string, _ []string) error { + return errors.New("not implemented") +} + +// RemovePolicy removes a policy rule from the storage. +func (a *Adapter) RemovePolicy(_ string, _ string, _ []string) error { + return errors.New("not implemented") +} + +// RemoveFilteredPolicy removes policy rules that match the filter from the storage. +func (a *Adapter) RemoveFilteredPolicy(_ string, _ string, _ int, _ ...string) error { + return errors.New("not implemented") +} + +// SavePolicy saves policy +func (a *Adapter) SavePolicy(model model.Model) error { + a.policy = []CasbinRule{} + var rules []CasbinRule + for ptype, ast := range model["p"] { + for _, rule := range ast.Policy { + rules = append(rules, CasbinRule{PType: ptype, V0: rule[0], V1: rule[1], V2: rule[2]}) + } + } + + for ptype, ast := range model["g"] { + for _, rule := range ast.Policy { + rules = append(rules, CasbinRule{PType: ptype, V0: rule[0], V1: rule[1]}) + } + } + + a.policy = rules + + return a.saveToBuffer() +} + +func (a *Adapter) saveToBuffer() error { + data, err := json.Marshal(a.policy) + if err == nil { + *a.source = data + } + + return err +} diff --git a/sda/internal/jsonadapter/jsonadapter_test.go b/sda/internal/jsonadapter/jsonadapter_test.go new file mode 100644 index 000000000..8aa655cbc --- /dev/null +++ b/sda/internal/jsonadapter/jsonadapter_test.go @@ -0,0 +1,117 @@ +package jsonadapter + +import ( + "fmt" + "os" + "testing" + + "github.com/casbin/casbin/v2" + "github.com/casbin/casbin/v2/model" + "github.com/casbin/casbin/v2/util" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/suite" +) + +type AdapterTestSuite struct { + suite.Suite + Model model.Model + EmptyPolicy []byte + ExpectedGroups [][]string + ExpectedPolicy [][]string + DefaultPolicy []byte + File *os.File +} + +func TestConfigTestSuite(t *testing.T) { + suite.Run(t, new(AdapterTestSuite)) +} + +func (suite *AdapterTestSuite) SetupSuite() { + suite.Model, _ = model.NewModelFromString(Model) + suite.EmptyPolicy = []byte(`{"policy":[],"roles":[]}`) + suite.DefaultPolicy = []byte(`{"policy":[{"role":"admin","path":"/keys/*","action":"(GET)|(POST)|(PUT)"}, + {"role":"submission","path":"/dataset/create","action":"POST"}, + {"role":"submission","path":"/dataset/release/*dataset","action":"POST"}, + {"role":"submission","path":"/file/ingest","action":"POST"}, + {"role":"submission","path":"/file/accession","action":"POST"}], + "roles":[{"role":"admin","rolebinding":"submission"}, + {"role":"dummy@example.org","rolebinding":"admin"}, + {"role":"foo@example.org","rolebinding":"submission"}]}`) + suite.ExpectedPolicy = [][]string{{"admin", "/keys/*", "(GET)|(POST)|(PUT)"}, + {"submission", "/dataset/create", "POST"}, + {"submission", "/dataset/release/*dataset", "POST"}, + {"submission", "/file/ingest", "POST"}, + {"submission", "/file/accession", "POST"}} + suite.ExpectedGroups = [][]string{{"admin", "submission"}, {"dummy@example.org", "admin"}, {"foo@example.org", "submission"}} + + suite.File, _ = os.CreateTemp("", "policy") + _, err := suite.File.Write(suite.DefaultPolicy) + if err != nil { + suite.FailNow("failed to write policy file to disk") + } + +} + +func (suite *AdapterTestSuite) TearDownSuite() { + os.RemoveAll(suite.File.Name()) +} + +func (suite *AdapterTestSuite) TestAdapter_empty() { + a := NewAdapter(&suite.EmptyPolicy) + e, err := casbin.NewEnforcer(suite.Model, a) + assert.NoError(suite.T(), err, "New enforcer failed on empty policy") + p, err := e.GetPolicy() + assert.NoError(suite.T(), err, "failed to get policy") + assert.Equal(suite.T(), [][]string(nil), p) +} + +func (suite *AdapterTestSuite) TestAdapter() { + a := NewAdapter(&suite.DefaultPolicy) + e, err := casbin.NewEnforcer(suite.Model, a) + assert.NoError(suite.T(), err, "New enforcer failed withpolicy") + p, err := e.GetPolicy() + assert.Equal(suite.T(), len(suite.ExpectedPolicy), len(p)) + assert.NoError(suite.T(), err, "failed to get policy") + assert.True(suite.T(), util.Array2DEquals(suite.ExpectedPolicy, p), fmt.Sprintf("Policy: %v, supposed to be %v", p, suite.ExpectedPolicy)) + + g, err := e.GetGroupingPolicy() + assert.NoError(suite.T(), err, "failed to get groups") + assert.True(suite.T(), util.Array2DEquals(suite.ExpectedGroups, g), fmt.Sprintf("Groups: %v, supposed to be %v", g, suite.ExpectedGroups)) +} + +func (suite *AdapterTestSuite) TestAdapter_fromFile() { + b, err := os.ReadFile(suite.File.Name()) + assert.NoError(suite.T(), err, "failed to read json file") + e, err := casbin.NewEnforcer(suite.Model, NewAdapter(&b)) + assert.NoError(suite.T(), err, "New enforcer failed with policy from file: %s", suite.File.Name()) + p, err := e.GetPolicy() + assert.Equal(suite.T(), len(suite.ExpectedPolicy), len(p)) + assert.NoError(suite.T(), err, "failed to get policy") + assert.True(suite.T(), util.Array2DEquals(suite.ExpectedPolicy, p), fmt.Sprintf("Policy: %v, supposed to be %v", p, suite.ExpectedPolicy)) + + g, err := e.GetGroupingPolicy() + assert.NoError(suite.T(), err, "failed to get groups") + assert.True(suite.T(), util.Array2DEquals(suite.ExpectedGroups, g), fmt.Sprintf("Groups: %v, supposed to be %v", g, suite.ExpectedGroups)) +} + +func (suite *AdapterTestSuite) TestAdapter_save() { + a := NewAdapter(&suite.EmptyPolicy) + e, err := casbin.NewEnforcer(suite.Model, a) + assert.NoError(suite.T(), err, "New enforcer failed without policy") + + _, err = e.AddPolicy("foo", "/data/*", "GET") + assert.NoError(suite.T(), err, "failed to add policy") + + _, err = e.AddPolicy("foo", "/data/:value", "POST") + assert.NoError(suite.T(), err, "failed to add policy") + + assert.NoError(suite.T(), e.SavePolicy(), "failed to save policy") + assert.Equal(suite.T(), 2, len(a.policy)) +} + +func (suite *AdapterTestSuite) TestAdapter_notImplemented() { + a := NewAdapter(&suite.EmptyPolicy) + assert.Error(suite.T(), a.AddPolicy("", "", []string{""})) + assert.Error(suite.T(), a.RemovePolicy("", "", []string{""})) + assert.Error(suite.T(), a.RemoveFilteredPolicy("", "", 0, "")) +}