mirror of
https://github.com/harness/drone.git
synced 2025-05-17 01:20:13 +08:00
Merge remote-tracking branch 'origin' into abhinav/CODE-852
This commit is contained in:
commit
6e99fc903d
@ -13,7 +13,6 @@ import (
|
||||
|
||||
"github.com/harness/gitness/cli/provide"
|
||||
"github.com/harness/gitness/internal/api/controller/user"
|
||||
"github.com/harness/gitness/types/enum"
|
||||
|
||||
"github.com/drone/funcmap"
|
||||
"github.com/gotidy/ptr"
|
||||
@ -47,7 +46,6 @@ func (c *createPATCommand) run(*kingpin.ParseContext) error {
|
||||
in := user.CreateTokenInput{
|
||||
UID: c.uid,
|
||||
Lifetime: lifeTime,
|
||||
Grants: enum.AccessGrantAll,
|
||||
}
|
||||
|
||||
tokenResp, err := provide.Client().UserCreatePAT(ctx, in)
|
||||
|
@ -85,7 +85,7 @@ func initSystem(ctx context.Context, config *types.Config) (*server.System, erro
|
||||
principalInfoCache := cache.ProvidePrincipalInfoCache(principalInfoView)
|
||||
membershipStore := database.ProvideMembershipStore(db, principalInfoCache)
|
||||
permissionCache := authz.ProvidePermissionCache(spaceStore, membershipStore)
|
||||
authorizer := authz.ProvideAuthorizer(permissionCache)
|
||||
authorizer := authz.ProvideAuthorizer(permissionCache, spaceStore)
|
||||
principalUIDTransformation := store.ProvidePrincipalUIDTransformation()
|
||||
principalStore := database.ProvidePrincipalStore(db, principalUIDTransformation)
|
||||
tokenStore := database.ProvideTokenStore(db)
|
||||
|
@ -17,14 +17,17 @@ import (
|
||||
)
|
||||
|
||||
type CreateTokenInput struct {
|
||||
UID string `json:"uid"`
|
||||
Lifetime *time.Duration `json:"lifetime"`
|
||||
Grants enum.AccessGrant `json:"grants"`
|
||||
UID string `json:"uid"`
|
||||
Lifetime *time.Duration `json:"lifetime"`
|
||||
}
|
||||
|
||||
// CreateToken creates a new service account access token.
|
||||
func (c *Controller) CreateToken(ctx context.Context, session *auth.Session,
|
||||
saUID string, in *CreateTokenInput) (*types.TokenResponse, error) {
|
||||
func (c *Controller) CreateToken(
|
||||
ctx context.Context,
|
||||
session *auth.Session,
|
||||
saUID string,
|
||||
in *CreateTokenInput,
|
||||
) (*types.TokenResponse, error) {
|
||||
sa, err := findServiceAccountFromUID(ctx, c.principalStore, saUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -36,18 +39,20 @@ func (c *Controller) CreateToken(ctx context.Context, session *auth.Session,
|
||||
if err = check.TokenLifetime(in.Lifetime, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// TODO: Added to unblock UI - Depending on product decision enforce grants, or remove Grants completely.
|
||||
if err = check.AccessGrant(in.Grants, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Ensure principal has required permissions on parent (ensures that parent exists)
|
||||
if err = apiauth.CheckServiceAccount(ctx, c.authorizer, session, c.spaceStore, c.repoStore,
|
||||
sa.ParentType, sa.ParentID, sa.UID, enum.PermissionServiceAccountEdit); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
token, jwtToken, err := token.CreateSAT(ctx, c.tokenStore, &session.Principal,
|
||||
sa, in.UID, in.Lifetime, in.Grants)
|
||||
token, jwtToken, err := token.CreateSAT(
|
||||
ctx,
|
||||
c.tokenStore,
|
||||
&session.Principal,
|
||||
sa,
|
||||
in.UID,
|
||||
in.Lifetime,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -17,16 +17,19 @@ import (
|
||||
)
|
||||
|
||||
type CreateTokenInput struct {
|
||||
UID string `json:"uid"`
|
||||
Lifetime *time.Duration `json:"lifetime"`
|
||||
Grants enum.AccessGrant `json:"grants"`
|
||||
UID string `json:"uid"`
|
||||
Lifetime *time.Duration `json:"lifetime"`
|
||||
}
|
||||
|
||||
/*
|
||||
* CreateToken creates a new user access token.
|
||||
*/
|
||||
func (c *Controller) CreateAccessToken(ctx context.Context, session *auth.Session,
|
||||
userUID string, in *CreateTokenInput) (*types.TokenResponse, error) {
|
||||
func (c *Controller) CreateAccessToken(
|
||||
ctx context.Context,
|
||||
session *auth.Session,
|
||||
userUID string,
|
||||
in *CreateTokenInput,
|
||||
) (*types.TokenResponse, error) {
|
||||
user, err := findUserFromUID(ctx, c.principalStore, userUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@ -43,13 +46,15 @@ func (c *Controller) CreateAccessToken(ctx context.Context, session *auth.Sessio
|
||||
if err = check.TokenLifetime(in.Lifetime, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// TODO: Added to unblock UI - Depending on product decision enforce grants, or remove Grants completely.
|
||||
if err = check.AccessGrant(in.Grants, true); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
token, jwtToken, err := token.CreatePAT(ctx, c.tokenStore, &session.Principal,
|
||||
user, in.UID, in.Lifetime, in.Grants)
|
||||
token, jwtToken, err := token.CreatePAT(
|
||||
ctx,
|
||||
c.tokenStore,
|
||||
&session.Principal,
|
||||
user,
|
||||
in.UID,
|
||||
in.Lifetime,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
@ -33,7 +33,7 @@ func (c *Controller) Logout(ctx context.Context, session *auth.Session) error {
|
||||
tokenID = t.TokenID
|
||||
tokenType = t.TokenType
|
||||
default:
|
||||
return errors.New("session metadata is of unknown type")
|
||||
return errors.New("provided jwt doesn't support logout")
|
||||
}
|
||||
|
||||
if tokenType != enum.TokenTypeSession {
|
||||
|
@ -5,6 +5,7 @@
|
||||
package authn
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
@ -12,34 +13,31 @@ import (
|
||||
|
||||
"github.com/harness/gitness/internal/api/request"
|
||||
"github.com/harness/gitness/internal/auth"
|
||||
"github.com/harness/gitness/internal/jwt"
|
||||
"github.com/harness/gitness/internal/store"
|
||||
"github.com/harness/gitness/internal/token"
|
||||
"github.com/harness/gitness/types"
|
||||
|
||||
"github.com/dgrijalva/jwt-go"
|
||||
gojwt "github.com/dgrijalva/jwt-go"
|
||||
)
|
||||
|
||||
var _ Authenticator = (*TokenAuthenticator)(nil)
|
||||
var _ Authenticator = (*JWTAuthenticator)(nil)
|
||||
|
||||
/*
|
||||
* Authenticates a user by checking for an access token in the
|
||||
* "Authorization" header or the "access_token" form value.
|
||||
*/
|
||||
type TokenAuthenticator struct {
|
||||
// JWTAuthenticator uses the provided JWT to authenticate the caller.
|
||||
type JWTAuthenticator struct {
|
||||
principalStore store.PrincipalStore
|
||||
tokenStore store.TokenStore
|
||||
}
|
||||
|
||||
func NewTokenAuthenticator(
|
||||
principalStore store.PrincipalStore,
|
||||
tokenStore store.TokenStore) *TokenAuthenticator {
|
||||
return &TokenAuthenticator{
|
||||
tokenStore store.TokenStore) *JWTAuthenticator {
|
||||
return &JWTAuthenticator{
|
||||
principalStore: principalStore,
|
||||
tokenStore: tokenStore,
|
||||
}
|
||||
}
|
||||
|
||||
func (a *TokenAuthenticator) Authenticate(r *http.Request, sourceRouter SourceRouter) (*auth.Session, error) {
|
||||
func (a *JWTAuthenticator) Authenticate(r *http.Request, sourceRouter SourceRouter) (*auth.Session, error) {
|
||||
ctx := r.Context()
|
||||
str := extractToken(r)
|
||||
|
||||
@ -49,8 +47,8 @@ func (a *TokenAuthenticator) Authenticate(r *http.Request, sourceRouter SourceRo
|
||||
|
||||
var principal *types.Principal
|
||||
var err error
|
||||
claims := &token.JWTClaims{}
|
||||
parsed, err := jwt.ParseWithClaims(str, claims, func(token_ *jwt.Token) (interface{}, error) {
|
||||
claims := &jwt.Claims{}
|
||||
parsed, err := gojwt.ParseWithClaims(str, claims, func(token_ *gojwt.Token) (interface{}, error) {
|
||||
principal, err = a.principalStore.Find(ctx, claims.PrincipalID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get principal for token: %w", err)
|
||||
@ -65,12 +63,39 @@ func (a *TokenAuthenticator) Authenticate(r *http.Request, sourceRouter SourceRo
|
||||
return nil, errors.New("parsed JWT token is invalid")
|
||||
}
|
||||
|
||||
if _, ok := parsed.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
if _, ok := parsed.Method.(*gojwt.SigningMethodHMAC); !ok {
|
||||
return nil, errors.New("invalid HMAC signature for JWT")
|
||||
}
|
||||
|
||||
var metadata auth.Metadata
|
||||
switch {
|
||||
case claims.Token != nil:
|
||||
metadata, err = a.metadataFromTokenClaims(ctx, principal, claims.Token)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get metadata from token claims: %w", err)
|
||||
}
|
||||
case claims.Membership != nil:
|
||||
metadata, err = a.metadataFromMembershipClaims(claims.Membership)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get metadata from membership claims: %w", err)
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("jwt is missing sub-claims")
|
||||
}
|
||||
|
||||
return &auth.Session{
|
||||
Principal: *principal,
|
||||
Metadata: metadata,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *JWTAuthenticator) metadataFromTokenClaims(
|
||||
ctx context.Context,
|
||||
principal *types.Principal,
|
||||
tknClaims *jwt.SubClaimsToken,
|
||||
) (auth.Metadata, error) {
|
||||
// ensure tkn exists
|
||||
tkn, err := a.tokenStore.Find(ctx, claims.TokenID)
|
||||
tkn, err := a.tokenStore.Find(ctx, tknClaims.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to find token in db: %w", err)
|
||||
}
|
||||
@ -81,13 +106,19 @@ func (a *TokenAuthenticator) Authenticate(r *http.Request, sourceRouter SourceRo
|
||||
principal.ID, tkn.PrincipalID)
|
||||
}
|
||||
|
||||
return &auth.Session{
|
||||
Principal: *principal,
|
||||
Metadata: &auth.TokenMetadata{
|
||||
TokenType: tkn.Type,
|
||||
TokenID: tkn.ID,
|
||||
Grants: tkn.Grants,
|
||||
},
|
||||
return &auth.TokenMetadata{
|
||||
TokenType: tkn.Type,
|
||||
TokenID: tkn.ID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (a *JWTAuthenticator) metadataFromMembershipClaims(
|
||||
mbsClaims *jwt.SubClaimsMembership,
|
||||
) (auth.Metadata, error) {
|
||||
// We could check if space exists - but also okay to fail later (saves db call)
|
||||
return &auth.MembershipMetadata{
|
||||
SpaceID: mbsClaims.SpaceID,
|
||||
Role: mbsClaims.Role,
|
||||
}, nil
|
||||
}
|
||||
|
@ -6,9 +6,11 @@ package authz
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/harness/gitness/internal/auth"
|
||||
"github.com/harness/gitness/internal/paths"
|
||||
"github.com/harness/gitness/internal/store"
|
||||
"github.com/harness/gitness/types"
|
||||
"github.com/harness/gitness/types/enum"
|
||||
|
||||
@ -19,13 +21,16 @@ var _ Authorizer = (*MembershipAuthorizer)(nil)
|
||||
|
||||
type MembershipAuthorizer struct {
|
||||
permissionCache PermissionCache
|
||||
spaceStore store.SpaceStore
|
||||
}
|
||||
|
||||
func NewMembershipAuthorizer(
|
||||
permissionCache PermissionCache,
|
||||
spaceStore store.SpaceStore,
|
||||
) *MembershipAuthorizer {
|
||||
return &MembershipAuthorizer{
|
||||
permissionCache: permissionCache,
|
||||
spaceStore: spaceStore,
|
||||
}
|
||||
}
|
||||
|
||||
@ -51,23 +56,23 @@ func (a *MembershipAuthorizer) Check(
|
||||
return true, nil // system admin can call any API
|
||||
}
|
||||
|
||||
var spaceRef string
|
||||
var spacePath string
|
||||
|
||||
switch resource.Type {
|
||||
case enum.ResourceTypeSpace:
|
||||
spaceRef = paths.Concatinate(scope.SpacePath, resource.Name)
|
||||
spacePath = paths.Concatinate(scope.SpacePath, resource.Name)
|
||||
|
||||
case enum.ResourceTypeRepo:
|
||||
spaceRef = scope.SpacePath
|
||||
spacePath = scope.SpacePath
|
||||
|
||||
case enum.ResourceTypeServiceAccount:
|
||||
spaceRef = scope.SpacePath
|
||||
spacePath = scope.SpacePath
|
||||
|
||||
case enum.ResourceTypePipeline:
|
||||
spaceRef = scope.SpacePath
|
||||
spacePath = scope.SpacePath
|
||||
|
||||
case enum.ResourceTypeSecret:
|
||||
spaceRef = scope.SpacePath
|
||||
spacePath = scope.SpacePath
|
||||
|
||||
case enum.ResourceTypeUser:
|
||||
// a user is allowed to view / edit themselves
|
||||
@ -87,12 +92,23 @@ func (a *MembershipAuthorizer) Check(
|
||||
return false, nil
|
||||
}
|
||||
|
||||
// ephemeral membership overrides any other space memberships of the principal
|
||||
if membershipMetadata, ok := session.Metadata.(*auth.MembershipMetadata); ok {
|
||||
return a.checkWithMembershipMetadata(ctx, membershipMetadata, spacePath, permission)
|
||||
}
|
||||
|
||||
// ensure we aren't bypassing unknown metadata with impact on authorization
|
||||
if session.Metadata.ImpactsAuthorization() {
|
||||
return false, fmt.Errorf("session contains unknown metadata that impacts authorization: %T", session.Metadata)
|
||||
}
|
||||
|
||||
return a.permissionCache.Get(ctx, PermissionCacheKey{
|
||||
PrincipalID: session.Principal.ID,
|
||||
SpaceRef: spaceRef,
|
||||
SpaceRef: spacePath,
|
||||
Permission: permission,
|
||||
})
|
||||
}
|
||||
|
||||
func (a *MembershipAuthorizer) CheckAll(ctx context.Context, session *auth.Session,
|
||||
permissionChecks ...types.PermissionCheck) (bool, error) {
|
||||
for _, p := range permissionChecks {
|
||||
@ -103,3 +119,35 @@ func (a *MembershipAuthorizer) CheckAll(ctx context.Context, session *auth.Sessi
|
||||
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// checkWithMembershipMetadata checks access using the ephemeral membership provided in the metadata.
|
||||
func (a *MembershipAuthorizer) checkWithMembershipMetadata(
|
||||
ctx context.Context,
|
||||
membershipMetadata *auth.MembershipMetadata,
|
||||
requestedSpacePath string,
|
||||
requestedPermission enum.Permission,
|
||||
) (bool, error) {
|
||||
space, err := a.spaceStore.Find(ctx, membershipMetadata.SpaceID)
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to find space: %w", err)
|
||||
}
|
||||
|
||||
if !paths.IsAncesterOf(space.Path, requestedSpacePath) {
|
||||
return false, fmt.Errorf(
|
||||
"requested permission scope '%s' is outside of ephemeral membership scope '%s'",
|
||||
requestedSpacePath,
|
||||
space.Path,
|
||||
)
|
||||
}
|
||||
|
||||
if !roleHasPermission(membershipMetadata.Role, requestedPermission) {
|
||||
return false, fmt.Errorf(
|
||||
"requested permission '%s' is outside of ephemeral membership role '%s'",
|
||||
requestedPermission,
|
||||
membershipMetadata.Role,
|
||||
)
|
||||
}
|
||||
|
||||
// access is granted by ephemeral membership
|
||||
return true, nil
|
||||
}
|
||||
|
@ -67,11 +67,9 @@ func (g permissionCacheGetter) Find(ctx context.Context, key PermissionCacheKey)
|
||||
}
|
||||
|
||||
// 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 != nil &&
|
||||
roleHasPermission(membership.Role, key.Permission) {
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If membership with the requested permission has not been found in the current space,
|
||||
@ -89,3 +87,8 @@ func (g permissionCacheGetter) Find(ctx context.Context, key PermissionCacheKey)
|
||||
|
||||
return false, nil
|
||||
}
|
||||
|
||||
func roleHasPermission(role enum.MembershipRole, permission enum.Permission) bool {
|
||||
_, hasRole := slices.BinarySearch(role.Permissions(), permission)
|
||||
return hasRole
|
||||
}
|
||||
|
@ -18,8 +18,8 @@ var WireSet = wire.NewSet(
|
||||
ProvidePermissionCache,
|
||||
)
|
||||
|
||||
func ProvideAuthorizer(pCache PermissionCache) Authorizer {
|
||||
return NewMembershipAuthorizer(pCache)
|
||||
func ProvideAuthorizer(pCache PermissionCache, spaceStore store.SpaceStore) Authorizer {
|
||||
return NewMembershipAuthorizer(pCache, spaceStore)
|
||||
}
|
||||
|
||||
func ProvidePermissionCache(
|
||||
|
@ -17,23 +17,22 @@ func (m *EmptyMetadata) ImpactsAuthorization() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// SSHMetadata contains information about the ssh connection that was used during auth.
|
||||
type SSHMetadata struct {
|
||||
KeyID string
|
||||
Grants enum.AccessGrant // retrieved from ssh key table during verification
|
||||
}
|
||||
|
||||
func (m *SSHMetadata) ImpactsAuthorization() bool {
|
||||
return m.Grants != enum.AccessGrantAll
|
||||
}
|
||||
|
||||
// TokenMetadata contains information about the token that was used during auth.
|
||||
type TokenMetadata struct {
|
||||
TokenType enum.TokenType
|
||||
TokenID int64
|
||||
Grants enum.AccessGrant // retrieved from token during verification
|
||||
}
|
||||
|
||||
func (m *TokenMetadata) ImpactsAuthorization() bool {
|
||||
return m.Grants != enum.AccessGrantAll
|
||||
return false
|
||||
}
|
||||
|
||||
// MembershipMetadata contains information about an ephemeral membership grant.
|
||||
type MembershipMetadata struct {
|
||||
SpaceID int64
|
||||
Role enum.MembershipRole
|
||||
}
|
||||
|
||||
func (m *MembershipMetadata) ImpactsAuthorization() bool {
|
||||
return true
|
||||
}
|
||||
|
@ -29,6 +29,17 @@ func NewSystemServiceSession() *auth.Session {
|
||||
}
|
||||
}
|
||||
|
||||
// pipelineServicePrincipal is the principal that is used during
|
||||
// pipeline executions for calling gitness APIs.
|
||||
var pipelineServicePrincipal *types.Principal
|
||||
|
||||
func NewPipelineServiceSession() *auth.Session {
|
||||
return &auth.Session{
|
||||
Principal: *pipelineServicePrincipal,
|
||||
Metadata: &auth.EmptyMetadata{},
|
||||
}
|
||||
}
|
||||
|
||||
// Bootstrap is an abstraction of a function that bootstraps a system.
|
||||
type Bootstrap func(context.Context) error
|
||||
|
||||
@ -36,11 +47,15 @@ func System(config *types.Config, userCtrl *user.Controller,
|
||||
serviceCtrl *service.Controller) func(context.Context) error {
|
||||
return func(ctx context.Context) error {
|
||||
if err := SystemService(ctx, config, serviceCtrl); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to setup system service: %w", err)
|
||||
}
|
||||
|
||||
if err := PipelineService(ctx, config, serviceCtrl); err != nil {
|
||||
return fmt.Errorf("failed to setup pipeline service: %w", err)
|
||||
}
|
||||
|
||||
if err := AdminUser(ctx, config, userCtrl); err != nil {
|
||||
return err
|
||||
return fmt.Errorf("failed to setup admin user: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
@ -70,7 +85,11 @@ func AdminUser(ctx context.Context, config *types.Config, userCtrl *user.Control
|
||||
return nil
|
||||
}
|
||||
|
||||
func createAdminUser(ctx context.Context, config *types.Config, userCtrl *user.Controller) (*types.User, error) {
|
||||
func createAdminUser(
|
||||
ctx context.Context,
|
||||
config *types.Config,
|
||||
userCtrl *user.Controller,
|
||||
) (*types.User, error) {
|
||||
in := &user.CreateInput{
|
||||
UID: config.Principal.Admin.UID,
|
||||
DisplayName: config.Principal.Admin.DisplayName,
|
||||
@ -96,10 +115,21 @@ func createAdminUser(ctx context.Context, config *types.Config, userCtrl *user.C
|
||||
|
||||
// SystemService sets up the gitness service principal that is used for
|
||||
// resources that are automatically created by the system.
|
||||
func SystemService(ctx context.Context, config *types.Config, serviceCtrl *service.Controller) error {
|
||||
func SystemService(
|
||||
ctx context.Context,
|
||||
config *types.Config,
|
||||
serviceCtrl *service.Controller,
|
||||
) error {
|
||||
svc, err := serviceCtrl.FindNoAuth(ctx, config.Principal.System.UID)
|
||||
if errors.Is(err, store.ErrResourceNotFound) {
|
||||
svc, err = createSystemService(ctx, config, serviceCtrl)
|
||||
svc, err = createServicePrincipal(
|
||||
ctx,
|
||||
serviceCtrl,
|
||||
config.Principal.System.UID,
|
||||
config.Principal.System.Email,
|
||||
config.Principal.System.DisplayName,
|
||||
true,
|
||||
)
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
@ -116,25 +146,65 @@ func SystemService(ctx context.Context, config *types.Config, serviceCtrl *servi
|
||||
return nil
|
||||
}
|
||||
|
||||
func createSystemService(ctx context.Context, config *types.Config,
|
||||
serviceCtrl *service.Controller) (*types.Service, error) {
|
||||
in := &service.CreateInput{
|
||||
UID: config.Principal.System.UID,
|
||||
Email: config.Principal.System.Email,
|
||||
DisplayName: config.Principal.System.DisplayName,
|
||||
// PipelineService sets up the pipeline service principal that is used during
|
||||
// pipeline executions for calling gitness APIs.
|
||||
func PipelineService(
|
||||
ctx context.Context,
|
||||
config *types.Config,
|
||||
serviceCtrl *service.Controller,
|
||||
) error {
|
||||
svc, err := serviceCtrl.FindNoAuth(ctx, config.Principal.Pipeline.UID)
|
||||
if errors.Is(err, store.ErrResourceNotFound) {
|
||||
svc, err = createServicePrincipal(
|
||||
ctx,
|
||||
serviceCtrl,
|
||||
config.Principal.Pipeline.UID,
|
||||
config.Principal.Pipeline.Email,
|
||||
config.Principal.Pipeline.DisplayName,
|
||||
false,
|
||||
)
|
||||
}
|
||||
|
||||
svc, createErr := serviceCtrl.CreateNoAuth(ctx, in, true)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to setup pipeline service: %w", err)
|
||||
}
|
||||
|
||||
pipelineServicePrincipal = svc.ToPrincipal()
|
||||
|
||||
log.Ctx(ctx).Info().Msgf("Completed setup of pipeline service '%s' (id: %d).", svc.UID, svc.ID)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func createServicePrincipal(
|
||||
ctx context.Context,
|
||||
serviceCtrl *service.Controller,
|
||||
uid string,
|
||||
email string,
|
||||
displayName string,
|
||||
admin bool,
|
||||
) (*types.Service, error) {
|
||||
in := &service.CreateInput{
|
||||
UID: uid,
|
||||
Email: email,
|
||||
DisplayName: displayName,
|
||||
}
|
||||
|
||||
svc, createErr := serviceCtrl.CreateNoAuth(ctx, in, admin)
|
||||
if createErr == nil || !errors.Is(createErr, store.ErrDuplicate) {
|
||||
return svc, createErr
|
||||
}
|
||||
|
||||
// service might've been created by another instance in which case we should find it now.
|
||||
var findErr error
|
||||
svc, findErr = serviceCtrl.FindNoAuth(ctx, config.Principal.System.UID)
|
||||
svc, findErr = serviceCtrl.FindNoAuth(ctx, uid)
|
||||
if findErr != nil {
|
||||
return nil, fmt.Errorf("failed to find service with uid '%s' (%s) after duplicate error: %w",
|
||||
config.Principal.System.UID, findErr, createErr)
|
||||
return nil, fmt.Errorf(
|
||||
"failed to find service with uid '%s' (%s) after duplicate error: %w",
|
||||
uid,
|
||||
findErr,
|
||||
createErr,
|
||||
)
|
||||
}
|
||||
|
||||
return svc, nil
|
||||
|
97
internal/jwt/jwt.go
Normal file
97
internal/jwt/jwt.go
Normal file
@ -0,0 +1,97 @@
|
||||
// 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 jwt
|
||||
|
||||
import (
|
||||
"time"
|
||||
|
||||
"github.com/harness/gitness/types"
|
||||
"github.com/harness/gitness/types/enum"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
issuer = "Gitness"
|
||||
)
|
||||
|
||||
// Claims defines gitness jwt claims.
|
||||
type Claims struct {
|
||||
jwt.StandardClaims
|
||||
|
||||
PrincipalID int64 `json:"pid,omitempty"`
|
||||
|
||||
Token *SubClaimsToken `json:"tkn,omitempty"`
|
||||
Membership *SubClaimsMembership `json:"ms,omitempty"`
|
||||
}
|
||||
|
||||
// SubClaimsToken contains information about the token the JWT was created for.
|
||||
type SubClaimsToken struct {
|
||||
Type enum.TokenType `json:"typ,omitempty"`
|
||||
ID int64 `json:"id,omitempty"`
|
||||
}
|
||||
|
||||
// SubClaimsMembership contains the ephemeral membership the JWT was created with.
|
||||
type SubClaimsMembership struct {
|
||||
Role enum.MembershipRole `json:"role,omitempty"`
|
||||
SpaceID int64 `json:"sid,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateForToken generates a jwt for a given token.
|
||||
func GenerateForToken(token *types.Token, secret string) (string, error) {
|
||||
var expiresAt int64
|
||||
if token.ExpiresAt != nil {
|
||||
expiresAt = *token.ExpiresAt
|
||||
}
|
||||
|
||||
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Issuer: issuer,
|
||||
// times required to be in sec not millisec
|
||||
IssuedAt: token.IssuedAt / 1000,
|
||||
ExpiresAt: expiresAt / 1000,
|
||||
},
|
||||
PrincipalID: token.PrincipalID,
|
||||
Token: &SubClaimsToken{
|
||||
Type: token.Type,
|
||||
ID: token.ID,
|
||||
},
|
||||
})
|
||||
|
||||
res, err := jwtToken.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "Failed to sign token")
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
||||
|
||||
// GenerateWithMembership generates a jwt with the given ephemeral membership.
|
||||
func GenerateWithMembership(principalID int64, spaceID int64, role enum.MembershipRole, lifetime time.Duration, secret string) (string, error) {
|
||||
issuedAt := time.Now()
|
||||
expiresAt := issuedAt.Add(lifetime)
|
||||
|
||||
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, Claims{
|
||||
StandardClaims: jwt.StandardClaims{
|
||||
Issuer: issuer,
|
||||
// times required to be in sec
|
||||
IssuedAt: issuedAt.Unix(),
|
||||
ExpiresAt: expiresAt.Unix(),
|
||||
},
|
||||
PrincipalID: principalID,
|
||||
Membership: &SubClaimsMembership{
|
||||
SpaceID: spaceID,
|
||||
Role: role,
|
||||
},
|
||||
})
|
||||
|
||||
res, err := jwtToken.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "Failed to sign token")
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
@ -18,6 +18,8 @@ var (
|
||||
// DisectLeaf splits a path into its parent path and the leaf name
|
||||
// e.g. space1/space2/space3 -> (space1/space2, space3, nil).
|
||||
func DisectLeaf(path string) (string, string, error) {
|
||||
path = strings.Trim(path, types.PathSeparator)
|
||||
|
||||
if path == "" {
|
||||
return "", "", ErrPathEmpty
|
||||
}
|
||||
@ -33,6 +35,8 @@ func DisectLeaf(path string) (string, string, error) {
|
||||
// DisectRoot splits a path into its root space and sub-path
|
||||
// e.g. space1/space2/space3 -> (space1, space2/space3, nil).
|
||||
func DisectRoot(path string) (string, string, error) {
|
||||
path = strings.Trim(path, types.PathSeparator)
|
||||
|
||||
if path == "" {
|
||||
return "", "", ErrPathEmpty
|
||||
}
|
||||
@ -67,5 +71,19 @@ func Concatinate(path1 string, path2 string) string {
|
||||
// Segments returns all segments of the path
|
||||
// e.g. /space1/space2/space3 -> [space1, space2, space3].
|
||||
func Segments(path string) []string {
|
||||
path = strings.Trim(path, types.PathSeparator)
|
||||
return strings.Split(path, types.PathSeparator)
|
||||
}
|
||||
|
||||
// IsAncesterOf returns true iff 'path' is an ancestor of 'other' or they are the same.
|
||||
// e.g. other = path(/.*)
|
||||
func IsAncesterOf(path string, other string) bool {
|
||||
path = strings.Trim(path, types.PathSeparator)
|
||||
other = strings.Trim(other, types.PathSeparator)
|
||||
|
||||
// add "/" to both to handle space1/inner and space1/in
|
||||
return strings.Contains(
|
||||
other+types.PathSeparator,
|
||||
path+types.PathSeparator,
|
||||
)
|
||||
}
|
||||
|
@ -83,12 +83,14 @@ func (e *embedded) Detail(ctx context.Context, stage *drone.Stage) (*client.Cont
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &client.Context{
|
||||
Build: convertToDroneBuild(details.Execution),
|
||||
Repo: convertToDroneRepo(details.Repo),
|
||||
Stage: convertToDroneStage(details.Stage),
|
||||
Secrets: convertToDroneSecrets(details.Secrets),
|
||||
Config: convertToDroneFile(details.Config),
|
||||
Netrc: convertToDroneNetrc(details.Netrc),
|
||||
System: &drone.System{
|
||||
Proto: e.config.Server.HTTP.Proto,
|
||||
Host: "host.docker.internal",
|
||||
|
@ -236,3 +236,15 @@ func convertToDroneSecrets(secrets []*types.Secret) []*drone.Secret {
|
||||
}
|
||||
return ret
|
||||
}
|
||||
|
||||
func convertToDroneNetrc(netrc *Netrc) *drone.Netrc {
|
||||
if netrc == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &drone.Netrc{
|
||||
Machine: netrc.Machine,
|
||||
Login: netrc.Login,
|
||||
Password: netrc.Password,
|
||||
}
|
||||
}
|
||||
|
@ -9,7 +9,11 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/url"
|
||||
"time"
|
||||
|
||||
"github.com/harness/gitness/internal/bootstrap"
|
||||
"github.com/harness/gitness/internal/jwt"
|
||||
"github.com/harness/gitness/internal/pipeline/file"
|
||||
"github.com/harness/gitness/internal/pipeline/scheduler"
|
||||
"github.com/harness/gitness/internal/sse"
|
||||
@ -23,6 +27,13 @@ import (
|
||||
"github.com/rs/zerolog/log"
|
||||
)
|
||||
|
||||
const (
|
||||
// pipelineJWTLifetime specifies the max lifetime of an ephemeral pipeline jwt token.
|
||||
pipelineJWTLifetime = 72 * time.Hour
|
||||
// pipelineJWTRole specifies the role of an ephemeral pipeline jwt token.
|
||||
pipelineJWTRole = enum.MembershipRoleContributor
|
||||
)
|
||||
|
||||
var noContext = context.Background()
|
||||
|
||||
var _ ExecutionManager = (*Manager)(nil)
|
||||
@ -47,6 +58,14 @@ type (
|
||||
Kind string `json:"kind"`
|
||||
}
|
||||
|
||||
// Netrc contains login and initialization information used
|
||||
// by an automated login process.
|
||||
Netrc struct {
|
||||
Machine string `json:"machine"`
|
||||
Login string `json:"login"`
|
||||
Password string `json:"password"`
|
||||
}
|
||||
|
||||
// ExecutionContext represents the minimum amount of information
|
||||
// required by the runner to execute a build.
|
||||
ExecutionContext struct {
|
||||
@ -55,6 +74,7 @@ type (
|
||||
Stage *types.Stage `json:"stage"`
|
||||
Secrets []*types.Secret `json:"secrets"`
|
||||
Config *file.File `json:"config"`
|
||||
Netrc *Netrc `json:"netrc"`
|
||||
}
|
||||
|
||||
// ExecutionManager encapsulates complex build operations and provides
|
||||
@ -294,12 +314,44 @@ func (m *Manager) Details(ctx context.Context, stageID int64) (*ExecutionContext
|
||||
return nil, err
|
||||
}
|
||||
|
||||
netrc, err := m.createNetrc(repo)
|
||||
if err != nil {
|
||||
log.Warn().Err(err).Msg("manager: failed to create netrc")
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &ExecutionContext{
|
||||
Repo: repo,
|
||||
Execution: execution,
|
||||
Stage: stage,
|
||||
Secrets: secrets,
|
||||
Config: file,
|
||||
Netrc: netrc,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (m *Manager) createNetrc(repo *types.Repository) (*Netrc, error) {
|
||||
pipelinePrincipal := bootstrap.NewPipelineServiceSession().Principal
|
||||
jwt, err := jwt.GenerateWithMembership(
|
||||
pipelinePrincipal.ID,
|
||||
repo.ParentID,
|
||||
pipelineJWTRole,
|
||||
pipelineJWTLifetime,
|
||||
pipelinePrincipal.Salt,
|
||||
)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create jwt: %w", err)
|
||||
}
|
||||
|
||||
cloneUrl, err := url.Parse(repo.GitURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse clone url '%s': %w", cloneUrl, err)
|
||||
}
|
||||
|
||||
return &Netrc{
|
||||
Machine: cloneUrl.Hostname(),
|
||||
Login: pipelinePrincipal.UID,
|
||||
Password: jwt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
@ -8,4 +8,9 @@ CREATE TABLE tokens (
|
||||
,token_issued_at BIGINT
|
||||
,token_created_by INTEGER
|
||||
,UNIQUE(token_principal_id, token_uid)
|
||||
|
||||
,CONSTRAINT fk_token_principal_id FOREIGN KEY (token_principal_id)
|
||||
REFERENCES principals (principal_id) MATCH SIMPLE
|
||||
ON UPDATE NO ACTION
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
@ -0,0 +1 @@
|
||||
ALTER TABLE tokens ADD COLUMN token_grants BIGINT DEFAULT 0;
|
@ -0,0 +1 @@
|
||||
ALTER TABLE tokens DROP COLUMN token_grants;
|
@ -8,4 +8,9 @@ CREATE TABLE tokens (
|
||||
,token_issued_at BIGINT
|
||||
,token_created_by INTEGER
|
||||
,UNIQUE(token_principal_id, token_uid COLLATE NOCASE)
|
||||
|
||||
,CONSTRAINT fk_token_principal_id FOREIGN KEY (token_principal_id)
|
||||
REFERENCES principals (principal_id) MATCH SIMPLE
|
||||
ON UPDATE NO ACTION
|
||||
ON DELETE CASCADE
|
||||
);
|
||||
|
@ -0,0 +1 @@
|
||||
ALTER TABLE tokens ADD COLUMN token_grants BIGINT DEFAULT 0;
|
@ -0,0 +1 @@
|
||||
ALTER TABLE tokens DROP COLUMN token_grants;
|
@ -127,7 +127,6 @@ token_id
|
||||
,token_uid
|
||||
,token_principal_id
|
||||
,token_expires_at
|
||||
,token_grants
|
||||
,token_issued_at
|
||||
,token_created_by
|
||||
FROM tokens
|
||||
@ -168,7 +167,6 @@ INSERT INTO tokens (
|
||||
,token_uid
|
||||
,token_principal_id
|
||||
,token_expires_at
|
||||
,token_grants
|
||||
,token_issued_at
|
||||
,token_created_by
|
||||
) values (
|
||||
@ -176,7 +174,6 @@ INSERT INTO tokens (
|
||||
,:token_uid
|
||||
,:token_principal_id
|
||||
,:token_expires_at
|
||||
,:token_grants
|
||||
,:token_issued_at
|
||||
,:token_created_by
|
||||
) RETURNING token_id
|
||||
|
@ -187,6 +187,7 @@ func (s *triggerStore) Update(ctx context.Context, t *types.Trigger) error {
|
||||
SET
|
||||
trigger_uid = :trigger_uid
|
||||
,trigger_description = :trigger_description
|
||||
,trigger_disabled = :trigger_disabled
|
||||
,trigger_updated = :trigger_updated
|
||||
,trigger_actions = :trigger_actions
|
||||
,trigger_version = :trigger_version
|
||||
|
@ -1,53 +0,0 @@
|
||||
// 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 token
|
||||
|
||||
import (
|
||||
"github.com/harness/gitness/types"
|
||||
"github.com/harness/gitness/types/enum"
|
||||
|
||||
"github.com/golang-jwt/jwt"
|
||||
"github.com/pkg/errors"
|
||||
)
|
||||
|
||||
const (
|
||||
issuer = "Gitness"
|
||||
)
|
||||
|
||||
// JWTClaims defines custom token claims.
|
||||
type JWTClaims struct {
|
||||
jwt.StandardClaims
|
||||
|
||||
TokenType enum.TokenType `json:"ttp,omitempty"`
|
||||
TokenID int64 `json:"tid,omitempty"`
|
||||
PrincipalID int64 `json:"pid,omitempty"`
|
||||
}
|
||||
|
||||
// GenerateJWTForToken generates a jwt for a given token.
|
||||
func GenerateJWTForToken(token *types.Token, secret string) (string, error) {
|
||||
var expiresAt int64
|
||||
if token.ExpiresAt != nil {
|
||||
expiresAt = *token.ExpiresAt
|
||||
}
|
||||
|
||||
jwtToken := jwt.NewWithClaims(jwt.SigningMethodHS256, JWTClaims{
|
||||
jwt.StandardClaims{
|
||||
Issuer: issuer,
|
||||
// times required to be in sec not millisec
|
||||
IssuedAt: token.IssuedAt / 1000,
|
||||
ExpiresAt: expiresAt / 1000,
|
||||
},
|
||||
token.Type,
|
||||
token.ID,
|
||||
token.PrincipalID,
|
||||
})
|
||||
|
||||
res, err := jwtToken.SignedString([]byte(secret))
|
||||
if err != nil {
|
||||
return "", errors.Wrap(err, "Failed to sign token")
|
||||
}
|
||||
|
||||
return res, nil
|
||||
}
|
@ -9,6 +9,7 @@ import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/harness/gitness/internal/jwt"
|
||||
"github.com/harness/gitness/internal/store"
|
||||
"github.com/harness/gitness/types"
|
||||
"github.com/harness/gitness/types/enum"
|
||||
@ -20,10 +21,14 @@ const (
|
||||
userTokenLifeTime time.Duration = 24 * time.Hour // 1 day.
|
||||
)
|
||||
|
||||
func CreateUserSession(ctx context.Context, tokenStore store.TokenStore,
|
||||
user *types.User, uid string) (*types.Token, string, error) {
|
||||
func CreateUserSession(
|
||||
ctx context.Context,
|
||||
tokenStore store.TokenStore,
|
||||
user *types.User,
|
||||
uid string,
|
||||
) (*types.Token, string, error) {
|
||||
principal := user.ToPrincipal()
|
||||
return Create(
|
||||
return create(
|
||||
ctx,
|
||||
tokenStore,
|
||||
enum.TokenTypeSession,
|
||||
@ -31,14 +36,18 @@ func CreateUserSession(ctx context.Context, tokenStore store.TokenStore,
|
||||
principal,
|
||||
uid,
|
||||
ptr.Duration(userTokenLifeTime),
|
||||
enum.AccessGrantAll,
|
||||
)
|
||||
}
|
||||
|
||||
func CreatePAT(ctx context.Context, tokenStore store.TokenStore,
|
||||
createdBy *types.Principal, createdFor *types.User,
|
||||
uid string, lifetime *time.Duration, grants enum.AccessGrant) (*types.Token, string, error) {
|
||||
return Create(
|
||||
func CreatePAT(
|
||||
ctx context.Context,
|
||||
tokenStore store.TokenStore,
|
||||
createdBy *types.Principal,
|
||||
createdFor *types.User,
|
||||
uid string,
|
||||
lifetime *time.Duration,
|
||||
) (*types.Token, string, error) {
|
||||
return create(
|
||||
ctx,
|
||||
tokenStore,
|
||||
enum.TokenTypePAT,
|
||||
@ -46,14 +55,18 @@ func CreatePAT(ctx context.Context, tokenStore store.TokenStore,
|
||||
createdFor.ToPrincipal(),
|
||||
uid,
|
||||
lifetime,
|
||||
grants,
|
||||
)
|
||||
}
|
||||
|
||||
func CreateSAT(ctx context.Context, tokenStore store.TokenStore,
|
||||
createdBy *types.Principal, createdFor *types.ServiceAccount,
|
||||
uid string, lifetime *time.Duration, grants enum.AccessGrant) (*types.Token, string, error) {
|
||||
return Create(
|
||||
func CreateSAT(
|
||||
ctx context.Context,
|
||||
tokenStore store.TokenStore,
|
||||
createdBy *types.Principal,
|
||||
createdFor *types.ServiceAccount,
|
||||
uid string,
|
||||
lifetime *time.Duration,
|
||||
) (*types.Token, string, error) {
|
||||
return create(
|
||||
ctx,
|
||||
tokenStore,
|
||||
enum.TokenTypeSAT,
|
||||
@ -61,13 +74,18 @@ func CreateSAT(ctx context.Context, tokenStore store.TokenStore,
|
||||
createdFor.ToPrincipal(),
|
||||
uid,
|
||||
lifetime,
|
||||
grants,
|
||||
)
|
||||
}
|
||||
|
||||
func Create(ctx context.Context, tokenStore store.TokenStore,
|
||||
tokenType enum.TokenType, createdBy *types.Principal, createdFor *types.Principal,
|
||||
uid string, lifetime *time.Duration, grants enum.AccessGrant) (*types.Token, string, error) {
|
||||
func create(
|
||||
ctx context.Context,
|
||||
tokenStore store.TokenStore,
|
||||
tokenType enum.TokenType,
|
||||
createdBy *types.Principal,
|
||||
createdFor *types.Principal,
|
||||
uid string,
|
||||
lifetime *time.Duration,
|
||||
) (*types.Token, string, error) {
|
||||
issuedAt := time.Now()
|
||||
|
||||
var expiresAt *int64
|
||||
@ -82,7 +100,6 @@ func Create(ctx context.Context, tokenStore store.TokenStore,
|
||||
PrincipalID: createdFor.ID,
|
||||
IssuedAt: issuedAt.UnixMilli(),
|
||||
ExpiresAt: expiresAt,
|
||||
Grants: grants,
|
||||
CreatedBy: createdBy.ID,
|
||||
}
|
||||
|
||||
@ -92,7 +109,7 @@ func Create(ctx context.Context, tokenStore store.TokenStore,
|
||||
}
|
||||
|
||||
// create jwt token.
|
||||
jwtToken, err := GenerateJWTForToken(&token, createdFor.Salt)
|
||||
jwtToken, err := jwt.GenerateForToken(&token, createdFor.Salt)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("failed to create jwt token: %w", err)
|
||||
}
|
||||
|
@ -1,100 +0,0 @@
|
||||
// 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 token
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestToken(t *testing.T) {
|
||||
// user := &types.User{ID: 42, Admin: true}
|
||||
// tokenStr, err := GenerateJWT(user, "TEST0E4C2F76C58916E")
|
||||
// if err != nil {
|
||||
// t.Error(err)
|
||||
// return
|
||||
// }
|
||||
|
||||
// token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
// sub := token.Claims.(*JWTClaims).Subject
|
||||
// id, _ := strconv.ParseInt(sub, 10, 64)
|
||||
// if id != 42 {
|
||||
// t.Errorf("want subscriber id, got %v", id)
|
||||
// }
|
||||
// return []byte("TEST0E4C2F76C58916E"), nil
|
||||
// })
|
||||
// if err != nil {
|
||||
// t.Error(err)
|
||||
// return
|
||||
// }
|
||||
// if token.Valid == false {
|
||||
// t.Errorf("invalid token")
|
||||
// return
|
||||
// }
|
||||
// if _, ok := token.Method.(*jwt.SigningMethodHMAC); !ok {
|
||||
// t.Errorf("invalid token signing method")
|
||||
// return
|
||||
// }
|
||||
|
||||
// if expires := token.Claims.(*JWTClaims).ExpiresAt; expires > 0 {
|
||||
// if time.Now().Unix() > expires {
|
||||
// t.Errorf("token expired")
|
||||
// }
|
||||
// }
|
||||
}
|
||||
|
||||
func TestTokenExpired(t *testing.T) {
|
||||
// user := &types.User{ID: 42, Admin: true}
|
||||
// tokenStr, err := GenerateJWTWithExpiration(user, 1637549186, "TEST0E4C2F76C58916E")
|
||||
// if err != nil {
|
||||
// t.Error(err)
|
||||
// return
|
||||
// }
|
||||
|
||||
// _, err = jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
// sub := token.Claims.(*JWTClaims).Subject
|
||||
// id, _ := strconv.ParseInt(sub, 10, 64)
|
||||
// if id != 42 {
|
||||
// t.Errorf("want subscriber id, got %v", id)
|
||||
// }
|
||||
// return []byte("TEST0E4C2F76C58916E"), nil
|
||||
// })
|
||||
// if err == nil {
|
||||
// t.Errorf("expect token expired")
|
||||
// return
|
||||
// }
|
||||
}
|
||||
|
||||
func TestTokenNotExpired(t *testing.T) {
|
||||
// user := &types.User{ID: 42, Admin: true}
|
||||
// tokenStr, err := GenerateJWTWithExpiration(user, time.Now().Add(time.Hour).Unix(), "TEST0E4C2F76C58916E")
|
||||
// if err != nil {
|
||||
// t.Error(err)
|
||||
// return
|
||||
// }
|
||||
|
||||
// token, err := jwt.ParseWithClaims(tokenStr, &JWTClaims{}, func(token *jwt.Token) (interface{}, error) {
|
||||
// sub := token.Claims.(*JWTClaims).Subject
|
||||
// id, _ := strconv.ParseInt(sub, 10, 64)
|
||||
// if id != 42 {
|
||||
// t.Errorf("want subscriber id, got %v", id)
|
||||
// }
|
||||
// return []byte("TEST0E4C2F76C58916E"), nil
|
||||
// })
|
||||
// if err != nil {
|
||||
// t.Error(err)
|
||||
// return
|
||||
// }
|
||||
|
||||
// claims, ok := token.Claims.(*JWTClaims)
|
||||
// if !ok {
|
||||
// t.Errorf("expect token claims from token")
|
||||
// return
|
||||
// }
|
||||
|
||||
// if claims.ExpiresAt > 0 && time.Now().Unix() > claims.ExpiresAt {
|
||||
// t.Errorf("expect token not expired")
|
||||
// return
|
||||
// }
|
||||
}
|
@ -1,26 +0,0 @@
|
||||
// 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 check
|
||||
|
||||
import (
|
||||
"github.com/harness/gitness/types/enum"
|
||||
)
|
||||
|
||||
var (
|
||||
ErrTokenGrantEmpty = &ValidationError{
|
||||
"The token requires at least one grant.",
|
||||
}
|
||||
)
|
||||
|
||||
// AccessGrant returns true if the access grant is valid.
|
||||
func AccessGrant(grant enum.AccessGrant, allowNone bool) error {
|
||||
if !allowNone && grant == enum.AccessGrantNone {
|
||||
return ErrTokenGrantEmpty
|
||||
}
|
||||
|
||||
// TODO: Ensure grant contains valid values?
|
||||
|
||||
return nil
|
||||
}
|
@ -147,7 +147,14 @@ type Config struct {
|
||||
DisplayName string `envconfig:"GITNESS_PRINCIPAL_SYSTEM_DISPLAY_NAME" default:"Gitness"`
|
||||
Email string `envconfig:"GITNESS_PRINCIPAL_SYSTEM_EMAIL" default:"system@gitness.io"`
|
||||
}
|
||||
// Pipeline defines the principal information used to create the pipeline service.
|
||||
Pipeline struct {
|
||||
UID string `envconfig:"GITNESS_PRINCIPAL_PIPELINE_UID" default:"pipeline"`
|
||||
DisplayName string `envconfig:"GITNESS_PRINCIPAL_PIPELINE_DISPLAY_NAME" default:"Gitness Pipeline"`
|
||||
Email string `envconfig:"GITNESS_PRINCIPAL_PIPELINE_EMAIL" default:"pipeline@gitness.io"`
|
||||
}
|
||||
// Admin defines the principal information used to create the admin user.
|
||||
// NOTE: The admin user is only auto-created in case a password is provided.
|
||||
Admin struct {
|
||||
UID string `envconfig:"GITNESS_PRINCIPAL_ADMIN_UID" default:"admin"`
|
||||
DisplayName string `envconfig:"GITNESS_PRINCIPAL_ADMIN_DISPLAY_NAME" default:"Administrator"`
|
||||
|
@ -1,55 +0,0 @@
|
||||
// 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
|
||||
|
||||
// AccessGrant represents the access grants a token or sshkey can have.
|
||||
// Keep as int64 to allow for simpler+faster lookup of grants for a given token
|
||||
// as we don't have to store an array field or need to do a join / 2nd db call.
|
||||
// Multiple grants can be combined using the bit-wise or operation.
|
||||
// ASSUMPTION: we don't need more than 63 grants!
|
||||
//
|
||||
// NOTE: A grant is always restricted by the principal permissions
|
||||
//
|
||||
// TODO: Beter name, access grant and permission might be to close in terminology?
|
||||
type AccessGrant int64
|
||||
|
||||
const (
|
||||
// no grants - useless token.
|
||||
AccessGrantNone AccessGrant = 0
|
||||
|
||||
// privacy related grants.
|
||||
AccessGrantPublic AccessGrant = 1 << 0 // 1
|
||||
AccessGrantPrivate AccessGrant = 1 << 1 // 2
|
||||
|
||||
// api related grants (spaces / repos, ...).
|
||||
AccessGrantAPICreate AccessGrant = 1 << 10 // 1024
|
||||
AccessGrantAPIView AccessGrant = 1 << 11 // 2048
|
||||
AccessGrantAPIEdit AccessGrant = 1 << 12 // 4096
|
||||
AccessGrantAPIDelete AccessGrant = 1 << 13 // 8192
|
||||
|
||||
// code related grants.
|
||||
AccessGrantCodeRead AccessGrant = 1 << 20 // 1048576
|
||||
AccessGrantCodeWrite AccessGrant = 1 << 21 // 2097152
|
||||
|
||||
// grants everything - for user sessions.
|
||||
AccessGrantAll AccessGrant = 1<<63 - 1
|
||||
)
|
||||
|
||||
// DoesGrantContain checks whether the grants contain all grants in the provided grant.
|
||||
func (g AccessGrant) Contains(grants AccessGrant) bool {
|
||||
return g&grants == grants
|
||||
}
|
||||
|
||||
// CombineGrants combines all grants into a single grant.
|
||||
// Note: duplicates are ignored.
|
||||
func CombineGrants(grants ...AccessGrant) AccessGrant {
|
||||
res := AccessGrantNone
|
||||
|
||||
for _, grant := range grants {
|
||||
res |= grant
|
||||
}
|
||||
|
||||
return res
|
||||
}
|
@ -18,9 +18,8 @@ type Token struct {
|
||||
// ExpiresAt is an optional unix time that if specified restricts the validity of a token.
|
||||
ExpiresAt *int64 `db:"token_expires_at" json:"expires_at,omitempty"`
|
||||
// IssuedAt is the unix time at which the token was issued.
|
||||
IssuedAt int64 `db:"token_issued_at" json:"issued_at"`
|
||||
Grants enum.AccessGrant `db:"token_grants" json:"grants"`
|
||||
CreatedBy int64 `db:"token_created_by" json:"created_by"`
|
||||
IssuedAt int64 `db:"token_issued_at" json:"issued_at"`
|
||||
CreatedBy int64 `db:"token_created_by" json:"created_by"`
|
||||
}
|
||||
|
||||
// TokenResponse is returned as part of token creation for PAT / SAT / User Session.
|
||||
|
@ -0,0 +1,9 @@
|
||||
.actionsContainer {
|
||||
background-color: var(--grey-50) !important;
|
||||
width: 100% !important;
|
||||
border-radius: 4px !important;
|
||||
border: 1px solid rgba(217, 218, 229, 0.5) !important;
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
gap: 10px !important;
|
||||
}
|
3
web/src/components/NewTriggerModalButton/NewTriggerModalButton.module.scss.d.ts
vendored
Normal file
3
web/src/components/NewTriggerModalButton/NewTriggerModalButton.module.scss.d.ts
vendored
Normal file
@ -0,0 +1,3 @@
|
||||
/* eslint-disable */
|
||||
// This is an auto-generated file
|
||||
export declare const actionsContainer: string
|
@ -0,0 +1,154 @@
|
||||
import {
|
||||
useToaster,
|
||||
type ButtonProps,
|
||||
Button,
|
||||
Dialog,
|
||||
Layout,
|
||||
Container,
|
||||
Formik,
|
||||
FormikForm,
|
||||
FormInput,
|
||||
FlexExpander,
|
||||
Text,
|
||||
Checkbox
|
||||
} from '@harnessio/uicore'
|
||||
import { Icon } from '@harnessio/icons'
|
||||
import { FontVariation, Intent } from '@harnessio/design-system'
|
||||
import React from 'react'
|
||||
import { useMutate } from 'restful-react'
|
||||
import * as yup from 'yup'
|
||||
import { useModalHook } from 'hooks/useModalHook'
|
||||
import { useStrings } from 'framework/strings'
|
||||
import type { EnumTriggerAction, OpenapiCreateTriggerRequest, TypesTrigger } from 'services/code'
|
||||
import { getErrorMessage } from 'utils/Utils'
|
||||
import { triggerActions } from 'components/PipelineTriggersTab/PipelineTriggersTab'
|
||||
import css from './NewTriggerModalButton.module.scss'
|
||||
|
||||
export interface TriggerFormData {
|
||||
name: string
|
||||
actions: EnumTriggerAction[]
|
||||
}
|
||||
|
||||
const formInitialValues: TriggerFormData = {
|
||||
name: '',
|
||||
actions: []
|
||||
}
|
||||
|
||||
export interface NewTriggerModalButtonProps extends Omit<ButtonProps, 'onClick' | 'onSubmit'> {
|
||||
repoPath: string
|
||||
pipeline: string
|
||||
modalTitle: string
|
||||
submitButtonTitle?: string
|
||||
cancelButtonTitle?: string
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
export const NewTriggerModalButton: React.FC<NewTriggerModalButtonProps> = ({
|
||||
repoPath,
|
||||
pipeline,
|
||||
modalTitle,
|
||||
submitButtonTitle,
|
||||
cancelButtonTitle,
|
||||
onSuccess,
|
||||
...props
|
||||
}) => {
|
||||
const ModalComponent: React.FC = () => {
|
||||
const { getString } = useStrings()
|
||||
const { showError, showSuccess } = useToaster()
|
||||
|
||||
const { mutate: createTrigger, loading } = useMutate<TypesTrigger>({
|
||||
verb: 'POST',
|
||||
path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}/triggers`
|
||||
})
|
||||
|
||||
const handleSubmit = async (formData: TriggerFormData) => {
|
||||
try {
|
||||
const payload: OpenapiCreateTriggerRequest = {
|
||||
actions: formData.actions,
|
||||
uid: formData.name
|
||||
}
|
||||
await createTrigger(payload)
|
||||
hideModal()
|
||||
showSuccess(getString('triggers.createSuccess'))
|
||||
onSuccess()
|
||||
} catch (exception) {
|
||||
showError(getErrorMessage(exception), 0, getString('triggers.failedToCreate'))
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
isOpen
|
||||
enforceFocus={false}
|
||||
onClose={hideModal}
|
||||
title={modalTitle}
|
||||
style={{ width: 700, maxHeight: '95vh', overflow: 'auto' }}>
|
||||
<Layout.Vertical padding={'large'} style={{ height: '100%' }} data-testid="add-trigger-modal">
|
||||
<Container>
|
||||
<Formik
|
||||
initialValues={formInitialValues}
|
||||
formName="addTrigger"
|
||||
enableReinitialize={true}
|
||||
validationSchema={yup.object().shape({
|
||||
name: yup
|
||||
.string()
|
||||
.required('name is required')
|
||||
.matches(
|
||||
/^[a-zA-Z_][a-zA-Z0-9-_.]*$/,
|
||||
'name must start with a letter or _ and only contain [a-zA-Z0-9-_.]'
|
||||
),
|
||||
actions: yup.array().of(yup.string())
|
||||
})}
|
||||
validateOnChange
|
||||
validateOnBlur
|
||||
onSubmit={handleSubmit}>
|
||||
{formik => (
|
||||
<FormikForm>
|
||||
<FormInput.Text
|
||||
name="name"
|
||||
label={getString('name')}
|
||||
placeholder={getString('triggers.enterTriggerName')}
|
||||
inputGroup={{ autoFocus: true }}
|
||||
/>
|
||||
<Text font={{ variation: FontVariation.FORM_LABEL }} margin={{ bottom: 'xsmall' }}>
|
||||
{getString('triggers.actions')}
|
||||
</Text>
|
||||
<Container className={css.actionsContainer} padding={'large'}>
|
||||
{triggerActions.map(action => (
|
||||
<Checkbox
|
||||
key={action.name}
|
||||
name="actions"
|
||||
label={action.name}
|
||||
value={action.value}
|
||||
onChange={event => {
|
||||
if (event.currentTarget.checked) {
|
||||
formik.setFieldValue('actions', [...formik.values.actions, action.value])
|
||||
} else {
|
||||
formik.setFieldValue(
|
||||
'actions',
|
||||
formik.values.actions.filter((value: string) => value !== action.value)
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Container>
|
||||
<Layout.Horizontal spacing="small" padding={{ top: 'large' }} style={{ alignItems: 'center' }}>
|
||||
<Button type="submit" text={getString('create')} intent={Intent.PRIMARY} disabled={loading} />
|
||||
<Button text={cancelButtonTitle || getString('cancel')} minimal onClick={hideModal} />
|
||||
<FlexExpander />
|
||||
{loading && <Icon intent={Intent.PRIMARY} name="steps-spinner" size={16} />}
|
||||
</Layout.Horizontal>
|
||||
</FormikForm>
|
||||
)}
|
||||
</Formik>
|
||||
</Container>
|
||||
</Layout.Vertical>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
const [openModal, hideModal] = useModalHook(ModalComponent, [onSuccess])
|
||||
|
||||
return <Button onClick={openModal} {...props} />
|
||||
}
|
@ -7,22 +7,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
.generalContainer {
|
||||
width: 100%;
|
||||
background: var(--grey-0) !important;
|
||||
box-shadow: 0px 0px 1px rgba(40, 41, 61, 0.08), 0px 0.5px 2px rgba(96, 97, 112, 0.16);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.yellowContainer {
|
||||
background: var(--yellow-100) !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.withError {
|
||||
display: grid;
|
||||
}
|
||||
|
@ -1,8 +1,5 @@
|
||||
/* eslint-disable */
|
||||
// This is an auto-generated file
|
||||
export declare const generalContainer: string
|
||||
export declare const layout: string
|
||||
export declare const main: string
|
||||
export declare const textContainer: string
|
||||
export declare const withError: string
|
||||
export declare const yellowContainer: string
|
||||
|
@ -1,29 +1,17 @@
|
||||
import {
|
||||
Button,
|
||||
ButtonVariation,
|
||||
Container,
|
||||
FormInput,
|
||||
Formik,
|
||||
FormikForm,
|
||||
Layout,
|
||||
PageBody,
|
||||
Text,
|
||||
useToaster
|
||||
} from '@harnessio/uicore'
|
||||
import { Container, PageBody } from '@harnessio/uicore'
|
||||
import React from 'react'
|
||||
import { useHistory, useParams } from 'react-router-dom'
|
||||
import { Color, Intent } from '@harnessio/design-system'
|
||||
import { useGet, useMutate } from 'restful-react'
|
||||
import { useParams } from 'react-router-dom'
|
||||
import { useGet } from 'restful-react'
|
||||
import cx from 'classnames'
|
||||
import * as yup from 'yup'
|
||||
import PipelineSettingsPageHeader from 'components/PipelineSettingsPageHeader/PipelineSettingsPageHeader'
|
||||
import { String, useStrings } from 'framework/strings'
|
||||
import { useStrings } from 'framework/strings'
|
||||
import { useGetRepositoryMetadata } from 'hooks/useGetRepositoryMetadata'
|
||||
import { routes, type CODEProps } from 'RouteDefinitions'
|
||||
import { useConfirmAct } from 'hooks/useConfirmAction'
|
||||
import { getErrorMessage, voidFn } from 'utils/Utils'
|
||||
import type { OpenapiUpdatePipelineRequest, TypesPipeline } from 'services/code'
|
||||
import type { TypesPipeline } from 'services/code'
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
|
||||
import PipelineSettingsTab from 'components/PipelineSettingsTab/PipelineSettingsTab'
|
||||
import PipelineTriggersTabs from 'components/PipelineTriggersTab/PipelineTriggersTab'
|
||||
import css from './PipelineSettings.module.scss'
|
||||
|
||||
export enum TabOptions {
|
||||
@ -31,163 +19,6 @@ export enum TabOptions {
|
||||
TRIGGERS = 'Triggers'
|
||||
}
|
||||
|
||||
interface SettingsContentProps {
|
||||
pipeline: string
|
||||
repoPath: string
|
||||
yamlPath: string
|
||||
}
|
||||
|
||||
interface SettingsFormData {
|
||||
name: string
|
||||
yamlPath: string
|
||||
}
|
||||
|
||||
const SettingsContent = ({ pipeline, repoPath, yamlPath }: SettingsContentProps) => {
|
||||
const { getString } = useStrings()
|
||||
const { mutate: updatePipeline } = useMutate<TypesPipeline>({
|
||||
verb: 'PATCH',
|
||||
path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}`
|
||||
})
|
||||
const { mutate: deletePipeline } = useMutate<TypesPipeline>({
|
||||
verb: 'DELETE',
|
||||
path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}`
|
||||
})
|
||||
const { showSuccess, showError } = useToaster()
|
||||
const confirmDeletePipeline = useConfirmAct()
|
||||
const history = useHistory()
|
||||
|
||||
return (
|
||||
<Layout.Vertical padding={'medium'} spacing={'medium'}>
|
||||
<Container padding={'large'} className={css.generalContainer}>
|
||||
<Formik<SettingsFormData>
|
||||
initialValues={{
|
||||
name: pipeline,
|
||||
yamlPath
|
||||
}}
|
||||
formName="pipelineSettings"
|
||||
enableReinitialize={true}
|
||||
validationSchema={yup.object().shape({
|
||||
name: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(`${getString('name')} ${getString('isRequired')}`),
|
||||
yamlPath: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(`${getString('pipelines.yamlPath')} ${getString('isRequired')}`)
|
||||
})}
|
||||
validateOnChange
|
||||
validateOnBlur
|
||||
onSubmit={async formData => {
|
||||
const { name, yamlPath: newYamlPath } = formData
|
||||
try {
|
||||
const payload: OpenapiUpdatePipelineRequest = {
|
||||
config_path: newYamlPath,
|
||||
uid: name
|
||||
}
|
||||
await updatePipeline(payload, {
|
||||
pathParams: { path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}` }
|
||||
})
|
||||
history.push(
|
||||
routes.toCODEPipelineSettings({
|
||||
repoPath,
|
||||
pipeline: name
|
||||
})
|
||||
)
|
||||
showSuccess(getString('pipelines.updatePipelineSuccess', { pipeline }))
|
||||
} catch (exception) {
|
||||
showError(getErrorMessage(exception), 0, 'pipelines.failedToUpdatePipeline')
|
||||
}
|
||||
}}>
|
||||
{() => {
|
||||
return (
|
||||
<FormikForm>
|
||||
<Layout.Vertical spacing={'large'}>
|
||||
<Layout.Horizontal spacing={'large'} flex={{ alignItems: 'center', justifyContent: 'flex-start' }}>
|
||||
<FormInput.Text
|
||||
name="name"
|
||||
className={css.textContainer}
|
||||
label={
|
||||
<Text color={Color.GREY_800} font={{ size: 'small' }}>
|
||||
{getString('name')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Layout.Horizontal>
|
||||
<Layout.Horizontal spacing={'large'} flex={{ alignItems: 'center', justifyContent: 'flex-start' }}>
|
||||
<FormInput.Text
|
||||
name="yamlPath"
|
||||
className={css.textContainer}
|
||||
label={
|
||||
<Text color={Color.GREY_800} font={{ size: 'small' }}>
|
||||
{getString('pipelines.yamlPath')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Layout.Horizontal>
|
||||
<Layout.Horizontal spacing={'large'}>
|
||||
<Button intent={Intent.PRIMARY} type="submit" text={getString('save')} />
|
||||
<Button variation={ButtonVariation.TERTIARY} type="reset" text={getString('cancel')} />
|
||||
</Layout.Horizontal>
|
||||
</Layout.Vertical>
|
||||
</FormikForm>
|
||||
)
|
||||
}}
|
||||
</Formik>
|
||||
</Container>
|
||||
<Container padding={'large'} className={css.generalContainer}>
|
||||
<Layout.Vertical>
|
||||
<Text icon="main-trash" color={Color.GREY_600} font={{ size: 'normal' }}>
|
||||
{getString('dangerDeleteRepo')}
|
||||
</Text>
|
||||
<Layout.Horizontal padding={{ top: 'medium', left: 'medium' }} flex={{ justifyContent: 'space-between' }}>
|
||||
<Container intent="warning" padding={'small'} className={css.yellowContainer}>
|
||||
<Text
|
||||
icon="main-issue"
|
||||
iconProps={{ size: 18, color: Color.ORANGE_700, margin: { right: 'small' } }}
|
||||
color={Color.WARNING}>
|
||||
{getString('pipelines.deletePipelineWarning', {
|
||||
pipeline
|
||||
})}
|
||||
</Text>
|
||||
</Container>
|
||||
<Button
|
||||
margin={{ right: 'medium' }}
|
||||
intent={Intent.DANGER}
|
||||
onClick={() => {
|
||||
confirmDeletePipeline({
|
||||
title: getString('pipelines.deletePipelineButton'),
|
||||
confirmText: getString('delete'),
|
||||
intent: Intent.DANGER,
|
||||
message: <String useRichText stringID="pipelines.deletePipelineConfirm" vars={{ pipeline }} />,
|
||||
action: async () => {
|
||||
try {
|
||||
await deletePipeline(null)
|
||||
history.push(
|
||||
routes.toCODEPipelines({
|
||||
repoPath
|
||||
})
|
||||
)
|
||||
showSuccess(getString('pipelines.deletePipelineSuccess', { pipeline }))
|
||||
} catch (e) {
|
||||
showError(getString('pipelines.deletePipelineError'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
variation={ButtonVariation.PRIMARY}
|
||||
text={getString('pipelines.deletePipelineButton')}></Button>
|
||||
</Layout.Horizontal>
|
||||
</Layout.Vertical>
|
||||
</Container>
|
||||
</Layout.Vertical>
|
||||
)
|
||||
}
|
||||
|
||||
const TriggersContent = () => {
|
||||
return <div>Triggers</div>
|
||||
}
|
||||
|
||||
const PipelineSettings = () => {
|
||||
const { getString } = useStrings()
|
||||
|
||||
@ -232,13 +63,15 @@ const PipelineSettings = () => {
|
||||
retryOnError={voidFn(refetch)}>
|
||||
<LoadingSpinner visible={loading || pipelineLoading} withBorder={!!pipeline} />
|
||||
{selectedTab === TabOptions.SETTINGS && (
|
||||
<SettingsContent
|
||||
<PipelineSettingsTab
|
||||
pipeline={pipeline as string}
|
||||
repoPath={repoMetadata?.path as string}
|
||||
yamlPath={pipelineData?.config_path as string}
|
||||
/>
|
||||
)}
|
||||
{selectedTab === TabOptions.TRIGGERS && <TriggersContent />}
|
||||
{selectedTab === TabOptions.TRIGGERS && (
|
||||
<PipelineTriggersTabs pipeline={pipeline as string} repoPath={repoMetadata?.path as string} />
|
||||
)}
|
||||
</PageBody>
|
||||
</Container>
|
||||
)
|
||||
|
@ -0,0 +1,21 @@
|
||||
.generalContainer {
|
||||
width: 100%;
|
||||
background: var(--grey-0) !important;
|
||||
box-shadow: 0px 0px 1px rgba(40, 41, 61, 0.08), 0px 0.5px 2px rgba(96, 97, 112, 0.16);
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
.yellowContainer {
|
||||
background: var(--yellow-100) !important;
|
||||
border-radius: 4px !important;
|
||||
}
|
||||
|
||||
.textContainer {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.separator {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 1px solid rgba(217, 218, 229, 0.2) !important;
|
||||
}
|
6
web/src/components/PipelineSettingsTab/PipelineSettingsTab.module.scss.d.ts
vendored
Normal file
6
web/src/components/PipelineSettingsTab/PipelineSettingsTab.module.scss.d.ts
vendored
Normal file
@ -0,0 +1,6 @@
|
||||
/* eslint-disable */
|
||||
// This is an auto-generated file
|
||||
export declare const generalContainer: string
|
||||
export declare const separator: string
|
||||
export declare const textContainer: string
|
||||
export declare const yellowContainer: string
|
178
web/src/components/PipelineSettingsTab/PipelineSettingsTab.tsx
Normal file
178
web/src/components/PipelineSettingsTab/PipelineSettingsTab.tsx
Normal file
@ -0,0 +1,178 @@
|
||||
import {
|
||||
Button,
|
||||
ButtonVariation,
|
||||
Container,
|
||||
FormInput,
|
||||
Formik,
|
||||
FormikForm,
|
||||
Layout,
|
||||
Text,
|
||||
useToaster
|
||||
} from '@harnessio/uicore'
|
||||
import React from 'react'
|
||||
import { useHistory } from 'react-router-dom'
|
||||
import { Color, Intent } from '@harnessio/design-system'
|
||||
import { useMutate } from 'restful-react'
|
||||
import * as yup from 'yup'
|
||||
import { String, useStrings } from 'framework/strings'
|
||||
import { routes } from 'RouteDefinitions'
|
||||
import { useConfirmAct } from 'hooks/useConfirmAction'
|
||||
import { getErrorMessage } from 'utils/Utils'
|
||||
import type { OpenapiUpdatePipelineRequest, TypesPipeline } from 'services/code'
|
||||
import css from './PipelineSettingsTab.module.scss'
|
||||
|
||||
interface SettingsContentProps {
|
||||
pipeline: string
|
||||
repoPath: string
|
||||
yamlPath: string
|
||||
}
|
||||
|
||||
interface SettingsFormData {
|
||||
name: string
|
||||
yamlPath: string
|
||||
}
|
||||
|
||||
const PipelineSettingsTab = ({ pipeline, repoPath, yamlPath }: SettingsContentProps) => {
|
||||
const { getString } = useStrings()
|
||||
const { mutate: updatePipeline } = useMutate<TypesPipeline>({
|
||||
verb: 'PATCH',
|
||||
path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}`
|
||||
})
|
||||
const { mutate: deletePipeline } = useMutate<TypesPipeline>({
|
||||
verb: 'DELETE',
|
||||
path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}`
|
||||
})
|
||||
const { showSuccess, showError } = useToaster()
|
||||
const confirmDeletePipeline = useConfirmAct()
|
||||
const history = useHistory()
|
||||
|
||||
return (
|
||||
<Layout.Vertical padding={'medium'} spacing={'medium'}>
|
||||
<Container padding={'large'} className={css.generalContainer}>
|
||||
<Formik<SettingsFormData>
|
||||
initialValues={{
|
||||
name: pipeline,
|
||||
yamlPath
|
||||
}}
|
||||
formName="pipelineSettings"
|
||||
enableReinitialize={true}
|
||||
validationSchema={yup.object().shape({
|
||||
name: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(`${getString('name')} ${getString('isRequired')}`),
|
||||
yamlPath: yup
|
||||
.string()
|
||||
.trim()
|
||||
.required(`${getString('pipelines.yamlPath')} ${getString('isRequired')}`)
|
||||
})}
|
||||
validateOnChange
|
||||
validateOnBlur
|
||||
onSubmit={async formData => {
|
||||
const { name, yamlPath: newYamlPath } = formData
|
||||
try {
|
||||
const payload: OpenapiUpdatePipelineRequest = {
|
||||
config_path: newYamlPath,
|
||||
uid: name
|
||||
}
|
||||
await updatePipeline(payload, {
|
||||
pathParams: { path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}` }
|
||||
})
|
||||
history.push(
|
||||
routes.toCODEPipelineSettings({
|
||||
repoPath,
|
||||
pipeline: name
|
||||
})
|
||||
)
|
||||
showSuccess(getString('pipelines.updatePipelineSuccess', { pipeline }))
|
||||
} catch (exception) {
|
||||
showError(getErrorMessage(exception), 0, 'pipelines.failedToUpdatePipeline')
|
||||
}
|
||||
}}>
|
||||
{() => {
|
||||
return (
|
||||
<FormikForm>
|
||||
<Layout.Vertical spacing={'large'}>
|
||||
<Layout.Horizontal spacing={'large'} flex={{ alignItems: 'center', justifyContent: 'flex-start' }}>
|
||||
<FormInput.Text
|
||||
name="name"
|
||||
className={css.textContainer}
|
||||
label={
|
||||
<Text color={Color.GREY_800} font={{ size: 'small' }}>
|
||||
{getString('name')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Layout.Horizontal>
|
||||
<Layout.Horizontal spacing={'large'} flex={{ alignItems: 'center', justifyContent: 'flex-start' }}>
|
||||
<FormInput.Text
|
||||
name="yamlPath"
|
||||
className={css.textContainer}
|
||||
label={
|
||||
<Text color={Color.GREY_800} font={{ size: 'small' }}>
|
||||
{getString('pipelines.yamlPath')}
|
||||
</Text>
|
||||
}
|
||||
/>
|
||||
</Layout.Horizontal>
|
||||
<div className={css.separator} />
|
||||
<Layout.Horizontal spacing={'large'}>
|
||||
<Button intent={Intent.PRIMARY} type="submit" text={getString('save')} />
|
||||
<Button variation={ButtonVariation.TERTIARY} type="reset" text={getString('cancel')} />
|
||||
</Layout.Horizontal>
|
||||
</Layout.Vertical>
|
||||
</FormikForm>
|
||||
)
|
||||
}}
|
||||
</Formik>
|
||||
</Container>
|
||||
<Container padding={'large'} className={css.generalContainer}>
|
||||
<Layout.Vertical>
|
||||
<Text icon="main-trash" color={Color.GREY_600} font={{ size: 'normal' }}>
|
||||
{getString('dangerDeleteRepo')}
|
||||
</Text>
|
||||
<Layout.Horizontal padding={{ top: 'medium', left: 'medium' }} flex={{ justifyContent: 'space-between' }}>
|
||||
<Container intent="warning" padding={'small'} className={css.yellowContainer}>
|
||||
<Text
|
||||
icon="main-issue"
|
||||
iconProps={{ size: 18, color: Color.ORANGE_700, margin: { right: 'small' } }}
|
||||
color={Color.WARNING}>
|
||||
{getString('pipelines.deletePipelineWarning', {
|
||||
pipeline
|
||||
})}
|
||||
</Text>
|
||||
</Container>
|
||||
<Button
|
||||
margin={{ right: 'medium' }}
|
||||
intent={Intent.DANGER}
|
||||
onClick={() => {
|
||||
confirmDeletePipeline({
|
||||
title: getString('pipelines.deletePipelineButton'),
|
||||
confirmText: getString('delete'),
|
||||
intent: Intent.DANGER,
|
||||
message: <String useRichText stringID="pipelines.deletePipelineConfirm" vars={{ pipeline }} />,
|
||||
action: async () => {
|
||||
try {
|
||||
await deletePipeline(null)
|
||||
history.push(
|
||||
routes.toCODEPipelines({
|
||||
repoPath
|
||||
})
|
||||
)
|
||||
showSuccess(getString('pipelines.deletePipelineSuccess', { pipeline }))
|
||||
} catch (e) {
|
||||
showError(getString('pipelines.deletePipelineError'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
variation={ButtonVariation.PRIMARY}
|
||||
text={getString('pipelines.deletePipelineButton')}></Button>
|
||||
</Layout.Horizontal>
|
||||
</Layout.Vertical>
|
||||
</Container>
|
||||
</Layout.Vertical>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipelineSettingsTab
|
@ -0,0 +1,65 @@
|
||||
.separator {
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
border: 1px solid rgba(217, 218, 229, 0.2) !important;
|
||||
}
|
||||
|
||||
.generalContainer {
|
||||
background: var(--grey-0) !important;
|
||||
box-shadow: 0px 0.5px 2px 0px rgba(96, 97, 112, 0.16), 0px 0px 1px 0px rgba(40, 41, 61, 0.08) !important;
|
||||
border-radius: 4px !important;
|
||||
margin: 1px 1px !important;
|
||||
}
|
||||
|
||||
.TriggerMenuItem {
|
||||
width: 500px !important;
|
||||
cursor: pointer !important;
|
||||
}
|
||||
|
||||
.editTriggerContainer {
|
||||
width: 800px !important;
|
||||
margin-top: 53px !important;
|
||||
}
|
||||
|
||||
.selected {
|
||||
border: 1px solid var(--primary-7) !important;
|
||||
}
|
||||
|
||||
.pillContainer {
|
||||
background-color: var(--grey-100) !important;
|
||||
color: var(--grey-400) !important;
|
||||
padding: 4px !important;
|
||||
height: 24px !important;
|
||||
}
|
||||
|
||||
.pillText {
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.triggerName {
|
||||
color: var(--gray-scale-700) !important;
|
||||
font-size: 14px !important;
|
||||
font-weight: 600 !important;
|
||||
}
|
||||
|
||||
.triggerDate {
|
||||
color: var(--gray-scale-500) !important;
|
||||
font-size: 12px !important;
|
||||
font-weight: 500 !important;
|
||||
}
|
||||
|
||||
.triggerList {
|
||||
overflow-y: auto !important;
|
||||
}
|
||||
|
||||
.editTriggerTitle {
|
||||
color: var(--gray-scale-700);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.actionsContainer {
|
||||
width: 100% !important;
|
||||
display: grid !important;
|
||||
grid-template-columns: repeat(2, 1fr) !important;
|
||||
gap: 10px !important;
|
||||
}
|
14
web/src/components/PipelineTriggersTab/PipelineTriggersTab.module.scss.d.ts
vendored
Normal file
14
web/src/components/PipelineTriggersTab/PipelineTriggersTab.module.scss.d.ts
vendored
Normal file
@ -0,0 +1,14 @@
|
||||
/* eslint-disable */
|
||||
// This is an auto-generated file
|
||||
export declare const actionsContainer: string
|
||||
export declare const editTriggerContainer: string
|
||||
export declare const editTriggerTitle: string
|
||||
export declare const generalContainer: string
|
||||
export declare const pillContainer: string
|
||||
export declare const pillText: string
|
||||
export declare const selected: string
|
||||
export declare const separator: string
|
||||
export declare const triggerDate: string
|
||||
export declare const triggerList: string
|
||||
export declare const triggerMenuItem: string
|
||||
export declare const triggerName: string
|
302
web/src/components/PipelineTriggersTab/PipelineTriggersTab.tsx
Normal file
302
web/src/components/PipelineTriggersTab/PipelineTriggersTab.tsx
Normal file
@ -0,0 +1,302 @@
|
||||
import {
|
||||
ButtonVariation,
|
||||
FlexExpander,
|
||||
Layout,
|
||||
Text,
|
||||
Formik,
|
||||
FormikForm,
|
||||
Container,
|
||||
Button,
|
||||
useToaster,
|
||||
Checkbox
|
||||
} from '@harnessio/uicore'
|
||||
import React from 'react'
|
||||
import { useGet, useMutate } from 'restful-react'
|
||||
import cx from 'classnames'
|
||||
import { FontVariation, Intent } from '@harnessio/design-system'
|
||||
import * as yup from 'yup'
|
||||
import { Icon } from '@harnessio/icons'
|
||||
import { String, useStrings } from 'framework/strings'
|
||||
import type { EnumTriggerAction, OpenapiUpdateTriggerRequest, TypesTrigger } from 'services/code'
|
||||
import { LoadingSpinner } from 'components/LoadingSpinner/LoadingSpinner'
|
||||
import { NewTriggerModalButton } from 'components/NewTriggerModalButton/NewTriggerModalButton'
|
||||
import { getErrorMessage } from 'utils/Utils'
|
||||
import { useConfirmAct } from 'hooks/useConfirmAction'
|
||||
import css from './PipelineTriggersTab.module.scss'
|
||||
|
||||
type TriggerAction = {
|
||||
name: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export const triggerActions: TriggerAction[] = [
|
||||
{ name: 'Branch Created', value: 'branch_created' },
|
||||
{ name: 'Branch Updated', value: 'branch_updated' },
|
||||
{ name: 'Pull Request Branch Updated', value: 'pullreq_branch_updated' },
|
||||
{ name: 'Pull Request Created', value: 'pullreq_created' },
|
||||
{ name: 'Pull Request Reopened', value: 'pullreq_reopened' },
|
||||
{ name: 'Tag Created', value: 'tag_created' },
|
||||
{ name: 'Tag Updated', value: 'tag_updated' }
|
||||
]
|
||||
|
||||
interface TriggerMenuItemProps {
|
||||
name: string
|
||||
lastUpdated: number
|
||||
index: number
|
||||
setSelectedTrigger: (trigger: number) => void
|
||||
isSelected?: boolean
|
||||
}
|
||||
|
||||
const TriggerMenuItem = ({ name, lastUpdated, setSelectedTrigger, index, isSelected }: TriggerMenuItemProps) => {
|
||||
return (
|
||||
<Layout.Horizontal
|
||||
spacing={'medium'}
|
||||
className={cx(css.generalContainer, css.triggerMenuItem, { [css.selected]: isSelected })}
|
||||
flex
|
||||
padding={'large'}
|
||||
onClick={() => setSelectedTrigger(index)}>
|
||||
<Layout.Vertical spacing={'small'}>
|
||||
<Text className={css.triggerName}>{name}</Text>
|
||||
<Text className={css.triggerDate}>{`Last update: ${new Date(lastUpdated).toLocaleDateString('en-US', {
|
||||
month: 'short', // abbreviated month name
|
||||
day: '2-digit', // two-digit day
|
||||
year: 'numeric' // four-digit year
|
||||
})}`}</Text>
|
||||
</Layout.Vertical>
|
||||
<FlexExpander />
|
||||
<Layout.Horizontal
|
||||
spacing={'xsmall'}
|
||||
style={{ alignItems: 'center', borderRadius: '4px' }}
|
||||
className={css.pillContainer}>
|
||||
<Text className={css.pillText} font={{ size: 'xsmall' }}>
|
||||
Internal
|
||||
</Text>
|
||||
</Layout.Horizontal>
|
||||
</Layout.Horizontal>
|
||||
)
|
||||
}
|
||||
|
||||
interface TriggerDetailsProps {
|
||||
name: string
|
||||
repoPath: string
|
||||
pipeline: string
|
||||
refetchTriggers: () => void
|
||||
setSelectedTrigger: (trigger: number) => void
|
||||
initialDisabled: boolean
|
||||
initialActions: EnumTriggerAction[]
|
||||
}
|
||||
|
||||
export interface TriggerFormData {
|
||||
disabled: boolean
|
||||
actions: EnumTriggerAction[]
|
||||
}
|
||||
|
||||
const TriggerDetails = ({
|
||||
name,
|
||||
repoPath,
|
||||
pipeline,
|
||||
refetchTriggers,
|
||||
setSelectedTrigger,
|
||||
initialActions,
|
||||
initialDisabled
|
||||
}: TriggerDetailsProps) => {
|
||||
const { getString } = useStrings()
|
||||
const { showError, showSuccess } = useToaster()
|
||||
|
||||
const { mutate: updateTrigger, loading } = useMutate<TypesTrigger>({
|
||||
verb: 'PATCH',
|
||||
path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}/triggers/${name}`
|
||||
})
|
||||
const { mutate: deleteTrigger } = useMutate<TypesTrigger>({
|
||||
verb: 'DELETE',
|
||||
path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}/triggers/${name}`
|
||||
})
|
||||
|
||||
const confirmDeleteTrigger = useConfirmAct()
|
||||
|
||||
const handleSubmit = async (formData: TriggerFormData) => {
|
||||
try {
|
||||
const payload: OpenapiUpdateTriggerRequest = {
|
||||
actions: formData.actions,
|
||||
disabled: formData.disabled
|
||||
}
|
||||
await updateTrigger(payload)
|
||||
showSuccess(getString('triggers.updateSuccess'))
|
||||
refetchTriggers()
|
||||
} catch (exception) {
|
||||
showError(getErrorMessage(exception), 0, getString('triggers.failedToUpdate'))
|
||||
}
|
||||
}
|
||||
|
||||
const formInitialValues: TriggerFormData = {
|
||||
actions: initialActions,
|
||||
disabled: initialDisabled
|
||||
}
|
||||
|
||||
return (
|
||||
<Layout.Vertical className={cx(css.generalContainer, css.editTriggerContainer)} padding={'large'}>
|
||||
<Layout.Horizontal padding={{ top: 'medium', left: 'large', right: 'large' }}>
|
||||
<Text font={{ variation: FontVariation.H5 }} className={css.editTriggerTitle}>
|
||||
{name}
|
||||
</Text>
|
||||
<FlexExpander />
|
||||
<Layout.Horizontal
|
||||
spacing={'xsmall'}
|
||||
style={{ alignItems: 'center', borderRadius: '4px' }}
|
||||
className={css.pillContainer}>
|
||||
<Text className={css.pillText} font={{ size: 'xsmall' }}>
|
||||
Internal
|
||||
</Text>
|
||||
</Layout.Horizontal>
|
||||
</Layout.Horizontal>
|
||||
<Formik
|
||||
initialValues={formInitialValues}
|
||||
formName="editTrigger"
|
||||
enableReinitialize={true}
|
||||
validationSchema={yup.object().shape({
|
||||
actions: yup.array().of(yup.string()),
|
||||
disabled: yup.boolean()
|
||||
})}
|
||||
validateOnChange
|
||||
validateOnBlur
|
||||
onSubmit={handleSubmit}>
|
||||
{formik => (
|
||||
<FormikForm>
|
||||
<Container padding={'large'}>
|
||||
<Checkbox
|
||||
name="disabled"
|
||||
label={getString('triggers.disableTrigger')}
|
||||
checked={formik.values.disabled}
|
||||
onChange={event => {
|
||||
if (event.currentTarget.checked) {
|
||||
formik.setFieldValue('disabled', true)
|
||||
} else {
|
||||
formik.setFieldValue('disabled', false)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
</Container>
|
||||
<div className={css.separator} />
|
||||
<Container className={css.actionsContainer} padding={'large'}>
|
||||
{triggerActions.map(action => (
|
||||
<Checkbox
|
||||
key={action.name}
|
||||
name="actions"
|
||||
label={action.name}
|
||||
value={action.value}
|
||||
checked={formik.values.actions.includes(action.value as EnumTriggerAction)}
|
||||
onChange={event => {
|
||||
if (event.currentTarget.checked) {
|
||||
formik.setFieldValue('actions', [...formik.values.actions, action.value])
|
||||
} else {
|
||||
formik.setFieldValue(
|
||||
'actions',
|
||||
formik.values.actions.filter((value: string) => value !== action.value)
|
||||
)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
))}
|
||||
</Container>
|
||||
<div className={css.separator} />
|
||||
<Layout.Horizontal
|
||||
spacing="small"
|
||||
padding={{ top: 'large', left: 'large', right: 'large' }}
|
||||
style={{ alignItems: 'center' }}>
|
||||
<Button type="submit" text={getString('edit')} intent={Intent.PRIMARY} disabled={loading} />
|
||||
<Button
|
||||
text={getString('triggers.deleteTrigger')}
|
||||
intent={Intent.DANGER}
|
||||
variation={ButtonVariation.SECONDARY}
|
||||
onClick={() => {
|
||||
confirmDeleteTrigger({
|
||||
title: getString('triggers.deleteTrigger'),
|
||||
confirmText: getString('delete'),
|
||||
intent: Intent.DANGER,
|
||||
message: <String useRichText stringID="triggers.deleteTriggerConfirm" vars={{ name }} />,
|
||||
action: async () => {
|
||||
try {
|
||||
await deleteTrigger(null)
|
||||
refetchTriggers()
|
||||
setSelectedTrigger(0)
|
||||
showSuccess(getString('triggers.deleteTriggerSuccess', { name }))
|
||||
} catch (e) {
|
||||
showError(getString('triggers.deleteTriggerError'))
|
||||
}
|
||||
}
|
||||
})
|
||||
}}
|
||||
/>
|
||||
<FlexExpander />
|
||||
{loading && <Icon intent={Intent.PRIMARY} name="steps-spinner" size={16} />}
|
||||
</Layout.Horizontal>
|
||||
</FormikForm>
|
||||
)}
|
||||
</Formik>
|
||||
</Layout.Vertical>
|
||||
)
|
||||
}
|
||||
|
||||
interface PipelineTriggersTabsProps {
|
||||
pipeline: string
|
||||
repoPath: string
|
||||
}
|
||||
|
||||
const PipelineTriggersTabs = ({ repoPath, pipeline }: PipelineTriggersTabsProps) => {
|
||||
const { getString } = useStrings()
|
||||
|
||||
const { data, loading, refetch } = useGet<TypesTrigger[]>({
|
||||
path: `/api/v1/repos/${repoPath}/+/pipelines/${pipeline}/triggers`,
|
||||
lazy: !repoPath || !pipeline
|
||||
})
|
||||
|
||||
const [selectedTrigger, setSelectedTrigger] = React.useState<number>(0)
|
||||
|
||||
return (
|
||||
<>
|
||||
<LoadingSpinner visible={loading} />
|
||||
{data?.length && (
|
||||
<Layout.Horizontal padding={'large'}>
|
||||
<Layout.Vertical padding={'large'}>
|
||||
<NewTriggerModalButton
|
||||
modalTitle={getString('triggers.createTrigger')}
|
||||
text={getString('triggers.newTrigger')}
|
||||
variation={ButtonVariation.PRIMARY}
|
||||
icon="plus"
|
||||
onSuccess={() => refetch()}
|
||||
repoPath={repoPath}
|
||||
pipeline={pipeline}
|
||||
width="150px"
|
||||
/>
|
||||
<Layout.Vertical spacing={'large'} className={css.triggerList}>
|
||||
{data?.map((trigger, index) => (
|
||||
<TriggerMenuItem
|
||||
key={trigger.id}
|
||||
name={trigger.uid as string}
|
||||
lastUpdated={trigger.updated as number}
|
||||
setSelectedTrigger={setSelectedTrigger}
|
||||
index={index}
|
||||
isSelected={selectedTrigger === index}
|
||||
/>
|
||||
))}
|
||||
</Layout.Vertical>
|
||||
</Layout.Vertical>
|
||||
<div className={css.separator} />
|
||||
<Layout.Vertical padding={'large'}>
|
||||
<TriggerDetails
|
||||
name={data?.[selectedTrigger]?.uid as string}
|
||||
repoPath={repoPath}
|
||||
pipeline={pipeline}
|
||||
refetchTriggers={refetch}
|
||||
setSelectedTrigger={setSelectedTrigger}
|
||||
initialActions={data?.[selectedTrigger]?.actions as EnumTriggerAction[]}
|
||||
initialDisabled={data?.[selectedTrigger]?.disabled as boolean}
|
||||
/>
|
||||
</Layout.Vertical>
|
||||
</Layout.Horizontal>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export default PipelineTriggersTabs
|
@ -597,6 +597,19 @@ export interface StringsMap {
|
||||
title: string
|
||||
token: string
|
||||
tooltipRepoEdit: string
|
||||
'triggers.actions': string
|
||||
'triggers.createSuccess': string
|
||||
'triggers.createTrigger': string
|
||||
'triggers.deleteTrigger': string
|
||||
'triggers.deleteTriggerConfirm': string
|
||||
'triggers.deleteTriggerError': string
|
||||
'triggers.deleteTriggerSuccess': string
|
||||
'triggers.disableTrigger': string
|
||||
'triggers.enterTriggerName': string
|
||||
'triggers.failedToCreate': string
|
||||
'triggers.failedToUpdate': string
|
||||
'triggers.newTrigger': string
|
||||
'triggers.updateSuccess': string
|
||||
unrsolvedComment: string
|
||||
'unsavedChanges.leave': string
|
||||
'unsavedChanges.message': string
|
||||
|
@ -730,5 +730,19 @@ importSpace:
|
||||
githubOrg: GitHub Organization Name
|
||||
gitlabGroup: GitLab Group Name
|
||||
importProgress: 'Import in progress...'
|
||||
triggers:
|
||||
newTrigger: New Trigger
|
||||
createTrigger: Create a Trigger
|
||||
createSuccess: Trigger created successfully
|
||||
failedToCreate: Failed to create Trigger. Please try again.
|
||||
enterTriggerName: Enter Trigger name
|
||||
actions: Actions
|
||||
updateSuccess: Trigger updated successfully
|
||||
failedToUpdate: Failed to update Trigger. Please try again.
|
||||
deleteTrigger: Delete trigger
|
||||
disableTrigger: Disable trigger
|
||||
deleteTriggerConfirm: Are you sure you want to delete trigger <strong>{{name}}</strong>? You can't undo this action.
|
||||
deleteTriggerSuccess: Trigger {{name}} deleted.
|
||||
deleteTriggerError: Failed to delete Trigger. Please try again.
|
||||
step:
|
||||
select: Select a step
|
||||
|
@ -5,8 +5,6 @@ import { Get, GetProps, useGet, UseGetProps, Mutate, MutateProps, useMutate, Use
|
||||
|
||||
import { getConfig } from '../config'
|
||||
export const SPEC_VERSION = '0.0.0'
|
||||
export type EnumAccessGrant = number
|
||||
|
||||
export type EnumCIStatus = string
|
||||
|
||||
export type EnumCheckPayloadKind = '' | 'markdown' | 'pipeline' | 'raw'
|
||||
@ -270,7 +268,6 @@ export interface OpenapiCreateTemplateRequest {
|
||||
}
|
||||
|
||||
export interface OpenapiCreateTokenRequest {
|
||||
grants?: EnumAccessGrant
|
||||
lifetime?: TimeDuration
|
||||
uid?: string
|
||||
}
|
||||
@ -278,7 +275,7 @@ export interface OpenapiCreateTokenRequest {
|
||||
export interface OpenapiCreateTriggerRequest {
|
||||
actions?: EnumTriggerAction[] | null
|
||||
description?: string
|
||||
enabled?: boolean
|
||||
disabled?: boolean
|
||||
secret?: string
|
||||
uid?: string
|
||||
}
|
||||
@ -401,7 +398,7 @@ export interface OpenapiUpdateTemplateRequest {
|
||||
export interface OpenapiUpdateTriggerRequest {
|
||||
actions?: EnumTriggerAction[] | null
|
||||
description?: string | null
|
||||
enabled?: boolean | null
|
||||
disabled?: boolean | null
|
||||
secret?: string | null
|
||||
uid?: string | null
|
||||
}
|
||||
@ -660,6 +657,7 @@ export interface TypesPipeline {
|
||||
created_by?: number
|
||||
default_branch?: string
|
||||
description?: string
|
||||
disabled?: boolean
|
||||
execution?: TypesExecution
|
||||
id?: number
|
||||
repo_id?: number
|
||||
@ -779,6 +777,7 @@ export interface TypesRepository {
|
||||
|
||||
export interface TypesSecret {
|
||||
created?: number
|
||||
created_by?: number
|
||||
description?: string
|
||||
id?: number
|
||||
space_id?: number
|
||||
@ -872,7 +871,6 @@ export interface TypesTemplate {
|
||||
export interface TypesToken {
|
||||
created_by?: number
|
||||
expires_at?: number | null
|
||||
grants?: EnumAccessGrant
|
||||
issued_at?: number
|
||||
principal_id?: number
|
||||
type?: EnumTokenType
|
||||
@ -889,10 +887,11 @@ export interface TypesTrigger {
|
||||
created?: number
|
||||
created_by?: number
|
||||
description?: string
|
||||
enabled?: boolean
|
||||
disabled?: boolean
|
||||
id?: number
|
||||
pipeline_id?: number
|
||||
repo_id?: number
|
||||
trigger_type?: string
|
||||
uid?: string
|
||||
updated?: number
|
||||
}
|
||||
|
@ -6745,8 +6745,6 @@ components:
|
||||
type: object
|
||||
OpenapiCreateTokenRequest:
|
||||
properties:
|
||||
grants:
|
||||
$ref: '#/components/schemas/EnumAccessGrant'
|
||||
lifetime:
|
||||
$ref: '#/components/schemas/TimeDuration'
|
||||
uid:
|
||||
@ -6761,7 +6759,7 @@ components:
|
||||
type: array
|
||||
description:
|
||||
type: string
|
||||
enabled:
|
||||
disabled:
|
||||
type: boolean
|
||||
secret:
|
||||
type: string
|
||||
@ -6965,7 +6963,7 @@ components:
|
||||
description:
|
||||
nullable: true
|
||||
type: string
|
||||
enabled:
|
||||
disabled:
|
||||
nullable: true
|
||||
type: boolean
|
||||
secret:
|
||||
@ -7414,6 +7412,8 @@ components:
|
||||
type: string
|
||||
description:
|
||||
type: string
|
||||
disabled:
|
||||
type: boolean
|
||||
execution:
|
||||
$ref: '#/components/schemas/TypesExecution'
|
||||
id:
|
||||
@ -7634,6 +7634,8 @@ components:
|
||||
properties:
|
||||
created:
|
||||
type: integer
|
||||
created_by:
|
||||
type: integer
|
||||
description:
|
||||
type: string
|
||||
id:
|
||||
@ -7805,8 +7807,6 @@ components:
|
||||
expires_at:
|
||||
nullable: true
|
||||
type: integer
|
||||
grants:
|
||||
$ref: '#/components/schemas/EnumAccessGrant'
|
||||
issued_at:
|
||||
type: integer
|
||||
principal_id:
|
||||
@ -7836,7 +7836,7 @@ components:
|
||||
type: integer
|
||||
description:
|
||||
type: string
|
||||
enabled:
|
||||
disabled:
|
||||
type: boolean
|
||||
id:
|
||||
type: integer
|
||||
@ -7844,6 +7844,8 @@ components:
|
||||
type: integer
|
||||
repo_id:
|
||||
type: integer
|
||||
trigger_type:
|
||||
type: string
|
||||
uid:
|
||||
type: string
|
||||
updated:
|
||||
|
Loading…
Reference in New Issue
Block a user