Add server config to disable user registered agents (#4206)

This commit is contained in:
6543 2024-11-11 18:51:14 +01:00 committed by GitHub
parent 07baae28af
commit 04e8309e60
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
12 changed files with 587 additions and 12 deletions

View file

@ -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",

View file

@ -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")

View file

@ -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

275
server/api/agent_test.go Normal file
View file

@ -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"))
})
}

View file

@ -54,6 +54,9 @@ var Config = struct {
CustomCSSFile string
CustomJsFile string
}
Agent struct {
DisableUserRegisteredAgentRegistration bool
}
WebUI struct {
EnableSwagger bool
SkipVersionCheck bool

259
server/queue/mocks/queue.go Normal file
View file

@ -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
}

View file

@ -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 {

View file

@ -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,6 +75,7 @@ func apiRoutes(e *gin.RouterGroup) {
org.PATCH("/registries/:registry", api.PatchOrgRegistry)
org.DELETE("/registries/:registry", api.DeleteOrgRegistry)
if !server.Config.Agent.DisableUserRegisteredAgentRegistration {
org.GET("/agents", api.GetOrgAgents)
org.POST("/agents", api.PostOrgAgent)
org.PATCH("/agents/:agent_id", api.PatchOrgAgent)
@ -81,6 +83,7 @@ func apiRoutes(e *gin.RouterGroup) {
}
}
}
}
repo := apiBase.Group("/repos")
{

View file

@ -46,6 +46,7 @@ func Config(c *gin.Context) {
"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 }}
`

View file

@ -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,
});

View file

@ -14,7 +14,7 @@
<Tab id="cli-and-api" :title="$t('user.settings.cli_and_api.cli_and_api')">
<UserCLIAndAPITab />
</Tab>
<Tab id="agents" :title="$t('admin.settings.agents.agents')">
<Tab v-if="useConfig().userRegisteredAgents" id="agents" :title="$t('admin.settings.agents.agents')">
<UserAgentsTab />
</Tab>
</Scaffold>

View file

@ -19,7 +19,7 @@
<OrgRegistriesTab />
</Tab>
<Tab id="agents" :title="$t('admin.settings.agents.agents')">
<Tab v-if="useConfig().userRegisteredAgents" id="agents" :title="$t('admin.settings.agents.agents')">
<OrgAgentsTab />
</Tab>
</Scaffold>
@ -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';