Merge pull request #1219 from trheyi/main

Update team model and creation logic to support role management
This commit is contained in:
Max 2025-10-20 17:02:00 +08:00 committed by GitHub
commit 6b38a235af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
9 changed files with 263 additions and 193 deletions

File diff suppressed because one or more lines are too long

View file

@ -38,16 +38,16 @@ func (acl *ACL) Enforce(c *gin.Context) (bool, error) {
decision := acl.Scope.Check(request) decision := acl.Scope.Check(request)
if !decision.Allowed { if !decision.Allowed {
// Return 403 Forbidden with details // Return error with details, let the caller handle the response
c.JSON(403, map[string]interface{}{ err := &Error{
"code": 403, Type: ErrorTypePermissionDenied,
"message": "Access denied", Message: decision.Reason,
"reason": decision.Reason, Details: map[string]interface{}{
"required_scopes": decision.RequiredScopes, "required_scopes": decision.RequiredScopes,
"missing_scopes": decision.MissingScopes, "missing_scopes": decision.MissingScopes,
}) },
c.Abort() }
return false, nil return false, err
} }
return true, nil return true, nil

View file

@ -10,6 +10,7 @@ import (
"github.com/yaoapp/yao/openapi/oauth/acl" "github.com/yaoapp/yao/openapi/oauth/acl"
"github.com/yaoapp/yao/openapi/oauth/authorized" "github.com/yaoapp/yao/openapi/oauth/authorized"
"github.com/yaoapp/yao/openapi/oauth/types" "github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/openapi/response"
) )
// Guard is the OAuth guard middleware // Guard is the OAuth guard middleware
@ -19,7 +20,7 @@ func (s *Service) Guard(c *gin.Context) {
// Validate the token // Validate the token
if token == "" { if token == "" {
c.JSON(http.StatusUnauthorized, types.ErrTokenMissing) response.RespondWithError(c, http.StatusUnauthorized, types.ErrTokenMissing)
c.Abort() c.Abort()
return return
} }
@ -27,7 +28,7 @@ func (s *Service) Guard(c *gin.Context) {
// Validate the token // Validate the token
claims, err := s.VerifyToken(token) claims, err := s.VerifyToken(token)
if err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, types.ErrInvalidToken) response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidToken)
c.Abort() c.Abort()
return return
} }
@ -53,9 +54,10 @@ func (s *Service) Guard(c *gin.Context) {
return return
} }
// If permissions are not granted, return forbidden // If permissions are not granted but no error returned, it's an unexpected state
// This should not happen with the current implementation
if !ok { if !ok {
c.JSON(http.StatusForbidden, types.ErrForbidden) response.RespondWithError(c, http.StatusForbidden, types.ErrForbidden)
c.Abort() c.Abort()
return return
} }
@ -70,7 +72,7 @@ func GetAuthorizedInfo(c *gin.Context) *types.AuthorizedInfo {
func (s *Service) tryAutoRefreshToken(c *gin.Context, _ *types.TokenClaims) { func (s *Service) tryAutoRefreshToken(c *gin.Context, _ *types.TokenClaims) {
refreshToken := s.getRefreshToken(c) refreshToken := s.getRefreshToken(c)
if refreshToken == "" { if refreshToken == "" {
c.JSON(http.StatusUnauthorized, types.ErrRefreshTokenMissing) response.RespondWithError(c, http.StatusUnauthorized, types.ErrRefreshTokenMissing)
c.Abort() c.Abort()
return return
} }
@ -78,7 +80,7 @@ func (s *Service) tryAutoRefreshToken(c *gin.Context, _ *types.TokenClaims) {
// Verify the refresh token // Verify the refresh token
_, err := s.VerifyToken(refreshToken) _, err := s.VerifyToken(refreshToken)
if err != nil { if err != nil {
c.JSON(http.StatusUnauthorized, types.ErrInvalidRefreshToken) response.RespondWithError(c, http.StatusUnauthorized, types.ErrInvalidRefreshToken)
c.Abort() c.Abort()
return return
} }
@ -177,11 +179,32 @@ func (s *Service) handleACLError(c *gin.Context, err error) {
case acl.ErrorTypeInsufficientScope: case acl.ErrorTypeInsufficientScope:
statusCode = http.StatusForbidden statusCode = http.StatusForbidden
errResponse = types.ErrInsufficientScope // Include detailed scope information for insufficient scope errors
requiredScopes, _ := aclErr.Details["required_scopes"].([]string)
missingScopes, _ := aclErr.Details["missing_scopes"].([]string)
errResponse = &types.ErrorResponse{
Code: "insufficient_scope",
ErrorDescription: "The access token does not have the required scope",
Reason: aclErr.Message,
RequiredScopes: requiredScopes,
MissingScopes: missingScopes,
}
case acl.ErrorTypePermissionDenied: case acl.ErrorTypePermissionDenied:
statusCode = http.StatusForbidden statusCode = http.StatusForbidden
errResponse = types.ErrForbidden // Include detailed information for permission denied errors
requiredScopes, _ := aclErr.Details["required_scopes"].([]string)
missingScopes, _ := aclErr.Details["missing_scopes"].([]string)
// Use standard ErrorResponse format with extended ACL fields
errResponse = &types.ErrorResponse{
Code: "forbidden",
ErrorDescription: "You do not have permission to access this resource",
Reason: aclErr.Message,
RequiredScopes: requiredScopes,
MissingScopes: missingScopes,
}
case acl.ErrorTypeResourceNotAllowed: case acl.ErrorTypeResourceNotAllowed:
statusCode = http.StatusForbidden statusCode = http.StatusForbidden
@ -211,12 +234,12 @@ func (s *Service) handleACLError(c *gin.Context, err error) {
errResponse = types.ErrACLInternalError errResponse = types.ErrACLInternalError
} }
c.JSON(statusCode, errResponse) response.RespondWithError(c, statusCode, errResponse)
c.Abort() c.Abort()
return return
} }
// If it's not an ACL error, treat it as an internal error // If it's not an ACL error, treat it as an internal error
c.JSON(http.StatusInternalServerError, types.ErrACLInternalError) response.RespondWithError(c, http.StatusInternalServerError, types.ErrACLInternalError)
c.Abort() c.Abort()
} }

