[Standalone] Add temporary JWT for pipeline executions (#480)

jobatzil/rename
Johannes Batzill 2023-09-14 08:54:03 +00:00 committed by Harness
parent fedd04a2da
commit 8cd3e5d015
32 changed files with 488 additions and 358 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

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

@ -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
}
@ -872,7 +869,6 @@ export interface TypesTemplate {
export interface TypesToken {
created_by?: number
expires_at?: number | null
grants?: EnumAccessGrant
issued_at?: number
principal_id?: number
type?: EnumTokenType

View File

@ -6277,8 +6277,6 @@ paths:
- user
components:
schemas:
EnumAccessGrant:
type: integer
EnumCIStatus:
type: string
EnumCheckPayloadKind:
@ -6745,8 +6743,6 @@ components:
type: object
OpenapiCreateTokenRequest:
properties:
grants:
$ref: '#/components/schemas/EnumAccessGrant'
lifetime:
$ref: '#/components/schemas/TimeDuration'
uid:
@ -7805,8 +7801,6 @@ components:
expires_at:
nullable: true
type: integer
grants:
$ref: '#/components/schemas/EnumAccessGrant'
issued_at:
type: integer
principal_id: