Use id to access orgs (#1873)

closes #1743 

fixes: setting secrets for own user namespace

- create org in database
- use orgID for org related APIs

Co-authored-by: 6543 <6543@obermui.de>
This commit is contained in:
Anbraten 2023-07-21 19:45:32 +02:00 committed by GitHub
parent aec2051071
commit e5d5ec8b47
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
51 changed files with 1261 additions and 392 deletions

View file

@ -72,3 +72,9 @@ var RepoFlag = &cli.StringFlag{
Aliases: []string{"repo"},
Usage: "repository id or full-name (e.g. 134 or octocat/hello-world)",
}
var OrgFlag = &cli.StringFlag{
Name: "organization",
Aliases: []string{"org"},
Usage: "organization id or full-name (e.g. 123 or octocat)",
}

View file

@ -1,6 +1,8 @@
package secret
import (
"fmt"
"strconv"
"strings"
"github.com/urfave/cli/v2"
@ -24,9 +26,9 @@ var Command = &cli.Command{
},
}
func parseTargetArgs(client woodpecker.Client, c *cli.Context) (global bool, owner string, repoID int64, err error) {
func parseTargetArgs(client woodpecker.Client, c *cli.Context) (global bool, orgID, repoID int64, err error) {
if c.Bool("global") {
return true, "", -1, nil
return true, -1, -1, nil
}
repoIDOrFullName := c.String("repository")
@ -34,19 +36,36 @@ func parseTargetArgs(client woodpecker.Client, c *cli.Context) (global bool, own
repoIDOrFullName = c.Args().First()
}
orgName := c.String("organization")
if orgName != "" && repoIDOrFullName == "" {
return false, orgName, -1, err
orgIDOrName := c.String("organization")
if orgIDOrName == "" && repoIDOrFullName == "" {
if err := cli.ShowSubcommandHelp(c); err != nil {
return false, -1, -1, err
}
return false, -1, -1, fmt.Errorf("missing arguments")
}
if orgName != "" && !strings.Contains(repoIDOrFullName, "/") {
repoIDOrFullName = orgName + "/" + repoIDOrFullName
if orgIDOrName != "" && repoIDOrFullName == "" {
if orgID, err := strconv.ParseInt(orgIDOrName, 10, 64); err == nil {
return false, orgID, -1, nil
}
org, err := client.OrgLookup(orgIDOrName)
if err != nil {
return false, -1, -1, err
}
return false, org.ID, -1, nil
}
if orgIDOrName != "" && !strings.Contains(repoIDOrFullName, "/") {
repoIDOrFullName = orgIDOrName + "/" + repoIDOrFullName
}
repoID, err = internal.ParseRepo(client, repoIDOrFullName)
if err != nil {
return false, "", -1, err
return false, -1, -1, err
}
return false, "", repoID, nil
return false, -1, repoID, nil
}

View file

@ -21,10 +21,7 @@ var secretCreateCmd = &cli.Command{
Name: "global",
Usage: "global secret",
},
&cli.StringFlag{
Name: "organization",
Usage: "organization name (e.g. octocat)",
},
common.OrgFlag,
common.RepoFlag,
&cli.StringFlag{
Name: "name",
@ -74,7 +71,7 @@ func secretCreate(c *cli.Context) error {
secret.Value = string(out)
}
global, owner, repoID, err := parseTargetArgs(client, c)
global, orgID, repoID, err := parseTargetArgs(client, c)
if err != nil {
return err
}
@ -83,10 +80,12 @@ func secretCreate(c *cli.Context) error {
_, err = client.GlobalSecretCreate(secret)
return err
}
if owner != "" {
_, err = client.OrgSecretCreate(owner, secret)
if orgID != -1 {
_, err = client.OrgSecretCreate(orgID, secret)
return err
}
_, err = client.SecretCreate(repoID, secret)
return err
}

View file

@ -21,10 +21,7 @@ var secretInfoCmd = &cli.Command{
Name: "global",
Usage: "global secret",
},
&cli.StringFlag{
Name: "organization",
Usage: "organization name (e.g. octocat)",
},
common.OrgFlag,
common.RepoFlag,
&cli.StringFlag{
Name: "name",
@ -44,7 +41,7 @@ func secretInfo(c *cli.Context) error {
return err
}
global, owner, repoID, err := parseTargetArgs(client, c)
global, orgID, repoID, err := parseTargetArgs(client, c)
if err != nil {
return err
}
@ -55,8 +52,8 @@ func secretInfo(c *cli.Context) error {
if err != nil {
return err
}
} else if owner != "" {
secret, err = client.OrgSecret(owner, secretName)
} else if orgID != -1 {
secret, err = client.OrgSecret(orgID, secretName)
if err != nil {
return err
}

View file

@ -22,10 +22,7 @@ var secretListCmd = &cli.Command{
Name: "global",
Usage: "global secret",
},
&cli.StringFlag{
Name: "organization",
Usage: "organization name (e.g. octocat)",
},
common.OrgFlag,
common.RepoFlag,
common.FormatFlag(tmplSecretList, true),
),
@ -39,7 +36,7 @@ func secretList(c *cli.Context) error {
return err
}
global, owner, repoID, err := parseTargetArgs(client, c)
global, orgID, repoID, err := parseTargetArgs(client, c)
if err != nil {
return err
}
@ -50,8 +47,8 @@ func secretList(c *cli.Context) error {
if err != nil {
return err
}
} else if owner != "" {
list, err = client.OrgSecretList(owner)
} else if orgID != -1 {
list, err = client.OrgSecretList(orgID)
if err != nil {
return err
}

View file

@ -17,10 +17,7 @@ var secretDeleteCmd = &cli.Command{
Name: "global",
Usage: "global secret",
},
&cli.StringFlag{
Name: "organization",
Usage: "organization name (e.g. octocat)",
},
common.OrgFlag,
common.RepoFlag,
&cli.StringFlag{
Name: "name",
@ -37,7 +34,7 @@ func secretDelete(c *cli.Context) error {
return err
}
global, owner, repoID, err := parseTargetArgs(client, c)
global, orgID, repoID, err := parseTargetArgs(client, c)
if err != nil {
return err
}
@ -45,8 +42,8 @@ func secretDelete(c *cli.Context) error {
if global {
return client.GlobalSecretDelete(secretName)
}
if owner != "" {
return client.OrgSecretDelete(owner, secretName)
if orgID != -1 {
return client.OrgSecretDelete(orgID, secretName)
}
return client.SecretDelete(repoID, secretName)
}

View file

@ -21,10 +21,7 @@ var secretUpdateCmd = &cli.Command{
Name: "global",
Usage: "global secret",
},
&cli.StringFlag{
Name: "organization",
Usage: "organization name (e.g. octocat)",
},
common.OrgFlag,
common.RepoFlag,
&cli.StringFlag{
Name: "name",
@ -71,7 +68,7 @@ func secretUpdate(c *cli.Context) error {
secret.Value = string(out)
}
global, owner, repoID, err := parseTargetArgs(client, c)
global, orgID, repoID, err := parseTargetArgs(client, c)
if err != nil {
return err
}
@ -80,8 +77,8 @@ func secretUpdate(c *cli.Context) error {
_, err = client.GlobalSecretUpdate(secret)
return err
}
if owner != "" {
_, err = client.OrgSecretUpdate(owner, secret)
if orgID != -1 {
_, err = client.OrgSecretUpdate(orgID, secret)
return err
}
_, err = client.SecretUpdate(repoID, secret)

View file

@ -783,7 +783,82 @@ const docTemplate = `{
}
}
},
"/orgs/{owner}/permissions": {
"/org/lookup/{org_full_name}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Organizations"
],
"summary": "Lookup organization by full-name",
"parameters": [
{
"type": "string",
"default": "Bearer \u003cpersonal access token\u003e",
"description": "Insert your personal access token",
"name": "Authorization",
"in": "header",
"required": true
},
{
"type": "string",
"description": "the organizations full-name / slug",
"name": "org_full_name",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/Org"
}
}
}
}
},
"/orgs/{org_id}": {
"get": {
"produces": [
"application/json"
],
"tags": [
"Organization"
],
"summary": "Get organization by id",
"parameters": [
{
"type": "string",
"default": "Bearer \u003cpersonal access token\u003e",
"description": "Insert your personal access token",
"name": "Authorization",
"in": "header",
"required": true
},
{
"type": "string",
"description": "the organziation's id",
"name": "org_id",
"in": "path",
"required": true
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"type": "array",
"items": {
"$ref": "#/definitions/Org"
}
}
}
}
}
},
"/orgs/{org_id}/permissions": {
"get": {
"produces": [
"application/json"
@ -803,8 +878,8 @@ const docTemplate = `{
},
{
"type": "string",
"description": "the owner's name",
"name": "owner",
"description": "the organziation's id",
"name": "org_id",
"in": "path",
"required": true
}
@ -822,7 +897,7 @@ const docTemplate = `{
}
}
},
"/orgs/{owner}/secrets": {
"/orgs/{org_id}/secrets": {
"get": {
"produces": [
"application/json"
@ -842,8 +917,8 @@ const docTemplate = `{
},
{
"type": "string",
"description": "the owner's name",
"name": "owner",
"description": "the org's id",
"name": "org_id",
"in": "path",
"required": true
},
@ -873,52 +948,9 @@ const docTemplate = `{
}
}
}
},
"post": {
"produces": [
"application/json"
],
"tags": [
"Organization secrets"
],
"summary": "Persist/create an organization secret",
"parameters": [
{
"type": "string",
"default": "Bearer \u003cpersonal access token\u003e",
"description": "Insert your personal access token",
"name": "Authorization",
"in": "header",
"required": true
},
{
"type": "string",
"description": "the owner's name",
"name": "owner",
"in": "path",
"required": true
},
{
"description": "the new secret",
"name": "secretData",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/Secret"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/Secret"
}
}
}
}
},
"/orgs/{owner}/secrets/{secret}": {
"/orgs/{org_id}/secrets/{secret}": {
"get": {
"produces": [
"application/json"
@ -938,8 +970,8 @@ const docTemplate = `{
},
{
"type": "string",
"description": "the owner's name",
"name": "owner",
"description": "the org's id",
"name": "org_id",
"in": "path",
"required": true
},
@ -979,8 +1011,8 @@ const docTemplate = `{
},
{
"type": "string",
"description": "the owner's name",
"name": "owner",
"description": "the org's id",
"name": "org_id",
"in": "path",
"required": true
},
@ -1017,8 +1049,8 @@ const docTemplate = `{
},
{
"type": "string",
"description": "the owner's name",
"name": "owner",
"description": "the org's id",
"name": "org_id",
"in": "path",
"required": true
},
@ -1049,6 +1081,51 @@ const docTemplate = `{
}
}
},
"/orgs/{owner}/secrets": {
"post": {
"produces": [
"application/json"
],
"tags": [
"Organization secrets"
],
"summary": "Persist/create an organization secret",
"parameters": [
{
"type": "string",
"default": "Bearer \u003cpersonal access token\u003e",
"description": "Insert your personal access token",
"name": "Authorization",
"in": "header",
"required": true
},
{
"type": "string",
"description": "the org's id",
"name": "org_id",
"in": "path",
"required": true
},
{
"description": "the new secret",
"name": "secretData",
"in": "body",
"required": true,
"schema": {
"$ref": "#/definitions/Secret"
}
}
],
"responses": {
"200": {
"description": "OK",
"schema": {
"$ref": "#/definitions/Secret"
}
}
}
}
},
"/pipelines": {
"get": {
"produces": [
@ -3603,6 +3680,20 @@ const docTemplate = `{
}
}
},
"Org": {
"type": "object",
"properties": {
"id": {
"type": "integer"
},
"is_user": {
"type": "boolean"
},
"name": {
"type": "string"
}
}
},
"OrgPerm": {
"type": "object",
"properties": {
@ -3845,6 +3936,9 @@ const docTemplate = `{
"netrc_only_trusted": {
"type": "boolean"
},
"org_id": {
"type": "integer"
},
"owner": {
"type": "string"
},

View file

@ -86,7 +86,7 @@ func HandleAuth(c *gin.Context) {
if len(config.Orgs) != 0 {
teams, terr := _forge.Teams(c, tmpuser)
if terr != nil || !config.IsMember(teams) {
log.Error().Msgf("cannot verify team membership for %s.", u.Login)
log.Error().Err(terr).Msgf("cannot verify team membership for %s.", u.Login)
c.Redirect(303, "/login?error=access_denied")
return
}
@ -111,6 +111,15 @@ func HandleAuth(c *gin.Context) {
c.Redirect(http.StatusSeeOther, "/login?error=internal_error")
return
}
// if another user already have activated repos on behave of that user,
// the user was stored as org. now we adopt it to the user.
if org, err := _store.OrgFindByName(u.Login); err == nil && org != nil {
org.IsUser = true
if err := _store.OrgUpdate(org); err != nil {
log.Error().Err(err).Msgf("on user creation, could not mark org as user")
}
}
}
// update the user meta data and authorization data.
@ -127,7 +136,7 @@ func HandleAuth(c *gin.Context) {
if len(config.Orgs) != 0 {
teams, terr := _forge.Teams(c, u)
if terr != nil || !config.IsMember(teams) {
log.Error().Msgf("cannot verify team membership for %s.", u.Login)
log.Error().Err(terr).Msgf("cannot verify team membership for %s.", u.Login)
c.Redirect(http.StatusSeeOther, "/login?error=access_denied")
return
}

View file

@ -15,41 +15,149 @@
package api
import (
"errors"
"net/http"
"strconv"
"strings"
"github.com/rs/zerolog/log"
"github.com/woodpecker-ci/woodpecker/server"
"github.com/woodpecker-ci/woodpecker/server/model"
"github.com/woodpecker-ci/woodpecker/server/router/middleware/session"
"github.com/woodpecker-ci/woodpecker/server/store"
"github.com/woodpecker-ci/woodpecker/server/store/types"
"github.com/gin-gonic/gin"
)
// GetOrg
//
// @Summary Get organization by id
// @Router /orgs/{org_id} [get]
// @Produce json
// @Success 200 {array} Org
// @Tags Organization
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
// @Param org_id path string true "the organziation's id"
func GetOrg(c *gin.Context) {
_store := store.FromContext(c)
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
return
}
org, err := _store.OrgGet(orgID)
if err != nil {
if errors.Is(err, types.RecordNotExist) {
c.AbortWithStatus(http.StatusNotFound)
return
}
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
c.JSON(http.StatusOK, org)
}
// GetOrgPermissions
//
// @Summary Get the permissions of the current user in the given organization
// @Router /orgs/{owner}/permissions [get]
// @Router /orgs/{org_id}/permissions [get]
// @Produce json
// @Success 200 {array} OrgPerm
// @Tags Organization permissions
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
// @Param owner path string true "the owner's name"
// @Param org_id path string true "the organziation's id"
func GetOrgPermissions(c *gin.Context) {
var (
err error
user = session.User(c)
owner = c.Param("owner")
)
user := session.User(c)
_store := store.FromContext(c)
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
return
}
if user == nil {
c.JSON(http.StatusOK, &model.OrgPerm{})
return
}
perm, err := server.Config.Services.Membership.Get(c, user, owner)
org, err := _store.OrgGet(orgID)
if err != nil {
c.String(http.StatusInternalServerError, "Error getting membership for %q. %s", owner, err)
c.String(http.StatusInternalServerError, "Error getting org %d. %s", orgID, err)
return
}
if (org.IsUser && org.Name == user.Login) || user.Admin {
c.JSON(http.StatusOK, &model.OrgPerm{
Member: true,
Admin: true,
})
return
}
perm, err := server.Config.Services.Membership.Get(c, user, org.Name)
if err != nil {
c.String(http.StatusInternalServerError, "Error getting membership for %d. %s", orgID, err)
return
}
c.JSON(http.StatusOK, perm)
}
// LookupOrg
//
// @Summary Lookup organization by full-name
// @Router /org/lookup/{org_full_name} [get]
// @Produce json
// @Success 200 {object} Org
// @Tags Organizations
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
// @Param org_full_name path string true "the organizations full-name / slug"
func LookupOrg(c *gin.Context) {
_store := store.FromContext(c)
orgFullName := strings.TrimLeft(c.Param("org_full_name"), "/")
org, err := _store.OrgFindByName(orgFullName)
if err != nil {
if errors.Is(err, types.RecordNotExist) {
c.AbortWithStatus(http.StatusNotFound)
return
}
_ = c.AbortWithError(http.StatusInternalServerError, err)
return
}
// don't leak private org infos
if org.Private {
user := session.User(c)
if user == nil {
c.AbortWithStatus(http.StatusNotFound)
return
}
if !user.Admin && org.Name != user.Login {
c.AbortWithStatus(http.StatusNotFound)
return
} else if !user.Admin {
perm, err := server.Config.Services.Membership.Get(c, user, org.Name)
if err != nil {
log.Error().Msgf("Failed to check membership: %v", err)
c.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
return
}
if perm == nil || !perm.Member {
c.AbortWithStatus(http.StatusNotFound)
return
}
}
}
c.JSON(http.StatusOK, org)
}

View file

@ -16,6 +16,7 @@ package api
import (
"net/http"
"strconv"
"github.com/gin-gonic/gin"
"github.com/woodpecker-ci/woodpecker/server/router/middleware/session"
@ -27,19 +28,23 @@ import (
// GetOrgSecret
//
// @Summary Get the named organization secret
// @Router /orgs/{owner}/secrets/{secret} [get]
// @Router /orgs/{org_id}/secrets/{secret} [get]
// @Produce json
// @Success 200 {object} Secret
// @Tags Organization secrets
// @Tags Organization secrets
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
// @Param owner path string true "the owner's name"
// @Param org_id path string true "the org's id"
// @Param secret path string true "the secret's name"
func GetOrgSecret(c *gin.Context) {
var (
owner = c.Param("owner")
name = c.Param("secret")
)
secret, err := server.Config.Services.Secrets.OrgSecretFind(owner, name)
name := c.Param("secret")
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
return
}
secret, err := server.Config.Services.Secrets.OrgSecretFind(orgID, name)
if err != nil {
handleDbGetError(c, err)
return
@ -50,19 +55,24 @@ func GetOrgSecret(c *gin.Context) {
// GetOrgSecretList
//
// @Summary Get the organization secret list
// @Router /orgs/{owner}/secrets [get]
// @Router /orgs/{org_id}/secrets [get]
// @Produce json
// @Success 200 {array} Secret
// @Tags Organization secrets
// @Tags Organization secrets
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
// @Param owner path string true "the owner's name"
// @Param org_id path string true "the org's id"
// @Param page query int false "for response pagination, page offset number" default(1)
// @Param perPage query int false "for response pagination, max items per page" default(50)
// @Param perPage query int false "for response pagination, max items per page" default(50)
func GetOrgSecretList(c *gin.Context) {
owner := c.Param("owner")
list, err := server.Config.Services.Secrets.OrgSecretList(owner, session.Pagination(c))
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
if err != nil {
c.String(http.StatusInternalServerError, "Error getting secret list for %q. %s", owner, err)
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
return
}
list, err := server.Config.Services.Secrets.OrgSecretList(orgID, session.Pagination(c))
if err != nil {
c.String(http.StatusInternalServerError, "Error getting secret list for %q. %s", orgID, err)
return
}
// copy the secret detail to remove the sensitive
@ -79,20 +89,24 @@ func GetOrgSecretList(c *gin.Context) {
// @Router /orgs/{owner}/secrets [post]
// @Produce json
// @Success 200 {object} Secret
// @Tags Organization secrets
// @Tags Organization secrets
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
// @Param owner path string true "the owner's name"
// @Param org_id path string true "the org's id"
// @Param secretData body Secret true "the new secret"
func PostOrgSecret(c *gin.Context) {
owner := c.Param("owner")
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
return
}
in := new(model.Secret)
if err := c.Bind(in); err != nil {
c.String(http.StatusBadRequest, "Error parsing org %q secret. %s", owner, err)
c.String(http.StatusBadRequest, "Error parsing org %q secret. %s", orgID, err)
return
}
secret := &model.Secret{
Owner: owner,
OrgID: orgID,
Name: in.Name,
Value: in.Value,
Events: in.Events,
@ -100,11 +114,11 @@ func PostOrgSecret(c *gin.Context) {
PluginsOnly: in.PluginsOnly,
}
if err := secret.Validate(); err != nil {
c.String(http.StatusUnprocessableEntity, "Error inserting org %q secret. %s", owner, err)
c.String(http.StatusUnprocessableEntity, "Error inserting org %q secret. %s", orgID, err)
return
}
if err := server.Config.Services.Secrets.OrgSecretCreate(owner, secret); err != nil {
c.String(http.StatusInternalServerError, "Error inserting org %q secret %q. %s", owner, in.Name, err)
if err := server.Config.Services.Secrets.OrgSecretCreate(orgID, secret); err != nil {
c.String(http.StatusInternalServerError, "Error inserting org %q secret %q. %s", orgID, in.Name, err)
return
}
c.JSON(http.StatusOK, secret.Copy())
@ -113,28 +127,30 @@ func PostOrgSecret(c *gin.Context) {
// PatchOrgSecret
//
// @Summary Update an organization secret
// @Router /orgs/{owner}/secrets/{secret} [patch]
// @Router /orgs/{org_id}/secrets/{secret} [patch]
// @Produce json
// @Success 200 {object} Secret
// @Tags Organization secrets
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
// @Param owner path string true "the owner's name"
// @Param org_id path string true "the org's id"
// @Param secret path string true "the secret's name"
// @Param secretData body Secret true "the update secret data"
// @Param secretData body Secret true "the update secret data"
func PatchOrgSecret(c *gin.Context) {
var (
owner = c.Param("owner")
name = c.Param("secret")
)
name := c.Param("secret")
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
return
}
in := new(model.Secret)
err := c.Bind(in)
err = c.Bind(in)
if err != nil {
c.String(http.StatusBadRequest, "Error parsing secret. %s", err)
return
}
secret, err := server.Config.Services.Secrets.OrgSecretFind(owner, name)
secret, err := server.Config.Services.Secrets.OrgSecretFind(orgID, name)
if err != nil {
handleDbGetError(c, err)
return
@ -151,11 +167,11 @@ func PatchOrgSecret(c *gin.Context) {
secret.PluginsOnly = in.PluginsOnly
if err := secret.Validate(); err != nil {
c.String(http.StatusUnprocessableEntity, "Error updating org %q secret. %s", owner, err)
c.String(http.StatusUnprocessableEntity, "Error updating org %q secret. %s", orgID, err)
return
}
if err := server.Config.Services.Secrets.OrgSecretUpdate(owner, secret); err != nil {
c.String(http.StatusInternalServerError, "Error updating org %q secret %q. %s", owner, in.Name, err)
if err := server.Config.Services.Secrets.OrgSecretUpdate(orgID, secret); err != nil {
c.String(http.StatusInternalServerError, "Error updating org %q secret %q. %s", orgID, in.Name, err)
return
}
c.JSON(http.StatusOK, secret.Copy())
@ -164,19 +180,22 @@ func PatchOrgSecret(c *gin.Context) {
// DeleteOrgSecret
//
// @Summary Delete the named secret from an organization
// @Router /orgs/{owner}/secrets/{secret} [delete]
// @Router /orgs/{org_id}/secrets/{secret} [delete]
// @Produce plain
// @Success 200
// @Tags Organization secrets
// @Param Authorization header string true "Insert your personal access token" default(Bearer <personal access token>)
// @Param owner path string true "the owner's name"
// @Param secret path string true "the secret's name"
// @Param org_id path string true "the org's id"
// @Param secret path string true "the secret's name"
func DeleteOrgSecret(c *gin.Context) {
var (
owner = c.Param("owner")
name = c.Param("secret")
)
if err := server.Config.Services.Secrets.OrgSecretDelete(owner, name); err != nil {
name := c.Param("secret")
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
return
}
if err := server.Config.Services.Secrets.OrgSecretDelete(orgID, name); err != nil {
handleDbGetError(c, err)
return
}

View file

@ -119,6 +119,31 @@ func PostRepo(c *gin.Context) {
sig,
)
// find org of repo
var org *model.Org
org, err = _store.OrgFindByName(repo.Owner)
if err != nil && !errors.Is(err, types.RecordNotExist) {
c.String(http.StatusInternalServerError, err.Error())
return
}
// create an org if it doesn't exist yet
if errors.Is(err, types.RecordNotExist) {
org, err = forge.Org(c, user, repo.Owner)
if err != nil {
c.String(http.StatusInternalServerError, "Could not fetch organization from forge.")
return
}
err = _store.OrgCreate(org)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())
return
}
}
repo.OrgID = org.ID
err = forge.Activate(c, user, repo, link)
if err != nil {
c.String(http.StatusInternalServerError, err.Error())

View file

@ -16,6 +16,7 @@ package cache
import (
"context"
"fmt"
"time"
"github.com/woodpecker-ci/woodpecker/server/forge"
@ -27,37 +28,37 @@ import (
// MembershipService is a service to check for user membership.
type MembershipService interface {
// Get returns if the user is a member of the organization.
Get(ctx context.Context, u *model.User, name string) (*model.OrgPerm, error)
Get(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error)
}
type membershipCache struct {
Forge forge.Forge
Cache *ttlcache.Cache[string, *model.OrgPerm]
TTL time.Duration
forge forge.Forge
cache *ttlcache.Cache[string, *model.OrgPerm]
ttl time.Duration
}
// NewMembershipService creates a new membership service.
func NewMembershipService(f forge.Forge) MembershipService {
return &membershipCache{
TTL: 10 * time.Minute,
Forge: f,
Cache: ttlcache.New(ttlcache.WithDisableTouchOnHit[string, *model.OrgPerm]()),
ttl: 10 * time.Minute,
forge: f,
cache: ttlcache.New(ttlcache.WithDisableTouchOnHit[string, *model.OrgPerm]()),
}
}
// Get returns if the user is a member of the organization.
func (c *membershipCache) Get(ctx context.Context, u *model.User, name string) (*model.OrgPerm, error) {
key := u.Login + "/" + name
func (c *membershipCache) Get(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error) {
key := fmt.Sprintf("%s-%s", u.ForgeRemoteID, org)
// Error can be safely ignored, as cache can only return error from loaders.
item, _ := c.Cache.Get(key)
item, _ := c.cache.Get(key)
if item != nil && !item.IsExpired() {
return item.Value(), nil
}
perm, err := c.Forge.OrgMembership(ctx, u, name)
perm, err := c.forge.OrgMembership(ctx, u, org)
if err != nil {
return nil, err
}
c.Cache.Set(key, perm, c.TTL)
c.cache.Set(key, perm, c.ttl)
return perm, nil
}

View file

@ -334,6 +334,18 @@ func (c *config) OrgMembership(ctx context.Context, u *model.User, owner string)
return &model.OrgPerm{Member: perm != "", Admin: perm == "owner"}, nil
}
func (c *config) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {
workspace, err := c.newClient(ctx, u).GetWorkspace(owner)
if err != nil {
return nil, err
}
return &model.Org{
Name: workspace.Slug,
IsUser: false, // bitbucket uses workspaces (similar to orgs) for teams and single users so we can not distinguish between them
}, nil
}
// helper function to return the bitbucket oauth2 client
func (c *config) newClient(ctx context.Context, u *model.User) *internal.Client {
if u == nil {

View file

@ -226,6 +226,13 @@ func (c *Client) ListPullRequests(owner, name string, opts *ListOpts) ([]*PullRe
return out.Values, err
}
func (c *Client) GetWorkspace(name string) (*Workspace, error) {
out := new(Workspace)
uri := fmt.Sprintf(pathWorkspace, c.base, name)
_, err := c.do(uri, get, nil, out)
return out, err
}
func (c *Client) do(rawurl, method string, in, out interface{}) (*string, error) {
uri, err := url.Parse(rawurl)
if err != nil {

View file

@ -52,7 +52,7 @@ type Forge interface {
// Repos fetches a list of repos from the forge.
Repos(ctx context.Context, u *model.User) ([]*model.Repo, error)
// File fetches a file from the forge repository and returns in string
// File fetches a file from the forge repository and returns it in string
// format.
File(ctx context.Context, u *model.User, r *model.Repo, b *model.Pipeline, f string) ([]byte, error)
@ -89,7 +89,10 @@ type Forge interface {
// OrgMembership returns if user is member of organization and if user
// is admin/owner in that organization.
OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error)
OrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error)
// Org fetches the organization from the forge by name. If the name is a user an org with type user is returned.
Org(ctx context.Context, u *model.User, org string) (*model.Org, error)
}
// Refresher refreshes an oauth token and expiration for the given user. It

View file

@ -528,6 +528,32 @@ func (c *Gitea) OrgMembership(ctx context.Context, u *model.User, owner string)
return &model.OrgPerm{Member: member, Admin: perm.IsAdmin || perm.IsOwner}, nil
}
func (c *Gitea) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {
client, err := c.newClientToken(ctx, u.Token)
if err != nil {
return nil, err
}
user, _, err := client.GetUserInfo(owner)
if user != nil && err == nil {
return &model.Org{
Name: user.UserName,
IsUser: true,
Private: user.Visibility != gitea.VisibleTypePublic,
}, nil
}
org, _, err := client.GetOrg(owner)
if err != nil {
return nil, err
}
return &model.Org{
Name: org.UserName,
Private: gitea.VisibleType(org.Visibility) != gitea.VisibleTypePublic,
}, nil
}
// helper function to return the Gitea client with Token
func (c *Gitea) newClientToken(ctx context.Context, token string) (*gitea.Client, error) {
httpClient := &http.Client{}

View file

@ -350,6 +350,27 @@ func (c *client) OrgMembership(ctx context.Context, u *model.User, owner string)
return &model.OrgPerm{Member: org.GetState() == "active", Admin: org.GetRole() == "admin"}, nil
}
func (c *client) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {
client := c.newClientToken(ctx, u.Token)
user, _, err := client.Users.Get(ctx, owner)
if user != nil && err == nil {
return &model.Org{
Name: user.GetName(),
IsUser: true,
}, nil
}
org, _, err := client.Organizations.Get(ctx, owner)
if err != nil {
return nil, err
}
return &model.Org{
Name: org.GetName(),
}, nil
}
// helper function to return the GitHub oauth2 context using an HTTPClient that
// disables TLS verification if disabled in the forge settings.
func (c *client) newContext(ctx context.Context) context.Context {

View file

@ -680,6 +680,48 @@ func (g *GitLab) OrgMembership(ctx context.Context, u *model.User, owner string)
return &model.OrgPerm{}, nil
}
func (g *GitLab) Org(ctx context.Context, u *model.User, owner string) (*model.Org, error) {
client, err := newClient(g.url, u.Token, g.SkipVerify)
if err != nil {
return nil, err
}
users, _, err := client.Users.ListUsers(&gitlab.ListUsersOptions{
ListOptions: gitlab.ListOptions{
Page: 1,
PerPage: 1,
},
Username: gitlab.String(owner),
})
if len(users) == 1 && err == nil {
return &model.Org{
Name: users[0].Username,
IsUser: true,
Private: users[0].PrivateProfile,
}, nil
}
groups, _, err := client.Groups.ListGroups(&gitlab.ListGroupsOptions{
ListOptions: gitlab.ListOptions{
Page: 1,
PerPage: 1,
},
Search: gitlab.String(owner),
}, gitlab.WithContext(ctx))
if err != nil {
return nil, err
}
if len(groups) != 1 {
return nil, fmt.Errorf("could not find org %s", owner)
}
return &model.Org{
Name: groups[0].Name,
Private: groups[0].Visibility != gitlab.PublicVisibility,
}, nil
}
func (g *GitLab) loadChangedFilesFromMergeRequest(ctx context.Context, tmpRepo *model.Repo, pipeline *model.Pipeline, mergeIID int) (*model.Pipeline, error) {
_store, ok := store.TryFromContext(ctx)
if !ok {

View file

@ -274,17 +274,43 @@ func (_m *Forge) Netrc(u *model.User, r *model.Repo) (*model.Netrc, error) {
return r0, r1
}
// OrgMembership provides a mock function with given fields: ctx, u, owner
func (_m *Forge) OrgMembership(ctx context.Context, u *model.User, owner string) (*model.OrgPerm, error) {
ret := _m.Called(ctx, u, owner)
// Org provides a mock function with given fields: ctx, u, org
func (_m *Forge) Org(ctx context.Context, u *model.User, org string) (*model.Org, error) {
ret := _m.Called(ctx, u, org)
var r0 *model.Org
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *model.User, string) (*model.Org, error)); ok {
return rf(ctx, u, org)
}
if rf, ok := ret.Get(0).(func(context.Context, *model.User, string) *model.Org); ok {
r0 = rf(ctx, u, org)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Org)
}
}
if rf, ok := ret.Get(1).(func(context.Context, *model.User, string) error); ok {
r1 = rf(ctx, u, org)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// OrgMembership provides a mock function with given fields: ctx, u, org
func (_m *Forge) OrgMembership(ctx context.Context, u *model.User, org string) (*model.OrgPerm, error) {
ret := _m.Called(ctx, u, org)
var r0 *model.OrgPerm
var r1 error
if rf, ok := ret.Get(0).(func(context.Context, *model.User, string) (*model.OrgPerm, error)); ok {
return rf(ctx, u, owner)
return rf(ctx, u, org)
}
if rf, ok := ret.Get(0).(func(context.Context, *model.User, string) *model.OrgPerm); ok {
r0 = rf(ctx, u, owner)
r0 = rf(ctx, u, org)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.OrgPerm)
@ -292,7 +318,7 @@ func (_m *Forge) OrgMembership(ctx context.Context, u *model.User, owner string)
}
if rf, ok := ret.Get(1).(func(context.Context, *model.User, string) error); ok {
r1 = rf(ctx, u, owner)
r1 = rf(ctx, u, org)
} else {
r1 = ret.Error(1)
}

29
server/model/org.go Normal file
View file

@ -0,0 +1,29 @@
// Copyright 2023 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 model
// Org represents an organization.
type Org struct {
ID int64 `json:"id,omitempty" xorm:"pk autoincr 'id'"`
Name string `json:"name" xorm:"UNIQUE 'name'"`
IsUser bool `json:"is_user" xorm:"is_user"`
// if name lookup has to check for membership or not
Private bool `json:"-" xorm:"private"`
} // @name Org
// TableName return database table name for xorm
func (Org) TableName() string {
return "orgs"
}

View file

@ -26,6 +26,7 @@ type Repo struct {
UserID int64 `json:"-" xorm:"repo_user_id"`
// ForgeRemoteID is the unique identifier for the repository on the forge.
ForgeRemoteID ForgeRemoteID `json:"forge_remote_id" xorm:"forge_remote_id"`
OrgID int64 `json:"org_id" xorm:"repo_org_id"`
Owner string `json:"owner" xorm:"UNIQUE(name) 'repo_owner'"`
Name string `json:"name" xorm:"UNIQUE(name) 'repo_name'"`
FullName string `json:"full_name" xorm:"UNIQUE 'repo_full_name'"`

View file

@ -40,11 +40,11 @@ type SecretService interface {
SecretUpdate(*Repo, *Secret) error
SecretDelete(*Repo, string) error
// Organization secrets
OrgSecretFind(string, string) (*Secret, error)
OrgSecretList(string, *ListOptions) ([]*Secret, error)
OrgSecretCreate(string, *Secret) error
OrgSecretUpdate(string, *Secret) error
OrgSecretDelete(string, string) error
OrgSecretFind(int64, string) (*Secret, error)
OrgSecretList(int64, *ListOptions) ([]*Secret, error)
OrgSecretCreate(int64, *Secret) error
OrgSecretUpdate(int64, *Secret) error
OrgSecretDelete(int64, string) error
// Global secrets
GlobalSecretFind(string) (*Secret, error)
GlobalSecretList(*ListOptions) ([]*Secret, error)
@ -60,8 +60,8 @@ type SecretStore interface {
SecretCreate(*Secret) error
SecretUpdate(*Secret) error
SecretDelete(*Secret) error
OrgSecretFind(string, string) (*Secret, error)
OrgSecretList(string, *ListOptions) ([]*Secret, error)
OrgSecretFind(int64, string) (*Secret, error)
OrgSecretList(int64, *ListOptions) ([]*Secret, error)
GlobalSecretFind(string) (*Secret, error)
GlobalSecretList(*ListOptions) ([]*Secret, error)
SecretListAll() ([]*Secret, error)
@ -70,7 +70,7 @@ 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'"`
Owner string `json:"-" xorm:"NOT NULL DEFAULT '' UNIQUE(s) INDEX 'secret_owner'"`
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'"`
Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"`
Value string `json:"value,omitempty" xorm:"TEXT 'secret_value'"`
@ -93,12 +93,12 @@ func (s *Secret) BeforeInsert() {
// Global secret.
func (s Secret) Global() bool {
return s.RepoID == 0 && s.Owner == ""
return s.RepoID == 0 && s.OrgID == 0
}
// Organization secret.
func (s Secret) Organization() bool {
return s.RepoID == 0 && s.Owner != ""
return s.RepoID == 0 && s.OrgID != 0
}
// Match returns true if an image and event match the restricted list.
@ -155,7 +155,7 @@ func (s *Secret) Validate() error {
func (s *Secret) Copy() *Secret {
return &Secret{
ID: s.ID,
Owner: s.Owner,
OrgID: s.OrgID,
RepoID: s.RepoID,
Name: s.Name,
Images: s.Images,

View file

@ -99,7 +99,7 @@ func (wrapper *EncryptedSecretStore) SecretDelete(secret *model.Secret) error {
return wrapper.store.SecretDelete(secret)
}
func (wrapper *EncryptedSecretStore) OrgSecretFind(s, s2 string) (*model.Secret, error) {
func (wrapper *EncryptedSecretStore) OrgSecretFind(s int64, s2 string) (*model.Secret, error) {
result, err := wrapper.store.OrgSecretFind(s, s2)
if err != nil {
return nil, err
@ -112,7 +112,7 @@ func (wrapper *EncryptedSecretStore) OrgSecretFind(s, s2 string) (*model.Secret,
return result, nil
}
func (wrapper *EncryptedSecretStore) OrgSecretList(s string, p *model.ListOptions) ([]*model.Secret, error) {
func (wrapper *EncryptedSecretStore) OrgSecretList(s int64, p *model.ListOptions) ([]*model.Secret, error) {
results, err := wrapper.store.OrgSecretList(s, p)
if err != nil {
return nil, err

View file

@ -86,23 +86,23 @@ func (b *builtin) SecretDelete(repo *model.Repo, name string) error {
return b.store.SecretDelete(secret)
}
func (b *builtin) OrgSecretFind(owner, name string) (*model.Secret, error) {
func (b *builtin) OrgSecretFind(owner int64, name string) (*model.Secret, error) {
return b.store.OrgSecretFind(owner, name)
}
func (b *builtin) OrgSecretList(owner string, p *model.ListOptions) ([]*model.Secret, error) {
func (b *builtin) OrgSecretList(owner int64, p *model.ListOptions) ([]*model.Secret, error) {
return b.store.OrgSecretList(owner, p)
}
func (b *builtin) OrgSecretCreate(_ string, in *model.Secret) error {
func (b *builtin) OrgSecretCreate(_ int64, in *model.Secret) error {
return b.store.SecretCreate(in)
}
func (b *builtin) OrgSecretUpdate(_ string, in *model.Secret) error {
func (b *builtin) OrgSecretUpdate(_ int64, in *model.Secret) error {
return b.store.SecretUpdate(in)
}
func (b *builtin) OrgSecretDelete(owner, name string) error {
func (b *builtin) OrgSecretDelete(owner int64, name string) error {
secret, err := b.store.OrgSecretFind(owner, name)
if err != nil {
return err

View file

@ -46,13 +46,15 @@ func apiRoutes(e *gin.Engine) {
users.DELETE("/:login", api.DeleteUser)
}
orgBase := apiBase.Group("/orgs/:owner")
apiBase.GET("/orgs/lookup/*org_full_name", api.LookupOrg)
orgBase := apiBase.Group("/orgs/:org_id")
{
orgBase.GET("/permissions", api.GetOrgPermissions)
org := orgBase.Group("")
{
org.Use(session.MustOrgMember(true))
org.GET("", api.GetOrg)
org.GET("/secrets", api.GetOrgSecretList)
org.POST("/secrets", api.PostOrgSecret)
org.GET("/secrets/:secret", api.GetOrgSecret)

View file

@ -16,6 +16,7 @@ package session
import (
"net/http"
"strconv"
"github.com/woodpecker-ci/woodpecker/server"
"github.com/woodpecker-ci/woodpecker/server/model"
@ -117,36 +118,47 @@ func MustUser() gin.HandlerFunc {
func MustOrgMember(admin bool) gin.HandlerFunc {
return func(c *gin.Context) {
_store := store.FromContext(c)
user := User(c)
owner := c.Param("owner")
if user == nil {
c.String(http.StatusUnauthorized, "User not authorized")
c.Abort()
return
}
if owner == "" {
c.String(http.StatusForbidden, "User not authorized")
c.Abort()
orgID, err := strconv.ParseInt(c.Param("org_id"), 10, 64)
if err != nil {
c.String(http.StatusBadRequest, "Error parsing org id. %s", err)
return
}
org, err := _store.OrgGet(orgID)
if err != nil {
c.String(http.StatusNotFound, "Organization not found")
return
}
// User can access his own, admin can access all
if user.Login == owner || user.Admin {
if (org.Name == user.Login) || user.Admin {
c.Next()
return
}
perm, err := server.Config.Services.Membership.Get(c, user, owner)
perm, err := server.Config.Services.Membership.Get(c, user, org.Name)
if err != nil {
log.Error().Msgf("Failed to check membership: %v", err)
c.String(http.StatusInternalServerError, http.StatusText(http.StatusInternalServerError))
c.Abort()
return
}
if perm == nil || (!admin && !perm.Member) || (admin && !perm.Admin) {
c.String(http.StatusForbidden, "User not authorized")
c.Abort()
return
}
c.Next()
}
}

View file

@ -1,4 +1,4 @@
// Copyright 2018 Drone.IO Inc.
// Copyright 2023 Woodpecker Authors
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.

View file

@ -0,0 +1,112 @@
// 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"
"strings"
"xorm.io/builder"
"xorm.io/xorm"
"github.com/woodpecker-ci/woodpecker/server/model"
)
type oldSecret021 struct {
ID int64 `json:"id" xorm:"pk autoincr 'secret_id'"`
Owner string `json:"-" xorm:"'secret_owner'"`
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'"`
Name string `json:"name" xorm:"NOT NULL UNIQUE(s) INDEX 'secret_name'"`
}
func (oldSecret021) TableName() string {
return "secrets"
}
var addOrgs = task{
name: "add-orgs",
required: true,
fn: func(sess *xorm.Session) error {
if exist, err := sess.IsTableExist("orgs"); exist && err == nil {
if err := sess.DropTable("orgs"); err != nil {
return fmt.Errorf("drop old orgs table failed: %w", err)
}
}
if err := sess.Sync(new(model.Org), new(model.Repo), new(model.User)); err != nil {
return fmt.Errorf("sync new models failed: %w", err)
}
// make sure the columns exist before removing them
if err := sess.Sync(new(oldSecret021)); err != nil {
return fmt.Errorf("sync old secrets models failed: %w", err)
}
// get all org names from repos
var repos []*model.Repo
if err := sess.Find(&repos); err != nil {
return fmt.Errorf("find all repos failed: %w", err)
}
orgs := make(map[string]*model.Org)
users := make(map[string]bool)
for _, repo := range repos {
orgName := strings.ToLower(repo.Owner)
// check if it's a registered user
if _, ok := users[orgName]; !ok {
exist, err := sess.Where("user_login = ?", orgName).Exist(new(model.User))
if err != nil {
return fmt.Errorf("check if user '%s' exist failed: %w", orgName, err)
}
users[orgName] = exist
}
// create org if not already created
if _, ok := orgs[orgName]; !ok {
org := &model.Org{
Name: orgName,
IsUser: users[orgName],
}
if _, err := sess.Insert(org); err != nil {
return fmt.Errorf("insert org %#v failed: %w", org, err)
}
orgs[orgName] = org
// update org secrets
var secrets []*oldSecret021
if err := sess.Where(builder.Eq{"secret_owner": orgName, "secret_repo_id": 0}).Find(&secrets); err != nil {
return fmt.Errorf("get org secrets failed: %w", err)
}
for _, secret := range secrets {
secret.OrgID = org.ID
if _, err := sess.ID(secret.ID).Cols("secret_org_id").Update(secret); err != nil {
return fmt.Errorf("update org secret %d failed: %w", secret.ID, err)
}
}
}
// update the repo
repo.OrgID = orgs[orgName].ID
if _, err := sess.ID(repo.ID).Cols("repo_org_id").Update(repo); err != nil {
return fmt.Errorf("update repos failed: %w", err)
}
}
return dropTableColumns(sess, "secrets", "secret_owner")
},
}

View file

@ -53,6 +53,7 @@ var migrationTasks = []*task{
&initLogsEntriesTable,
&migrateLogs2LogEntries,
&parentStepsToWorkflows,
&addOrgs,
}
var allBeans = []interface{}{
@ -72,6 +73,7 @@ var allBeans = []interface{}{
new(model.Cron),
new(model.Redirection),
new(model.Workflow),
new(model.Org),
}
type migrations struct {
@ -106,39 +108,47 @@ func Migrate(e *xorm.Engine) error {
e.SetDisableGlobalCache(true)
if err := e.Sync(new(migrations)); err != nil {
return err
return fmt.Errorf("error to create migrations table: %w", err)
}
sess := e.NewSession()
defer sess.Close()
if err := sess.Begin(); err != nil {
return err
return fmt.Errorf("could not create initial migration session: %w", err)
}
// check if we have a fresh installation or need to check for migrations
c, err := sess.Count(new(migrations))
if err != nil {
return err
return fmt.Errorf("could not count migrations: %w", err)
}
if c == 0 {
if err := initNew(sess); err != nil {
return err
return fmt.Errorf("could not init a new database: %w", err)
}
return sess.Commit()
if err := sess.Commit(); err != nil {
return fmt.Errorf("could not commit initial migration session: %w", err)
}
return nil
}
if err := sess.Commit(); err != nil {
return err
return fmt.Errorf("could not commit initial migration session: %w", err)
}
if err := runTasks(e, migrationTasks); err != nil {
return err
return fmt.Errorf("run tasks failed: %w", err)
}
e.SetDisableGlobalCache(false)
return syncAll(e)
if err := syncAll(e); err != nil {
return fmt.Errorf("msg: %w", err)
}
return nil
}
func runTasks(e *xorm.Engine, tasks []*task) error {
@ -166,17 +176,16 @@ func runTasks(e *xorm.Engine, tasks []*task) error {
sess := e.NewSession().NoCache()
defer sess.Close()
if err := sess.Begin(); err != nil {
return err
return fmt.Errorf("could not begin session for '%s': %w", task.name, err)
}
if taskErr = task.fn(sess); taskErr != nil {
aliveMsgCancel(nil)
if err2 := sess.Rollback(); err2 != nil {
taskErr = errors.Join(taskErr, err2)
if err := sess.Rollback(); err != nil {
taskErr = errors.Join(taskErr, err)
}
}
if err := sess.Commit(); err != nil {
return err
} else if err := sess.Commit(); err != nil {
return fmt.Errorf("could not commit session for '%s': %w", task.name, err)
}
} else if task.engineFn != nil {
taskErr = task.engineFn(e)
@ -189,7 +198,7 @@ func runTasks(e *xorm.Engine, tasks []*task) error {
aliveMsgCancel(nil)
if taskErr != nil {
if task.required {
return taskErr
return fmt.Errorf("migration task '%s' failed: %w", task.name, taskErr)
}
log.Error().Err(taskErr).Msgf("migration task '%s' failed but is not required", task.name)
continue
@ -197,7 +206,7 @@ func runTasks(e *xorm.Engine, tasks []*task) error {
log.Debug().Msgf("migration task '%s' done", task.name)
if _, err := e.Insert(&migrations{task.name}); err != nil {
return err
return fmt.Errorf("migration task '%s' could not be marked as finished: %w", task.name, err)
}
migCache[task.name] = true

View file

@ -0,0 +1,59 @@
// 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 datastore
import (
"strings"
"github.com/woodpecker-ci/woodpecker/server/model"
)
func (s storage) OrgCreate(org *model.Org) error {
// sanitize
org.Name = strings.ToLower(org.Name)
// insert
_, err := s.engine.Insert(org)
return err
}
func (s storage) OrgGet(id int64) (*model.Org, error) {
org := new(model.Org)
return org, wrapGet(s.engine.ID(id).Get(org))
}
func (s storage) OrgUpdate(org *model.Org) error {
// sanitize
org.Name = strings.ToLower(org.Name)
// update
_, err := s.engine.ID(org.ID).AllCols().Update(org)
return err
}
func (s storage) OrgDelete(id int64) error {
return wrapDelete(s.engine.ID(id).Delete(new(model.Org)))
}
func (s storage) OrgFindByName(name string) (*model.Org, error) {
// sanitize
name = strings.ToLower(name)
// find
org := new(model.Org)
return org, wrapGet(s.engine.Where("name = ?", name).Get(org))
}
func (s storage) OrgRepoList(org *model.Org, p *model.ListOptions) ([]*model.Repo, error) {
var repos []*model.Repo
return repos, s.paginate(p).OrderBy("repo_id").Where("repo_org_id = ?", org.ID).Find(&repos)
}

View file

@ -0,0 +1,75 @@
// Copyright 2023 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 datastore
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/woodpecker-ci/woodpecker/server/model"
)
func TestOrgCRUD(t *testing.T) {
store, closer := newTestStore(t, new(model.Org), new(model.Repo))
defer closer()
org1 := &model.Org{
Name: "someAwesomeOrg",
IsUser: false,
Private: true,
}
// create first org to play with
assert.NoError(t, store.OrgCreate(org1))
assert.EqualValues(t, "someawesomeorg", org1.Name)
// retrieve it
orgOne, err := store.OrgGet(org1.ID)
assert.NoError(t, err)
assert.EqualValues(t, org1, orgOne)
// change name
assert.NoError(t, store.OrgUpdate(&model.Org{ID: org1.ID, Name: "RenamedOrg"}))
// force a name duplication and fail
assert.Error(t, store.OrgCreate(&model.Org{Name: "reNamedorg"}))
// find updated org by name
orgOne, err = store.OrgFindByName("renamedorG")
assert.NoError(t, err)
assert.NotEqualValues(t, org1, orgOne)
assert.EqualValues(t, org1.ID, orgOne.ID)
assert.EqualValues(t, false, orgOne.IsUser)
assert.EqualValues(t, false, orgOne.Private)
assert.EqualValues(t, "renamedorg", orgOne.Name)
// create two more orgs and repos
someUser := &model.Org{Name: "some_other_u", IsUser: true}
assert.NoError(t, store.OrgCreate(someUser))
assert.NoError(t, store.OrgCreate(&model.Org{Name: "some_other_org"}))
assert.NoError(t, store.CreateRepo(&model.Repo{UserID: 1, Owner: "some_other_u", Name: "abc", FullName: "some_other_u/abc", OrgID: someUser.ID}))
assert.NoError(t, store.CreateRepo(&model.Repo{UserID: 1, Owner: "some_other_u", Name: "xyz", FullName: "some_other_u/xyz", OrgID: someUser.ID}))
assert.NoError(t, store.CreateRepo(&model.Repo{UserID: 1, Owner: "renamedorg", Name: "567", FullName: "renamedorg/567", OrgID: orgOne.ID}))
// get all repos for a specific org
repos, err := store.OrgRepoList(someUser, &model.ListOptions{All: true})
assert.NoError(t, err)
assert.Len(t, repos, 2)
// delete an org and check if it's gone
assert.NoError(t, store.OrgDelete(org1.ID))
assert.Error(t, store.OrgDelete(org1.ID))
}

View file

@ -33,8 +33,8 @@ func (s storage) SecretList(repo *model.Repo, includeGlobalAndOrgSecrets bool, p
var secrets []*model.Secret
var cond builder.Cond = builder.Eq{"secret_repo_id": repo.ID}
if includeGlobalAndOrgSecrets {
cond = cond.Or(builder.Eq{"secret_owner": repo.Owner}).
Or(builder.And(builder.Eq{"secret_owner": ""}, builder.Eq{"secret_repo_id": 0}))
cond = cond.Or(builder.Eq{"secret_org_id": repo.OrgID}).
Or(builder.And(builder.Eq{"secret_org_id": 0}, builder.Eq{"secret_repo_id": 0}))
}
return secrets, s.paginate(p).Where(cond).OrderBy(orderSecretsBy).Find(&secrets)
}
@ -59,28 +59,28 @@ func (s storage) SecretDelete(secret *model.Secret) error {
return wrapDelete(s.engine.ID(secret.ID).Delete(new(model.Secret)))
}
func (s storage) OrgSecretFind(owner, name string) (*model.Secret, error) {
func (s storage) OrgSecretFind(orgID int64, name string) (*model.Secret, error) {
secret := new(model.Secret)
return secret, wrapGet(s.engine.Where(
builder.Eq{"secret_owner": owner, "secret_name": name},
builder.Eq{"secret_org_id": orgID, "secret_name": name},
).Get(secret))
}
func (s storage) OrgSecretList(owner string, p *model.ListOptions) ([]*model.Secret, error) {
func (s storage) OrgSecretList(orgID int64, p *model.ListOptions) ([]*model.Secret, error) {
secrets := make([]*model.Secret, 0)
return secrets, s.paginate(p).Where("secret_owner = ?", owner).OrderBy(orderSecretsBy).Find(&secrets)
return secrets, s.paginate(p).Where("secret_org_id = ?", orgID).OrderBy(orderSecretsBy).Find(&secrets)
}
func (s storage) GlobalSecretFind(name string) (*model.Secret, error) {
secret := new(model.Secret)
return secret, wrapGet(s.engine.Where(
builder.Eq{"secret_owner": "", "secret_repo_id": 0, "secret_name": name},
builder.Eq{"secret_org_id": 0, "secret_repo_id": 0, "secret_name": name},
).Get(secret))
}
func (s storage) GlobalSecretList(p *model.ListOptions) ([]*model.Secret, error) {
secrets := make([]*model.Secret, 0)
return secrets, s.paginate(p).Where(
builder.Eq{"secret_owner": "", "secret_repo_id": 0},
builder.Eq{"secret_org_id": 0, "secret_repo_id": 0},
).OrderBy(orderSecretsBy).Find(&secrets)
}

View file

@ -73,7 +73,7 @@ func TestSecretList(t *testing.T) {
createTestSecrets(t, store)
list, err := store.SecretList(&model.Repo{ID: 1, Owner: "org"}, false, &model.ListOptions{Page: 1, PerPage: 50})
list, err := store.SecretList(&model.Repo{ID: 1, OrgID: 12}, false, &model.ListOptions{Page: 1, PerPage: 50})
assert.NoError(t, err)
assert.Len(t, list, 2)
}
@ -95,7 +95,7 @@ func TestSecretPipelineList(t *testing.T) {
createTestSecrets(t, store)
list, err := store.SecretList(&model.Repo{ID: 1, Owner: "org"}, true, &model.ListOptions{Page: 1, PerPage: 50})
list, err := store.SecretList(&model.Repo{ID: 1, OrgID: 12}, true, &model.ListOptions{Page: 1, PerPage: 50})
assert.NoError(t, err)
assert.Len(t, list, 4)
}
@ -179,7 +179,7 @@ func TestSecretIndexes(t *testing.T) {
func createTestSecrets(t *testing.T, store *storage) {
assert.NoError(t, store.SecretCreate(&model.Secret{
Owner: "org",
OrgID: 12,
Name: "usr",
Value: "sec",
}))
@ -204,7 +204,7 @@ func TestOrgSecretFind(t *testing.T) {
defer closer()
err := store.SecretCreate(&model.Secret{
Owner: "org",
OrgID: 12,
Name: "password",
Value: "correct-horse-battery-staple",
Images: []string{"golang", "node"},
@ -215,13 +215,13 @@ func TestOrgSecretFind(t *testing.T) {
return
}
secret, err := store.OrgSecretFind("org", "password")
secret, err := store.OrgSecretFind(12, "password")
if err != nil {
t.Error(err)
return
}
if got, want := secret.Owner, "org"; got != want {
t.Errorf("Want owner %s, got %s", want, got)
if got, want := secret.OrgID, int64(12); got != want {
t.Errorf("Want org_id %d, got %d", want, got)
}
if got, want := secret.Name, "password"; got != want {
t.Errorf("Want secret name %s, got %s", want, got)
@ -249,7 +249,7 @@ func TestOrgSecretList(t *testing.T) {
createTestSecrets(t, store)
list, err := store.OrgSecretList("org", &model.ListOptions{All: true})
list, err := store.OrgSecretList(12, &model.ListOptions{All: true})
assert.NoError(t, err)
assert.Len(t, list, 1)

View file

@ -1169,16 +1169,122 @@ func (_m *Store) Migrate() error {
return r0
}
// OrgCreate provides a mock function with given fields: _a0
func (_m *Store) OrgCreate(_a0 *model.Org) error {
ret := _m.Called(_a0)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Org) error); ok {
r0 = rf(_a0)
} else {
r0 = ret.Error(0)
}
return r0
}
// OrgDelete provides a mock function with given fields: _a0
func (_m *Store) OrgDelete(_a0 int64) error {
ret := _m.Called(_a0)
var r0 error
if rf, ok := ret.Get(0).(func(int64) error); ok {
r0 = rf(_a0)
} else {
r0 = ret.Error(0)
}
return r0
}
// OrgFindByName provides a mock function with given fields: _a0
func (_m *Store) OrgFindByName(_a0 string) (*model.Org, error) {
ret := _m.Called(_a0)
var r0 *model.Org
var r1 error
if rf, ok := ret.Get(0).(func(string) (*model.Org, error)); ok {
return rf(_a0)
}
if rf, ok := ret.Get(0).(func(string) *model.Org); ok {
r0 = rf(_a0)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Org)
}
}
if rf, ok := ret.Get(1).(func(string) error); ok {
r1 = rf(_a0)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// OrgGet provides a mock function with given fields: _a0
func (_m *Store) OrgGet(_a0 int64) (*model.Org, error) {
ret := _m.Called(_a0)
var r0 *model.Org
var r1 error
if rf, ok := ret.Get(0).(func(int64) (*model.Org, error)); ok {
return rf(_a0)
}
if rf, ok := ret.Get(0).(func(int64) *model.Org); ok {
r0 = rf(_a0)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*model.Org)
}
}
if rf, ok := ret.Get(1).(func(int64) error); ok {
r1 = rf(_a0)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// OrgRepoList provides a mock function with given fields: _a0, _a1
func (_m *Store) OrgRepoList(_a0 *model.Org, _a1 *model.ListOptions) ([]*model.Repo, error) {
ret := _m.Called(_a0, _a1)
var r0 []*model.Repo
var r1 error
if rf, ok := ret.Get(0).(func(*model.Org, *model.ListOptions) ([]*model.Repo, error)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(*model.Org, *model.ListOptions) []*model.Repo); ok {
r0 = rf(_a0, _a1)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).([]*model.Repo)
}
}
if rf, ok := ret.Get(1).(func(*model.Org, *model.ListOptions) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// OrgSecretFind provides a mock function with given fields: _a0, _a1
func (_m *Store) OrgSecretFind(_a0 string, _a1 string) (*model.Secret, error) {
func (_m *Store) OrgSecretFind(_a0 int64, _a1 string) (*model.Secret, error) {
ret := _m.Called(_a0, _a1)
var r0 *model.Secret
var r1 error
if rf, ok := ret.Get(0).(func(string, string) (*model.Secret, error)); ok {
if rf, ok := ret.Get(0).(func(int64, string) (*model.Secret, error)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(string, string) *model.Secret); ok {
if rf, ok := ret.Get(0).(func(int64, string) *model.Secret); ok {
r0 = rf(_a0, _a1)
} else {
if ret.Get(0) != nil {
@ -1186,7 +1292,7 @@ func (_m *Store) OrgSecretFind(_a0 string, _a1 string) (*model.Secret, error) {
}
}
if rf, ok := ret.Get(1).(func(string, string) error); ok {
if rf, ok := ret.Get(1).(func(int64, string) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
@ -1196,15 +1302,15 @@ func (_m *Store) OrgSecretFind(_a0 string, _a1 string) (*model.Secret, error) {
}
// OrgSecretList provides a mock function with given fields: _a0, _a1
func (_m *Store) OrgSecretList(_a0 string, _a1 *model.ListOptions) ([]*model.Secret, error) {
func (_m *Store) OrgSecretList(_a0 int64, _a1 *model.ListOptions) ([]*model.Secret, error) {
ret := _m.Called(_a0, _a1)
var r0 []*model.Secret
var r1 error
if rf, ok := ret.Get(0).(func(string, *model.ListOptions) ([]*model.Secret, error)); ok {
if rf, ok := ret.Get(0).(func(int64, *model.ListOptions) ([]*model.Secret, error)); ok {
return rf(_a0, _a1)
}
if rf, ok := ret.Get(0).(func(string, *model.ListOptions) []*model.Secret); ok {
if rf, ok := ret.Get(0).(func(int64, *model.ListOptions) []*model.Secret); ok {
r0 = rf(_a0, _a1)
} else {
if ret.Get(0) != nil {
@ -1212,7 +1318,7 @@ func (_m *Store) OrgSecretList(_a0 string, _a1 *model.ListOptions) ([]*model.Sec
}
}
if rf, ok := ret.Get(1).(func(string, *model.ListOptions) error); ok {
if rf, ok := ret.Get(1).(func(int64, *model.ListOptions) error); ok {
r1 = rf(_a0, _a1)
} else {
r1 = ret.Error(1)
@ -1221,6 +1327,20 @@ func (_m *Store) OrgSecretList(_a0 string, _a1 *model.ListOptions) ([]*model.Sec
return r0, r1
}
// OrgUpdate provides a mock function with given fields: _a0
func (_m *Store) OrgUpdate(_a0 *model.Org) error {
ret := _m.Called(_a0)
var r0 error
if rf, ok := ret.Get(0).(func(*model.Org) error); ok {
r0 = rf(_a0)
} else {
r0 = ret.Error(0)
}
return r0
}
// PermDelete provides a mock function with given fields: perm
func (_m *Store) PermDelete(perm *model.Perm) error {
ret := _m.Called(perm)

View file

@ -121,8 +121,8 @@ type Store interface {
SecretCreate(*model.Secret) error
SecretUpdate(*model.Secret) error
SecretDelete(*model.Secret) error
OrgSecretFind(string, string) (*model.Secret, error)
OrgSecretList(string, *model.ListOptions) ([]*model.Secret, error)
OrgSecretFind(int64, string) (*model.Secret, error)
OrgSecretList(int64, *model.ListOptions) ([]*model.Secret, error)
GlobalSecretFind(string) (*model.Secret, error)
GlobalSecretList(*model.ListOptions) ([]*model.Secret, error)
@ -183,6 +183,16 @@ type Store interface {
WorkflowLoad(int64) (*model.Workflow, error)
WorkflowUpdate(*model.Workflow) error
// Org
OrgCreate(*model.Org) error
OrgGet(int64) (*model.Org, error)
OrgFindByName(string) (*model.Org, error)
OrgUpdate(*model.Org) error
OrgDelete(int64) error
// Org repos
OrgRepoList(*model.Org, *model.ListOptions) ([]*model.Repo, error)
// Store operations
Ping() error
Close() error

View file

@ -38,9 +38,9 @@
</Panel>
</template>
<script lang="ts">
<script lang="ts" setup>
import { cloneDeep } from 'lodash';
import { computed, defineComponent, inject, Ref, ref } from 'vue';
import { computed, inject, Ref, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import Button from '~/components/atomic/Button.vue';
@ -61,86 +61,61 @@ const emptySecret = {
event: [WebhookEvents.Push],
};
export default defineComponent({
name: 'OrgSecretsTab',
const apiClient = useApiClient();
const notifications = useNotifications();
const i18n = useI18n();
components: {
Button,
Panel,
DocsLink,
SecretList,
SecretEdit,
},
const org = inject<Ref<Org>>('org');
const selectedSecret = ref<Partial<Secret>>();
const isEditingSecret = computed(() => !!selectedSecret.value?.id);
setup() {
const apiClient = useApiClient();
const notifications = useNotifications();
const i18n = useI18n();
async function loadSecrets(page: number): Promise<Secret[] | null> {
if (!org?.value) {
throw new Error("Unexpected: Can't load org");
}
const org = inject<Ref<Org>>('org');
const selectedSecret = ref<Partial<Secret>>();
const isEditingSecret = computed(() => !!selectedSecret.value?.id);
return apiClient.getOrgSecretList(org.value.id, page);
}
async function loadSecrets(page: number): Promise<Secret[] | null> {
if (!org?.value) {
throw new Error("Unexpected: Can't load org");
}
const { resetPage, data: secrets } = usePagination(loadSecrets, () => !selectedSecret.value);
return apiClient.getOrgSecretList(org.value.name, page);
}
const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {
if (!org?.value) {
throw new Error("Unexpected: Can't load org");
}
const { resetPage, data: secrets } = usePagination(loadSecrets, () => !selectedSecret.value);
if (!selectedSecret.value) {
throw new Error("Unexpected: Can't get secret");
}
const { doSubmit: createSecret, isLoading: isSaving } = useAsyncAction(async () => {
if (!org?.value) {
throw new Error("Unexpected: Can't load org");
}
if (!selectedSecret.value) {
throw new Error("Unexpected: Can't get secret");
}
if (isEditingSecret.value) {
await apiClient.updateOrgSecret(org.value.name, selectedSecret.value);
} else {
await apiClient.createOrgSecret(org.value.name, selectedSecret.value);
}
notifications.notify({
title: i18n.t(isEditingSecret.value ? 'org.settings.secrets.saved' : 'org.settings.secrets.created'),
type: 'success',
});
selectedSecret.value = undefined;
resetPage();
});
const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {
if (!org?.value) {
throw new Error("Unexpected: Can't load org");
}
await apiClient.deleteOrgSecret(org.value.name, _secret.name);
notifications.notify({ title: i18n.t('org.settings.secrets.deleted'), type: 'success' });
resetPage();
});
function editSecret(secret: Secret) {
selectedSecret.value = cloneDeep(secret);
}
function showAddSecret() {
selectedSecret.value = cloneDeep(emptySecret);
}
return {
selectedSecret,
secrets,
isDeleting,
isSaving,
showAddSecret,
createSecret,
editSecret,
deleteSecret,
};
},
if (isEditingSecret.value) {
await apiClient.updateOrgSecret(org.value.id, selectedSecret.value);
} else {
await apiClient.createOrgSecret(org.value.id, selectedSecret.value);
}
notifications.notify({
title: i18n.t(isEditingSecret.value ? 'org.settings.secrets.saved' : 'org.settings.secrets.created'),
type: 'success',
});
selectedSecret.value = undefined;
resetPage();
});
const { doSubmit: deleteSecret, isLoading: isDeleting } = useAsyncAction(async (_secret: Secret) => {
if (!org?.value) {
throw new Error("Unexpected: Can't load org");
}
await apiClient.deleteOrgSecret(org.value.id, _secret.name);
notifications.notify({ title: i18n.t('org.settings.secrets.deleted'), type: 'success' });
resetPage();
});
function editSecret(secret: Secret) {
selectedSecret.value = cloneDeep(secret);
}
function showAddSecret() {
selectedSecret.value = cloneDeep(emptySecret);
}
</script>

View file

@ -1,9 +1,11 @@
import { inject as vueInject, InjectionKey, provide as vueProvide, Ref } from 'vue';
import { Repo } from '~/lib/api/types';
import { Org, OrgPermissions, Repo } from '~/lib/api/types';
export type InjectKeys = {
repo: Ref<Repo>;
org: Ref<Org | undefined>;
'org-permissions': Ref<OrgPermissions | undefined>;
};
export function inject<T extends keyof InjectKeys>(key: T): InjectKeys[T] {

View file

@ -2,6 +2,7 @@ import ApiClient, { encodeQueryString } from './client';
import {
Agent,
Cron,
Org,
OrgPermissions,
Pipeline,
PipelineConfig,
@ -190,26 +191,34 @@ export default class WoodpeckerClient extends ApiClient {
return this._post(`/api/repos/${repoId}/cron/${cronId}`) as Promise<Pipeline>;
}
getOrgPermissions(owner: string): Promise<OrgPermissions> {
return this._get(`/api/orgs/${owner}/permissions`) as Promise<OrgPermissions>;
getOrg(orgId: number): Promise<Org> {
return this._get(`/api/orgs/${orgId}`) as Promise<Org>;
}
getOrgSecretList(owner: string, page: number): Promise<Secret[] | null> {
return this._get(`/api/orgs/${owner}/secrets?page=${page}`) as Promise<Secret[] | null>;
lookupOrg(name: string): Promise<Org> {
return this._get(`/api/orgs/lookup/${name}`) as Promise<Org>;
}
createOrgSecret(owner: string, secret: Partial<Secret>): Promise<unknown> {
return this._post(`/api/orgs/${owner}/secrets`, secret);
getOrgPermissions(orgId: number): Promise<OrgPermissions> {
return this._get(`/api/orgs/${orgId}/permissions`) as Promise<OrgPermissions>;
}
updateOrgSecret(owner: string, secret: Partial<Secret>): Promise<unknown> {
getOrgSecretList(orgId: number, page: number): Promise<Secret[] | null> {
return this._get(`/api/orgs/${orgId}/secrets?page=${page}`) as Promise<Secret[] | null>;
}
createOrgSecret(orgId: number, secret: Partial<Secret>): Promise<unknown> {
return this._post(`/api/orgs/${orgId}/secrets`, secret);
}
updateOrgSecret(orgId: number, secret: Partial<Secret>): Promise<unknown> {
const secretName = encodeURIComponent(secret.name ?? '');
return this._patch(`/api/orgs/${owner}/secrets/${secretName}`, secret);
return this._patch(`/api/orgs/${orgId}/secrets/${secretName}`, secret);
}
deleteOrgSecret(owner: string, secretName: string): Promise<unknown> {
deleteOrgSecret(orgId: number, secretName: string): Promise<unknown> {
const name = encodeURIComponent(secretName);
return this._delete(`/api/orgs/${owner}/secrets/${name}`);
return this._delete(`/api/orgs/${orgId}/secrets/${name}`);
}
getGlobalSecretList(page: number): Promise<Secret[] | null> {

View file

@ -1,7 +1,9 @@
// A version control organization.
export type Org = {
// The name of the organization.
id: number;
name: string;
is_user: boolean;
};
export type OrgPermissions = {

View file

@ -13,6 +13,9 @@ export type Repo = {
// Currently this is either 'git' or 'hg' (Mercurial).
scm: string;
// The id of the organization that owns the repository.
org_id: number;
// The owner of the repository.
owner: string;

View file

@ -105,7 +105,7 @@ const routes: RouteRecordRaw[] = [
],
},
{
path: '/org/:orgName',
path: '/orgs/:orgId',
component: (): Component => import('~/views/org/OrgWrapper.vue'),
props: true,
children: [
@ -124,6 +124,11 @@ const routes: RouteRecordRaw[] = [
},
],
},
{
path: '/org/:orgName/:pathMatch(.*)*',
component: (): Component => import('~/views/org/OrgDeprecatedRedirect.vue'),
props: true,
},
{
path: '/admin',
component: (): Component => import('~/views/RouterView.vue'),

View file

@ -0,0 +1,26 @@
<template>
<div />
</template>
<script setup lang="ts">
import { onMounted } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import useApiClient from '~/compositions/useApiClient';
const apiClient = useApiClient();
const route = useRoute();
const router = useRouter();
const props = defineProps<{
orgName: string;
}>();
onMounted(async () => {
const org = await apiClient.lookupOrg(props.orgName);
const path = route.path.replace(`/org/${props.orgName}`, `/orgs/${org?.id}`);
await router.replace({ path });
});
</script>

View file

@ -1,7 +1,7 @@
<template>
<Scaffold v-model:search="search">
<Scaffold v-if="org && orgPermissions" v-model:search="search">
<template #title>
{{ orgName }}
{{ org.name }}
</template>
<template #titleActions>
@ -25,33 +25,25 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, ref, toRef } from 'vue';
import { computed, onMounted, ref } from 'vue';
import IconButton from '~/components/atomic/IconButton.vue';
import ListItem from '~/components/atomic/ListItem.vue';
import Scaffold from '~/components/layout/scaffold/Scaffold.vue';
import useApiClient from '~/compositions/useApiClient';
import { inject } from '~/compositions/useInjectProvide';
import { useRepoSearch } from '~/compositions/useRepoSearch';
import { OrgPermissions } from '~/lib/api/types';
import { useRepoStore } from '~/store/repos';
const props = defineProps<{
orgName: string;
}>();
const apiClient = useApiClient();
const repoStore = useRepoStore();
// TODO: filter server side
const orgName = toRef(props, 'orgName');
const repos = computed(() => Array.from(repoStore.repos.values()).filter((repo) => repo.owner === orgName.value));
const search = ref('');
const orgPermissions = ref<OrgPermissions>({ member: false, admin: false });
const org = inject('org');
const orgPermissions = inject('org-permissions');
const search = ref('');
const repos = computed(() => Array.from(repoStore.repos.values()).filter((repo) => repo.org_id === org.value?.id));
const { searchedRepos } = useRepoSearch(repos, search);
onMounted(async () => {
await repoStore.loadRepos();
orgPermissions.value = await apiClient.getOrgPermissions(orgName.value);
await repoStore.loadRepos(); // TODO: load only org repos
});
</script>

View file

@ -1,8 +1,8 @@
<template>
<Scaffold enable-tabs :go-back="goBack">
<Scaffold v-if="org" enable-tabs :go-back="goBack">
<template #title>
<span>
<router-link :to="{ name: 'org', params: { orgName: org.name } }" class="hover:underline">
<router-link :to="{ name: 'org' }" class="hover:underline">
{{ org.name }}
</router-link>
/
@ -17,32 +17,25 @@
</template>
<script lang="ts" setup>
import { inject, onMounted, Ref } from 'vue';
import { onMounted } from 'vue';
import { useI18n } from 'vue-i18n';
import { useRouter } from 'vue-router';
import Tab from '~/components/layout/scaffold/Tab.vue';
import OrgSecretsTab from '~/components/org/settings/OrgSecretsTab.vue';
import { inject } from '~/compositions/useInjectProvide';
import useNotifications from '~/compositions/useNotifications';
import { useRouteBackOrDefault } from '~/compositions/useRouteBackOrDefault';
import { Org, OrgPermissions } from '~/lib/api/types';
const notifications = useNotifications();
const router = useRouter();
const i18n = useI18n();
const orgPermissions = inject<Ref<OrgPermissions>>('org-permissions');
if (!orgPermissions) {
throw new Error('Unexpected: "orgPermissions" should be provided at this place');
}
const org = inject<Ref<Org>>('org');
if (!org) {
throw new Error('Unexpected: "org" should be provided at this place');
}
const org = inject('org');
const orgPermissions = inject('org-permissions');
onMounted(async () => {
if (!orgPermissions.value.admin) {
if (!orgPermissions.value?.admin) {
notifications.notify({ type: 'error', title: i18n.t('org.settings.not_allowed') });
await router.replace({ name: 'home' });
}

View file

@ -19,34 +19,31 @@
</template>
<script lang="ts" setup>
import { computed, onMounted, provide, ref, toRef, watch } from 'vue';
import { computed, onMounted, ref, watch } from 'vue';
import IconButton from '~/components/atomic/IconButton.vue';
import Scaffold from '~/components/layout/scaffold/Scaffold.vue';
import useApiClient from '~/compositions/useApiClient';
import { provide } from '~/compositions/useInjectProvide';
import { Org, OrgPermissions } from '~/lib/api/types';
const props = defineProps<{
orgName: string;
orgId: string;
}>();
const orgName = toRef(props, 'orgName');
const orgId = computed(() => parseInt(props.orgId, 10));
const apiClient = useApiClient();
const org = computed<Org>(() => ({ name: orgName.value }));
const org = ref<Org>();
const orgPermissions = ref<OrgPermissions>();
provide('org', org);
provide('org-permissions', orgPermissions);
async function load() {
orgPermissions.value = await apiClient.getOrgPermissions(orgName.value);
org.value = await apiClient.getOrg(orgId.value);
orgPermissions.value = await apiClient.getOrgPermissions(org.value.id);
}
onMounted(() => {
load();
});
watch([orgName], () => {
load();
});
onMounted(load);
watch(orgId, load);
</script>

View file

@ -2,7 +2,7 @@
<Scaffold enable-tabs :go-back="goBack">
<template #title>
<span>
<router-link :to="{ name: 'org', params: { orgName: repo.owner } }" class="hover:underline">
<router-link :to="{ name: 'org', params: { orgId: repo.org_id } }" class="hover:underline">
{{ repo.owner }}
</router-link>
/

View file

@ -7,7 +7,7 @@
>
<template #title>
<span class="flex">
<router-link :to="{ name: 'org', params: { orgName: repo?.owner } }" class="hover:underline">{{
<router-link :to="{ name: 'org', params: { orgId: repo.org_id } }" class="hover:underline">{{
repo.owner
}}</router-link>
{{ `&nbsp;/&nbsp;${repo.name}` }}

View file

@ -46,8 +46,10 @@ const (
pathRepoRegistry = "%s/api/repos/%d/registry/%s"
pathRepoCrons = "%s/api/repos/%d/cron"
pathRepoCron = "%s/api/repos/%d/cron/%d"
pathOrgSecrets = "%s/api/orgs/%s/secrets"
pathOrgSecret = "%s/api/orgs/%s/secrets/%s"
pathOrg = "%s/api/orgs/%d"
pathOrgLookup = "%s/api/orgs/lookup/%s"
pathOrgSecrets = "%s/api/orgs/%d/secrets"
pathOrgSecret = "%s/api/orgs/%d/secrets/%s"
pathGlobalSecrets = "%s/api/secrets"
pathGlobalSecret = "%s/api/secrets/%s"
pathUsers = "%s/api/users"
@ -397,41 +399,57 @@ func (c *client) SecretDelete(repoID int64, secret string) error {
return c.delete(uri)
}
// Org returns an organization by id.
func (c *client) Org(orgID int64) (*Org, error) {
out := new(Org)
uri := fmt.Sprintf(pathOrg, c.addr, orgID)
err := c.get(uri, out)
return out, err
}
// OrgLookup returns a organsization by its name.
func (c *client) OrgLookup(name string) (*Org, error) {
out := new(Org)
uri := fmt.Sprintf(pathOrgLookup, c.addr, name)
err := c.get(uri, out)
return out, err
}
// OrgSecret returns an organization secret by name.
func (c *client) OrgSecret(owner, secret string) (*Secret, error) {
func (c *client) OrgSecret(orgID int64, secret string) (*Secret, error) {
out := new(Secret)
uri := fmt.Sprintf(pathOrgSecret, c.addr, owner, secret)
uri := fmt.Sprintf(pathOrgSecret, c.addr, orgID, secret)
err := c.get(uri, out)
return out, err
}
// OrgSecretList returns a list of all organization secrets.
func (c *client) OrgSecretList(owner string) ([]*Secret, error) {
func (c *client) OrgSecretList(orgID int64) ([]*Secret, error) {
var out []*Secret
uri := fmt.Sprintf(pathOrgSecrets, c.addr, owner)
uri := fmt.Sprintf(pathOrgSecrets, c.addr, orgID)
err := c.get(uri, &out)
return out, err
}
// OrgSecretCreate creates an organization secret.
func (c *client) OrgSecretCreate(owner string, in *Secret) (*Secret, error) {
func (c *client) OrgSecretCreate(orgID int64, in *Secret) (*Secret, error) {
out := new(Secret)
uri := fmt.Sprintf(pathOrgSecrets, c.addr, owner)
uri := fmt.Sprintf(pathOrgSecrets, c.addr, orgID)
err := c.post(uri, in, out)
return out, err
}
// OrgSecretUpdate updates an organization secret.
func (c *client) OrgSecretUpdate(owner string, in *Secret) (*Secret, error) {
func (c *client) OrgSecretUpdate(orgID int64, in *Secret) (*Secret, error) {
out := new(Secret)
uri := fmt.Sprintf(pathOrgSecret, c.addr, owner, in.Name)
uri := fmt.Sprintf(pathOrgSecret, c.addr, orgID, in.Name)
err := c.patch(uri, in, out)
return out, err
}
// OrgSecretDelete deletes an organization secret.
func (c *client) OrgSecretDelete(owner, secret string) error {
uri := fmt.Sprintf(pathOrgSecret, c.addr, owner, secret)
func (c *client) OrgSecretDelete(orgID int64, secret string) error {
uri := fmt.Sprintf(pathOrgSecret, c.addr, orgID, secret)
return c.delete(uri)
}

View file

@ -148,20 +148,26 @@ type Client interface {
// SecretDelete deletes a secret.
SecretDelete(repoID int64, secret string) error
// Org returns an organization by name.
Org(orgID int64) (*Org, error)
// OrgLookup returns an organization id by name.
OrgLookup(orgName string) (*Org, error)
// OrgSecret returns an organization secret by name.
OrgSecret(owner, secret string) (*Secret, error)
OrgSecret(orgID int64, secret string) (*Secret, error)
// OrgSecretList returns a list of all organization secrets.
OrgSecretList(owner string) ([]*Secret, error)
OrgSecretList(orgID int64) ([]*Secret, error)
// OrgSecretCreate creates an organization secret.
OrgSecretCreate(owner string, secret *Secret) (*Secret, error)
OrgSecretCreate(orgID int64, secret *Secret) (*Secret, error)
// OrgSecretUpdate updates an organization secret.
OrgSecretUpdate(owner string, secret *Secret) (*Secret, error)
OrgSecretUpdate(orgID int64, secret *Secret) (*Secret, error)
// OrgSecretDelete deletes an organization secret.
OrgSecretDelete(owner, secret string) error
OrgSecretDelete(orgID int64, secret string) error
// GlobalSecret returns an global secret by name.
GlobalSecret(secret string) (*Secret, error)

View file

@ -241,4 +241,11 @@ type (
DepStatus map[string]string `json:"dep_status"`
AgentID int64 `json:"agent_id"`
}
// Org is the JSON data for an organization
Org struct {
ID int64 `json:"id"`
Name string `json:"name"`
IsUser bool `json:"is_user"`
}
)