View file

@ -86,7 +86,7 @@ func TestTeamBasicOperations(t *testing.T) {
OwnerID: ownerUserID, OwnerID: ownerUserID,
Status: "active", Status: "active",
Type: "corporation", Type: "corporation",
TypeID: "business", TypeID: "free",
Metadata: map[string]interface{}{"test": true, "uuid": testUUID}, Metadata: map[string]interface{}{"test": true, "uuid": testUUID},
} }
@ -126,6 +126,7 @@ func TestTeamBasicOperations(t *testing.T) {
assert.Equal(t, testTeam.Name, team["name"]) assert.Equal(t, testTeam.Name, team["name"])
assert.Equal(t, testTeam.DisplayName, team["display_name"]) assert.Equal(t, testTeam.DisplayName, team["display_name"])
assert.Equal(t, testTeam.OwnerID, team["owner_id"]) assert.Equal(t, testTeam.OwnerID, team["owner_id"])
assert.Equal(t, testTeam.TypeID, team["type_id"])
}) })
// Test GetTeamDetail // Test GetTeamDetail

View file

@ -34,6 +34,11 @@ type ErrorResponse struct {
ErrorDescription string `json:"error_description,omitempty"` ErrorDescription string `json:"error_description,omitempty"`
ErrorURI string `json:"error_uri,omitempty"` ErrorURI string `json:"error_uri,omitempty"`
State string `json:"state,omitempty"` State string `json:"state,omitempty"`
// Extended fields for ACL and permission errors (optional, following OAuth 2.0 extensibility)
Reason string `json:"reason,omitempty"` // Detailed reason for denial
RequiredScopes []string `json:"required_scopes,omitempty"` // Required scopes for access
MissingScopes []string `json:"missing_scopes,omitempty"` // Scopes that are missing
} }
// Error implements the error interface // Error implements the error interface

