refactor(backend): authorization v2

This commit is contained in:
pycook
2025-07-16 18:11:04 +08:00
parent d8387323dd
commit 7e2c667fc4
48 changed files with 10593 additions and 554 deletions

View File

@@ -47,6 +47,8 @@ require (
gorm.io/plugin/soft_delete v1.2.1 gorm.io/plugin/soft_delete v1.2.1
) )
require github.com/stretchr/testify v1.9.0
require ( require (
github.com/Azure/azure-pipeline-go v0.2.3 // indirect github.com/Azure/azure-pipeline-go v0.2.3 // indirect
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be // indirect
@@ -56,6 +58,7 @@ require (
github.com/charmbracelet/x/term v0.1.1 // indirect github.com/charmbracelet/x/term v0.1.1 // indirect
github.com/charmbracelet/x/windows v0.1.0 // indirect github.com/charmbracelet/x/windows v0.1.0 // indirect
github.com/clbanning/mxj v1.8.4 // indirect github.com/clbanning/mxj v1.8.4 // indirect
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
github.com/dustin/go-humanize v1.0.1 // indirect github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/go-ini/ini v1.67.0 // indirect github.com/go-ini/ini v1.67.0 // indirect
@@ -75,6 +78,7 @@ require (
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
github.com/muesli/cancelreader v0.2.2 // indirect github.com/muesli/cancelreader v0.2.2 // indirect
github.com/muesli/termenv v0.15.2 // indirect github.com/muesli/termenv v0.15.2 // indirect
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
github.com/rs/xid v1.6.0 // indirect github.com/rs/xid v1.6.0 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/time v0.6.0 // indirect golang.org/x/time v0.6.0 // indirect

View File

@@ -63,6 +63,178 @@ func MigrateNode() {
} }
} }
func MigrateCommand() {
ctx := context.Background()
rts, err := GetResourceTypes(ctx)
if err != nil {
logger.L().Fatal("get resource type failed", zap.Error(err))
}
// Ensure command resource type exists
if !lo.ContainsBy(rts, func(rt *ResourceType) bool { return rt.Name == "command" }) {
if err = AddResourceTypes(ctx, &ResourceType{Name: "command", Perms: AllPermissions}); err != nil {
logger.L().Fatal("add command resource type failed", zap.Error(err))
}
}
// Ensure command_template resource type exists
if !lo.ContainsBy(rts, func(rt *ResourceType) bool { return rt.Name == "command_template" }) {
if err = AddResourceTypes(ctx, &ResourceType{Name: "command_template", Perms: AllPermissions}); err != nil {
logger.L().Fatal("add command_template resource type failed", zap.Error(err))
}
}
// Migrate Commands
commands := make([]*model.Command, 0)
if err = dbpkg.DB.Model(&commands).Where("resource_id = 0").Or("resource_id IS NULL").Find(&commands).Error; err != nil {
logger.L().Fatal("get commands failed", zap.Error(err))
}
// Get existing command resources to avoid duplicates
existingCommandResources, err := GetCommandResources(ctx)
if err != nil {
logger.L().Fatal("get existing command resources failed", zap.Error(err))
}
commandNameToResourceId := lo.SliceToMap(existingCommandResources, func(r *Resource) (string, int) {
return r.Name, r.ResourceId
})
eg := errgroup.Group{}
for _, c := range commands {
cmd := c
eg.Go(func() error {
var resourceId int
// Check if resource already exists
if existingResourceId, exists := commandNameToResourceId[cmd.Name]; exists {
resourceId = existingResourceId
logger.L().Info("Using existing resource for command",
zap.String("command", cmd.Name),
zap.Int("resource_id", resourceId))
} else {
// Create new resource
r, err := AddResource(ctx, cmd.CreatorId, "command", cmd.Name)
if err != nil {
return err
}
resourceId = r.ResourceId
logger.L().Info("Created new resource for command",
zap.String("command", cmd.Name),
zap.Int("resource_id", resourceId))
}
// Update command with resource_id
if err := dbpkg.DB.Model(&cmd).Where("id=?", cmd.Id).Update("resource_id", resourceId).Error; err != nil {
return err
}
return nil
})
}
// Migrate CommandTemplates
templates := make([]*model.CommandTemplate, 0)
if err = dbpkg.DB.Model(&templates).Where("resource_id = 0").Or("resource_id IS NULL").Find(&templates).Error; err != nil {
logger.L().Fatal("get command templates failed", zap.Error(err))
}
// Get existing command_template resources to avoid duplicates
existingTemplateResources, err := GetCommandTemplateResources(ctx)
if err != nil {
logger.L().Fatal("get existing command template resources failed", zap.Error(err))
}
templateNameToResourceId := lo.SliceToMap(existingTemplateResources, func(r *Resource) (string, int) {
return r.Name, r.ResourceId
})
for _, t := range templates {
tmpl := t
eg.Go(func() error {
var resourceId int
// Check if resource already exists
if existingResourceId, exists := templateNameToResourceId[tmpl.Name]; exists {
resourceId = existingResourceId
logger.L().Info("Using existing resource for command template",
zap.String("template", tmpl.Name),
zap.Int("resource_id", resourceId))
} else {
// Create new resource
r, err := AddResource(ctx, tmpl.CreatorId, "command_template", tmpl.Name)
if err != nil {
return err
}
resourceId = r.ResourceId
logger.L().Info("Created new resource for command template",
zap.String("template", tmpl.Name),
zap.Int("resource_id", resourceId))
}
// Update template with resource_id
if err := dbpkg.DB.Model(&tmpl).Where("id=?", tmpl.Id).Update("resource_id", resourceId).Error; err != nil {
return err
}
return nil
})
}
if err = eg.Wait(); err != nil {
logger.L().Fatal("migrate command and template resources failed", zap.Error(err))
}
}
// GetCommandResources retrieves all command resources from ACL system
func GetCommandResources(ctx context.Context) ([]*Resource, error) {
token, err := remote.GetAclToken(ctx)
if err != nil {
return nil, err
}
data := &ResourceResult{}
url := fmt.Sprintf("%s/acl/resources", config.Cfg.Auth.Acl.Url)
resp, err := remote.RC.R().
SetHeader("App-Access-Token", token).
SetQueryParams(map[string]string{
"app_id": config.Cfg.Auth.Acl.AppId,
"resource_type_id": "command",
"page_size": "1000", // Get all resources
}).
SetResult(data).
Get(url)
if err = remote.HandleErr(err, resp, func(dt map[string]any) bool { return true }); err != nil {
return nil, err
}
return data.Resources, nil
}
// GetCommandTemplateResources retrieves all command template resources from ACL system
func GetCommandTemplateResources(ctx context.Context) ([]*Resource, error) {
token, err := remote.GetAclToken(ctx)
if err != nil {
return nil, err
}
data := &ResourceResult{}
url := fmt.Sprintf("%s/acl/resources", config.Cfg.Auth.Acl.Url)
resp, err := remote.RC.R().
SetHeader("App-Access-Token", token).
SetQueryParams(map[string]string{
"app_id": config.Cfg.Auth.Acl.AppId,
"resource_type_id": "command_template",
"page_size": "1000", // Get all resources
}).
SetResult(data).
Get(url)
if err = remote.HandleErr(err, resp, func(dt map[string]any) bool { return true }); err != nil {
return nil, err
}
return data.Resources, nil
}
func GetResourceTypes(ctx context.Context) (rt []*ResourceType, err error) { func GetResourceTypes(ctx context.Context) (rt []*ResourceType, err error) {
token, err := remote.GetAclToken(ctx) token, err := remote.GetAclToken(ctx)
if err != nil { if err != nil {

View File

@@ -28,11 +28,12 @@ func initDB() {
cfg := db.ConfigFromGlobal() cfg := db.ConfigFromGlobal()
if err := db.Init(cfg, true, if err := db.Init(cfg, true,
model.DefaultAccount, model.DefaultAsset, model.DefaultAuthorization, model.DefaultCommand, model.DefaultAccount, model.DefaultAsset, model.DefaultAuthorization, model.DefaultAuthorizationV2,
model.DefaultConfig, model.DefaultFileHistory, model.DefaultGateway, model.DefaultHistory, model.DefaultCommand, model.DefaultCommandTemplate, model.DefaultConfig, model.DefaultFileHistory,
model.DefaultNode, model.DefaultPublicKey, model.DefaultSession, model.DefaultSessionCmd, model.DefaultGateway, model.DefaultHistory, model.DefaultNode, model.DefaultPublicKey,
model.DefaultShare, model.DefaultQuickCommand, model.DefaultUserPreference, model.DefaultSession, model.DefaultSessionCmd, model.DefaultShare, model.DefaultQuickCommand,
model.DefaultStorageConfig, model.DefaultStorageMetrics, model.DefaultUserPreference, model.DefaultStorageConfig, model.DefaultStorageMetrics,
model.DefaultTimeTemplate, model.DefaultMigrationRecord,
); err != nil { ); err != nil {
logger.L().Fatal("Failed to init database", zap.Error(err)) logger.L().Fatal("Failed to init database", zap.Error(err))
} }
@@ -42,6 +43,7 @@ func initDB() {
} }
acl.MigrateNode() acl.MigrateNode()
acl.MigrateCommand()
gsession.InitSessionCleanup() gsession.InitSessionCleanup()
} }
@@ -49,6 +51,17 @@ func initDB() {
func initServices() { func initServices() {
service.InitAuthorizationService() service.InitAuthorizationService()
fileservice.InitFileService() fileservice.InitFileService()
// Initialize predefined dangerous commands and templates
if err := service.InitBuiltinCommands(); err != nil {
logger.L().Error("Failed to initialize builtin commands", zap.Error(err))
}
// Initialize built-in time templates
timeTemplateService := service.NewTimeTemplateService()
if err := timeTemplateService.InitializeBuiltInTemplates(ctx); err != nil {
logger.L().Error("Failed to initialize built-in time templates", zap.Error(err))
}
} }
func initStorage() error { func initStorage() error {

View File

@@ -7,7 +7,6 @@ import (
"github.com/samber/lo" "github.com/samber/lo"
"github.com/spf13/cast" "github.com/spf13/cast"
"github.com/veops/oneterm/internal/acl"
"github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/service" "github.com/veops/oneterm/internal/service"
"github.com/veops/oneterm/pkg/config" "github.com/veops/oneterm/pkg/config"
@@ -103,27 +102,18 @@ func (c *Controller) UpdateAccount(ctx *gin.Context) {
// @Success 200 {object} HttpResponse{data=ListData{list=[]model.Account}} // @Success 200 {object} HttpResponse{data=ListData{list=[]model.Account}}
// @Router /account [get] // @Router /account [get]
func (c *Controller) GetAccounts(ctx *gin.Context) { func (c *Controller) GetAccounts(ctx *gin.Context) {
currentUser, _ := acl.GetSessionFromCtx(ctx)
info := cast.ToBool(ctx.Query("info")) info := cast.ToBool(ctx.Query("info"))
// Build base query using service layer // Build query with integrated V2 authorization filter
db := accountService.BuildQuery(ctx) db, err := accountService.BuildQueryWithAuthorization(ctx)
// Apply select fields for info mode
if info {
db = db.Select("id", "name", "account")
// Apply authorization filter if needed
if !acl.IsAdmin(currentUser) {
assetIds, err := GetAssetIdsByAuthorization(ctx)
if err != nil { if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal, Data: map[string]any{"err": err}})
return return
} }
// Filter accounts by asset IDs // Apply info mode settings
db = accountService.FilterByAssetIds(db, assetIds) if info {
} db = db.Select("id", "name", "account")
} }
doGet(ctx, !info, db, config.RESOURCE_ACCOUNT, accountPostHooks...) doGet(ctx, !info, db, config.RESOURCE_ACCOUNT, accountPostHooks...)

View File

@@ -2,12 +2,14 @@ package controller
import ( import (
"net/http" "net/http"
"strconv"
"strings"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/spf13/cast" "github.com/spf13/cast"
"go.uber.org/zap" "go.uber.org/zap"
"github.com/veops/oneterm/internal/acl" "github.com/samber/lo"
"github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/service" "github.com/veops/oneterm/internal/service"
"github.com/veops/oneterm/pkg/config" "github.com/veops/oneterm/pkg/config"
@@ -39,19 +41,6 @@ var (
return return
} }
}, },
// Apply authorization filters
func(ctx *gin.Context, data []*model.Asset) {
currentUser, _ := acl.GetSessionFromCtx(ctx)
if acl.IsAdmin(currentUser) {
return
}
authorizationIds, _ := ctx.Value(kAuthorizationIds).([]*model.AuthorizationIds)
nodeIds, _ := ctx.Value(kNodeIds).([]int)
accountIds, _ := ctx.Value(kAccountIds).([]int)
assetService.ApplyAuthorizationFilters(ctx, data, authorizationIds, nodeIds, accountIds)
},
} }
) )
@@ -105,11 +94,10 @@ func (c *Controller) UpdateAsset(ctx *gin.Context) {
// @Success 200 {object} HttpResponse{data=ListData{list=[]model.Asset}} // @Success 200 {object} HttpResponse{data=ListData{list=[]model.Asset}}
// @Router /asset [get] // @Router /asset [get]
func (c *Controller) GetAssets(ctx *gin.Context) { func (c *Controller) GetAssets(ctx *gin.Context) {
currentUser, _ := acl.GetSessionFromCtx(ctx)
info := cast.ToBool(ctx.Query("info")) info := cast.ToBool(ctx.Query("info"))
// Build base query using service layer // Build query with integrated V2 authorization filter
db, err := assetService.BuildQuery(ctx) db, err := assetService.BuildQueryWithAuthorization(ctx)
if err != nil { if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}})
return return
@@ -127,24 +115,116 @@ func (c *Controller) GetAssets(ctx *gin.Context) {
// Apply info mode settings // Apply info mode settings
if info { if info {
db = db.Select("id", "parent_id", "name", "ip", "protocols", "connectable", "authorization") db = db.Select("id", "parent_id", "name", "ip", "protocols", "connectable", "authorization", "resource_id", "access_time_control", "asset_command_control")
// Apply authorization filter if needed
if !acl.IsAdmin(currentUser) {
ids, err := GetAssetIdsByAuthorization(ctx)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}})
return
}
db = db.Where("id IN ?", ids)
}
} }
doGet(ctx, !info, db, config.RESOURCE_ASSET, assetPostHooks...) doGet(ctx, false, db, config.RESOURCE_ASSET, assetPostHooks...)
} }
// GetAssetIdsByAuthorization gets asset IDs by authorization // GetAssetIdsByAuthorization gets asset IDs by authorization
func GetAssetIdsByAuthorization(ctx *gin.Context) ([]int, error) { func GetAssetIdsByAuthorization(ctx *gin.Context) ([]int, error) {
_, assetIds, _, err := assetService.GetAssetIdsByAuthorization(ctx) // Use V2 authorization system for asset filtering
authV2Service := service.NewAuthorizationV2Service()
_, assetIds, _, err := authV2Service.GetAuthorizationScopeByACL(ctx)
return assetIds, err return assetIds, err
} }
// AssetPermissionResult represents simplified permission result without permissions field
type AssetPermissionResult struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason"`
RuleId int `json:"rule_id"`
RuleName string `json:"rule_name"`
Restrictions map[string]interface{} `json:"restrictions"`
}
// AssetPermissionBatchResult represents batch permission results without permissions field
type AssetPermissionBatchResult struct {
Results map[model.AuthAction]*AssetPermissionResult `json:"results"`
}
// AssetPermissionMultiAccountResult represents permission results for multiple accounts
type AssetPermissionMultiAccountResult struct {
Results map[int]*AssetPermissionBatchResult `json:"results"` // accountId -> batch results
}
// GetAssetPermissions godoc
//
// @Tags asset
// @Param id path int true "asset id"
// @Param account_ids query string false "account ids (comma separated, e.g. 123,456,789)"
// @Success 200 {object} HttpResponse{data=AssetPermissionMultiAccountResult}
// @Router /asset/:id/permissions [get]
func (c *Controller) GetAssetPermissions(ctx *gin.Context) {
assetId := cast.ToInt(ctx.Param("id"))
accountIdsStr := ctx.Query("account_ids")
if assetId <= 0 {
ctx.AbortWithError(http.StatusBadRequest, &errors.ApiError{Code: errors.ErrInvalidArgument, Data: map[string]any{"err": "invalid asset id"}})
return
}
// Parse account_ids parameter
var accountIds []int
if accountIdsStr != "" {
// Split by comma and convert to integers
idStrs := strings.Split(accountIdsStr, ",")
for _, idStr := range idStrs {
idStr = strings.TrimSpace(idStr)
if idStr != "" {
if id, err := strconv.Atoi(idStr); err == nil && id > 0 {
accountIds = append(accountIds, id)
}
}
}
}
// Remove duplicates
accountIds = lo.Uniq(accountIds)
// Use V2 authorization service to get permissions
authV2Service := service.NewAuthorizationV2Service()
// If no account IDs provided, return empty result
if len(accountIds) == 0 {
ctx.JSON(http.StatusOK, HttpResponse{
Data: &AssetPermissionBatchResult{
Results: make(map[model.AuthAction]*AssetPermissionResult),
},
})
return
}
multiResult := &AssetPermissionMultiAccountResult{
Results: make(map[int]*AssetPermissionBatchResult),
}
for _, accId := range accountIds {
result, err := authV2Service.GetAssetPermissions(ctx, assetId, accId)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}})
return
}
// Convert to simplified result without permissions field
simplifiedResult := &AssetPermissionBatchResult{
Results: make(map[model.AuthAction]*AssetPermissionResult),
}
for action, authResult := range result.Results {
simplifiedResult.Results[action] = &AssetPermissionResult{
Allowed: authResult.Allowed,
Reason: authResult.Reason,
RuleId: authResult.RuleId,
RuleName: authResult.RuleName,
Restrictions: authResult.Restrictions,
}
}
multiResult.Results[accId] = simplifiedResult
}
ctx.JSON(http.StatusOK, HttpResponse{
Data: multiResult,
})
}

View File

@@ -11,7 +11,6 @@ import (
"github.com/veops/oneterm/internal/acl" "github.com/veops/oneterm/internal/acl"
"github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/service" "github.com/veops/oneterm/internal/service"
gsession "github.com/veops/oneterm/internal/session"
myErrors "github.com/veops/oneterm/pkg/errors" myErrors "github.com/veops/oneterm/pkg/errors"
) )
@@ -143,11 +142,6 @@ func handleAuthorization(ctx *gin.Context, tx *gorm.DB, action int, asset *model
return service.DefaultAuthService.HandleAuthorization(ctx, tx, action, asset, auths...) return service.DefaultAuthService.HandleAuthorization(ctx, tx, action, asset, auths...)
} }
// hasAuthorization checks if the session has authorization
func hasAuthorization(ctx *gin.Context, sess *gsession.Session) (ok bool, err error) {
return service.DefaultAuthService.HasAuthorization(ctx, sess)
}
func getIdsByAuthorizationIds(ctx *gin.Context) (nodeIds, assetIds, accountIds []int) { func getIdsByAuthorizationIds(ctx *gin.Context) (nodeIds, assetIds, accountIds []int) {
authorizationIds, ok := ctx.Value(kAuthorizationIds).([]*model.AuthorizationIds) authorizationIds, ok := ctx.Value(kAuthorizationIds).([]*model.AuthorizationIds)
if !ok || len(authorizationIds) == 0 { if !ok || len(authorizationIds) == 0 {

View File

@@ -0,0 +1,329 @@
package controller
import (
"errors"
"net/http"
"github.com/gin-gonic/gin"
"github.com/spf13/cast"
"gorm.io/gorm"
"github.com/veops/oneterm/internal/acl"
"github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/service"
myErrors "github.com/veops/oneterm/pkg/errors"
)
// CreateAuthorizationV2 godoc
//
// @Tags authorization_v2
// @Param authorization body model.AuthorizationV2 true "authorization rule"
// @Success 200 {object} HttpResponse
// @Router /authorization_v2 [post]
func (c *Controller) CreateAuthorizationV2(ctx *gin.Context) {
auth := &model.AuthorizationV2{}
err := ctx.ShouldBindBodyWithJSON(auth)
if err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return
}
authV2Service := service.NewAuthorizationV2Service()
// Check permissions
currentUser, _ := acl.GetSessionFromCtx(ctx)
if !acl.IsAdmin(currentUser) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{"perm": acl.GRANT}})
return
}
// Create the rule
err = authV2Service.CreateRule(ctx, auth)
if err != nil {
if ctx.IsAborted() {
return
}
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal, Data: map[string]any{"err": err}})
return
}
ctx.JSON(http.StatusOK, HttpResponse{
Data: map[string]any{
"id": auth.GetId(),
},
})
}
// UpdateAuthorizationV2 godoc
//
// @Tags authorization_v2
// @Param id path int true "authorization id"
// @Param authorization body model.AuthorizationV2 true "authorization rule"
// @Success 200 {object} HttpResponse
// @Router /authorization_v2/:id [put]
func (c *Controller) UpdateAuthorizationV2(ctx *gin.Context) {
authId := cast.ToInt(ctx.Param("id"))
auth := &model.AuthorizationV2{}
err := ctx.ShouldBindBodyWithJSON(auth)
if err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return
}
auth.Id = authId
authV2Service := service.NewAuthorizationV2Service()
// Check permissions
currentUser, _ := acl.GetSessionFromCtx(ctx)
if !acl.IsAdmin(currentUser) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{"perm": acl.GRANT}})
return
}
// Update the rule
err = authV2Service.UpdateRule(ctx, auth)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
} else {
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal, Data: map[string]any{"err": err}})
}
return
}
ctx.JSON(http.StatusOK, HttpResponse{
Data: map[string]any{
"id": auth.GetId(),
},
})
}
// DeleteAuthorizationV2 godoc
//
// @Tags authorization_v2
// @Param id path int true "authorization id"
// @Success 200 {object} HttpResponse
// @Router /authorization_v2/:id [delete]
func (c *Controller) DeleteAuthorizationV2(ctx *gin.Context) {
authId := cast.ToInt(ctx.Param("id"))
authV2Service := service.NewAuthorizationV2Service()
// Check permissions
currentUser, _ := acl.GetSessionFromCtx(ctx)
if !acl.IsAdmin(currentUser) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{"perm": acl.GRANT}})
return
}
// Delete the rule
if err := authV2Service.DeleteRule(ctx, authId); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
} else {
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal, Data: map[string]any{"err": err}})
}
return
}
ctx.JSON(http.StatusOK, HttpResponse{
Data: map[string]any{
"id": authId,
},
})
}
// GetAuthorizationV2 godoc
//
// @Tags authorization_v2
// @Param id path int true "authorization id"
// @Success 200 {object} HttpResponse{data=model.AuthorizationV2}
// @Router /authorization_v2/:id [get]
func (c *Controller) GetAuthorizationV2(ctx *gin.Context) {
authId := cast.ToInt(ctx.Param("id"))
authV2Service := service.NewAuthorizationV2Service()
// Get the rule
auth, err := authV2Service.GetRuleById(ctx, authId)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.AbortWithError(http.StatusNotFound, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
} else {
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal, Data: map[string]any{"err": err}})
}
return
}
if auth == nil {
ctx.AbortWithError(http.StatusNotFound, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": "rule not found"}})
return
}
ctx.JSON(http.StatusOK, HttpResponse{
Data: auth,
})
}
// CloneAuthorizationV2 godoc
//
// @Tags authorization_v2
// @Param id path int true "source authorization id"
// @Param body body object true "clone request"
// @Success 200 {object} HttpResponse{data=model.AuthorizationV2}
// @Router /authorization_v2/:id/clone [post]
func (c *Controller) CloneAuthorizationV2(ctx *gin.Context) {
sourceId := cast.ToInt(ctx.Param("id"))
// Parse request body
var req struct {
Name string `json:"name" binding:"required"`
}
err := ctx.ShouldBindBodyWithJSON(&req)
if err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return
}
authV2Service := service.NewAuthorizationV2Service()
// Check permissions
currentUser, _ := acl.GetSessionFromCtx(ctx)
if !acl.IsAdmin(currentUser) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{"perm": acl.GRANT}})
return
}
// Clone the rule
clonedRule, err := authV2Service.CloneRule(ctx, sourceId, req.Name)
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.AbortWithError(http.StatusNotFound, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": "source rule not found"}})
} else {
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal, Data: map[string]any{"err": err}})
}
return
}
ctx.JSON(http.StatusOK, HttpResponse{
Data: clonedRule,
})
}
// GetAuthorizationsV2 godoc
//
// @Tags authorization_v2
// @Param page_index query int false "page index"
// @Param page_size query int false "page size"
// @Param enabled query bool false "filter by enabled status"
// @Param search query string false "search by name or description"
// @Success 200 {object} HttpResponse{data=ListData{list=[]model.AuthorizationV2}}
// @Router /authorization_v2 [get]
func (c *Controller) GetAuthorizationsV2(ctx *gin.Context) {
pageIndex := cast.ToInt(ctx.DefaultQuery("page_index", "1"))
pageSize := cast.ToInt(ctx.DefaultQuery("page_size", "20"))
enabled := ctx.Query("enabled")
search := ctx.Query("search")
// Check permissions
currentUser, _ := acl.GetSessionFromCtx(ctx)
if !acl.IsAdmin(currentUser) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{"perm": acl.READ}})
return
}
authV2Service := service.NewAuthorizationV2Service()
// Build query with filters
db, err := authV2Service.BuildQuery(ctx)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal, Data: map[string]any{"err": err}})
return
}
// Apply filters
if enabled != "" {
db = db.Where("enabled = ?", enabled == "true")
}
if search != "" {
db = db.Where("name LIKE ? OR description LIKE ?", "%"+search+"%", "%"+search+"%")
}
// Get total count
var count int64
if err := db.Count(&count).Error; err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal, Data: map[string]any{"err": err}})
return
}
// Apply pagination
offset := (pageIndex - 1) * pageSize
var auths []*model.AuthorizationV2
if err := db.Offset(offset).Limit(pageSize).Order("created_at DESC").Find(&auths).Error; err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal, Data: map[string]any{"err": err}})
return
}
// Convert to slice of any type
authsAny := make([]any, 0, len(auths))
for _, a := range auths {
authsAny = append(authsAny, a)
}
result := &ListData{
Count: count,
List: authsAny,
}
ctx.JSON(http.StatusOK, HttpResponse{
Data: result,
})
}
// CheckPermissionV2 godoc
//
// @Tags authorization_v2
// @Param request body CheckPermissionRequest true "permission check request"
// @Success 200 {object} HttpResponse{data=model.AuthResult}
// @Router /authorization_v2/check [post]
func (c *Controller) CheckPermissionV2(ctx *gin.Context) {
var req CheckPermissionRequest
if err := ctx.ShouldBindBodyWithJSON(&req); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument})
return
}
result, err := service.DefaultAuthService.CheckPermission(ctx, req.NodeId, req.AssetId, req.AccountId, req.Action)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal})
return
}
ctx.JSON(http.StatusOK, HttpResponse{Data: result})
}
// CheckPermissionRequest represents a permission check request
type CheckPermissionRequest struct {
NodeId int `json:"node_id" binding:"gte=0"`
AssetId int `json:"asset_id" binding:"gte=0"`
AccountId int `json:"account_id" binding:"gte=0"`
Action model.AuthAction `json:"action" binding:"required"`
}
// AuthorizationV2 hooks
var (
authV2PreHooks = []preHook[*model.AuthorizationV2]{
func(ctx *gin.Context, data *model.AuthorizationV2) {
authV2Service := service.NewAuthorizationV2Service()
if err := authV2Service.ValidateRule(ctx, data); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err.Error()}})
}
},
}
authV2PostHooks = []postHook[*model.AuthorizationV2]{
func(ctx *gin.Context, data []*model.AuthorizationV2) {
// Add any post-processing logic here
},
}
)

View File

