Support user secrets (#2126)

This commit is contained in:
qwerty287 2023-08-21 15:04:12 +02:00 committed by GitHub
parent 09624aa286
commit 81ead7cbf2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 251 additions and 22 deletions

View file

@ -139,7 +139,7 @@ ui-dependencies: ## Install UI dependencies
.PHONY: lint .PHONY: lint
lint: install-tools ## Lint code lint: install-tools ## Lint code
@echo "Running golangci-lint" @echo "Running golangci-lint"
golangci-lint run --timeout 10m golangci-lint run --timeout 15m
@echo "Running zerolog linter" @echo "Running zerolog linter"
lint github.com/woodpecker-ci/woodpecker/cmd/agent lint github.com/woodpecker-ci/woodpecker/cmd/agent
lint github.com/woodpecker-ci/woodpecker/cmd/cli lint github.com/woodpecker-ci/woodpecker/cmd/cli

View file

@ -4197,6 +4197,10 @@ const docTemplate = `{
"login": { "login": {
"description": "Login is the username for this user.\n\nrequired: true", "description": "Login is the username for this user.\n\nrequired: true",
"type": "string" "type": "string"
},
"org_id": {
"description": "OrgID is the of the user as model.Org.",
"type": "integer"
} }
} }
}, },

View file

@ -91,12 +91,15 @@ func GetOrgPermissions(c *gin.Context) {
return 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{ c.JSON(http.StatusOK, &model.OrgPerm{
Member: true, Member: true,
Admin: true, Admin: true,
}) })
return return
} else if org.IsUser {
c.JSON(http.StatusOK, &model.OrgPerm{})
return
} }
perm, err := server.Config.Services.Membership.Get(c, user, org.Name) perm, err := server.Config.Services.Membership.Get(c, user, org.Name)

View file

@ -64,6 +64,9 @@ type User struct {
// Hash is a unique token used to sign tokens. // Hash is a unique token used to sign tokens.
Hash string `json:"-" xorm:"UNIQUE varchar(500) 'user_hash'"` 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 } // @name User
// TableName return database table name for xorm // TableName return database table name for xorm

View file

@ -23,7 +23,7 @@ import (
) )
func TestGetPipelineQueue(t *testing.T) { 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() defer closer()
user := &model.User{ user := &model.User{
@ -64,7 +64,7 @@ func TestGetPipelineQueue(t *testing.T) {
} }
func TestUserFeed(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() defer closer()
user := &model.User{ user := &model.User{
@ -115,7 +115,7 @@ func TestUserFeed(t *testing.T) {
} }
func TestRepoListLatest(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() defer closer()
user := &model.User{ user := &model.User{

View 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")
},
}

View file

@ -54,6 +54,7 @@ var migrationTasks = []*task{
&migrateLogs2LogEntries, &migrateLogs2LogEntries,
&parentStepsToWorkflows, &parentStepsToWorkflows,
&addOrgs, &addOrgs,
&addOrgID,
} }
var allBeans = []interface{}{ var allBeans = []interface{}{

View file

@ -18,13 +18,18 @@ import (
"strings" "strings"
"github.com/woodpecker-ci/woodpecker/server/model" "github.com/woodpecker-ci/woodpecker/server/model"
"xorm.io/xorm"
) )
func (s storage) OrgCreate(org *model.Org) error { 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 // sanitize
org.Name = strings.ToLower(org.Name) org.Name = strings.ToLower(org.Name)
// insert // insert
_, err := s.engine.Insert(org) _, err := sess.Insert(org)
return err return err
} }

View file

@ -140,7 +140,7 @@ func TestRepos(t *testing.T) {
} }
func TestRepoList(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() defer closer()
user := &model.User{ user := &model.User{
@ -196,7 +196,7 @@ func TestRepoList(t *testing.T) {
} }
func TestOwnedRepoList(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() defer closer()
user := &model.User{ user := &model.User{

View file

@ -53,8 +53,18 @@ func (s storage) GetUserCount() (int64, error) {
} }
func (s storage) CreateUser(user *model.User) 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 // only Insert set auto created ID back to object
_, err := s.engine.Insert(user) _, err = sess.Insert(user)
return err return err
} }

View file

@ -24,7 +24,7 @@ import (
) )
func TestUsers(t *testing.T) { 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() defer closer()
g := goblin.Goblin(t) g := goblin.Goblin(t)
@ -40,6 +40,8 @@ func TestUsers(t *testing.T) {
g.Assert(err).IsNil() g.Assert(err).IsNil()
_, err = store.engine.Exec("DELETE FROM steps") _, err = store.engine.Exec("DELETE FROM steps")
g.Assert(err).IsNil() g.Assert(err).IsNil()
_, err = store.engine.Exec("DELETE FROM orgs")
g.Assert(err).IsNil()
}) })
g.It("Should Update a User", func() { g.It("Should Update a User", func() {

1
web/components.d.ts vendored
View file

@ -104,6 +104,7 @@ declare module '@vue/runtime-core' {
TextField: typeof import('./src/components/form/TextField.vue')['default'] TextField: typeof import('./src/components/form/TextField.vue')['default']
UserAPITab: typeof import('./src/components/user/UserAPITab.vue')['default'] UserAPITab: typeof import('./src/components/user/UserAPITab.vue')['default']
UserGeneralTab: typeof import('./src/components/user/UserGeneralTab.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'] Warning: typeof import('./src/components/atomic/Warning.vue')['default']
} }
} }

View file

@ -431,6 +431,28 @@
"general": "General", "general": "General",
"language": "Language" "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": "API", "api": "API",
"desc": "Personal Access Token and API usage", "desc": "Personal Access Token and API usage",

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

View file

@ -17,4 +17,7 @@ export type User = {
active: boolean; active: boolean;
// Whether the account is currently active. // Whether the account is currently active.
org_id: number;
// The ID of the org assigned to the user.
}; };

View file

@ -5,6 +5,9 @@
<Tab id="general" :title="$t('user.settings.general.general')"> <Tab id="general" :title="$t('user.settings.general.general')">
<UserGeneralTab /> <UserGeneralTab />
</Tab> </Tab>
<Tab id="secrets" :title="$t('user.settings.secrets.secrets')">
<UserSecretsTab />
</Tab>
<Tab id="api" :title="$t('user.settings.api.api')"> <Tab id="api" :title="$t('user.settings.api.api')">
<UserAPITab /> <UserAPITab />
</Tab> </Tab>
@ -16,6 +19,7 @@ import Scaffold from '~/components/layout/scaffold/Scaffold.vue';
import Tab from '~/components/layout/scaffold/Tab.vue'; import Tab from '~/components/layout/scaffold/Tab.vue';
import UserAPITab from '~/components/user/UserAPITab.vue'; import UserAPITab from '~/components/user/UserAPITab.vue';
import UserGeneralTab from '~/components/user/UserGeneralTab.vue'; import UserGeneralTab from '~/components/user/UserGeneralTab.vue';
import UserSecretsTab from '~/components/user/UserSecretsTab.vue';
import useConfig from '~/compositions/useConfig'; import useConfig from '~/compositions/useConfig';
const address = `${window.location.protocol}//${window.location.host}${useConfig().rootPath}`; // port is included in location.host const address = `${window.location.protocol}//${window.location.host}${useConfig().rootPath}`; // port is included in location.host

View file

@ -6,9 +6,9 @@
<template #titleActions> <template #titleActions>
<IconButton <IconButton
v-if="!org.is_user && orgPermissions.admin" v-if="orgPermissions.admin"
icon="settings" icon="settings"
:to="{ name: 'org-settings' }" :to="{ name: org.is_user ? 'user' : 'org-settings' }"
:title="$t('org.settings.settings')" :title="$t('org.settings.settings')"
/> />
</template> </template>

View file

@ -6,8 +6,8 @@
<template #titleActions> <template #titleActions>
<IconButton <IconButton
v-if="!org.is_user && orgPermissions.admin" v-if="orgPermissions.admin"
:to="{ name: 'repo-settings' }" :to="{ name: org.is_user ? 'user' : 'repo-settings' }"
:title="$t('org.settings.settings')" :title="$t('org.settings.settings')"
icon="settings" icon="settings"
/> />
@ -41,15 +41,8 @@ provide('org-permissions', orgPermissions);
async function load() { async function load() {
org.value = await apiClient.getOrg(orgId.value); 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); orgPermissions.value = await apiClient.getOrgPermissions(org.value.id);
} }
}
onMounted(load); onMounted(load);
watch(orgId, load); watch(orgId, load);