diff --git a/pkg/models/org_user.go b/pkg/models/org_user.go index f03f5741ab9..a7059f94d06 100644 --- a/pkg/models/org_user.go +++ b/pkg/models/org_user.go @@ -107,6 +107,7 @@ type UpdateOrgUserCommand struct { // QUERIES type GetOrgUsersQuery struct { + UserID int64 OrgId int64 Query string Limit int diff --git a/pkg/services/serviceaccounts/api/api.go b/pkg/services/serviceaccounts/api/api.go index 4d9dd2bc593..0a5510e89df 100644 --- a/pkg/services/serviceaccounts/api/api.go +++ b/pkg/services/serviceaccounts/api/api.go @@ -48,6 +48,7 @@ func (api *ServiceAccountsAPI) RegisterAPIEndpoints( auth := acmiddleware.Middleware(api.accesscontrol) api.RouterRegister.Group("/api/org/serviceaccounts", func(serviceAccountsRoute routing.RouteRegister) { serviceAccountsRoute.Get("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeAll)), routing.Wrap(api.ListServiceAccounts)) + serviceAccountsRoute.Get("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionRead, serviceaccounts.ScopeID)), routing.Wrap(api.RetrieveServiceAccount)) serviceAccountsRoute.Delete("/:serviceAccountId", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionDelete, serviceaccounts.ScopeID)), routing.Wrap(api.DeleteServiceAccount)) serviceAccountsRoute.Get("/upgrade", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate, serviceaccounts.ScopeID)), routing.Wrap(api.UpgradeServiceAccounts)) serviceAccountsRoute.Post("/", auth(middleware.ReqOrgAdmin, accesscontrol.EvalPermission(serviceaccounts.ActionCreate, serviceaccounts.ScopeID)), routing.Wrap(api.CreateServiceAccount)) @@ -122,7 +123,25 @@ func (api *ServiceAccountsAPI) ListTokens(ctx *models.ReqContext) response.Respo func (api *ServiceAccountsAPI) ListServiceAccounts(ctx *models.ReqContext) response.Response { serviceAccounts, err := api.store.ListServiceAccounts(ctx.Req.Context(), ctx.OrgId) if err != nil { - return response.Error(http.StatusInternalServerError, "Failed to list roles", err) + return response.Error(http.StatusInternalServerError, "Failed to list service accounts", err) } return response.JSON(http.StatusOK, serviceAccounts) } + +func (api *ServiceAccountsAPI) RetrieveServiceAccount(ctx *models.ReqContext) response.Response { + scopeID, err := strconv.ParseInt(web.Params(ctx.Req)[":serviceAccountId"], 10, 64) + if err != nil { + return response.Error(http.StatusBadRequest, "serviceAccountId is invalid", err) + } + + serviceAccount, err := api.store.RetrieveServiceAccount(ctx.Req.Context(), ctx.OrgId, scopeID) + if err != nil { + switch { + case errors.Is(err, serviceaccounts.ErrServiceAccountNotFound): + return response.Error(http.StatusNotFound, "Failed to retrieve service account", err) + default: + return response.Error(http.StatusInternalServerError, "Failed to retrieve service account", err) + } + } + return response.JSON(http.StatusOK, serviceAccount) +} diff --git a/pkg/services/serviceaccounts/api/api_test.go b/pkg/services/serviceaccounts/api/api_test.go index bfb22ebfa4c..0586ab4a900 100644 --- a/pkg/services/serviceaccounts/api/api_test.go +++ b/pkg/services/serviceaccounts/api/api_test.go @@ -2,6 +2,7 @@ package api import ( "context" + "encoding/json" "fmt" "net/http" "net/http/httptest" @@ -23,7 +24,7 @@ import ( ) var ( - serviceaccountIDPath = "/api/org/serviceaccounts/%s" + serviceaccountIDPath = "/api/org/serviceaccounts/%v" ) // test the accesscontrol endpoints @@ -56,9 +57,9 @@ func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) { ), expectedCode: http.StatusOK, } - serviceAccountDeletionScenario(t, http.MethodDelete, serviceaccountIDPath, &testcase.user, func(httpmethod string, endpoint string, user *tests.TestUser) { + serviceAccountRequestScenario(t, http.MethodDelete, serviceaccountIDPath, &testcase.user, func(httpmethod string, endpoint string, user *tests.TestUser) { createduser := tests.SetupUserServiceAccount(t, store, testcase.user) - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock) + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store) actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, fmt.Sprint(createduser.Id))).Code require.Equal(t, testcase.expectedCode, actual) }) @@ -80,23 +81,22 @@ func TestServiceAccountsAPI_DeleteServiceAccount(t *testing.T) { ), expectedCode: http.StatusForbidden, } - serviceAccountDeletionScenario(t, http.MethodDelete, serviceaccountIDPath, &testcase.user, func(httpmethod string, endpoint string, user *tests.TestUser) { + serviceAccountRequestScenario(t, http.MethodDelete, serviceaccountIDPath, &testcase.user, func(httpmethod string, endpoint string, user *tests.TestUser) { createduser := tests.SetupUserServiceAccount(t, store, testcase.user) - server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock) - actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, fmt.Sprint(createduser.Id))).Code + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), testcase.acmock, store) + actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, createduser.Id)).Code require.Equal(t, testcase.expectedCode, actual) }) }) } -func serviceAccountDeletionScenario(t *testing.T, httpMethod string, endpoint string, user *tests.TestUser, fn func(httpmethod string, endpoint string, user *tests.TestUser)) { +func serviceAccountRequestScenario(t *testing.T, httpMethod string, endpoint string, user *tests.TestUser, fn func(httpmethod string, endpoint string, user *tests.TestUser)) { t.Helper() fn(httpMethod, endpoint, user) } -func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, routerRegister routing.RouteRegister, acmock *accesscontrolmock.Mock) *web.Mux { - store := sqlstore.InitTestDB(t) - a := NewServiceAccountsAPI(svc, acmock, routerRegister, database.NewServiceAccountsStore(store)) +func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, routerRegister routing.RouteRegister, acmock *accesscontrolmock.Mock, sqlStore *sqlstore.SQLStore) *web.Mux { + a := NewServiceAccountsAPI(svc, acmock, routerRegister, database.NewServiceAccountsStore(sqlStore)) a.RegisterAPIEndpoints(&setting.Cfg{FeatureToggles: map[string]bool{"service-accounts": true}}) m := web.New() @@ -117,3 +117,88 @@ func setupTestServer(t *testing.T, svc *tests.ServiceAccountMock, routerRegister a.RouterRegister.Register(m.Router) return m } + +func TestServiceAccountsAPI_RetrieveServiceAccount(t *testing.T) { + store := sqlstore.InitTestDB(t) + svcmock := tests.ServiceAccountMock{} + type testRetrieveSATestCase struct { + desc string + user *tests.TestUser + expectedCode int + acmock *accesscontrolmock.Mock + userID int + } + testCases := []testRetrieveSATestCase{ + { + desc: "should be ok to retrieve serviceaccount with permissions", + user: &tests.TestUser{Login: "servicetest1@admin", IsServiceAccount: true}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionRead, Scope: serviceaccounts.ScopeAll}}, nil + }, + false, + ), + expectedCode: http.StatusOK, + }, + { + desc: "should be forbidden to retrieve serviceaccount if no permissions", + user: &tests.TestUser{Login: "servicetest2@admin", IsServiceAccount: true}, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{}, nil + }, + false, + ), + expectedCode: http.StatusForbidden, + }, + { + desc: "should be not found when the user doesnt exist", + user: nil, + userID: 12, + acmock: tests.SetupMockAccesscontrol( + t, + func(c context.Context, siu *models.SignedInUser) ([]*accesscontrol.Permission, error) { + return []*accesscontrol.Permission{{Action: serviceaccounts.ActionRead, Scope: serviceaccounts.ScopeAll}}, nil + }, + false, + ), + expectedCode: http.StatusNotFound, + }, + } + + var requestResponse = func(server *web.Mux, httpMethod, requestpath string) *httptest.ResponseRecorder { + req, err := http.NewRequest(httpMethod, requestpath, nil) + require.NoError(t, err) + recorder := httptest.NewRecorder() + server.ServeHTTP(recorder, req) + return recorder + } + + for _, tc := range testCases { + t.Run(tc.desc, func(t *testing.T) { + serviceAccountRequestScenario(t, http.MethodGet, serviceaccountIDPath, tc.user, func(httpmethod string, endpoint string, user *tests.TestUser) { + scopeID := tc.userID + if tc.user != nil { + createdUser := tests.SetupUserServiceAccount(t, store, *tc.user) + scopeID = int(createdUser.Id) + } + server := setupTestServer(t, &svcmock, routing.NewRouteRegister(), tc.acmock, store) + + actual := requestResponse(server, httpmethod, fmt.Sprintf(endpoint, scopeID)) + + actualCode := actual.Code + require.Equal(t, tc.expectedCode, actualCode) + + if actualCode == http.StatusOK { + actualBody := map[string]interface{}{} + err := json.Unmarshal(actual.Body.Bytes(), &actualBody) + require.NoError(t, err) + require.Equal(t, scopeID, int(actualBody["userId"].(float64))) + require.Equal(t, tc.user.Login, actualBody["login"].(string)) + } + }) + }) + } +} diff --git a/pkg/services/serviceaccounts/database/database.go b/pkg/services/serviceaccounts/database/database.go index 0528b57a4ce..95810a5262b 100644 --- a/pkg/services/serviceaccounts/database/database.go +++ b/pkg/services/serviceaccounts/database/database.go @@ -111,3 +111,18 @@ func (s *ServiceAccountsStoreImpl) ListServiceAccounts(ctx context.Context, orgI } return query.Result, err } + +// RetrieveServiceAccountByID returns a service account by its ID +func (s *ServiceAccountsStoreImpl) RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*models.OrgUserDTO, error) { + query := models.GetOrgUsersQuery{UserID: serviceAccountID, OrgId: orgID, IsServiceAccount: true} + err := s.sqlStore.GetOrgUsers(ctx, &query) + if err != nil { + return nil, err + } + + if len(query.Result) != 1 { + return nil, serviceaccounts.ErrServiceAccountNotFound + } + + return query.Result[0], err +} diff --git a/pkg/services/serviceaccounts/database/database_test.go b/pkg/services/serviceaccounts/database/database_test.go index a26dada9a13..799cc1ac8ce 100644 --- a/pkg/services/serviceaccounts/database/database_test.go +++ b/pkg/services/serviceaccounts/database/database_test.go @@ -47,3 +47,36 @@ func setupTestDatabase(t *testing.T) (*sqlstore.SQLStore, *ServiceAccountsStoreI db := sqlstore.InitTestDB(t) return db, NewServiceAccountsStore(db) } + +func TestStore_RetrieveServiceAccount(t *testing.T) { + cases := []struct { + desc string + user tests.TestUser + expectedErr error + }{ + { + desc: "service accounts should exist and get retrieved", + user: tests.TestUser{Login: "servicetest1@admin", IsServiceAccount: true}, + expectedErr: nil, + }, + { + desc: "service accounts is false should not retrieve user", + user: tests.TestUser{Login: "test1@admin", IsServiceAccount: false}, + expectedErr: serviceaccounts.ErrServiceAccountNotFound, + }, + } + + for _, c := range cases { + t.Run(c.desc, func(t *testing.T) { + db, store := setupTestDatabase(t) + user := tests.SetupUserServiceAccount(t, db, c.user) + dto, err := store.RetrieveServiceAccount(context.Background(), user.OrgId, user.Id) + if c.expectedErr != nil { + require.ErrorIs(t, err, c.expectedErr) + } else { + require.NoError(t, err) + require.Equal(t, c.user.Login, dto.Login) + } + }) + } +} diff --git a/pkg/services/serviceaccounts/serviceaccounts.go b/pkg/services/serviceaccounts/serviceaccounts.go index 2d7b44e7715..07f8959e82d 100644 --- a/pkg/services/serviceaccounts/serviceaccounts.go +++ b/pkg/services/serviceaccounts/serviceaccounts.go @@ -15,6 +15,7 @@ type Service interface { type Store interface { CreateServiceAccount(ctx context.Context, saForm *CreateServiceaccountForm) (*models.User, error) ListServiceAccounts(ctx context.Context, orgID int64) ([]*models.OrgUserDTO, error) + RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*models.OrgUserDTO, error) DeleteServiceAccount(ctx context.Context, orgID, serviceAccountID int64) error UpgradeServiceAccounts(ctx context.Context) error ListTokens(ctx context.Context, orgID int64, serviceAccount int64) ([]*models.ApiKey, error) diff --git a/pkg/services/serviceaccounts/tests/common.go b/pkg/services/serviceaccounts/tests/common.go index f933d7be73b..c338e075e18 100644 --- a/pkg/services/serviceaccounts/tests/common.go +++ b/pkg/services/serviceaccounts/tests/common.go @@ -58,6 +58,7 @@ var _ serviceaccounts.Store = new(ServiceAccountsStoreMock) type Calls struct { CreateServiceAccount []interface{} ListServiceAccounts []interface{} + RetrieveServiceAccount []interface{} DeleteServiceAccount []interface{} UpgradeServiceAccounts []interface{} ListTokens []interface{} @@ -92,3 +93,8 @@ func (s *ServiceAccountsStoreMock) ListServiceAccounts(ctx context.Context, orgI s.Calls.ListServiceAccounts = append(s.Calls.ListServiceAccounts, []interface{}{ctx, orgID}) return nil, nil } + +func (s *ServiceAccountsStoreMock) RetrieveServiceAccount(ctx context.Context, orgID, serviceAccountID int64) (*models.OrgUserDTO, error) { + s.Calls.RetrieveServiceAccount = append(s.Calls.RetrieveServiceAccount, []interface{}{ctx, orgID, serviceAccountID}) + return nil, nil +} diff --git a/pkg/services/sqlstore/org_users.go b/pkg/services/sqlstore/org_users.go index 6040a1f0634..83e29c2b7ca 100644 --- a/pkg/services/sqlstore/org_users.go +++ b/pkg/services/sqlstore/org_users.go @@ -108,6 +108,11 @@ func (ss *SQLStore) GetOrgUsers(ctx context.Context, query *models.GetOrgUsersQu whereConditions = append(whereConditions, "org_user.org_id = ?") whereParams = append(whereParams, query.OrgId) + if query.UserID != 0 { + whereConditions = append(whereConditions, "org_user.user_id = ?") + whereParams = append(whereParams, query.UserID) + } + // TODO: add to chore, for cleaning up after we have created // service accounts table in the modelling whereConditions = append(whereConditions, fmt.Sprintf("%s.is_service_account = %t", x.Dialect().Quote("user"), query.IsServiceAccount))