Merge remote-tracking branch 'origin' into abhinav/CODE-852

This commit is contained in:
Abhinav Singh 2023-09-14 04:27:50 -07:00
commit 6e99fc903d
47 changed files with 1294 additions and 559 deletions

View File

@ -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)

View File

@ -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)

View File

@ -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
}

View File

@ -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
}

View File

@ -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 {

View File

@ -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
}

View File

@ -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
}

View File

@ -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
}

View File

@ -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(

View File

@ -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
}

View File

@ -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
View 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
}

View File

@ -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,
)
}

View File

@ -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",

View File

@ -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,
}
}

View File

@ -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
}

View File

@ -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
);

View File

@ -0,0 +1 @@
ALTER TABLE tokens ADD COLUMN token_grants BIGINT DEFAULT 0;

View File

@ -0,0 +1 @@
ALTER TABLE tokens DROP COLUMN token_grants;

View File

@ -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
);

View File

@ -0,0 +1 @@
ALTER TABLE tokens ADD COLUMN token_grants BIGINT DEFAULT 0;

View File

@ -0,0 +1 @@
ALTER TABLE tokens DROP COLUMN token_grants;

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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)
}

View File

@ -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
// }
}

View File

@ -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
}

View File

@ -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"`

View File

@ -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
}

View File

@ -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.

View File

@ -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;
}

View File

@ -0,0 +1,3 @@
/* eslint-disable */
// This is an auto-generated file
export declare const actionsContainer: string

View File

@ -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} />
}

View File

@ -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;
}

View File

@ -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

View File

@ -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>
)

View File

@ -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;
}

View 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

View 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

View File

@ -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;
}

View 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

View 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

View File

@ -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

View File

@ -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

View File

@ -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
}

View File

@ -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: