feat(coderepos): configure a per-provider SCM credential

Users had no way to supply their own access token: the only credential in
play was the OAuth token captured at login, which expires and which the
builders then copied into their own table. The clone credential table
existed but had no write path, so every lookup fell back to that token.

Give each provider an explicit credential:

- GET/PUT/DELETE /api/v1/coderepos/{provider}/credential plus
  POST .../credential/validate; the read endpoint returns a masked token and
  "has" flags, never the secret
- UpsertCodeRepositoryCredential validates a token against the provider,
  resolves the account id from it, ensures the third-party binding exists
  (409 when the account already belongs to another user) and seals
  token/ssh_key/password before storing them
- only token credentials can list repositories: ssh and username/password
  are clone credentials, so validate reports validated=false for them and
  no sync is queued
- ListCodeRepositoryProviders returns the configured providers joined with
  the caller's binding, credential and last sync state, so a provider that
  is configured but not linked yet is still offered by the UI
- fix the AccountID and UpdatedAt mistakes in the user3rdparty response,
  which dereferenced a nil pointer and reported the creation time twice
- regenerate the coderepos and user repository mocks and the operator
  swagger spec

Verification:
- make gormgen
- make swagen
- CI_DATABASE_TYPE=turso go test -tags "netgo,timetzdata,exclude_graphdriver_btrfs,containers_image_openpgp" ./pkg/service/coderepos/... ./pkg/server/handlers/coderepos/...
This commit is contained in:
Tosone committed 2026-10-27 21:07:37 +08:00
1 parent 3cb76aae80
commit a80519d86f
11 files changed
+1330 -26

No files matched your search