@@ -435,10 +435,6 @@ func hasPerm[T model.Model](ctx context.Context, md T, resourceTypeName, action
} }
func handlePermissions[T any](ctx *gin.Context, data []T, resourceTypeName string) (err error) { func handlePermissions[T any](ctx *gin.Context, data []T, resourceTypeName string) (err error) {
if info := cast.ToBool(ctx.Query("info")); info {
return
}
currentUser, _ := acl.GetSessionFromCtx(ctx) currentUser, _ := acl.GetSessionFromCtx(ctx)
if !lo.Contains(config.PermResource, resourceTypeName) { if !lo.Contains(config.PermResource, resourceTypeName) {

View File

@@ -54,6 +54,7 @@ func (c *Controller) UpdateCommandTemplate(ctx *gin.Context) {
// @Tags command_template // @Tags command_template
// @Param page_index query int false "page index" // @Param page_index query int false "page index"
// @Param page_size query int false "page size" // @Param page_size query int false "page size"
// @Param search query string false "search by name or description"
// @Param category query string false "template category" // @Param category query string false "template category"
// @Param builtin query bool false "filter by builtin status" // @Param builtin query bool false "filter by builtin status"
// @Param info query bool false "info mode" // @Param info query bool false "info mode"
@@ -62,23 +63,13 @@ func (c *Controller) UpdateCommandTemplate(ctx *gin.Context) {
func (c *Controller) GetCommandTemplates(ctx *gin.Context) { func (c *Controller) GetCommandTemplates(ctx *gin.Context) {
info := cast.ToBool(ctx.Query("info")) info := cast.ToBool(ctx.Query("info"))
// Build base query using service layer // Build base query using service layer with all filters
db, err := commandTemplateService.BuildQuery(ctx) db, err := commandTemplateService.BuildQuery(ctx)
if err != nil { if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &pkgErrors.ApiError{Code: pkgErrors.ErrInternal, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusInternalServerError, &pkgErrors.ApiError{Code: pkgErrors.ErrInternal, Data: map[string]any{"err": err}})
return return
} }
// Apply filters
if category := ctx.Query("category"); category != "" {
db = db.Where("category = ?", category)
}
if builtinStr := ctx.Query("builtin"); builtinStr != "" {
builtin := cast.ToBool(builtinStr)
db = db.Where("is_builtin = ?", builtin)
}
doGet(ctx, !info, db, config.RESOURCE_AUTHORIZATION, commandTemplatePostHooks...) doGet(ctx, !info, db, config.RESOURCE_AUTHORIZATION, commandTemplatePostHooks...)
} }

View File

@@ -63,10 +63,14 @@ func (c *Controller) GetConfig(ctx *gin.Context) {
cfg, err := configService.GetConfig(ctx) cfg, err := configService.GetConfig(ctx)
if err != nil { if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) { if errors.Is(err, gorm.ErrRecordNotFound) {
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal, Data: map[string]any{"err": err}}) // Return default configuration if no config exists
defaultCfg := model.GetDefaultConfig()
ctx.JSON(http.StatusOK, NewHttpResponseWithData(defaultCfg))
return return
} }
ctx.AbortWithError(http.StatusInternalServerError, &myErrors.ApiError{Code: myErrors.ErrInternal, Data: map[string]any{"err": err}})
return
} }
ctx.JSON(http.StatusOK, NewHttpResponseWithData(cfg)) ctx.JSON(http.StatusOK, NewHttpResponseWithData(cfg))

View File

@@ -21,6 +21,7 @@ import (
"github.com/veops/oneterm/internal/acl" "github.com/veops/oneterm/internal/acl"
"github.com/veops/oneterm/internal/guacd" "github.com/veops/oneterm/internal/guacd"
"github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/service"
fileservice "github.com/veops/oneterm/internal/service/file" fileservice "github.com/veops/oneterm/internal/service/file"
gsession "github.com/veops/oneterm/internal/session" gsession "github.com/veops/oneterm/internal/session"
myErrors "github.com/veops/oneterm/pkg/errors" myErrors "github.com/veops/oneterm/pkg/errors"
@@ -78,10 +79,11 @@ func (c *Controller) FileLS(ctx *gin.Context) {
}, },
} }
if ok, err := hasAuthorization(ctx, sess); err != nil { // Check connect permission first
if result, err := service.DefaultAuthService.HasAuthorizationV2(ctx, sess, model.ActionConnect); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return return
} else if !ok { } else if !result.IsAllowed(model.ActionConnect) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}})
return return
} }
@@ -148,11 +150,12 @@ func (c *Controller) FileMkdir(ctx *gin.Context) {
}, },
} }
if ok, err := hasAuthorization(ctx, sess); err != nil { // Check file upload permission (mkdir is considered an upload operation)
if result, err := service.DefaultAuthService.HasAuthorizationV2(ctx, sess, model.ActionFileUpload); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return return
} else if !ok { } else if !result.IsAllowed(model.ActionFileUpload) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{"message": "No file upload permission"}})
return return
} }
@@ -189,11 +192,12 @@ func (c *Controller) FileUpload(ctx *gin.Context) {
}, },
} }
if ok, err := hasAuthorization(ctx, sess); err != nil { // Check file upload permission using V2 system
if result, err := service.DefaultAuthService.HasAuthorizationV2(ctx, sess, model.ActionFileUpload); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return return
} else if !ok { } else if !result.IsAllowed(model.ActionFileUpload) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{"message": "No file upload permission"}})
return return
} }
@@ -350,11 +354,12 @@ func (c *Controller) FileDownload(ctx *gin.Context) {
}, },
} }
if ok, err := hasAuthorization(ctx, sess); err != nil { // Check file download permission using V2 system
if result, err := service.DefaultAuthService.HasAuthorizationV2(ctx, sess, model.ActionFileDownload); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return return
} else if !ok { } else if !result.IsAllowed(model.ActionFileDownload) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{"message": "No file download permission"}})
return return
} }
@@ -431,7 +436,7 @@ func (c *Controller) RDPFileList(ctx *gin.Context) {
sessionId := ctx.Param("session_id") sessionId := ctx.Param("session_id")
path := ctx.DefaultQuery("path", "/") path := ctx.DefaultQuery("path", "/")
tunnel, err := c.validateRDPAccess(ctx, sessionId) tunnel, authResult, err := c.validateRDPAccess(ctx, sessionId)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "permission") { if strings.Contains(err.Error(), "permission") {
ctx.JSON(http.StatusForbidden, HttpResponse{ ctx.JSON(http.StatusForbidden, HttpResponse{
@@ -447,6 +452,9 @@ func (c *Controller) RDPFileList(ctx *gin.Context) {
return return
} }
// Connect permission already validated in validateRDPAccess
_ = authResult // Use the already validated result
// Check if RDP drive is enabled // Check if RDP drive is enabled
if !fileservice.IsRDPDriveEnabled(tunnel) { if !fileservice.IsRDPDriveEnabled(tunnel) {
ctx.JSON(http.StatusBadRequest, HttpResponse{ ctx.JSON(http.StatusBadRequest, HttpResponse{
@@ -503,7 +511,7 @@ func (c *Controller) RDPFileUpload(ctx *gin.Context) {
// Create progress record IMMEDIATELY when request starts // Create progress record IMMEDIATELY when request starts
fileservice.CreateTransferProgress(transferId, "rdp") fileservice.CreateTransferProgress(transferId, "rdp")
tunnel, err := c.validateRDPAccess(ctx, sessionId) tunnel, authResult, err := c.validateRDPAccess(ctx, sessionId)
if err != nil { if err != nil {
if strings.Contains(err.Error(), "permission") { if strings.Contains(err.Error(), "permission") {
ctx.JSON(http.StatusForbidden, HttpResponse{ ctx.JSON(http.StatusForbidden, HttpResponse{
@@ -519,6 +527,15 @@ func (c *Controller) RDPFileUpload(ctx *gin.Context) {
return return
} }
// Check specific file upload permission using already validated result
if !authResult.IsAllowed(model.ActionFileUpload) {
ctx.JSON(http.StatusForbidden, HttpResponse{
Code: http.StatusForbidden,
Message: "No file upload permission",
})
return
}
if !fileservice.IsRDPDriveEnabled(tunnel) { if !fileservice.IsRDPDriveEnabled(tunnel) {
logger.L().Error("RDP drive is not enabled for session", zap.String("sessionId", sessionId)) logger.L().Error("RDP drive is not enabled for session", zap.String("sessionId", sessionId))
ctx.JSON(http.StatusBadRequest, HttpResponse{ ctx.JSON(http.StatusBadRequest, HttpResponse{
@@ -726,7 +743,7 @@ func (c *Controller) RDPFileUpload(ctx *gin.Context) {
func (c *Controller) RDPFileDownload(ctx *gin.Context) { func (c *Controller) RDPFileDownload(ctx *gin.Context) {
sessionId := ctx.Param("session_id") sessionId := ctx.Param("session_id")
tunnel, validationErr := c.validateRDPAccess(ctx, sessionId) tunnel, authResult, validationErr := c.validateRDPAccess(ctx, sessionId)
if validationErr != nil { if validationErr != nil {
if strings.Contains(validationErr.Error(), "permission") { if strings.Contains(validationErr.Error(), "permission") {
ctx.JSON(http.StatusForbidden, HttpResponse{ ctx.JSON(http.StatusForbidden, HttpResponse{
@@ -742,6 +759,15 @@ func (c *Controller) RDPFileDownload(ctx *gin.Context) {
return return
} }
// Check specific file download permission using already validated result
if !authResult.IsAllowed(model.ActionFileDownload) {
ctx.JSON(http.StatusForbidden, HttpResponse{
Code: http.StatusForbidden,
Message: "No file download permission",
})
return
}
if !fileservice.IsRDPDriveEnabled(tunnel) { if !fileservice.IsRDPDriveEnabled(tunnel) {
ctx.JSON(http.StatusForbidden, HttpResponse{ ctx.JSON(http.StatusForbidden, HttpResponse{
Code: http.StatusForbidden, Code: http.StatusForbidden,
@@ -876,7 +902,7 @@ func (c *Controller) RDPFileMkdir(ctx *gin.Context) {
return return
} }
tunnel, validateErr := c.validateRDPAccess(ctx, sessionId) tunnel, authResult, validateErr := c.validateRDPAccess(ctx, sessionId)
if validateErr != nil { if validateErr != nil {
if strings.Contains(validateErr.Error(), "permission") { if strings.Contains(validateErr.Error(), "permission") {
ctx.JSON(http.StatusForbidden, HttpResponse{ ctx.JSON(http.StatusForbidden, HttpResponse{
@@ -892,6 +918,15 @@ func (c *Controller) RDPFileMkdir(ctx *gin.Context) {
return return
} }
// Check specific file upload permission using already validated result (mkdir is considered an upload operation)
if !authResult.IsAllowed(model.ActionFileUpload) {
ctx.JSON(http.StatusForbidden, HttpResponse{
Code: http.StatusForbidden,
Message: "No file upload permission",
})
return
}
// Check if upload is allowed (mkdir is considered an upload operation) // Check if upload is allowed (mkdir is considered an upload operation)
if !fileservice.IsRDPUploadAllowed(tunnel) { if !fileservice.IsRDPUploadAllowed(tunnel) {
ctx.JSON(http.StatusForbidden, HttpResponse{ ctx.JSON(http.StatusForbidden, HttpResponse{
@@ -927,23 +962,37 @@ func (c *Controller) RDPFileMkdir(ctx *gin.Context) {
}) })
} }
func (c *Controller) validateRDPAccess(ctx *gin.Context, sessionId string) (*guacd.Tunnel, error) { func (c *Controller) validateRDPAccess(ctx *gin.Context, sessionId string) (*guacd.Tunnel, *model.BatchAuthResult, error) {
currentUser, err := acl.GetSessionFromCtx(ctx) currentUser, err := acl.GetSessionFromCtx(ctx)
if err != nil || currentUser == nil { if err != nil || currentUser == nil {
return nil, fmt.Errorf("no permission to access this session") return nil, nil, fmt.Errorf("no permission to access this session")
} }
onlineSession := gsession.GetOnlineSessionById(sessionId) onlineSession := gsession.GetOnlineSessionById(sessionId)
if onlineSession == nil { if onlineSession == nil {
return nil, fmt.Errorf("session not found or not active") return nil, nil, fmt.Errorf("session not found or not active")
} }
tunnel := onlineSession.GuacdTunnel tunnel := onlineSession.GuacdTunnel
if tunnel == nil { if tunnel == nil {
return nil, fmt.Errorf("session not found or not active") return nil, nil, fmt.Errorf("session not found or not active")
} }
return tunnel, nil // V2 authorization check for RDP file operations
// Check connect permission and file permissions using batch check
result, err := service.DefaultAuthService.HasAuthorizationV2(ctx, onlineSession,
model.ActionConnect, model.ActionFileUpload, model.ActionFileDownload)
if err != nil {
return nil, nil, fmt.Errorf("authorization check failed: %w", err)
}
// Must have connect permission to access the session
if !result.IsAllowed(model.ActionConnect) {
return nil, nil, fmt.Errorf("no permission to access this session")
}
// Return tunnel and permission result for efficient reuse
return tunnel, result, nil
} }
// ============================================================================= // =============================================================================
@@ -985,11 +1034,11 @@ func (c *Controller) SftpFileLS(ctx *gin.Context) {
return return
} }
// Check authorization using the same logic as legacy API // Check connect permission for SFTP file listing
if ok, err := hasAuthorization(ctx, onlineSession); err != nil { if result, err := service.DefaultAuthService.HasAuthorizationV2(ctx, onlineSession, model.ActionConnect); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return return
} else if !ok { } else if !result.IsAllowed(model.ActionConnect) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}})
return return
} }
@@ -1073,12 +1122,12 @@ func (c *Controller) SftpFileMkdir(ctx *gin.Context) {
return return
} }
// Check authorization using the same logic as legacy API // Check file upload permission for SFTP mkdir
if ok, err := hasAuthorization(ctx, onlineSession); err != nil { if result, err := service.DefaultAuthService.HasAuthorizationV2(ctx, onlineSession, model.ActionFileUpload); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return return
} else if !ok { } else if !result.IsAllowed(model.ActionFileUpload) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{"message": "No file upload permission"}})
return return
} }
@@ -1239,11 +1288,12 @@ func (c *Controller) SftpFileUpload(ctx *gin.Context) {
return return
} }
if ok, err := hasAuthorization(ctx, onlineSession); err != nil { // Check file upload permission for SFTP upload
if result, err := service.DefaultAuthService.HasAuthorizationV2(ctx, onlineSession, model.ActionFileUpload); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return return
} else if !ok { } else if !result.IsAllowed(model.ActionFileUpload) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{"message": "No file upload permission"}})
return return
} }
@@ -1444,12 +1494,12 @@ func (c *Controller) SftpFileDownload(ctx *gin.Context) {
return return
} }
// Check authorization using the same logic as legacy API // Check file download permission for SFTP download
if ok, err := hasAuthorization(ctx, onlineSession); err != nil { if result, err := service.DefaultAuthService.HasAuthorizationV2(ctx, onlineSession, model.ActionFileDownload); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusBadRequest, &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}})
return return
} else if !ok { } else if !result.IsAllowed(model.ActionFileDownload) {
ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{}}) ctx.AbortWithError(http.StatusForbidden, &myErrors.ApiError{Code: myErrors.ErrNoPerm, Data: map[string]any{"message": "No file download permission"}})
return return
} }

View File

@@ -111,7 +111,9 @@ func (c *Controller) GetGateways(ctx *gin.Context) {
// Apply authorization filter if needed // Apply authorization filter if needed
if info && !acl.IsAdmin(currentUser) { if info && !acl.IsAdmin(currentUser) {
assetIds, err := GetAssetIdsByAuthorization(ctx) // Use V2 authorization system for asset filtering
authV2Service := service.NewAuthorizationV2Service()
_, assetIds, _, err := authV2Service.GetAuthorizationScopeByACL(ctx)
if err != nil { if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}})
return return

View File

@@ -8,7 +8,6 @@ import (
"github.com/samber/lo" "github.com/samber/lo"
"github.com/spf13/cast" "github.com/spf13/cast"
"github.com/veops/oneterm/internal/acl"
"github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/repository" "github.com/veops/oneterm/internal/repository"
"github.com/veops/oneterm/internal/service" "github.com/veops/oneterm/internal/service"
@@ -82,16 +81,21 @@ func (c *Controller) UpdateNode(ctx *gin.Context) {
// @Success 200 {object} HttpResponse{data=ListData{list=[]model.Node}} // @Success 200 {object} HttpResponse{data=ListData{list=[]model.Node}}
// @Router /node [get] // @Router /node [get]
func (c *Controller) GetNodes(ctx *gin.Context) { func (c *Controller) GetNodes(ctx *gin.Context) {
currentUser, _ := acl.GetSessionFromCtx(ctx)
info := cast.ToBool(ctx.Query("info")) info := cast.ToBool(ctx.Query("info"))
recursive := cast.ToBool(ctx.Query("recursive")) recursive := cast.ToBool(ctx.Query("recursive"))
db, err := nodeService.BuildQuery(ctx, currentUser, info) // Build query with integrated V2 authorization filter
db, err := nodeService.BuildQueryWithAuthorization(ctx)
if err != nil { if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}}) ctx.AbortWithError(http.StatusInternalServerError, &errors.ApiError{Code: errors.ErrInternal, Data: map[string]any{"err": err}})
return return
} }
// Apply info mode settings
if info {
db = db.Select("id", "parent_id", "name")
}
if recursive { if recursive {
treeNodes, err := nodeService.GetNodesTree(ctx, db, !info, config.RESOURCE_NODE) treeNodes, err := nodeService.GetNodesTree(ctx, db, !info, config.RESOURCE_NODE)
if err != nil { if err != nil {

View File

@@ -0,0 +1,198 @@
package controller
import (
"net/http"
"github.com/gin-gonic/gin"
"github.com/spf13/cast"
"github.com/veops/oneterm/internal/acl"
"github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/service"
pkgErrors "github.com/veops/oneterm/pkg/errors"
)
var (
timeTemplateService = service.NewTimeTemplateService()
)
// CreateTimeTemplate godoc
//
// @Tags time_template
// @Param template body model.TimeTemplate true "time template"
// @Success 200 {object} HttpResponse
// @Router /time_template [post]
func (c *Controller) CreateTimeTemplate(ctx *gin.Context) {
// Time templates require admin permission - no ACL needed
currentUser, _ := acl.GetSessionFromCtx(ctx)
if !acl.IsAdmin(currentUser) {
ctx.AbortWithError(http.StatusForbidden, &pkgErrors.ApiError{Code: pkgErrors.ErrNoPerm, Data: map[string]any{"perm": "admin"}})
return
}
template := &model.TimeTemplate{}
doCreate(ctx, false, template, "", timeTemplatePreHooks...)
}
// DeleteTimeTemplate godoc
//
// @Tags time_template
// @Param id path int true "template id"
// @Success 200 {object} HttpResponse
// @Router /time_template/:id [delete]
func (c *Controller) DeleteTimeTemplate(ctx *gin.Context) {
// Time templates require admin permission - no ACL needed
currentUser, _ := acl.GetSessionFromCtx(ctx)
if !acl.IsAdmin(currentUser) {
ctx.AbortWithError(http.StatusForbidden, &pkgErrors.ApiError{Code: pkgErrors.ErrNoPerm, Data: map[string]any{"perm": "admin"}})
return
}
doDelete(ctx, false, &model.TimeTemplate{}, "", timeTemplateDeleteChecks...)
}
// UpdateTimeTemplate godoc
//
// @Tags time_template
// @Param id path int true "template id"
// @Param template body model.TimeTemplate true "time template"
// @Success 200 {object} HttpResponse
// @Router /time_template/:id [put]
func (c *Controller) UpdateTimeTemplate(ctx *gin.Context) {
// Time templates require admin permission - no ACL needed
currentUser, _ := acl.GetSessionFromCtx(ctx)
if !acl.IsAdmin(currentUser) {
ctx.AbortWithError(http.StatusForbidden, &pkgErrors.ApiError{Code: pkgErrors.ErrNoPerm, Data: map[string]any{"perm": "admin"}})
return
}
template := &model.TimeTemplate{}
doUpdate(ctx, false, template, "", timeTemplatePreHooks...)
}
// GetTimeTemplates godoc
//
// @Tags time_template
// @Param page_index query int false "page index"
// @Param page_size query int false "page size"
// @Param search query string false "search by name or description"
// @Param category query string false "template category"
// @Param active query bool false "filter by active status"
// @Param info query bool false "info mode"
// @Success 200 {object} HttpResponse{data=[]model.TimeTemplate}
// @Router /time_template [get]
func (c *Controller) GetTimeTemplates(ctx *gin.Context) {
info := cast.ToBool(ctx.Query("info"))
// Build base query using service layer with all filters
db, err := timeTemplateService.BuildQuery(ctx)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &pkgErrors.ApiError{Code: pkgErrors.ErrInternal, Data: map[string]any{"err": err}})
return
}
doGet(ctx, !info, db, "", timeTemplatePostHooks...)
}
// GetBuiltInTimeTemplates godoc
//
// @Tags time_template
// @Success 200 {object} HttpResponse{data=[]model.TimeTemplate}
// @Router /time_template/builtin [get]
func (c *Controller) GetBuiltInTimeTemplates(ctx *gin.Context) {
templates, err := timeTemplateService.GetBuiltInTemplates(ctx)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &pkgErrors.ApiError{Code: pkgErrors.ErrInternal, Data: map[string]any{"err": err}})
return
}
ctx.JSON(http.StatusOK, NewHttpResponseWithData(templates))
}
// CheckTimeAccess godoc
//
// @Tags time_template
// @Param request body CheckTimeAccessRequest true "time access check request"
// @Success 200 {object} HttpResponse{data=CheckTimeAccessResponse}
// @Router /time_template/check [post]
func (c *Controller) CheckTimeAccess(ctx *gin.Context) {
var req CheckTimeAccessRequest
if err := ctx.ShouldBindBodyWithJSON(&req); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &pkgErrors.ApiError{Code: pkgErrors.ErrInvalidArgument})
return
}
allowed, err := timeTemplateService.CheckTimeAccess(ctx, req.TemplateID, req.Timezone)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &pkgErrors.ApiError{Code: pkgErrors.ErrInternal, Data: map[string]any{"err": err}})
return
}
response := CheckTimeAccessResponse{
Allowed: allowed,
TemplateID: req.TemplateID,
Timezone: req.Timezone,
CheckedAt: "now",
}
ctx.JSON(http.StatusOK, NewHttpResponseWithData(response))
}
// InitBuiltInTemplates godoc
//
// @Tags time_template
// @Success 200 {object} HttpResponse
// @Router /time_template/init [post]
func (c *Controller) InitBuiltInTemplates(ctx *gin.Context) {
if err := timeTemplateService.InitializeBuiltInTemplates(ctx); err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &pkgErrors.ApiError{Code: pkgErrors.ErrInternal, Data: map[string]any{"err": err}})
return
}
ctx.JSON(http.StatusOK, NewHttpResponseWithData("Built-in time templates initialized successfully"))
}
// CheckTimeAccessRequest represents a time access check request
type CheckTimeAccessRequest struct {
TemplateID int `json:"template_id" binding:"required,gt=0"`
Timezone string `json:"timezone"`
}
// CheckTimeAccessResponse represents a time access check response
type CheckTimeAccessResponse struct {
Allowed bool `json:"allowed"`
TemplateID int `json:"template_id"`
Timezone string `json:"timezone"`
CheckedAt string `json:"checked_at"`
}
// Time template hooks
var (
timeTemplatePreHooks = []preHook[*model.TimeTemplate]{
func(ctx *gin.Context, data *model.TimeTemplate) {
if err := timeTemplateService.ValidateTimeTemplate(data); err != nil {
ctx.AbortWithError(http.StatusBadRequest, &pkgErrors.ApiError{Code: pkgErrors.ErrInvalidArgument, Data: map[string]any{"err": err.Error()}})
}
},
}
timeTemplatePostHooks = []postHook[*model.TimeTemplate]{
func(ctx *gin.Context, data []*model.TimeTemplate) {
// Add any post-processing logic here
},
}
timeTemplateDeleteChecks = []deleteCheck{
func(ctx *gin.Context, id int) {
// Check if template is built-in
template, err := timeTemplateService.GetTimeTemplate(ctx, id)
if err != nil {
ctx.AbortWithError(http.StatusInternalServerError, &pkgErrors.ApiError{Code: pkgErrors.ErrInternal, Data: map[string]any{"err": err}})
return
}
if template != nil && template.IsBuiltIn {
ctx.AbortWithError(http.StatusBadRequest, &pkgErrors.ApiError{Code: pkgErrors.ErrInvalidArgument, Data: map[string]any{"err": "cannot delete built-in time template"}})
}
},
}
)

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -38,6 +38,7 @@ func SetupRouter(r *gin.Engine) {
asset.DELETE("/:id", c.DeleteAsset) asset.DELETE("/:id", c.DeleteAsset)
asset.PUT("/:id", c.UpdateAsset) asset.PUT("/:id", c.UpdateAsset)
asset.GET("", c.GetAssets) asset.GET("", c.GetAssets)
asset.GET("/:id/permissions", c.GetAssetPermissions)
} }
node := v1.Group("node") node := v1.Group("node")
@@ -147,10 +148,21 @@ func SetupRouter(r *gin.Engine) {
authorization := v1.Group("/authorization") authorization := v1.Group("/authorization")
{ {
authorization.POST("", c.UpsertAuthorization) authorization.POST("", c.UpsertAuthorization)
authorization.DELETE("/:id", c.DeleteAccount) authorization.DELETE("/:id", c.DeleteAuthorization)
authorization.GET("", c.GetAuthorizations) authorization.GET("", c.GetAuthorizations)
} }
authorizationV2 := v1.Group("/authorization_v2")
{
authorizationV2.POST("", c.CreateAuthorizationV2)
authorizationV2.GET("", c.GetAuthorizationsV2)
authorizationV2.GET("/:id", c.GetAuthorizationV2)
authorizationV2.PUT("/:id", c.UpdateAuthorizationV2)
authorizationV2.DELETE("/:id", c.DeleteAuthorizationV2)
authorizationV2.POST("/:id/clone", c.CloneAuthorizationV2)
authorizationV2.POST("/check", c.CheckPermissionV2)
}
quickCommand := v1.Group("/quick_command") quickCommand := v1.Group("/quick_command")
{ {
quickCommand.POST("", c.CreateQuickCommand) quickCommand.POST("", c.CreateQuickCommand)
@@ -189,5 +201,28 @@ func SetupRouter(r *gin.Engine) {
storage.PUT("/configs/:id/set-primary", c.SetPrimaryStorage) storage.PUT("/configs/:id/set-primary", c.SetPrimaryStorage)
storage.PUT("/configs/:id/toggle", c.ToggleStorageProvider) storage.PUT("/configs/:id/toggle", c.ToggleStorageProvider)
} }
// Time template management routes
timeTemplate := v1.Group("/time_template")
{
timeTemplate.POST("", c.CreateTimeTemplate)
timeTemplate.DELETE("/:id", c.DeleteTimeTemplate)
timeTemplate.PUT("/:id", c.UpdateTimeTemplate)
timeTemplate.GET("", c.GetTimeTemplates)
timeTemplate.GET("/builtin", c.GetBuiltInTimeTemplates)
timeTemplate.POST("/check", c.CheckTimeAccess)
timeTemplate.POST("/init", c.InitBuiltInTemplates)
}
// Command template management routes
commandTemplate := v1.Group("/command_template")
{
commandTemplate.POST("", c.CreateCommandTemplate)
commandTemplate.DELETE("/:id", c.DeleteCommandTemplate)
commandTemplate.PUT("/:id", c.UpdateCommandTemplate)
commandTemplate.GET("", c.GetCommandTemplates)
commandTemplate.GET("/builtin", c.GetBuiltInCommandTemplates)
commandTemplate.GET("/:id/commands", c.GetTemplateCommands)
}
} }
} }

View File

@@ -4,7 +4,6 @@ import (
"errors" "errors"
"fmt" "fmt"
"net/http" "net/http"
"regexp"
"strings" "strings"
"time" "time"
@@ -207,41 +206,61 @@ func DoConnect(ctx *gin.Context, ws *websocket.Conn) (sess *gsession.Session, er
sess.SshParser = gsession.NewParser(sess.SessionId, w, h) sess.SshParser = gsession.NewParser(sess.SessionId, w, h)
sess.SshParser.Protocol = sess.Protocol sess.SshParser.Protocol = sess.Protocol
sessionService := service.NewSessionService() // Use V2 command analyzer instead of legacy method
cmds, err := sessionService.GetSshParserCommands(ctx, []int(asset.AccessAuth.CmdIds)) commandAnalyzer := service.NewCommandAnalyzer()
cmds, err := commandAnalyzer.AnalyzeSessionCommands(ctx, sess)
if err != nil { if err != nil {
return sess, err logger.L().Error("Failed to analyze session commands", zap.String("sessionId", sess.SessionId), zap.Error(err))
// Continue with empty command list (no command restrictions)
cmds = []*model.Command{}
} }
sess.SshParser.Cmds = cmds sess.SshParser.Cmds = cmds
for _, c := range sess.SshParser.Cmds {
if c.IsRe {
c.Re, _ = regexp.Compile(c.Cmd)
}
}
if sess.SshRecoder, err = gsession.NewAsciinema(sess.SessionId, w, h); err != nil { if sess.SshRecoder, err = gsession.NewAsciinema(sess.SessionId, w, h); err != nil {
return sess, err return sess, err
} }
} }
if sess.SessionType == model.SESSIONTYPE_WEB { switch sess.SessionType {
case model.SESSIONTYPE_WEB:
sess.ClientIp = ctx.ClientIP() sess.ClientIp = ctx.ClientIP()
} else if sess.SessionType == model.SESSIONTYPE_CLIENT { case model.SESSIONTYPE_CLIENT:
sess.ClientIp = ctx.RemoteIP() sess.ClientIp = ctx.RemoteIP()
} }
if !protocols.CheckTime(asset.AccessAuth) { // V2 authorization check - determine required permissions based on protocol
err = &myErrors.ApiError{Code: myErrors.ErrAccessTime} protocol := strings.Split(sess.Protocol, ":")[0]
return var requiredActions []model.AuthAction
// All protocols need connect permission
requiredActions = append(requiredActions, model.ActionConnect)
// SSH protocol needs file permissions since it will initialize SFTP client
if protocol == "ssh" {
requiredActions = append(requiredActions, model.ActionFileUpload, model.ActionFileDownload)
} }
if ok, err := service.DefaultAuthService.HasAuthorization(ctx, sess); err != nil {
// RDP/VNC are handled separately in ConnectGuacd with their own batch permission check
// but we still check connect permission here for consistency
result, err := service.DefaultAuthService.HasAuthorizationV2(ctx, sess, requiredActions...)
if err != nil {
err = &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}} err = &myErrors.ApiError{Code: myErrors.ErrInvalidArgument, Data: map[string]any{"err": err}}
return sess, err return sess, err
} else if !ok { }
// Check connect permission (required for all protocols)
if !result.IsAllowed(model.ActionConnect) {
err = &myErrors.ApiError{Code: myErrors.ErrUnauthorized, Data: map[string]any{"perm": "connect"}} err = &myErrors.ApiError{Code: myErrors.ErrUnauthorized, Data: map[string]any{"perm": "connect"}}
return sess, err return sess, err
} }
switch strings.Split(sess.Protocol, ":")[0] { // For SSH, check if user has any file permissions before initializing SFTP
hasFilePermissions := false
if protocol == "ssh" {
hasFilePermissions = result.IsAllowed(model.ActionFileUpload) || result.IsAllowed(model.ActionFileDownload)
}
switch protocol {
case "ssh": case "ssh":
go protocols.ConnectSsh(ctx, sess, asset, account, gateway) go protocols.ConnectSsh(ctx, sess, asset, account, gateway)
case "redis", "mysql", "mongodb", "postgresql": case "redis", "mysql", "mongodb", "postgresql":
@@ -263,10 +282,10 @@ func DoConnect(ctx *gin.Context, ws *websocket.Conn) (sess *gsession.Session, er
gsession.GetOnlineSession().Store(sess.SessionId, sess) gsession.GetOnlineSession().Store(sess.SessionId, sess)
gsession.UpsertSession(sess) gsession.UpsertSession(sess)
// Initialize session-based file client for high-performance file operations // Initialize session-based file client only for SSH and only if user has file permissions
// Only for SSH-based protocols that support SFTP switch protocol {
protocol := strings.Split(sess.Protocol, ":")[0] case "ssh":
if protocol == "ssh" { if hasFilePermissions {
if err := fileservice.DefaultFileService.InitSessionFileClient(sess.SessionId, sess.AssetId, sess.AccountId); err != nil { if err := fileservice.DefaultFileService.InitSessionFileClient(sess.SessionId, sess.AssetId, sess.AccountId); err != nil {
logger.L().Warn("Failed to initialize session file client", logger.L().Warn("Failed to initialize session file client",
zap.String("sessionId", sess.SessionId), zap.String("sessionId", sess.SessionId),
@@ -280,7 +299,13 @@ func DoConnect(ctx *gin.Context, ws *websocket.Conn) (sess *gsession.Session, er
zap.Int("assetId", sess.AssetId), zap.Int("assetId", sess.AssetId),
zap.Int("accountId", sess.AccountId)) zap.Int("accountId", sess.AccountId))
} }
} else if protocol == "rdp" || protocol == "vnc" { } else {
logger.L().Info("Skipping SFTP client initialization - no file permissions",
zap.String("sessionId", sess.SessionId),
zap.Int("assetId", sess.AssetId),
zap.Int("accountId", sess.AccountId))
}
case "rdp", "vnc":
logger.L().Debug("Skipping session file client initialization for Guacamole protocol", logger.L().Debug("Skipping session file client initialization for Guacamole protocol",
zap.String("protocol", protocol), zap.String("protocol", protocol),
zap.String("sessionId", sess.SessionId)) zap.String("sessionId", sess.SessionId))

View File

@@ -30,7 +30,28 @@ func ConnectGuacd(ctx *gin.Context, sess *gsession.Session, asset *model.Asset,
w, h, dpi := cast.ToInt(ctx.Query("w")), cast.ToInt(ctx.Query("h")), cast.ToInt(ctx.Query("dpi")) w, h, dpi := cast.ToInt(ctx.Query("w")), cast.ToInt(ctx.Query("h")), cast.ToInt(ctx.Query("dpi"))
t, err := guacd.NewTunnel("", sess.SessionId, w, h, dpi, sess.Protocol, asset, account, gateway) // Get permissions for guacd connection using batch check
permissions := &guacd.PermissionInfo{}
// Check all relevant permissions in one batch call
batchResult, err := service.DefaultAuthService.HasAuthorizationV2(ctx, sess,
model.ActionCopy,
model.ActionPaste,
model.ActionFileUpload,
model.ActionFileDownload)
if err != nil {
logger.L().Warn("Failed to check permissions, using default settings", zap.Error(err))
// Continue with default (denied) permissions if check fails
} else {
// Extract individual permissions from batch result
permissions.AllowCopy = batchResult.IsAllowed(model.ActionCopy)
permissions.AllowPaste = batchResult.IsAllowed(model.ActionPaste)
permissions.AllowFileUpload = batchResult.IsAllowed(model.ActionFileUpload)
permissions.AllowFileDownload = batchResult.IsAllowed(model.ActionFileDownload)
}
t, err := guacd.NewTunnel("", sess.SessionId, w, h, dpi, sess.Protocol, asset, account, gateway, permissions)
if err != nil { if err != nil {
logger.L().Error("guacd tunnel failed", zap.Error(err)) logger.L().Error("guacd tunnel failed", zap.Error(err))
return return
@@ -135,7 +156,8 @@ func MonitGuacd(ctx *gin.Context, sess *gsession.Session, chs *gsession.SessionC
chs.ErrChan <- err chs.ErrChan <- err
}() }()
t, err := guacd.NewTunnel(sess.ConnectionId, "", w, h, dpi, ":", nil, nil, nil) // For monitoring, no permissions needed since it's read-only
t, err := guacd.NewTunnel(sess.ConnectionId, "", w, h, dpi, ":", nil, nil, nil, nil)
if err != nil { if err != nil {
logger.L().Error("guacd tunnel failed", zap.Error(err)) logger.L().Error("guacd tunnel failed", zap.Error(err))
return return

View File

@@ -34,6 +34,14 @@ const (
DRIVE_NAME = "drive-name" DRIVE_NAME = "drive-name"
) )
// PermissionInfo contains permission information for guacd connection
type PermissionInfo struct {
AllowCopy bool
AllowPaste bool
AllowFileUpload bool
AllowFileDownload bool
}
type Configuration struct { type Configuration struct {
Protocol string Protocol string
Parameters map[string]string Parameters map[string]string
@@ -57,7 +65,7 @@ type Tunnel struct {
drivePath string drivePath string
} }
func NewTunnel(connectionId, sessionId string, w, h, dpi int, protocol string, asset *model.Asset, account *model.Account, gateway *model.Gateway) (t *Tunnel, err error) { func NewTunnel(connectionId, sessionId string, w, h, dpi int, protocol string, asset *model.Asset, account *model.Account, gateway *model.Gateway, permissions *PermissionInfo) (t *Tunnel, err error) {
var hostPort string var hostPort string
if strings.Contains(config.Cfg.Guacd.Host, ":") { if strings.Contains(config.Cfg.Guacd.Host, ":") {
// IPv6 address // IPv6 address
@@ -70,9 +78,34 @@ func NewTunnel(connectionId, sessionId string, w, h, dpi int, protocol string, a
if err != nil { if err != nil {
return return
} }
ss := strings.Split(protocol, ":") // Find the port for the protocol from asset.Protocols
protocol, port := ss[0], ss[1] var port string
cfg := model.GlobalConfig.Load() protocolLower := strings.ToLower(protocol)
for _, p := range asset.Protocols {
if strings.HasPrefix(strings.ToLower(p), protocolLower) {
parts := strings.Split(p, ":")
if len(parts) >= 2 {
port = parts[1]
break
}
}
}
// Fallback to default ports if not found in asset.Protocols
if port == "" {
switch protocolLower {
case "rdp":
port = "3389"
case "vnc":
port = "5900"
default:
port = "22" // SSH default
}
}
// Determine if file transfer should be enabled
enableFileTransfer := permissions != nil && (permissions.AllowFileUpload || permissions.AllowFileDownload)
t = &Tunnel{ t = &Tunnel{
conn: conn, conn: conn,
reader: bufio.NewReader(conn), reader: bufio.NewReader(conn),
@@ -84,7 +117,7 @@ func NewTunnel(connectionId, sessionId string, w, h, dpi int, protocol string, a
Parameters: lo.TernaryF( Parameters: lo.TernaryF(
connectionId == "", connectionId == "",
func() map[string]string { func() map[string]string {
return map[string]string{ params := map[string]string{
"version": VERSION, "version": VERSION,
"client-name": "OneTerm", "client-name": "OneTerm",
"recording-path": RECORDING_PATH, "recording-path": RECORDING_PATH,
@@ -98,22 +131,36 @@ func NewTunnel(connectionId, sessionId string, w, h, dpi int, protocol string, a
"port": port, "port": port,
"username": account.Account, "username": account.Account,
"password": account.Password, "password": account.Password,
"disable-copy": cast.ToString(lo.Ternary(strings.Contains(protocol, "rdp"), !cfg.RdpConfig.Copy, !cfg.VncConfig.Copy)),
"disable-paste": cast.ToString(lo.Ternary(strings.Contains(protocol, "rdp"), !cfg.RdpConfig.Paste, !cfg.VncConfig.Paste)),
"resize-method": "display-update", "resize-method": "display-update",
// Set file transfer related parameters from config
// DRIVE_ENABLE: cast.ToString(lo.Ternary(strings.Contains(protocol, "rdp"), cfg.RdpConfig.EnableDrive, false)),
// DRIVE_PATH: cast.ToString(lo.Ternary(strings.Contains(protocol, "rdp"), cfg.RdpConfig.DrivePath, "")),
// DRIVE_CREATE_PATH: cast.ToString(lo.Ternary(strings.Contains(protocol, "rdp"), cfg.RdpConfig.CreateDrivePath, false)),
// DRIVE_DISABLE_UPLOAD: cast.ToString(lo.Ternary(strings.Contains(protocol, "rdp"), cfg.RdpConfig.DisableUpload, false)),
// DRIVE_DISABLE_DOWNLOAD: cast.ToString(lo.Ternary(strings.Contains(protocol, "rdp"), cfg.RdpConfig.DisableDownload, false)),
DRIVE_ENABLE: "true",
DRIVE_PATH: fmt.Sprintf("/rdp/asset_%d", asset.Id),
DRIVE_CREATE_PATH: "true",
DRIVE_DISABLE_UPLOAD: "false",
DRIVE_DISABLE_DOWNLOAD: "false",
DRIVE_NAME: "Drive",
} }
// Set permission-based parameters
if permissions != nil {
// Copy/Paste permissions
params["disable-copy"] = cast.ToString(!permissions.AllowCopy)
params["disable-paste"] = cast.ToString(!permissions.AllowPaste)
// File transfer permissions
params[DRIVE_ENABLE] = cast.ToString(enableFileTransfer)
params[DRIVE_DISABLE_UPLOAD] = cast.ToString(!permissions.AllowFileUpload)
params[DRIVE_DISABLE_DOWNLOAD] = cast.ToString(!permissions.AllowFileDownload)
} else {
// Default to deny all permissions if no permissions provided
params["disable-copy"] = "true"
params["disable-paste"] = "true"
params[DRIVE_ENABLE] = "false"
params[DRIVE_DISABLE_UPLOAD] = "true"
params[DRIVE_DISABLE_DOWNLOAD] = "true"
}
// Only set file transfer parameters if enabled
if enableFileTransfer {
params[DRIVE_PATH] = fmt.Sprintf("/rdp/asset_%d", asset.Id)
params[DRIVE_CREATE_PATH] = "true"
params[DRIVE_NAME] = "Drive"
}
return params
}, func() map[string]string { }, func() map[string]string {
return map[string]string{ return map[string]string{
"width": cast.ToString(w), "width": cast.ToString(w),

View File

@@ -242,4 +242,370 @@ var (
One: "\x1b[31;47m Welcome: {{.User}}", One: "\x1b[31;47m Welcome: {{.User}}",
Other: "\x1b[31;47m Welcome: {{.User}}", Other: "\x1b[31;47m Welcome: {{.User}}",
} }
// Predefined dangerous commands
CmdDeleteRootDir = &i18n.Message{
ID: "CmdDeleteRootDir",
One: "Delete root directory",
Other: "Delete root directory",
}
CmdDeleteRootDirDesc = &i18n.Message{
ID: "CmdDeleteRootDirDesc",
One: "Prohibit deletion of root directory, this will destroy the entire system",
Other: "Prohibit deletion of root directory, this will destroy the entire system",
}
CmdDeleteSystemDirs = &i18n.Message{
ID: "CmdDeleteSystemDirs",
One: "Delete system directories",
Other: "Delete system directories",
}
CmdDeleteSystemDirsDesc = &i18n.Message{
ID: "CmdDeleteSystemDirsDesc",
One: "Prohibit deletion of critical system directories",
Other: "Prohibit deletion of critical system directories",
}
CmdDiskDestruction = &i18n.Message{
ID: "CmdDiskDestruction",
One: "Disk destruction operations",
Other: "Disk destruction operations",
}
CmdDiskDestructionDesc = &i18n.Message{
ID: "CmdDiskDestructionDesc",
One: "Prohibit writing random data to disk devices, will destroy data",
Other: "Prohibit writing random data to disk devices, will destroy data",
}
CmdFormatDisk = &i18n.Message{
ID: "CmdFormatDisk",
One: "Format disk",
Other: "Format disk",
}
CmdFormatDiskDesc = &i18n.Message{
ID: "CmdFormatDiskDesc",
One: "Prohibit formatting disk partitions",
Other: "Prohibit formatting disk partitions",
}
CmdForkBomb = &i18n.Message{
ID: "CmdForkBomb",
One: "Fork bomb",
Other: "Fork bomb",
}
CmdForkBombDesc = &i18n.Message{
ID: "CmdForkBombDesc",
One: "Prohibit fork bomb attacks",
Other: "Prohibit fork bomb attacks",
}
CmdSystemReboot = &i18n.Message{
ID: "CmdSystemReboot",
One: "System reboot shutdown",
Other: "System reboot shutdown",
}
CmdSystemRebootDesc = &i18n.Message{
ID: "CmdSystemRebootDesc",
One: "Restrict system reboot and shutdown operations",
Other: "Restrict system reboot and shutdown operations",
}
CmdModifySystemFiles = &i18n.Message{
ID: "CmdModifySystemFiles",
One: "Modify critical system files",
Other: "Modify critical system files",
}
CmdModifySystemFilesDesc = &i18n.Message{
ID: "CmdModifySystemFilesDesc",
One: "Restrict direct modification of system configuration files",
Other: "Restrict direct modification of system configuration files",
}
CmdDropDatabase = &i18n.Message{
ID: "CmdDropDatabase",
One: "Drop database",
Other: "Drop database",
}
CmdDropDatabaseDesc = &i18n.Message{
ID: "CmdDropDatabaseDesc",
One: "Restrict dropping entire databases",
Other: "Restrict dropping entire databases",
}
CmdTruncateTable = &i18n.Message{
ID: "CmdTruncateTable",
One: "Truncate table data",
Other: "Truncate table data",
}
CmdTruncateTableDesc = &i18n.Message{
ID: "CmdTruncateTableDesc",
One: "Restrict clearing all table data",
Other: "Restrict clearing all table data",
}
CmdModifyPermissions = &i18n.Message{
ID: "CmdModifyPermissions",
One: "Modify user permissions",
Other: "Modify user permissions",
}
CmdModifyPermissionsDesc = &i18n.Message{
ID: "CmdModifyPermissionsDesc",
One: "Restrict modifying root directory permissions",
Other: "Restrict modifying root directory permissions",
}
CmdDropTable = &i18n.Message{
ID: "CmdDropTable",
One: "Drop table",
Other: "Drop table",
}
CmdDropTableDesc = &i18n.Message{
ID: "CmdDropTableDesc",
One: "Warning: Dropping database table",
Other: "Warning: Dropping database table",
}
CmdServiceControl = &i18n.Message{
ID: "CmdServiceControl",
One: "Service control commands",
Other: "Service control commands",
}
CmdServiceControlDesc = &i18n.Message{
ID: "CmdServiceControlDesc",
One: "Warning: Operating on system services",
Other: "Warning: Operating on system services",
}
CmdNetworkConfig = &i18n.Message{
ID: "CmdNetworkConfig",
One: "Network configuration modification",
Other: "Network configuration modification",
}
CmdNetworkConfigDesc = &i18n.Message{
ID: "CmdNetworkConfigDesc",
One: "Warning: Modifying network configuration",
Other: "Warning: Modifying network configuration",
}
CmdUserManagement = &i18n.Message{
ID: "CmdUserManagement",
One: "User management",
Other: "User management",
}
CmdUserManagementDesc = &i18n.Message{
ID: "CmdUserManagementDesc",
One: "Warning: Performing user management operations",
Other: "Warning: Performing user management operations",
}
CmdKernelModule = &i18n.Message{
ID: "CmdKernelModule",
One: "Kernel module operations",
Other: "Kernel module operations",
}
CmdKernelModuleDesc = &i18n.Message{
ID: "CmdKernelModuleDesc",
One: "Warning: Operating on kernel modules",
Other: "Warning: Operating on kernel modules",
}
// Predefined command templates
TmplBasicSecurity = &i18n.Message{
ID: "TmplBasicSecurity",
One: "Basic Security Protection",
Other: "Basic Security Protection",
}
TmplBasicSecurityDesc = &i18n.Message{
ID: "TmplBasicSecurityDesc",
One: "Basic security command restrictions for all production environments",
Other: "Basic security command restrictions for all production environments",
}
TmplDatabaseProtection = &i18n.Message{
ID: "TmplDatabaseProtection",
One: "Production Database Protection",
Other: "Production Database Protection",
}
TmplDatabaseProtectionDesc = &i18n.Message{
ID: "TmplDatabaseProtectionDesc",
One: "Security policies to protect production databases",
Other: "Security policies to protect production databases",
}
TmplServiceRestrictions = &i18n.Message{
ID: "TmplServiceRestrictions",
One: "System Service Control Restrictions",
Other: "System Service Control Restrictions",
}
TmplServiceRestrictionsDesc = &i18n.Message{
ID: "TmplServiceRestrictionsDesc",
One: "Restrictions on critical system service operations",
Other: "Restrictions on critical system service operations",
}
TmplNetworkSecurity = &i18n.Message{
ID: "TmplNetworkSecurity",
One: "Network Security Control",
Other: "Network Security Control",
}
TmplNetworkSecurityDesc = &i18n.Message{
ID: "TmplNetworkSecurityDesc",
One: "Security controls for network configuration and user permissions",
Other: "Security controls for network configuration and user permissions",
}
TmplDevEnvironment = &i18n.Message{
ID: "TmplDevEnvironment",
One: "Development Environment Basic Restrictions",
Other: "Development Environment Basic Restrictions",
}
TmplDevEnvironmentDesc = &i18n.Message{
ID: "TmplDevEnvironmentDesc",
One: "Minimal security restrictions for development environments",
Other: "Minimal security restrictions for development environments",
}
// Command tags
TagDangerous = &i18n.Message{
ID: "TagDangerous",
One: "dangerous",
Other: "dangerous",
}
TagDelete = &i18n.Message{
ID: "TagDelete",
One: "delete",
Other: "delete",
}
TagSystem = &i18n.Message{
ID: "TagSystem",
One: "system",
Other: "system",
}
TagSystemDirs = &i18n.Message{
ID: "TagSystemDirs",
One: "system-dirs",
Other: "system-dirs",
}
TagDisk = &i18n.Message{
ID: "TagDisk",
One: "disk",
Other: "disk",
}
TagDestruction = &i18n.Message{
ID: "TagDestruction",
One: "destruction",
Other: "destruction",
}
TagFormat = &i18n.Message{
ID: "TagFormat",
One: "format",
Other: "format",
}
TagAttack = &i18n.Message{
ID: "TagAttack",
One: "attack",
Other: "attack",
}
TagResourceExhaustion = &i18n.Message{
ID: "TagResourceExhaustion",
One: "resource-exhaustion",
Other: "resource-exhaustion",
}
TagReboot = &i18n.Message{
ID: "TagReboot",
One: "reboot",
Other: "reboot",
}
TagShutdown = &i18n.Message{
ID: "TagShutdown",
One: "shutdown",
Other: "shutdown",
}
TagEdit = &i18n.Message{
ID: "TagEdit",
One: "edit",
Other: "edit",
}
TagSystemFiles = &i18n.Message{
ID: "TagSystemFiles",
One: "system-files",
Other: "system-files",
}
TagConfig = &i18n.Message{
ID: "TagConfig",
One: "config",
Other: "config",
}
TagDatabase = &i18n.Message{
ID: "TagDatabase",
One: "database",
Other: "database",
}
TagDrop = &i18n.Message{
ID: "TagDrop",
One: "drop",
Other: "drop",
}
TagClear = &i18n.Message{
ID: "TagClear",
One: "clear",
Other: "clear",
}
TagTruncate = &i18n.Message{
ID: "TagTruncate",
One: "truncate",
Other: "truncate",
}
TagPermissions = &i18n.Message{
ID: "TagPermissions",
One: "permissions",
Other: "permissions",
}
TagChmod = &i18n.Message{
ID: "TagChmod",
One: "chmod",
Other: "chmod",
}
TagSecurity = &i18n.Message{
ID: "TagSecurity",
One: "security",
Other: "security",
}
TagTable = &i18n.Message{
ID: "TagTable",
One: "table",
Other: "table",
}
TagService = &i18n.Message{
ID: "TagService",
One: "service",
Other: "service",
}
TagSystemctl = &i18n.Message{
ID: "TagSystemctl",
One: "systemctl",
Other: "systemctl",
}
TagControl = &i18n.Message{
ID: "TagControl",
One: "control",
Other: "control",
}
TagNetwork = &i18n.Message{
ID: "TagNetwork",
One: "network",
Other: "network",
}
TagFirewall = &i18n.Message{
ID: "TagFirewall",
One: "firewall",
Other: "firewall",
}
TagRouting = &i18n.Message{
ID: "TagRouting",
One: "routing",
Other: "routing",
}
TagUser = &i18n.Message{
ID: "TagUser",
One: "user",
Other: "user",
}
TagManagement = &i18n.Message{
ID: "TagManagement",
One: "management",
Other: "management",
}
TagKernel = &i18n.Message{
ID: "TagKernel",
One: "kernel",
Other: "kernel",
}
TagModule = &i18n.Message{
ID: "TagModule",
One: "module",
Other: "module",
}
) )

View File

@@ -153,3 +153,290 @@ other = "Bad Request: Invalid SSH public key"
[MsgWrongPvk] [MsgWrongPvk]
one = "Bad Request: Invalid SSH private key" one = "Bad Request: Invalid SSH private key"
other = "Bad Request: Invalid SSH private key" other = "Bad Request: Invalid SSH private key"
# Predefined dangerous commands
[CmdDeleteRootDir]
one = "Delete root directory"
other = "Delete root directory"
[CmdDeleteRootDirDesc]
one = "Prohibit deletion of root directory, this will destroy the entire system"
other = "Prohibit deletion of root directory, this will destroy the entire system"
[CmdDeleteSystemDirs]
one = "Delete system directories"
other = "Delete system directories"
[CmdDeleteSystemDirsDesc]
one = "Prohibit deletion of critical system directories"
other = "Prohibit deletion of critical system directories"
[CmdDiskDestruction]
one = "Disk destruction operations"
other = "Disk destruction operations"
[CmdDiskDestructionDesc]
one = "Prohibit writing random data to disk devices, will destroy data"
other = "Prohibit writing random data to disk devices, will destroy data"
[CmdFormatDisk]
one = "Format disk"
other = "Format disk"
[CmdFormatDiskDesc]
one = "Prohibit formatting disk partitions"
other = "Prohibit formatting disk partitions"
[CmdForkBomb]
one = "Fork bomb"
other = "Fork bomb"
[CmdForkBombDesc]
one = "Prohibit fork bomb attacks"
other = "Prohibit fork bomb attacks"
[CmdSystemReboot]
one = "System reboot shutdown"
other = "System reboot shutdown"
[CmdSystemRebootDesc]
one = "Restrict system reboot and shutdown operations"
other = "Restrict system reboot and shutdown operations"
[CmdModifySystemFiles]
one = "Modify critical system files"
other = "Modify critical system files"
[CmdModifySystemFilesDesc]
one = "Restrict direct modification of system configuration files"
other = "Restrict direct modification of system configuration files"
[CmdDropDatabase]
one = "Drop database"
other = "Drop database"
[CmdDropDatabaseDesc]
one = "Restrict dropping entire databases"
other = "Restrict dropping entire databases"
[CmdTruncateTable]
one = "Truncate table data"
other = "Truncate table data"
[CmdTruncateTableDesc]
one = "Restrict clearing all table data"
other = "Restrict clearing all table data"
[CmdModifyPermissions]
one = "Modify user permissions"
other = "Modify user permissions"
[CmdModifyPermissionsDesc]
one = "Restrict modifying root directory permissions"
other = "Restrict modifying root directory permissions"
[CmdDropTable]
one = "Drop table"
other = "Drop table"
[CmdDropTableDesc]
one = "Warning: Dropping database table"
other = "Warning: Dropping database table"
[CmdServiceControl]
one = "Service control commands"
other = "Service control commands"
[CmdServiceControlDesc]
one = "Warning: Operating on system services"
other = "Warning: Operating on system services"
[CmdNetworkConfig]
one = "Network configuration modification"
other = "Network configuration modification"
[CmdNetworkConfigDesc]
one = "Warning: Modifying network configuration"
other = "Warning: Modifying network configuration"
[CmdUserManagement]
one = "User management"
other = "User management"
[CmdUserManagementDesc]
one = "Warning: Performing user management operations"
other = "Warning: Performing user management operations"
[CmdKernelModule]
one = "Kernel module operations"
other = "Kernel module operations"
# Predefined command templates
[TmplBasicSecurity]
one = "Basic Security Protection"
other = "Basic Security Protection"
[TmplBasicSecurityDesc]
one = "Basic security command restrictions for all production environments"
other = "Basic security command restrictions for all production environments"
[TmplDatabaseProtection]
one = "Production Database Protection"
other = "Production Database Protection"
[TmplDatabaseProtectionDesc]
one = "Security policies to protect production databases"
other = "Security policies to protect production databases"
[TmplServiceRestrictions]
one = "System Service Control Restrictions"
other = "System Service Control Restrictions"
[TmplServiceRestrictionsDesc]
one = "Restrictions on critical system service operations"
other = "Restrictions on critical system service operations"
[TmplNetworkSecurity]
one = "Network Security Control"
other = "Network Security Control"
[TmplNetworkSecurityDesc]
one = "Security controls for network configuration and user permissions"
other = "Security controls for network configuration and user permissions"
[TmplDevEnvironment]
one = "Development Environment Basic Restrictions"
other = "Development Environment Basic Restrictions"
[TmplDevEnvironmentDesc]
one = "Minimal security restrictions for development environments"
other = "Minimal security restrictions for development environments"
# Tags
[TagDangerous]
one = "dangerous"
other = "dangerous"
[TagDelete]
one = "delete"
other = "delete"
[TagSystem]
one = "system"
other = "system"
[TagSystemDirs]
one = "system-dirs"
other = "system-dirs"
[TagDisk]
one = "disk"
other = "disk"
[TagDestruction]
one = "destruction"
other = "destruction"
[TagFormat]
one = "format"
other = "format"
[TagAttack]
one = "attack"
other = "attack"
[TagResourceExhaustion]
one = "resource-exhaustion"
other = "resource-exhaustion"
[TagReboot]
one = "reboot"
other = "reboot"
[TagShutdown]
one = "shutdown"
other = "shutdown"
[TagEdit]
one = "edit"
other = "edit"
[TagSystemFiles]
one = "system-files"
other = "system-files"
[TagConfig]
one = "config"
other = "config"
[TagDatabase]
one = "database"
other = "database"
[TagDrop]
one = "drop"
other = "drop"
[TagClear]
one = "clear"
other = "clear"
[TagTruncate]
one = "truncate"
other = "truncate"
[TagPermissions]
one = "permissions"
other = "permissions"
[TagChmod]
one = "chmod"
other = "chmod"
[TagSecurity]
one = "security"
other = "security"
[TagTable]
one = "table"
other = "table"
[TagService]
one = "service"
other = "service"
[TagSystemctl]
one = "systemctl"
other = "systemctl"
[TagControl]
one = "control"
other = "control"
[TagNetwork]
one = "network"
other = "network"
[TagFirewall]
one = "firewall"
other = "firewall"
[TagRouting]
one = "routing"
other = "routing"
[TagUser]
one = "user"
other = "user"
[TagManagement]
one = "management"
other = "management"
[TagKernel]
one = "kernel"
other = "kernel"
[TagModule]
one = "module"
other = "module"

View File

@@ -153,3 +153,222 @@ other = "请求错误: 非法SSH公钥"
[MsgWrongPvk] [MsgWrongPvk]
hash = "sha1-fd11d7d098d05415f5ed082abdf31223cb2aeda9" hash = "sha1-fd11d7d098d05415f5ed082abdf31223cb2aeda9"
other = "请求错误: 非法SSH私钥" other = "请求错误: 非法SSH私钥"
# 预定义危险命令
[CmdDeleteRootDir]
other = "删除根目录"
[CmdDeleteRootDirDesc]
other = "禁止删除根目录,这会摧毁整个系统"
[CmdDeleteSystemDirs]
other = "删除系统目录"
[CmdDeleteSystemDirsDesc]
other = "禁止删除系统关键目录"
[CmdDiskDestruction]
other = "磁盘破坏操作"
[CmdDiskDestructionDesc]
other = "禁止向磁盘设备写入随机数据,会破坏数据"
[CmdFormatDisk]
other = "格式化磁盘"
[CmdFormatDiskDesc]
other = "禁止格式化磁盘分区"
[CmdForkBomb]
other = "fork炸弹"
[CmdForkBombDesc]
other = "禁止fork炸弹攻击"
[CmdSystemReboot]
other = "系统重启关机"
[CmdSystemRebootDesc]
other = "限制系统重启和关机操作"
[CmdModifySystemFiles]
other = "修改关键系统文件"
[CmdModifySystemFilesDesc]
other = "限制直接修改系统配置文件"
[CmdDropDatabase]
other = "删除数据库"
[CmdDropDatabaseDesc]
other = "限制删除整个数据库的操作"
[CmdTruncateTable]
other = "清空表数据"
[CmdTruncateTableDesc]
other = "限制清空表数据的操作"
[CmdModifyPermissions]
other = "修改用户权限"
[CmdModifyPermissionsDesc]
other = "限制修改根目录权限"
[CmdDropTable]
other = "删除表"
[CmdDropTableDesc]
other = "警告:正在删除数据库表"
[CmdServiceControl]
other = "服务控制命令"
[CmdServiceControlDesc]
other = "警告:正在操作系统服务"
[CmdNetworkConfig]
other = "网络配置修改"
[CmdNetworkConfigDesc]
other = "警告:正在修改网络配置"
[CmdUserManagement]
other = "用户管理"
[CmdUserManagementDesc]
other = "警告:正在进行用户管理操作"
[CmdKernelModule]
other = "内核模块操作"
[CmdKernelModuleDesc]
other = "警告:正在操作内核模块"
# 预定义命令模板
[TmplBasicSecurity]
other = "基础安全防护"
[TmplBasicSecurityDesc]
other = "适用于所有生产环境的基础安全命令限制"
[TmplDatabaseProtection]
other = "生产环境数据库保护"
[TmplDatabaseProtectionDesc]
other = "保护生产环境数据库的安全策略"
[TmplServiceRestrictions]
other = "系统服务控制限制"
[TmplServiceRestrictionsDesc]
other = "限制对关键系统服务的操作"
[TmplNetworkSecurity]
other = "网络安全控制"
[TmplNetworkSecurityDesc]
other = "网络配置和用户权限相关的安全控制"
[TmplDevEnvironment]
other = "开发环境基础限制"
[TmplDevEnvironmentDesc]
other = "适用于开发环境的最小安全限制"
# 标签
[TagDangerous]
other = "危险"
[TagDelete]
other = "删除"
[TagSystem]
other = "系统"
[TagSystemDirs]
other = "系统目录"
[TagDisk]
other = "磁盘"
[TagDestruction]
other = "破坏"
[TagFormat]
other = "格式化"
[TagAttack]
other = "攻击"
[TagResourceExhaustion]
other = "资源耗尽"
[TagReboot]
other = "重启"
[TagShutdown]
other = "关机"
[TagEdit]
other = "编辑"
[TagSystemFiles]
other = "系统文件"
[TagConfig]
other = "配置"
[TagDatabase]
other = "数据库"
[TagDrop]
other = "删除"
[TagClear]
other = "清空"
[TagTruncate]
other = "truncate"
[TagPermissions]
other = "权限"
[TagChmod]
other = "chmod"
[TagSecurity]
other = "安全"
[TagTable]
other = "表"
[TagService]
other = "服务"
[TagSystemctl]
other = "systemctl"
[TagControl]
other = "控制"
[TagNetwork]
other = "网络"
[TagFirewall]
other = "防火墙"
[TagRouting]
other = "路由"
[TagUser]
other = "用户"
[TagManagement]
other = "管理"
[TagKernel]
other = "内核"
[TagModule]
other = "模块"

View File

@@ -1,6 +1,8 @@
package model package model
import ( import (
"database/sql/driver"
"encoding/json"
"time" "time"
"gorm.io/plugin/soft_delete" "gorm.io/plugin/soft_delete"
@@ -10,6 +12,104 @@ const (
TABLE_NAME_ASSET = "asset" TABLE_NAME_ASSET = "asset"
) )
// AccountAuthorization represents authorization info for a specific account
type AccountAuthorization struct {
Rids Slice[int] `json:"rids"` // Role IDs for ACL system
Permissions *AuthPermissions `json:"permissions"` // V2 permissions (connect, file_upload, etc.)
}
// AuthorizationMap is a custom type that handles V1 to V2 authorization format conversion
type AuthorizationMap map[int]AccountAuthorization
// Scan implements the driver.Scanner interface for database deserialization
func (am *AuthorizationMap) Scan(value interface{}) error {
if value == nil {
*am = make(AuthorizationMap)
return nil
}
bytes, ok := value.([]byte)
if !ok {
*am = make(AuthorizationMap)
return nil
}
// Try to unmarshal as V2 format first
var v2Auth map[int]AccountAuthorization
if err := json.Unmarshal(bytes, &v2Auth); err == nil {
// Check if this is actually V2 format (has Permissions field)
for _, auth := range v2Auth {
if auth.Permissions != nil {
*am = AuthorizationMap(v2Auth)
return nil
}
}
}
// Try to unmarshal as V1 format
var v1Auth map[int][]int
if err := json.Unmarshal(bytes, &v1Auth); err == nil {
// Successfully parsed as V1 format, convert to V2
defaultPermissions := getDefaultPermissionsForAsset()
v2Auth = make(map[int]AccountAuthorization)
for accountId, roleIds := range v1Auth {
v2Auth[accountId] = AccountAuthorization{
Rids: roleIds,
Permissions: &defaultPermissions,
}
}
*am = AuthorizationMap(v2Auth)
return nil
}
// Cannot parse as either format, set to empty map
*am = make(AuthorizationMap)
return nil
}
// Value implements the driver.Valuer interface for database serialization
func (am AuthorizationMap) Value() (driver.Value, error) {
if am == nil {
return "{}", nil
}
return json.Marshal(am)
}
// UnmarshalJSON implements JSON unmarshaling for API requests
func (am *AuthorizationMap) UnmarshalJSON(data []byte) error {
// Try to unmarshal as V2 format first
var v2Auth map[int]AccountAuthorization
if err := json.Unmarshal(data, &v2Auth); err == nil {
*am = AuthorizationMap(v2Auth)
return nil
}
// Try to unmarshal as V1 format
var v1Auth map[int][]int
if err := json.Unmarshal(data, &v1Auth); err == nil {
// Successfully parsed as V1 format, convert to V2
defaultPermissions := getDefaultPermissionsForAsset()
v2Auth = make(map[int]AccountAuthorization)
for accountId, roleIds := range v1Auth {
v2Auth[accountId] = AccountAuthorization{
Rids: roleIds,
Permissions: &defaultPermissions,
}
}
*am = AuthorizationMap(v2Auth)
return nil
}
// Cannot parse as either format, set to empty map
*am = make(AuthorizationMap)
return nil
}
// MarshalJSON implements JSON marshaling
func (am AuthorizationMap) MarshalJSON() ([]byte, error) {
return json.Marshal(map[int]AccountAuthorization(am))
}
type Asset struct { type Asset struct {
Id int `json:"id" gorm:"column:id;primarykey;autoIncrement"` Id int `json:"id" gorm:"column:id;primarykey;autoIncrement"`
Name string `json:"name" gorm:"column:name;uniqueIndex:name_del;size:128"` Name string `json:"name" gorm:"column:name;uniqueIndex:name_del;size:128"`
@@ -18,11 +118,15 @@ type Asset struct {
Ip string `json:"ip" gorm:"column:ip"` Ip string `json:"ip" gorm:"column:ip"`
Protocols Slice[string] `json:"protocols" gorm:"column:protocols;type:text"` Protocols Slice[string] `json:"protocols" gorm:"column:protocols;type:text"`
GatewayId int `json:"gateway_id" gorm:"column:gateway_id"` GatewayId int `json:"gateway_id" gorm:"column:gateway_id"`
Authorization Map[int, Slice[int]] `json:"authorization" gorm:"column:authorization;type:text"` Authorization AuthorizationMap `json:"authorization" gorm:"column:authorization;type:text"`
AccessAuth AccessAuth `json:"access_auth" gorm:"embedded;column:access_auth"` AccessAuth AccessAuth `json:"access_auth" gorm:"embedded;column:access_auth"` // Deprecated: Use V2 fields below
Connectable bool `json:"connectable" gorm:"column:connectable"` Connectable bool `json:"connectable" gorm:"column:connectable"`
NodeChain string `json:"node_chain" gorm:"-"` NodeChain string `json:"node_chain" gorm:"-"`
// V2 Access Control (replaces AccessAuth)
AccessTimeControl *AccessTimeControl `json:"access_time_control,omitempty" gorm:"column:access_time_control;type:json"`
AssetCommandControl *AssetCommandControl `json:"asset_command_control,omitempty" gorm:"column:asset_command_control;type:json"`
Permissions []string `json:"permissions" gorm:"-"` Permissions []string `json:"permissions" gorm:"-"`
ResourceId int `json:"resource_id" gorm:"column:resource_id"` ResourceId int `json:"resource_id" gorm:"column:resource_id"`
CreatorId int `json:"creator_id" gorm:"column:creator_id"` CreatorId int `json:"creator_id" gorm:"column:creator_id"`
@@ -32,6 +136,24 @@ type Asset struct {
DeletedAt soft_delete.DeletedAt `json:"-" gorm:"column:deleted_at;uniqueIndex:name_del"` DeletedAt soft_delete.DeletedAt `json:"-" gorm:"column:deleted_at;uniqueIndex:name_del"`
} }
// getDefaultPermissionsForAsset returns default permissions for asset authorization conversion
func getDefaultPermissionsForAsset() AuthPermissions {
// Try to get from global config first
if config := GlobalConfig.Load(); config != nil {
return config.GetDefaultPermissionsAsAuthPermissions()
}
// Fallback to connect-only permissions for security
return AuthPermissions{
Connect: true,
FileUpload: false,
FileDownload: false,
Copy: false,
Paste: false,
Share: false,
}
}
type AccessAuth struct { type AccessAuth struct {
Start *time.Time `json:"start,omitempty" gorm:"column:start"` Start *time.Time `json:"start,omitempty" gorm:"column:start"`
End *time.Time `json:"end,omitempty" gorm:"column:end"` End *time.Time `json:"end,omitempty" gorm:"column:end"`
@@ -40,6 +162,8 @@ type AccessAuth struct {
Allow bool `json:"allow" gorm:"column:allow"` Allow bool `json:"allow" gorm:"column:allow"`
} }
// AccessTimeControl and AssetCommandControl are defined in authorization_v2.go
type Range struct { type Range struct {
Week int `json:"week" gorm:"column:week"` Week int `json:"week" gorm:"column:week"`
Times Slice[string] `json:"times" gorm:"column:times"` Times Slice[string] `json:"times" gorm:"column:times"`

View File

@@ -75,12 +75,12 @@ type AssetInfo struct {
Protocols Slice[string] `json:"protocols" gorm:"column:protocols"` Protocols Slice[string] `json:"protocols" gorm:"column:protocols"`
Connectable bool `json:"connectable" gorm:"column:connectable"` Connectable bool `json:"connectable" gorm:"column:connectable"`
NodeChain string `json:"node_chain" gorm:"-"` NodeChain string `json:"node_chain" gorm:"-"`
*AccessAuth `json:"access_auth" gorm:"column:access_auth"`
Authorization Map[int, Slice[int]] `json:"-" gorm:"column:authorization"` Authorization Map[int, Slice[int]] `json:"-" gorm:"column:authorization"`
GatewayId int `json:"-" gorm:"column:gateway_id"` GatewayId int `json:"-" gorm:"column:gateway_id"`
Gateway *GatewayInfo `json:"gateway,omitempty" gorm:"-"` Gateway *GatewayInfo `json:"gateway,omitempty" gorm:"-"`
Accounts []*AccountInfo `json:"accounts" gorm:"-"` Accounts []*AccountInfo `json:"accounts" gorm:"-"`
Commands []*CmdInfo `json:"commands" gorm:"-"` Commands []*CmdInfo `json:"commands" gorm:"-"`
*AccessAuth `json:"access_auth" gorm:"column:access_auth"`
} }
func (m *AssetInfo) GetId() int { func (m *AssetInfo) GetId() int {

View File

@@ -0,0 +1,391 @@
package model
import (
"database/sql/driver"
"encoding/json"
"fmt"
"strings"
"time"
"gorm.io/plugin/soft_delete"
)
type CustomTime struct {
time.Time
}
func (ct *CustomTime) UnmarshalJSON(data []byte) error {
str := string(data)
str = strings.Trim(str, "\"")
parsedTime, err := time.Parse("2006-01-02 15:04:05", str)
if err != nil {
return fmt.Errorf("failed to parse time %s: %v", str, err)
}
ct.Time = parsedTime
return nil
}
func (ct CustomTime) MarshalJSON() ([]byte, error) {
return json.Marshal(ct.Time.Format("2006-01-02 15:04:05"))
}
func (ct *CustomTime) Scan(value interface{}) error {
if value == nil {
ct.Time = time.Time{}
return nil
}
if t, ok := value.(time.Time); ok {
ct.Time = t
return nil
}
return fmt.Errorf("cannot scan %T into CustomTime", value)
}
func (ct CustomTime) Value() (driver.Value, error) {
return ct.Time, nil
}
// SelectorType defines the type of target selector
type SelectorType string
const (
SelectorTypeAll SelectorType = "all"
SelectorTypeIds SelectorType = "ids"
SelectorTypeRegex SelectorType = "regex"
SelectorTypeTags SelectorType = "tags"
)
// AuthAction defines the supported authorization actions
type AuthAction string
const (
ActionConnect AuthAction = "connect"
ActionFileUpload AuthAction = "file_upload"
ActionFileDownload AuthAction = "file_download"
ActionCopy AuthAction = "copy"
ActionPaste AuthAction = "paste"
ActionShare AuthAction = "share"
)
// TimeRange defines time restrictions
type TimeRange struct {
StartTime string `json:"start_time" gorm:"column:start_time"`
EndTime string `json:"end_time" gorm:"column:end_time"`
Weekdays Slice[int] `json:"weekdays" gorm:"column:weekdays"`
}
func (t *TimeRange) Scan(value interface{}) error {
if value == nil {
return nil
}
return json.Unmarshal(value.([]byte), t)
}
func (t TimeRange) Value() (driver.Value, error) {
return json.Marshal(t)
}
// TimeRanges is a custom slice type for TimeRange
type TimeRanges []TimeRange
func (t *TimeRanges) Scan(value interface{}) error {
if value == nil {
return nil
}
return json.Unmarshal(value.([]byte), t)
}
func (t TimeRanges) Value() (driver.Value, error) {
return json.Marshal(t)
}
// AccessTimeControl defines time-based access control (used by both Asset and AuthorizationV2)
type AccessTimeControl struct {
Enabled bool `json:"enabled" gorm:"column:enabled"`
TimeRanges TimeRanges `json:"time_ranges" gorm:"column:time_ranges"`
Timezone string `json:"timezone" gorm:"column:timezone"` // e.g., "Asia/Shanghai"
Comment string `json:"comment" gorm:"column:comment"` // Description of the time restriction
}
func (a *AccessTimeControl) Scan(value interface{}) error {
if value == nil {
return nil
}
return json.Unmarshal(value.([]byte), a)
}
func (a AccessTimeControl) Value() (driver.Value, error) {
return json.Marshal(a)
}
// AssetCommandControl defines command control restrictions for assets
type AssetCommandControl struct {
Enabled bool `json:"enabled" gorm:"column:enabled"`
CmdIds Slice[int] `json:"cmd_ids" gorm:"column:cmd_ids"` // Command IDs to control
TemplateIds Slice[int] `json:"template_ids" gorm:"column:template_ids"` // Command template IDs
Comment string `json:"comment" gorm:"column:comment"` // Description of the command restriction
}
func (a *AssetCommandControl) Scan(value interface{}) error {
if value == nil {
return nil
}
return json.Unmarshal(value.([]byte), a)
}
func (a AssetCommandControl) Value() (driver.Value, error) {
return json.Marshal(a)
}
// TargetSelector defines how to select targets (nodes, assets, accounts)
type TargetSelector struct {
Type SelectorType `json:"type" gorm:"column:type"`
Values Slice[string] `json:"values" gorm:"column:values"`
ExcludeIds Slice[int] `json:"exclude_ids" gorm:"column:exclude_ids"`
}
func (t *TargetSelector) Scan(value interface{}) error {
if value == nil {
return nil
}
return json.Unmarshal(value.([]byte), t)
}
func (t TargetSelector) Value() (driver.Value, error) {
return json.Marshal(t)
}
// AuthPermissions defines the permissions for different actions
type AuthPermissions struct {
Connect bool `json:"connect" gorm:"column:connect"`
FileUpload bool `json:"file_upload" gorm:"column:file_upload"`
FileDownload bool `json:"file_download" gorm:"column:file_download"`
Copy bool `json:"copy" gorm:"column:copy"`
Paste bool `json:"paste" gorm:"column:paste"`
Share bool `json:"share" gorm:"column:share"`
}
func (p *AuthPermissions) Scan(value interface{}) error {
if value == nil {
return nil
}
return json.Unmarshal(value.([]byte), p)
}
func (p AuthPermissions) Value() (driver.Value, error) {
return json.Marshal(p)
}
func (p *AuthPermissions) HasPermission(action AuthAction) bool {
switch action {
case ActionConnect:
return p.Connect
case ActionFileUpload:
return p.FileUpload
case ActionFileDownload:
return p.FileDownload
case ActionCopy:
return p.Copy
case ActionPaste:
return p.Paste
case ActionShare:
return p.Share
default:
return false
}
}
// CommandAction defines simple command actions
type CommandAction string
const (
CommandActionAllow CommandAction = "allow"
CommandActionDeny CommandAction = "deny"
CommandActionAudit CommandAction = "audit" // Log but allow
)
// AccessControl defines access control restrictions for authorization rules with time template support
type AccessControl struct {
IPWhitelist Slice[string] `json:"ip_whitelist" gorm:"column:ip_whitelist"`
// Time control options
TimeTemplate *TimeTemplateReference `json:"time_template" gorm:"column:time_template;type:json"` // Reference to template
CustomTimeRanges TimeRanges `json:"custom_time_ranges" gorm:"column:custom_time_ranges;type:json"` // Direct definition
Timezone string `json:"timezone" gorm:"column:timezone;size:64"`
// Command control
CmdIds Slice[int] `json:"cmd_ids" gorm:"column:cmd_ids"`
TemplateIds Slice[int] `json:"template_ids" gorm:"column:template_ids"`
MaxSessions int `json:"max_sessions" gorm:"column:max_sessions"`
SessionTimeout int `json:"session_timeout" gorm:"column:session_timeout"`
}
func (a *AccessControl) Scan(value interface{}) error {
if value == nil {
return nil
}
return json.Unmarshal(value.([]byte), a)
}
func (a AccessControl) Value() (driver.Value, error) {
return json.Marshal(a)
}
// AuthorizationV2 is the new flexible authorization model
type AuthorizationV2 struct {
Id int `json:"id" gorm:"column:id;primarykey;autoIncrement"`
Name string `json:"name" gorm:"column:name;uniqueIndex:name_del;size:128"`
Description string `json:"description" gorm:"column:description"`
Enabled bool `json:"enabled" gorm:"column:enabled;default:true"`
// Rule validity period
ValidFrom *CustomTime `json:"valid_from" gorm:"column:valid_from"`
ValidTo *CustomTime `json:"valid_to" gorm:"column:valid_to"`
// Target selectors
NodeSelector TargetSelector `json:"node_selector" gorm:"column:node_selector;type:json"`
AssetSelector TargetSelector `json:"asset_selector" gorm:"column:asset_selector;type:json"`
AccountSelector TargetSelector `json:"account_selector" gorm:"column:account_selector;type:json"`
// Permissions configuration
Permissions AuthPermissions `json:"permissions" gorm:"column:permissions;type:json"`
// Access control with time template support
AccessControl AccessControl `json:"access_control" gorm:"column:access_control;type:json"`
// Role IDs for ACL integration
Rids Slice[int] `json:"rids" gorm:"column:rids"`
// Standard fields
ResourceId int `json:"resource_id" gorm:"column:resource_id"`
CreatorId int `json:"creator_id" gorm:"column:creator_id"`
UpdaterId int `json:"updater_id" gorm:"column:updater_id"`
CreatedAt time.Time `json:"created_at" gorm:"column:created_at"`
UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"`
DeletedAt soft_delete.DeletedAt `json:"-" gorm:"column:deleted_at;uniqueIndex:name_del"`
}
func (m *AuthorizationV2) TableName() string {
return "authorization_v2"
}
func (m *AuthorizationV2) GetName() string {
return m.Name
}
func (m *AuthorizationV2) GetId() int {
return m.Id
}
func (m *AuthorizationV2) SetId(id int) {
m.Id = id
}
func (m *AuthorizationV2) SetCreatorId(creatorId int) {
m.CreatorId = creatorId
}
func (m *AuthorizationV2) SetUpdaterId(updaterId int) {
m.UpdaterId = updaterId
}
func (m *AuthorizationV2) SetResourceId(resourceId int) {
m.ResourceId = resourceId
}
func (m *AuthorizationV2) GetResourceId() int {
return m.ResourceId
}
func (m *AuthorizationV2) SetPerms(perms []string) {}
// IsValid checks if the authorization rule is currently valid based on time period
func (m *AuthorizationV2) IsValid(checkTime time.Time) bool {
if !m.Enabled {
return false
}
// Check valid from time
if m.ValidFrom != nil && checkTime.Before(m.ValidFrom.Time) {
return false
}
// Check valid to time
if m.ValidTo != nil && checkTime.After(m.ValidTo.Time) {
return false
}
return true
}
// IsCurrentlyValid checks if the authorization rule is currently valid
func (m *AuthorizationV2) IsCurrentlyValid() bool {
return m.IsValid(time.Now())
}
// AuthRequest represents an authorization request
type AuthRequest struct {
UserId int `json:"user_id"`
NodeId int `json:"node_id"`
AssetId int `json:"asset_id"`
AccountId int `json:"account_id"`
Action AuthAction `json:"action"`
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent"`
Timestamp time.Time `json:"timestamp"`
}
// BatchAuthRequest represents a batch authorization request for multiple actions
type BatchAuthRequest struct {
UserId int `json:"user_id"`
NodeId int `json:"node_id"`
AssetId int `json:"asset_id"`
AccountId int `json:"account_id"`
Actions []AuthAction `json:"actions"`
ClientIP string `json:"client_ip"`
UserAgent string `json:"user_agent"`
Timestamp time.Time `json:"timestamp"`
}
// AuthResult represents the result of an authorization check
type AuthResult struct {
Allowed bool `json:"allowed"`
Permissions AuthPermissions `json:"permissions"`
Reason string `json:"reason"`
RuleId int `json:"rule_id"`
RuleName string `json:"rule_name"`
Restrictions map[string]interface{} `json:"restrictions"`
}
// BatchAuthResult represents the result of a batch authorization check
type BatchAuthResult struct {
Results map[AuthAction]*AuthResult `json:"results"`
}
// IsAllowed checks if a specific action is allowed in the batch result
func (r *BatchAuthResult) IsAllowed(action AuthAction) bool {
if result, exists := r.Results[action]; exists {
return result.Allowed
}
return false
}
// GetResult returns the authorization result for a specific action
func (r *BatchAuthResult) GetResult(action AuthAction) *AuthResult {
return r.Results[action]
}
// CommandCheckResult represents the result of a command permission check
type CommandCheckResult struct {
Allowed bool `json:"allowed"`
Action CommandAction `json:"action"`
Reason string `json:"reason"`
}
// DefaultAuthorizationV2 for caching
var DefaultAuthorizationV2 = &AuthorizationV2{}

View File

@@ -11,30 +11,22 @@ var (
GlobalConfig atomic.Pointer[Config] GlobalConfig atomic.Pointer[Config]
) )
type SshConfig struct { // DefaultPermissions defines default permissions for authorization
Copy bool `json:"copy" gorm:"column:copy"` type DefaultPermissions struct {
Paste bool `json:"paste" gorm:"column:paste"` Connect bool `json:"connect" gorm:"column:connect"`
} FileUpload bool `json:"file_upload" gorm:"column:file_upload"`
type RdpConfig struct { FileDownload bool `json:"file_download" gorm:"column:file_download"`
Copy bool `json:"copy" gorm:"column:copy"`
Paste bool `json:"paste" gorm:"column:paste"`
EnableDrive bool `json:"enable_drive" gorm:"column:enable_drive"`
DrivePath string `json:"drive_path" gorm:"column:drive_path"`
CreateDrivePath bool `json:"create_drive_path" gorm:"column:create_drive_path"`
DisableUpload bool `json:"disable_upload" gorm:"column:disable_upload"`
DisableDownload bool `json:"disable_download" gorm:"column:disable_download"`
}
type VncConfig struct {
Copy bool `json:"copy" gorm:"column:copy"` Copy bool `json:"copy" gorm:"column:copy"`
Paste bool `json:"paste" gorm:"column:paste"` Paste bool `json:"paste" gorm:"column:paste"`
Share bool `json:"share" gorm:"column:share"`
} }
type Config struct { type Config struct {
Id int `json:"id" gorm:"column:id;primarykey;autoIncrement"` Id int `json:"id" gorm:"column:id;primarykey;autoIncrement"`
Timeout int `json:"timeout" gorm:"column:timeout"` Timeout int `json:"timeout" gorm:"column:timeout"`
SshConfig SshConfig `json:"ssh_config" gorm:"embedded;embeddedPrefix:ssh_;column:ssh_config"`
RdpConfig RdpConfig `json:"rdp_config" gorm:"embedded;embeddedPrefix:rdp_;column:rdp_config"` // Default permissions for authorization creation
VncConfig VncConfig `json:"vnc_config" gorm:"embedded;embeddedPrefix:vnc_;column:vnc_config"` DefaultPermissions DefaultPermissions `json:"default_permissions" gorm:"embedded;embeddedPrefix:default_"`
CreatorId int `json:"creator_id" gorm:"column:creator_id"` CreatorId int `json:"creator_id" gorm:"column:creator_id"`
UpdaterId int `json:"updater_id" gorm:"column:updater_id"` UpdaterId int `json:"updater_id" gorm:"column:updater_id"`
@@ -47,6 +39,23 @@ func (m *Config) TableName() string {
return "config" return "config"
} }
// GetDefaultPermissions returns the default permissions configuration
func (c *Config) GetDefaultPermissions() DefaultPermissions {
return c.DefaultPermissions
}
// GetDefaultPermissionsAsAuthPermissions converts to AuthPermissions format
func (c *Config) GetDefaultPermissionsAsAuthPermissions() AuthPermissions {
return AuthPermissions{
Connect: c.DefaultPermissions.Connect,
FileUpload: c.DefaultPermissions.FileUpload,
FileDownload: c.DefaultPermissions.FileDownload,
Copy: c.DefaultPermissions.Copy,
Paste: c.DefaultPermissions.Paste,
Share: c.DefaultPermissions.Share,
}
}
// ScheduleConfig defines configuration for scheduled tasks // ScheduleConfig defines configuration for scheduled tasks
type ScheduleConfig struct { type ScheduleConfig struct {
ConnectableCheckInterval time.Duration `json:"connectable_check_interval" yaml:"connectable_check_interval" default:"30m"` ConnectableCheckInterval time.Duration `json:"connectable_check_interval" yaml:"connectable_check_interval" default:"30m"`
@@ -66,3 +75,18 @@ func GetDefaultScheduleConfig() *ScheduleConfig {
ConnectTimeout: 3 * time.Second, // 3 second timeout for connectivity tests ConnectTimeout: 3 * time.Second, // 3 second timeout for connectivity tests
} }
} }
// GetDefaultConfig returns a default configuration with reasonable defaults
func GetDefaultConfig() *Config {
return &Config{
Timeout: 1800, // 30 minutes
DefaultPermissions: DefaultPermissions{
Connect: true,
FileUpload: true,
FileDownload: true,
Copy: true,
Paste: true,
Share: false, // Share is disabled by default for security
},
}
}

View File

@@ -5,6 +5,7 @@ var (
DefaultAsset = &Asset{} DefaultAsset = &Asset{}
DefaultAuthorization = &Authorization{} DefaultAuthorization = &Authorization{}
DefaultCommand = &Command{} DefaultCommand = &Command{}
DefaultCommandTemplate = &CommandTemplate{}
DefaultConfig = &Config{} DefaultConfig = &Config{}
DefaultFileHistory = &FileHistory{} DefaultFileHistory = &FileHistory{}
DefaultGateway = &Gateway{} DefaultGateway = &Gateway{}
@@ -18,4 +19,5 @@ var (
DefaultUserPreference = &UserPreference{} DefaultUserPreference = &UserPreference{}
DefaultStorageConfig = &StorageConfig{} DefaultStorageConfig = &StorageConfig{}
DefaultStorageMetrics = &StorageMetrics{} DefaultStorageMetrics = &StorageMetrics{}
DefaultMigrationRecord = &MigrationRecord{}
) )

View File

@@ -0,0 +1,36 @@
package model
import (
"time"
)
// MigrationRecord tracks the status of different migrations
type MigrationRecord struct {
Id int `json:"id" gorm:"column:id;primarykey;autoIncrement"`
MigrationName string `json:"migration_name" gorm:"column:migration_name;uniqueIndex;size:128;not null"`
Status string `json:"status" gorm:"column:status;size:32;not null"` // pending, running, completed, failed
StartedAt *time.Time `json:"started_at" gorm:"column:started_at"`
CompletedAt *time.Time `json:"completed_at" gorm:"column:completed_at"`
ErrorMessage string `json:"error_message" gorm:"column:error_message;type:text"`
RecordsCount int `json:"records_count" gorm:"column:records_count;default:0"` // Number of records migrated
CreatedAt time.Time `json:"created_at" gorm:"column:created_at"`
UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"`
}
func (m *MigrationRecord) TableName() string {
return "migration_records"
}
// Migration constants
const (
MigrationAuthV1ToV2 = "auth_v1_to_v2"
)
// Migration status constants
const (
MigrationStatusPending = "pending"
MigrationStatusRunning = "running"
MigrationStatusCompleted = "completed"
MigrationStatusFailed = "failed"
)

View File

@@ -0,0 +1,165 @@
package model
import (
"database/sql/driver"
"encoding/json"
"time"
"gorm.io/plugin/soft_delete"
)
// TimeTemplate defines predefined time access templates
type TimeTemplate struct {
Id int `json:"id" gorm:"column:id;primarykey;autoIncrement"`
Name string `json:"name" gorm:"column:name;uniqueIndex:name_del;size:128;not null"`
Description string `json:"description" gorm:"column:description"`
Category string `json:"category" gorm:"column:category;size:64"` // work, maintenance, emergency, etc.
// Time configuration
TimeRanges TimeRanges `json:"time_ranges" gorm:"column:time_ranges;type:json"`
Timezone string `json:"timezone" gorm:"column:timezone;size:64;default:'Asia/Shanghai'"`
// Status and metadata
IsBuiltIn bool `json:"is_builtin" gorm:"column:is_builtin;default:false"` // System built-in templates
IsActive bool `json:"is_active" gorm:"column:is_active;default:true"`
// Usage statistics
UsageCount int `json:"usage_count" gorm:"column:usage_count;default:0"`
// Standard fields
ResourceId int `json:"resource_id" gorm:"column:resource_id"`
CreatorId int `json:"creator_id" gorm:"column:creator_id"`
UpdaterId int `json:"updater_id" gorm:"column:updater_id"`
CreatedAt time.Time `json:"created_at" gorm:"column:created_at"`
UpdatedAt time.Time `json:"updated_at" gorm:"column:updated_at"`
DeletedAt soft_delete.DeletedAt `json:"-" gorm:"column:deleted_at;uniqueIndex:name_del"`
}
func (m *TimeTemplate) TableName() string {
return "time_template"
}
func (m *TimeTemplate) GetName() string {
return m.Name
}
func (m *TimeTemplate) GetId() int {
return m.Id
}
func (m *TimeTemplate) SetId(id int) {
m.Id = id
}
func (m *TimeTemplate) SetCreatorId(creatorId int) {
m.CreatorId = creatorId
}
func (m *TimeTemplate) SetUpdaterId(updaterId int) {
m.UpdaterId = updaterId
}
func (m *TimeTemplate) SetResourceId(resourceId int) {
m.ResourceId = resourceId
}
func (m *TimeTemplate) GetResourceId() int {
return m.ResourceId
}
func (m *TimeTemplate) SetPerms(perms []string) {}
// TimeTemplateReference defines how authorization rules reference time templates
type TimeTemplateReference struct {
TemplateId int `json:"template_id" gorm:"column:template_id"`
TemplateName string `json:"template_name" gorm:"column:template_name"` // For display
CustomRanges TimeRanges `json:"custom_ranges" gorm:"column:custom_ranges;type:json"` // Additional custom time ranges
}
func (t *TimeTemplateReference) Scan(value interface{}) error {
if value == nil {
return nil
}
return json.Unmarshal(value.([]byte), t)
}
func (t TimeTemplateReference) Value() (driver.Value, error) {
return json.Marshal(t)
}
// BuiltInTimeTemplates defines system built-in time templates
var BuiltInTimeTemplates = []TimeTemplate{
{
Name: "Business Hours",
Description: "Standard business hours: Monday to Friday 9:00-18:00",
Category: "work",
TimeRanges: TimeRanges{
{
StartTime: "09:00",
EndTime: "18:00",
Weekdays: Slice[int]{1, 2, 3, 4, 5}, // Monday to Friday
},
},
IsBuiltIn: true,
IsActive: true,
},
{
Name: "Weekend Duty",
Description: "Weekend duty hours: Saturday and Sunday 10:00-16:00",
Category: "duty",
TimeRanges: TimeRanges{
{
StartTime: "10:00",
EndTime: "16:00",
Weekdays: Slice[int]{6, 7}, // Saturday and Sunday
},
},
IsBuiltIn: true,
IsActive: true,
},
{
Name: "Maintenance Window",
Description: "System maintenance window: Sunday 2:00-6:00",
Category: "maintenance",
TimeRanges: TimeRanges{
{
StartTime: "02:00",
EndTime: "06:00",
Weekdays: Slice[int]{7}, // Sunday
},
},
IsBuiltIn: true,
IsActive: true,
},
{
Name: "24x7 Access",
Description: "24x7 around the clock access",
Category: "always",
TimeRanges: TimeRanges{
{
StartTime: "00:00",
EndTime: "23:59",
Weekdays: Slice[int]{1, 2, 3, 4, 5, 6, 7}, // All days
},
},
IsBuiltIn: true,
IsActive: true,
},
{
Name: "Emergency Response",
Description: "Emergency response hours: weekdays 18:00-22:00",
Category: "emergency",
TimeRanges: TimeRanges{
{
StartTime: "18:00",
EndTime: "22:00",
Weekdays: Slice[int]{1, 2, 3, 4, 5}, // Monday to Friday
},
},
IsBuiltIn: true,
IsActive: true,
},
}
// DefaultTimeTemplate for caching
var DefaultTimeTemplate = &TimeTemplate{}

View File

@@ -27,7 +27,6 @@ const (
type AssetRepository interface { type AssetRepository interface {
GetById(ctx context.Context, id int) (*model.Asset, error) GetById(ctx context.Context, id int) (*model.Asset, error)
AttachNodeChain(ctx context.Context, assets []*model.Asset) error AttachNodeChain(ctx context.Context, assets []*model.Asset) error
ApplyAuthorizationFilters(ctx *gin.Context, assets []*model.Asset, authorizationIds []*model.AuthorizationIds, nodeIds, accountIds []int)
BuildQuery(ctx *gin.Context) (*gorm.DB, error) BuildQuery(ctx *gin.Context) (*gorm.DB, error)
FilterByParentId(db *gorm.DB, parentId int) (*gorm.DB, error) FilterByParentId(db *gorm.DB, parentId int) (*gorm.DB, error)
GetAssetIdsByAuthorization(ctx *gin.Context, authorizationIds []*model.AuthorizationIds) ([]int, []int, []int, error) GetAssetIdsByAuthorization(ctx *gin.Context, authorizationIds []*model.AuthorizationIds) ([]int, []int, []int, error)
@@ -109,45 +108,6 @@ func (r *assetRepository) AttachNodeChain(ctx context.Context, assets []*model.A
return nil return nil
} }
// ApplyAuthorizationFilters applies authorization filters to assets
func (r *assetRepository) ApplyAuthorizationFilters(ctx *gin.Context, assets []*model.Asset, authorizationIds []*model.AuthorizationIds, nodeIds, accountIds []int) {
currentUser, _ := acl.GetSessionFromCtx(ctx)
if acl.IsAdmin(currentUser) {
return
}
info := cast.ToBool(ctx.Query("info"))
noInfoIds := make([]int, 0)
if !info {
t := dbpkg.DB.Model(model.DefaultAsset)
assetResIds, _ := acl.GetRoleResourceIds(ctx, currentUser.GetRid(), config.RESOURCE_ASSET)
t, _ = r.handleAssetIds(ctx, t, assetResIds)
t.Pluck("id", &noInfoIds)
}
for _, a := range assets {
if lo.Contains(nodeIds, a.ParentId) || lo.Contains(noInfoIds, a.Id) {
continue
}
if lo.ContainsBy(authorizationIds, func(item *model.AuthorizationIds) bool {
return item.AssetId == a.Id && item.NodeId == 0 && item.AccountId == 0
}) {
continue
}
ids := lo.Map(lo.Filter(authorizationIds, func(item *model.AuthorizationIds, _ int) bool {
return item.AssetId == a.Id && item.AccountId != 0 && item.NodeId == 0
}),
func(item *model.AuthorizationIds, _ int) int { return item.AccountId })
for k := range a.Authorization {
if !lo.Contains(ids, k) && !lo.Contains(accountIds, k) {
delete(a.Authorization, k)
}
}
}
}
// GetAssetIdsByAuthorization gets asset IDs by authorization // GetAssetIdsByAuthorization gets asset IDs by authorization
func (r *assetRepository) GetAssetIdsByAuthorization(ctx *gin.Context, authorizationIds []*model.AuthorizationIds) ([]int, []int, []int, error) { func (r *assetRepository) GetAssetIdsByAuthorization(ctx *gin.Context, authorizationIds []*model.AuthorizationIds) ([]int, []int, []int, error) {
ctx.Set(kAuthorizationIds, authorizationIds) ctx.Set(kAuthorizationIds, authorizationIds)

View File

@@ -4,7 +4,6 @@ import (
"context" "context"
"errors" "errors"
"github.com/samber/lo"
"gorm.io/gorm" "gorm.io/gorm"
"github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/model"
@@ -50,7 +49,6 @@ func (r *AuthorizationRepository) UpsertAuthorization(ctx context.Context, auth
return r.db.Save(auth).Error return r.db.Save(auth).Error
} }
// GetAuthorizationById 根据ID获取授权
func (r *AuthorizationRepository) GetAuthorizationById(ctx context.Context, id int) (*model.Authorization, error) { func (r *AuthorizationRepository) GetAuthorizationById(ctx context.Context, id int) (*model.Authorization, error) {
auth := &model.Authorization{} auth := &model.Authorization{}
err := r.db.Model(auth).Where("id = ?", id).First(auth).Error err := r.db.Model(auth).Where("id = ?", id).First(auth).Error
@@ -60,7 +58,6 @@ func (r *AuthorizationRepository) GetAuthorizationById(ctx context.Context, id i
return auth, nil return auth, nil
} }
// GetAuthorizationByFields 根据字段获取授权
func (r *AuthorizationRepository) GetAuthorizationByFields(ctx context.Context, nodeId, assetId, accountId int) (*model.Authorization, error) { func (r *AuthorizationRepository) GetAuthorizationByFields(ctx context.Context, nodeId, assetId, accountId int) (*model.Authorization, error) {
auth := &model.Authorization{} auth := &model.Authorization{}
err := r.db.Model(auth). err := r.db.Model(auth).
@@ -104,8 +101,21 @@ func (r *AuthorizationRepository) GetAuthorizations(ctx context.Context, nodeId,
func (r *AuthorizationRepository) GetAuthsByAsset(ctx context.Context, asset *model.Asset) ([]*model.Authorization, error) { func (r *AuthorizationRepository) GetAuthsByAsset(ctx context.Context, asset *model.Asset) ([]*model.Authorization, error) {
var data []*model.Authorization var data []*model.Authorization
// Extract account IDs from the new Authorization structure
accountIds := make([]int, 0, len(asset.Authorization))
for accountId := range asset.Authorization {
if accountId != 0 {
accountIds = append(accountIds, accountId)
}
}
if len(accountIds) == 0 {
return data, nil
}
err := r.db.Model(&model.Authorization{}). err := r.db.Model(&model.Authorization{}).
Where("asset_id=? AND account_id IN ? AND node_id=0", asset.Id, lo.Without(lo.Keys(asset.Authorization), 0)). Where("asset_id=? AND account_id IN ? AND node_id=0", asset.Id, accountIds).
Find(&data).Error Find(&data).Error
return data, err return data, err
} }

View File

@@ -0,0 +1,90 @@
package repository
import (
"context"
"encoding/json"
"github.com/veops/oneterm/internal/model"
"gorm.io/gorm"
)
// IAuthorizationV2Repository defines the interface for authorization V2 repository
type IAuthorizationV2Repository interface {
Create(ctx context.Context, auth *model.AuthorizationV2) error
GetById(ctx context.Context, id int) (*model.AuthorizationV2, error)
Update(ctx context.Context, auth *model.AuthorizationV2) error
Delete(ctx context.Context, id int) error
GetUserRules(ctx context.Context, userRids []int) ([]*model.AuthorizationV2, error)
GetAll(ctx context.Context) ([]*model.AuthorizationV2, error)
GetByResourceIds(ctx context.Context, resourceIds []int) ([]*model.AuthorizationV2, error)
}
// AuthorizationV2Repository implements IAuthorizationV2Repository
type AuthorizationV2Repository struct {
db *gorm.DB
}
// NewAuthorizationV2Repository creates a new authorization V2 repository
func NewAuthorizationV2Repository(db *gorm.DB) IAuthorizationV2Repository {
return &AuthorizationV2Repository{db: db}
}
// Create creates a new authorization V2 rule
func (r *AuthorizationV2Repository) Create(ctx context.Context, auth *model.AuthorizationV2) error {
return r.db.Create(auth).Error
}
// GetById retrieves an authorization V2 rule by ID
func (r *AuthorizationV2Repository) GetById(ctx context.Context, id int) (*model.AuthorizationV2, error) {
auth := &model.AuthorizationV2{}
err := r.db.Where("id = ?", id).First(auth).Error
return auth, err
}
// Update updates an authorization V2 rule
func (r *AuthorizationV2Repository) Update(ctx context.Context, auth *model.AuthorizationV2) error {
return r.db.Model(auth).Where("id = ?", auth.Id).Updates(auth).Error
}
// Delete deletes an authorization V2 rule
func (r *AuthorizationV2Repository) Delete(ctx context.Context, id int) error {
return r.db.Where("id = ?", id).Delete(&model.AuthorizationV2{}).Error
}
// GetUserRules retrieves authorization rules for specific user role IDs
func (r *AuthorizationV2Repository) GetUserRules(ctx context.Context, userRids []int) ([]*model.AuthorizationV2, error) {
if len(userRids) == 0 {
return []*model.AuthorizationV2{}, nil
}
jsonRids, err := json.Marshal(userRids)
if err != nil {
return nil, err
}
var rules []*model.AuthorizationV2
err = r.db.Where("enabled = ? AND JSON_OVERLAPS(rids, ?)", true, string(jsonRids)).
Order("id ASC").
Find(&rules).Error
return rules, err
}
// GetAll retrieves all authorization V2 rules
func (r *AuthorizationV2Repository) GetAll(ctx context.Context) ([]*model.AuthorizationV2, error) {
var rules []*model.AuthorizationV2
err := r.db.Order("id ASC").Find(&rules).Error
return rules, err
}
// GetByResourceIds retrieves authorization rules by resource IDs
func (r *AuthorizationV2Repository) GetByResourceIds(ctx context.Context, resourceIds []int) ([]*model.AuthorizationV2, error) {
if len(resourceIds) == 0 {
return []*model.AuthorizationV2{}, nil
}
var rules []*model.AuthorizationV2
err := r.db.Where("resource_id IN ? AND enabled = ?", resourceIds, true).
Order("id ASC").
Find(&rules).Error
return rules, err
}

View File

@@ -0,0 +1,155 @@
package repository
import (
"context"
"errors"
"gorm.io/gorm"
"github.com/veops/oneterm/internal/model"
)
// ITimeTemplateRepository defines the interface for time template data access
type ITimeTemplateRepository interface {
Create(ctx context.Context, template *model.TimeTemplate) error
GetByID(ctx context.Context, id int) (*model.TimeTemplate, error)
GetByName(ctx context.Context, name string) (*model.TimeTemplate, error)
List(ctx context.Context, offset, limit int, category string, active *bool) ([]*model.TimeTemplate, int64, error)
Update(ctx context.Context, template *model.TimeTemplate) error
Delete(ctx context.Context, id int) error
IncrementUsage(ctx context.Context, id int) error
GetBuiltInTemplates(ctx context.Context) ([]*model.TimeTemplate, error)
InitBuiltInTemplates(ctx context.Context) error
}
// TimeTemplateRepository implements ITimeTemplateRepository
type TimeTemplateRepository struct {
db *gorm.DB
}
// NewTimeTemplateRepository creates a new time template repository
func NewTimeTemplateRepository(db *gorm.DB) ITimeTemplateRepository {
return &TimeTemplateRepository{db: db}
}
// Create creates a new time template
func (r *TimeTemplateRepository) Create(ctx context.Context, template *model.TimeTemplate) error {
return r.db.WithContext(ctx).Create(template).Error
}
// GetByID retrieves a time template by ID
func (r *TimeTemplateRepository) GetByID(ctx context.Context, id int) (*model.TimeTemplate, error) {
var template model.TimeTemplate
err := r.db.WithContext(ctx).Where("id = ?", id).First(&template).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &template, nil
}
// GetByName retrieves a time template by name
func (r *TimeTemplateRepository) GetByName(ctx context.Context, name string) (*model.TimeTemplate, error) {
var template model.TimeTemplate
err := r.db.WithContext(ctx).Where("name = ?", name).First(&template).Error
if err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil, nil
}
return nil, err
}
return &template, nil
}
// List retrieves time templates with pagination and filters
func (r *TimeTemplateRepository) List(ctx context.Context, offset, limit int, category string, active *bool) ([]*model.TimeTemplate, int64, error) {
query := r.db.WithContext(ctx).Model(&model.TimeTemplate{})
// Apply filters
if category != "" {
query = query.Where("category = ?", category)
}
if active != nil {
query = query.Where("is_active = ?", *active)
}
// Get total count
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
// Apply pagination and ordering
var templates []*model.TimeTemplate
err := query.Order("is_builtin DESC, usage_count DESC, created_at DESC").
Offset(offset).
Limit(limit).
Find(&templates).Error
return templates, total, err
}
// Update updates an existing time template
func (r *TimeTemplateRepository) Update(ctx context.Context, template *model.TimeTemplate) error {
// Don't allow updating built-in templates
if template.IsBuiltIn {
return errors.New("cannot update built-in time template")
}
return r.db.WithContext(ctx).Save(template).Error
}
// Delete soft deletes a time template
func (r *TimeTemplateRepository) Delete(ctx context.Context, id int) error {
// Check if it's a built-in template
var template model.TimeTemplate
if err := r.db.WithContext(ctx).Where("id = ?", id).First(&template).Error; err != nil {
return err
}
if template.IsBuiltIn {
return errors.New("cannot delete built-in time template")
}
return r.db.WithContext(ctx).Delete(&model.TimeTemplate{}, id).Error
}
// IncrementUsage increments the usage count of a time template
func (r *TimeTemplateRepository) IncrementUsage(ctx context.Context, id int) error {
return r.db.WithContext(ctx).Model(&model.TimeTemplate{}).
Where("id = ?", id).
UpdateColumn("usage_count", gorm.Expr("usage_count + 1")).Error
}
// GetBuiltInTemplates retrieves all built-in time templates
func (r *TimeTemplateRepository) GetBuiltInTemplates(ctx context.Context) ([]*model.TimeTemplate, error) {
var templates []*model.TimeTemplate
err := r.db.WithContext(ctx).Where("is_builtin = ?", true).
Order("category, id").
Find(&templates).Error
return templates, err
}
// InitBuiltInTemplates initializes built-in time templates
func (r *TimeTemplateRepository) InitBuiltInTemplates(ctx context.Context) error {
// Check if built-in templates already exist
var count int64
r.db.WithContext(ctx).Model(&model.TimeTemplate{}).Where("is_builtin = ?", true).Count(&count)
if count > 0 {
// Built-in templates already exist, skip initialization
return nil
}
// Create built-in templates
for _, template := range model.BuiltInTimeTemplates {
// Create a copy to avoid modifying the original
newTemplate := template
if err := r.db.WithContext(ctx).Create(&newTemplate).Error; err != nil {
return err
}
}
return nil
}

View File

@@ -83,7 +83,8 @@ func getAssetsToCheck(ids ...int) ([]*model.Asset, error) {
time.Now().Add(-checkInterval).Add(-time.Second*30), false) time.Now().Add(-checkInterval).Add(-time.Second*30), false)
} }
if err := db.Find(&assets).Error; err != nil { // Only select fields needed for connectivity check, exclude authorization to avoid V1/V2 compatibility issues
if err := db.Select("id", "name", "ip", "protocols", "gateway_id", "connectable", "updated_at").Find(&assets).Error; err != nil {
logger.L().Error("Failed to get assets for connectivity check", zap.Error(err)) logger.L().Error("Failed to get assets for connectivity check", zap.Error(err))
return nil, err return nil, err
} }

View File

@@ -4,6 +4,7 @@ import (
"context" "context"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/veops/oneterm/internal/acl"
"github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/repository" "github.com/veops/oneterm/internal/repository"
"github.com/veops/oneterm/pkg/utils" "github.com/veops/oneterm/pkg/utils"
@@ -78,3 +79,27 @@ func (s *AccountService) FilterByAssetIds(db *gorm.DB, assetIds []int) *gorm.DB
func (s *AccountService) GetAccountIdsByAuthorization(ctx context.Context, assetIds []int, authorizationIds []int) ([]int, error) { func (s *AccountService) GetAccountIdsByAuthorization(ctx context.Context, assetIds []int, authorizationIds []int) ([]int, error) {
return s.repo.GetAccountIdsByAuthorization(ctx, assetIds, authorizationIds) return s.repo.GetAccountIdsByAuthorization(ctx, assetIds, authorizationIds)
} }
// BuildQueryWithAuthorization builds query with integrated V2 authorization filter
func (s *AccountService) BuildQueryWithAuthorization(ctx *gin.Context) (*gorm.DB, error) {
// Start with base query
db := s.repo.BuildQuery(ctx)
currentUser, _ := acl.GetSessionFromCtx(ctx)
// Administrators have access to all accounts
if acl.IsAdmin(currentUser) {
return db, nil
}
// Apply V2 authorization filter: get authorized asset IDs using V2 system
authV2Service := NewAuthorizationV2Service()
_, assetIds, _, err := authV2Service.GetAuthorizationScopeByACL(ctx)
if err != nil {
return nil, err
}
// Use the same filtering logic as before, but with V2 authorized assets
// This maintains the original logic: find accounts that can access the authorized assets
return s.FilterByAssetIds(db, assetIds), nil
}

View File

@@ -6,6 +6,7 @@ import (
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/samber/lo" "github.com/samber/lo"
"github.com/veops/oneterm/internal/acl"
"github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/repository" "github.com/veops/oneterm/internal/repository"
"github.com/veops/oneterm/internal/schedule" "github.com/veops/oneterm/internal/schedule"
@@ -34,7 +35,32 @@ func (s *AssetService) PreprocessAssetData(asset *model.Asset) {
asset.Ip = strings.TrimSpace(asset.Ip) asset.Ip = strings.TrimSpace(asset.Ip)
asset.Protocols = lo.Map(asset.Protocols, func(s string, _ int) string { return strings.TrimSpace(s) }) asset.Protocols = lo.Map(asset.Protocols, func(s string, _ int) string { return strings.TrimSpace(s) })
if asset.Authorization == nil { if asset.Authorization == nil {
asset.Authorization = make(model.Map[int, model.Slice[int]]) asset.Authorization = make(model.AuthorizationMap)
}
// Handle backward compatibility: convert old format to new format
// This handles cases where frontend still sends old format: Map[int, Slice[int]]
s.ensureAuthorizationFormat(asset)
}
// ensureAuthorizationFormat ensures asset.Authorization is in the correct V2 format
// Handles backward compatibility with old V1 format
func (s *AssetService) ensureAuthorizationFormat(asset *model.Asset) {
// Check if we need to convert from old format to new format
// This is needed for backward compatibility
for accountId, authData := range asset.Authorization {
// If permissions is nil, set default permissions (connect only for V1 compatibility)
if authData.Permissions == nil {
authData.Permissions = &model.AuthPermissions{
Connect: true, // Default: allow connect (V1 behavior)
FileUpload: false, // Default: deny file upload
FileDownload: false, // Default: deny file download
Copy: false, // Default: deny copy
Paste: false, // Default: deny paste
Share: false, // Default: deny share
}
asset.Authorization[accountId] = authData
}
} }
} }
@@ -43,11 +69,6 @@ func (s *AssetService) AttachNodeChain(ctx context.Context, assets []*model.Asse
return s.repo.AttachNodeChain(ctx, assets) return s.repo.AttachNodeChain(ctx, assets)
} }
// ApplyAuthorizationFilters applies authorization filters to assets
func (s *AssetService) ApplyAuthorizationFilters(ctx *gin.Context, assets []*model.Asset, authorizationIds []*model.AuthorizationIds, nodeIds, accountIds []int) {
s.repo.ApplyAuthorizationFilters(ctx, assets, authorizationIds, nodeIds, accountIds)
}
// BuildQuery constructs asset query with basic filters // BuildQuery constructs asset query with basic filters
func (s *AssetService) BuildQuery(ctx *gin.Context) (*gorm.DB, error) { func (s *AssetService) BuildQuery(ctx *gin.Context) (*gorm.DB, error) {
return s.repo.BuildQuery(ctx) return s.repo.BuildQuery(ctx)
@@ -58,14 +79,11 @@ func (s *AssetService) FilterByParentId(db *gorm.DB, parentId int) (*gorm.DB, er
return s.repo.FilterByParentId(db, parentId) return s.repo.FilterByParentId(db, parentId)
} }
// GetAssetIdsByAuthorization gets asset IDs by authorization // GetAssetIdsByAuthorization gets asset IDs by authorization using efficient V2 method
func (s *AssetService) GetAssetIdsByAuthorization(ctx *gin.Context) ([]int, []int, []int, error) { func (s *AssetService) GetAssetIdsByAuthorization(ctx *gin.Context) ([]int, []int, []int, error) {
authorizationIds, err := DefaultAuthService.GetAuthorizationIds(ctx) // Use efficient V2 method: get authorized resource IDs from ACL, then find V2 rules
if err != nil { authV2Service := NewAuthorizationV2Service()
return nil, nil, nil, err return authV2Service.GetAuthorizationScopeByACL(ctx)
}
return s.repo.GetAssetIdsByAuthorization(ctx, authorizationIds)
} }
// GetIdsByAuthorizationIds extracts node IDs, asset IDs, and account IDs from authorization IDs // GetIdsByAuthorizationIds extracts node IDs, asset IDs, and account IDs from authorization IDs
@@ -82,3 +100,36 @@ func (s *AssetService) GetAssetIdsByNodeAccount(ctx context.Context, nodeIds, ac
func (s *AssetService) UpdateConnectables(ids ...int) error { func (s *AssetService) UpdateConnectables(ids ...int) error {
return schedule.UpdateAssetConnectables(ids...) return schedule.UpdateAssetConnectables(ids...)
} }
// BuildQueryWithAuthorization constructs asset query with integrated V2 authorization filter
func (s *AssetService) BuildQueryWithAuthorization(ctx *gin.Context) (*gorm.DB, error) {
// Start with base query
db, err := s.repo.BuildQuery(ctx)
if err != nil {
return nil, err
}
currentUser, _ := acl.GetSessionFromCtx(ctx)
// Administrators have access to all assets
if acl.IsAdmin(currentUser) {
return db, nil
}
// Apply V2 authorization filter directly at database level
authV2Service := NewAuthorizationV2Service()
_, assetIds, _, err := authV2Service.GetAuthorizationScopeByACL(ctx)
if err != nil {
return nil, err
}
// Filter by authorized asset IDs at database level (much more efficient)
if len(assetIds) == 0 {
// No access to any assets
db = db.Where("1 = 0") // Returns empty result set efficiently
} else {
db = db.Where("id IN ?", assetIds)
}
return db, nil
}

View File

@@ -3,6 +3,9 @@ package service
import ( import (
"context" "context"
"errors" "errors"
"fmt"
"net"
"time"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
"github.com/samber/lo" "github.com/samber/lo"
@@ -31,10 +34,23 @@ var (
// InitAuthorizationService initializes the global authorization service // InitAuthorizationService initializes the global authorization service
func InitAuthorizationService() { func InitAuthorizationService() {
repo := repository.NewAuthorizationRepository(dbpkg.DB) repo := repository.NewAuthorizationRepository(dbpkg.DB)
DefaultAuthService = NewAuthorizationService(repo, dbpkg.DB) v2Repo := repository.NewAuthorizationV2Repository(dbpkg.DB)
matcher := NewAuthorizationMatcher(v2Repo)
// Perform V1 to V2 migration if needed
migrationService := NewAuthorizationMigrationService(dbpkg.DB, repo, v2Repo)
ctx := context.Background()
if err := migrationService.MigrateV1ToV2(ctx); err != nil {
logger.L().Error("Failed to migrate V1 authorization rules to V2", zap.Error(err))
// Continue with service initialization even if migration fails
// This allows the system to start with existing V2 rules
}
DefaultAuthService = NewAuthorizationService(repo, dbpkg.DB, matcher) // Use V2 by default
} }
type IAuthorizationService interface { type IAuthorizationService interface {
// V1 methods
UpsertAuthorization(ctx context.Context, auth *model.Authorization) error UpsertAuthorization(ctx context.Context, auth *model.Authorization) error
UpsertAuthorizationWithTx(ctx context.Context, auth *model.Authorization) error UpsertAuthorizationWithTx(ctx context.Context, auth *model.Authorization) error
DeleteAuthorization(ctx context.Context, auth *model.Authorization) error DeleteAuthorization(ctx context.Context, auth *model.Authorization) error
@@ -46,16 +62,22 @@ type IAuthorizationService interface {
HandleAuthorization(ctx context.Context, tx *gorm.DB, action int, asset *model.Asset, auths ...*model.Authorization) error HandleAuthorization(ctx context.Context, tx *gorm.DB, action int, asset *model.Asset, auths ...*model.Authorization) error
GetNodeAssetAccountIdsByAction(ctx context.Context, action string) (nodeIds, assetIds, accountIds []int, err error) GetNodeAssetAccountIdsByAction(ctx context.Context, action string) (nodeIds, assetIds, accountIds []int, err error)
GetAuthorizationIds(ctx *gin.Context) ([]*model.AuthorizationIds, error) GetAuthorizationIds(ctx *gin.Context) ([]*model.AuthorizationIds, error)
// V2 methods
HasAuthorizationV2(ctx *gin.Context, sess *gsession.Session, actions ...model.AuthAction) (*model.BatchAuthResult, error)
CheckPermission(ctx *gin.Context, nodeId, assetId, accountId int, action model.AuthAction) (*model.AuthResult, error)
} }
type AuthorizationService struct { type AuthorizationService struct {
repo repository.IAuthorizationRepository repo repository.IAuthorizationRepository
db *gorm.DB // Add database field for transaction processing matcher IAuthorizationMatcher
db *gorm.DB
} }
func NewAuthorizationService(repo repository.IAuthorizationRepository, db *gorm.DB) IAuthorizationService { func NewAuthorizationService(repo repository.IAuthorizationRepository, db *gorm.DB, matcher IAuthorizationMatcher) IAuthorizationService {
return &AuthorizationService{ return &AuthorizationService{
repo: repo, repo: repo,
matcher: matcher,
db: db, db: db,
} }
} }
@@ -86,7 +108,7 @@ func (s *AuthorizationService) UpsertAuthorizationWithTx(ctx context.Context, au
action := lo.Ternary(auth.Id > 0, model.ACTION_UPDATE, model.ACTION_CREATE) action := lo.Ternary(auth.Id > 0, model.ACTION_UPDATE, model.ACTION_CREATE)
// Create a temporary Service for transaction handling // Create a temporary Service for transaction handling
txService := &AuthorizationService{repo: txRepo, db: s.db} txService := &AuthorizationService{repo: txRepo, db: s.db, matcher: s.matcher}
return txService.HandleAuthorization(ctx, tx, action, nil, auth) return txService.HandleAuthorization(ctx, tx, action, nil, auth)
}) })
@@ -100,7 +122,7 @@ func (s *AuthorizationService) GetAuthorizationById(ctx context.Context, id int)
func (s *AuthorizationService) DeleteAuthorization(ctx context.Context, auth *model.Authorization) error { func (s *AuthorizationService) DeleteAuthorization(ctx context.Context, auth *model.Authorization) error {
return s.db.Transaction(func(tx *gorm.DB) error { return s.db.Transaction(func(tx *gorm.DB) error {
txRepo := repository.NewAuthorizationRepository(tx) txRepo := repository.NewAuthorizationRepository(tx)
txService := &AuthorizationService{repo: txRepo, db: s.db} txService := &AuthorizationService{repo: txRepo, db: s.db, matcher: s.matcher}
return txService.HandleAuthorization(ctx, tx, model.ACTION_DELETE, nil, auth) return txService.HandleAuthorization(ctx, tx, model.ACTION_DELETE, nil, auth)
}) })
} }
@@ -205,6 +227,7 @@ func (s *AuthorizationService) GetAuthsByAsset(ctx context.Context, asset *model
return auths, err return auths, err
} }
// HandleAuthorization handles authorization operations
func (s *AuthorizationService) HandleAuthorization(ctx context.Context, tx *gorm.DB, action int, asset *model.Asset, auths ...*model.Authorization) (err error) { func (s *AuthorizationService) HandleAuthorization(ctx context.Context, tx *gorm.DB, action int, asset *model.Asset, auths ...*model.Authorization) (err error) {
defer repository.DeleteAllFromCacheDb(ctx, model.DefaultAuthorization) defer repository.DeleteAllFromCacheDb(ctx, model.DefaultAuthorization)
@@ -213,46 +236,29 @@ func (s *AuthorizationService) HandleAuthorization(ctx context.Context, tx *gorm
eg := &errgroup.Group{} eg := &errgroup.Group{}
if asset != nil && asset.Id > 0 { if asset != nil && asset.Id > 0 {
var pres []*model.Authorization
pres, err = s.GetAuthsByAsset(ctx, asset)
if err != nil {
return
}
switch action { switch action {
case model.ACTION_CREATE: case model.ACTION_CREATE:
auths = lo.Map(lo.Keys(asset.Authorization), func(id int, _ int) *model.Authorization { // V2: Create authorization rules instead of V1 authorization records
return &model.Authorization{AssetId: asset.Id, AccountId: id, Rids: asset.Authorization[id]} err = s.createV2AuthorizationRulesForAsset(ctx, tx, asset, currentUser)
}) if err != nil {
return err
}
case model.ACTION_DELETE: case model.ACTION_DELETE:
auths = pres // V2: Delete authorization rules for this asset
err = s.deleteV2AuthorizationRulesForAsset(ctx, tx, asset)
if err != nil {
return err
}
case model.ACTION_UPDATE: case model.ACTION_UPDATE:
for _, pre := range pres { // V2: Update authorization rules for this asset
p := pre err = s.updateV2AuthorizationRulesForAsset(ctx, tx, asset, currentUser)
if v, ok := asset.Authorization[p.AccountId]; ok { if err != nil {
p.Rids = v return err
auths = append(auths, p)
} else {
eg.Go(func() (err error) {
if err = acl.DeleteResource(ctx, currentUser.GetUid(), p.ResourceId); err != nil {
return
}
if err = tx.Delete(p).Error; err != nil {
return
}
return
})
}
}
preAccountsIds := lo.Map(pres, func(p *model.Authorization, _ int) int { return p.AccountId })
for k, v := range asset.Authorization {
if !lo.Contains(preAccountsIds, k) {
auths = append(auths, &model.Authorization{AssetId: asset.Id, AccountId: k, Rids: v})
}
} }
} }
} }
// Handle individual authorization records (V1 compatibility)
for _, a := range lo.Filter(auths, func(item *model.Authorization, _ int) bool { return item != nil }) { for _, a := range lo.Filter(auths, func(item *model.Authorization, _ int) bool { return item != nil }) {
auth := a auth := a
switch action { switch action {
@@ -344,56 +350,237 @@ func (s *AuthorizationService) GetAuthorizationIds(ctx *gin.Context) (authIds []
return return
} }
// HasAuthorization checks if the current user has permission to connect to the specified asset with the given account.
func (s *AuthorizationService) HasAuthorization(ctx *gin.Context, sess *gsession.Session) (ok bool, err error) { func (s *AuthorizationService) HasAuthorization(ctx *gin.Context, sess *gsession.Session) (ok bool, err error) {
result, err := s.HasAuthorizationV2(ctx, sess, model.ActionConnect)
if err != nil {
return false, err
}
// Check if connect action is allowed in the batch result
return result.IsAllowed(model.ActionConnect), nil
}
// HasAuthorizationV2 implements the new V2 authorization logic
func (s *AuthorizationService) HasAuthorizationV2(ctx *gin.Context, sess *gsession.Session, actions ...model.AuthAction) (*model.BatchAuthResult, error) {
currentUser, _ := acl.GetSessionFromCtx(ctx) currentUser, _ := acl.GetSessionFromCtx(ctx)
// Helper function to create batch result for all actions
createBatchResult := func(allowed bool, reason string) *model.BatchAuthResult {
results := make(map[model.AuthAction]*model.AuthResult)
for _, action := range actions {
results[action] = &model.AuthResult{
Allowed: allowed,
Reason: reason,
}
}
return &model.BatchAuthResult{
Results: results,
}
}
// 1. Share sessions are always allowed
if sess.ShareId != 0 { if sess.ShareId != 0 {
return true, nil return createBatchResult(true, "Share session"), nil
} }
if ok = acl.IsAdmin(currentUser); ok { // 2. Administrators have access to all resources
return if acl.IsAdmin(currentUser) {
return createBatchResult(true, "Administrator access"), nil
} }
// 3. Get user's authorized V2 rule IDs from ACL (like V1's AuthorizationIds)
authV2ResourceIds, err := s.getAuthorizedV2ResourceIds(ctx)
if err != nil {
return createBatchResult(false, "Failed to get authorized rules"), err
}
if len(authV2ResourceIds) == 0 {
return createBatchResult(false, "No authorization rules available"), nil
}
// Load asset if not already loaded
if sess.Session.Asset == nil { if sess.Session.Asset == nil {
if err := s.db.Model(sess.Session.Asset).Where("id=?", sess.AssetId).First(&sess.Session.Asset).Error; err != nil { if err := s.db.Model(sess.Session.Asset).Where("id=?", sess.AssetId).First(&sess.Session.Asset).Error; err != nil {
return false, err return createBatchResult(false, "Asset not found"), err
} }
} }
authIds, err := s.GetAuthorizationIds(ctx) // Create base authorization request (without action, will be added per action)
if err != nil { clientIP := s.getClientIP(ctx)
return false, err baseReq := &model.BatchAuthRequest{
} UserId: currentUser.GetUid(),
if ok = lo.ContainsBy(authIds, func(item *model.AuthorizationIds) bool { NodeId: sess.Session.Asset.ParentId,
return item.NodeId == 0 && item.AssetId == sess.AssetId && item.AccountId == sess.AccountId AssetId: sess.AssetId,
}); ok { AccountId: sess.AccountId,
return true, nil Actions: actions,
} ClientIP: clientIP,
ctx.Set(kAuthorizationIds, authIds) Timestamp: time.Now(),
authorizationIds, ok := ctx.Value(kAuthorizationIds).([]*model.AuthorizationIds)
if !ok || len(authorizationIds) == 0 {
return false, errors.New("authorizationIds not found")
}
assetService := NewAssetService()
nodeIds, assetIds, accountIds := assetService.GetIdsByAuthorizationIds(ctx, authorizationIds)
tmp, err := repository.HandleSelfChild(ctx, nodeIds...)
if err != nil {
logger.L().Error("", zap.Error(err))
return false, err
}
nodeIds = append(nodeIds, tmp...)
if ok = lo.Contains(nodeIds, sess.Session.Asset.ParentId) || lo.Contains(assetIds, sess.AssetId) || lo.Contains(accountIds, sess.AccountId); ok {
return true, nil
} }
ids, err := assetService.GetAssetIdsByNodeAccount(ctx, nodeIds, accountIds) // Use V2 matcher with filtered rule scope
if err != nil { return s.matcher.MatchBatchWithScope(ctx, baseReq, authV2ResourceIds)
logger.L().Error("", zap.Error(err)) }
return false, err
} // getAuthorizedV2ResourceIds gets V2 authorization rule resource IDs that user has permission to (like V1's AuthorizationIds)
func (s *AuthorizationService) getAuthorizedV2ResourceIds(ctx *gin.Context) ([]int, error) {
return lo.Contains(ids, sess.AssetId), nil currentUser, _ := acl.GetSessionFromCtx(ctx)
// Get ACL resources for authorization_v2 that this user's role has access to
res, err := acl.GetRoleResources(ctx, currentUser.GetRid(), config.RESOURCE_AUTHORIZATION)
if err != nil {
return nil, err
}
// Extract resource IDs (these are the V2 rule IDs user can access)
resourceIds := lo.Map(res, func(r *acl.Resource, _ int) int { return r.ResourceId })
return resourceIds, nil
}
// CheckPermission checks permission for specific node/asset/account combination
func (s *AuthorizationService) CheckPermission(ctx *gin.Context, nodeId, assetId, accountId int, action model.AuthAction) (*model.AuthResult, error) {
currentUser, _ := acl.GetSessionFromCtx(ctx)
// Administrators have access to all resources
if acl.IsAdmin(currentUser) {
return &model.AuthResult{
Allowed: true,
Reason: "Administrator access",
}, nil
}
// Create authorization request
clientIP := s.getClientIP(ctx)
req := &model.AuthRequest{
UserId: currentUser.GetUid(),
NodeId: nodeId,
AssetId: assetId,
AccountId: accountId,
Action: action,
ClientIP: clientIP,
Timestamp: time.Now(),
}
// Use V2 matcher
return s.matcher.Match(ctx, req)
}
// getClientIP extracts client IP from gin context
func (s *AuthorizationService) getClientIP(ctx *gin.Context) string {
// Try to get real IP from headers first
clientIP := ctx.GetHeader("X-Forwarded-For")
if clientIP == "" {
clientIP = ctx.GetHeader("X-Real-IP")
}
if clientIP == "" {
clientIP = ctx.ClientIP()
}
// Parse and validate IP
if ip := net.ParseIP(clientIP); ip != nil {
return clientIP
}
return ""
}
// createV2AuthorizationRulesForAsset creates V2 authorization rules for an asset
func (s *AuthorizationService) createV2AuthorizationRulesForAsset(ctx context.Context, tx *gorm.DB, asset *model.Asset, currentUser *acl.Session) error {
if len(asset.Authorization) == 0 {
return nil
}
for accountId, authData := range asset.Authorization {
// Create a V2 authorization rule for this asset-account combination
rule := &model.AuthorizationV2{
Name: fmt.Sprintf("Asset-%d-Account-%d", asset.Id, accountId),
Description: fmt.Sprintf("Auto-generated rule for asset %s and account %d", asset.Name, accountId),
Enabled: true,
// Target selectors - specific asset and account
AssetSelector: model.TargetSelector{
Type: model.SelectorTypeIds,
Values: []string{fmt.Sprintf("%d", asset.Id)},
ExcludeIds: []int{},
},
AccountSelector: model.TargetSelector{
Type: model.SelectorTypeIds,
Values: []string{fmt.Sprintf("%d", accountId)},
ExcludeIds: []int{},
},
// Use permissions from asset.Authorization
Permissions: *authData.Permissions,
// Role IDs for ACL integration
Rids: authData.Rids,
// Standard fields
CreatorId: currentUser.GetUid(),
UpdaterId: currentUser.GetUid(),
}
// Create ACL resource for this rule
resourceId, err := acl.CreateAcl(ctx, currentUser, config.RESOURCE_AUTHORIZATION, rule.Name)
if err != nil {
return fmt.Errorf("failed to create ACL resource: %w", err)
}
rule.ResourceId = resourceId
// Create the V2 rule
if err := tx.Create(rule).Error; err != nil {
return fmt.Errorf("failed to create V2 authorization rule: %w", err)
}
// Grant permissions to roles
if len(authData.Rids) > 0 {
if err := acl.BatchGrantRoleResource(ctx, currentUser.GetUid(), authData.Rids, resourceId, []string{acl.READ}); err != nil {
return fmt.Errorf("failed to grant role permissions: %w", err)
}
}
}
return nil
}
// deleteV2AuthorizationRulesForAsset deletes V2 authorization rules for an asset
func (s *AuthorizationService) deleteV2AuthorizationRulesForAsset(ctx context.Context, tx *gorm.DB, asset *model.Asset) error {
// Find all V2 rules that target this specific asset
var rules []*model.AuthorizationV2
if err := tx.Where("asset_selector->>'$.values' LIKE ?", fmt.Sprintf("%%\"%d\"%%", asset.Id)).Find(&rules).Error; err != nil {
return fmt.Errorf("failed to find V2 rules for asset: %w", err)
}
// Delete each rule and its ACL resource
for _, rule := range rules {
// Delete ACL resource
if err := acl.DeleteResource(ctx, 0, rule.ResourceId); err != nil {
logger.L().Error("Failed to delete ACL resource", zap.Int("resourceId", rule.ResourceId), zap.Error(err))
// Continue with database deletion even if ACL deletion fails
}
// Delete the rule from database
if err := tx.Delete(rule).Error; err != nil {
return fmt.Errorf("failed to delete V2 authorization rule: %w", err)
}
}
return nil
}
// updateV2AuthorizationRulesForAsset updates V2 authorization rules for an asset
func (s *AuthorizationService) updateV2AuthorizationRulesForAsset(ctx context.Context, tx *gorm.DB, asset *model.Asset, currentUser *acl.Session) error {
// For simplicity, we'll delete existing rules and create new ones
// This ensures consistency and handles complex permission changes
// First, delete existing rules for this asset
if err := s.deleteV2AuthorizationRulesForAsset(ctx, tx, asset); err != nil {
return fmt.Errorf("failed to delete existing V2 rules: %w", err)
}
// Then create new rules based on current asset.Authorization
if err := s.createV2AuthorizationRulesForAsset(ctx, tx, asset, currentUser); err != nil {
return fmt.Errorf("failed to create new V2 rules: %w", err)
}
return nil
} }

View File

@@ -0,0 +1,637 @@
package service
import (
"context"
"fmt"
"net"
"regexp"
"strings"
"time"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"github.com/spf13/cast"
"github.com/veops/oneterm/internal/acl"
"github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/repository"
)
// IAuthorizationMatcher defines the interface for authorization matching
type IAuthorizationMatcher interface {
// Match checks if a request is authorized using all available rules
Match(ctx *gin.Context, req *model.AuthRequest) (*model.AuthResult, error)
// MatchWithScope checks if a request is authorized using only specified rule IDs
MatchWithScope(ctx *gin.Context, req *model.AuthRequest, ruleIds []int) (*model.AuthResult, error)
// MatchBatchWithScope checks if batch requests are authorized using only specified rule IDs
MatchBatchWithScope(ctx *gin.Context, req *model.BatchAuthRequest, ruleIds []int) (*model.BatchAuthResult, error)
GetTargetName(targetType string, targetId int) (string, error)
GetTargetTags(targetType string, targetId int) ([]string, error)
}
// AuthorizationMatcher implements IAuthorizationMatcher
type AuthorizationMatcher struct {
repo repository.IAuthorizationV2Repository
}
// NewAuthorizationMatcher creates a new authorization matcher
func NewAuthorizationMatcher(repo repository.IAuthorizationV2Repository) IAuthorizationMatcher {
return &AuthorizationMatcher{
repo: repo,
}
}
// Match performs authorization matching against rules
func (m *AuthorizationMatcher) Match(ctx *gin.Context, req *model.AuthRequest) (*model.AuthResult, error) {
// Get cache key for this request
cacheKey := m.getCacheKey(req)
// Try to get result from cache first
if cached := m.getCachedResult(cacheKey); cached != nil {
return cached, nil
}
// Get user's role IDs from context (assuming it's passed through ctx)
userRids := m.getUserRoleIds(ctx, req.UserId)
// Get user's authorization rules
rules, err := m.repo.GetUserRules(ctx, userRids)
if err != nil {
return nil, fmt.Errorf("failed to get user rules: %w", err)
}
// Check all matching rules - if any rule allows the action, grant permission
var matchedRules []*model.AuthorizationV2
for _, rule := range rules {
if m.matchRule(ctx, rule, req) {
matchedRules = append(matchedRules, rule)
// If this rule allows the requested action, grant permission immediately
if rule.Permissions.HasPermission(req.Action) {
result := &model.AuthResult{
Allowed: true,
Permissions: rule.Permissions,
Reason: fmt.Sprintf("Allowed by rule: %s", rule.Name),
RuleId: rule.Id,
RuleName: rule.Name,
}
// Cache the result
m.cacheResult(cacheKey, result)
return result, nil
}
}
}
// If we found matching rules but none allowed the action, deny with specific reason
if len(matchedRules) > 0 {
result := &model.AuthResult{
Allowed: false,
Reason: fmt.Sprintf("Action '%s' denied by %d matching rule(s)", req.Action, len(matchedRules)),
}
m.cacheResult(cacheKey, result)
return result, nil
}
// No matching rule found
result := &model.AuthResult{
Allowed: false,
Reason: "No matching authorization rule found",
}
m.cacheResult(cacheKey, result)
return result, nil
}
// getUserRoleIds extracts user role IDs from context or fetches them
func (m *AuthorizationMatcher) getUserRoleIds(ctx context.Context, userId int) []int {
// Try to get from gin context if available
if ginCtx, ok := ctx.(*gin.Context); ok {
if currentUser, err := acl.GetSessionFromCtx(ginCtx); err == nil {
return []int{currentUser.GetRid()}
}
}
// Fallback: try to get role from ACL service
// This is a simplified approach - in production you might want to implement
// a more robust user role resolution mechanism
return []int{} // Return empty for now - should be implemented based on your ACL system
}
// matchRule checks if a rule matches the request
func (m *AuthorizationMatcher) matchRule(ctx context.Context, rule *model.AuthorizationV2, req *model.AuthRequest) bool {
// First check if the rule is currently valid (enabled and within validity period)
if !rule.IsValid(req.Timestamp) {
return false
}
// Check node selector
if !m.matchSelector(ctx, rule.NodeSelector, "node", req.NodeId) {
return false
}
// Check asset selector
if !m.matchSelector(ctx, rule.AssetSelector, "asset", req.AssetId) {
return false
}
// Check account selector
if !m.matchSelector(ctx, rule.AccountSelector, "account", req.AccountId) {
return false
}
// Check access control restrictions
if !m.checkAccessControl(rule.AccessControl, req) {
return false
}
return true
}
// matchSelector checks if a target selector matches the given target
func (m *AuthorizationMatcher) matchSelector(ctx context.Context, selector model.TargetSelector, targetType string, targetId int) bool {
// Handle zero ID based on selector type
if targetId == 0 {
return selector.Type == model.SelectorTypeAll
}
// Check if target is in exclude list
if lo.Contains(selector.ExcludeIds, targetId) {
return false
}
switch selector.Type {
case model.SelectorTypeAll:
return true
case model.SelectorTypeIds:
targetIds := lo.Map(selector.Values, func(v string, _ int) int {
return cast.ToInt(v)
})
return lo.Contains(targetIds, targetId)
case model.SelectorTypeRegex:
targetName, err := m.GetTargetName(targetType, targetId)
if err != nil {
return false
}
return m.matchRegexPatterns(selector.Values, targetName)
case model.SelectorTypeTags:
targetTags, err := m.GetTargetTags(targetType, targetId)
if err != nil {
return false
}
return len(lo.Intersect(selector.Values, targetTags)) > 0
default:
return false
}
}
// matchRegexPatterns checks if any regex pattern matches the target name
func (m *AuthorizationMatcher) matchRegexPatterns(patterns []string, targetName string) bool {
for _, pattern := range patterns {
if matched, err := regexp.MatchString(pattern, targetName); err == nil && matched {
return true
}
}
return false
}
// checkAccessControl validates access control restrictions
func (m *AuthorizationMatcher) checkAccessControl(accessControl model.AccessControl, req *model.AuthRequest) bool {
// Check IP whitelist
if len(accessControl.IPWhitelist) > 0 && !m.checkIPWhitelist(accessControl.IPWhitelist, req.ClientIP) {
return false
}
// Get asset for asset-level restrictions
asset, err := m.getAssetById(req.AssetId)
if err != nil {
return false
}
// Check time restrictions with time template support
if !m.checkUpdatedTimeRestrictions(asset.AccessTimeControl, &accessControl, req.Timestamp) {
return false
}
// TODO: Check max sessions and session timeout (requires session management)
return true
}
// checkUpdatedTimeRestrictions implements time restriction logic with template support
func (m *AuthorizationMatcher) checkUpdatedTimeRestrictions(assetTimeControl *model.AccessTimeControl, accessControl *model.AccessControl, timestamp time.Time) bool {
if timestamp.IsZero() {
timestamp = time.Now()
}
// 1. Check asset-level V2 time restrictions (base constraint)
if assetTimeControl != nil && assetTimeControl.Enabled {
if !m.checkAssetTimeRanges(assetTimeControl, timestamp) {
return false // Asset-level restriction failed, deny access
}
}
// 2. Check authorization rule's time template restrictions if configured
if accessControl.TimeTemplate != nil {
if !m.checkTimeTemplateAccess(accessControl.TimeTemplate, accessControl.Timezone, timestamp) {
return false
}
}
// 3. Check authorization rule's custom time ranges if configured
if len(accessControl.CustomTimeRanges) > 0 {
if !m.checkTimeRanges(accessControl.CustomTimeRanges, timestamp) {
return false
}
}
// 4. If no restrictions are configured or all pass, allow access
return true
}
// checkTimeTemplateAccess checks if current time is within template's allowed ranges
func (m *AuthorizationMatcher) checkTimeTemplateAccess(templateRef *model.TimeTemplateReference, timezone string, timestamp time.Time) bool {
// Get the time template service for validation
timeTemplateService := NewTimeTemplateService()
// Get the template
ctx := context.Background()
template, err := timeTemplateService.GetTimeTemplate(ctx, templateRef.TemplateId)
if err != nil || template == nil {
// If template cannot be found, deny access for safety
return false
}
// Use the specified timezone or template's timezone
checkTimezone := timezone
if checkTimezone == "" {
checkTimezone = template.Timezone
}
if checkTimezone == "" {
checkTimezone = "Asia/Shanghai" // Default timezone
}
// Check if current time is within template ranges
if m.isTimeInTemplateRanges(template.TimeRanges, checkTimezone, timestamp) {
return true
}
// Check custom ranges in the template reference
if len(templateRef.CustomRanges) > 0 {
if m.isTimeInTemplateRanges(templateRef.CustomRanges, checkTimezone, timestamp) {
return true
}
}
return false
}
// isTimeInTemplateRanges checks if timestamp is within any of the template time ranges
func (m *AuthorizationMatcher) isTimeInTemplateRanges(ranges model.TimeRanges, timezone string, timestamp time.Time) bool {
// Load timezone location
loc, err := time.LoadLocation(timezone)
if err != nil {
// Fall back to UTC if timezone is invalid
loc = time.UTC
}
targetTime := timestamp.In(loc)
currentWeekday := int(targetTime.Weekday())
if currentWeekday == 0 {
currentWeekday = 7 // Convert Sunday from 0 to 7
}
timeStr := targetTime.Format("15:04")
// Check each time range
for _, timeRange := range ranges {
// Check if current weekday is in the allowed weekdays
weekdayMatch := false
for _, day := range timeRange.Weekdays {
if day == currentWeekday {
weekdayMatch = true
break
}
}
if !weekdayMatch {
continue
}
// Check if current time is in the allowed time range
if m.isTimeInRange(timeStr, timeRange.StartTime, timeRange.EndTime) {
return true
}
}
return false
}
// checkAssetTimeRanges validates asset-level time restrictions with timezone support
func (m *AuthorizationMatcher) checkAssetTimeRanges(assetTimeControl *model.AccessTimeControl, timestamp time.Time) bool {
if len(assetTimeControl.TimeRanges) == 0 {
return true // No time ranges specified, allow access
}
// Handle timezone conversion
targetTime := timestamp
if assetTimeControl.Timezone != "" {
if loc, err := time.LoadLocation(assetTimeControl.Timezone); err == nil {
targetTime = timestamp.In(loc)
}
}
weekday := int(targetTime.Weekday())
if weekday == 0 {
weekday = 7 // Convert Sunday from 0 to 7
}
timeStr := targetTime.Format("15:04")
for _, tr := range assetTimeControl.TimeRanges {
// Check if current weekday is allowed
if len(tr.Weekdays) > 0 && !lo.Contains(tr.Weekdays, weekday) {
continue
}
// Check if current time is within allowed range
if tr.StartTime != "" && tr.EndTime != "" {
if m.isTimeInRange(timeStr, tr.StartTime, tr.EndTime) {
return true
}
} else {
return true // No time restriction
}
}
return false
}
// getAssetById retrieves asset by ID (with caching)
func (m *AuthorizationMatcher) getAssetById(assetId int) (*model.Asset, error) {
assets, err := repository.GetAllFromCacheDb(context.Background(), model.DefaultAsset)
if err != nil {
return nil, err
}
for _, asset := range assets {
if asset.Id == assetId {
return asset, nil
}
}
return nil, fmt.Errorf("asset not found: %d", assetId)
}
// checkIPWhitelist validates if client IP is in whitelist
func (m *AuthorizationMatcher) checkIPWhitelist(whitelist []string, clientIP string) bool {
if clientIP == "" {
return false
}
clientIPNet := net.ParseIP(clientIP)
if clientIPNet == nil {
return false
}
for _, ipRange := range whitelist {
if strings.Contains(ipRange, "/") {
// CIDR notation
_, cidr, err := net.ParseCIDR(ipRange)
if err == nil && cidr.Contains(clientIPNet) {
return true
}
} else {
// Single IP
if ipRange == clientIP {
return true
}
}
}
return false
}
// checkTimeRanges validates if current time is within allowed ranges
func (m *AuthorizationMatcher) checkTimeRanges(timeRanges model.TimeRanges, timestamp time.Time) bool {
if timestamp.IsZero() {
timestamp = time.Now()
}
weekday := int(timestamp.Weekday())
if weekday == 0 {
weekday = 7 // Convert Sunday from 0 to 7
}
timeStr := timestamp.Format("15:04")
for _, tr := range timeRanges {
// Check if current weekday is allowed
if len(tr.Weekdays) > 0 && !lo.Contains(tr.Weekdays, weekday) {
continue
}
// Check if current time is within allowed range
if tr.StartTime != "" && tr.EndTime != "" {
if m.isTimeInRange(timeStr, tr.StartTime, tr.EndTime) {
return true
}
} else {
return true // No time restriction
}
}
return len(timeRanges) == 0 // Allow if no time ranges specified
}
// isTimeInRange checks if time is within the specified range
func (m *AuthorizationMatcher) isTimeInRange(timeStr, startTime, endTime string) bool {
return timeStr >= startTime && timeStr <= endTime
}
// GetTargetName retrieves the name of a target by type and ID
func (m *AuthorizationMatcher) GetTargetName(targetType string, targetId int) (string, error) {
switch targetType {
case "node":
nodes, err := repository.GetAllFromCacheDb(context.Background(), model.DefaultNode)
if err != nil {
return "", err
}
for _, node := range nodes {
if node.Id == targetId {
return node.Name, nil
}
}
return "", fmt.Errorf("node not found: %d", targetId)
case "asset":
assets, err := repository.GetAllFromCacheDb(context.Background(), model.DefaultAsset)
if err != nil {
return "", err
}
for _, asset := range assets {
if asset.Id == targetId {
return asset.Name, nil
}
}
return "", fmt.Errorf("asset not found: %d", targetId)
case "account":
accounts, err := repository.GetAllFromCacheDb(context.Background(), model.DefaultAccount)
if err != nil {
return "", err
}
for _, account := range accounts {
if account.Id == targetId {
return account.Name, nil
}
}
return "", fmt.Errorf("account not found: %d", targetId)
default:
return "", fmt.Errorf("unknown target type: %s", targetType)
}
}
// GetTargetTags retrieves tags for a target (placeholder implementation)
func (m *AuthorizationMatcher) GetTargetTags(targetType string, targetId int) ([]string, error) {
// TODO: Implement tag system for nodes, assets, and accounts
// For now, return empty tags
return []string{}, nil
}
// getCacheKey generates a cache key for the request
func (m *AuthorizationMatcher) getCacheKey(req *model.AuthRequest) string {
return fmt.Sprintf("auth_v2:%d:%d:%d:%d:%s",
req.UserId, req.NodeId, req.AssetId, req.AccountId, req.Action)
}
// getCachedResult retrieves cached authorization result
func (m *AuthorizationMatcher) getCachedResult(cacheKey string) *model.AuthResult {
// TODO: Implement Redis caching
return nil
}
// cacheResult caches the authorization result
func (m *AuthorizationMatcher) cacheResult(cacheKey string, result *model.AuthResult) {
// TODO: Implement Redis caching with TTL
}
// MatchWithScope checks if a request is authorized using only specified rule IDs (like V1's AuthorizationIds filtering)
func (m *AuthorizationMatcher) MatchWithScope(ctx *gin.Context, req *model.AuthRequest, ruleIds []int) (*model.AuthResult, error) {
if len(ruleIds) == 0 {
return &model.AuthResult{
Allowed: false,
Reason: "No rule IDs provided",
}, nil
}
// Get only the rules that user has permission to access (already filtered by enabled=true)
enabledRules, err := m.repo.GetByResourceIds(ctx, ruleIds)
if err != nil {
return &model.AuthResult{
Allowed: false,
Reason: "Failed to load authorization rules",
}, err
}
// Check each rule in the filtered scope
for _, rule := range enabledRules {
if m.matchRule(ctx, rule, req) {
// If this rule allows the requested action, grant permission immediately
if rule.Permissions.HasPermission(req.Action) {
return &model.AuthResult{
Allowed: true,
Permissions: rule.Permissions,
Reason: fmt.Sprintf("Allowed by rule: %s", rule.Name),
RuleId: rule.Id,
RuleName: rule.Name,
}, nil
}
}
}
return &model.AuthResult{
Allowed: false,
Reason: "No matching authorization rule found in scope",
}, nil
}
// MatchBatchWithScope checks if batch requests are authorized using only specified rule IDs
func (m *AuthorizationMatcher) MatchBatchWithScope(ctx *gin.Context, req *model.BatchAuthRequest, ruleIds []int) (*model.BatchAuthResult, error) {
results := make(map[model.AuthAction]*model.AuthResult)
// Initialize all actions as denied
for _, action := range req.Actions {
results[action] = &model.AuthResult{
Allowed: false,
Reason: "No matching authorization rule found in scope",
}
}
if len(ruleIds) == 0 {
for action := range results {
results[action].Reason = "No rule IDs provided"
}
return &model.BatchAuthResult{Results: results}, nil
}
// Single database query for all rules
enabledRules, err := m.repo.GetByResourceIds(ctx, ruleIds)
if err != nil {
for action := range results {
results[action] = &model.AuthResult{
Allowed: false,
Reason: "Failed to load authorization rules",
}
}
return &model.BatchAuthResult{Results: results}, err
}
// Base request for rule matching
baseReq := &model.AuthRequest{
UserId: req.UserId,
NodeId: req.NodeId,
AssetId: req.AssetId,
AccountId: req.AccountId,
ClientIP: req.ClientIP,
UserAgent: req.UserAgent,
Timestamp: req.Timestamp,
}
// Check each rule once and validate all actions
for _, rule := range enabledRules {
if m.matchRule(ctx, rule, baseReq) {
for _, action := range req.Actions {
if rule.Permissions.HasPermission(action) && !results[action].Allowed {
results[action] = &model.AuthResult{
Allowed: true,
Permissions: rule.Permissions,
Reason: fmt.Sprintf("Allowed by rule: %s", rule.Name),
RuleId: rule.Id,
RuleName: rule.Name,
}
}
}
// Early exit if all actions are allowed
allAllowed := true
for _, action := range req.Actions {
if !results[action].Allowed {
allAllowed = false
break
}
}
if allAllowed {
break
}
}
}
return &model.BatchAuthResult{Results: results}, nil
}

View File

@@ -0,0 +1,625 @@
package service
import (
"context"
"encoding/json"
"fmt"
"time"
"go.uber.org/zap"
"gorm.io/gorm"
"github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/repository"
dbpkg "github.com/veops/oneterm/pkg/db"
"github.com/veops/oneterm/pkg/logger"
)
// AuthorizationMigrationService handles V1 to V2 authorization migration
type AuthorizationMigrationService struct {
db *gorm.DB
v1Repo repository.IAuthorizationRepository
v2Repo repository.IAuthorizationV2Repository
}
// NewAuthorizationMigrationService creates a new migration service
func NewAuthorizationMigrationService(
db *gorm.DB,
v1Repo repository.IAuthorizationRepository,
v2Repo repository.IAuthorizationV2Repository,
) *AuthorizationMigrationService {
return &AuthorizationMigrationService{
db: db,
v1Repo: v1Repo,
v2Repo: v2Repo,
}
}
// MigrateV1ToV2 performs the complete migration from V1 to V2
func (s *AuthorizationMigrationService) MigrateV1ToV2(ctx context.Context) error {
logger.L().Info("Starting V1 to V2 authorization migration")
// Check if migration is already completed
if completed, err := s.IsMigrationCompleted(ctx); err != nil {
return fmt.Errorf("failed to check migration status: %w", err)
} else if completed {
logger.L().Info("Migration already completed, skipping")
return nil
}
// Get all V1 authorization rules
v1Rules, err := s.getAllV1Rules(ctx)
if err != nil {
return fmt.Errorf("failed to get V1 rules: %w", err)
}
if len(v1Rules) == 0 {
logger.L().Info("No V1 authorization rules found, marking migration as completed")
return s.markMigrationCompleted(ctx, 0)
}
logger.L().Info("Found V1 authorization rules", zap.Int("count", len(v1Rules)))
// Mark migration as running
if err := s.markMigrationRunning(ctx); err != nil {
return fmt.Errorf("failed to mark migration as running: %w", err)
}
// Start transaction
tx := s.db.Begin()
defer func() {
if r := recover(); r != nil {
tx.Rollback()
s.markMigrationFailed(ctx, fmt.Sprintf("panic during migration: %v", r))
}
}()
// Migrate each V1 rule to V2
migratedCount := 0
for _, v1Rule := range v1Rules {
v2Rule := s.convertV1ToV2(v1Rule)
if err := s.v2Repo.Create(ctx, v2Rule); err != nil {
tx.Rollback()
s.markMigrationFailed(ctx, fmt.Sprintf("failed to create V2 rule for V1 rule %d: %v", v1Rule.Id, err))
return fmt.Errorf("failed to create V2 rule for V1 rule %d: %w", v1Rule.Id, err)
}
migratedCount++
logger.L().Debug("Migrated V1 rule", zap.Int("v1_id", v1Rule.Id), zap.Int("v2_id", v2Rule.Id))
}
// Also migrate asset authorization fields from V1 to V2 format
if err := s.migrateAssetAuthorizationFields(ctx, tx); err != nil {
tx.Rollback()
s.markMigrationFailed(ctx, fmt.Sprintf("failed to migrate asset authorization fields: %v", err))
return fmt.Errorf("failed to migrate asset authorization fields: %w", err)
}
// Mark migration as completed
if err := s.markMigrationCompleted(ctx, migratedCount); err != nil {
tx.Rollback()
return fmt.Errorf("failed to mark migration as completed: %w", err)
}
// Commit transaction
if err := tx.Commit().Error; err != nil {
return fmt.Errorf("failed to commit migration transaction: %w", err)
}
logger.L().Info("V1 to V2 migration completed successfully",
zap.Int("migrated_count", migratedCount))
return nil
}
// IsMigrationCompleted checks if the migration has been completed
func (s *AuthorizationMigrationService) IsMigrationCompleted(ctx context.Context) (bool, error) {
var record model.MigrationRecord
err := s.db.Where("migration_name = ?", model.MigrationAuthV1ToV2).First(&record).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
return false, nil
}
return false, err
}
return record.Status == model.MigrationStatusCompleted, nil
}
// markMigrationCompleted marks the migration as completed
func (s *AuthorizationMigrationService) markMigrationCompleted(ctx context.Context, recordsCount int) error {
now := time.Now()
// Try to update existing record, or create new one
var record model.MigrationRecord
err := s.db.Where("migration_name = ?", model.MigrationAuthV1ToV2).First(&record).Error
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
if err == gorm.ErrRecordNotFound {
// Create new record
record = model.MigrationRecord{
MigrationName: model.MigrationAuthV1ToV2,
Status: model.MigrationStatusCompleted,
StartedAt: &now,
CompletedAt: &now,
RecordsCount: recordsCount,
}
return s.db.Create(&record).Error
}
// Update existing record
return s.db.Model(&record).Updates(map[string]interface{}{
"status": model.MigrationStatusCompleted,
"completed_at": &now,
"error_message": "",
"records_count": recordsCount,
}).Error
}
// getAllV1Rules retrieves all V1 authorization rules
func (s *AuthorizationMigrationService) getAllV1Rules(ctx context.Context) ([]*model.Authorization, error) {
var rules []*model.Authorization
err := s.db.Find(&rules).Error
return rules, err
}
// convertV1ToV2 converts a V1 authorization rule to V2 format
func (s *AuthorizationMigrationService) convertV1ToV2(v1 *model.Authorization) *model.AuthorizationV2 {
v2 := &model.AuthorizationV2{
Name: s.generateRuleName(v1),
Description: s.generateRuleDescription(v1),
Enabled: true,
Rids: v1.Rids,
// Copy standard fields
ResourceId: v1.ResourceId,
CreatorId: v1.CreatorId,
UpdaterId: v1.UpdaterId,
CreatedAt: v1.CreatedAt,
UpdatedAt: v1.UpdatedAt,
}
// Convert specific IDs to selectors
if v1.NodeId > 0 {
v2.NodeSelector = model.TargetSelector{
Type: model.SelectorTypeIds,
Values: []string{fmt.Sprintf("%d", v1.NodeId)},
}
} else {
v2.NodeSelector = model.TargetSelector{
Type: model.SelectorTypeAll,
}
}
if v1.AssetId > 0 {
v2.AssetSelector = model.TargetSelector{
Type: model.SelectorTypeIds,
Values: []string{fmt.Sprintf("%d", v1.AssetId)},
}
} else {
v2.AssetSelector = model.TargetSelector{
Type: model.SelectorTypeAll,
}
}
if v1.AccountId > 0 {
v2.AccountSelector = model.TargetSelector{
Type: model.SelectorTypeIds,
Values: []string{fmt.Sprintf("%d", v1.AccountId)},
}
} else {
v2.AccountSelector = model.TargetSelector{
Type: model.SelectorTypeAll,
}
}
// Set default permissions - V1 only had connect permission
v2.Permissions = model.AuthPermissions{
Connect: true,
FileUpload: s.getDefaultFileUploadPermission(),
FileDownload: s.getDefaultFileDownloadPermission(),
Copy: s.getDefaultCopyPermission(),
Paste: s.getDefaultPastePermission(),
Share: false, // Default to false for security
}
// Set default access control
v2.AccessControl = model.AccessControl{
IPWhitelist: []string{}, // No IP restrictions by default
MaxSessions: 0, // No session limit by default
SessionTimeout: 0, // Use system default
}
return v2
}
// generateRuleName generates a name for the migrated rule
func (s *AuthorizationMigrationService) generateRuleName(v1 *model.Authorization) string {
parts := []string{"Migrated"}
if v1.NodeId > 0 {
parts = append(parts, fmt.Sprintf("Node-%d", v1.NodeId))
}
if v1.AssetId > 0 {
parts = append(parts, fmt.Sprintf("Asset-%d", v1.AssetId))
}
if v1.AccountId > 0 {
parts = append(parts, fmt.Sprintf("Account-%d", v1.AccountId))
}
return fmt.Sprintf("%s-Rule-V1-%d", joinParts(parts), v1.Id)
}
// generateRuleDescription generates a description for the migrated rule
func (s *AuthorizationMigrationService) generateRuleDescription(v1 *model.Authorization) string {
return fmt.Sprintf("Automatically migrated from V1 authorization rule (ID: %d). "+
"Node: %d, Asset: %d, Account: %d, Roles: %v",
v1.Id, v1.NodeId, v1.AssetId, v1.AccountId, v1.Rids)
}
// Helper functions to get default permissions from system config
func (s *AuthorizationMigrationService) getDefaultFileUploadPermission() bool {
// In a real implementation, you might check system configuration
// For now, return true as a safe default
return true
}
func (s *AuthorizationMigrationService) getDefaultFileDownloadPermission() bool {
return true
}
func (s *AuthorizationMigrationService) getDefaultCopyPermission() bool {
return true
}
func (s *AuthorizationMigrationService) getDefaultPastePermission() bool {
return true
}
// joinParts joins string parts with "-"
func joinParts(parts []string) string {
result := ""
for i, part := range parts {
if i > 0 {
result += "-"
}
result += part
}
return result
}
// markMigrationRunning marks the migration as running
func (s *AuthorizationMigrationService) markMigrationRunning(ctx context.Context) error {
now := time.Now()
// Try to update existing record, or create new one
var record model.MigrationRecord
err := s.db.Where("migration_name = ?", model.MigrationAuthV1ToV2).First(&record).Error
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
if err == gorm.ErrRecordNotFound {
// Create new record
record = model.MigrationRecord{
MigrationName: model.MigrationAuthV1ToV2,
Status: model.MigrationStatusRunning,
StartedAt: &now,
}
return s.db.Create(&record).Error
}
// Update existing record
return s.db.Model(&record).Updates(map[string]interface{}{
"status": model.MigrationStatusRunning,
"started_at": &now,
"completed_at": nil,
"error_message": "",
}).Error
}
// markMigrationFailed marks the migration as failed with error message
func (s *AuthorizationMigrationService) markMigrationFailed(ctx context.Context, errorMsg string) error {
var record model.MigrationRecord
err := s.db.Where("migration_name = ?", model.MigrationAuthV1ToV2).First(&record).Error
if err != nil {
if err == gorm.ErrRecordNotFound {
// Create new record with failed status
record = model.MigrationRecord{
MigrationName: model.MigrationAuthV1ToV2,
Status: model.MigrationStatusFailed,
ErrorMessage: errorMsg,
}
return s.db.Create(&record).Error
}
return err
}
// Update existing record
return s.db.Model(&record).Updates(map[string]interface{}{
"status": model.MigrationStatusFailed,
"error_message": errorMsg,
}).Error
}
// migrateAssetAuthorizationFields migrates asset authorization fields from V1 to V2 format
func (s *AuthorizationMigrationService) migrateAssetAuthorizationFields(ctx context.Context, tx *gorm.DB) error {
logger.L().Info("Starting asset authorization field migration")
// Query assets with potential V1 authorization data
var assets []*model.Asset
if err := tx.Select("id", "authorization").Find(&assets).Error; err != nil {
logger.L().Error("Failed to get assets for authorization field migration", zap.Error(err))
return err
}
migrationCount := 0
for _, asset := range assets {
if asset.Id == 0 {
continue
}
// Check if authorization field needs migration
needsMigration, err := s.checkAssetAuthorizationNeedsMigration(tx, asset.Id)
if err != nil {
logger.L().Error("Failed to check asset authorization migration need", zap.Int("assetId", asset.Id), zap.Error(err))
continue
}
if !needsMigration {
continue
}
// Migrate this asset
if err := s.migrateAssetAuthorizationField(ctx, tx, asset.Id); err != nil {
logger.L().Error("Failed to migrate asset authorization field", zap.Int("assetId", asset.Id), zap.Error(err))
continue
}
migrationCount++
}
logger.L().Info("Asset authorization field migration completed", zap.Int("migrated", migrationCount))
return nil
}
// MigrateV1AuthorizationData migrates V1 authorization data format to V2
func MigrateV1AuthorizationData(ctx context.Context) error {
logger.L().Info("Starting V1 to V2 authorization data migration")
// Query assets with potential V1 authorization data
var assets []*model.Asset
if err := dbpkg.DB.Select("id", "authorization").Find(&assets).Error; err != nil {
logger.L().Error("Failed to get assets for migration", zap.Error(err))
return err
}
migrationCount := 0
for _, asset := range assets {
if asset.Id == 0 {
continue
}
// Check if authorization field needs migration
needsMigration, err := checkNeedsMigration(asset.Id)
if err != nil {
logger.L().Error("Failed to check migration need", zap.Int("assetId", asset.Id), zap.Error(err))
continue
}
if !needsMigration {
continue
}
// Migrate this asset
if err := migrateAssetAuthorization(ctx, asset.Id); err != nil {
logger.L().Error("Failed to migrate asset authorization", zap.Int("assetId", asset.Id), zap.Error(err))
continue
}
migrationCount++
}
logger.L().Info("V1 to V2 authorization migration completed", zap.Int("migrated", migrationCount))
return nil
}
// checkNeedsMigration checks if an asset's authorization data needs migration
func checkNeedsMigration(assetId int) (bool, error) {
var rawAuth json.RawMessage
if err := dbpkg.DB.Model(&model.Asset{}).
Where("id = ?", assetId).
Select("authorization").
Scan(&rawAuth).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return false, nil
}
return false, err
}
if len(rawAuth) == 0 {
return false, nil
}
// Try to parse as V2 format first
var v2Auth map[int]model.AccountAuthorization
if err := json.Unmarshal(rawAuth, &v2Auth); err == nil {
// Successfully parsed as V2, no migration needed
return false, nil
}
// Try to parse as V1 format
var v1Auth map[int][]int
if err := json.Unmarshal(rawAuth, &v1Auth); err == nil {
// Successfully parsed as V1, needs migration
return true, nil
}
// Cannot parse as either format, skip
return false, nil
}
// migrateAssetAuthorization migrates a single asset's authorization data
func migrateAssetAuthorization(ctx context.Context, assetId int) error {
// Get raw authorization data
var rawAuth json.RawMessage
if err := dbpkg.DB.Model(&model.Asset{}).
Where("id = ?", assetId).
Select("authorization").
Scan(&rawAuth).Error; err != nil {
return err
}
if len(rawAuth) == 0 {
return nil
}
// Parse as V1 format
var v1Auth map[int][]int
if err := json.Unmarshal(rawAuth, &v1Auth); err != nil {
return fmt.Errorf("failed to parse V1 authorization data: %w", err)
}
// Get default permissions from config
defaultPermissions := getDefaultAuthPermissions()
// Convert V1 to V2 format
v2Auth := make(map[int]model.AccountAuthorization)
for accountId, roleIds := range v1Auth {
v2Auth[accountId] = model.AccountAuthorization{
Rids: roleIds,
Permissions: &defaultPermissions,
}
}
// Update the database
if err := dbpkg.DB.Model(&model.Asset{}).
Where("id = ?", assetId).
Update("authorization", v2Auth).Error; err != nil {
return fmt.Errorf("failed to update authorization data: %w", err)
}
logger.L().Debug("Migrated asset authorization data",
zap.Int("assetId", assetId),
zap.Int("accounts", len(v1Auth)))
return nil
}
// getDefaultAuthPermissions returns default permissions for migration
func getDefaultAuthPermissions() model.AuthPermissions {
// Get from config if available
if config := model.GlobalConfig.Load(); config != nil {
return config.GetDefaultPermissionsAsAuthPermissions()
}
// Fallback to connect-only permissions
return model.AuthPermissions{
Connect: true,
FileUpload: false,
FileDownload: false,
Copy: false,
Paste: false,
Share: false,
}
}
// checkAssetAuthorizationNeedsMigration checks if an asset's authorization data needs migration
func (s *AuthorizationMigrationService) checkAssetAuthorizationNeedsMigration(tx *gorm.DB, assetId int) (bool, error) {
var rawAuth json.RawMessage
if err := tx.Model(&model.Asset{}).
Where("id = ?", assetId).
Select("authorization").
Scan(&rawAuth).Error; err != nil {
if err == gorm.ErrRecordNotFound {
return false, nil
}
return false, err
}
if len(rawAuth) == 0 {
return false, nil
}
// Try to parse as V2 format first
var v2Auth map[int]model.AccountAuthorization
if err := json.Unmarshal(rawAuth, &v2Auth); err == nil {
// Successfully parsed as V2, no migration needed
return false, nil
}
// Try to parse as V1 format
var v1Auth map[int][]int
if err := json.Unmarshal(rawAuth, &v1Auth); err == nil {
// Successfully parsed as V1, needs migration
return true, nil
}
// Cannot parse as either format, skip
return false, nil
}
// migrateAssetAuthorizationField migrates a single asset's authorization data
func (s *AuthorizationMigrationService) migrateAssetAuthorizationField(ctx context.Context, tx *gorm.DB, assetId int) error {
// Get raw authorization data
var rawAuth json.RawMessage
if err := tx.Model(&model.Asset{}).
Where("id = ?", assetId).
Select("authorization").
Scan(&rawAuth).Error; err != nil {
return err
}
if len(rawAuth) == 0 {
return nil
}
// Parse as V1 format
var v1Auth map[int][]int
if err := json.Unmarshal(rawAuth, &v1Auth); err != nil {
return fmt.Errorf("failed to parse V1 authorization data: %w", err)
}
// Get default permissions
defaultPermissions := s.getDefaultAuthPermissions()
// Convert V1 to V2 format
v2Auth := make(map[int]model.AccountAuthorization)
for accountId, roleIds := range v1Auth {
v2Auth[accountId] = model.AccountAuthorization{
Rids: roleIds,
Permissions: &defaultPermissions,
}
}
// Update the database
if err := tx.Model(&model.Asset{}).
Where("id = ?", assetId).
Update("authorization", v2Auth).Error; err != nil {
return fmt.Errorf("failed to update authorization data: %w", err)
}
logger.L().Debug("Migrated asset authorization data",
zap.Int("assetId", assetId),
zap.Int("accounts", len(v1Auth)))
return nil
}
// getDefaultAuthPermissions returns default permissions for migration service
func (s *AuthorizationMigrationService) getDefaultAuthPermissions() model.AuthPermissions {
// Get from config if available
if config := model.GlobalConfig.Load(); config != nil {
return config.GetDefaultPermissionsAsAuthPermissions()
}
// Fallback to connect-only permissions
return model.AuthPermissions{
Connect: true,
FileUpload: false,
FileDownload: false,
Copy: false,
Paste: false,
Share: false,
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,265 @@
package service
import (
"context"
"regexp"
"strconv"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"go.uber.org/zap"
"github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/repository"
gsession "github.com/veops/oneterm/internal/session"
"github.com/veops/oneterm/pkg/logger"
)
// CommandAnalyzer handles command analysis for sessions
type CommandAnalyzer struct {
authService IAuthorizationService
}
// NewCommandAnalyzer creates a new command analyzer
func NewCommandAnalyzer() *CommandAnalyzer {
return &CommandAnalyzer{
authService: DefaultAuthService,
}
}
// AnalyzeSessionCommands analyzes and builds the final command list for a session
// This combines asset-level and authorization-level command controls
func (ca *CommandAnalyzer) AnalyzeSessionCommands(ctx *gin.Context, sess *gsession.Session) ([]*model.Command, error) {
// Get all available commands from cache
allCommands, err := repository.GetAllFromCacheDb(ctx, model.DefaultCommand)
if err != nil {
logger.L().Error("Failed to get commands from cache", zap.Error(err))
return nil, err
}
// Filter enabled commands
enabledCommands := lo.Filter(allCommands, func(cmd *model.Command, _ int) bool {
return cmd.Enable
})
// Analyze asset-level command control
assetCommands := ca.analyzeAssetCommands(sess.Session.Asset, enabledCommands)
// Analyze authorization-level command control
authCommands, err := ca.analyzeAuthorizationCommands(ctx, sess, enabledCommands)
if err != nil {
logger.L().Error("Failed to analyze authorization commands", zap.Error(err))
// Continue with asset-level commands only
authCommands = []*model.Command{}
}
// Merge and deduplicate command lists
finalCommands := ca.mergeCommands(assetCommands, authCommands)
// Compile regex patterns for performance
for _, cmd := range finalCommands {
if cmd.IsRe {
if re, err := regexp.Compile(cmd.Cmd); err == nil {
cmd.Re = re
} else {
logger.L().Warn("Invalid regex pattern in command",
zap.String("cmd", cmd.Cmd),
zap.Int("id", cmd.Id),
zap.Error(err))
}
}
}
logger.L().Info("Command analysis completed",
zap.String("sessionId", sess.SessionId),
zap.Int("assetCommands", len(assetCommands)),
zap.Int("authCommands", len(authCommands)),
zap.Int("finalCommands", len(finalCommands)))
return finalCommands, nil
}
// analyzeAssetCommands analyzes asset-level command controls from V2 system
func (ca *CommandAnalyzer) analyzeAssetCommands(asset *model.Asset, allCommands []*model.Command) []*model.Command {
var result []*model.Command
// V2 asset command control
if asset.AssetCommandControl != nil && asset.AssetCommandControl.Enabled {
var v2Commands []*model.Command
// Process direct command IDs
if len(asset.AssetCommandControl.CmdIds) > 0 {
cmdMap := make(map[int]*model.Command)
for _, cmd := range allCommands {
cmdMap[cmd.Id] = cmd
}
for _, cmdId := range asset.AssetCommandControl.CmdIds {
if cmd, exists := cmdMap[cmdId]; exists {
v2Commands = append(v2Commands, cmd)
}
}
}
// Process command template IDs
if len(asset.AssetCommandControl.TemplateIds) > 0 {
templateCommands := ca.expandCommandTemplates(asset.AssetCommandControl.TemplateIds, allCommands)
v2Commands = append(v2Commands, templateCommands...)
}
// All configured commands are intercepted
result = append(result, v2Commands...)
logger.L().Debug("Asset V2 command control applied",
zap.Int("assetId", asset.Id),
zap.Int("cmdCount", len(v2Commands)))
}
return lo.UniqBy(result, func(cmd *model.Command) int { return cmd.Id })
}
// analyzeAuthorizationCommands analyzes authorization-level command controls from V2 rules
func (ca *CommandAnalyzer) analyzeAuthorizationCommands(ctx *gin.Context, sess *gsession.Session, allCommands []*model.Command) ([]*model.Command, error) {
// Get user's authorized V2 rules
authV2ResourceIds, err := ca.getAuthorizedV2ResourceIds(ctx)
if err != nil {
return nil, err
}
if len(authV2ResourceIds) == 0 {
return []*model.Command{}, nil
}
// Get V2 rules that apply to this session
authV2Service := NewAuthorizationV2Service()
rules, err := authV2Service.repo.GetByResourceIds(ctx, authV2ResourceIds)
if err != nil {
return nil, err
}
var result []*model.Command
// Analyze each applicable rule
for _, rule := range rules {
if !rule.Enabled {
continue
}
// Check if rule matches this session (simplified matching)
if ca.ruleMatchesSession(rule, sess) {
ruleCommands := ca.extractCommandsFromRule(rule, allCommands)
result = append(result, ruleCommands...)
}
}
return lo.UniqBy(result, func(cmd *model.Command) int { return cmd.Id }), nil
}
// ruleMatchesSession checks if a V2 rule matches the current session (simplified)
func (ca *CommandAnalyzer) ruleMatchesSession(rule *model.AuthorizationV2, sess *gsession.Session) bool {
// Quick check for asset selector
if rule.AssetSelector.Type == model.SelectorTypeIds {
assetIds := lo.FilterMap(rule.AssetSelector.Values, func(v string, _ int) (int, bool) {
if id, err := strconv.Atoi(v); err == nil {
return id, true
}
return 0, false
})
if !lo.Contains(assetIds, sess.AssetId) {
return false
}
} else if rule.AssetSelector.Type != model.SelectorTypeAll {
// For regex/tags selectors, we'd need more complex matching
// For now, assume they match (could be optimized later)
}
// Quick check for account selector
if rule.AccountSelector.Type == model.SelectorTypeIds {
accountIds := lo.FilterMap(rule.AccountSelector.Values, func(v string, _ int) (int, bool) {
if id, err := strconv.Atoi(v); err == nil {
return id, true
}
return 0, false
})
if !lo.Contains(accountIds, sess.AccountId) {
return false
}
}
return true
}
// extractCommandsFromRule extracts commands from a V2 authorization rule
func (ca *CommandAnalyzer) extractCommandsFromRule(rule *model.AuthorizationV2, allCommands []*model.Command) []*model.Command {
var result []*model.Command
// Process direct command IDs
if len(rule.AccessControl.CmdIds) > 0 {
cmdMap := make(map[int]*model.Command)
for _, cmd := range allCommands {
cmdMap[cmd.Id] = cmd
}
for _, cmdId := range rule.AccessControl.CmdIds {
if cmd, exists := cmdMap[cmdId]; exists {
result = append(result, cmd)
}
}
}
// Process command template IDs
if len(rule.AccessControl.TemplateIds) > 0 {
templateCommands := ca.expandCommandTemplates(rule.AccessControl.TemplateIds, allCommands)
result = append(result, templateCommands...)
}
// All configured commands are intercepted
return result
}
// expandCommandTemplates expands command template IDs to actual commands
func (ca *CommandAnalyzer) expandCommandTemplates(templateIds []int, allCommands []*model.Command) []*model.Command {
// Get command templates from database
commandTemplateService := NewCommandTemplateService()
ctx := context.Background()
var expandedCommands []*model.Command
for _, templateId := range templateIds {
template, err := commandTemplateService.GetCommandTemplate(ctx, templateId)
if err != nil {
logger.L().Warn("Failed to get command template",
zap.Int("templateId", templateId),
zap.Error(err))
continue
}
if template == nil {
continue
}
// Get commands from template
templateCmdIds := lo.Map(template.CmdIds, func(id int, _ int) int { return id })
templateCommands := lo.Filter(allCommands, func(cmd *model.Command, _ int) bool {
return lo.Contains(templateCmdIds, cmd.Id)
})
expandedCommands = append(expandedCommands, templateCommands...)
}
return expandedCommands
}
// mergeCommands merges and deduplicates command lists
func (ca *CommandAnalyzer) mergeCommands(assetCommands, authCommands []*model.Command) []*model.Command {
// Combine all commands
allCommands := append(assetCommands, authCommands...)
// Deduplicate by ID
return lo.UniqBy(allCommands, func(cmd *model.Command) int { return cmd.Id })
}
// getAuthorizedV2ResourceIds gets V2 authorization rule resource IDs that user has permission to
func (ca *CommandAnalyzer) getAuthorizedV2ResourceIds(ctx *gin.Context) ([]int, error) {
return ca.authService.(*AuthorizationService).getAuthorizedV2ResourceIds(ctx)
}

View File

@@ -7,6 +7,7 @@ import (
"gorm.io/gorm" "gorm.io/gorm"
"github.com/gin-gonic/gin"
"github.com/veops/oneterm/internal/model" "github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/repository" "github.com/veops/oneterm/internal/repository"
dbpkg "github.com/veops/oneterm/pkg/db" dbpkg "github.com/veops/oneterm/pkg/db"
@@ -26,8 +27,25 @@ func NewCommandTemplateService() *CommandTemplateService {
} }
// BuildQuery builds the base query for command templates // BuildQuery builds the base query for command templates
func (s *CommandTemplateService) BuildQuery(ctx context.Context) (*gorm.DB, error) { func (s *CommandTemplateService) BuildQuery(ctx *gin.Context) (*gorm.DB, error) {
db := dbpkg.DB.Model(model.DefaultCommandTemplate) db := dbpkg.DB.Model(model.DefaultCommandTemplate)
// Apply search filter
if search := ctx.Query("search"); search != "" {
db = db.Where("name LIKE ? OR description LIKE ?", "%"+search+"%", "%"+search+"%")
}
// Apply category filter
if category := ctx.Query("category"); category != "" {
db = db.Where("category = ?", category)
}
// Apply builtin filter
if builtinStr := ctx.Query("builtin"); builtinStr != "" {
builtin := builtinStr == "true"
db = db.Where("is_builtin = ?", builtin)
}
return db, nil return db, nil
} }

View File

@@ -8,6 +8,8 @@ import (
"github.com/veops/oneterm/internal/repository" "github.com/veops/oneterm/internal/repository"
"github.com/veops/oneterm/pkg/cache" "github.com/veops/oneterm/pkg/cache"
dbpkg "github.com/veops/oneterm/pkg/db" dbpkg "github.com/veops/oneterm/pkg/db"
"github.com/veops/oneterm/pkg/logger"
"go.uber.org/zap"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -47,3 +49,168 @@ func (s *ConfigService) SaveConfig(ctx context.Context, cfg *model.Config) error
func (s *ConfigService) GetConfig(ctx context.Context) (*model.Config, error) { func (s *ConfigService) GetConfig(ctx context.Context) (*model.Config, error) {
return s.repo.GetConfig(ctx) return s.repo.GetConfig(ctx)
} }
// MigrateConfigStructure migrates the config table structure from protocol-specific to unified permissions
func (s *ConfigService) MigrateConfigStructure(ctx context.Context) error {
logger.L().Info("Starting config table structure migration")
// Check if migration is needed by looking for old columns
var columnExists bool
if err := dbpkg.DB.Raw("SELECT COUNT(*) > 0 FROM information_schema.columns WHERE table_name = 'config' AND column_name = 'ssh_copy'").Scan(&columnExists).Error; err != nil {
logger.L().Error("Failed to check for old columns", zap.Error(err))
return err
}
if !columnExists {
logger.L().Info("Config table already migrated, skipping")
return nil
}
logger.L().Info("Config table migration needed, starting migration")
// Migrate existing config data
if err := dbpkg.DB.Transaction(func(tx *gorm.DB) error {
// Read existing config with old structure
var oldConfig struct {
ID int `gorm:"column:id"`
Timeout int `gorm:"column:timeout"`
SSHCopy bool `gorm:"column:ssh_copy"`
SSHPaste bool `gorm:"column:ssh_paste"`
RDPCopy bool `gorm:"column:rdp_copy"`
RDPPaste bool `gorm:"column:rdp_paste"`
VNCCopy bool `gorm:"column:vnc_copy"`
VNCPaste bool `gorm:"column:vnc_paste"`
CreatorId int `gorm:"column:creator_id"`
UpdaterId int `gorm:"column:updater_id"`
}
if err := tx.Table("config").Where("deleted_at = 0").First(&oldConfig).Error; err != nil {
if err == gorm.ErrRecordNotFound {
// No existing config, create default
logger.L().Info("No existing config found, will create default after migration")
return nil
}
return err
}
// Convert old config to new structure
// Use logical OR for permissions - if any protocol allowed it, then allow it
newConfig := &model.Config{
Id: oldConfig.ID,
Timeout: oldConfig.Timeout,
DefaultPermissions: model.DefaultPermissions{
Connect: true, // Always allow connect
FileUpload: true, // Default to allow file operations
FileDownload: true,
Copy: oldConfig.SSHCopy || oldConfig.RDPCopy || oldConfig.VNCCopy,
Paste: oldConfig.SSHPaste || oldConfig.RDPPaste || oldConfig.VNCPaste,
Share: false, // Default to deny share for security
},
CreatorId: oldConfig.CreatorId,
UpdaterId: oldConfig.UpdaterId,
}
// Store converted config data temporarily
if err := tx.Exec("CREATE TEMPORARY TABLE temp_config AS SELECT * FROM config WHERE deleted_at = 0").Error; err != nil {
return err
}
logger.L().Info("Migrated config permissions",
zap.Bool("copy", newConfig.DefaultPermissions.Copy),
zap.Bool("paste", newConfig.DefaultPermissions.Paste))
return nil
}); err != nil {
logger.L().Error("Failed to migrate config data", zap.Error(err))
return err
}
// Drop old columns and add new ones
if err := dbpkg.DB.Transaction(func(tx *gorm.DB) error {
// Add new columns
alterSQL := `
ALTER TABLE config
ADD COLUMN default_connect BOOLEAN DEFAULT TRUE,
ADD COLUMN default_file_upload BOOLEAN DEFAULT TRUE,
ADD COLUMN default_file_download BOOLEAN DEFAULT TRUE,
ADD COLUMN default_copy BOOLEAN DEFAULT TRUE,
ADD COLUMN default_paste BOOLEAN DEFAULT TRUE,
ADD COLUMN default_share BOOLEAN DEFAULT FALSE
`
if err := tx.Exec(alterSQL).Error; err != nil {
logger.L().Error("Failed to add new columns", zap.Error(err))
return err
}
// Update data from temporary table if it exists
updateSQL := `
UPDATE config SET
default_connect = TRUE,
default_file_upload = TRUE,
default_file_download = TRUE,
default_copy = COALESCE((SELECT (ssh_copy OR rdp_copy OR vnc_copy) FROM temp_config WHERE temp_config.id = config.id), TRUE),
default_paste = COALESCE((SELECT (ssh_paste OR rdp_paste OR vnc_paste) FROM temp_config WHERE temp_config.id = config.id), TRUE),
default_share = FALSE
WHERE deleted_at = 0
`
if err := tx.Exec(updateSQL).Error; err != nil {
logger.L().Error("Failed to update config data", zap.Error(err))
return err
}
// Drop old columns
dropSQL := `
ALTER TABLE config
DROP COLUMN ssh_copy,
DROP COLUMN ssh_paste,
DROP COLUMN rdp_copy,
DROP COLUMN rdp_paste,
DROP COLUMN rdp_enable_drive,
DROP COLUMN rdp_drive_path,
DROP COLUMN rdp_create_drive_path,
DROP COLUMN rdp_disable_upload,
DROP COLUMN rdp_disable_download,
DROP COLUMN vnc_copy,
DROP COLUMN vnc_paste
`
if err := tx.Exec(dropSQL).Error; err != nil {
logger.L().Error("Failed to drop old columns", zap.Error(err))
return err
}
// Drop temporary table
if err := tx.Exec("DROP TEMPORARY TABLE IF EXISTS temp_config").Error; err != nil {
logger.L().Warn("Failed to drop temporary table", zap.Error(err))
}
return nil
}); err != nil {
return err
}
logger.L().Info("Config table structure migration completed successfully")
return nil
}
// EnsureDefaultConfig ensures there's a default config in the database
func (s *ConfigService) EnsureDefaultConfig(ctx context.Context) error {
cfg, err := s.GetConfig(ctx)
if err != nil && err != gorm.ErrRecordNotFound {
return err
}
if cfg == nil {
// Create default config
defaultCfg := model.GetDefaultConfig()
defaultCfg.CreatorId = 1 // System user
defaultCfg.UpdaterId = 1
if err := s.SaveConfig(ctx, defaultCfg); err != nil {
return err
}
logger.L().Info("Created default config")
}
return nil
}

View File

@@ -125,6 +125,82 @@ func (s *NodeService) BuildQuery(ctx *gin.Context, currentUser interface{}, info
return db, nil return db, nil
} }
// BuildQueryWithAuthorization builds query with integrated V2 authorization filter
func (s *NodeService) BuildQueryWithAuthorization(ctx *gin.Context) (*gorm.DB, error) {
// Start with base query filters (without authorization)
db := dbpkg.DB.Model(model.DefaultNode)
db = dbpkg.FilterEqual(ctx, db, "parent_id", "id")
db = dbpkg.FilterLike(ctx, db, "name")
db = dbpkg.FilterSearch(ctx, db, "name", "id")
// Handle IDs filter
if q, ok := ctx.GetQuery("ids"); ok {
db = db.Where("id IN ?", lo.Map(strings.Split(q, ","), func(s string, _ int) int { return cast.ToInt(s) }))
}
// Handle no_self_child filter
if id, ok := ctx.GetQuery("no_self_child"); ok {
ids, err := s.handleNoSelfChild(ctx, cast.ToInt(id))
if err != nil {
return nil, err
}
db = db.Where("id IN ?", ids)
}
// Handle self_parent filter
if id, ok := ctx.GetQuery("self_parent"); ok {
ids, err := repository.HandleSelfParent(ctx, cast.ToInt(id))
if err != nil {
return nil, err
}
db = db.Where("id IN ?", ids)
}
currentUser, _ := acl.GetSessionFromCtx(ctx)
// Administrators have access to all nodes
if acl.IsAdmin(currentUser) {
return db, nil
}
// Apply V2 authorization filter: get authorized asset IDs using V2 system
authV2Service := NewAuthorizationV2Service()
_, assetIds, _, err := authV2Service.GetAuthorizationScopeByACL(ctx)
if err != nil {
return nil, err
}
// Use the same logic as GetNodeIdsByAuthorization but more efficiently
if len(assetIds) == 0 {
// No access to any assets = no access to any nodes
db = db.Where("1 = 0")
} else {
// Get parent node IDs from authorized assets (same logic as before)
var parentIds []int
err = dbpkg.DB.Model(model.DefaultAsset).
Where("id IN ?", assetIds).
Distinct("parent_id").
Pluck("parent_id", &parentIds).Error
if err != nil {
return nil, err
}
if len(parentIds) == 0 {
db = db.Where("1 = 0")
} else {
// Include self and parent hierarchy for proper tree navigation
allNodeIds, err := repository.HandleSelfParent(ctx, parentIds...)
if err != nil {
return nil, err
}
db = db.Where("id IN ?", allNodeIds)
}
}
return db, nil
}
// AttachAssetCount attaches asset count to nodes // AttachAssetCount attaches asset count to nodes
func (s *NodeService) AttachAssetCount(ctx *gin.Context, data []*model.Node) error { func (s *NodeService) AttachAssetCount(ctx *gin.Context, data []*model.Node) error {
currentUser, _ := acl.GetSessionFromCtx(ctx) currentUser, _ := acl.GetSessionFromCtx(ctx)
@@ -136,7 +212,9 @@ func (s *NodeService) AttachAssetCount(ctx *gin.Context, data []*model.Node) err
if !acl.IsAdmin(currentUser) { if !acl.IsAdmin(currentUser) {
info := cast.ToBool(ctx.Query("info")) info := cast.ToBool(ctx.Query("info"))
if info { if info {
_, assetIds, _, err := NewAssetService().GetAssetIdsByAuthorization(ctx) // Use V2 authorization system for asset filtering
authV2Service := NewAuthorizationV2Service()
_, assetIds, _, err := authV2Service.GetAuthorizationScopeByACL(ctx)
if err != nil { if err != nil {
return err return err
} }
@@ -215,7 +293,9 @@ func (s *NodeService) AttachHasChild(ctx *gin.Context, data []*model.Node) error
} }
if info { if info {
_, assetIds, _, err := NewAssetService().GetAssetIdsByAuthorization(ctx) // Use V2 authorization system for asset filtering
authV2Service := NewAuthorizationV2Service()
_, assetIds, _, err := authV2Service.GetAuthorizationScopeByACL(ctx)
if err != nil { if err != nil {
return err return err
} }
@@ -311,7 +391,9 @@ func (s *NodeService) CheckDependencies(ctx context.Context, id int) (string, er
// GetNodeIdsByAuthorization gets node IDs that the user is authorized to access // GetNodeIdsByAuthorization gets node IDs that the user is authorized to access
func (s *NodeService) GetNodeIdsByAuthorization(ctx *gin.Context) ([]int, error) { func (s *NodeService) GetNodeIdsByAuthorization(ctx *gin.Context) ([]int, error) {
_, assetIds, _, err := NewAssetService().GetAssetIdsByAuthorization(ctx) // Use V2 authorization system for asset filtering
authV2Service := NewAuthorizationV2Service()
_, assetIds, _, err := authV2Service.GetAuthorizationScopeByACL(ctx)
if err != nil { if err != nil {
return nil, err return nil, err
} }
@@ -425,8 +507,7 @@ func (s *NodeService) GetNodesTree(ctx *gin.Context, dbQuery *gorm.DB, needAcl b
return nil, err return nil, err
} }
// Apply postHooks only if info=false // Apply postHooks - now always apply permission handling regardless of info mode
if !info {
if err := s.AttachAssetCount(ctx, allNodes); err != nil { if err := s.AttachAssetCount(ctx, allNodes); err != nil {
logger.L().Error("failed to attach asset count", zap.Error(err)) logger.L().Error("failed to attach asset count", zap.Error(err))
} }
@@ -435,10 +516,10 @@ func (s *NodeService) GetNodesTree(ctx *gin.Context, dbQuery *gorm.DB, needAcl b
logger.L().Error("failed to attach has_child flag", zap.Error(err)) logger.L().Error("failed to attach has_child flag", zap.Error(err))
} }
// Always handle permissions, regardless of info mode
if err := s.handleNodePermissions(ctx, allNodes, resourceType); err != nil { if err := s.handleNodePermissions(ctx, allNodes, resourceType); err != nil {
return nil, err return nil, err
} }
}
// Build tree structure // Build tree structure
return s.buildNodeTree(allNodes, nodeIds), nil return s.buildNodeTree(allNodes, nodeIds), nil
@@ -487,10 +568,6 @@ func (s *NodeService) buildNodeTree(nodes []*model.Node, rootIds []int) []any {
// handleNodePermissions handles node permissions // handleNodePermissions handles node permissions
func (s *NodeService) handleNodePermissions(ctx *gin.Context, nodes []*model.Node, resourceType string) error { func (s *NodeService) handleNodePermissions(ctx *gin.Context, nodes []*model.Node, resourceType string) error {
if info := cast.ToBool(ctx.Query("info")); info {
return nil
}
currentUser, _ := acl.GetSessionFromCtx(ctx) currentUser, _ := acl.GetSessionFromCtx(ctx)
if !lo.Contains(config.PermResource, resourceType) { if !lo.Contains(config.PermResource, resourceType) {

View File

@@ -0,0 +1,333 @@
package service
import (
"context"
"errors"
"fmt"
"regexp"
"strconv"
"strings"
"time"
"gorm.io/gorm"
"github.com/gin-gonic/gin"
"github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/repository"
dbpkg "github.com/veops/oneterm/pkg/db"
)
// TimeTemplateService handles business logic for time templates
type TimeTemplateService struct {
repo repository.ITimeTemplateRepository
}
// NewTimeTemplateService creates a new time template service
func NewTimeTemplateService() *TimeTemplateService {
repo := repository.NewTimeTemplateRepository(dbpkg.DB)
return &TimeTemplateService{
repo: repo,
}
}
// BuildQuery builds the base query for time templates
func (s *TimeTemplateService) BuildQuery(ctx *gin.Context) (*gorm.DB, error) {
db := dbpkg.DB.Model(model.DefaultTimeTemplate)
// Apply search filter
if search := ctx.Query("search"); search != "" {
db = db.Where("name LIKE ? OR description LIKE ?", "%"+search+"%", "%"+search+"%")
}
// Apply category filter
if category := ctx.Query("category"); category != "" {
db = db.Where("category = ?", category)
}
// Apply active filter
if activeStr := ctx.Query("active"); activeStr != "" {
active := activeStr == "true"
db = db.Where("is_active = ?", active)
}
return db, nil
}
// CreateTimeTemplate creates a new time template
func (s *TimeTemplateService) CreateTimeTemplate(ctx context.Context, template *model.TimeTemplate) error {
// Validate the template
if err := s.ValidateTimeTemplate(template); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Check for duplicate names
existing, err := s.repo.GetByName(ctx, template.Name)
if err != nil {
return fmt.Errorf("failed to check existing template: %w", err)
}
if existing != nil {
return errors.New("template with this name already exists")
}
// Set default values
if template.Timezone == "" {
template.Timezone = "Asia/Shanghai"
}
template.IsBuiltIn = false
template.UsageCount = 0
return s.repo.Create(ctx, template)
}
// GetTimeTemplate retrieves a time template by ID
func (s *TimeTemplateService) GetTimeTemplate(ctx context.Context, id int) (*model.TimeTemplate, error) {
return s.repo.GetByID(ctx, id)
}
// GetTimeTemplateByName retrieves a time template by name
func (s *TimeTemplateService) GetTimeTemplateByName(ctx context.Context, name string) (*model.TimeTemplate, error) {
return s.repo.GetByName(ctx, name)
}
// ListTimeTemplates retrieves time templates with pagination and filters
func (s *TimeTemplateService) ListTimeTemplates(ctx context.Context, offset, limit int, category string, active *bool) ([]*model.TimeTemplate, int64, error) {
return s.repo.List(ctx, offset, limit, category, active)
}
// UpdateTimeTemplate updates an existing time template
func (s *TimeTemplateService) UpdateTimeTemplate(ctx context.Context, template *model.TimeTemplate) error {
// Validate the template
if err := s.ValidateTimeTemplate(template); err != nil {
return fmt.Errorf("validation failed: %w", err)
}
// Check if template exists
existing, err := s.repo.GetByID(ctx, template.Id)
if err != nil {
return fmt.Errorf("failed to get existing template: %w", err)
}
if existing == nil {
return errors.New("time template not found")
}
// Don't allow changing built-in status
template.IsBuiltIn = existing.IsBuiltIn
template.UsageCount = existing.UsageCount
return s.repo.Update(ctx, template)
}
// DeleteTimeTemplate deletes a time template
func (s *TimeTemplateService) DeleteTimeTemplate(ctx context.Context, id int) error {
return s.repo.Delete(ctx, id)
}
// UseTimeTemplate increments usage count and returns the template
func (s *TimeTemplateService) UseTimeTemplate(ctx context.Context, id int) (*model.TimeTemplate, error) {
template, err := s.repo.GetByID(ctx, id)
if err != nil {
return nil, err
}
if template == nil {
return nil, errors.New("time template not found")
}
// Increment usage count
if err := s.repo.IncrementUsage(ctx, id); err != nil {
// Log error but don't fail the operation
fmt.Printf("Failed to increment usage count for template %d: %v\n", id, err)
}
return template, nil
}
// GetBuiltInTemplates retrieves all built-in time templates
func (s *TimeTemplateService) GetBuiltInTemplates(ctx context.Context) ([]*model.TimeTemplate, error) {
return s.repo.GetBuiltInTemplates(ctx)
}
// InitializeBuiltInTemplates initializes built-in time templates
func (s *TimeTemplateService) InitializeBuiltInTemplates(ctx context.Context) error {
return s.repo.InitBuiltInTemplates(ctx)
}
// ValidateTimeTemplate validates a time template
func (s *TimeTemplateService) ValidateTimeTemplate(template *model.TimeTemplate) error {
if template.Name == "" {
return errors.New("template name is required")
}
if len(template.Name) > 128 {
return errors.New("template name too long (max 128 characters)")
}
if template.Category == "" {
return errors.New("template category is required")
}
// Validate category
validCategories := []string{"work", "duty", "maintenance", "emergency", "always", "custom"}
categoryValid := false
for _, cat := range validCategories {
if template.Category == cat {
categoryValid = true
break
}
}
if !categoryValid {
return fmt.Errorf("invalid category: %s. Valid categories: %v", template.Category, validCategories)
}
// Validate timezone
if template.Timezone != "" {
if _, err := time.LoadLocation(template.Timezone); err != nil {
return fmt.Errorf("invalid timezone: %s", template.Timezone)
}
}
// Validate time ranges
if len(template.TimeRanges) == 0 {
return errors.New("at least one time range is required")
}
for i, timeRange := range template.TimeRanges {
if err := s.validateTimeRange(timeRange); err != nil {
return fmt.Errorf("invalid time range %d: %w", i+1, err)
}
}
return nil
}
// validateTimeRange validates a single time range
func (s *TimeTemplateService) validateTimeRange(timeRange model.TimeRange) error {
// Validate time format (HH:MM)
timePattern := regexp.MustCompile(`^([0-1]?[0-9]|2[0-3]):[0-5][0-9]$`)
if !timePattern.MatchString(timeRange.StartTime) {
return fmt.Errorf("invalid start time format: %s (expected HH:MM)", timeRange.StartTime)
}
if !timePattern.MatchString(timeRange.EndTime) {
return fmt.Errorf("invalid end time format: %s (expected HH:MM)", timeRange.EndTime)
}
// Parse and validate time logic
startMinutes, err := s.parseTimeToMinutes(timeRange.StartTime)
if err != nil {
return fmt.Errorf("invalid start time: %w", err)
}
endMinutes, err := s.parseTimeToMinutes(timeRange.EndTime)
if err != nil {
return fmt.Errorf("invalid end time: %w", err)
}
if startMinutes >= endMinutes {
return errors.New("start time must be before end time")
}
// Validate weekdays
if len(timeRange.Weekdays) == 0 {
return errors.New("at least one weekday must be specified")
}
for _, day := range timeRange.Weekdays {
if day < 1 || day > 7 {
return fmt.Errorf("invalid weekday: %d (must be 1-7, where 1=Monday, 7=Sunday)", day)
}
}
return nil
}
// parseTimeToMinutes converts HH:MM format to minutes since midnight
func (s *TimeTemplateService) parseTimeToMinutes(timeStr string) (int, error) {
parts := strings.Split(timeStr, ":")
if len(parts) != 2 {
return 0, errors.New("invalid time format")
}
hour, err := strconv.Atoi(parts[0])
if err != nil {
return 0, err
}
minute, err := strconv.Atoi(parts[1])
if err != nil {
return 0, err
}
return hour*60 + minute, nil
}
// CheckTimeAccess checks if current time is within the template's allowed time ranges
func (s *TimeTemplateService) CheckTimeAccess(ctx context.Context, templateID int, timezone string) (bool, error) {
template, err := s.repo.GetByID(ctx, templateID)
if err != nil {
return false, err
}
if template == nil {
return false, errors.New("time template not found")
}
return s.IsTimeInTemplate(template, timezone), nil
}
// IsTimeInTemplate checks if current time matches any time range in the template
func (s *TimeTemplateService) IsTimeInTemplate(template *model.TimeTemplate, timezone string) bool {
// Use template's timezone if not specified
if timezone == "" {
timezone = template.Timezone
}
if timezone == "" {
timezone = "Asia/Shanghai" // Default timezone
}
// Load timezone location
loc, err := time.LoadLocation(timezone)
if err != nil {
// Fall back to UTC if timezone is invalid
loc = time.UTC
}
now := time.Now().In(loc)
currentWeekday := int(now.Weekday())
if currentWeekday == 0 {
currentWeekday = 7 // Convert Sunday from 0 to 7
}
currentMinutes := now.Hour()*60 + now.Minute()
// Check each time range
for _, timeRange := range template.TimeRanges {
// Check if current weekday is in the allowed weekdays
weekdayMatch := false
for _, day := range timeRange.Weekdays {
if day == currentWeekday {
weekdayMatch = true
break
}
}
if !weekdayMatch {
continue
}
// Check if current time is in the allowed time range
startMinutes, err := s.parseTimeToMinutes(timeRange.StartTime)
if err != nil {
continue
}
endMinutes, err := s.parseTimeToMinutes(timeRange.EndTime)
if err != nil {
continue
}
if currentMinutes >= startMinutes && currentMinutes <= endMinutes {
return true
}
}
return false
}

View File

@@ -243,7 +243,10 @@ func (m *view) refresh() {
} }
if !acl.IsAdmin(m.currentUser) { if !acl.IsAdmin(m.currentUser) {
var assetIds, accountIds []int var assetIds, accountIds []int
if _, assetIds, _, err = service.NewAssetService().GetAssetIdsByAuthorization(m.Ctx); err != nil {
// Use V2 authorization system for asset filtering
authV2Service := service.NewAuthorizationV2Service()
if _, assetIds, _, err = authV2Service.GetAuthorizationScopeByACL(m.Ctx); err != nil {
return return
} }
assets = lo.Filter(assets, func(a *model.Asset, _ int) bool { return lo.Contains(assetIds, a.Id) }) assets = lo.Filter(assets, func(a *model.Asset, _ int) bool { return lo.Contains(assetIds, a.Id) })
@@ -258,11 +261,17 @@ func (m *view) refresh() {
m.combines = make(map[string][3]int) m.combines = make(map[string][3]int)
for _, asset := range assets { for _, asset := range assets {
for accountId := range asset.Authorization { for accountId, authData := range asset.Authorization {
account, ok := accountMap[accountId] account, ok := accountMap[accountId]
if !ok { if !ok {
continue continue
} }
// Check if this account has connect permission
if authData.Permissions == nil || !authData.Permissions.Connect {
continue
}
for _, p := range asset.Protocols { for _, p := range asset.Protocols {
ss := strings.Split(p, ":") ss := strings.Split(p, ":")
if len(ss) != 2 { if len(ss) != 2 {