diff --git a/cmd/server/flags.go b/cmd/server/flags.go index 3e8bbd8c2..2ad82b9ac 100644 --- a/cmd/server/flags.go +++ b/cmd/server/flags.go @@ -213,6 +213,11 @@ var flags = append([]cli.Flag{ Name: "agent-secret", Usage: "server-agent shared password", }, + &cli.BoolFlag{ + Sources: cli.EnvVars("WOODPECKER_DISABLE_USER_AGENT_REGISTRATION"), + Name: "disable-user-agent-registration", + Usage: "Disable user registered agents", + }, &cli.DurationFlag{ Sources: cli.EnvVars("WOODPECKER_KEEPALIVE_MIN_TIME"), Name: "keepalive-min-time", diff --git a/cmd/server/setup.go b/cmd/server/setup.go index 83a92f01f..608a5ba14 100644 --- a/cmd/server/setup.go +++ b/cmd/server/setup.go @@ -167,6 +167,9 @@ func setupEvilGlobals(ctx context.Context, c *cli.Command, s store.Store) (err e return fmt.Errorf("could not setup log store: %w", err) } + // agents + server.Config.Agent.DisableUserRegisteredAgentRegistration = c.Bool("disable-user-agent-registration") + // authentication server.Config.Pipeline.AuthenticatePublicRepos = c.Bool("authenticate-public-repos") diff --git a/docs/docs/30-administration/10-server-config.md b/docs/docs/30-administration/10-server-config.md index 17da7abd2..6b9297f6a 100644 --- a/docs/docs/30-administration/10-server-config.md +++ b/docs/docs/30-administration/10-server-config.md @@ -54,6 +54,20 @@ Use the `WOODPECKER_REPO_OWNERS` variable to filter which GitHub user's repos sh WOODPECKER_REPO_OWNERS=my_company,my_company_oss_github_user ``` +## Disallow normal users to create agents + +By default, users can create new agents for their repos they have admin access to. +If an instance admin doesn't want this feature enabled, they can disable the API and hide the Web UI elements. + +:::note +You should set this option if you have, for example, +global secrets and don't trust your users to create a rogue agent and pipeline for secret extraction. +::: + +```ini +WOODPECKER_DISABLE_USER_AGENT_REGISTRATION=true +``` + ## Global registry setting If you want to make available a specific private registry to all pipelines, use the `WOODPECKER_DOCKER_CONFIG` server configuration. @@ -422,6 +436,12 @@ A shared secret used by server and agents to authenticate communication. A secre Read the value for `WOODPECKER_AGENT_SECRET` from the specified filepath +### `WOODPECKER_DISABLE_USER_AGENT_REGISTRATION` + +> Default: false + +[Read about "Disallow normal users to create agents"](./10-server-config.md#disallow-normal-users-to-create-agents) + ### `WOODPECKER_KEEPALIVE_MIN_TIME` > Default: empty diff --git a/server/api/agent_test.go b/server/api/agent_test.go new file mode 100644 index 000000000..0a87d99e0 --- /dev/null +++ b/server/api/agent_test.go @@ -0,0 +1,275 @@ +// Copyright 2024 Woodpecker Authors +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// 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. + +package api + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + + "github.com/gin-gonic/gin" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + + "go.woodpecker-ci.org/woodpecker/v2/server" + "go.woodpecker-ci.org/woodpecker/v2/server/model" + "go.woodpecker-ci.org/woodpecker/v2/server/queue" + queue_mocks "go.woodpecker-ci.org/woodpecker/v2/server/queue/mocks" + mocks_manager "go.woodpecker-ci.org/woodpecker/v2/server/services/mocks" + store_mocks "go.woodpecker-ci.org/woodpecker/v2/server/store/mocks" + "go.woodpecker-ci.org/woodpecker/v2/server/store/types" +) + +var fakeAgent = &model.Agent{ + ID: 1, + Name: "test-agent", + OwnerID: 1, + NoSchedule: false, +} + +func TestGetAgents(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("should get agents", func(t *testing.T) { + agents := []*model.Agent{fakeAgent} + + mockStore := store_mocks.NewStore(t) + mockStore.On("AgentList", mock.Anything).Return(agents, nil) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("store", mockStore) + + GetAgents(c) + c.Writer.WriteHeaderNow() + + mockStore.AssertCalled(t, "AgentList", mock.Anything) + assert.Equal(t, http.StatusOK, w.Code) + + var response []*model.Agent + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, agents, response) + }) +} + +func TestGetAgent(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("should get agent", func(t *testing.T) { + mockStore := store_mocks.NewStore(t) + mockStore.On("AgentFind", int64(1)).Return(fakeAgent, nil) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("store", mockStore) + c.Params = gin.Params{{Key: "agent_id", Value: "1"}} + + GetAgent(c) + c.Writer.WriteHeaderNow() + + mockStore.AssertCalled(t, "AgentFind", int64(1)) + assert.Equal(t, http.StatusOK, w.Code) + + var response model.Agent + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, fakeAgent, &response) + }) + + t.Run("should return bad request for invalid agent id", func(t *testing.T) { + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Params = gin.Params{{Key: "agent_id", Value: "invalid"}} + + GetAgent(c) + c.Writer.WriteHeaderNow() + + assert.Equal(t, http.StatusBadRequest, w.Code) + }) + + t.Run("should return not found for non-existent agent", func(t *testing.T) { + mockStore := store_mocks.NewStore(t) + mockStore.On("AgentFind", int64(2)).Return((*model.Agent)(nil), types.RecordNotExist) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("store", mockStore) + c.Params = gin.Params{{Key: "agent_id", Value: "2"}} + + GetAgent(c) + c.Writer.WriteHeaderNow() + + assert.Equal(t, http.StatusNotFound, w.Code) + }) +} + +func TestPatchAgent(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("should update agent", func(t *testing.T) { + updatedAgent := *fakeAgent + updatedAgent.Name = "updated-agent" + + mockStore := store_mocks.NewStore(t) + mockStore.On("AgentFind", int64(1)).Return(fakeAgent, nil) + mockStore.On("AgentUpdate", mock.AnythingOfType("*model.Agent")).Return(nil) + + mockManager := mocks_manager.NewManager(t) + server.Config.Services.Manager = mockManager + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("store", mockStore) + c.Params = gin.Params{{Key: "agent_id", Value: "1"}} + c.Request, _ = http.NewRequest(http.MethodPatch, "/", strings.NewReader(`{"name":"updated-agent"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + PatchAgent(c) + c.Writer.WriteHeaderNow() + + mockStore.AssertCalled(t, "AgentFind", int64(1)) + mockStore.AssertCalled(t, "AgentUpdate", mock.AnythingOfType("*model.Agent")) + assert.Equal(t, http.StatusOK, w.Code) + + var response model.Agent + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, "updated-agent", response.Name) + }) +} + +func TestPostAgent(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("should create agent", func(t *testing.T) { + newAgent := &model.Agent{ + Name: "new-agent", + NoSchedule: false, + } + + mockStore := store_mocks.NewStore(t) + mockStore.On("AgentCreate", mock.AnythingOfType("*model.Agent")).Return(nil) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("store", mockStore) + c.Set("user", &model.User{ID: 1}) + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"new-agent"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + PostAgent(c) + c.Writer.WriteHeaderNow() + + mockStore.AssertCalled(t, "AgentCreate", mock.AnythingOfType("*model.Agent")) + assert.Equal(t, http.StatusOK, w.Code) + + var response model.Agent + err := json.Unmarshal(w.Body.Bytes(), &response) + assert.NoError(t, err) + assert.Equal(t, newAgent.Name, response.Name) + assert.NotEmpty(t, response.Token) + }) +} + +func TestDeleteAgent(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("should delete agent", func(t *testing.T) { + mockStore := store_mocks.NewStore(t) + mockStore.On("AgentFind", int64(1)).Return(fakeAgent, nil) + mockStore.On("AgentDelete", mock.AnythingOfType("*model.Agent")).Return(nil) + + mockManager := mocks_manager.NewManager(t) + server.Config.Services.Manager = mockManager + + mockQueue := queue_mocks.NewQueue(t) + mockQueue.On("Info", mock.Anything).Return(queue.InfoT{}) + mockQueue.On("KickAgentWorkers", int64(1)).Return() + server.Config.Services.Queue = mockQueue + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("store", mockStore) + c.Params = gin.Params{{Key: "agent_id", Value: "1"}} + + DeleteAgent(c) + c.Writer.WriteHeaderNow() + + mockStore.AssertCalled(t, "AgentFind", int64(1)) + mockStore.AssertCalled(t, "AgentDelete", mock.AnythingOfType("*model.Agent")) + mockQueue.AssertCalled(t, "KickAgentWorkers", int64(1)) + assert.Equal(t, http.StatusNoContent, w.Code) + }) + + t.Run("should not delete agent with running tasks", func(t *testing.T) { + mockStore := store_mocks.NewStore(t) + mockStore.On("AgentFind", int64(1)).Return(fakeAgent, nil) + + mockManager := mocks_manager.NewManager(t) + server.Config.Services.Manager = mockManager + + mockQueue := queue_mocks.NewQueue(t) + mockQueue.On("Info", mock.Anything).Return(queue.InfoT{ + Running: []*model.Task{{AgentID: 1}}, + }) + server.Config.Services.Queue = mockQueue + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("store", mockStore) + c.Params = gin.Params{{Key: "agent_id", Value: "1"}} + + DeleteAgent(c) + c.Writer.WriteHeaderNow() + + mockStore.AssertCalled(t, "AgentFind", int64(1)) + mockStore.AssertNotCalled(t, "AgentDelete", mock.Anything) + assert.Equal(t, http.StatusConflict, w.Code) + }) +} + +func TestPostOrgAgent(t *testing.T) { + gin.SetMode(gin.TestMode) + + t.Run("create org agent should succeed", func(t *testing.T) { + mockStore := store_mocks.NewStore(t) + mockStore.On("AgentCreate", mock.AnythingOfType("*model.Agent")).Return(nil) + + w := httptest.NewRecorder() + c, _ := gin.CreateTestContext(w) + c.Set("store", mockStore) + + // Set up a non-admin user + c.Set("user", &model.User{ + ID: 1, + Admin: false, + }) + + c.Params = gin.Params{{Key: "org_id", Value: "1"}} + c.Request, _ = http.NewRequest(http.MethodPost, "/", strings.NewReader(`{"name":"new-agent"}`)) + c.Request.Header.Set("Content-Type", "application/json") + + PostOrgAgent(c) + c.Writer.WriteHeaderNow() + + assert.Equal(t, http.StatusOK, w.Code) + + // Ensure an agent was created + mockStore.AssertCalled(t, "AgentCreate", mock.AnythingOfType("*model.Agent")) + }) +} diff --git a/server/config.go b/server/config.go index fb6c42292..3349e535b 100644 --- a/server/config.go +++ b/server/config.go @@ -54,6 +54,9 @@ var Config = struct { CustomCSSFile string CustomJsFile string } + Agent struct { + DisableUserRegisteredAgentRegistration bool + } WebUI struct { EnableSwagger bool SkipVersionCheck bool diff --git a/server/queue/mocks/queue.go b/server/queue/mocks/queue.go new file mode 100644 index 000000000..ad8686971 --- /dev/null +++ b/server/queue/mocks/queue.go @@ -0,0 +1,259 @@ +// Code generated by mockery. DO NOT EDIT. + +//go:build test +// +build test + +package mocks + +import ( + context "context" + + mock "github.com/stretchr/testify/mock" + model "go.woodpecker-ci.org/woodpecker/v2/server/model" + + queue "go.woodpecker-ci.org/woodpecker/v2/server/queue" +) + +// Queue is an autogenerated mock type for the Queue type +type Queue struct { + mock.Mock +} + +// Done provides a mock function with given fields: c, id, exitStatus +func (_m *Queue) Done(c context.Context, id string, exitStatus model.StatusValue) error { + ret := _m.Called(c, id, exitStatus) + + if len(ret) == 0 { + panic("no return value specified for Done") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, model.StatusValue) error); ok { + r0 = rf(c, id, exitStatus) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Error provides a mock function with given fields: c, id, err +func (_m *Queue) Error(c context.Context, id string, err error) error { + ret := _m.Called(c, id, err) + + if len(ret) == 0 { + panic("no return value specified for Error") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, error) error); ok { + r0 = rf(c, id, err) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ErrorAtOnce provides a mock function with given fields: c, ids, err +func (_m *Queue) ErrorAtOnce(c context.Context, ids []string, err error) error { + ret := _m.Called(c, ids, err) + + if len(ret) == 0 { + panic("no return value specified for ErrorAtOnce") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []string, error) error); ok { + r0 = rf(c, ids, err) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Evict provides a mock function with given fields: c, id +func (_m *Queue) Evict(c context.Context, id string) error { + ret := _m.Called(c, id) + + if len(ret) == 0 { + panic("no return value specified for Evict") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(c, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// EvictAtOnce provides a mock function with given fields: c, ids +func (_m *Queue) EvictAtOnce(c context.Context, ids []string) error { + ret := _m.Called(c, ids) + + if len(ret) == 0 { + panic("no return value specified for EvictAtOnce") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []string) error); ok { + r0 = rf(c, ids) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Extend provides a mock function with given fields: c, agentID, workflowID +func (_m *Queue) Extend(c context.Context, agentID int64, workflowID string) error { + ret := _m.Called(c, agentID, workflowID) + + if len(ret) == 0 { + panic("no return value specified for Extend") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, int64, string) error); ok { + r0 = rf(c, agentID, workflowID) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Info provides a mock function with given fields: c +func (_m *Queue) Info(c context.Context) queue.InfoT { + ret := _m.Called(c) + + if len(ret) == 0 { + panic("no return value specified for Info") + } + + var r0 queue.InfoT + if rf, ok := ret.Get(0).(func(context.Context) queue.InfoT); ok { + r0 = rf(c) + } else { + r0 = ret.Get(0).(queue.InfoT) + } + + return r0 +} + +// KickAgentWorkers provides a mock function with given fields: agentID +func (_m *Queue) KickAgentWorkers(agentID int64) { + _m.Called(agentID) +} + +// Pause provides a mock function with given fields: +func (_m *Queue) Pause() { + _m.Called() +} + +// Poll provides a mock function with given fields: c, agentID, f +func (_m *Queue) Poll(c context.Context, agentID int64, f queue.FilterFn) (*model.Task, error) { + ret := _m.Called(c, agentID, f) + + if len(ret) == 0 { + panic("no return value specified for Poll") + } + + var r0 *model.Task + var r1 error + if rf, ok := ret.Get(0).(func(context.Context, int64, queue.FilterFn) (*model.Task, error)); ok { + return rf(c, agentID, f) + } + if rf, ok := ret.Get(0).(func(context.Context, int64, queue.FilterFn) *model.Task); ok { + r0 = rf(c, agentID, f) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*model.Task) + } + } + + if rf, ok := ret.Get(1).(func(context.Context, int64, queue.FilterFn) error); ok { + r1 = rf(c, agentID, f) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Push provides a mock function with given fields: c, task +func (_m *Queue) Push(c context.Context, task *model.Task) error { + ret := _m.Called(c, task) + + if len(ret) == 0 { + panic("no return value specified for Push") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, *model.Task) error); ok { + r0 = rf(c, task) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// PushAtOnce provides a mock function with given fields: c, tasks +func (_m *Queue) PushAtOnce(c context.Context, tasks []*model.Task) error { + ret := _m.Called(c, tasks) + + if len(ret) == 0 { + panic("no return value specified for PushAtOnce") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, []*model.Task) error); ok { + r0 = rf(c, tasks) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Resume provides a mock function with given fields: +func (_m *Queue) Resume() { + _m.Called() +} + +// Wait provides a mock function with given fields: c, id +func (_m *Queue) Wait(c context.Context, id string) error { + ret := _m.Called(c, id) + + if len(ret) == 0 { + panic("no return value specified for Wait") + } + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string) error); ok { + r0 = rf(c, id) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// NewQueue creates a new instance of Queue. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +// The first argument is typically a *testing.T value. +func NewQueue(t interface { + mock.TestingT + Cleanup(func()) +}) *Queue { + mock := &Queue{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/server/queue/queue.go b/server/queue/queue.go index 3301f7e95..cc856b6e1 100644 --- a/server/queue/queue.go +++ b/server/queue/queue.go @@ -72,6 +72,8 @@ func (t *InfoT) String() string { // The int return value represents the matching score (higher is better). type FilterFn func(*model.Task) (bool, int) +//go:generate mockery --name Queue --output mocks --case underscore --note "+build test" + // Queue defines a task queue for scheduling tasks among // a pool of workers. type Queue interface { diff --git a/server/router/api.go b/server/router/api.go index 2a514904d..591c9a7d3 100644 --- a/server/router/api.go +++ b/server/router/api.go @@ -18,6 +18,7 @@ import ( "github.com/gin-gonic/gin" "github.com/rs/zerolog" + "go.woodpecker-ci.org/woodpecker/v2/server" "go.woodpecker-ci.org/woodpecker/v2/server/api" "go.woodpecker-ci.org/woodpecker/v2/server/api/debug" "go.woodpecker-ci.org/woodpecker/v2/server/router/middleware/session" @@ -74,10 +75,12 @@ func apiRoutes(e *gin.RouterGroup) { org.PATCH("/registries/:registry", api.PatchOrgRegistry) org.DELETE("/registries/:registry", api.DeleteOrgRegistry) - org.GET("/agents", api.GetOrgAgents) - org.POST("/agents", api.PostOrgAgent) - org.PATCH("/agents/:agent_id", api.PatchOrgAgent) - org.DELETE("/agents/:agent_id", api.DeleteOrgAgent) + if !server.Config.Agent.DisableUserRegisteredAgentRegistration { + org.GET("/agents", api.GetOrgAgents) + org.POST("/agents", api.PostOrgAgent) + org.PATCH("/agents/:agent_id", api.PatchOrgAgent) + org.DELETE("/agents/:agent_id", api.DeleteOrgAgent) + } } } } diff --git a/server/web/config.go b/server/web/config.go index 44c6f1fe5..2d204d277 100644 --- a/server/web/config.go +++ b/server/web/config.go @@ -40,12 +40,13 @@ func Config(c *gin.Context) { } configData := map[string]any{ - "user": user, - "csrf": csrf, - "version": version.String(), - "skip_version_check": server.Config.WebUI.SkipVersionCheck, - "root_path": server.Config.Server.RootPath, - "enable_swagger": server.Config.WebUI.EnableSwagger, + "user": user, + "csrf": csrf, + "version": version.String(), + "skip_version_check": server.Config.WebUI.SkipVersionCheck, + "root_path": server.Config.Server.RootPath, + "enable_swagger": server.Config.WebUI.EnableSwagger, + "user_registered_agents": !server.Config.Agent.DisableUserRegisteredAgentRegistration, } // default func map with json parser. @@ -79,4 +80,5 @@ window.WOODPECKER_VERSION = "{{ .version }}"; window.WOODPECKER_ROOT_PATH = "{{ .root_path }}"; window.WOODPECKER_ENABLE_SWAGGER = {{ .enable_swagger }}; window.WOODPECKER_SKIP_VERSION_CHECK = {{ .skip_version_check }} +window.WOODPECKER_USER_REGISTERED_AGENTS = {{ .user_registered_agents }} ` diff --git a/web/src/compositions/useConfig.ts b/web/src/compositions/useConfig.ts index 3f12e1f92..f5f4324c0 100644 --- a/web/src/compositions/useConfig.ts +++ b/web/src/compositions/useConfig.ts @@ -8,6 +8,7 @@ declare global { WOODPECKER_CSRF: string | undefined; WOODPECKER_ROOT_PATH: string | undefined; WOODPECKER_ENABLE_SWAGGER: boolean | undefined; + WOODPECKER_USER_REGISTERED_AGENTS: boolean | undefined; } } @@ -18,4 +19,5 @@ export default () => ({ csrf: window.WOODPECKER_CSRF ?? null, rootPath: window.WOODPECKER_ROOT_PATH ?? '', enableSwagger: window.WOODPECKER_ENABLE_SWAGGER === true || false, + userRegisteredAgents: window.WOODPECKER_USER_REGISTERED_AGENTS || false, }); diff --git a/web/src/views/User.vue b/web/src/views/User.vue index b19639618..9a2f618ae 100644 --- a/web/src/views/User.vue +++ b/web/src/views/User.vue @@ -14,7 +14,7 @@ - + diff --git a/web/src/views/org/OrgSettings.vue b/web/src/views/org/OrgSettings.vue index 25d2e92e0..89c8d4fca 100644 --- a/web/src/views/org/OrgSettings.vue +++ b/web/src/views/org/OrgSettings.vue @@ -19,7 +19,7 @@ - + @@ -35,6 +35,7 @@ import Tab from '~/components/layout/scaffold/Tab.vue'; import OrgAgentsTab from '~/components/org/settings/OrgAgentsTab.vue'; import OrgRegistriesTab from '~/components/org/settings/OrgRegistriesTab.vue'; import OrgSecretsTab from '~/components/org/settings/OrgSecretsTab.vue'; +import useConfig from '~/compositions/useConfig'; import { inject } from '~/compositions/useInjectProvide'; import useNotifications from '~/compositions/useNotifications'; import { useRouteBack } from '~/compositions/useRouteBack';