mirror of
https://github.com/woodpecker-ci/woodpecker.git
synced 2024-12-21 07:56:31 +00:00
Support user secrets (#2126)
This commit is contained in:
parent
09624aa286
commit
81ead7cbf2
18 changed files with 251 additions and 22 deletions
2
Makefile
2
Makefile
|
@ -139,7 +139,7 @@ ui-dependencies: ## Install UI dependencies
|
|||
.PHONY: lint
|
||||
lint: install-tools ## Lint code
|
||||
@echo "Running golangci-lint"
|
||||
golangci-lint run --timeout 10m
|
||||
golangci-lint run --timeout 15m
|
||||
@echo "Running zerolog linter"
|
||||
lint github.com/woodpecker-ci/woodpecker/cmd/agent
|
||||
lint github.com/woodpecker-ci/woodpecker/cmd/cli
|
||||
|
|
|
@ -4197,6 +4197,10 @@ const docTemplate = `{
|
|||
"login": {
|
||||
"description": "Login is the username for this user.\n\nrequired: true",
|
||||
"type": "string"
|
||||
},
|
||||
"org_id": {
|
||||
"description": "OrgID is the of the user as model.Org.",
|
||||
"type": "integer"
|
||||
}
|
||||
}
|
||||
},
|
||||
|
|
|
@ -91,12 +91,15 @@ func GetOrgPermissions(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
if (org.IsUser && org.Name == user.Login) || user.Admin {
|
||||
if (org.IsUser && org.Name == user.Login) || (user.Admin && !org.IsUser) {
|
||||
c.JSON(http.StatusOK, &model.OrgPerm{
|
||||
Member: true,
|
||||
Admin: true,
|
||||
})
|
||||
return
|
||||
} else if org.IsUser {
|
||||
c.JSON(http.StatusOK, &model.OrgPerm{})
|
||||
return
|
||||
}
|
||||
|
||||
perm, err := server.Config.Services.Membership.Get(c, user, org.Name)
|
||||
|
|
|
@ -64,6 +64,9 @@ type User struct {
|
|||
|
||||
// Hash is a unique token used to sign tokens.
|
||||
Hash string `json:"-" xorm:"UNIQUE varchar(500) 'user_hash'"`
|
||||
|
||||
// OrgID is the of the user as model.Org.
|
||||
OrgID int64 `json:"org_id" xorm:"user_org_id"`
|
||||
} // @name User
|
||||
|
||||
// TableName return database table name for xorm
|
||||
|
|
|
@ -23,7 +23,7 @@ import (
|
|||
)
|
||||
|
||||
func TestGetPipelineQueue(t *testing.T) {
|
||||
store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline))
|
||||
store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline), new(model.Org))
|
||||
defer closer()
|
||||
|
||||
user := &model.User{
|
||||
|
@ -64,7 +64,7 @@ func TestGetPipelineQueue(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestUserFeed(t *testing.T) {
|
||||
store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline))
|
||||
store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline), new(model.Org))
|
||||
defer closer()
|
||||
|
||||
user := &model.User{
|
||||
|
@ -115,7 +115,7 @@ func TestUserFeed(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestRepoListLatest(t *testing.T) {
|
||||
store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline))
|
||||
store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Pipeline), new(model.Org))
|
||||
defer closer()
|
||||
|
||||
user := &model.User{
|
||||
|
|
61
server/store/datastore/migration/022_add_org_id.go
Normal file
61
server/store/datastore/migration/022_add_org_id.go
Normal file
|
@ -0,0 +1,61 @@
|
|||
// Copyright 2022 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 migration
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"xorm.io/xorm"
|
||||
|
||||
"github.com/woodpecker-ci/woodpecker/server/model"
|
||||
)
|
||||
|
||||
var addOrgID = task{
|
||||
name: "add-org-id",
|
||||
required: true,
|
||||
fn: func(sess *xorm.Session) error {
|
||||
if err := sess.Sync(new(model.User)); err != nil {
|
||||
return fmt.Errorf("sync new models failed: %w", err)
|
||||
}
|
||||
|
||||
// get all users
|
||||
var users []*model.User
|
||||
if err := sess.Find(&users); err != nil {
|
||||
return fmt.Errorf("find all repos failed: %w", err)
|
||||
}
|
||||
|
||||
for _, user := range users {
|
||||
org := &model.Org{}
|
||||
has, err := sess.Where("name = ?", user.Login).Get(org)
|
||||
if err != nil {
|
||||
return fmt.Errorf("getting org failed: %w", err)
|
||||
} else if !has {
|
||||
org = &model.Org{
|
||||
Name: user.Login,
|
||||
IsUser: true,
|
||||
}
|
||||
if _, err := sess.Insert(org); err != nil {
|
||||
return fmt.Errorf("inserting org failed: %w", err)
|
||||
}
|
||||
}
|
||||
user.OrgID = org.ID
|
||||
if _, err := sess.Cols("user_org_id").Update(user); err != nil {
|
||||
return fmt.Errorf("updating user failed: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return dropTableColumns(sess, "secrets", "secret_owner")
|
||||
},
|
||||
}
|
|
@ -54,6 +54,7 @@ var migrationTasks = []*task{
|
|||
&migrateLogs2LogEntries,
|
||||
&parentStepsToWorkflows,
|
||||
&addOrgs,
|
||||
&addOrgID,
|
||||
}
|
||||
|
||||
var allBeans = []interface{}{
|
||||
|
|
|
@ -18,13 +18,18 @@ import (
|
|||
"strings"
|
||||
|
||||
"github.com/woodpecker-ci/woodpecker/server/model"
|
||||
"xorm.io/xorm"
|
||||
)
|
||||
|
||||
func (s storage) OrgCreate(org *model.Org) error {
|
||||
return s.orgCreate(org, s.engine.NewSession())
|
||||
}
|
||||
|
||||
func (s storage) orgCreate(org *model.Org, sess *xorm.Session) error {
|
||||
// sanitize
|
||||
org.Name = strings.ToLower(org.Name)
|
||||
// insert
|
||||
_, err := s.engine.Insert(org)
|
||||
_, err := sess.Insert(org)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
@ -140,7 +140,7 @@ func TestRepos(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestRepoList(t *testing.T) {
|
||||
store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm))
|
||||
store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Org))
|
||||
defer closer()
|
||||
|
||||
user := &model.User{
|
||||
|
@ -196,7 +196,7 @@ func TestRepoList(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestOwnedRepoList(t *testing.T) {
|
||||
store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm))
|
||||
store, closer := newTestStore(t, new(model.Repo), new(model.User), new(model.Perm), new(model.Org))
|
||||
defer closer()
|
||||
|
||||
user := &model.User{
|
||||
|
|
|
@ -53,8 +53,18 @@ func (s storage) GetUserCount() (int64, error) {
|
|||
}
|
||||
|
||||
func (s storage) CreateUser(user *model.User) error {
|
||||
sess := s.engine.NewSession()
|
||||
org := &model.Org{
|
||||
Name: user.Login,
|
||||
IsUser: true,
|
||||
}
|
||||
err := s.orgCreate(org, sess)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
user.OrgID = org.ID
|
||||
// only Insert set auto created ID back to object
|
||||
_, err := s.engine.Insert(user)
|
||||
_, err = sess.Insert(user)
|
||||
return err
|
||||
}
|
||||
|
||||
|
|
|
@ -24,7 +24,7 @@ import (
|
|||
)
|
||||
|
||||
func TestUsers(t *testing.T) {
|
||||
store, closer := newTestStore(t, new(model.User), new(model.Repo), new(model.Pipeline), new(model.Step), new(model.Perm))
|
||||
store, closer := newTestStore(t, new(model.User), new(model.Repo), new(model.Pipeline), new(model.Step), new(model.Perm), new(model.Org))
|
||||
defer closer()
|
||||
|
||||
g := goblin.Goblin(t)
|
||||
|
@ -40,6 +40,8 @@ func TestUsers(t *testing.T) {
|
|||
g.Assert(err).IsNil()
|
||||
_, err = store.engine.Exec("DELETE FROM steps")
|
||||
g.Assert(err).IsNil()
|
||||
_, err = store.engine.Exec("DELETE FROM orgs")
|
||||
g.Assert(err).IsNil()
|
||||
})
|
||||
|
||||
g.It("Should Update a User", func() {
|
||||
|
|
1
web/components.d.ts
vendored
1
web/components.d.ts
vendored
|
@ -104,6 +104,7 @@ declare module '@vue/runtime-core' {
|
|||
TextField: typeof import('./src/components/form/TextField.vue')['default']
|
||||
UserAPITab: typeof import('./src/components/user/UserAPITab.vue')['default']
|
||||
UserGeneralTab: typeof import('./src/components/user/UserGeneralTab.vue')['default']
|
||||
UserSecretsTab: typeof import('./src/components/user/UserSecretsTab.vue')['default']
|
||||
Warning: typeof import('./src/components/atomic/Warning.vue')['default']
|
||||
}
|
||||
}
|
||||
|
|
|
@ -431,6 +431,28 @@
|
|||
"general": "General",
|
||||
"language": "Language"
|
||||
},
|
||||
"secrets": {
|
||||
"secrets": "Secrets",
|
||||
"desc": "User secrets can be passed to all user's repository individual pipeline steps at runtime as environmental variables.",
|
||||
"none": "There are no user secrets yet.",
|
||||
"add": "Add secret",
|
||||
"save": "Save secret",
|
||||
"show": "Show secrets",
|
||||
"name": "Name",
|
||||
"value": "Value",
|
||||
"deleted": "User secret deleted",
|
||||
"created": "User secret created",
|
||||
"saved": "User secret saved",
|
||||
"images": {
|
||||
"images": "Available for following images",
|
||||
"desc": "Comma separated list of images where this secret is available, leave empty to allow all images"
|
||||
},
|
||||
"plugins_only": "Only available for plugins",
|
||||
"events": {
|
||||
"events": "Available at following events",
|
||||
"pr_warning": "Please be careful with this option as a bad actor can submit a malicious pull request that exposes your secrets."
|
||||
}
|
||||
},
|
||||
"api": {
|
||||
"api": "API",
|
||||
"desc": "Personal Access Token and API usage",
|
||||
|
|
117
web/src/components/user/UserSecretsTab.vue
Normal file
117
web/src/components/user/UserSecretsTab.vue
Normal file
|
@ -0,0 +1,117 @@
|
|||
<template>
|
||||
<Panel>
|
||||
<div class="flex flex-row border-b mb-4 pb-4 items-center dark:border-wp-background-100">
|
||||
<div class="ml-2">
|
||||
<h1 class="text-xl text-wp-text-100">{{ $t('user.settings.secrets.secrets') }}</h1>
|
||||
<p class="text-sm text-wp-text-alt-100">
|
||||
{{ $t('user.settings.secrets.desc') }}
|
||||
<DocsLink :topic="$t('user.settings.secrets.secrets')" url="docs/usage/secrets" />
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
v-if="selectedSecret"
|
||||
class="ml-auto"
|
||||
:text="$t('user.settings.secrets.show')"
|
||||
start-icon="back"
|
||||
@click="selectedSecret = undefined"
|
||||
/>
|
||||
<Button v-else class="ml-auto" :text="$t('user.settings.secrets.add')" start-icon="plus" @click="showAddSecret" />
|
||||
</div>
|
||||
|
||||
<SecretList
|
||||
v-if="!selectedSecret"
|
||||
v-model="secrets"
|
||||
i18n-prefix="user.settings.secrets."
|
||||
:is-deleting="isDeleting"
|
||||
@edit="editSecret"
|
||||
@delete="deleteSecret"
|
||||
/>
|
||||
|
||||
<SecretEdit
|
||||
v-else
|
||||
v-model="selectedSecret"
|
||||
i18n-prefix="user.settings.secrets."
|
||||
:is-saving="isSaving"
|
||||
@save="createSecret"
|
||||
@cancel="selectedSecret = undefined"
|
||||
/>
|
||||
</Panel>
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
import { cloneDeep } from 'lodash';
|
||||
import { computed, ref } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
import Button from '~/components/atomic/Button.vue';
|
||||
import DocsLink from '~/components/atomic/DocsLink.vue';
|
||||
import Panel from '~/components/layout/Panel.vue';
|
||||
import SecretEdit from '~/components/secrets/SecretEdit.vue';
|
||||
import SecretList from '~/components/secrets/SecretList.vue';
|
||||
import useApiClient from '~/compositions/useApiClient';
|
||||
import { useAsyncAction } from '~/compositions/useAsyncAction';
|
||||
import useAuthentication from '~/compositions/useAuthentication';
|
||||
import useNotifications from '~/compositions/useNotifications';
|
||||
import { usePagination } from '~/compositions/usePaginate';
|
||||
import { Secret, WebhookEvents } from '~/lib/api/types';
|
||||
|
||||
const emptySecret = {
|
||||
name: '',
|
||||
value: '',
|
||||
image: [],
|
||||
event: [WebhookEvents.Push],
|
||||
};
|
||||
|
||||
const apiClient = useApiClient();
|
||||
const notifications = useNotifications();
|
||||
const i18n = useI18n();
|
||||
|
||||
const { user } = useAuthentication();
|
||||
if (!user) {
|
||||
throw new Error('Unexpected: Unauthenticated');
|
||||
}
|
||||
const selectedSecret = ref<Partial<Secret>>();
|
||||
const isEditingSecret = computed(() => !!selectedSecret.value?.id);
|
||||
|
||||
async function loadSecrets(page: number): Promise<Secret[] | null> {
|
||||
if (!user) {
|
||||
throw new Error('Unexpected: Unauthenticated');
|
||||
}
|
||||
|
||||
return apiClient.getOrgSecretList(user.org_id, page);
|
||||
}
|
||||
|
||||
const { resetPage, data: secrets } = usePagination(loadSecrets, () => !selectedSecret.value);
|
||||
|
||||
const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {
|
||||
if (!selectedSecret.value) {
|
||||
throw new Error("Unexpected: Can't get secret");
|
||||
}
|
||||
|
||||
if (isEditingSecret.value) {
|
||||
await apiClient.updateOrgSecret(user.org_id, selectedSecret.value);
|
||||
} else {
|
||||
await apiClient.createOrgSecret(user.org_id, selectedSecret.value);
|
||||
}
|
||||
notifications.notify({
|
||||
title: i18n.t(isEditingSecret.value ? 'user.settings.secrets.saved' : 'user.settings.secrets.created'),
|
||||
type: 'success',
|
||||
});
|
||||
selectedSecret.value = undefined;
|
||||
resetPage();
|
||||
});
|
||||
|
||||
const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {
|
||||
await apiClient.deleteOrgSecret(user.org_id, _secret.name);
|
||||
notifications.notify({ title: i18n.t('user.settings.secrets.deleted'), type: 'success' });
|
||||
resetPage();
|
||||
});
|
||||
|
||||
function editSecret(secret: Secret) {
|
||||
selectedSecret.value = cloneDeep(secret);
|
||||
}
|
||||
|
||||
function showAddSecret() {
|
||||
selectedSecret.value = cloneDeep(emptySecret);
|
||||
}
|
||||
</script>
|
|
@ -17,4 +17,7 @@ export type User = {
|
|||
|
||||
active: boolean;
|
||||
// Whether the account is currently active.
|
||||
|
||||
org_id: number;
|
||||
// The ID of the org assigned to the user.
|
||||
};
|
||||
|
|
|
@ -5,6 +5,9 @@
|
|||
<Tab id="general" :title="$t('user.settings.general.general')">
|
||||
<UserGeneralTab />
|
||||
</Tab>
|
||||
<Tab id="secrets" :title="$t('user.settings.secrets.secrets')">
|
||||
<UserSecretsTab />
|
||||
</Tab>
|
||||
<Tab id="api" :title="$t('user.settings.api.api')">
|
||||
<UserAPITab />
|
||||
</Tab>
|
||||
|
@ -16,6 +19,7 @@ import Scaffold from '~/components/layout/scaffold/Scaffold.vue';
|
|||
import Tab from '~/components/layout/scaffold/Tab.vue';
|
||||
import UserAPITab from '~/components/user/UserAPITab.vue';
|
||||
import UserGeneralTab from '~/components/user/UserGeneralTab.vue';
|
||||
import UserSecretsTab from '~/components/user/UserSecretsTab.vue';
|
||||
import useConfig from '~/compositions/useConfig';
|
||||
|
||||
const address = `${window.location.protocol}//${window.location.host}${useConfig().rootPath}`; // port is included in location.host
|
||||
|
|
|
@ -6,9 +6,9 @@
|
|||
|
||||
<template #titleActions>
|
||||
<IconButton
|
||||
v-if="!org.is_user && orgPermissions.admin"
|
||||
v-if="orgPermissions.admin"
|
||||
icon="settings"
|
||||
:to="{ name: 'org-settings' }"
|
||||
:to="{ name: org.is_user ? 'user' : 'org-settings' }"
|
||||
:title="$t('org.settings.settings')"
|
||||
/>
|
||||
</template>
|
||||
|
|
|
@ -6,8 +6,8 @@
|
|||
|
||||
<template #titleActions>
|
||||
<IconButton
|
||||
v-if="!org.is_user && orgPermissions.admin"
|
||||
:to="{ name: 'repo-settings' }"
|
||||
v-if="orgPermissions.admin"
|
||||
:to="{ name: org.is_user ? 'user' : 'repo-settings' }"
|
||||
:title="$t('org.settings.settings')"
|
||||
icon="settings"
|
||||
/>
|
||||
|
@ -41,15 +41,8 @@ provide('org-permissions', orgPermissions);
|
|||
|
||||
async function load() {
|
||||
org.value = await apiClient.getOrg(orgId.value);
|
||||
if (org.value.is_user) {
|
||||
orgPermissions.value = {
|
||||
member: true,
|
||||
admin: true,
|
||||
};
|
||||
} else {
|
||||
orgPermissions.value = await apiClient.getOrgPermissions(org.value.id);
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load);
|
||||
watch(orgId, load);
|
||||
|
|
Loading…
Reference in a new issue