diff --git a/sda/cmd/api/api.go b/sda/cmd/api/api.go index b0efe1476..e7ca529c1 100644 --- a/sda/cmd/api/api.go +++ b/sda/cmd/api/api.go @@ -81,14 +81,16 @@ func setup(config *config.Config) *http.Server { r.GET("/ready", readinessResponse) r.GET("/files", 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.GET("/users", isAdmin(), listActiveUsers) // Lists all users - r.GET("/users/:username/files", isAdmin(), listUserFiles) // Lists all unmapped files for a user + if len(config.API.RBAC.Admin) > 0 || len(config.API.RBAC.Helpdesk) > 0 { + r.POST("/file/ingest", rbac([]string{"admin", "helpdesk"}), ingestFile) // start ingestion of a file + r.POST("/file/accession", rbac([]string{"admin", "helpdesk"}), setAccession) // assign accession ID to a file + r.POST("/dataset/create", rbac([]string{"admin", "helpdesk"}), createDataset) // maps a set of files to a dataset + r.POST("/dataset/release/*dataset", rbac([]string{"admin", "helpdesk"}), releaseDataset) // Releases a dataset to be accessible + r.GET("/users", rbac([]string{"admin", "helpdesk"}), listActiveUsers) // Lists all users + r.GET("/users/:username/files", rbac([]string{"admin", "helpdesk"}), listUserFiles) // Lists all unmapped files for a user + } + if len(config.API.RBAC.Admin) > 0 { + r.POST("/c4gh-keys/add", rbac([]string{"admin"}), addC4ghHash) // Adds a key hash to the database } cfg := &tls.Config{MinVersion: tls.VersionTLS12} @@ -195,7 +197,7 @@ func getFiles(c *gin.Context) { c.JSON(200, files) } -func isAdmin() gin.HandlerFunc { +func rbac(roles []string) gin.HandlerFunc { return func(c *gin.Context) { token, err := auth.Authenticate(c.Request) if err != nil { @@ -204,8 +206,27 @@ func isAdmin() gin.HandlerFunc { return } - if !slices.Contains(Conf.API.Admins, token.Subject()) { - log.Debugf("%s is not an admin", token.Subject()) + + authorized := false + for _, role := range roles { + switch { + case role == "admin": + if slices.Contains(Conf.API.RBAC.Admin, token.Subject()) { + authorized = true + + break + } + case role == "helpdesk": + if slices.Contains(Conf.API.RBAC.Helpdesk, token.Subject()) { + authorized = true + + break + } + } + } + + if !authorized { + log.Debugf("%s is not in the list of allowed users", token.Subject()) c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{"error": "not authorized"}) return diff --git a/sda/cmd/api/api_test.go b/sda/cmd/api/api_test.go index 9332893ae..a4837f129 100644 --- a/sda/cmd/api/api_test.go +++ b/sda/cmd/api/api_test.go @@ -546,7 +546,7 @@ func testEndpoint(c *gin.Context) { c.JSON(200, gin.H{"ok": true}) } -func (suite *TestSuite) TestIsAdmin_NoToken() { +func (suite *TestSuite) TestRBAC_NoToken() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) @@ -554,7 +554,7 @@ func (suite *TestSuite) TestIsAdmin_NoToken() { w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) _, router := gin.CreateTestContext(w) - router.GET("/", isAdmin(), testEndpoint) + router.GET("/", rbac([]string{"admin"}), testEndpoint) // no token should not be allowed router.ServeHTTP(w, r) @@ -564,16 +564,16 @@ func (suite *TestSuite) TestIsAdmin_NoToken() { assert.Equal(suite.T(), http.StatusUnauthorized, badResponse.StatusCode) assert.Contains(suite.T(), string(b), "no access token supplied") } -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.RBAC.Admin = []string{"foo", "bar"} // Mock request and response holders w := httptest.NewRecorder() r := httptest.NewRequest("GET", "/", nil) _, router := gin.CreateTestContext(w) - router.GET("/", isAdmin(), testEndpoint) + router.GET("/", rbac([]string{"admin"}), testEndpoint) // non admin user should not be allowed r.Header.Add("Authorization", "Bearer "+suite.Token) @@ -584,10 +584,31 @@ func (suite *TestSuite) TestIsAdmin_BadUser() { assert.Equal(suite.T(), http.StatusUnauthorized, notAdmin.StatusCode) assert.Contains(suite.T(), string(b), "not authorized") } -func (suite *TestSuite) TestIsAdmin() { +func (suite *TestSuite) TestRBAC_WrongGroup() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"foo", "bar", "dummy"} + Conf.API.RBAC.Helpdesk = []string{"foo", "bar"} + + // Mock request and response holders + w := httptest.NewRecorder() + r := httptest.NewRequest("GET", "/", nil) + _, router := gin.CreateTestContext(w) + router.GET("/", rbac([]string{"admin"}), 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") +} +func (suite *TestSuite) TestRBAC() { + gin.SetMode(gin.ReleaseMode) + assert.NoError(suite.T(), setupJwtAuth()) + Conf.API.RBAC.Admin = []string{"foo", "bar"} + Conf.API.RBAC.Helpdesk = []string{"dummy"} // Mock request and response holders w := httptest.NewRecorder() @@ -595,7 +616,7 @@ func (suite *TestSuite) TestIsAdmin() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.GET("/", isAdmin(), testEndpoint) + router.GET("/", rbac([]string{"admin", "helpdesk"}), testEndpoint) router.ServeHTTP(w, r) okResponse := w.Result() @@ -616,7 +637,7 @@ func (suite *TestSuite) TestIngestFile() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" type ingest struct { @@ -665,7 +686,7 @@ func (suite *TestSuite) TestIngestFile_NoUser() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" type ingest struct { @@ -696,7 +717,7 @@ func (suite *TestSuite) TestIngestFile_WrongUser() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" type ingest struct { @@ -730,7 +751,7 @@ func (suite *TestSuite) TestIngestFile_WrongFilePath() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" type ingest struct { @@ -744,7 +765,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", rbac([]string{"admin"}), ingestFile) router.ServeHTTP(w, r) okResponse := w.Result() @@ -786,7 +807,7 @@ func (suite *TestSuite) TestSetAccession() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" type accession struct { @@ -802,7 +823,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([]string{"admin"}), setAccession) router.ServeHTTP(w, r) okResponse := w.Result() @@ -830,7 +851,7 @@ func (suite *TestSuite) TestSetAccession() { func (suite *TestSuite) TestSetAccession_WrongUser() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" type accession struct { @@ -846,7 +867,7 @@ 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([]string{"admin"}), setAccession) router.ServeHTTP(w, r) okResponse := w.Result() @@ -874,7 +895,7 @@ func (suite *TestSuite) TestSetAccession_WrongUser() { func (suite *TestSuite) TestSetAccession_WrongFormat() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/federated" type accession struct { @@ -890,7 +911,7 @@ 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([]string{"admin"}), setAccession) router.ServeHTTP(w, r) okResponse := w.Result() @@ -950,7 +971,7 @@ func (suite *TestSuite) TestCreateDataset() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" type dataset struct { @@ -964,7 +985,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([]string{"admin"}), createDataset) router.ServeHTTP(w, r) okResponse := w.Result() @@ -991,7 +1012,7 @@ func (suite *TestSuite) TestCreateDataset() { func (suite *TestSuite) TestCreateDataset_BadFormat() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/federated" type dataset struct { @@ -1005,7 +1026,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([]string{"admin"}), createDataset) router.ServeHTTP(w, r) response := w.Result() @@ -1052,7 +1073,7 @@ func (suite *TestSuite) TestReleaseDataset() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" // Mock request and response holders @@ -1061,7 +1082,7 @@ func (suite *TestSuite) TestReleaseDataset() { r.Header.Add("Authorization", "Bearer "+suite.Token) _, router := gin.CreateTestContext(w) - router.POST("/dataset/release/*dataset", isAdmin(), releaseDataset) + router.POST("/dataset/release/*dataset", rbac([]string{"admin"}), releaseDataset) router.ServeHTTP(w, r) okResponse := w.Result() @@ -1097,7 +1118,7 @@ func (suite *TestSuite) TestReleaseDataset_NoDataset() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" // Mock request and response holders @@ -1106,7 +1127,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([]string{"admin"}), releaseDataset) router.ServeHTTP(w, r) okResponse := w.Result() @@ -1117,7 +1138,7 @@ 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.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" // Mock request and response holders @@ -1126,7 +1147,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([]string{"admin"}), releaseDataset) router.ServeHTTP(w, r) response := w.Result() @@ -1166,7 +1187,7 @@ func (suite *TestSuite) TestReleaseDataset_DeprecatedDataset() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} Conf.Broker.SchemasPath = "../../schemas/isolated" // Mock request and response holders @@ -1175,7 +1196,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([]string{"admin"}), releaseDataset) router.ServeHTTP(w, r) response := w.Result() @@ -1212,7 +1233,7 @@ func (suite *TestSuite) TestListActiveUsers() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} // Mock request and response holders w := httptest.NewRecorder() @@ -1220,7 +1241,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([]string{"admin"}), listActiveUsers) router.ServeHTTP(w, r) okResponse := w.Result() @@ -1262,7 +1283,7 @@ func (suite *TestSuite) TestListUserFiles() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} // Mock request and response holders w := httptest.NewRecorder() @@ -1270,7 +1291,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([]string{"admin"}), listUserFiles) router.ServeHTTP(w, r) okResponse := w.Result() @@ -1286,10 +1307,10 @@ func (suite *TestSuite) TestListUserFiles() { func (suite *TestSuite) TestAddC4ghHash() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} r := gin.Default() - r.POST("/c4gh-keys/add", isAdmin(), addC4ghHash) + r.POST("/c4gh-keys/add", rbac([]string{"admin"}), addC4ghHash) ts := httptest.NewServer(r) defer ts.Close() @@ -1324,10 +1345,10 @@ func (suite *TestSuite) TestAddC4ghHash() { func (suite *TestSuite) TestAddC4ghHash_emptyJson() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} r := gin.Default() - r.POST("/c4gh-keys/add", isAdmin(), addC4ghHash) + r.POST("/c4gh-keys/add", rbac([]string{"admin"}), addC4ghHash) ts := httptest.NewServer(r) defer ts.Close() @@ -1351,10 +1372,10 @@ func (suite *TestSuite) TestAddC4ghHash_emptyJson() { func (suite *TestSuite) TestAddC4ghHash_notBase64() { gin.SetMode(gin.ReleaseMode) assert.NoError(suite.T(), setupJwtAuth()) - Conf.API.Admins = []string{"dummy"} + Conf.API.RBAC.Admin = []string{"dummy"} r := gin.Default() - r.POST("/c4gh-keys/add", isAdmin(), addC4ghHash) + r.POST("/c4gh-keys/add", rbac([]string{"admin"}), addC4ghHash) ts := httptest.NewServer(r) defer ts.Close() diff --git a/sda/internal/config/config.go b/sda/internal/config/config.go index dc326fa75..e2d8ef9f0 100644 --- a/sda/internal/config/config.go +++ b/sda/internal/config/config.go @@ -74,8 +74,13 @@ type SyncAPIConf struct { MappingRouting string `default:"mappings"` } +type APIrbac struct { + Admin []string `json:"admin"` + Helpdesk []string `json:"helpdesk"` +} + type APIConf struct { - Admins []string + RBAC APIrbac CACert string ServerCert string ServerKey string @@ -473,15 +478,11 @@ func NewConfig(app string) (*Config, error) { return nil, err } - if err := json.Unmarshal(admins, &c.API.Admins); err != nil { + if err := json.Unmarshal(admins, &c.API.RBAC); 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.configSchemas() case "auth": c.Auth.Cega.AuthURL = viper.GetString("auth.cega.authUrl") diff --git a/sda/internal/config/config_test.go b/sda/internal/config/config_test.go index 0d33516cf..78e4493b2 100644 --- a/sda/internal/config/config_test.go +++ b/sda/internal/config/config_test.go @@ -247,22 +247,16 @@ func (suite *ConfigTestSuite) TestAPIConfiguration() { 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"]`)) + _, err = adminFile.Write([]byte(`{"admin":["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) + rbac := APIrbac{Admin: []string{"foo@example.com", "bar@example.com", "baz@example.com"}} + assert.Equal(suite.T(), rbac, cFile.API.RBAC) 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() {