diff --git a/cmd/api/src/api/v2/apiclient/auth.go b/cmd/api/src/api/v2/apiclient/auth.go index c67492884b..ec43722aad 100644 --- a/cmd/api/src/api/v2/apiclient/auth.go +++ b/cmd/api/src/api/v2/apiclient/auth.go @@ -364,7 +364,7 @@ func (s Client) UserRemoveRole(userID uuid.UUID, roleID int32) error { func (s Client) GetPermission(id int32) (model.Permission, error) { var permission model.Permission - if response, err := s.Request(http.MethodGet, fmt.Sprintf("api/v2/auth/permissions/%d", id), nil, nil); err != nil { + if response, err := s.Request(http.MethodGet, fmt.Sprintf("api/v2/permissions/%d", id), nil, nil); err != nil { return permission, err } else { defer response.Body.Close() @@ -380,7 +380,7 @@ func (s Client) GetPermission(id int32) (model.Permission, error) { func (s Client) ListPermissions() (v2.ListPermissionsResponse, error) { var permissions v2.ListPermissionsResponse - if response, err := s.Request(http.MethodGet, "api/v2/auth/permissions", nil, nil); err != nil { + if response, err := s.Request(http.MethodGet, "api/v2/permissions", nil, nil); err != nil { return permissions, err } else { defer response.Body.Close() diff --git a/cmd/api/src/api/v2/apiclient/savedqueries.go b/cmd/api/src/api/v2/apiclient/savedqueries.go new file mode 100644 index 0000000000..d69ee29c8e --- /dev/null +++ b/cmd/api/src/api/v2/apiclient/savedqueries.go @@ -0,0 +1,43 @@ +package apiclient + +import ( + "github.com/specterops/bloodhound/src/api" + v2 "github.com/specterops/bloodhound/src/api/v2" + "github.com/specterops/bloodhound/src/model" + "net/http" +) + +func (s Client) ListSavedQueries() (model.SavedQueries, error) { + var queries model.SavedQueries + if response, err := s.Request(http.MethodGet, "api/v2/saved-queries", nil, nil); err != nil { + return queries, err + } else { + defer response.Body.Close() + + if api.IsErrorResponse(response) { + return queries, ReadAPIError(response) + } + + return queries, api.ReadAPIV2ResponsePayload(&queries, response) + } +} + +func (s Client) CreateSavedQuery() (model.SavedQuery, error) { + var query model.SavedQuery + payload := v2.CreateSavedQueryRequest{ + Query: "Match(q:Question {life: 1, universe: 1, everything: 1}) return q", + Name: "AnswerToLifeUniverseEverything", + } + + if response, err := s.Request(http.MethodPost, "api/v2/saved-queries", nil, payload); err != nil { + return query, err + } else { + defer response.Body.Close() + + if api.IsErrorResponse(response) { + return query, ReadAPIError(response) + } + + return query, api.ReadAPIV2ResponsePayload(&query, response) + } +} diff --git a/cmd/api/src/api/v2/auth/auth_test.go b/cmd/api/src/api/v2/auth/auth_test.go index 5b525ef58d..4093b03956 100644 --- a/cmd/api/src/api/v2/auth/auth_test.go +++ b/cmd/api/src/api/v2/auth/auth_test.go @@ -240,7 +240,7 @@ func TestManagementResource_ListPermissions_SortingError(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - endpoint := "/api/v2/auth/permissions" + endpoint := "/api/v2/permissions" mockDB := dbmocks.NewMockDatabase(mockCtrl) config, err := config.NewDefaultConfiguration() @@ -273,7 +273,7 @@ func TestManagementResource_ListPermissions_InvalidFilterPredicate(t *testing.T) mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - endpoint := "/api/v2/auth/permissions" + endpoint := "/api/v2/permissions" mockDB := dbmocks.NewMockDatabase(mockCtrl) config, err := config.NewDefaultConfiguration() @@ -306,7 +306,7 @@ func TestManagementResource_ListPermissions_PredicateMismatchWithColumn(t *testi mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - endpoint := "/api/v2/auth/permissions" + endpoint := "/api/v2/permissions" mockDB := dbmocks.NewMockDatabase(mockCtrl) config, err := config.NewDefaultConfiguration() @@ -339,7 +339,7 @@ func TestManagementResource_ListPermissions_DBError(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - endpoint := "/api/v2/auth/permissions" + endpoint := "/api/v2/permissions" mockDB := dbmocks.NewMockDatabase(mockCtrl) mockDB.EXPECT().GetAllPermissions("authority desc, name", model.SQLFilter{SQLString: "name = ?", Params: []any{"foo"}}).Return(model.Permissions{}, fmt.Errorf("foo")) @@ -375,7 +375,7 @@ func TestManagementResource_ListPermissions(t *testing.T) { mockCtrl := gomock.NewController(t) defer mockCtrl.Finish() - endpoint := "/api/v2/auth/permissions" + endpoint := "/api/v2/permissions" perm1 := model.Permission{ Authority: "a", diff --git a/cmd/api/src/api/v2/cypher_search_integration_test.go b/cmd/api/src/api/v2/cypher_search_integration_test.go index 31367aadbd..5e2906c82b 100644 --- a/cmd/api/src/api/v2/cypher_search_integration_test.go +++ b/cmd/api/src/api/v2/cypher_search_integration_test.go @@ -33,21 +33,21 @@ import ( func Test_CypherSearch(t *testing.T) { var ( - harness = harnesses.NewIntegrationTestHarness(fixtures.BHApiClientFixture) + harness = harnesses.NewIntegrationTestHarness(fixtures.BHAdminApiClientFixture) ) lab.Pack(harness, fixtures.BasicComputerFixture) lab.NewSpec(t, harness).Run( lab.TestCase("errors on empty input", func(assert *require.Assertions, harness *lab.Harness) { - apiClient, ok := lab.Unpack(harness, fixtures.BHApiClientFixture) + apiClient, ok := lab.Unpack(harness, fixtures.BHAdminApiClientFixture) assert.True(ok) _, err := apiClient.CypherSearch(v2.CypherSearch{}) assert.ErrorContains(err, frontend.ErrInvalidInput.Error()) }), lab.TestCase("errors on syntax mistake", func(assert *require.Assertions, harness *lab.Harness) { - apiClient, ok := lab.Unpack(harness, fixtures.BHApiClientFixture) + apiClient, ok := lab.Unpack(harness, fixtures.BHAdminApiClientFixture) assert.True(ok) _, err := apiClient.CypherSearch(v2.CypherSearch{ @@ -56,7 +56,7 @@ func Test_CypherSearch(t *testing.T) { assert.ErrorContains(err, "extraneous input") }), lab.TestCase("errors on queries that are not supported", func(assert *require.Assertions, harness *lab.Harness) { - apiClient, ok := lab.Unpack(harness, fixtures.BHApiClientFixture) + apiClient, ok := lab.Unpack(harness, fixtures.BHAdminApiClientFixture) assert.True(ok) queryWithUpdateClause := "match (b) where b.name = 'test' remove b.prop return b" @@ -66,7 +66,7 @@ func Test_CypherSearch(t *testing.T) { assert.ErrorContains(err, frontend.ErrUpdateClauseNotSupported.Error()) }), lab.TestCase("succesfully runs cypher query", func(assert *require.Assertions, harness *lab.Harness) { - apiClient, ok := lab.Unpack(harness, fixtures.BHApiClientFixture) + apiClient, ok := lab.Unpack(harness, fixtures.BHAdminApiClientFixture) assert.True(ok) graphResponse, err := apiClient.CypherSearch(v2.CypherSearch{ diff --git a/cmd/api/src/auth/permission.go b/cmd/api/src/auth/permission.go index af2dcb78d5..85119cd7e8 100644 --- a/cmd/api/src/auth/permission.go +++ b/cmd/api/src/auth/permission.go @@ -21,81 +21,79 @@ import ( ) type PermissionSet struct { - GraphDBRead model.Permission - GraphDBWrite model.Permission - AppReadApplicationConfiguration model.Permission AppWriteApplicationConfiguration model.Permission - CollectionManageJobs model.Permission - - ClientsManage model.Permission - ClientsTasking model.Permission + APsGenerateReport model.Permission + APsManageAPs model.Permission + AuthAcceptEULA model.Permission AuthCreateToken model.Permission + AuthManageApplicationConfigurations model.Permission + AuthManageProviders model.Permission AuthManageSelf model.Permission - AuthAcceptEULA model.Permission AuthManageUsers model.Permission - AuthManageProviders model.Permission - AuthManageApplicationConfigurations model.Permission - APsGenerateReport model.Permission - APsManageAPs model.Permission + ClientsManage model.Permission + ClientsRead model.Permission + ClientsTasking model.Permission + + CollectionManageJobs model.Permission + + GraphDBRead model.Permission + GraphDBWrite model.Permission SavedQueriesRead model.Permission SavedQueriesWrite model.Permission - - ClientsRead model.Permission } func (s PermissionSet) All() model.Permissions { return model.Permissions{ - s.GraphDBWrite, - s.GraphDBRead, s.AppReadApplicationConfiguration, s.AppWriteApplicationConfiguration, - s.CollectionManageJobs, - s.ClientsManage, - s.ClientsTasking, + s.APsGenerateReport, + s.APsManageAPs, s.AuthCreateToken, - s.AuthManageUsers, + s.AuthManageApplicationConfigurations, s.AuthManageProviders, s.AuthManageSelf, - s.AuthManageApplicationConfigurations, - s.APsGenerateReport, - s.APsManageAPs, + s.AuthManageUsers, + s.ClientsManage, + s.ClientsRead, + s.ClientsTasking, + s.CollectionManageJobs, + s.GraphDBRead, + s.GraphDBWrite, s.SavedQueriesRead, s.SavedQueriesWrite, - s.ClientsRead, } } func Permissions() PermissionSet { return PermissionSet{ - GraphDBRead: model.NewPermission("graphdb", "Read"), - GraphDBWrite: model.NewPermission("graphdb", "Write"), - AppReadApplicationConfiguration: model.NewPermission("app", "ReadAppConfig"), AppWriteApplicationConfiguration: model.NewPermission("app", "WriteAppConfig"), - CollectionManageJobs: model.NewPermission("collection", "ManageJobs"), - - ClientsManage: model.NewPermission("clients", "Manage"), - ClientsTasking: model.NewPermission("clients", "Tasking"), + APsGenerateReport: model.NewPermission("risks", "GenerateReport"), + APsManageAPs: model.NewPermission("risks", "ManageRisks"), - AuthCreateToken: model.NewPermission("auth", "CreateToken"), - AuthManageSelf: model.NewPermission("auth", "ManageSelf"), AuthAcceptEULA: model.NewPermission("auth", "AcceptEULA"), + AuthCreateToken: model.NewPermission("auth", "CreateToken"), + AuthManageApplicationConfigurations: model.NewPermission("auth", "ManageAppConfig"), AuthManageProviders: model.NewPermission("auth", "ManageProviders"), + AuthManageSelf: model.NewPermission("auth", "ManageSelf"), AuthManageUsers: model.NewPermission("auth", "ManageUsers"), - AuthManageApplicationConfigurations: model.NewPermission("auth", "ManageAppConfig"), - APsGenerateReport: model.NewPermission("risks", "GenerateReport"), - APsManageAPs: model.NewPermission("risks", "ManageRisks"), + ClientsManage: model.NewPermission("clients", "Manage"), + ClientsRead: model.NewPermission("clients", "Read"), + ClientsTasking: model.NewPermission("clients", "Tasking"), + + CollectionManageJobs: model.NewPermission("collection", "ManageJobs"), + + GraphDBRead: model.NewPermission("graphdb", "Read"), + GraphDBWrite: model.NewPermission("graphdb", "Write"), SavedQueriesRead: model.NewPermission("saved_queries", "Read"), SavedQueriesWrite: model.NewPermission("saved_queries", "Write"), - - ClientsRead: model.NewPermission("clients", "Read"), } } diff --git a/cmd/api/src/auth/role.go b/cmd/api/src/auth/role.go index 7a56745ef1..69d63a610c 100644 --- a/cmd/api/src/auth/role.go +++ b/cmd/api/src/auth/role.go @@ -26,6 +26,7 @@ const ( RoleUploadOnly = "Upload-Only" RoleReadOnly = "Read-Only" RoleUser = "User" + RolePowerUser = "Power User" RoleAdministrator = "Administrator" ) @@ -70,32 +71,52 @@ func Roles() map[string]RoleTemplate { Name: RoleReadOnly, Description: "Used for integrations", Permissions: model.Permissions{ - permissions.GraphDBRead, - permissions.AuthManageSelf, - permissions.APsGenerateReport, permissions.AppReadApplicationConfiguration, + permissions.APsGenerateReport, + permissions.AuthManageSelf, + permissions.GraphDBRead, }, }, RoleUploadOnly: { Name: RoleUploadOnly, Description: "Used for data collection clients, can post data but cannot read data", Permissions: model.Permissions{ - permissions.GraphDBWrite, permissions.ClientsTasking, + permissions.GraphDBWrite, }, }, RoleUser: { Name: RoleUser, Description: "Can read data, modify asset group memberships", Permissions: model.Permissions{ - permissions.GraphDBRead, + permissions.AppReadApplicationConfiguration, + permissions.APsGenerateReport, permissions.AuthCreateToken, permissions.AuthManageSelf, - permissions.APsGenerateReport, - permissions.AppReadApplicationConfiguration, + permissions.ClientsRead, + permissions.GraphDBRead, permissions.SavedQueriesRead, permissions.SavedQueriesWrite, + }, + }, + RolePowerUser: { + Name: RolePowerUser, + Description: "Can upload data, manage clients, and perform any action a User can", + Permissions: model.Permissions{ + permissions.AppReadApplicationConfiguration, + permissions.AppWriteApplicationConfiguration, + permissions.APsGenerateReport, + permissions.APsManageAPs, + permissions.AuthCreateToken, + permissions.AuthManageSelf, + permissions.ClientsManage, permissions.ClientsRead, + permissions.ClientsTasking, + permissions.CollectionManageJobs, + permissions.GraphDBWrite, + permissions.GraphDBRead, + permissions.SavedQueriesRead, + permissions.SavedQueriesWrite, }, }, RoleAdministrator: { diff --git a/cmd/api/src/auth/role_test.go b/cmd/api/src/auth/role_test.go new file mode 100644 index 0000000000..7e76c11c88 --- /dev/null +++ b/cmd/api/src/auth/role_test.go @@ -0,0 +1,234 @@ +// Copyright 2023 Specter Ops, Inc. +// +// Licensed under the Apache License, Version 2.0 +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// SPDX-License-Identifier: Apache-2.0 + +//go:build integration +// +build integration + +package auth_test + +import ( + "encoding/json" + "fmt" + "github.com/gofrs/uuid" + "github.com/specterops/bloodhound/lab" + "github.com/specterops/bloodhound/src/api" + v2 "github.com/specterops/bloodhound/src/api/v2" + "github.com/specterops/bloodhound/src/auth" + "github.com/specterops/bloodhound/src/model" + "github.com/specterops/bloodhound/src/model/appcfg" + "github.com/specterops/bloodhound/src/test/lab/fixtures" + "github.com/stretchr/testify/require" + "net/http" + "testing" + "time" +) + +func testCondition(role auth.RoleTemplate, permission model.Permission) string { + if role.Permissions.Has(permission) { + return "SHOULD" + } + return "SHOULD NOT" +} + +func requireForbidden(assert *require.Assertions, err error) { + var errByte []byte + errByte, err = json.Marshal(err) + assert.Nil(err) + + errWrapper := api.ErrorWrapper{} + err = json.Unmarshal(errByte, &errWrapper) + assert.Nilf(err, "Failed to unmarshal error %v", string(errByte)) + assert.Equal(errWrapper.HTTPStatus, http.StatusForbidden) +} + +func testRoleAccess(t *testing.T, roleName string) { + role, ok := auth.Roles()[roleName] + require.Truef(t, ok, "invalid role name") + + harness := lab.NewHarness() + adminApiClientFixture := fixtures.NewAdminApiClientFixture(fixtures.NewApiFixture()) + lab.Pack(harness, adminApiClientFixture) + userClientFixture := fixtures.NewUserApiClientFixture(adminApiClientFixture, role.Name) + lab.Pack(harness, userClientFixture) + + lab.NewSpec(t, harness).Run( + lab.TestCase(fmt.Sprintf("%s be able to access AppReadApplicationConfiguration endpoints", testCondition(role, auth.Permissions().AppReadApplicationConfiguration)), func(assert *require.Assertions, harness *lab.Harness) { + userClient, ok := lab.Unpack(harness, userClientFixture) + assert.True(ok) + + _, err := userClient.GetAppConfigs() + if role.Permissions.Has(auth.Permissions().AppReadApplicationConfiguration) { + assert.Nil(err) + } else { + requireForbidden(assert, err) + } + }), + + lab.TestCase(fmt.Sprintf("%s be able to access AppWriteApplicationConfiguration endpoints", testCondition(role, auth.Permissions().AppWriteApplicationConfiguration)), func(assert *require.Assertions, harness *lab.Harness) { + userClient, ok := lab.Unpack(harness, userClientFixture) + assert.True(ok) + + updatedPasswordExpirationWindowParameter := v2.AppConfigUpdateRequest{ + Key: appcfg.PasswordExpirationWindow, + Value: map[string]any{ + "duration": "P30D", + }, + } + _, err := userClient.PutAppConfig(updatedPasswordExpirationWindowParameter) + if role.Permissions.Has(auth.Permissions().AppWriteApplicationConfiguration) { + assert.Nil(err) + } else { + requireForbidden(assert, err) + } + }), + + lab.TestCase(fmt.Sprintf("%s be able to access own AuthCreateToken endpoints", testCondition(role, auth.Permissions().AuthCreateToken)), func(assert *require.Assertions, harness *lab.Harness) { + userClient, ok := lab.Unpack(harness, userClientFixture) + assert.True(ok) + + user, err := userClient.GetSelf() + assert.Nilf(err, "failed looking up user details") + + _, err = userClient.ListUserTokens(user.ID) + if role.Permissions.Has(auth.Permissions().AuthCreateToken) { + assert.Nil(err) + } else { + requireForbidden(assert, err) + } + }), + + lab.TestCase(fmt.Sprintf("%s be able to access AuthManageProviders endpoints", testCondition(role, auth.Permissions().AuthManageProviders)), func(assert *require.Assertions, harness *lab.Harness) { + userClient, ok := lab.Unpack(harness, userClientFixture) + assert.True(ok) + + _, err := userClient.ListSAMLIdentityProviders() + if role.Permissions.Has(auth.Permissions().AuthManageProviders) { + assert.Nil(err) + } else { + requireForbidden(assert, err) + } + }), + + lab.TestCase(fmt.Sprintf("%s be able to access AuthManageSelf endpoints", testCondition(role, auth.Permissions().AuthManageSelf)), func(assert *require.Assertions, harness *lab.Harness) { + userClient, ok := lab.Unpack(harness, userClientFixture) + assert.True(ok) + + _, err := userClient.ListPermissions() + if role.Permissions.Has(auth.Permissions().AuthManageSelf) { + assert.Nil(err) + } else { + requireForbidden(assert, err) + } + }), + + lab.TestCase(fmt.Sprintf("%s be able to access AuthManageUsers endpoints", testCondition(role, auth.Permissions().AuthManageUsers)), func(assert *require.Assertions, harness *lab.Harness) { + userClient, ok := lab.Unpack(harness, userClientFixture) + assert.True(ok) + + _, err := userClient.ListAuditLogs(time.Now(), time.Now(), 0, 0) + if role.Permissions.Has(auth.Permissions().AuthManageUsers) { + assert.Nil(err) + } else { + requireForbidden(assert, err) + } + }), + + lab.TestCase(fmt.Sprintf("%s be able to access GraphDBWrite endpoints", testCondition(role, auth.Permissions().GraphDBWrite)), func(assert *require.Assertions, harness *lab.Harness) { + userClient, ok := lab.Unpack(harness, userClientFixture) + assert.True(ok) + + _, err := userClient.CreateFileUploadTask() + if role.Permissions.Has(auth.Permissions().GraphDBWrite) { + assert.Nil(err) + } else { + requireForbidden(assert, err) + } + }), + + lab.TestCase(fmt.Sprintf("%s be able to access GraphDBRead endpoints", testCondition(role, auth.Permissions().GraphDBRead)), func(assert *require.Assertions, harness *lab.Harness) { + userClient, ok := lab.Unpack(harness, userClientFixture) + assert.True(ok) + + _, err := userClient.ListAssetGroups() + if role.Permissions.Has(auth.Permissions().GraphDBRead) { + assert.Nil(err) + } else { + requireForbidden(assert, err) + } + }), + + lab.TestCase(fmt.Sprintf("%s be able to access SavedQueriesRead endpoints", testCondition(role, auth.Permissions().SavedQueriesRead)), func(assert *require.Assertions, harness *lab.Harness) { + userClient, ok := lab.Unpack(harness, userClientFixture) + assert.True(ok) + + _, err := userClient.ListSavedQueries() + if role.Permissions.Has(auth.Permissions().SavedQueriesRead) { + assert.Nil(err) + } else { + requireForbidden(assert, err) + } + }), + + lab.TestCase(fmt.Sprintf("%s be able to access SavedQueriesWrite endpoints", testCondition(role, auth.Permissions().SavedQueriesWrite)), func(assert *require.Assertions, harness *lab.Harness) { + userClient, ok := lab.Unpack(harness, userClientFixture) + assert.True(ok) + + _, err := userClient.CreateSavedQuery() + if role.Permissions.Has(auth.Permissions().SavedQueriesWrite) { + assert.Nil(err) + } else { + requireForbidden(assert, err) + } + }), + ) +} + +func TestRole_ReadOnly(t *testing.T) { + testRoleAccess(t, auth.RoleReadOnly) +} + +func TestRole_UploadOnly(t *testing.T) { + testRoleAccess(t, auth.RoleUploadOnly) +} + +func TestRole_User(t *testing.T) { + testRoleAccess(t, auth.RoleUser) +} + +func TestRole_PowerUser(t *testing.T) { + testRoleAccess(t, auth.RolePowerUser) +} + +func TestRole_Administrator(t *testing.T) { + testRoleAccess(t, auth.RoleAdministrator) +} + +func TestRole_Administrator_ListOtherUserTokens(t *testing.T) { + harness := lab.NewHarness() + lab.Pack(harness, fixtures.BHAdminApiClientFixture) + lab.NewSpec(t, harness).Run( + lab.TestCase("Should be able to access AuthCreateToken endpoints for other users", func(assert *require.Assertions, harness *lab.Harness) { + adminClient, ok := lab.Unpack(harness, fixtures.BHAdminApiClientFixture) + assert.True(ok) + + randoUser, err := uuid.NewV4() + assert.Nilf(err, "failed to create rando user") + + _, err = adminClient.ListUserTokens(randoUser) + assert.Nil(err) + }), + ) +} diff --git a/cmd/api/src/test/lab/fixtures/apiclient.go b/cmd/api/src/test/lab/fixtures/apiclient.go index a75ac3a09b..2806ffbae1 100644 --- a/cmd/api/src/test/lab/fixtures/apiclient.go +++ b/cmd/api/src/test/lab/fixtures/apiclient.go @@ -18,6 +18,7 @@ package fixtures import ( "fmt" + "github.com/specterops/bloodhound/src/config" "log" "github.com/specterops/bloodhound/lab" @@ -25,9 +26,9 @@ import ( "github.com/specterops/bloodhound/src/api/v2/integration" ) -var BHApiClientFixture = NewApiClientFixture(BHApiFixture) +var BHAdminApiClientFixture = NewAdminApiClientFixture(BHApiFixture) -func NewApiClientFixture(apiFixture *lab.Fixture[bool]) *lab.Fixture[apiclient.Client] { +func NewAdminApiClientFixture(apiFixture *lab.Fixture[bool]) *lab.Fixture[apiclient.Client] { fixture := lab.NewFixture(func(harness *lab.Harness) (apiclient.Client, error) { if config, ok := lab.Unpack(harness, ConfigFixture); !ok { return apiclient.Client{}, fmt.Errorf("unable to unpack ConfigFixture") @@ -59,3 +60,59 @@ func NewApiClientFixture(apiFixture *lab.Fixture[bool]) *lab.Fixture[apiclient.C } return fixture } + +func NewUserApiClientFixture(adminApiFixture *lab.Fixture[apiclient.Client], roleNames ...string) *lab.Fixture[apiclient.Client] { + fixture := lab.NewFixture(func(harness *lab.Harness) (apiclient.Client, error) { + if configFixture, ok := lab.Unpack(harness, ConfigFixture); !ok { + return apiclient.Client{}, fmt.Errorf("unable to unpack ConfigFixture") + } else if adminClient, ok := lab.Unpack(harness, adminApiFixture); !ok { + return apiclient.Client{}, fmt.Errorf("unable to unpack adminApiFixture") + } else if username, err := config.GenerateSecureRandomString(7); err != nil { + return apiclient.Client{}, fmt.Errorf("unable to generate random username") + } else if secret, err := config.GenerateRandomBase64String(32); err != nil { + return apiclient.Client{}, fmt.Errorf("unable to generate secret") + } else if roles, err := adminClient.ListRoles(); err != nil { + return apiclient.Client{}, fmt.Errorf("unable to get roles") + } else { + var roleIds []int32 + for _, r := range roleNames { + if foundRole, found := roles.Roles.FindByName(r); !found { + return apiclient.Client{}, fmt.Errorf("unable to find role") + } else { + roleIds = append(roleIds, foundRole.ID) + } + } + + // Create user in database + if user, err := adminClient.CreateUser(username, "", roleIds); err != nil { + return apiclient.Client{}, fmt.Errorf("failed to create user in db") + } else { + if err := adminClient.SetUserSecret(user.ID, secret, false); err != nil { + return apiclient.Client{}, fmt.Errorf("failed resetting expired user password: %w", err) + } + } + // Get api client for user + client, err := apiclient.NewClient(configFixture.RootURL.String()) + if err != nil { + return apiclient.Client{}, fmt.Errorf("unable to initialize api client: %w", err) + } + + credentials := &apiclient.SecretCredentialsHandler{ + Username: username, + Secret: secret, + } + credentials.Client = client + client.Credentials = credentials + + if _, err := client.GetSelf(); err != nil { + return apiclient.Client{}, fmt.Errorf("failed looking up user details: %w", err) + } + + return client, nil + } + }, nil) + if err := lab.SetDependency(fixture, adminApiFixture); err != nil { + log.Fatalln(err) + } + return fixture +}