Merge pull request #1219 from trheyi/main
Update team model and creation logic to support role management
This commit is contained in:
commit
6b38a235af
9 changed files with 263 additions and 193 deletions
284
data/bindata.go
284
data/bindata.go
File diff suppressed because one or more lines are too long
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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(),
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
20
seed/seed.go
20
seed/seed.go
|
|
@ -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]
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue