feat: [CODE-626,CODE-627]: space membership API&DB (#194)

jobatzil/rename
Johannes Batzill 2023-07-20 21:20:56 +00:00 committed by Harness
parent 7fb7e47560
commit baa4eb5ac9
28 changed files with 1200 additions and 39 deletions

View File

@ -48,12 +48,20 @@ import (
func initSystem(ctx context.Context, config *types.Config) (*server.System, error) {
principalUID := check.ProvidePrincipalUIDCheck()
authorizer := authz.ProvideAuthorizer()
databaseConfig := server.ProvideDatabaseConfig(config)
db, err := database.ProvideDatabase(ctx, databaseConfig)
if err != nil {
return nil, err
}
pathTransformation := store.ProvidePathTransformation()
pathStore := database.ProvidePathStore(db, pathTransformation)
pathCache := cache.ProvidePathCache(pathStore, pathTransformation)
spaceStore := database.ProvideSpaceStore(db, pathCache)
principalInfoView := database.ProvidePrincipalInfoView(db)
principalInfoCache := cache.ProvidePrincipalInfoCache(principalInfoView)
membershipStore := database.ProvideMembershipStore(db, principalInfoCache)
permissionCache := authz.ProvidePermissionCache(spaceStore, membershipStore)
authorizer := authz.ProvideAuthorizer(permissionCache)
principalUIDTransformation := store.ProvidePrincipalUIDTransformation()
principalStore := database.ProvidePrincipalStore(db, principalUIDTransformation)
tokenStore := database.ProvideTokenStore(db)
@ -66,11 +74,7 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
return nil, err
}
pathUID := check.ProvidePathUIDCheck()
pathTransformation := store.ProvidePathTransformation()
pathStore := database.ProvidePathStore(db, pathTransformation)
pathCache := cache.ProvidePathCache(pathStore, pathTransformation)
repoStore := database.ProvideRepoStore(db, pathCache)
spaceStore := database.ProvideSpaceStore(db, pathCache)
gitrpcConfig, err := server.ProvideGitRPCClientConfig()
if err != nil {
return nil, err
@ -80,9 +84,7 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
return nil, err
}
repoController := repo.ProvideController(config, db, provider, pathUID, authorizer, pathStore, repoStore, spaceStore, principalStore, gitrpcInterface)
spaceController := space.ProvideController(db, provider, pathUID, authorizer, pathStore, spaceStore, repoStore, principalStore, repoController)
principalInfoView := database.ProvidePrincipalInfoView(db)
principalInfoCache := cache.ProvidePrincipalInfoCache(principalInfoView)
spaceController := space.ProvideController(db, provider, pathUID, authorizer, pathStore, spaceStore, repoStore, principalStore, repoController, membershipStore)
pullReqStore := database.ProvidePullReqStore(db, principalInfoCache)
pullReqActivityStore := database.ProvidePullReqActivityStore(db, principalInfoCache)
codeCommentView := database.ProvideCodeCommentView(db)

View File

@ -15,31 +15,34 @@ import (
)
type Controller struct {
db *sqlx.DB
urlProvider *url.Provider
uidCheck check.PathUID
authorizer authz.Authorizer
pathStore store.PathStore
spaceStore store.SpaceStore
repoStore store.RepoStore
principalStore store.PrincipalStore
repoCtrl *repo.Controller
db *sqlx.DB
urlProvider *url.Provider
uidCheck check.PathUID
authorizer authz.Authorizer
pathStore store.PathStore
spaceStore store.SpaceStore
repoStore store.RepoStore
principalStore store.PrincipalStore
repoCtrl *repo.Controller
membershipStore store.MembershipStore
}
func NewController(db *sqlx.DB, urlProvider *url.Provider,
uidCheck check.PathUID, authorizer authz.Authorizer,
pathStore store.PathStore, spaceStore store.SpaceStore,
repoStore store.RepoStore, principalStore store.PrincipalStore, repoCtrl *repo.Controller,
membershipStore store.MembershipStore,
) *Controller {
return &Controller{
db: db,
urlProvider: urlProvider,
uidCheck: uidCheck,
authorizer: authorizer,
pathStore: pathStore,
spaceStore: spaceStore,
repoStore: repoStore,
principalStore: principalStore,
repoCtrl: repoCtrl,
db: db,
urlProvider: urlProvider,
uidCheck: uidCheck,
authorizer: authorizer,
pathStore: pathStore,
spaceStore: spaceStore,
repoStore: repoStore,
principalStore: principalStore,
repoCtrl: repoCtrl,
membershipStore: membershipStore,
}
}

View File

@ -13,6 +13,7 @@ import (
apiauth "github.com/harness/gitness/internal/api/auth"
"github.com/harness/gitness/internal/api/usererror"
"github.com/harness/gitness/internal/auth"
"github.com/harness/gitness/internal/bootstrap"
"github.com/harness/gitness/internal/paths"
"github.com/harness/gitness/store/database/dbtx"
"github.com/harness/gitness/types"
@ -84,14 +85,32 @@ func (c *Controller) Create(ctx context.Context, session *auth.Session, in *Crea
TargetType: enum.PathTargetTypeSpace,
TargetID: space.ID,
CreatedBy: space.CreatedBy,
Created: space.Created,
Updated: space.Updated,
Created: now,
Updated: now,
}
err = c.pathStore.Create(ctx, path)
if err != nil {
return fmt.Errorf("failed to create path: %w", err)
}
// add space membership to top level space only (as the user doesn't have inhereted permissions alraedy)
if in.ParentID == 0 {
membership := &types.Membership{
SpaceID: space.ID,
PrincipalID: session.Principal.ID,
Role: enum.MembershipRoleSpaceOwner,
// membership has been created by the system
CreatedBy: bootstrap.NewSystemServiceSession().Principal.ID,
Created: now,
Updated: now,
}
err = c.membershipStore.Create(ctx, membership)
if err != nil {
return fmt.Errorf("failed to make user owner of the space: %w", err)
}
}
return nil
})
if err != nil {

View File

@ -0,0 +1,95 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package space
import (
"context"
"fmt"
"time"
apiauth "github.com/harness/gitness/internal/api/auth"
"github.com/harness/gitness/internal/api/usererror"
"github.com/harness/gitness/internal/auth"
"github.com/harness/gitness/store"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"github.com/pkg/errors"
)
type MembershipAddInput struct {
UserUID string `json:"user_uid"`
Role enum.MembershipRole `json:"role"`
}
func (in *MembershipAddInput) Validate() error {
if in.UserUID == "" {
return usererror.BadRequest("UserUID must be provided")
}
if in.Role == "" {
return usererror.BadRequest("Role must be provided")
}
role, ok := in.Role.Sanitize()
if !ok {
msg := fmt.Sprintf("Provided role '%s' is not suppored. Valid values are: %v",
in.Role, enum.MembershipRoles)
return usererror.BadRequest(msg)
}
in.Role = role
return nil
}
// MembershipAdd adds a new membership to a space.
func (c *Controller) MembershipAdd(ctx context.Context,
session *auth.Session,
spaceRef string,
in *MembershipAddInput,
) (*types.Membership, error) {
space, err := c.spaceStore.FindByRef(ctx, spaceRef)
if err != nil {
return nil, err
}
if err = apiauth.CheckSpace(ctx, c.authorizer, session, space, enum.PermissionSpaceEdit, false); err != nil {
return nil, err
}
err = in.Validate()
if err != nil {
return nil, err
}
user, err := c.principalStore.FindUserByUID(ctx, in.UserUID)
if errors.Is(err, store.ErrResourceNotFound) {
return nil, usererror.BadRequestf("User '%s' not found", in.UserUID)
} else if err != nil {
return nil, fmt.Errorf("failed to find the user: %w", err)
}
now := time.Now().UnixMilli()
membership := &types.Membership{
SpaceID: space.ID,
PrincipalID: user.ID,
CreatedBy: session.Principal.ID,
Created: now,
Updated: now,
Role: in.Role,
Principal: *user.ToPrincipalInfo(),
AdddedBy: *session.Principal.ToPrincipalInfo(),
}
err = c.membershipStore.Create(ctx, membership)
if err != nil {
return nil, fmt.Errorf("failed to create new membership: %w", err)
}
return membership, nil
}

View File

@ -0,0 +1,46 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package space
import (
"context"
"fmt"
apiauth "github.com/harness/gitness/internal/api/auth"
"github.com/harness/gitness/internal/auth"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
)
// MembershipDelete removes an existing membership from a space.
func (c *Controller) MembershipDelete(ctx context.Context,
session *auth.Session,
spaceRef string,
userUID string,
) error {
space, err := c.spaceStore.FindByRef(ctx, spaceRef)
if err != nil {
return err
}
if err = apiauth.CheckSpace(ctx, c.authorizer, session, space, enum.PermissionSpaceEdit, false); err != nil {
return err
}
user, err := c.principalStore.FindUserByUID(ctx, userUID)
if err != nil {
return fmt.Errorf("failed to find user by uid: %w", err)
}
err = c.membershipStore.Delete(ctx, types.MembershipKey{
SpaceID: space.ID,
PrincipalID: user.ID,
})
if err != nil {
return fmt.Errorf("failed to delete user membership: %w", err)
}
return nil
}

View File

@ -0,0 +1,37 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package space
import (
"context"
"fmt"
apiauth "github.com/harness/gitness/internal/api/auth"
"github.com/harness/gitness/internal/auth"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
)
// MembershipList lists all space memberships.
func (c *Controller) MembershipList(ctx context.Context,
session *auth.Session,
spaceRef string,
) ([]*types.Membership, error) {
space, err := c.spaceStore.FindByRef(ctx, spaceRef)
if err != nil {
return nil, err
}
if err = apiauth.CheckSpace(ctx, c.authorizer, session, space, enum.PermissionSpaceView, false); err != nil {
return nil, err
}
memberships, err := c.membershipStore.ListForSpace(ctx, space.ID)
if err != nil {
return nil, fmt.Errorf("failed to list memberships for space: %w", err)
}
return memberships, nil
}

View File

@ -0,0 +1,85 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package space
import (
"context"
"fmt"
apiauth "github.com/harness/gitness/internal/api/auth"
"github.com/harness/gitness/internal/api/usererror"
"github.com/harness/gitness/internal/auth"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
)
type MembershipUpdateInput struct {
Role enum.MembershipRole `json:"role"`
}
func (in *MembershipUpdateInput) Validate() error {
if in.Role == "" {
return usererror.BadRequest("Role must be provided")
}
role, ok := in.Role.Sanitize()
if !ok {
msg := fmt.Sprintf("Provided role '%s' is not suppored. Valid values are: %v",
in.Role, enum.MembershipRoles)
return usererror.BadRequest(msg)
}
in.Role = role
return nil
}
// MembershipUpdate changes the role of an existing membership.
func (c *Controller) MembershipUpdate(ctx context.Context,
session *auth.Session,
spaceRef string,
userUID string,
in *MembershipUpdateInput,
) (*types.Membership, error) {
space, err := c.spaceStore.FindByRef(ctx, spaceRef)
if err != nil {
return nil, err
}
if err = apiauth.CheckSpace(ctx, c.authorizer, session, space, enum.PermissionSpaceEdit, false); err != nil {
return nil, err
}
err = in.Validate()
if err != nil {
return nil, err
}
user, err := c.principalStore.FindUserByUID(ctx, userUID)
if err != nil {
return nil, fmt.Errorf("failed to find user by uid: %w", err)
}
membership, err := c.membershipStore.Find(ctx, types.MembershipKey{
SpaceID: space.ID,
PrincipalID: user.ID,
})
if err != nil {
return nil, fmt.Errorf("failed to find membership for update: %w", err)
}
if membership.Role == in.Role {
return membership, nil
}
membership.Role = in.Role
err = c.membershipStore.Update(ctx, membership)
if err != nil {
return nil, fmt.Errorf("failed to update membership")
}
return membership, nil
}

View File

@ -22,6 +22,11 @@ var WireSet = wire.NewSet(
func ProvideController(db *sqlx.DB, urlProvider *url.Provider, uidCheck check.PathUID, authorizer authz.Authorizer,
pathStore store.PathStore, spaceStore store.SpaceStore, repoStore store.RepoStore,
principalStore store.PrincipalStore, repoCtrl *repo.Controller) *Controller {
return NewController(db, urlProvider, uidCheck, authorizer, pathStore, spaceStore, repoStore, principalStore, repoCtrl)
principalStore store.PrincipalStore, repoCtrl *repo.Controller,
membershipStore store.MembershipStore,
) *Controller {
return NewController(db, urlProvider, uidCheck, authorizer,
pathStore, spaceStore, repoStore,
principalStore, repoCtrl,
membershipStore)
}

View File

@ -0,0 +1,43 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package space
import (
"encoding/json"
"net/http"
"github.com/harness/gitness/internal/api/controller/space"
"github.com/harness/gitness/internal/api/render"
"github.com/harness/gitness/internal/api/request"
)
// HandleMembershipAdd handles API that adds a new membership to a space.
func HandleMembershipAdd(spaceCtrl *space.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx)
spaceRef, err := request.GetSpaceRefFromPath(r)
if err != nil {
render.TranslatedUserError(w, err)
return
}
in := new(space.MembershipAddInput)
err = json.NewDecoder(r.Body).Decode(in)
if err != nil {
render.BadRequestf(w, "Invalid Request Body: %s.", err)
return
}
memberInfo, err := spaceCtrl.MembershipAdd(ctx, session, spaceRef, in)
if err != nil {
render.TranslatedUserError(w, err)
return
}
render.JSON(w, http.StatusCreated, memberInfo)
}
}

View File

@ -0,0 +1,41 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package space
import (
"net/http"
"github.com/harness/gitness/internal/api/controller/space"
"github.com/harness/gitness/internal/api/render"
"github.com/harness/gitness/internal/api/request"
)
// HandleMembershipDelete handles API that deletes an existing space membership.
func HandleMembershipDelete(spaceCtrl *space.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx)
spaceRef, err := request.GetSpaceRefFromPath(r)
if err != nil {
render.TranslatedUserError(w, err)
return
}
userUID, err := request.GetUserUIDFromPath(r)
if err != nil {
render.TranslatedUserError(w, err)
return
}
err = spaceCtrl.MembershipDelete(ctx, session, spaceRef, userUID)
if err != nil {
render.TranslatedUserError(w, err)
return
}
render.DeleteSuccessful(w)
}
}

View File

@ -0,0 +1,35 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package space
import (
"net/http"
"github.com/harness/gitness/internal/api/controller/space"
"github.com/harness/gitness/internal/api/render"
"github.com/harness/gitness/internal/api/request"
)
// HandleMembershipList handles API that lists all memberships of a space.
func HandleMembershipList(spaceCtrl *space.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx)
spaceRef, err := request.GetSpaceRefFromPath(r)
if err != nil {
render.TranslatedUserError(w, err)
return
}
memberInfos, err := spaceCtrl.MembershipList(ctx, session, spaceRef)
if err != nil {
render.TranslatedUserError(w, err)
return
}
render.JSON(w, http.StatusOK, memberInfos)
}
}

View File

@ -0,0 +1,49 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package space
import (
"encoding/json"
"net/http"
"github.com/harness/gitness/internal/api/controller/space"
"github.com/harness/gitness/internal/api/render"
"github.com/harness/gitness/internal/api/request"
)
// HandleMembershipUpdate handles API that changes the role of an existing space membership.
func HandleMembershipUpdate(spaceCtrl *space.Controller) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
session, _ := request.AuthSessionFrom(ctx)
spaceRef, err := request.GetSpaceRefFromPath(r)
if err != nil {
render.TranslatedUserError(w, err)
return
}
userUID, err := request.GetUserUIDFromPath(r)
if err != nil {
render.TranslatedUserError(w, err)
return
}
in := new(space.MembershipUpdateInput)
err = json.NewDecoder(r.Body).Decode(in)
if err != nil {
render.BadRequestf(w, "Invalid Request Body: %s.", err)
return
}
memberInfo, err := spaceCtrl.MembershipUpdate(ctx, session, spaceRef, userUID, in)
if err != nil {
render.TranslatedUserError(w, err)
return
}
render.JSON(w, http.StatusOK, memberInfo)
}
}

View File

@ -244,4 +244,60 @@ func spaceOperations(reflector *openapi3.Reflector) {
_ = reflector.SetJSONResponse(&onDeletePath, new(usererror.Error), http.StatusForbidden)
_ = reflector.SetJSONResponse(&onDeletePath, new(usererror.Error), http.StatusNotFound)
_ = reflector.Spec.AddOperation(http.MethodDelete, "/spaces/{space_ref}/paths/{path_id}", onDeletePath)
opMembershipAdd := openapi3.Operation{}
opMembershipAdd.WithTags("space")
opMembershipAdd.WithMapOfAnything(map[string]interface{}{"operationId": "membershipAdd"})
_ = reflector.SetRequest(&opMembershipAdd, struct {
spaceRequest
space.MembershipAddInput
}{}, http.MethodPost)
_ = reflector.SetJSONResponse(&opMembershipAdd, &types.Membership{}, http.StatusCreated)
_ = reflector.SetJSONResponse(&opMembershipAdd, new(usererror.Error), http.StatusInternalServerError)
_ = reflector.SetJSONResponse(&opMembershipAdd, new(usererror.Error), http.StatusUnauthorized)
_ = reflector.SetJSONResponse(&opMembershipAdd, new(usererror.Error), http.StatusForbidden)
_ = reflector.SetJSONResponse(&opMembershipAdd, new(usererror.Error), http.StatusNotFound)
_ = reflector.Spec.AddOperation(http.MethodPost, "/spaces/{space_ref}/members", opMembershipAdd)
opMembershipDelete := openapi3.Operation{}
opMembershipDelete.WithTags("space")
opMembershipDelete.WithMapOfAnything(map[string]interface{}{"operationId": "membershipDelete"})
_ = reflector.SetRequest(&opMembershipDelete, struct {
spaceRequest
UserUID string `path:"user_uid"`
}{}, http.MethodDelete)
_ = reflector.SetJSONResponse(&opMembershipDelete, nil, http.StatusNoContent)
_ = reflector.SetJSONResponse(&opMembershipDelete, new(usererror.Error), http.StatusInternalServerError)
_ = reflector.SetJSONResponse(&opMembershipDelete, new(usererror.Error), http.StatusUnauthorized)
_ = reflector.SetJSONResponse(&opMembershipDelete, new(usererror.Error), http.StatusForbidden)
_ = reflector.SetJSONResponse(&opMembershipDelete, new(usererror.Error), http.StatusNotFound)
_ = reflector.Spec.AddOperation(http.MethodDelete, "/spaces/{space_ref}/members/{user_uid}", opMembershipDelete)
opMembershipUpdate := openapi3.Operation{}
opMembershipUpdate.WithTags("space")
opMembershipUpdate.WithMapOfAnything(map[string]interface{}{"operationId": "membershipUpdate"})
_ = reflector.SetRequest(&opMembershipUpdate, &struct {
spaceRequest
UserUID string `path:"user_uid"`
space.MembershipUpdateInput
}{}, http.MethodPatch)
_ = reflector.SetJSONResponse(&opMembershipUpdate, &types.Membership{}, http.StatusOK)
_ = reflector.SetJSONResponse(&opMembershipUpdate, new(usererror.Error), http.StatusInternalServerError)
_ = reflector.SetJSONResponse(&opMembershipUpdate, new(usererror.Error), http.StatusUnauthorized)
_ = reflector.SetJSONResponse(&opMembershipUpdate, new(usererror.Error), http.StatusForbidden)
_ = reflector.SetJSONResponse(&opMembershipUpdate, new(usererror.Error), http.StatusNotFound)
_ = reflector.Spec.AddOperation(http.MethodPatch, "/spaces/{space_ref}/members/{user_uid}", opMembershipUpdate)
opMembershipList := openapi3.Operation{}
opMembershipList.WithTags("space")
opMembershipList.WithMapOfAnything(map[string]interface{}{"operationId": "membershipList"})
_ = reflector.SetRequest(&opMembershipList, &struct {
spaceRequest
}{}, http.MethodGet)
_ = reflector.SetJSONResponse(&opMembershipList, []types.Membership{}, http.StatusOK)
_ = reflector.SetJSONResponse(&opMembershipList, new(usererror.Error), http.StatusInternalServerError)
_ = reflector.SetJSONResponse(&opMembershipList, new(usererror.Error), http.StatusUnauthorized)
_ = reflector.SetJSONResponse(&opMembershipList, new(usererror.Error), http.StatusForbidden)
_ = reflector.SetJSONResponse(&opMembershipList, new(usererror.Error), http.StatusNotFound)
_ = reflector.Spec.AddOperation(http.MethodGet, "/spaces/{space_ref}/members", opMembershipList)
}

