Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Support user secrets #2126

Merged
merged 23 commits into from
Aug 21, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions cmd/server/docs/docs.go

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

5 changes: 4 additions & 1 deletion server/api/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
3 changes: 3 additions & 0 deletions server/model/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 3 additions & 3 deletions server/store/datastore/feed_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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{
Expand Down Expand Up @@ -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{
Expand Down
61 changes: 61 additions & 0 deletions server/store/datastore/migration/022_add_org_id.go
Original file line number Diff line number Diff line change
@@ -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")
},
}
1 change: 1 addition & 0 deletions server/store/datastore/migration/migration.go
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ var migrationTasks = []*task{
&migrateLogs2LogEntries,
&parentStepsToWorkflows,
&addOrgs,
&addOrgID,
}

var allBeans = []interface{}{
Expand Down
7 changes: 6 additions & 1 deletion server/store/datastore/org.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 2 additions & 2 deletions server/store/datastore/repo_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{
Expand Down Expand Up @@ -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{
Expand Down
12 changes: 11 additions & 1 deletion server/store/datastore/user.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}

Expand Down
4 changes: 3 additions & 1 deletion server/store/datastore/users_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand All @@ -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() {
Expand Down
1 change: 1 addition & 0 deletions web/components.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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']
}
}
22 changes: 22 additions & 0 deletions web/src/assets/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
117 changes: 117 additions & 0 deletions web/src/components/user/UserSecretsTab.vue
Original file line number Diff line number Diff line change
@@ -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>
3 changes: 3 additions & 0 deletions web/src/lib/api/types/user.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
};
Loading