From e5d5ec8b47e8fe5d331e1eb340ae240ee2c7ade2 Mon Sep 17 00:00:00 2001 From: Anbraten Date: Fri, 21 Jul 2023 19:45:32 +0200 Subject: [PATCH] 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> --- cli/common/flags.go | 6 + cli/secret/secret.go | 37 +++- cli/secret/secret_add.go | 13 +- cli/secret/secret_info.go | 11 +- cli/secret/secret_list.go | 11 +- cli/secret/secret_rm.go | 11 +- cli/secret/secret_set.go | 11 +- cmd/server/docs/docs.go | 206 +++++++++++++----- server/api/login.go | 13 +- server/api/org.go | 126 ++++++++++- server/api/org_secret.go | 105 +++++---- server/api/repo.go | 25 +++ server/cache/membership.go | 25 ++- server/forge/bitbucket/bitbucket.go | 12 + server/forge/bitbucket/internal/client.go | 7 + server/forge/forge.go | 7 +- server/forge/gitea/gitea.go | 26 +++ server/forge/github/github.go | 21 ++ server/forge/gitlab/gitlab.go | 42 ++++ server/forge/mocks/forge.go | 38 +++- server/model/org.go | 29 +++ server/model/repo.go | 1 + server/model/secret.go | 22 +- .../encryption/wrapper/store/secret_store.go | 4 +- server/plugins/secrets/builtin.go | 10 +- server/router/api.go | 4 +- server/router/middleware/session/user.go | 24 +- server/store/datastore/log_test.go | 2 +- .../store/datastore/migration/021_add_orgs.go | 112 ++++++++++ server/store/datastore/migration/migration.go | 41 ++-- server/store/datastore/org.go | 59 +++++ server/store/datastore/org_test.go | 75 +++++++ server/store/datastore/secret.go | 16 +- server/store/datastore/secret_test.go | 16 +- server/store/mocks/store.go | 136 +++++++++++- server/store/store.go | 14 +- .../components/org/settings/OrgSecretsTab.vue | 127 +++++------ web/src/compositions/useInjectProvide.ts | 4 +- web/src/lib/api/index.ts | 29 ++- web/src/lib/api/types/org.ts | 2 + web/src/lib/api/types/repo.ts | 3 + web/src/router.ts | 7 +- web/src/views/org/OrgDeprecatedRedirect.vue | 26 +++ web/src/views/org/OrgRepos.vue | 26 +-- web/src/views/org/OrgSettings.vue | 21 +- web/src/views/org/OrgWrapper.vue | 21 +- web/src/views/repo/RepoSettings.vue | 2 +- web/src/views/repo/RepoWrapper.vue | 2 +- woodpecker-go/woodpecker/client.go | 42 +++- woodpecker-go/woodpecker/interface.go | 16 +- woodpecker-go/woodpecker/types.go | 7 + 51 files changed, 1261 insertions(+), 392 deletions(-) create mode 100644 server/model/org.go create mode 100644 server/store/datastore/migration/021_add_orgs.go create mode 100644 server/store/datastore/org.go create mode 100644 server/store/datastore/org_test.go create mode 100644 web/src/views/org/OrgDeprecatedRedirect.vue diff --git a/cli/common/flags.go b/cli/common/flags.go index 5e7dc8e4c..5465a2d7d 100644 --- a/cli/common/flags.go +++ b/cli/common/flags.go @@ -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)", +} diff --git a/cli/secret/secret.go b/cli/secret/secret.go index 59788cf4a..670389b87 100644 --- a/cli/secret/secret.go +++ b/cli/secret/secret.go @@ -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 } diff --git a/cli/secret/secret_add.go b/cli/secret/secret_add.go index 045df8979..05ad9f5f9 100644 --- a/cli/secret/secret_add.go +++ b/cli/secret/secret_add.go @@ -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 } diff --git a/cli/secret/secret_info.go b/cli/secret/secret_info.go index 00399b924..1347804ef 100644 --- a/cli/secret/secret_info.go +++ b/cli/secret/secret_info.go @@ -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 } diff --git a/cli/secret/secret_list.go b/cli/secret/secret_list.go index 18d0f2cd3..7d4f61870 100644 --- a/cli/secret/secret_list.go +++ b/cli/secret/secret_list.go @@ -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 } diff --git a/cli/secret/secret_rm.go b/cli/secret/secret_rm.go index 5515ee52f..8e3c6fba8 100644 --- a/cli/secret/secret_rm.go +++ b/cli/secret/secret_rm.go @@ -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) } diff --git a/cli/secret/secret_set.go b/cli/secret/secret_set.go index 1b05b4ede..4ae584f12 100644 --- a/cli/secret/secret_set.go +++ b/cli/secret/secret_set.go @@ -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) diff --git a/cmd/server/docs/docs.go b/cmd/server/docs/docs.go index 9ed893b1b..905fc780b 100644 --- a/cmd/server/docs/docs.go +++ b/cmd/server/docs/docs.go @@ -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" }, diff --git a/server/api/login.go b/server/api/login.go index 8aaf58ded..38f49531f 100644 --- a/server/api/login.go +++ b/server/api/login.go @@ -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 } diff --git a/server/api/org.go b/server/api/org.go index 90f409576..3c9703c98 100644 --- a/server/api/org.go +++ b/server/api/org.go @@ -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 ) +// @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 ) -// @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 ) +// @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) +} diff --git a/server/api/org_secret.go b/server/api/org_secret.go index 9f5d388df..4cc13bbd6 100644 --- a/server/api/org_secret.go +++ b/server/api/org_secret.go @@ -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 ) -// @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 ) -// @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 ) -// @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 ) -// @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 ) -// @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 } diff --git a/server/api/repo.go b/server/api/repo.go index a65e1877c..ea4425ab1 100644 --- a/server/api/repo.go +++ b/server/api/repo.go @@ -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()) diff --git a/server/cache/membership.go b/server/cache/membership.go index 20b096d10..14b1eea13 100644 --- a/server/cache/membership.go +++ b/server/cache/membership.go @@ -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 } diff --git a/server/forge/bitbucket/bitbucket.go b/server/forge/bitbucket/bitbucket.go index d06a24424..d0d4c1982 100644 --- a/server/forge/bitbucket/bitbucket.go +++ b/server/forge/bitbucket/bitbucket.go @@ -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 { diff --git a/server/forge/bitbucket/internal/client.go b/server/forge/bitbucket/internal/client.go index 0888f1f2c..13fe875ed 100644 --- a/server/forge/bitbucket/internal/client.go +++ b/server/forge/bitbucket/internal/client.go @@ -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 { diff --git a/server/forge/forge.go b/server/forge/forge.go index fea060102..e9c224d5a 100644 --- a/server/forge/forge.go +++ b/server/forge/forge.go @@ -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 diff --git a/server/forge/gitea/gitea.go b/server/forge/gitea/gitea.go index 9f11fd92c..1502d8bd4 100644 --- a/server/forge/gitea/gitea.go +++ b/server/forge/gitea/gitea.go @@ -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{} diff --git a/server/forge/github/github.go b/server/forge/github/github.go index 4c7f6034f..73c22d26b 100644 --- a/server/forge/github/github.go +++ b/server/forge/github/github.go @@ -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 { diff --git a/server/forge/gitlab/gitlab.go b/server/forge/gitlab/gitlab.go index 310284163..5f5ec2ab3 100644 --- a/server/forge/gitlab/gitlab.go +++ b/server/forge/gitlab/gitlab.go @@ -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 { diff --git a/server/forge/mocks/forge.go b/server/forge/mocks/forge.go index 8867fcba2..0f2ab0310 100644 --- a/server/forge/mocks/forge.go +++ b/server/forge/mocks/forge.go @@ -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) } diff --git a/server/model/org.go b/server/model/org.go new file mode 100644 index 000000000..db093bb64 --- /dev/null +++ b/server/model/org.go @@ -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" +} diff --git a/server/model/repo.go b/server/model/repo.go index 6845382d6..836aae96f 100644 --- a/server/model/repo.go +++ b/server/model/repo.go @@ -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'"` diff --git a/server/model/secret.go b/server/model/secret.go index fe7401edf..71f331135 100644 --- a/server/model/secret.go +++ b/server/model/secret.go @@ -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, diff --git a/server/plugins/encryption/wrapper/store/secret_store.go b/server/plugins/encryption/wrapper/store/secret_store.go index 36b676991..fd0ae0e38 100644 --- a/server/plugins/encryption/wrapper/store/secret_store.go +++ b/server/plugins/encryption/wrapper/store/secret_store.go @@ -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 diff --git a/server/plugins/secrets/builtin.go b/server/plugins/secrets/builtin.go index f91db8450..98edd81f5 100644 --- a/server/plugins/secrets/builtin.go +++ b/server/plugins/secrets/builtin.go @@ -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 diff --git a/server/router/api.go b/server/router/api.go index 827d9715e..02fd341d7 100644 --- a/server/router/api.go +++ b/server/router/api.go @@ -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) diff --git a/server/router/middleware/session/user.go b/server/router/middleware/session/user.go index fb7bc1a28..f13f2f22c 100644 --- a/server/router/middleware/session/user.go +++ b/server/router/middleware/session/user.go @@ -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() } } diff --git a/server/store/datastore/log_test.go b/server/store/datastore/log_test.go index a49552660..6796aeea1 100644 --- a/server/store/datastore/log_test.go +++ b/server/store/datastore/log_test.go @@ -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. diff --git a/server/store/datastore/migration/021_add_orgs.go b/server/store/datastore/migration/021_add_orgs.go new file mode 100644 index 000000000..7e19d8d0e --- /dev/null +++ b/server/store/datastore/migration/021_add_orgs.go @@ -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") + }, +} diff --git a/server/store/datastore/migration/migration.go b/server/store/datastore/migration/migration.go index d0267ed7f..1057c2f5f 100644 --- a/server/store/datastore/migration/migration.go +++ b/server/store/datastore/migration/migration.go @@ -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 diff --git a/server/store/datastore/org.go b/server/store/datastore/org.go new file mode 100644 index 000000000..232e38321 --- /dev/null +++ b/server/store/datastore/org.go @@ -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) +} diff --git a/server/store/datastore/org_test.go b/server/store/datastore/org_test.go new file mode 100644 index 000000000..3135a05ac --- /dev/null +++ b/server/store/datastore/org_test.go @@ -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)) +} diff --git a/server/store/datastore/secret.go b/server/store/datastore/secret.go index 047901725..be30f5b6e 100644 --- a/server/store/datastore/secret.go +++ b/server/store/datastore/secret.go @@ -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) } diff --git a/server/store/datastore/secret_test.go b/server/store/datastore/secret_test.go index 76398f66d..d24e43528 100644 --- a/server/store/datastore/secret_test.go +++ b/server/store/datastore/secret_test.go @@ -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) diff --git a/server/store/mocks/store.go b/server/store/mocks/store.go index 818dd0265..d6056c74f 100644 --- a/server/store/mocks/store.go +++ b/server/store/mocks/store.go @@ -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) diff --git a/server/store/store.go b/server/store/store.go index 6b97227ee..1d59601af 100644 --- a/server/store/store.go +++ b/server/store/store.go @@ -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 diff --git a/web/src/components/org/settings/OrgSecretsTab.vue b/web/src/components/org/settings/OrgSecretsTab.vue index 024b5189d..4f46e272f 100644 --- a/web/src/components/org/settings/OrgSecretsTab.vue +++ b/web/src/components/org/settings/OrgSecretsTab.vue @@ -38,9 +38,9 @@ - diff --git a/web/src/compositions/useInjectProvide.ts b/web/src/compositions/useInjectProvide.ts index ca0ab9583..671dea8f7 100644 --- a/web/src/compositions/useInjectProvide.ts +++ b/web/src/compositions/useInjectProvide.ts @@ -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; + org: Ref; + 'org-permissions': Ref; }; export function inject(key: T): InjectKeys[T] { diff --git a/web/src/lib/api/index.ts b/web/src/lib/api/index.ts index 3e64af3c7..a41479cc4 100644 --- a/web/src/lib/api/index.ts +++ b/web/src/lib/api/index.ts @@ -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; } - getOrgPermissions(owner: string): Promise { - return this._get(`/api/orgs/${owner}/permissions`) as Promise; + getOrg(orgId: number): Promise { + return this._get(`/api/orgs/${orgId}`) as Promise; } - getOrgSecretList(owner: string, page: number): Promise { - return this._get(`/api/orgs/${owner}/secrets?page=${page}`) as Promise; + lookupOrg(name: string): Promise { + return this._get(`/api/orgs/lookup/${name}`) as Promise; } - createOrgSecret(owner: string, secret: Partial): Promise { - return this._post(`/api/orgs/${owner}/secrets`, secret); + getOrgPermissions(orgId: number): Promise { + return this._get(`/api/orgs/${orgId}/permissions`) as Promise; } - updateOrgSecret(owner: string, secret: Partial): Promise { + getOrgSecretList(orgId: number, page: number): Promise { + return this._get(`/api/orgs/${orgId}/secrets?page=${page}`) as Promise; + } + + createOrgSecret(orgId: number, secret: Partial): Promise { + return this._post(`/api/orgs/${orgId}/secrets`, secret); + } + + updateOrgSecret(orgId: number, secret: Partial): Promise { 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 { + deleteOrgSecret(orgId: number, secretName: string): Promise { const name = encodeURIComponent(secretName); - return this._delete(`/api/orgs/${owner}/secrets/${name}`); + return this._delete(`/api/orgs/${orgId}/secrets/${name}`); } getGlobalSecretList(page: number): Promise { diff --git a/web/src/lib/api/types/org.ts b/web/src/lib/api/types/org.ts index 7552724b4..118fd36ec 100644 --- a/web/src/lib/api/types/org.ts +++ b/web/src/lib/api/types/org.ts @@ -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 = { diff --git a/web/src/lib/api/types/repo.ts b/web/src/lib/api/types/repo.ts index 4825f6a2a..9241fc0ad 100644 --- a/web/src/lib/api/types/repo.ts +++ b/web/src/lib/api/types/repo.ts @@ -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; diff --git a/web/src/router.ts b/web/src/router.ts index 6b79cfce6..ea33cc561 100644 --- a/web/src/router.ts +++ b/web/src/router.ts @@ -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'), diff --git a/web/src/views/org/OrgDeprecatedRedirect.vue b/web/src/views/org/OrgDeprecatedRedirect.vue new file mode 100644 index 000000000..e4ab747c0 --- /dev/null +++ b/web/src/views/org/OrgDeprecatedRedirect.vue @@ -0,0 +1,26 @@ + + + diff --git a/web/src/views/org/OrgRepos.vue b/web/src/views/org/OrgRepos.vue index 12f326101..314cde920 100644 --- a/web/src/views/org/OrgRepos.vue +++ b/web/src/views/org/OrgRepos.vue @@ -1,7 +1,7 @@