View file

@ -692,34 +692,42 @@ func teamCreate(ctx context.Context, userID string, teamData maps.MapStrAny) (st
teamData["created_at"] = time.Now() teamData["created_at"] = time.Now()
teamData["updated_at"] = time.Now() teamData["updated_at"] = time.Now()
// Set default type_id from team config if not provided // Get team config for setting defaults
if _, hasType := teamData["type_id"]; !hasType { locale := ""
// Try to get locale from team data if localeVal, ok := teamData["locale"].(string); ok && localeVal != "" {
locale := "" locale = strings.TrimSpace(strings.ToLower(localeVal))
if localeVal, ok := teamData["locale"].(string); ok && localeVal != "" { }
locale = strings.TrimSpace(strings.ToLower(localeVal))
}
// Fallback: try common locale variations or use "en" as final fallback // Fallback: try common locale variations or use "en" as final fallback
// This ensures we always get a valid config even if locale is invalid // This ensures we always get a valid config even if locale is invalid
teamConfig := GetTeamConfig(locale) teamConfig := GetTeamConfig(locale)
if teamConfig == nil { if teamConfig == nil {
// Try fallback locales in order // Try fallback locales in order
fallbackLocales := []string{"en", "zh-cn"} fallbackLocales := []string{"en", "zh-cn"}
for _, fallback := range fallbackLocales { for _, fallback := range fallbackLocales {
teamConfig = GetTeamConfig(fallback) teamConfig = GetTeamConfig(fallback)
if teamConfig != nil { if teamConfig != nil {
break break
}
} }
} }
}
// Set default type_id from team config if not provided
if _, hasType := teamData["type_id"]; !hasType {
// Apply default type from config if available // Apply default type from config if available
if teamConfig != nil && teamConfig.Type != "" { if teamConfig != nil && teamConfig.Type != "" {
teamData["type_id"] = teamConfig.Type teamData["type_id"] = teamConfig.Type
} }
} }
// Set default role_id from team config if not provided
if _, hasRole := teamData["role_id"]; !hasRole {
// Apply default role from config if available
if teamConfig != nil && teamConfig.Role != "" {
teamData["role_id"] = teamConfig.Role
}
}
// Clean up: remove locale from team data as it's not stored in database // Clean up: remove locale from team data as it's not stored in database
delete(teamData, "locale") delete(teamData, "locale")
@ -729,12 +737,18 @@ func teamCreate(ctx context.Context, userID string, teamData maps.MapStrAny) (st
return "", fmt.Errorf("failed to create team: %w", err) return "", fmt.Errorf("failed to create team: %w", err)
} }
// Determine owner member role_id from team config
ownerRoleID := "owner" // fallback default
if teamConfig != nil && teamConfig.Role != "" {
ownerRoleID = teamConfig.Role
}
// Add the creator as an owner member of the team // Add the creator as an owner member of the team
ownerMemberData := maps.MapStrAny{ ownerMemberData := maps.MapStrAny{
"team_id": teamID, "team_id": teamID,
"user_id": userID, "user_id": userID,
"member_type": "user", "member_type": "user",
"role_id": "owner", "role_id": ownerRoleID,
"status": "active", "status": "active",
"joined_at": time.Now(), "joined_at": time.Now(),
"created_at": time.Now(), "created_at": time.Now(),

View file

@ -503,7 +503,8 @@ type CreateInvitationRequest struct {
type TeamConfig struct { type TeamConfig struct {
Roles []*TeamRole `json:"roles,omitempty"` Roles []*TeamRole `json:"roles,omitempty"`
Invite *InviteConfig `json:"invite,omitempty"` Invite *InviteConfig `json:"invite,omitempty"`
Type string `json:"type,omitempty"` // Default type for new teams Type string `json:"type,omitempty"` // Default subscription type for new teams
Role string `json:"role,omitempty"` // Default user role for team creator
} }
// TeamRole represents a team role configuration // TeamRole represents a team role configuration
@ -511,8 +512,9 @@ type TeamRole struct {
RoleID string `json:"role_id"` RoleID string `json:"role_id"`
Label string `json:"label"` Label string `json:"label"`
Description string `json:"description"` Description string `json:"description"`
Default bool `json:"default"` // Whether this role is the default role Default bool `json:"default"` // Whether this role is the default role
Hidden bool `json:"hidden"` // Whether this role is hidden from UI Hidden bool `json:"hidden"` // Whether this role is hidden from UI
IsOwner bool `json:"is_owner"` // Whether this role represents team owner (deprecated, use config.Role instead)
} }
// InviteConfig represents the invitation configuration // InviteConfig represents the invitation configuration

View file

@ -95,9 +95,10 @@ func importDataFromCSV(filename string, mod *model.Model, options ImportOption,
} }
// Convert to interface slice and parse JSON fields // Convert to interface slice and parse JSON fields
row := make([]interface{}, len(record)) // Ensure row length matches header length to prevent index out of range
for i, v := range record { row := make([]interface{}, len(header))
row[i] = parseJSONField(v, columnTypes[i]) for i := 0; i < len(header) && i < len(record); i++ {
row[i] = parseJSONField(record[i], columnTypes[i])
} }
chunk = append(chunk, row) chunk = append(chunk, row)
@ -195,9 +196,10 @@ func importDataFromXLSX(filename string, mod *model.Model, options ImportOption,
} }
// Convert to interface slice and parse JSON fields // Convert to interface slice and parse JSON fields
row := make([]interface{}, len(record)) // Ensure row length matches header length to prevent index out of range
for i, v := range record { row := make([]interface{}, len(header))
row[i] = parseJSONField(v, columnTypes[i]) for i := 0; i < len(header) && i < len(record); i++ {
row[i] = parseJSONField(record[i], columnTypes[i])
} }
chunk = append(chunk, row) chunk = append(chunk, row)
@ -425,7 +427,8 @@ func importBatch(mod *model.Model, columns []string, data [][]interface{}, start
for i, row := range data { for i, row := range data {
rowMap := maps.MakeMapStrAny() rowMap := maps.MakeMapStrAny()
for j, col := range columns { for j, col := range columns {
if j < len(row) { // Ensure we don't access beyond row length
if j < len(row) && j < len(columns) {
rowMap[col] = row[j] rowMap[col] = row[j]
} }
} }
@ -446,7 +449,8 @@ func importEach(mod *model.Model, columns []string, data [][]interface{}, startL
// Convert row to map // Convert row to map
rowMap := maps.MakeMapStrAny() rowMap := maps.MakeMapStrAny()
for j, col := range columns { for j, col := range columns {
if j < len(row) { // Ensure we don't access beyond row length
if j < len(row) && j < len(columns) {
rowMap[col] = row[j] rowMap[col] = row[j]
} }
} }

View file

@ -270,6 +270,15 @@
"index": true, "index": true,
"nullable": false "nullable": false
}, },
{
"name": "role_id",
"type": "string",
"label": "Role ID",
"comment": "Team owner role identifier (references role.role_id)",
"length": 50,
"nullable": true,
"index": true
},
{ {
"name": "type_id", "name": "type_id",
"type": "string", "type": "string",
@ -439,6 +448,12 @@
"columns": ["type_id", "status"], "columns": ["type_id", "status"],
"type": "index", "type": "index",
"comment": "Index on team type and status for limits and permissions" "comment": "Index on team type and status for limits and permissions"
},
{
"name": "idx_team_role_type",
"columns": ["role_id", "type_id"],
"type": "index",
"comment": "Index on team owner role and type for permission queries"
} }
], ],
"relations": { "relations": {
@ -448,6 +463,12 @@
"key": "owner_id", "key": "owner_id",
"foreign": "user_id" "foreign": "user_id"
}, },
"role": {
"type": "hasOne",
"model": "__yao.role",
"key": "role_id",
"foreign": "role_id"
},
"user_type": { "user_type": {
"type": "hasOne", "type": "hasOne",
"model": "__yao.user.type", "model": "__yao.user.type",