+58 -2
View File
@@ -141,9 +141,65 @@ type PutUserSelfRequest struct {
Email *string `json:"email,omitempty" validate:"omitempty,is_valid_email" example:"test@mail.com"`
}
// ListCodeRepositoryProvidersResponse represents an enabled source code provider.
// ListCodeRepositoryProvidersResponse describes one source code provider together with the
// caller's state: whether the provider is usable for OAuth login, whether the account is
// linked and whether an SCM credential is configured, plus the last sync result.
type ListCodeRepositoryProvidersResponse struct {
Provider enums.Provider `json:"provider" example:"github"`
Provider enums.Provider `json:"provider" example:"github"`
DisplayName string `json:"display_name" example:"GitHub"`
BaseURL string `json:"base_url" example:"https://github.com"`
OauthEnabled bool `json:"oauth_enabled"`
Linked bool `json:"linked"`
AccountID *string `json:"account_id"`
CredentialType enums.ScmCredentialType `json:"credential_type" example:"none"`
HasCredential bool `json:"has_credential"`
CrLastUpdateTimestamp string `json:"cr_last_update_timestamp"`
CrLastUpdateStatus enums.TaskCommonStatus `json:"cr_last_update_status"`
CrLastUpdateMessage *string `json:"cr_last_update_message"`
}
// GetCodeRepositoryCredentialRequest is the request for getting the SCM credential of a provider.
type GetCodeRepositoryCredentialRequest struct {
Provider enums.Provider `json:"provider" param:"provider" validate:"required,is_valid_provider"`
}
// GetCodeRepositoryCredentialResponse returns credential metadata. Secrets are never echoed
// back: only a masked token and "has" flags are exposed.
type GetCodeRepositoryCredentialResponse struct {
Type enums.ScmCredentialType `json:"type" example:"token"`
Token *string `json:"token" example:"ghp_****abcd"`
Username *string `json:"username" example:"sigma"`
HasSshKey bool `json:"has_ssh_key"`
HasPassword bool `json:"has_password"`
CreatedAt string `json:"created_at" example:"2006-01-02 15:04:05"`
UpdatedAt string `json:"updated_at" example:"2006-01-02 15:04:05"`
}
// UpdateCodeRepositoryCredentialRequest creates or replaces the SCM credential of a provider.
type UpdateCodeRepositoryCredentialRequest struct {
Provider enums.Provider `json:"provider" param:"provider" validate:"required,is_valid_provider"`
Type enums.ScmCredentialType `json:"type" validate:"required,is_valid_scm_credential_type" example:"token"`
Token *string `json:"token" validate:"required_if=Type token,max=1024" example:"ghp_xxx"`
SshKey *string `json:"ssh_key" validate:"required_if=Type ssh,max=8192" example:"-----BEGIN OPENSSH PRIVATE KEY-----"`
Username *string `json:"username" validate:"required_if=Type username,max=256" example:"sigma"`
Password *string `json:"password" validate:"required_if=Type username,max=512" example:"sigma"`
}
// DeleteCodeRepositoryCredentialRequest clears the SCM credential of a provider.
type DeleteCodeRepositoryCredentialRequest struct {
Provider enums.Provider `json:"provider" param:"provider" validate:"required,is_valid_provider"`
}
// ValidateCodeRepositoryCredentialResponse reports the account a credential resolves to.
type ValidateCodeRepositoryCredentialResponse struct {
Provider enums.Provider `json:"provider" example:"github"`
Type enums.ScmCredentialType `json:"type" example:"token"`
AccountID string `json:"account_id" example:"1234567"`
Username string `json:"username" example:"sigma"`
// Validated reports whether the credential was verified against the provider API.
// SSH keys and username/password credentials are only usable for cloning, so they
// cannot be verified.
Validated bool `json:"validated"`
}
// GetCodeRepositoryResyncRequest is the request for resynchronizing source code repositories.
@@ -0,0 +1,188 @@
// Copyright 2026 sigma
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package coderepos
import (
"log/slog"
"net/http"
"time"
"github.com/gin-gonic/gin"
"github.com/go-sigma/sigma/pkg/api"
"github.com/go-sigma/sigma/pkg/consts"
"github.com/go-sigma/sigma/pkg/dal/models"
"github.com/go-sigma/sigma/pkg/server/errcode"
"github.com/go-sigma/sigma/pkg/utils"
)
// maskedSecretLength is the number of leading and trailing characters kept when masking a secret.
const maskedSecretLength = 4
// GetCredential gets the SCM credential of a provider
//
// @Summary Get code repository credential
// @security BasicAuth
// @Tags CodeRepository
// @Accept json
// @Produce json
// @Router /coderepos/{provider}/credential [get]
// @Param provider path string true "Get the SCM credential of the provider"
// @Success 200 {object} api.GetCodeRepositoryCredentialResponse
// @Failure 404 {object} errcode.ErrCode
// @Failure 500 {object} errcode.ErrCode
func (h *handler) GetCredential(c *gin.Context, req *api.GetCodeRepositoryCredentialRequest) {
ctx := c.Request.Context()
user, ok := utils.GetFromCtx[*models.User](c, consts.ContextUser)
if !ok {
slog.Error("get user from context failed")
errcode.NewHTTPError(c, errcode.HTTPErrCodeUnauthorized)
return
}
credentialObj, err := h.CodeRepoSvc.GetCodeRepositoryCredential(ctx, user.ID, req.Provider)
if err != nil {
if e, ok := errcode.AsType[errcode.ErrCode](err); ok {
errcode.NewHTTPError(c, e)
return
}
errcode.NewHTTPError(c, errcode.HTTPErrCodeInternalError)
return
}
c.JSON(http.StatusOK, api.GetCodeRepositoryCredentialResponse{
Type: credentialObj.Type,
Token: maskSecret(credentialObj.Token),
Username: credentialObj.Username,
HasSshKey: credentialObj.SshKey != nil,
HasPassword: credentialObj.Password != nil,
CreatedAt: time.UnixMilli(credentialObj.CreatedAt).UTC().Format(consts.DefaultTimePattern),
UpdatedAt: time.UnixMilli(credentialObj.UpdatedAt).UTC().Format(consts.DefaultTimePattern),
})
}
// UpdateCredential creates or replaces the SCM credential of a provider
//
// @Summary Update code repository credential
// @security BasicAuth
// @Tags CodeRepository
// @Accept json
// @Produce json
// @Router /coderepos/{provider}/credential [put]
// @Param provider path string true "Update the SCM credential of the provider"
// @Param body body api.UpdateCodeRepositoryCredentialRequest true "SCM credential"
// @Success 200
// @Failure 400 {object} errcode.ErrCode
// @Failure 409 {object} errcode.ErrCode
// @Failure 500 {object} errcode.ErrCode
func (h *handler) UpdateCredential(c *gin.Context, req *api.UpdateCodeRepositoryCredentialRequest) {
ctx := c.Request.Context()
user, ok := utils.GetFromCtx[*models.User](c, consts.ContextUser)
if !ok {
slog.Error("get user from context failed")
errcode.NewHTTPError(c, errcode.HTTPErrCodeUnauthorized)
return
}
err := h.CodeRepoSvc.UpsertCodeRepositoryCredential(ctx, user.ID, *req)
if err != nil {
if e, ok := errcode.AsType[errcode.ErrCode](err); ok {
errcode.NewHTTPError(c, e)
return
}
errcode.NewHTTPError(c, errcode.HTTPErrCodeInternalError)
return
}
c.Status(http.StatusOK)
}
// DeleteCredential clears the SCM credential of a provider
//
// @Summary Delete code repository credential
// @security BasicAuth
// @Tags CodeRepository
// @Accept json
// @Produce json
// @Router /coderepos/{provider}/credential [delete]
// @Param provider path string true "Delete the SCM credential of the provider"
// @Success 204
// @Failure 404 {object} errcode.ErrCode
// @Failure 500 {object} errcode.ErrCode
func (h *handler) DeleteCredential(c *gin.Context, req *api.DeleteCodeRepositoryCredentialRequest) {
ctx := c.Request.Context()
user, ok := utils.GetFromCtx[*models.User](c, consts.ContextUser)
if !ok {
slog.Error("get user from context failed")
errcode.NewHTTPError(c, errcode.HTTPErrCodeUnauthorized)
return
}
err := h.CodeRepoSvc.DeleteCodeRepositoryCredential(ctx, user.ID, req.Provider)
if err != nil {
if e, ok := errcode.AsType[errcode.ErrCode](err); ok {
errcode.NewHTTPError(c, e)
return
}
errcode.NewHTTPError(c, errcode.HTTPErrCodeInternalError)
return
}
c.Status(http.StatusNoContent)
}
// ValidateCredential verifies the SCM credential of a provider without storing it
//
// @Summary Validate code repository credential
// @security BasicAuth
// @Tags CodeRepository
// @Accept json
// @Produce json
// @Router /coderepos/{provider}/credential/validate [post]
// @Param provider path string true "Validate the SCM credential of the provider"
// @Param body body api.UpdateCodeRepositoryCredentialRequest true "SCM credential"
// @Success 200 {object} api.ValidateCodeRepositoryCredentialResponse
// @Failure 400 {object} errcode.ErrCode
// @Failure 500 {object} errcode.ErrCode
func (h *handler) ValidateCredential(c *gin.Context, req *api.UpdateCodeRepositoryCredentialRequest) {
ctx := c.Request.Context()
resp, err := h.CodeRepoSvc.ValidateCodeRepositoryCredential(ctx, *req)
if err != nil {
if e, ok := errcode.AsType[errcode.ErrCode](err); ok {
errcode.NewHTTPError(c, e)
return
}
errcode.NewHTTPError(c, errcode.HTTPErrCodeInternalError)
return
}
c.JSON(http.StatusOK, resp)
}
// maskSecret returns a masked representation of a secret, keeping only its first and last
// characters, so that the API never echoes a stored secret back.
func maskSecret(secret *string) *string {
if secret == nil || *secret == "" {
return nil
}
runes := []rune(*secret)
if len(runes) < 2*maskedSecretLength+1 {
masked := "****"
return &masked
}
masked := string(runes[:maskedSecretLength]) + "****" + string(runes[len(runes)-maskedSecretLength:])
return &masked
}
@@ -49,7 +49,7 @@ func (h *handler) Providers(c *gin.Context) {
return
}
user3rdPartyObjs, err := h.CodeRepoSvc.ListCodeRepositoryProviders(ctx, user.ID)
providers, err := h.CodeRepoSvc.ListCodeRepositoryProviders(ctx, user.ID)
if err != nil {
if e, ok := errcode.AsType[errcode.ErrCode](err); ok {
errcode.NewHTTPError(c, e)
@@ -58,11 +58,9 @@ func (h *handler) Providers(c *gin.Context) {
errcode.NewHTTPError(c, errcode.HTTPErrCodeInternalError, fmt.Sprintf("List providers failed: %v", err))
return
}
resp := make([]any, 0, len(user3rdPartyObjs))
for _, user3rdPartyObj := range user3rdPartyObjs {
resp = append(resp, api.ListCodeRepositoryProvidersResponse{
Provider: user3rdPartyObj.Provider,
})
resp := make([]any, 0, len(providers))
for _, provider := range providers {
resp = append(resp, provider)
}
c.JSON(http.StatusOK, api.CommonList{Total: int64(len(user3rdPartyObjs)), Items: resp})
c.JSON(http.StatusOK, api.CommonList{Total: int64(len(providers)), Items: resp})
}
+102 -7
View File
@@ -263,14 +263,16 @@ func TestProviders(t *testing.T) {
serviceObj := codereposmocks.NewMockCodeRepositoryService(ctrl)
serviceObj.EXPECT().
ListCodeRepositoryProviders(gomock.Any(), "10").
DoAndReturn(func(_ context.Context, userID string) ([]*models.User3rdParty, error) {
DoAndReturn(func(_ context.Context, userID string) ([]api.ListCodeRepositoryProvidersResponse, error) {
require.Equal(t, "10", userID)
return []*models.User3rdParty{{
ID: "1",
UserID: "10",
Provider: enums.ProviderGithub,
CreatedAt: 1,
UpdatedAt: 1,
return []api.ListCodeRepositoryProvidersResponse{{
Provider: enums.ProviderGithub,
DisplayName: "GitHub",
BaseURL: "https://github.com",
OauthEnabled: true,
Linked: true,
AccountID: new("account-1"),
CredentialType: enums.ScmCredentialTypeNone,
}}, nil
})
@@ -330,3 +332,96 @@ func newCodereposContext(t *testing.T) (*httptest.ResponseRecorder, *gin.Context
c.Set(consts.ContextUser, &models.User{ID: "10", Role: enums.UserRoleAdmin})
return recorder, c
}
func TestGetCredential(t *testing.T) {
ctrl := gomock.NewController(t)
serviceObj := codereposmocks.NewMockCodeRepositoryService(ctrl)
serviceObj.EXPECT().
GetCodeRepositoryCredential(gomock.Any(), "10", enums.ProviderGithub).
Return(&models.CodeRepositoryCloneCredential{
ID: "1",
Type: enums.ScmCredentialTypeToken,
Token: new("ghp_1234567890abcd"),
CreatedAt: 1,
UpdatedAt: 2,
}, nil)
recorder, c := newCodereposContext(t)
(&handler{CodeRepoSvc: serviceObj}).GetCredential(c, &api.GetCodeRepositoryCredentialRequest{Provider: enums.ProviderGithub})
c.Writer.WriteHeaderNow()
require.Equal(t, http.StatusOK, recorder.Code)
require.Contains(t, recorder.Body.String(), `"token":"ghp_****abcd"`)
require.NotContains(t, recorder.Body.String(), "ghp_1234567890abcd")
}
func TestGetCredentialReturnsNotFound(t *testing.T) {
ctrl := gomock.NewController(t)
serviceObj := codereposmocks.NewMockCodeRepositoryService(ctrl)
serviceObj.EXPECT().
GetCodeRepositoryCredential(gomock.Any(), "10", enums.ProviderGithub).
Return(nil, errcode.HTTPErrCodeNotFound)
recorder, c := newCodereposContext(t)
(&handler{CodeRepoSvc: serviceObj}).GetCredential(c, &api.GetCodeRepositoryCredentialRequest{Provider: enums.ProviderGithub})
c.Writer.WriteHeaderNow()
require.Equal(t, http.StatusNotFound, recorder.Code)
}
func TestUpdateCredential(t *testing.T) {
ctrl := gomock.NewController(t)
serviceObj := codereposmocks.NewMockCodeRepositoryService(ctrl)
serviceObj.EXPECT().
UpsertCodeRepositoryCredential(gomock.Any(), "10", gomock.Any()).
Return(nil)
recorder, c := newCodereposContext(t)
(&handler{CodeRepoSvc: serviceObj}).UpdateCredential(c, &api.UpdateCodeRepositoryCredentialRequest{
Provider: enums.ProviderGithub,
Type: enums.ScmCredentialTypeToken,
Token: new("ghp_sigma"),
})
c.Writer.WriteHeaderNow()
require.Equal(t, http.StatusOK, recorder.Code)
}
func TestDeleteCredential(t *testing.T) {
ctrl := gomock.NewController(t)
serviceObj := codereposmocks.NewMockCodeRepositoryService(ctrl)
serviceObj.EXPECT().
DeleteCodeRepositoryCredential(gomock.Any(), "10", enums.ProviderGithub).
Return(nil)
recorder, c := newCodereposContext(t)
(&handler{CodeRepoSvc: serviceObj}).DeleteCredential(c, &api.DeleteCodeRepositoryCredentialRequest{Provider: enums.ProviderGithub})
c.Writer.WriteHeaderNow()
require.Equal(t, http.StatusNoContent, recorder.Code)
}
func TestValidateCredential(t *testing.T) {
ctrl := gomock.NewController(t)
serviceObj := codereposmocks.NewMockCodeRepositoryService(ctrl)
serviceObj.EXPECT().
ValidateCodeRepositoryCredential(gomock.Any(), gomock.Any()).
Return(&api.ValidateCodeRepositoryCredentialResponse{
Provider: enums.ProviderGithub,
Type: enums.ScmCredentialTypeToken,
AccountID: "1",
Username: "sigma",
Validated: true,
}, nil)
recorder, c := newCodereposContext(t)
(&handler{CodeRepoSvc: serviceObj}).ValidateCredential(c, &api.UpdateCodeRepositoryCredentialRequest{
Provider: enums.ProviderGithub,
Type: enums.ScmCredentialTypeToken,
Token: new("ghp_sigma"),
})
c.Writer.WriteHeaderNow()
require.Equal(t, http.StatusOK, recorder.Code)
require.Contains(t, recorder.Body.String(), `"account_id":"1"`)
}
+12
View File
@@ -43,6 +43,14 @@ type Handler interface {
Providers(c *gin.Context)
// User3rdParty get user 3rdparty
User3rdParty(c *gin.Context, req *api.GetCodeRepositoryUser3rdPartyRequest)
// GetCredential gets the SCM credential of the provider
GetCredential(c *gin.Context, req *api.GetCodeRepositoryCredentialRequest)
// UpdateCredential creates or replaces the SCM credential of the provider
UpdateCredential(c *gin.Context, req *api.UpdateCodeRepositoryCredentialRequest)
// DeleteCredential clears the SCM credential of the provider
DeleteCredential(c *gin.Context, req *api.DeleteCodeRepositoryCredentialRequest)
// ValidateCredential verifies the SCM credential of the provider
ValidateCredential(c *gin.Context, req *api.UpdateCodeRepositoryCredentialRequest)
}
var _ Handler = &handler{}
@@ -62,6 +70,10 @@ func Initialize(e *gin.Engine, h handler) error {
codereposGroup.GET("/:provider", server.WrapRequest(h.List))
codereposGroup.GET("/:provider/repos/:repo_id", server.WrapRequest(h.Get))
codereposGroup.GET("/:provider/user3rdparty", server.WrapRequest(h.User3rdParty))
codereposGroup.GET("/:provider/credential", server.WrapRequest(h.GetCredential))
codereposGroup.PUT("/:provider/credential", server.WrapRequest(h.UpdateCredential))
codereposGroup.DELETE("/:provider/credential", server.WrapRequest(h.DeleteCredential))
codereposGroup.POST("/:provider/credential/validate", server.WrapRequest(h.ValidateCredential))
codereposGroup.GET("/:provider/resync", server.WrapRequest(h.Resync))
codereposGroup.GET("/:provider/owners", server.WrapRequest(h.ListOwners))
codereposGroup.GET("/:provider/repos/:repo_id/branches", server.WrapRequest(h.ListBranches))
@@ -41,7 +41,7 @@ func TestInitialize(t *testing.T) {
require.NoError(t, digCon.Invoke(Initialize))
require.NoError(t, digCon.Invoke(func(engine *gin.Engine) {
routes := engine.Routes()
require.Len(t, routes, 8)
require.Len(t, routes, 12)
require.True(t, hasRoute(routes, http.MethodGet, "/api/v1/coderepos/providers"))
require.True(t, hasRoute(routes, http.MethodGet, "/api/v1/coderepos/:provider/repos/:repo_id/branches/:name"))
}))
+99 -7
View File
@@ -26,12 +26,15 @@ import (
"github.com/go-sigma/sigma/pkg/api"
"github.com/go-sigma/sigma/pkg/api/enums"
"github.com/go-sigma/sigma/pkg/config"
"github.com/go-sigma/sigma/pkg/consts"
"github.com/go-sigma/sigma/pkg/dal/models"
"github.com/go-sigma/sigma/pkg/dal/query"
repocoderepo "github.com/go-sigma/sigma/pkg/dal/repository/coderepo"
repouser "github.com/go-sigma/sigma/pkg/dal/repository/user"
"github.com/go-sigma/sigma/pkg/infra/workq"
"github.com/go-sigma/sigma/pkg/server/errcode"
svccredential "github.com/go-sigma/sigma/pkg/service/credential"
"github.com/go-sigma/sigma/pkg/utils"
)
@@ -51,18 +54,30 @@ type Service interface {
GetCodeRepositoryBranch(ctx context.Context, codeRepositoryID string, name string) (*models.CodeRepositoryBranch, error)
// ResyncCodeRepositories resync all code repositories for the user's 3rd party (includes: update user status + produce daemon task).
ResyncCodeRepositories(ctx context.Context, userID string, provider enums.Provider) error
// ListCodeRepositoryProviders lists code repository providers (user 3rd party).
ListCodeRepositoryProviders(ctx context.Context, userID string) ([]*models.User3rdParty, error)
// ListCodeRepositoryProviders lists the configured source code providers together with
// the caller's binding, credential and last sync state.
ListCodeRepositoryProviders(ctx context.Context, userID string) ([]api.ListCodeRepositoryProvidersResponse, error)
// GetCodeRepositoryUser3rdParty gets the user 3rd party by provider.
GetCodeRepositoryUser3rdParty(ctx context.Context, userID string, provider enums.Provider) (*models.User3rdParty, error)
// GetCodeRepositoryCredential gets the SCM credential metadata of a provider.
GetCodeRepositoryCredential(ctx context.Context, userID string, provider enums.Provider) (*models.CodeRepositoryCloneCredential, error)
// UpsertCodeRepositoryCredential validates and stores the SCM credential of a provider,
// then triggers a sync.
UpsertCodeRepositoryCredential(ctx context.Context, userID string, req api.UpdateCodeRepositoryCredentialRequest) error
// DeleteCodeRepositoryCredential clears the SCM credential of a provider.
DeleteCodeRepositoryCredential(ctx context.Context, userID string, provider enums.Provider) error
// ValidateCodeRepositoryCredential verifies a credential without storing it.
ValidateCodeRepositoryCredential(ctx context.Context, req api.UpdateCodeRepositoryCredentialRequest) (*api.ValidateCodeRepositoryCredentialResponse, error)
}
type service struct {
dig.In
RepoCode repocoderepo.CodeRepositoryRepository
RepoUser repouser.UserRepository
Producer workq.Producer
RepoCode repocoderepo.CodeRepositoryRepository
RepoUser repouser.UserRepository
Producer workq.Producer
Config *config.Configuration
SvcCredential svccredential.Service
}
func NewService(params service) Service {
@@ -184,13 +199,90 @@ func (s *service) ResyncCodeRepositories(ctx context.Context, userID string, pro
return nil
}
func (s *service) ListCodeRepositoryProviders(ctx context.Context, userID string) ([]*models.User3rdParty, error) {
// ListCodeRepositoryProviders lists the configured source code providers together with the
// caller's binding, credential and last sync state.
//
// Providers come from the configuration (not from the user's bindings), so a provider that
// is configured but not linked yet is still listed: the web UI needs it to offer the
// "configure access token" action.
func (s *service) ListCodeRepositoryProviders(ctx context.Context, userID string) ([]api.ListCodeRepositoryProvidersResponse, error) {
user3rdPartyObjs, err := s.RepoUser.ListUser3rdParty(ctx, userID)
if err != nil {
slog.Error("list providers failed", "err", err)
return nil, errcode.HTTPErrCodeInternalError.Detail(fmt.Sprintf("List providers failed: %v", err))
}
return user3rdPartyObjs, nil
bindings := make(map[enums.Provider]*models.User3rdParty, len(user3rdPartyObjs))
for _, user3rdPartyObj := range user3rdPartyObjs {
bindings[user3rdPartyObj.Provider] = user3rdPartyObj
}
scmProviders := s.Config.ResolveScmProviders()
resp := make([]api.ListCodeRepositoryProvidersResponse, 0, len(scmProviders))
for _, scmProvider := range scmProviders {
provider := enums.Provider(scmProvider.Provider.String())
item := api.ListCodeRepositoryProvidersResponse{
Provider: provider,
DisplayName: scmProviderDisplayName(scmProvider.Provider),
BaseURL: scmProvider.BaseURL,
OauthEnabled: s.oauthEnabled(provider),
CredentialType: enums.ScmCredentialTypeNone,
CrLastUpdateStatus: enums.TaskCommonStatusPending,
CrLastUpdateTimestamp: time.Unix(0, 0).UTC().Format(consts.DefaultTimePattern),
}
user3rdPartyObj, ok := bindings[provider]
if !ok {
resp = append(resp, item)
continue
}
item.Linked = true
item.AccountID = user3rdPartyObj.AccountID
item.CrLastUpdateTimestamp = time.UnixMilli(user3rdPartyObj.CrLastUpdateTimestamp).UTC().Format(consts.DefaultTimePattern)
item.CrLastUpdateStatus = user3rdPartyObj.CrLastUpdateStatus
item.CrLastUpdateMessage = user3rdPartyObj.CrLastUpdateMessage
credentialObj, err := s.RepoCode.GetCloneCredential(ctx, user3rdPartyObj.ID)
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
slog.Error("get code repository credential failed", "err", err, "user_3rdparty_id", user3rdPartyObj.ID)
return nil, errcode.HTTPErrCodeInternalError.Detail(fmt.Sprintf("Get code repository credential failed: %v", err))
}
resp = append(resp, item)
continue
}
if credentialObj.Type != enums.ScmCredentialTypeNone {
item.CredentialType = credentialObj.Type
item.HasCredential = true
}
resp = append(resp, item)
}
return resp, nil
}
// oauthEnabled reports whether the provider can be used to sign in.
func (s *service) oauthEnabled(provider enums.Provider) bool {
switch provider {
case enums.ProviderGithub:
return s.Config.Auth.Oauth2.Github.Enabled
case enums.ProviderGitlab:
return s.Config.Auth.Oauth2.Gitlab.Enabled
case enums.ProviderGitea:
return s.Config.Auth.Oauth2.Gitea.Enabled
}
return false
}
// scmProviderDisplayName returns the human readable name of a source code provider.
func scmProviderDisplayName(provider enums.ScmProvider) string {
switch provider {
case enums.ScmProviderGithub:
return "GitHub"
case enums.ScmProviderGitlab:
return "GitLab"
case enums.ScmProviderGitea:
return "Gitea"
}
return provider.String()
}
func (s *service) GetCodeRepositoryUser3rdParty(ctx context.Context, userID string, provider enums.Provider) (*models.User3rdParty, error) {
+317
View File
@@ -0,0 +1,317 @@
// Copyright 2026 sigma
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package coderepos
import (
"context"
"errors"
"fmt"
"log/slog"
"strconv"
"time"
"code.gitea.io/sdk/gitea"
"github.com/google/go-github/v74/github"
gitlab "gitlab.com/gitlab-org/api/client-go"
"gorm.io/gorm"
"github.com/go-sigma/sigma/pkg/api"
"github.com/go-sigma/sigma/pkg/api/enums"
"github.com/go-sigma/sigma/pkg/config"
"github.com/go-sigma/sigma/pkg/dal/models"
"github.com/go-sigma/sigma/pkg/dal/query"
"github.com/go-sigma/sigma/pkg/server/errcode"
"github.com/go-sigma/sigma/pkg/utils/ptr"
"github.com/go-sigma/sigma/pkg/utils/uuid"
)
// scmAccount is the provider account a credential resolves to.
type scmAccount struct {
ID string
Username string
}
// GetCodeRepositoryCredential gets the SCM credential metadata of a provider.
//
// Secrets are not decrypted here: the response only carries the credential type, a masked
// token and "has" flags, so that the API never echoes a secret back.
func (s *service) GetCodeRepositoryCredential(ctx context.Context, userID string, provider enums.Provider) (*models.CodeRepositoryCloneCredential, error) {
user3rdPartyObj, err := s.RepoUser.GetUser3rdPartyByProvider(ctx, userID, provider)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errcode.HTTPErrCodeNotFound.Detail(fmt.Sprintf("No %s account is linked", provider.String()))
}
slog.Error("get user 3rdparty by provider failed", "err", err, "provider", provider.String())
return nil, errcode.HTTPErrCodeInternalError.Detail(fmt.Sprintf("Get user 3rdparty by provider failed: %v", err))
}
credentialObj, err := s.RepoCode.GetCloneCredential(ctx, user3rdPartyObj.ID)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, errcode.HTTPErrCodeNotFound.Detail(fmt.Sprintf("No credential is configured for %s", provider.String()))
}
slog.Error("get code repository credential failed", "err", err, "provider", provider.String())
return nil, errcode.HTTPErrCodeInternalError.Detail(fmt.Sprintf("Get code repository credential failed: %v", err))
}
return credentialObj, nil
}
// UpsertCodeRepositoryCredential validates and stores the SCM credential of a provider, then triggers a sync.
func (s *service) UpsertCodeRepositoryCredential(ctx context.Context, userID string, req api.UpdateCodeRepositoryCredentialRequest) error {
scmProvider, err := s.mustScmProvider(req.Provider)
if err != nil {
return err
}
if err = validateCredentialRequest(req); err != nil {
return err
}
// A token is the only credential type that can list repositories, so it is the only
// one verified against the provider and used to discover the linked account.
var account *scmAccount
if req.Type == enums.ScmCredentialTypeToken {
account, err = fetchScmAccount(ctx, req.Provider, scmProvider.BaseURL, ptr.To(req.Token))
if err != nil {
return err
}
}
user3rdPartyObj, err := s.ensureUser3rdParty(ctx, userID, req.Provider, account)
if err != nil {
return err
}
credentialObj := &models.CodeRepositoryCloneCredential{
ID: uuid.NewV7String(),
User3rdPartyID: user3rdPartyObj.ID,
Type: req.Type,
Username: req.Username,
}
for _, field := range []struct {
name string
plain *string
dst **string
}{
{name: "token", plain: req.Token, dst: &credentialObj.Token},
{name: "password", plain: req.Password, dst: &credentialObj.Password},
{name: "ssh key", plain: req.SshKey, dst: &credentialObj.SshKey},
} {
if field.plain == nil || *field.plain == "" {
continue
}
sealed, err := s.SvcCredential.Seal(*field.plain)
if err != nil {
slog.Error("seal code repository credential failed", "err", err, "field", field.name)
return errcode.HTTPErrCodeInternalError.Detail("Seal code repository credential failed")
}
*field.dst = &sealed
}
err = s.RepoCode.UpsertCredential(ctx, credentialObj)
if err != nil {
slog.Error("upsert code repository credential failed", "err", err, "provider", req.Provider.String())
return errcode.HTTPErrCodeInternalError.Detail(fmt.Sprintf("Upsert code repository credential failed: %v", err))
}
// Only token credentials can list repositories; SSH and username/password are clone
// credentials, so syncing them would only produce a failure message.
if req.Type == enums.ScmCredentialTypeToken {
s.publishSync(ctx, user3rdPartyObj.ID)
}
return nil
}
// DeleteCodeRepositoryCredential clears the SCM credential of a provider.
func (s *service) DeleteCodeRepositoryCredential(ctx context.Context, userID string, provider enums.Provider) error {
if _, err := s.mustScmProvider(provider); err != nil {
return err
}
user3rdPartyObj, err := s.RepoUser.GetUser3rdPartyByProvider(ctx, userID, provider)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return errcode.HTTPErrCodeNotFound.Detail(fmt.Sprintf("No %s account is linked", provider.String()))
}
slog.Error("get user 3rdparty by provider failed", "err", err, "provider", provider.String())
return errcode.HTTPErrCodeInternalError.Detail(fmt.Sprintf("Get user 3rdparty by provider failed: %v", err))
}
err = s.RepoCode.DeleteCredential(ctx, user3rdPartyObj.ID)
if err != nil {
slog.Error("delete code repository credential failed", "err", err, "provider", provider.String())
return errcode.HTTPErrCodeInternalError.Detail(fmt.Sprintf("Delete code repository credential failed: %v", err))
}
return nil
}
// ValidateCodeRepositoryCredential verifies a credential without storing it.
//
// Only token credentials can be verified: SSH keys and username/password credentials are
// used by the builder when cloning and are never sent to a provider API here.
func (s *service) ValidateCodeRepositoryCredential(ctx context.Context, req api.UpdateCodeRepositoryCredentialRequest) (*api.ValidateCodeRepositoryCredentialResponse, error) {
scmProvider, err := s.mustScmProvider(req.Provider)
if err != nil {
return nil, err
}
if err = validateCredentialRequest(req); err != nil {
return nil, err
}
resp := &api.ValidateCodeRepositoryCredentialResponse{
Provider: req.Provider,
Type: req.Type,
}
if req.Type != enums.ScmCredentialTypeToken {
return resp, nil
}
account, err := fetchScmAccount(ctx, req.Provider, scmProvider.BaseURL, ptr.To(req.Token))
if err != nil {
return nil, err
}
resp.AccountID = account.ID
resp.Username = account.Username
resp.Validated = true
return resp, nil
}
// mustScmProvider returns the configuration of a provider, rejecting providers that the
// administrator did not enable.
func (s *service) mustScmProvider(provider enums.Provider) (config.ConfigurationScmProvider, error) {
scmProvider, ok := s.Config.ScmProvider(provider)
if !ok {
return config.ConfigurationScmProvider{}, errcode.HTTPErrCodeBadRequest.Detail(fmt.Sprintf("scm provider %s is not configured", provider.String()))
}
return scmProvider, nil
}
// validateCredentialRequest checks that the required fields of a credential type are present.
func validateCredentialRequest(req api.UpdateCodeRepositoryCredentialRequest) error {
switch req.Type {
case enums.ScmCredentialTypeToken:
if req.Token == nil || *req.Token == "" {
return errcode.HTTPErrCodeBadRequest.Detail("token is required for a token credential")
}
case enums.ScmCredentialTypeSsh:
if req.SshKey == nil || *req.SshKey == "" {
return errcode.HTTPErrCodeBadRequest.Detail("ssh_key is required for an ssh credential")
}
case enums.ScmCredentialTypeUsername:
if req.Username == nil || *req.Username == "" {
return errcode.HTTPErrCodeBadRequest.Detail("username is required for a username credential")
}
if req.Password == nil || *req.Password == "" {
return errcode.HTTPErrCodeBadRequest.Detail("password is required for a username credential")
}
case enums.ScmCredentialTypeNone:
return errcode.HTTPErrCodeBadRequest.Detail("type none is not a credential, delete the credential instead")
default:
return errcode.HTTPErrCodeBadRequest.Detail(fmt.Sprintf("invalid credential type %s", req.Type.String()))
}
return nil
}
// ensureUser3rdParty returns the binding of the provider for the user, creating it from the
// account resolved by the credential when the user never signed in with OAuth.
func (s *service) ensureUser3rdParty(ctx context.Context, userID string, provider enums.Provider, account *scmAccount) (*models.User3rdParty, error) {
user3rdPartyObj, err := s.RepoUser.GetUser3rdPartyByProvider(ctx, userID, provider)
if err == nil {
return user3rdPartyObj, nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
slog.Error("get user 3rdparty by provider failed", "err", err, "provider", provider.String())
return nil, errcode.HTTPErrCodeInternalError.Detail(fmt.Sprintf("Get user 3rdparty by provider failed: %v", err))
}
if account == nil || account.ID == "" {
return nil, errcode.HTTPErrCodeBadRequest.Detail(fmt.Sprintf(
"No %s account is linked yet, use a token credential to link it", provider.String()))
}
if existing, err := s.RepoUser.GetUser3rdPartyByAccountID(ctx, provider, account.ID); err == nil {
if existing.UserID != userID {
slog.Error("account already bound to another user", "err", err, "provider", provider.String())
return nil, errcode.HTTPErrCodeConflict.Detail("This account is already bound to another user")
}
return existing, nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
slog.Error("get user 3rdparty by account id failed", "err", err, "provider", provider.String())
return nil, errcode.HTTPErrCodeInternalError.Detail(fmt.Sprintf("Get user 3rdparty by account id failed: %v", err))
}
created, err := s.RepoUser.UpsertUser3rdPartyByAccountID(ctx, &models.User3rdParty{
ID: uuid.NewV7String(),
UserID: userID,
Provider: provider,
AccountID: &account.ID,
})
if err != nil {
slog.Error("create user 3rdparty failed", "err", err, "provider", provider.String())
return nil, errcode.HTTPErrCodeInternalError.Detail(fmt.Sprintf("Create user 3rdparty failed: %v", err))
}
if created.UserID != userID {
return nil, errcode.HTTPErrCodeConflict.Detail("This account is already bound to another user")
}
return created, nil
}
// publishSync marks the binding as syncing and queues a sync task.
func (s *service) publishSync(ctx context.Context, user3rdPartyID string) {
err := s.RepoUser.UpdateUser3rdParty(ctx, user3rdPartyID, map[string]any{
query.User3rdParty.CrLastUpdateTimestamp.ColumnName().String(): time.Now().UnixMilli(),
query.User3rdParty.CrLastUpdateStatus.ColumnName().String(): enums.TaskCommonStatusDoing,
query.User3rdParty.CrLastUpdateMessage.ColumnName().String(): "",
})
if err != nil {
slog.Error("update user 3rdparty sync status failed", "err", err, "user_3rdparty_id", user3rdPartyID)
}
err = s.Producer.Produce(ctx, enums.DaemonCodeRepository,
api.DaemonCodeRepositoryPayload{User3rdPartyID: user3rdPartyID})
if err != nil {
slog.Error("publish sync code repository failed", "err", err, "user_3rdparty_id", user3rdPartyID)
}
}
// fetchScmAccount resolves the account a token belongs to.
func fetchScmAccount(ctx context.Context, provider enums.Provider, baseURL, token string) (*scmAccount, error) {
invalid := func(err error) error {
slog.Error("validate scm credential failed", "err", err, "provider", provider.String())
return errcode.HTTPErrCodeBadRequest.Detail(fmt.Sprintf("Credential is invalid: %v", err))
}
switch provider {
case enums.ProviderGithub:
userObj, _, err := github.NewTokenClient(ctx, token).Users.Get(ctx, "")
if err != nil {
return nil, invalid(err)
}
return &scmAccount{ID: strconv.FormatInt(userObj.GetID(), 10), Username: userObj.GetLogin()}, nil
case enums.ProviderGitlab:
client, err := gitlab.NewClient(token, gitlab.WithBaseURL(baseURL))
if err != nil {
return nil, invalid(err)
}
userObj, _, err := client.Users.CurrentUser()
if err != nil {
return nil, invalid(err)
}
return &scmAccount{ID: strconv.FormatInt(userObj.ID, 10), Username: userObj.Username}, nil
case enums.ProviderGitea:
client, err := gitea.NewClient(baseURL, gitea.SetToken(token))
if err != nil {
return nil, invalid(err)
}
userObj, _, err := client.GetMyUserInfo()
if err != nil {
return nil, invalid(err)
}
return &scmAccount{ID: strconv.FormatInt(userObj.ID, 10), Username: userObj.UserName}, nil
}
return nil, errcode.HTTPErrCodeBadRequest.Detail(fmt.Sprintf("unsupported scm provider %s", provider.String()))
}
+260
View File
@@ -0,0 +1,260 @@
// Copyright 2026 sigma
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package coderepos
import (
"testing"
"github.com/stretchr/testify/require"
"go.uber.org/mock/gomock"
"gorm.io/gorm"
"github.com/go-sigma/sigma/pkg/api"
"github.com/go-sigma/sigma/pkg/api/enums"
"github.com/go-sigma/sigma/pkg/config"
"github.com/go-sigma/sigma/pkg/dal/models"
repocoderepomocks "github.com/go-sigma/sigma/pkg/dal/repository/coderepo/mocks"
repousermocks "github.com/go-sigma/sigma/pkg/dal/repository/user/mocks"
credentialmocks "github.com/go-sigma/sigma/pkg/service/credential/mocks"
"github.com/go-sigma/sigma/pkg/utils/ptr"
)
func scmConfig() *config.Configuration {
return &config.Configuration{
Scm: config.ConfigurationScm{Providers: []config.ConfigurationScmProvider{
{Provider: enums.ScmProviderGithub, BaseURL: "https://github.com"},
}},
}
}
func TestGetCodeRepositoryCredentialErrors(t *testing.T) {
ctrl := gomock.NewController(t)
t.Run("account is not linked", func(t *testing.T) {
userRepository := repousermocks.NewMockUserRepository(ctrl)
userRepository.EXPECT().
GetUser3rdPartyByProvider(gomock.Any(), "user-1", enums.ProviderGithub).
Return(nil, gorm.ErrRecordNotFound)
_, err := (&service{Config: scmConfig(), RepoUser: userRepository}).
GetCodeRepositoryCredential(t.Context(), "user-1", enums.ProviderGithub)
require.Error(t, err)
})
t.Run("credential is not configured", func(t *testing.T) {
userRepository := repousermocks.NewMockUserRepository(ctrl)
userRepository.EXPECT().
GetUser3rdPartyByProvider(gomock.Any(), "user-1", enums.ProviderGithub).
Return(&models.User3rdParty{ID: "3rdparty-1"}, nil)
codeRepository := repocoderepomocks.NewMockCodeRepositoryRepository(ctrl)
codeRepository.EXPECT().
GetCloneCredential(gomock.Any(), "3rdparty-1").
Return(nil, gorm.ErrRecordNotFound)
_, err := (&service{Config: scmConfig(), RepoUser: userRepository, RepoCode: codeRepository}).
GetCodeRepositoryCredential(t.Context(), "user-1", enums.ProviderGithub)
require.Error(t, err)
})
}
func TestValidateCodeRepositoryCredential(t *testing.T) {
t.Run("provider is not configured", func(t *testing.T) {
svc := &service{Config: &config.Configuration{}}
_, err := svc.ValidateCodeRepositoryCredential(t.Context(), api.UpdateCodeRepositoryCredentialRequest{
Provider: enums.ProviderGithub,
Type: enums.ScmCredentialTypeToken,
Token: new("ghp_sigma"),
})
require.Error(t, err)
})
t.Run("missing field for the credential type", func(t *testing.T) {
svc := &service{Config: scmConfig()}
for _, req := range []api.UpdateCodeRepositoryCredentialRequest{
{Provider: enums.ProviderGithub, Type: enums.ScmCredentialTypeToken},
{Provider: enums.ProviderGithub, Type: enums.ScmCredentialTypeSsh},
{Provider: enums.ProviderGithub, Type: enums.ScmCredentialTypeUsername, Username: new("sigma")},
{Provider: enums.ProviderGithub, Type: enums.ScmCredentialTypeNone},
} {
_, err := svc.ValidateCodeRepositoryCredential(t.Context(), req)
require.Error(t, err, "type %s must be rejected", req.Type.String())
}
})
t.Run("ssh credential cannot be verified", func(t *testing.T) {
svc := &service{Config: scmConfig()}
resp, err := svc.ValidateCodeRepositoryCredential(t.Context(), api.UpdateCodeRepositoryCredentialRequest{
Provider: enums.ProviderGithub,
Type: enums.ScmCredentialTypeSsh,
SshKey: new("-----BEGIN OPENSSH PRIVATE KEY-----"),
})
require.NoError(t, err)
require.False(t, resp.Validated)
require.Empty(t, resp.AccountID)
})
}
func TestUpsertCodeRepositoryCredentialSealsSecret(t *testing.T) {
ctrl := gomock.NewController(t)
userRepository := repousermocks.NewMockUserRepository(ctrl)
codeRepository := repocoderepomocks.NewMockCodeRepositoryRepository(ctrl)
svcCredential := credentialmocks.NewMockCredentialService(ctrl)
userRepository.EXPECT().
GetUser3rdPartyByProvider(gomock.Any(), "user-1", enums.ProviderGithub).
Return(&models.User3rdParty{ID: "3rdparty-1", UserID: "user-1", Provider: enums.ProviderGithub}, nil)
svcCredential.EXPECT().
Seal("-----BEGIN OPENSSH PRIVATE KEY-----").
Return("sealed-ssh-key", nil)
codeRepository.EXPECT().
UpsertCredential(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ any, credential *models.CodeRepositoryCloneCredential) error {
require.Equal(t, "3rdparty-1", credential.User3rdPartyID)
require.Equal(t, enums.ScmCredentialTypeSsh, credential.Type)
require.Equal(t, "sealed-ssh-key", ptr.To(credential.SshKey))
require.Nil(t, credential.Token)
return nil
})
// An SSH credential cannot list repositories, so no sync task is queued here.
svc := &service{Config: scmConfig(), RepoUser: userRepository, RepoCode: codeRepository, SvcCredential: svcCredential}
require.NoError(t, svc.UpsertCodeRepositoryCredential(t.Context(), "user-1", api.UpdateCodeRepositoryCredentialRequest{
Provider: enums.ProviderGithub,
Type: enums.ScmCredentialTypeSsh,
SshKey: new("-----BEGIN OPENSSH PRIVATE KEY-----"),
}))
}
func TestEnsureUser3rdPartyRejectsForeignAccount(t *testing.T) {
ctrl := gomock.NewController(t)
userRepository := repousermocks.NewMockUserRepository(ctrl)
userRepository.EXPECT().
GetUser3rdPartyByProvider(gomock.Any(), "user-1", enums.ProviderGithub).
Return(nil, gorm.ErrRecordNotFound)
userRepository.EXPECT().
GetUser3rdPartyByAccountID(gomock.Any(), enums.ProviderGithub, "account-1").
Return(&models.User3rdParty{ID: "3rdparty-1", UserID: "another-user"}, nil)
_, err := (&service{Config: scmConfig(), RepoUser: userRepository}).
ensureUser3rdParty(t.Context(), "user-1", enums.ProviderGithub, &scmAccount{ID: "account-1"})
require.Error(t, err)
}
func TestEnsureUser3rdPartyRequiresAccountForNewBinding(t *testing.T) {
ctrl := gomock.NewController(t)
userRepository := repousermocks.NewMockUserRepository(ctrl)
userRepository.EXPECT().
GetUser3rdPartyByProvider(gomock.Any(), "user-1", enums.ProviderGithub).
Return(nil, gorm.ErrRecordNotFound)
_, err := (&service{Config: scmConfig(), RepoUser: userRepository}).
ensureUser3rdParty(t.Context(), "user-1", enums.ProviderGithub, nil)
require.Error(t, err)
}
func TestEnsureUser3rdPartyCreatesBinding(t *testing.T) {
ctrl := gomock.NewController(t)
userRepository := repousermocks.NewMockUserRepository(ctrl)
userRepository.EXPECT().
GetUser3rdPartyByProvider(gomock.Any(), "user-1", enums.ProviderGithub).
Return(nil, gorm.ErrRecordNotFound)
userRepository.EXPECT().
GetUser3rdPartyByAccountID(gomock.Any(), enums.ProviderGithub, "account-1").
Return(nil, gorm.ErrRecordNotFound)
userRepository.EXPECT().
UpsertUser3rdPartyByAccountID(gomock.Any(), gomock.Any()).
DoAndReturn(func(_ any, user3rdParty *models.User3rdParty) (*models.User3rdParty, error) {
require.Equal(t, "user-1", user3rdParty.UserID)
require.Equal(t, "account-1", ptr.To(user3rdParty.AccountID))
return user3rdParty, nil
})
got, err := (&service{Config: scmConfig(), RepoUser: userRepository}).
ensureUser3rdParty(t.Context(), "user-1", enums.ProviderGithub, &scmAccount{ID: "account-1"})
require.NoError(t, err)
require.Equal(t, "user-1", got.UserID)
}
func TestDeleteCodeRepositoryCredential(t *testing.T) {
ctrl := gomock.NewController(t)
userRepository := repousermocks.NewMockUserRepository(ctrl)
codeRepository := repocoderepomocks.NewMockCodeRepositoryRepository(ctrl)
svc := &service{Config: scmConfig(), RepoUser: userRepository, RepoCode: codeRepository}
// A provider that is not configured is rejected before touching the database.
require.Error(t, svc.DeleteCodeRepositoryCredential(t.Context(), "user-1", enums.ProviderGitlab))
userRepository.EXPECT().
GetUser3rdPartyByProvider(gomock.Any(), "user-1", enums.ProviderGithub).
Return(&models.User3rdParty{ID: "3rdparty-1"}, nil)
codeRepository.EXPECT().
DeleteCredential(gomock.Any(), "3rdparty-1").
Return(nil)
require.NoError(t, svc.DeleteCodeRepositoryCredential(t.Context(), "user-1", enums.ProviderGithub))
}
func TestListCodeRepositoryProviders(t *testing.T) {
ctrl := gomock.NewController(t)
userRepository := repousermocks.NewMockUserRepository(ctrl)
codeRepository := repocoderepomocks.NewMockCodeRepositoryRepository(ctrl)
cfg := &config.Configuration{
Auth: config.ConfigurationAuth{Oauth2: config.ConfigurationAuthOauth2{
Github: config.ConfigurationAuthOauth2Github{Enabled: true},
}},
Scm: config.ConfigurationScm{Providers: []config.ConfigurationScmProvider{
{Provider: enums.ScmProviderGithub, BaseURL: "https://github.com"},
{Provider: enums.ScmProviderGitea, BaseURL: "https://gitea.example.com"},
}},
}
userRepository.EXPECT().
ListUser3rdParty(gomock.Any(), "user-1").
Return([]*models.User3rdParty{{
ID: "3rdparty-1",
UserID: "user-1",
Provider: enums.ProviderGithub,
AccountID: new("account-1"),
CrLastUpdateStatus: enums.TaskCommonStatusFailed,
}}, nil)
codeRepository.EXPECT().
GetCloneCredential(gomock.Any(), "3rdparty-1").
Return(&models.CodeRepositoryCloneCredential{Type: enums.ScmCredentialTypeToken}, nil)
providers, err := (&service{Config: cfg, RepoUser: userRepository, RepoCode: codeRepository}).
ListCodeRepositoryProviders(t.Context(), "user-1")
require.NoError(t, err)
require.Len(t, providers, 2)
// GitHub is linked and has a credential.
require.Equal(t, enums.ProviderGithub, providers[0].Provider)
require.Equal(t, "GitHub", providers[0].DisplayName)
require.True(t, providers[0].OauthEnabled)
require.True(t, providers[0].Linked)
require.True(t, providers[0].HasCredential)
require.Equal(t, enums.ScmCredentialTypeToken, providers[0].CredentialType)
require.Equal(t, enums.TaskCommonStatusFailed, providers[0].CrLastUpdateStatus)
// Gitea is configured but neither linked nor configured, and it is not an OAuth provider.
require.Equal(t, enums.ProviderGitea, providers[1].Provider)
require.False(t, providers[1].OauthEnabled)
require.False(t, providers[1].Linked)
require.False(t, providers[1].HasCredential)
require.Equal(t, enums.ScmCredentialTypeNone, providers[1].CredentialType)
}
+60 -2
View File
@@ -43,6 +43,20 @@ func (m *MockCodeRepositoryService) EXPECT() *MockCodeRepositoryServiceMockRecor
return m.recorder
}
// DeleteCodeRepositoryCredential mocks base method.
func (m *MockCodeRepositoryService) DeleteCodeRepositoryCredential(ctx context.Context, userID string, provider enums.Provider) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "DeleteCodeRepositoryCredential", ctx, userID, provider)
ret0, _ := ret[0].(error)
return ret0
}
// DeleteCodeRepositoryCredential indicates an expected call of DeleteCodeRepositoryCredential.
func (mr *MockCodeRepositoryServiceMockRecorder) DeleteCodeRepositoryCredential(ctx, userID, provider any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "DeleteCodeRepositoryCredential", reflect.TypeOf((*MockCodeRepositoryService)(nil).DeleteCodeRepositoryCredential), ctx, userID, provider)
}
// GetCodeRepository mocks base method.
func (m *MockCodeRepositoryService) GetCodeRepository(ctx context.Context, userID string, provider enums.Provider, id string) (*models.CodeRepository, []*models.CodeRepositoryOwner, error) {
m.ctrl.T.Helper()
@@ -74,6 +88,21 @@ func (mr *MockCodeRepositoryServiceMockRecorder) GetCodeRepositoryBranch(ctx, co
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCodeRepositoryBranch", reflect.TypeOf((*MockCodeRepositoryService)(nil).GetCodeRepositoryBranch), ctx, codeRepositoryID, name)
}
// GetCodeRepositoryCredential mocks base method.
func (m *MockCodeRepositoryService) GetCodeRepositoryCredential(ctx context.Context, userID string, provider enums.Provider) (*models.CodeRepositoryCloneCredential, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "GetCodeRepositoryCredential", ctx, userID, provider)
ret0, _ := ret[0].(*models.CodeRepositoryCloneCredential)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// GetCodeRepositoryCredential indicates an expected call of GetCodeRepositoryCredential.
func (mr *MockCodeRepositoryServiceMockRecorder) GetCodeRepositoryCredential(ctx, userID, provider any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "GetCodeRepositoryCredential", reflect.TypeOf((*MockCodeRepositoryService)(nil).GetCodeRepositoryCredential), ctx, userID, provider)
}
// GetCodeRepositoryUser3rdParty mocks base method.
func (m *MockCodeRepositoryService) GetCodeRepositoryUser3rdParty(ctx context.Context, userID string, provider enums.Provider) (*models.User3rdParty, error) {
m.ctrl.T.Helper()
@@ -139,10 +168,10 @@ func (mr *MockCodeRepositoryServiceMockRecorder) ListCodeRepositoryOwners(ctx, u
}
// ListCodeRepositoryProviders mocks base method.
func (m *MockCodeRepositoryService) ListCodeRepositoryProviders(ctx context.Context, userID string) ([]*models.User3rdParty, error) {
func (m *MockCodeRepositoryService) ListCodeRepositoryProviders(ctx context.Context, userID string) ([]api.ListCodeRepositoryProvidersResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ListCodeRepositoryProviders", ctx, userID)
ret0, _ := ret[0].([]*models.User3rdParty)
ret0, _ := ret[0].([]api.ListCodeRepositoryProvidersResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
@@ -166,3 +195,32 @@ func (mr *MockCodeRepositoryServiceMockRecorder) ResyncCodeRepositories(ctx, use
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ResyncCodeRepositories", reflect.TypeOf((*MockCodeRepositoryService)(nil).ResyncCodeRepositories), ctx, userID, provider)
}
// UpsertCodeRepositoryCredential mocks base method.
func (m *MockCodeRepositoryService) UpsertCodeRepositoryCredential(ctx context.Context, userID string, req api.UpdateCodeRepositoryCredentialRequest) error {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "UpsertCodeRepositoryCredential", ctx, userID, req)
ret0, _ := ret[0].(error)
return ret0
}
// UpsertCodeRepositoryCredential indicates an expected call of UpsertCodeRepositoryCredential.
func (mr *MockCodeRepositoryServiceMockRecorder) UpsertCodeRepositoryCredential(ctx, userID, req any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "UpsertCodeRepositoryCredential", reflect.TypeOf((*MockCodeRepositoryService)(nil).UpsertCodeRepositoryCredential), ctx, userID, req)
}
// ValidateCodeRepositoryCredential mocks base method.
func (m *MockCodeRepositoryService) ValidateCodeRepositoryCredential(ctx context.Context, req api.UpdateCodeRepositoryCredentialRequest) (*api.ValidateCodeRepositoryCredentialResponse, error) {
m.ctrl.T.Helper()
ret := m.ctrl.Call(m, "ValidateCodeRepositoryCredential", ctx, req)
ret0, _ := ret[0].(*api.ValidateCodeRepositoryCredentialResponse)
ret1, _ := ret[1].(error)
return ret0, ret1
}
// ValidateCodeRepositoryCredential indicates an expected call of ValidateCodeRepositoryCredential.
func (mr *MockCodeRepositoryServiceMockRecorder) ValidateCodeRepositoryCredential(ctx, req any) *gomock.Call {
mr.mock.ctrl.T.Helper()
return mr.mock.ctrl.RecordCallWithMethodType(mr.mock, "ValidateCodeRepositoryCredential", reflect.TypeOf((*MockCodeRepositoryService)(nil).ValidateCodeRepositoryCredential), ctx, req)
}
+228
View File
@@ -658,6 +658,29 @@ definitions:
example: "2006-01-02 15:04:05"
type: string
type: object
api.GetCodeRepositoryCredentialResponse:
properties:
created_at:
example: "2006-01-02 15:04:05"
type: string
has_password:
type: boolean
has_ssh_key:
type: boolean
token:
example: ghp_****abcd
type: string
type:
allOf:
- $ref: '#/definitions/enums.ScmCredentialType'
example: token
updated_at:
example: "2006-01-02 15:04:05"
type: string
username:
example: sigma
type: string
type: object
api.GetCodeRepositoryUser3rdPartyResponse:
properties:
account_id:
@@ -829,6 +852,30 @@ definitions:
type: object
api.ListCodeRepositoryProvidersResponse:
properties:
account_id:
type: string
base_url:
example: https://github.com
type: string
cr_last_update_message:
type: string
cr_last_update_status:
$ref: '#/definitions/enums.TaskCommonStatus'
cr_last_update_timestamp:
type: string
credential_type:
allOf:
- $ref: '#/definitions/enums.ScmCredentialType'
example: none
display_name:
example: GitHub
type: string
has_credential:
type: boolean
linked:
type: boolean
oauth_enabled:
type: boolean
provider:
allOf:
- $ref: '#/definitions/enums.Provider'
@@ -1536,6 +1583,34 @@ definitions:
required:
- buildkit_platforms
type: object
api.UpdateCodeRepositoryCredentialRequest:
properties:
password:
example: sigma
maxLength: 512
type: string
provider:
$ref: '#/definitions/enums.Provider'
ssh_key:
example: '-----BEGIN OPENSSH PRIVATE KEY-----'
maxLength: 8192
type: string
token:
example: ghp_xxx
maxLength: 1024
type: string
type:
allOf:
- $ref: '#/definitions/enums.ScmCredentialType'
example: token
username:
example: sigma
maxLength: 256
type: string
required:
- provider
- type
type: object
api.UpdateGcArtifactRuleRequest:
properties:
cron_enabled:
@@ -1684,6 +1759,29 @@ definitions:
username:
type: string
type: object
api.ValidateCodeRepositoryCredentialResponse:
properties:
account_id:
example: "1234567"
type: string
provider:
allOf:
- $ref: '#/definitions/enums.Provider'
example: github
type:
allOf:
- $ref: '#/definitions/enums.ScmCredentialType'
example: token
username:
example: sigma
type: string
validated:
description: |-
Validated reports whether the credential was verified against the provider API.
SSH keys and username/password credentials are only usable for cloning, so they
cannot be verified.
type: boolean
type: object
api.ValidateCronRequest:
properties:
cron:
@@ -2119,6 +2217,136 @@ paths:
summary: List code repositories
tags:
- CodeRepository
/coderepos/{provider}/credential:
delete:
consumes:
- application/json
parameters:
- description: Delete the SCM credential of the provider
in: path
name: provider
required: true
type: string
produces:
- application/json
responses:
"204":
description: No Content
"404":
description: Not Found
schema:
$ref: '#/definitions/errcode.ErrCode'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/errcode.ErrCode'
security:
- BasicAuth: []
summary: Delete code repository credential
tags:
- CodeRepository
get:
consumes:
- application/json
parameters:
- description: Get the SCM credential of the provider
in: path
name: provider
required: true
type: string
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/api.GetCodeRepositoryCredentialResponse'
"404":
description: Not Found
schema:
$ref: '#/definitions/errcode.ErrCode'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/errcode.ErrCode'
security:
- BasicAuth: []
summary: Get code repository credential
tags:
- CodeRepository
put:
consumes:
- application/json
parameters:
- description: Update the SCM credential of the provider
in: path
name: provider
required: true
type: string
- description: SCM credential
in: body
name: body
required: true
schema:
$ref: '#/definitions/api.UpdateCodeRepositoryCredentialRequest'
produces:
- application/json
responses:
"200":
description: OK
"400":
description: Bad Request
schema:
$ref: '#/definitions/errcode.ErrCode'
"409":
description: Conflict
schema:
$ref: '#/definitions/errcode.ErrCode'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/errcode.ErrCode'
security:
- BasicAuth: []
summary: Update code repository credential
tags:
- CodeRepository
/coderepos/{provider}/credential/validate:
post:
consumes:
- application/json
parameters:
- description: Validate the SCM credential of the provider
in: path
name: provider
required: true
type: string
- description: SCM credential
in: body
name: body
required: true
schema:
$ref: '#/definitions/api.UpdateCodeRepositoryCredentialRequest'
produces:
- application/json
responses:
"200":
description: OK
schema:
$ref: '#/definitions/api.ValidateCodeRepositoryCredentialResponse'
"400":
description: Bad Request
schema:
$ref: '#/definitions/errcode.ErrCode'
"500":
description: Internal Server Error
schema:
$ref: '#/definitions/errcode.ErrCode'
security:
- BasicAuth: []
summary: Validate code repository credential
tags:
- CodeRepository
/coderepos/{provider}/owners:
get:
consumes: