Show secrets from org and global level (#2873)

Co-authored-by: qwerty287 <80460567+qwerty287@users.noreply.github.com>
This commit is contained in:
Anbraten 2023-12-16 10:29:13 +01:00 committed by GitHub
parent 60a3922e02
commit 16803d6217
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
13 changed files with 191 additions and 94 deletions

View file

@ -4233,6 +4233,12 @@ const docTemplate = `{
"name": {
"type": "string"
},
"org_id": {
"type": "integer"
},
"repo_id": {
"type": "integer"
},
"value": {
"type": "string"
}

View file

@ -70,8 +70,8 @@ type SecretStore interface {
// Secret represents a secret variable, such as a password or token.
type Secret struct {
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
OrgID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_org_id'"`
RepoID int64 `json:"-" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"`
OrgID int64 `json:"org_id" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_org_id'"`
RepoID int64 `json:"repo_id" xorm:"NOT NULL DEFAULT 0 UNIQUE(s) INDEX 'secret_repo_id'"`
Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"`
Value string `json:"value,omitempty" xorm:"TEXT 'secret_value'"`
Images []string `json:"images" xorm:"json 'secret_images'"`
@ -89,15 +89,20 @@ func (s *Secret) BeforeInsert() {
}
// Global secret.
func (s Secret) Global() bool {
func (s Secret) IsGlobal() bool {
return s.RepoID == 0 && s.OrgID == 0
}
// Organization secret.
func (s Secret) Organization() bool {
func (s Secret) IsOrganization() bool {
return s.RepoID == 0 && s.OrgID != 0
}
// Repository secret.
func (s Secret) IsRepository() bool {
return s.RepoID != 0 && s.OrgID == 0
}
// Match returns true if an image and event match the restricted list.
func (s *Secret) Match(event WebhookEvent) bool {
if len(s.Events) == 0 {

View file

@ -48,16 +48,17 @@ func (b *builtin) SecretListPipeline(repo *model.Repo, _ *model.Pipeline, p *mod
// Priority order in case of duplicate names are repository, user/organization, global
secrets := make([]*model.Secret, 0, len(s))
uniq := make(map[string]struct{})
for _, cond := range []struct {
Global bool
Organization bool
for _, condition := range []struct {
IsRepository bool
IsOrganization bool
IsGlobal bool
}{
{},
{Organization: true},
{Global: true},
{IsRepository: true},
{IsOrganization: true},
{IsGlobal: true},
} {
for _, secret := range s {
if secret.Global() != cond.Global || secret.Organization() != cond.Organization {
if secret.IsRepository() != condition.IsRepository || secret.IsOrganization() != condition.IsOrganization || secret.IsGlobal() != condition.IsGlobal {
continue
}
if _, ok := uniq[secret.Name]; ok {

View file

@ -51,7 +51,7 @@ func TestSecretListPipeline(t *testing.T) {
// repo secret
repoSecret := &model.Secret{
ID: 3,
OrgID: 1,
OrgID: 0,
RepoID: 1,
Name: "secret",
Value: "value-repo",

View file

@ -253,7 +253,7 @@ func TestOrgSecretList(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, list, 1)
assert.True(t, list[0].Organization())
assert.True(t, list[0].IsOrganization())
}
func TestGlobalSecretFind(t *testing.T) {
@ -306,5 +306,5 @@ func TestGlobalSecretList(t *testing.T) {
assert.NoError(t, err)
assert.Len(t, list, 1)
assert.True(t, list[0].Global())
assert.True(t, list[0].IsGlobal())
}

View file

@ -140,7 +140,7 @@
"saved": "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"
"desc": "List of images where this secret is available, leave empty to allow all images"
},
"events": {
"events": "Available at following events",
@ -305,7 +305,7 @@
"saved": "Organization 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"
"desc": "List of images where this secret is available, leave empty to allow all images"
},
"plugins_only": "Only available for plugins",
"events": {
@ -334,7 +334,7 @@
"saved": "Global 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"
"desc": "List of images where this secret is available, leave empty to allow all images"
},
"plugins_only": "Only available for plugins",
"events": {
@ -476,7 +476,7 @@
"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"
"desc": "List of images where this secret is available, leave empty to allow all images"
},
"plugins_only": "Only available for plugins",
"events": {
@ -504,5 +504,7 @@
"default": "default",
"info": "Info",
"running_version": "You are running Woodpecker {0}",
"update_woodpecker": "Please update your Woodpecker instance to {0}"
"update_woodpecker": "Please update your Woodpecker instance to {0}",
"global_level_secret": "global secret",
"org_level_secret": "organization secret"
}

View file

@ -16,7 +16,7 @@
>
<slot>
<Icon v-if="startIcon" :name="startIcon" class="!w-6 !h-6" :class="{ invisible: isLoading, 'mr-1': text }" />
<span :class="{ invisible: isLoading }">{{ text }}</span>
<span :class="{ invisible: isLoading }" class="flex-shrink-0">{{ text }}</span>
<Icon v-if="endIcon" :name="endIcon" class="ml-2 w-6 h-6" :class="{ invisible: isLoading }" />
<div
v-if="isLoading"

View file

@ -16,7 +16,7 @@
<SecretList
v-if="!selectedSecret"
v-model="secrets"
:model-value="secrets"
i18n-prefix="repo.settings.secrets."
:is-deleting="isDeleting"
@edit="editSecret"
@ -64,15 +64,54 @@ const repo = inject<Ref<Repo>>('repo');
const selectedSecret = ref<Partial<Secret>>();
const isEditingSecret = computed(() => !!selectedSecret.value?.id);
async function loadSecrets(page: number): Promise<Secret[] | null> {
async function loadSecrets(page: number, level: 'repo' | 'org' | 'global'): Promise<Secret[] | null> {
if (!repo?.value) {
throw new Error("Unexpected: Can't load repo");
}
return apiClient.getSecretList(repo.value.id, page);
switch (level) {
case 'repo':
return apiClient.getSecretList(repo.value.id, page);
case 'org':
return apiClient.getOrgSecretList(repo.value.org_id, page);
case 'global':
return apiClient.getGlobalSecretList(page);
default:
throw new Error(`Unexpected level: ${level}`);
}
}
const { resetPage, data: secrets } = usePagination(loadSecrets, () => !selectedSecret.value);
const { resetPage, data: _secrets } = usePagination(loadSecrets, () => !selectedSecret.value, {
each: ['repo', 'org', 'global'],
});
const secrets = computed(() => {
const secretsList: Record<string, Secret & { edit?: boolean; level: 'repo' | 'org' | 'global' }> = {};
// eslint-disable-next-line no-restricted-syntax
for (const level of ['repo', 'org', 'global']) {
// eslint-disable-next-line no-restricted-syntax
for (const secret of _secrets.value) {
if (
((level === 'repo' && secret.repo_id !== 0 && secret.org_id === 0) ||
(level === 'org' && secret.repo_id === 0 && secret.org_id !== 0) ||
(level === 'global' && secret.repo_id === 0 && secret.org_id === 0)) &&
!secretsList[secret.name]
) {
secretsList[secret.name] = { ...secret, edit: secret.repo_id !== 0, level };
}
}
}
const levelsOrder = {
global: 0,
org: 1,
repo: 2,
};
return Object.values(secretsList)
.toSorted((a, b) => a.name.localeCompare(b.name))
.toSorted((a, b) => levelsOrder[b.level] - levelsOrder[a.level]);
});
const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {
if (!repo?.value) {
@ -93,7 +132,7 @@ const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async ()
type: 'success',
});
selectedSecret.value = undefined;
resetPage();
await resetPage();
});
const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {
@ -103,7 +142,7 @@ const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (
await apiClient.deleteSecret(repo.value.id, _secret.name);
notifications.notify({ title: i18n.t('repo.settings.secrets.deleted'), type: 'success' });
resetPage();
await resetPage();
});
function editSecret(secret: Secret) {

View file

@ -11,11 +11,27 @@
</InputField>
<InputField :label="$t(i18nPrefix + 'value')">
<TextField v-model="innerValue.value" :placeholder="$t(i18nPrefix + 'value')" :lines="5" />
<TextField
v-model="innerValue.value"
:placeholder="$t(i18nPrefix + 'value')"
:lines="5"
:required="!isEditingSecret"
/>
</InputField>
<InputField :label="$t(i18nPrefix + 'images.images')">
<TextField v-model="images" :placeholder="$t(i18nPrefix + 'images.desc')" />
<span class="ml-1 mb-2 text-wp-text-alt-100">{{ $t(i18nPrefix + 'images.desc') }}</span>
<div class="flex flex-col gap-2">
<div v-for="image in innerValue.images" :key="image" class="flex gap-2">
<TextField :model-value="image" disabled />
<Button type="button" color="gray" start-icon="trash" @click="removeImage(image)" />
</div>
<div class="flex gap-2">
<TextField v-model="newImage" @keydown.enter.prevent="addNewImage" />
<Button type="button" color="gray" start-icon="plus" @click="addNewImage" />
</div>
</div>
</InputField>
<InputField :label="$t(i18nPrefix + 'events.events')">
@ -36,7 +52,7 @@
</template>
<script lang="ts" setup>
import { computed, toRef } from 'vue';
import { computed, ref, toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from '~/components/atomic/Button.vue';
@ -67,21 +83,20 @@ const innerValue = computed({
emit('update:modelValue', value);
},
});
const images = computed<string>({
get() {
return innerValue.value?.images?.join(',') || '';
},
set(value) {
if (innerValue.value) {
innerValue.value.images = value
.split(',')
.map((s) => s.trim())
.filter((s) => s !== '');
}
},
});
const isEditingSecret = computed(() => !!innerValue.value?.id);
const newImage = ref('');
function addNewImage() {
if (!newImage.value) {
return;
}
innerValue.value.images?.push(newImage.value);
newImage.value = '';
}
function removeImage(image: string) {
innerValue.value.images = innerValue.value.images?.filter((i) => i !== image);
}
const secretEventsOptions: CheckboxOption[] = [
{ value: WebhookEvents.Push, text: i18n.t('repo.pipeline.event.push') },
{ value: WebhookEvents.Tag, text: i18n.t('repo.pipeline.event.tag') },
@ -99,6 +114,11 @@ function save() {
if (!innerValue.value) {
return;
}
if (newImage.value) {
innerValue.value.images?.push(newImage.value);
}
emit('save', innerValue.value);
}
</script>

View file

@ -6,22 +6,29 @@
class="items-center !bg-wp-background-200 !dark:bg-wp-background-100"
>
<span>{{ secret.name }}</span>
<Badge
v-if="secret.edit === false"
class="ml-2"
:label="secret.org_id === 0 ? $t('global_level_secret') : $t('org_level_secret')"
/>
<div class="ml-auto space-x-2 <md:hidden">
<Badge v-for="event in secret.events" :key="event" :label="event" />
</div>
<IconButton
icon="edit"
class="ml-2 <md:ml-auto w-8 h-8"
:title="$t('repo.settings.secrets.edit')"
@click="editSecret(secret)"
/>
<IconButton
icon="trash"
class="ml-2 w-8 h-8 hover:text-wp-control-error-100"
:is-loading="isDeleting"
:title="$t('repo.settings.secrets.delete')"
@click="deleteSecret(secret)"
/>
<template v-if="secret.edit !== false">
<IconButton
icon="edit"
class="ml-2 <md:ml-auto w-8 h-8"
:title="$t('repo.settings.secrets.edit')"
@click="editSecret(secret)"
/>
<IconButton
icon="trash"
class="ml-2 w-8 h-8 hover:text-wp-control-error-100"
:is-loading="isDeleting"
:title="$t('repo.settings.secrets.delete')"
@click="deleteSecret(secret)"
/>
</template>
</ListItem>
<div v-if="secrets?.length === 0" class="ml-2">{{ $t(i18nPrefix + 'none') }}</div>
@ -32,12 +39,13 @@
import { toRef } from 'vue';
import { useI18n } from 'vue-i18n';
import Badge from '~/components/atomic/Badge.vue';
import IconButton from '~/components/atomic/IconButton.vue';
import ListItem from '~/components/atomic/ListItem.vue';
import { Secret } from '~/lib/api/types';
const props = defineProps<{
modelValue: Secret[];
modelValue: (Secret & { edit?: boolean })[];
isDeleting: boolean;
i18nPrefix: string;
}>();

View file

@ -1,5 +1,5 @@
import { useInfiniteScroll } from '@vueuse/core';
import { onMounted, Ref, ref, watch } from 'vue';
import { onMounted, Ref, ref, UnwrapRef, watch } from 'vue';
export async function usePaginate<T>(getSingle: (page: number) => Promise<T[]>): Promise<T[]> {
let hasMore = true;
@ -15,59 +15,71 @@ export async function usePaginate<T>(getSingle: (page: number) => Promise<T[]>):
return result;
}
export function usePagination<T>(
_loadData: (page: number) => Promise<T[] | null>,
export function usePagination<T, S = unknown>(
_loadData: (page: number, arg: S) => Promise<T[] | null>,
isActive: () => boolean = () => true,
scrollElement = ref(document.getElementById('scroll-component')),
{ scrollElement: _scrollElement, each: _each }: { scrollElement?: Ref<HTMLElement | null>; each?: S[] } = {},
) {
const scrollElement = _scrollElement ?? ref(document.getElementById('scroll-component'));
const page = ref(1);
const pageSize = ref(0);
const hasMore = ref(true);
const data = ref<T[]>([]) as Ref<T[]>;
const loading = ref(false);
const each = ref(_each ?? []);
async function loadData() {
loading.value = true;
const newData = await _loadData(page.value);
hasMore.value = newData !== null && newData.length >= pageSize.value;
if (newData !== null && newData.length !== 0) {
if (page.value === 1) {
pageSize.value = newData.length;
data.value = newData;
} else {
data.value.push(...newData);
}
} else if (page.value === 1) {
data.value = [];
} else {
hasMore.value = false;
if (loading.value === true || hasMore.value === false) {
return;
}
loading.value = true;
const newData = (await _loadData(page.value, each.value?.[0] as S)) ?? [];
hasMore.value = newData.length >= pageSize.value && newData.length > 0;
if (newData.length > 0) {
data.value.push(...newData);
}
// last page and each has more
if (!hasMore.value && each.value.length > 0) {
// use next each element
each.value.shift();
page.value = 1;
pageSize.value = 0;
hasMore.value = each.value.length > 0;
if (hasMore.value) {
loading.value = false;
await loadData();
}
}
pageSize.value = newData.length;
loading.value = false;
}
onMounted(loadData);
watch(page, loadData);
useInfiniteScroll(
scrollElement,
() => {
if (isActive() && !loading.value && hasMore.value) {
// load more
page.value += 1;
}
},
{ distance: 10 },
);
const resetPage = () => {
if (page.value !== 1) {
// just set page = 1, will be handled by watcher
page.value = 1;
} else {
// we need to reload, but page is already 1, so changing won't trigger watcher
loadData();
function nextPage() {
if (isActive() && !loading.value && hasMore.value) {
page.value += 1;
}
};
}
return { resetPage, data };
useInfiniteScroll(scrollElement, nextPage, { distance: 10 });
async function resetPage() {
const _page = page.value;
hasMore.value = true;
data.value = [];
each.value = (_each ?? []) as UnwrapRef<S[]>;
page.value = 1;
if (_page === 1) {
// we need to reload manually as the page is already 1, so changing won't trigger watcher
await loadData();
}
}
return { resetPage, nextPage, data, hasMore, loading };
}

View file

@ -2,6 +2,8 @@ import { WebhookEvents } from './webhook';
export type Secret = {
id: string;
repo_id: number;
org_id: number;
name: string;
value: string;
events: WebhookEvents[];

View file

@ -141,6 +141,8 @@ type (
// Secret represents a secret variable, such as a password or token.
Secret struct {
ID int64 `json:"id"`
OrgID int64 `json:"org_id"`
RepoID int64 `json:"repo_id"`
Name string `json:"name"`
Value string `json:"value,omitempty"`
Images []string `json:"images"`