Files
oneterm/backend/internal/service/authorization.go
2025-07-16 18:11:04 +08:00

587 lines
20 KiB
Go

package service
import (
"context"
"errors"
"fmt"
"net"
"time"
"github.com/gin-gonic/gin"
"github.com/samber/lo"
"go.uber.org/zap"
"golang.org/x/sync/errgroup"
"gorm.io/gorm"
"github.com/veops/oneterm/internal/acl"
"github.com/veops/oneterm/internal/model"
"github.com/veops/oneterm/internal/repository"
gsession "github.com/veops/oneterm/internal/session"
"github.com/veops/oneterm/pkg/config"
dbpkg "github.com/veops/oneterm/pkg/db"
"github.com/veops/oneterm/pkg/logger"
)
const (
kAuthorizationIds = "authorizationIds"
)
var (
// Global service instance, created at initialization
DefaultAuthService IAuthorizationService
)
// InitAuthorizationService initializes the global authorization service
func InitAuthorizationService() {
repo := repository.NewAuthorizationRepository(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 {
// V1 methods
UpsertAuthorization(ctx context.Context, auth *model.Authorization) error
UpsertAuthorizationWithTx(ctx context.Context, auth *model.Authorization) error
DeleteAuthorization(ctx context.Context, auth *model.Authorization) error
GetAuthorizations(ctx context.Context, nodeId, assetId, accountId int) ([]*model.Authorization, int64, error)
GetAuthorizationById(ctx context.Context, id int) (*model.Authorization, error)
HasPermAuthorization(ctx context.Context, auth *model.Authorization, action string) bool
HasAuthorization(ctx *gin.Context, sess *gsession.Session) (bool, error)
GetAuthsByAsset(ctx context.Context, asset *model.Asset) ([]*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)
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 {
repo repository.IAuthorizationRepository
matcher IAuthorizationMatcher
db *gorm.DB
}
func NewAuthorizationService(repo repository.IAuthorizationRepository, db *gorm.DB, matcher IAuthorizationMatcher) IAuthorizationService {
return &AuthorizationService{
repo: repo,
matcher: matcher,
db: db,
}
}
// UpsertAuthorization updates or creates authorization (without transaction)
func (s *AuthorizationService) UpsertAuthorization(ctx context.Context, auth *model.Authorization) error {
return s.repo.UpsertAuthorization(ctx, auth)
}
// UpsertAuthorizationWithTx updates or creates authorization (with transaction)
func (s *AuthorizationService) UpsertAuthorizationWithTx(ctx context.Context, auth *model.Authorization) error {
return s.db.Transaction(func(tx *gorm.DB) error {
// Create a Repository in the transaction
txRepo := repository.NewAuthorizationRepository(tx)
// Check if it exists
existing, err := txRepo.GetAuthorizationByFields(ctx, auth.NodeId, auth.AssetId, auth.AccountId)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if existing != nil {
auth.Id = existing.Id
auth.ResourceId = existing.ResourceId
}
// Determine action based on whether it's an update or create
action := lo.Ternary(auth.Id > 0, model.ACTION_UPDATE, model.ACTION_CREATE)
// Create a temporary Service for transaction handling
txService := &AuthorizationService{repo: txRepo, db: s.db, matcher: s.matcher}
return txService.HandleAuthorization(ctx, tx, action, nil, auth)
})
}
// GetAuthorizationById gets authorization by ID
func (s *AuthorizationService) GetAuthorizationById(ctx context.Context, id int) (*model.Authorization, error) {
return s.repo.GetAuthorizationById(ctx, id)
}
func (s *AuthorizationService) DeleteAuthorization(ctx context.Context, auth *model.Authorization) error {
return s.db.Transaction(func(tx *gorm.DB) error {
txRepo := repository.NewAuthorizationRepository(tx)
txService := &AuthorizationService{repo: txRepo, db: s.db, matcher: s.matcher}
return txService.HandleAuthorization(ctx, tx, model.ACTION_DELETE, nil, auth)
})
}
func (s *AuthorizationService) GetAuthorizations(ctx context.Context, nodeId, assetId, accountId int) ([]*model.Authorization, int64, error) {
return s.repo.GetAuthorizations(ctx, nodeId, assetId, accountId)
}
func (s *AuthorizationService) GetNodeAssetAccountIdsByAction(ctx context.Context, action string) (nodeIds, assetIds, accountIds []int, err error) {
currentUser, _ := acl.GetSessionFromCtx(ctx)
eg := &errgroup.Group{}
ch := make(chan bool)
eg.Go(func() (err error) {
defer close(ch)
res, err := acl.GetRoleResources(ctx, currentUser.GetRid(), config.RESOURCE_NODE)
if err != nil {
return
}
res = lo.Filter(res, func(r *acl.Resource, _ int) bool { return lo.Contains(r.Permissions, action) })
resIds := lo.Map(res, func(r *acl.Resource, _ int) int { return r.ResourceId })
nodes, err := repository.GetAllFromCacheDb(ctx, model.DefaultNode)
if err != nil {
return
}
nodes = lo.Filter(nodes, func(n *model.Node, _ int) bool { return lo.Contains(resIds, n.ResourceId) })
nodeIds = lo.Map(nodes, func(n *model.Node, _ int) int { return n.Id })
nodeIds, err = repository.HandleSelfChild(ctx, nodeIds...)
return
})
eg.Go(func() (err error) {
res, err := acl.GetRoleResources(ctx, currentUser.GetRid(), config.RESOURCE_ASSET)
if err != nil {
return
}
res = lo.Filter(res, func(r *acl.Resource, _ int) bool { return lo.Contains(r.Permissions, action) })
resIds := lo.Map(res, func(r *acl.Resource, _ int) int { return r.ResourceId })
<-ch
assets, err := repository.GetAllFromCacheDb(ctx, model.DefaultAsset)
if err != nil {
return
}
assets = lo.Filter(assets, func(a *model.Asset, _ int) bool {
return lo.Contains(resIds, a.ResourceId) || lo.Contains(nodeIds, a.ParentId)
})
assetIds = lo.Map(assets, func(a *model.Asset, _ int) int { return a.Id })
return
})
eg.Go(func() (err error) {
res, err := acl.GetRoleResources(ctx, currentUser.GetRid(), config.RESOURCE_ACCOUNT)
if err != nil {
return
}
res = lo.Filter(res, func(r *acl.Resource, _ int) bool { return lo.Contains(r.Permissions, action) })
resIds := lo.Map(res, func(r *acl.Resource, _ int) int { return r.ResourceId })
accounts, err := repository.GetAllFromCacheDb(ctx, model.DefaultAccount)
if err != nil {
return
}
accounts = lo.Filter(accounts, func(a *model.Account, _ int) bool { return lo.Contains(resIds, a.ResourceId) })
accountIds = lo.Map(accounts, func(a *model.Account, _ int) int { return a.Id })
return
})
err = eg.Wait()
return
}
func (s *AuthorizationService) HasPermAuthorization(ctx context.Context, auth *model.Authorization, action string) (ok bool) {
currentUser, _ := acl.GetSessionFromCtx(ctx)
if ok = acl.IsAdmin(currentUser); ok {
return
}
if auth == nil {
auth = &model.Authorization{}
}
nodeIds, assetIds, accountIds, err := s.GetNodeAssetAccountIdsByAction(ctx, action)
if err != nil {
return
}
if auth.NodeId != 0 && auth.AssetId == 0 && auth.AccountId == 0 {
ok = lo.Contains(nodeIds, auth.NodeId)
} else if auth.AssetId != 0 && auth.NodeId == 0 && auth.AccountId == 0 {
ok = lo.Contains(assetIds, auth.AssetId)
} else if auth.AccountId != 0 && auth.AssetId == 0 && auth.NodeId == 0 {
ok = lo.Contains(accountIds, auth.AccountId)
}
return
}
func (s *AuthorizationService) GetAuthsByAsset(ctx context.Context, asset *model.Asset) ([]*model.Authorization, error) {
auths, err := s.repo.GetAuthsByAsset(ctx, asset)
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) {
defer repository.DeleteAllFromCacheDb(ctx, model.DefaultAuthorization)
currentUser, _ := acl.GetSessionFromCtx(ctx)
eg := &errgroup.Group{}
if asset != nil && asset.Id > 0 {
switch action {
case model.ACTION_CREATE:
// V2: Create authorization rules instead of V1 authorization records
err = s.createV2AuthorizationRulesForAsset(ctx, tx, asset, currentUser)
if err != nil {
return err
}
case model.ACTION_DELETE:
// V2: Delete authorization rules for this asset
err = s.deleteV2AuthorizationRulesForAsset(ctx, tx, asset)
if err != nil {
return err
}
case model.ACTION_UPDATE:
// V2: Update authorization rules for this asset
err = s.updateV2AuthorizationRulesForAsset(ctx, tx, asset, currentUser)
if err != nil {
return err
}
}
}
// Handle individual authorization records (V1 compatibility)
for _, a := range lo.Filter(auths, func(item *model.Authorization, _ int) bool { return item != nil }) {
auth := a
switch action {
case model.ACTION_CREATE:
eg.Go(func() (err error) {
resourceId := 0
if resourceId, err = acl.CreateAcl(ctx, currentUser, config.RESOURCE_AUTHORIZATION, auth.GetName()); err != nil {
return
}
if err = acl.BatchGrantRoleResource(ctx, currentUser.GetUid(), auth.Rids, resourceId, []string{acl.READ}); err != nil {
return
}
auth.CreatorId = currentUser.GetUid()
auth.UpdaterId = currentUser.GetUid()
auth.ResourceId = resourceId
return tx.Create(auth).Error
})
case model.ACTION_DELETE:
eg.Go(func() (err error) {
return acl.DeleteResource(ctx, currentUser.GetUid(), auth.ResourceId)
})
case model.ACTION_UPDATE:
eg.Go(func() (err error) {
pre, err := s.GetAuthorizationById(ctx, auth.GetId())
if err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) {
return
}
resourceId := 0
if resourceId, err = acl.CreateAcl(ctx, currentUser, config.RESOURCE_AUTHORIZATION, auth.GetName()); err != nil {
return
}
auth.ResourceId = resourceId
if err = tx.Create(auth).Error; err != nil {
return
}
pre = &model.Authorization{Rids: []int{}}
}
revokeRids := lo.Without(pre.Rids, auth.Rids...)
if len(revokeRids) > 0 {
if err = acl.BatchRevokeRoleResource(ctx, currentUser.GetUid(), revokeRids, auth.ResourceId, []string{acl.READ}); err != nil {
return
}
}
grantRids := lo.Without(auth.Rids, pre.Rids...)
if len(grantRids) > 0 {
if err = acl.BatchGrantRoleResource(ctx, currentUser.GetUid(), grantRids, auth.ResourceId, []string{acl.READ}); err != nil {
return
}
}
return tx.Model(auth).Update("rids", auth.Rids).Error
})
}
}
err = eg.Wait()
return
}
func getAuthorizations(ctx *gin.Context) (res []*acl.Resource, err error) {
currentUser, _ := acl.GetSessionFromCtx(ctx)
res, err = acl.GetRoleResources(ctx, currentUser.GetRid(), config.RESOURCE_AUTHORIZATION)
if err != nil {
return
}
return
}
func getAutorizationResourceIds(ctx *gin.Context) (resourceIds []int, err error) {
res, err := getAuthorizations(ctx)
if err != nil {
return
}
resourceIds = lo.Map(res, func(r *acl.Resource, _ int) int { return r.ResourceId })
return
}
func (s *AuthorizationService) GetAuthorizationIds(ctx *gin.Context) (authIds []*model.AuthorizationIds, err error) {
resourceIds, err := getAutorizationResourceIds(ctx)
if err != nil {
return
}
authIds, err = s.repo.GetAuthorizationIds(ctx, resourceIds)
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) {
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)
// 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 {
return createBatchResult(true, "Share session"), nil
}
// 2. Administrators have access to all resources
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 err := s.db.Model(sess.Session.Asset).Where("id=?", sess.AssetId).First(&sess.Session.Asset).Error; err != nil {
return createBatchResult(false, "Asset not found"), err
}
}
// Create base authorization request (without action, will be added per action)
clientIP := s.getClientIP(ctx)
baseReq := &model.BatchAuthRequest{
UserId: currentUser.GetUid(),
NodeId: sess.Session.Asset.ParentId,
AssetId: sess.AssetId,
AccountId: sess.AccountId,
Actions: actions,
ClientIP: clientIP,
Timestamp: time.Now(),
}
// Use V2 matcher with filtered rule scope
return s.matcher.MatchBatchWithScope(ctx, baseReq, authV2ResourceIds)
}
// 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) {
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
}