View File

@ -0,0 +1,99 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package authz
import (
"context"
"github.com/harness/gitness/internal/auth"
"github.com/harness/gitness/internal/paths"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"github.com/rs/zerolog/log"
)
var _ Authorizer = (*MembershipAuthorizer)(nil)
type MembershipAuthorizer struct {
permissionCache PermissionCache
}
func NewMembershipAuthorizer(
permissionCache PermissionCache,
) *MembershipAuthorizer {
return &MembershipAuthorizer{
permissionCache: permissionCache,
}
}
func (a *MembershipAuthorizer) Check(
ctx context.Context,
session *auth.Session,
scope *types.Scope,
resource *types.Resource,
permission enum.Permission,
) (bool, error) {
log.Ctx(ctx).Debug().Msgf(
"[MembershipAuthorizer] %s with id '%d' requests %s for %s '%s' in scope %#v with metadata %#v",
session.Principal.Type,
session.Principal.ID,
permission,
resource.Type,
resource.Name,
scope,
session.Metadata,
)
if session.Principal.Admin {
return true, nil // system admin can call any API
}
var spaceRef string
switch resource.Type {
case enum.ResourceTypeSpace:
spaceRef = paths.Concatinate(scope.SpacePath, resource.Name)
case enum.ResourceTypeRepo:
spaceRef = scope.SpacePath
case enum.ResourceTypeServiceAccount:
spaceRef = scope.SpacePath
case enum.ResourceTypeUser:
// a user is allowed to view / edit themselves
if resource.Name == session.Principal.UID &&
(permission == enum.PermissionUserView || permission == enum.PermissionUserEdit) {
return true, nil
}
// everything else is reserved for admins only (like operations on users other than yourself, or setting admin)
return false, nil
// Service operations aren't exposed to users
case enum.ResourceTypeService:
return false, nil
default:
return false, nil
}
return a.permissionCache.Get(ctx, PermissionCacheKey{
PrincipalID: session.Principal.ID,
SpaceRef: spaceRef,
Permission: permission,
})
}
func (a *MembershipAuthorizer) CheckAll(ctx context.Context, session *auth.Session,
permissionChecks ...types.PermissionCheck) (bool, error) {
for _, p := range permissionChecks {
if _, err := a.Check(ctx, session, &p.Scope, &p.Resource, p.Permission); err != nil {
return false, err
}
}
return true, nil
}

View File

@ -0,0 +1,91 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package authz
import (
"context"
"errors"
"fmt"
"time"
"github.com/harness/gitness/cache"
"github.com/harness/gitness/internal/paths"
"github.com/harness/gitness/internal/store"
gitness_store "github.com/harness/gitness/store"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"golang.org/x/exp/slices"
)
type PermissionCacheKey struct {
PrincipalID int64
SpaceRef string
Permission enum.Permission
}
type PermissionCache cache.Cache[PermissionCacheKey, bool]
func NewPermissionCache(
spaceStore store.SpaceStore,
membershipStore store.MembershipStore,
cacheDuration time.Duration,
) PermissionCache {
return cache.New[PermissionCacheKey, bool](permissionCacheGetter{
spaceStore: spaceStore,
membershipStore: membershipStore,
}, cacheDuration)
}
type permissionCacheGetter struct {
spaceStore store.SpaceStore
membershipStore store.MembershipStore
}
func (g permissionCacheGetter) Find(ctx context.Context, key PermissionCacheKey) (bool, error) {
spaceRef := key.SpaceRef
principalID := key.PrincipalID
// Find the starting space.
space, err := g.spaceStore.FindByRef(ctx, spaceRef)
if err != nil {
return false, fmt.Errorf("failed to find space '%s': %w", spaceRef, err)
}
// limit the depth to be safe (e.g. root/space1/space2 => maxDepth of 3)
maxDepth := len(paths.Segments(spaceRef))
for depth := 0; depth < maxDepth; depth++ {
// Find the membership in the current space.
membership, err := g.membershipStore.Find(ctx, types.MembershipKey{
SpaceID: space.ID,
PrincipalID: principalID,
})
if err != nil && !errors.Is(err, gitness_store.ErrResourceNotFound) {
return false, fmt.Errorf("failed to find membership: %w", err)
}
// If the membership is defined in the current space, check if the user has the required permission.
if membership != nil {
_, hasRole := slices.BinarySearch(membership.Role.Permissions(), key.Permission)
if hasRole {
return true, nil
}
}
// If membership with the requested permission has not been found in the current space,
// move to the parent space, if any.
if space.ParentID == 0 {
return false, nil
}
space, err = g.spaceStore.Find(ctx, space.ParentID)
if err != nil {
return false, fmt.Errorf("failed to find parent space with id %d: %w", space.ParentID, err)
}
}
return false, nil
}

View File

@ -5,14 +5,27 @@
package authz
import (
"time"
"github.com/harness/gitness/internal/store"
"github.com/google/wire"
)
// WireSet provides a wire set for this package.
var WireSet = wire.NewSet(
ProvideAuthorizer,
ProvidePermissionCache,
)
func ProvideAuthorizer() Authorizer {
return NewUnsafeAuthorizer()
func ProvideAuthorizer(pCache PermissionCache) Authorizer {
return NewMembershipAuthorizer(pCache)
}
func ProvidePermissionCache(
spaceStore store.SpaceStore,
membershipStore store.MembershipStore,
) PermissionCache {
const permissionCacheTimeout = time.Second * 15
return NewPermissionCache(spaceStore, membershipStore, permissionCacheTimeout)
}

View File

@ -160,6 +160,15 @@ func setupSpaces(r chi.Router, spaceCtrl *space.Controller, repoCtrl *repo.Contr
r.Delete("/", handlerspace.HandleDeletePath(spaceCtrl))
})
})
r.Route("/members", func(r chi.Router) {
r.Get("/", handlerspace.HandleMembershipList(spaceCtrl))
r.Post("/", handlerspace.HandleMembershipAdd(spaceCtrl))
r.Route(fmt.Sprintf("/{%s}", request.PathParamUserUID), func(r chi.Router) {
r.Delete("/", handlerspace.HandleMembershipDelete(spaceCtrl))
r.Patch("/", handlerspace.HandleMembershipUpdate(spaceCtrl))
})
})
})
})
}

View File

@ -223,6 +223,15 @@ type (
Find(ctx context.Context, id int64) (*types.RepositoryGitInfo, error)
}
// MembershipStore defines the membership data storage.
MembershipStore interface {
Find(ctx context.Context, key types.MembershipKey) (*types.Membership, error)
Create(ctx context.Context, v *types.Membership) error
Update(ctx context.Context, membership *types.Membership) error
Delete(ctx context.Context, key types.MembershipKey) error
ListForSpace(ctx context.Context, spaceID int64) ([]*types.Membership, error)
}
// TokenStore defines the token data storage.
TokenStore interface {
// Find finds the token by id

View File

@ -0,0 +1,256 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package database
import (
"context"
"fmt"
"time"
"github.com/harness/gitness/internal/store"
"github.com/harness/gitness/store/database"
"github.com/harness/gitness/store/database/dbtx"
"github.com/harness/gitness/types"
"github.com/harness/gitness/types/enum"
"github.com/rs/zerolog/log"
"github.com/jmoiron/sqlx"
"github.com/pkg/errors"
)
var _ store.MembershipStore = (*MembershipStore)(nil)
// NewMembershipStore returns a new MembershipStore.
func NewMembershipStore(db *sqlx.DB, pCache store.PrincipalInfoCache) *MembershipStore {
return &MembershipStore{
db: db,
pCache: pCache,
}
}
// MembershipStore implements store.MembershipStore backed by a relational database.
type MembershipStore struct {
db *sqlx.DB
pCache store.PrincipalInfoCache
}
type membership struct {
SpaceID int64 `db:"membership_space_id"`
PrincipalID int64 `db:"membership_principal_id"`
CreatedBy int64 `db:"membership_created_by"`
Created int64 `db:"membership_created"`
Updated int64 `db:"membership_updated"`
Role enum.MembershipRole `db:"membership_role"`
}
const (
membershipColumns = `
membership_space_id
,membership_principal_id
,membership_created_by
,membership_created
,membership_updated
,membership_role`
membershipSelectBase = `
SELECT` + membershipColumns + `
FROM memberships`
)
// Find finds the membership by space id and principal id.
func (s *MembershipStore) Find(ctx context.Context, key types.MembershipKey) (*types.Membership, error) {
const sqlQuery = membershipSelectBase + `
WHERE membership_space_id = $1 AND membership_principal_id = $2`
db := dbtx.GetAccessor(ctx, s.db)
dst := &membership{}
if err := db.GetContext(ctx, dst, sqlQuery, key.SpaceID, key.PrincipalID); err != nil {
return nil, database.ProcessSQLErrorf(err, "Failed to find membership")
}
return s.mapToMembership(ctx, dst), nil
}
// Create creates a new membership.
func (s *MembershipStore) Create(ctx context.Context, membership *types.Membership) error {
const sqlQuery = `
INSERT INTO memberships (
membership_space_id
,membership_principal_id
,membership_created_by
,membership_created
,membership_updated
,membership_role
) values (
:membership_space_id
,:membership_principal_id
,:membership_created_by
,:membership_created
,:membership_updated
,:membership_role
)`
db := dbtx.GetAccessor(ctx, s.db)
query, arg, err := db.BindNamed(sqlQuery, mapToInternalMembership(membership))
if err != nil {
return database.ProcessSQLErrorf(err, "Failed to bind membership object")
}
if _, err = db.ExecContext(ctx, query, arg...); err != nil {
return database.ProcessSQLErrorf(err, "Failed to insert membership")
}
return nil
}
// Update updates the role of a member of a space.
func (s *MembershipStore) Update(ctx context.Context, membership *types.Membership) error {
const sqlQuery = `
UPDATE memberships
SET
membership_updated = :membership_updated
,membership_role = :membership_role
WHERE membership_space_id = :membership_space_id AND
membership_principal_id = :membership_principal_id`
db := dbtx.GetAccessor(ctx, s.db)
dbMembership := mapToInternalMembership(membership)
dbMembership.Updated = time.Now().UnixMilli()
query, arg, err := db.BindNamed(sqlQuery, dbMembership)
if err != nil {
return database.ProcessSQLErrorf(err, "Failed to bind membership object")
}
_, err = db.ExecContext(ctx, query, arg...)
if err != nil {
return database.ProcessSQLErrorf(err, "Failed to update membership role")
}
membership.Updated = dbMembership.Updated
return nil
}
// Delete deletes the membership.
func (s *MembershipStore) Delete(ctx context.Context, key types.MembershipKey) error {
const sqlQuery = `
DELETE from memberships
WHERE membership_space_id = $1 AND
membership_principal_id = $2`
db := dbtx.GetAccessor(ctx, s.db)
if _, err := db.ExecContext(ctx, sqlQuery, key.SpaceID, key.PrincipalID); err != nil {
return database.ProcessSQLErrorf(err, "delete membership query failed")
}
return nil
}
// ListForSpace returns a list of memberships for a space.
func (s *MembershipStore) ListForSpace(ctx context.Context, spaceID int64) ([]*types.Membership, error) {
stmt := database.Builder.
Select(membershipColumns).
From("memberships").
Where("membership_space_id = ?", spaceID).
OrderBy("membership_created asc")
sql, args, err := stmt.ToSql()
if err != nil {
return nil, errors.Wrap(err, "Failed to convert membership for space list query to sql")
}
dst := make([]*membership, 0)
db := dbtx.GetAccessor(ctx, s.db)
if err = db.SelectContext(ctx, &dst, sql, args...); err != nil {
return nil, database.ProcessSQLErrorf(err, "Failed executing membership list query")
}
result, err := s.mapToMemberships(ctx, dst)
if err != nil {
return nil, fmt.Errorf("failed to map memberships to external type: %w", err)
}
return result, nil
}
func mapToMembershipNoPrincipalInfo(m *membership) *types.Membership {
return &types.Membership{
SpaceID: m.SpaceID,
PrincipalID: m.PrincipalID,
CreatedBy: m.CreatedBy,
Created: m.Created,
Updated: m.Updated,
Role: m.Role,
}
}
func mapToInternalMembership(m *types.Membership) *membership {
return &membership{
SpaceID: m.SpaceID,
PrincipalID: m.PrincipalID,
CreatedBy: m.CreatedBy,
Created: m.Created,
Updated: m.Updated,
Role: m.Role,
}
}
func (s *MembershipStore) mapToMembership(ctx context.Context, m *membership) *types.Membership {
res := mapToMembershipNoPrincipalInfo(m)
addedBy, err := s.pCache.Get(ctx, res.CreatedBy)
if err != nil {
log.Ctx(ctx).Error().Err(err).Msg("failed to load membership creator")
}
if addedBy != nil {
res.AdddedBy = *addedBy
}
principal, err := s.pCache.Get(ctx, res.PrincipalID)
if err != nil {
log.Ctx(ctx).Error().Err(err).Msg("failed to load membership principal")
}
if principal != nil {
res.Principal = *principal
}
return res
}
func (s *MembershipStore) mapToMemberships(ctx context.Context, ms []*membership) ([]*types.Membership, error) {
// collect all principal IDs
ids := make([]int64, 0, 2*len(ms))
for _, m := range ms {
ids = append(ids, m.CreatedBy, m.PrincipalID)
}
// pull principal infos from cache
infoMap, err := s.pCache.Map(ctx, ids)
if err != nil {
return nil, fmt.Errorf("failed to load membership principal infos: %w", err)
}
// attach the principal infos back to the slice items
res := make([]*types.Membership, len(ms))
for i, m := range ms {
res[i] = mapToMembershipNoPrincipalInfo(m)
if addedBy, ok := infoMap[m.CreatedBy]; ok {
res[i].AdddedBy = *addedBy
}
if principal, ok := infoMap[m.PrincipalID]; ok {
res[i].Principal = *principal
}
}
return res, nil
}

View File

@ -0,0 +1,2 @@
DROP TABLE memberships;

View File

@ -0,0 +1,21 @@
CREATE TABLE memberships (
membership_space_id INTEGER NOT NULL
,membership_principal_id INTEGER NOT NULL
,membership_created_by INTEGER NOT NULL
,membership_created BIGINT NOT NULL
,membership_updated BIGINT NOT NULL
,membership_role TEXT NOT NULL
,CONSTRAINT pk_memberships PRIMARY KEY (membership_space_id, membership_principal_id)
,CONSTRAINT fk_membership_space_id FOREIGN KEY (membership_space_id)
REFERENCES spaces (space_id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE
,CONSTRAINT fk_membership_principal_id FOREIGN KEY (membership_principal_id)
REFERENCES principals (principal_id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE
,CONSTRAINT fk_membership_created_by FOREIGN KEY (membership_created_by)
REFERENCES principals (principal_id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE NO ACTION
);

View File

@ -0,0 +1,2 @@
DROP TABLE memberships;

View File

@ -0,0 +1,21 @@
CREATE TABLE memberships (
membership_space_id INTEGER NOT NULL
,membership_principal_id INTEGER NOT NULL
,membership_created_by INTEGER NOT NULL
,membership_created BIGINT NOT NULL
,membership_updated BIGINT NOT NULL
,membership_role TEXT NOT NULL
,CONSTRAINT pk_memberships PRIMARY KEY (membership_space_id, membership_principal_id)
,CONSTRAINT fk_membership_space_id FOREIGN KEY (membership_space_id)
REFERENCES spaces (space_id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE
,CONSTRAINT fk_membership_principal_id FOREIGN KEY (membership_principal_id)
REFERENCES principals (principal_id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE CASCADE
,CONSTRAINT fk_membership_created_by FOREIGN KEY (membership_created_by)
REFERENCES principals (principal_id) MATCH SIMPLE
ON UPDATE NO ACTION
ON DELETE NO ACTION
);

View File

@ -24,6 +24,7 @@ var WireSet = wire.NewSet(
ProvideSpaceStore,
ProvideRepoStore,
ProvideRepoGitInfoView,
ProvideMembershipStore,
ProvideTokenStore,
ProvidePullReqStore,
ProvidePullReqActivityStore,
@ -36,7 +37,7 @@ var WireSet = wire.NewSet(
ProvideReqCheckStore,
)
// helper function to setup the database by performing automated
// migrator is helper function to set up the database by performing automated
// database migration steps.
func migrator(ctx context.Context, db *sqlx.DB) error {
return migrate.Migrate(ctx, db)
@ -82,6 +83,13 @@ func ProvideRepoGitInfoView(db *sqlx.DB) store.RepoGitInfoView {
return NewRepoGitInfoView(db)
}
func ProvideMembershipStore(
db *sqlx.DB,
principalInfoCache store.PrincipalInfoCache,
) store.MembershipStore {
return NewMembershipStore(db, principalInfoCache)
}
// ProvideTokenStore provides a token store.
func ProvideTokenStore(db *sqlx.DB) store.TokenStore {
return NewTokenStore(db)
@ -89,13 +97,15 @@ func ProvideTokenStore(db *sqlx.DB) store.TokenStore {
// ProvidePullReqStore provides a pull request store.
func ProvidePullReqStore(db *sqlx.DB,
principalInfoCache store.PrincipalInfoCache) store.PullReqStore {
principalInfoCache store.PrincipalInfoCache,
) store.PullReqStore {
return NewPullReqStore(db, principalInfoCache)
}
// ProvidePullReqActivityStore provides a pull request activity store.
func ProvidePullReqActivityStore(db *sqlx.DB,
principalInfoCache store.PrincipalInfoCache) store.PullReqActivityStore {
principalInfoCache store.PrincipalInfoCache,
) store.PullReqActivityStore {
return NewPullReqActivityStore(db, principalInfoCache)
}
@ -111,7 +121,8 @@ func ProvidePullReqReviewStore(db *sqlx.DB) store.PullReqReviewStore {
// ProvidePullReqReviewerStore provides a pull request reviewer store.
func ProvidePullReqReviewerStore(db *sqlx.DB,
principalInfoCache store.PrincipalInfoCache) store.PullReqReviewerStore {
principalInfoCache store.PrincipalInfoCache,
) store.PullReqReviewerStore {
return NewPullReqReviewerStore(db, principalInfoCache)
}

View File

@ -15,7 +15,8 @@ import (
func isSQLUniqueConstraintError(original error) bool {
var sqliteErr sqlite3.Error
if errors.As(original, &sqliteErr) {
return errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintUnique)
return errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintUnique) ||
errors.Is(sqliteErr.ExtendedCode, sqlite3.ErrConstraintPrimaryKey)
}
return false

View File

@ -0,0 +1,80 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package enum
import "golang.org/x/exp/slices"
// MembershipRole represents the different level of space memberships (permission set).
type MembershipRole string
func (MembershipRole) Enum() []interface{} { return toInterfaceSlice(MembershipRoles) }
func (m MembershipRole) Sanitize() (MembershipRole, bool) { return Sanitize(m, GetAllMembershipRoles) }
func GetAllMembershipRoles() ([]MembershipRole, MembershipRole) { return MembershipRoles, "" }
var MembershipRoles = sortEnum([]MembershipRole{
MembershipRoleReader,
MembershipRoleExecutor,
MembershipRoleContributor,
MembershipRoleSpaceOwner,
})
var membershipRoleReaderPermissions = slices.Clip(slices.Insert([]Permission{}, 0,
PermissionRepoView,
PermissionSpaceView,
PermissionServiceAccountView,
))
var membershipRoleExecutorPermissions = slices.Clip(slices.Insert(membershipRoleReaderPermissions, 0,
PermissionCommitCheckReport,
))
var membershipRoleContributorPermissions = slices.Clip(slices.Insert(membershipRoleReaderPermissions, 0,
PermissionRepoPush,
))
var membershipRoleSpaceOwnerPermissions = slices.Clip(slices.Insert(membershipRoleReaderPermissions, 0,
PermissionRepoEdit,
PermissionRepoDelete,
PermissionRepoPush,
PermissionCommitCheckReport,
PermissionSpaceEdit,
PermissionSpaceCreate,
PermissionSpaceDelete,
PermissionServiceAccountCreate,
PermissionServiceAccountEdit,
PermissionServiceAccountDelete,
))
func init() {
slices.Sort(membershipRoleReaderPermissions)
slices.Sort(membershipRoleExecutorPermissions)
slices.Sort(membershipRoleContributorPermissions)
slices.Sort(membershipRoleSpaceOwnerPermissions)
}
// Permissions returns the list of permissions for the role.
func (m MembershipRole) Permissions() []Permission {
switch m {
case MembershipRoleReader:
return membershipRoleReaderPermissions
case MembershipRoleExecutor:
return membershipRoleExecutorPermissions
case MembershipRoleContributor:
return membershipRoleContributorPermissions
case MembershipRoleSpaceOwner:
return membershipRoleSpaceOwnerPermissions
default:
return nil
}
}
const (
MembershipRoleReader MembershipRole = "reader"
MembershipRoleExecutor MembershipRole = "executor"
MembershipRoleContributor MembershipRole = "contributor"
MembershipRoleSpaceOwner MembershipRole = "space_owner"
)

View File

@ -47,7 +47,7 @@ const (
PermissionUserView Permission = "user_view"
PermissionUserEdit Permission = "user_edit"
PermissionUserDelete Permission = "user_delete"
PermissionUserEditAdmin Permission = "user_editadmin"
PermissionUserEditAdmin Permission = "user_editAdmin"
)
const (
@ -68,7 +68,7 @@ const (
PermissionServiceView Permission = "service_view"
PermissionServiceEdit Permission = "service_edit"
PermissionServiceDelete Permission = "service_delete"
PermissionServiceEditAdmin Permission = "service_editadmin"
PermissionServiceEditAdmin Permission = "service_editAdmin"
)
const (

30
types/membership.go Normal file
View File

@ -0,0 +1,30 @@
// Copyright 2022 Harness Inc. All rights reserved.
// Use of this source code is governed by the Polyform Free Trial License
// that can be found in the LICENSE.md file for this repository.
package types
import (
"github.com/harness/gitness/types/enum"
)
// MembershipKey can be used as a key for finding a user's space membership info.
type MembershipKey struct {
SpaceID int64
PrincipalID int64
}
// Membership represents a user's membership of a space.
type Membership struct {
SpaceID int64 `json:"-"`
PrincipalID int64 `json:"-"`
CreatedBy int64 `json:"-"`
Created int64 `json:"created"`
Updated int64 `json:"updated"`
Role enum.MembershipRole `json:"role"`
Principal PrincipalInfo `json:"principal"`
AdddedBy PrincipalInfo `json:"added_by"`
}