Normalize request paths in ACL checks and add path normalization utility
- Implemented path normalization in the ACL enforcement logic to ensure consistent matching by removing trailing slashes from request paths. - Introduced a new utility function, normalizePath, to handle path normalization across various components. - Updated endpoint matching and public endpoint checks to utilize the normalized paths, improving access decision accuracy. - Enhanced team and member creation logic to include a new field, __yao_team_id, for better data management.
This commit is contained in:
parent
cc181a52f6
commit
cd9c52fe33
7 changed files with 844 additions and 17 deletions
|
|
@ -341,6 +341,9 @@ func (m *ScopeManager) buildIndexes() error {
|
|||
|
||||
// addEndpointRule adds an endpoint rule to the index
|
||||
func (m *ScopeManager) addEndpointRule(method, path, action string, scopes []string) error {
|
||||
// Normalize path: remove trailing slash (except for root path)
|
||||
path = normalizePath(path)
|
||||
|
||||
// Get or create PathMatcher for this method
|
||||
matcher := m.endpointIndex[method]
|
||||
if matcher == nil {
|
||||
|
|
@ -478,16 +481,19 @@ func (m *ScopeManager) Check(req *AccessRequest) *AccessDecision {
|
|||
UserScopes: req.Scopes,
|
||||
}
|
||||
|
||||
// Normalize the request path
|
||||
normalizedPath := normalizePath(req.Path)
|
||||
|
||||
// 1. Check if it's a public endpoint
|
||||
publicKey := req.Method + " " + req.Path
|
||||
publicKey := req.Method + " " + normalizedPath
|
||||
if _, ok := m.publicPaths[publicKey]; ok {
|
||||
decision.Allowed = true
|
||||
decision.Reason = "public endpoint"
|
||||
return decision
|
||||
}
|
||||
|
||||
// 2. Find matching endpoint
|
||||
endpoint, pattern := m.matchEndpoint(req.Method, req.Path)
|
||||
// 2. Find matching endpoint (matchEndpoint will normalize the path again, but it's idempotent)
|
||||
endpoint, pattern := m.matchEndpoint(req.Method, normalizedPath)
|
||||
if endpoint == nil {
|
||||
// No match found, use default policy
|
||||
decision.Allowed = m.defaultAction == "allow"
|
||||
|
|
@ -560,16 +566,19 @@ func (m *ScopeManager) CheckRestricted(req *AccessRequest) *AccessDecision {
|
|||
UserScopes: req.Scopes,
|
||||
}
|
||||
|
||||
// Normalize the request path
|
||||
normalizedPath := normalizePath(req.Path)
|
||||
|
||||
// 1. Check if it's a public endpoint - public endpoints cannot be restricted
|
||||
publicKey := req.Method + " " + req.Path
|
||||
publicKey := req.Method + " " + normalizedPath
|
||||
if _, ok := m.publicPaths[publicKey]; ok {
|
||||
decision.Allowed = true
|
||||
decision.Reason = "public endpoint"
|
||||
return decision
|
||||
}
|
||||
|
||||
// 2. Find matching endpoint
|
||||
endpoint, pattern := m.matchEndpoint(req.Method, req.Path)
|
||||
// 2. Find matching endpoint (matchEndpoint will normalize the path again, but it's idempotent)
|
||||
endpoint, pattern := m.matchEndpoint(req.Method, normalizedPath)
|
||||
if endpoint == nil {
|
||||
// No match found - not restricted
|
||||
decision.Allowed = true
|
||||
|
|
@ -622,6 +631,9 @@ func (m *ScopeManager) CheckRestricted(req *AccessRequest) *AccessDecision {
|
|||
|
||||
// matchEndpoint finds the matching endpoint for a request
|
||||
func (m *ScopeManager) matchEndpoint(method, path string) (*EndpointInfo, string) {
|
||||
// Normalize path: remove trailing slash (except for root path)
|
||||
path = normalizePath(path)
|
||||
|
||||
matcher := m.endpointIndex[method]
|
||||
if matcher == nil {
|
||||
return nil, ""
|
||||
|
|
@ -823,3 +835,20 @@ func (m *ScopeManager) Reload() error {
|
|||
|
||||
return nil
|
||||
}
|
||||
|
||||
// normalizePath normalizes a path by removing trailing slashes (except for root path "/")
|
||||
// This ensures consistent path matching regardless of whether the request or definition has a trailing slash
|
||||
// Examples:
|
||||
// - "/user/teams/" -> "/user/teams"
|
||||
// - "/user/teams" -> "/user/teams"
|
||||
// - "/" -> "/"
|
||||
// - "" -> ""
|
||||
func normalizePath(path string) string {
|
||||
// Empty path or root path - return as is
|
||||
if path == "" || path == "/" {
|
||||
return path
|
||||
}
|
||||
|
||||
// Remove trailing slash
|
||||
return strings.TrimSuffix(path, "/")
|
||||
}
|
||||
|
|
|
|||
150
openapi/oauth/acl/scope_test.go
Normal file
150
openapi/oauth/acl/scope_test.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package acl
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestNormalizePath(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{
|
||||
name: "path with trailing slash",
|
||||
input: "/user/teams/",
|
||||
expected: "/user/teams",
|
||||
},
|
||||
{
|
||||
name: "path without trailing slash",
|
||||
input: "/user/teams",
|
||||
expected: "/user/teams",
|
||||
},
|
||||
{
|
||||
name: "root path",
|
||||
input: "/",
|
||||
expected: "/",
|
||||
},
|
||||
{
|
||||
name: "empty path",
|
||||
input: "",
|
||||
expected: "",
|
||||
},
|
||||
{
|
||||
name: "nested path with trailing slash",
|
||||
input: "/user/teams/members/",
|
||||
expected: "/user/teams/members",
|
||||
},
|
||||
{
|
||||
name: "nested path without trailing slash",
|
||||
input: "/user/teams/members",
|
||||
expected: "/user/teams/members",
|
||||
},
|
||||
{
|
||||
name: "path with multiple trailing slashes",
|
||||
input: "/user/teams//",
|
||||
expected: "/user/teams/",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := normalizePath(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("normalizePath(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathMatchingWithTrailingSlash tests that paths match correctly regardless of trailing slashes
|
||||
func TestPathMatchingWithTrailingSlash(t *testing.T) {
|
||||
manager := &ScopeManager{
|
||||
endpointIndex: make(map[string]*PathMatcher),
|
||||
publicPaths: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
// Add a test endpoint without trailing slash
|
||||
err := manager.addEndpointRule("POST", "/user/teams", "require-scopes", []string{"teams:write:own"})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add endpoint rule: %v", err)
|
||||
}
|
||||
|
||||
// Test that matching works with trailing slash
|
||||
endpoint, pattern := manager.matchEndpoint("POST", "/user/teams/")
|
||||
if endpoint == nil {
|
||||
t.Errorf("Expected to match endpoint POST /user/teams/, but got nil")
|
||||
}
|
||||
if pattern != "/user/teams" {
|
||||
t.Errorf("Expected pattern /user/teams, got %s", pattern)
|
||||
}
|
||||
|
||||
// Test that matching works without trailing slash
|
||||
endpoint, pattern = manager.matchEndpoint("POST", "/user/teams")
|
||||
if endpoint == nil {
|
||||
t.Errorf("Expected to match endpoint POST /user/teams, but got nil")
|
||||
}
|
||||
if pattern != "/user/teams" {
|
||||
t.Errorf("Expected pattern /user/teams, got %s", pattern)
|
||||
}
|
||||
}
|
||||
|
||||
// TestPathMatchingExactPaths tests exact path matching with normalization
|
||||
func TestPathMatchingExactPaths(t *testing.T) {
|
||||
manager := &ScopeManager{
|
||||
endpointIndex: make(map[string]*PathMatcher),
|
||||
publicPaths: make(map[string]struct{}),
|
||||
}
|
||||
|
||||
// Add endpoints with different trailing slash patterns
|
||||
testCases := []struct {
|
||||
method string
|
||||
definedPath string
|
||||
requestPaths []string
|
||||
shouldMatch bool
|
||||
}{
|
||||
{
|
||||
method: "POST",
|
||||
definedPath: "/user/teams",
|
||||
requestPaths: []string{"/user/teams", "/user/teams/"},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
method: "GET",
|
||||
definedPath: "/user/teams/",
|
||||
requestPaths: []string{"/user/teams", "/user/teams/"},
|
||||
shouldMatch: true,
|
||||
},
|
||||
{
|
||||
method: "DELETE",
|
||||
definedPath: "/user/profile",
|
||||
requestPaths: []string{"/user/profile", "/user/profile/"},
|
||||
shouldMatch: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range testCases {
|
||||
// Add the endpoint
|
||||
err := manager.addEndpointRule(tc.method, tc.definedPath, "allow", nil)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to add endpoint rule %s %s: %v", tc.method, tc.definedPath, err)
|
||||
}
|
||||
|
||||
// Test all request paths
|
||||
for _, requestPath := range tc.requestPaths {
|
||||
endpoint, pattern := manager.matchEndpoint(tc.method, requestPath)
|
||||
if tc.shouldMatch && endpoint == nil {
|
||||
t.Errorf("Expected %s %s to match defined path %s, but got nil",
|
||||
tc.method, requestPath, tc.definedPath)
|
||||
} else if tc.shouldMatch && endpoint != nil {
|
||||
// Both paths should normalize to the same pattern
|
||||
expectedPattern := normalizePath(tc.definedPath)
|
||||
if pattern != expectedPattern {
|
||||
t.Errorf("Expected pattern %s, got %s (defined: %s, request: %s)",
|
||||
expectedPattern, pattern, tc.definedPath, requestPath)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -130,6 +130,9 @@ func (u *DefaultUser) CreateMember(ctx context.Context, memberData maps.MapStrAn
|
|||
return 0, fmt.Errorf("role_id is required in memberData")
|
||||
}
|
||||
|
||||
// Add __yao_team_id to the member data
|
||||
memberData["__yao_team_id"] = memberData["team_id"]
|
||||
|
||||
// Set default values if not provided
|
||||
if _, exists := memberData["member_type"]; !exists {
|
||||
memberData["member_type"] = "user"
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ func (u *DefaultUser) CreateTeam(ctx context.Context, teamData maps.MapStrAny) (
|
|||
return "", fmt.Errorf("failed to generate team_id: %w", err)
|
||||
}
|
||||
teamData["team_id"] = teamID
|
||||
teamData["__yao_team_id"] = teamID // Add __yao_team_id to the team data
|
||||
}
|
||||
|
||||
// Validate required fields
|
||||
|
|
|
|||
177
openapi/oauth/types/authorized.go
Normal file
177
openapi/oauth/types/authorized.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/kun/maps"
|
||||
)
|
||||
|
||||
// CreateAccessScope extracts access scope from the authorized info for creating records
|
||||
func (info *AuthorizedInfo) CreateAccessScope() *model.AccessScope {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
scope := &model.AccessScope{}
|
||||
|
||||
if info.UserID != "" {
|
||||
scope.CreatedBy = info.UserID
|
||||
}
|
||||
|
||||
if info.TeamID != "" {
|
||||
scope.TeamID = info.TeamID
|
||||
}
|
||||
|
||||
if info.TenantID != "" {
|
||||
scope.TenantID = info.TenantID
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
// UpdateAccessScope extracts access scope from the authorized info for updating records
|
||||
func (info *AuthorizedInfo) UpdateAccessScope() *model.AccessScope {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
scope := &model.AccessScope{}
|
||||
|
||||
if info.UserID != "" {
|
||||
scope.UpdatedBy = info.UserID
|
||||
}
|
||||
|
||||
if info.TeamID != "" {
|
||||
scope.TeamID = info.TeamID
|
||||
}
|
||||
|
||||
if info.TenantID != "" {
|
||||
scope.TenantID = info.TenantID
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
// AccessScope extracts access scope from the authorized info
|
||||
// Returns an AccessScope with all available fields populated
|
||||
// Use specific Wheres methods (WheresTeamOnly, WheresCreatorOnly, etc.) to control query logic
|
||||
func (info *AuthorizedInfo) AccessScope() *model.AccessScope {
|
||||
if info == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
scope := &model.AccessScope{}
|
||||
|
||||
if info.UserID != "" {
|
||||
scope.CreatedBy = info.UserID
|
||||
scope.UpdatedBy = info.UserID
|
||||
}
|
||||
|
||||
if info.TeamID != "" {
|
||||
scope.TeamID = info.TeamID
|
||||
}
|
||||
|
||||
if info.TenantID != "" {
|
||||
scope.TenantID = info.TenantID
|
||||
}
|
||||
|
||||
return scope
|
||||
}
|
||||
|
||||
// WithCreateScope appends CreateAccessScope fields to data for insertion
|
||||
// Returns map[string]interface{} with access scope fields added
|
||||
func (info *AuthorizedInfo) WithCreateScope(data interface{}) map[string]interface{} {
|
||||
scope := info.CreateAccessScope()
|
||||
if scope == nil {
|
||||
// If no scope, just convert data to map[string]interface{}
|
||||
result := map[string]interface{}{}
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
return v
|
||||
default:
|
||||
// Try to convert using type assertion for common map types
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
return scope.Append(data)
|
||||
}
|
||||
|
||||
// WithUpdateScope appends UpdateAccessScope fields to data for update
|
||||
// Returns map[string]interface{} with access scope fields added
|
||||
func (info *AuthorizedInfo) WithUpdateScope(data interface{}) map[string]interface{} {
|
||||
scope := info.UpdateAccessScope()
|
||||
if scope == nil {
|
||||
// If no scope, just convert data to map[string]interface{}
|
||||
result := map[string]interface{}{}
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
return v
|
||||
default:
|
||||
// Try to convert using type assertion for common map types
|
||||
if m, ok := data.(map[string]interface{}); ok {
|
||||
return m
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
return scope.Append(data)
|
||||
}
|
||||
|
||||
// CopyCreateScope copies CreateAccessScope fields from source to dest
|
||||
// Extracts __yao_created_by, __yao_team_id, __yao_tenant_id from source
|
||||
// Returns dest map[string]interface{} with access scope fields added
|
||||
func CopyCreateScope(source, dest interface{}) map[string]interface{} {
|
||||
sourceMap := convertToMap(source)
|
||||
scope := &model.AccessScope{}
|
||||
|
||||
// Extract fields from source
|
||||
if createdBy, ok := sourceMap["__yao_created_by"].(string); ok && createdBy != "" {
|
||||
scope.CreatedBy = createdBy
|
||||
}
|
||||
if teamID, ok := sourceMap["__yao_team_id"].(string); ok && teamID != "" {
|
||||
scope.TeamID = teamID
|
||||
}
|
||||
if tenantID, ok := sourceMap["__yao_tenant_id"].(string); ok && tenantID != "" {
|
||||
scope.TenantID = tenantID
|
||||
}
|
||||
|
||||
return scope.Append(dest)
|
||||
}
|
||||
|
||||
// CopyUpdateScope copies UpdateAccessScope fields from source to dest
|
||||
// Extracts __yao_updated_by, __yao_team_id, __yao_tenant_id from source
|
||||
// Returns dest map[string]interface{} with access scope fields added
|
||||
func CopyUpdateScope(source, dest interface{}) map[string]interface{} {
|
||||
sourceMap := convertToMap(source)
|
||||
scope := &model.AccessScope{}
|
||||
|
||||
if updatedBy, ok := sourceMap["__yao_updated_by"].(string); ok && updatedBy != "" {
|
||||
scope.UpdatedBy = updatedBy
|
||||
}
|
||||
if teamID, ok := sourceMap["__yao_team_id"].(string); ok && teamID != "" {
|
||||
scope.TeamID = teamID
|
||||
}
|
||||
if tenantID, ok := sourceMap["__yao_tenant_id"].(string); ok && tenantID != "" {
|
||||
scope.TenantID = tenantID
|
||||
}
|
||||
|
||||
return scope.Append(dest)
|
||||
}
|
||||
|
||||
// convertToMap converts interface{} to map[string]interface{}
|
||||
func convertToMap(data interface{}) map[string]interface{} {
|
||||
if data == nil {
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
|
||||
switch v := data.(type) {
|
||||
case map[string]interface{}:
|
||||
return v
|
||||
case maps.MapStrAny:
|
||||
return map[string]interface{}(v)
|
||||
default:
|
||||
return map[string]interface{}{}
|
||||
}
|
||||
}
|
||||
464
openapi/oauth/types/authorized_test.go
Normal file
464
openapi/oauth/types/authorized_test.go
Normal file
|
|
@ -0,0 +1,464 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCreateAccessScope(t *testing.T) {
|
||||
info := &AuthorizedInfo{
|
||||
UserID: "user123",
|
||||
TeamID: "team456",
|
||||
TenantID: "tenant789",
|
||||
}
|
||||
|
||||
scope := info.CreateAccessScope()
|
||||
assert.NotNil(t, scope)
|
||||
assert.Equal(t, "user123", scope.CreatedBy)
|
||||
assert.Empty(t, scope.UpdatedBy)
|
||||
assert.Equal(t, "team456", scope.TeamID)
|
||||
assert.Equal(t, "tenant789", scope.TenantID)
|
||||
}
|
||||
|
||||
func TestCreateAccessScopeNil(t *testing.T) {
|
||||
var info *AuthorizedInfo
|
||||
scope := info.CreateAccessScope()
|
||||
assert.Nil(t, scope)
|
||||
}
|
||||
|
||||
func TestCreateAccessScopePartial(t *testing.T) {
|
||||
info := &AuthorizedInfo{
|
||||
UserID: "user123",
|
||||
}
|
||||
|
||||
scope := info.CreateAccessScope()
|
||||
assert.NotNil(t, scope)
|
||||
assert.Equal(t, "user123", scope.CreatedBy)
|
||||
assert.Empty(t, scope.UpdatedBy)
|
||||
assert.Empty(t, scope.TeamID)
|
||||
assert.Empty(t, scope.TenantID)
|
||||
}
|
||||
|
||||
func TestUpdateAccessScope(t *testing.T) {
|
||||
info := &AuthorizedInfo{
|
||||
UserID: "user456",
|
||||
TeamID: "team789",
|
||||
TenantID: "tenant000",
|
||||
}
|
||||
|
||||
scope := info.UpdateAccessScope()
|
||||
assert.NotNil(t, scope)
|
||||
assert.Empty(t, scope.CreatedBy)
|
||||
assert.Equal(t, "user456", scope.UpdatedBy)
|
||||
assert.Equal(t, "team789", scope.TeamID)
|
||||
assert.Equal(t, "tenant000", scope.TenantID)
|
||||
}
|
||||
|
||||
func TestUpdateAccessScopeNil(t *testing.T) {
|
||||
var info *AuthorizedInfo
|
||||
scope := info.UpdateAccessScope()
|
||||
assert.Nil(t, scope)
|
||||
}
|
||||
|
||||
func TestAccessScope(t *testing.T) {
|
||||
info := &AuthorizedInfo{
|
||||
UserID: "user123",
|
||||
TeamID: "team456",
|
||||
TenantID: "tenant789",
|
||||
}
|
||||
|
||||
scope := info.AccessScope()
|
||||
assert.NotNil(t, scope)
|
||||
assert.Equal(t, "user123", scope.CreatedBy)
|
||||
assert.Equal(t, "user123", scope.UpdatedBy)
|
||||
assert.Equal(t, "team456", scope.TeamID)
|
||||
assert.Equal(t, "tenant789", scope.TenantID)
|
||||
}
|
||||
|
||||
func TestAccessScopeNil(t *testing.T) {
|
||||
var info *AuthorizedInfo
|
||||
scope := info.AccessScope()
|
||||
assert.Nil(t, scope)
|
||||
}
|
||||
|
||||
func TestAccessScopeEmptyFields(t *testing.T) {
|
||||
info := &AuthorizedInfo{}
|
||||
scope := info.AccessScope()
|
||||
assert.NotNil(t, scope)
|
||||
assert.Empty(t, scope.CreatedBy)
|
||||
assert.Empty(t, scope.UpdatedBy)
|
||||
assert.Empty(t, scope.TeamID)
|
||||
assert.Empty(t, scope.TenantID)
|
||||
}
|
||||
|
||||
func TestAccessScopeOnlyUser(t *testing.T) {
|
||||
info := &AuthorizedInfo{
|
||||
UserID: "user999",
|
||||
}
|
||||
|
||||
scope := info.AccessScope()
|
||||
assert.NotNil(t, scope)
|
||||
assert.Equal(t, "user999", scope.CreatedBy)
|
||||
assert.Equal(t, "user999", scope.UpdatedBy)
|
||||
assert.Empty(t, scope.TeamID)
|
||||
assert.Empty(t, scope.TenantID)
|
||||
}
|
||||
|
||||
func TestAccessScopeOnlyTeam(t *testing.T) {
|
||||
info := &AuthorizedInfo{
|
||||
TeamID: "team111",
|
||||
TenantID: "tenant222",
|
||||
}
|
||||
|
||||
scope := info.AccessScope()
|
||||
assert.NotNil(t, scope)
|
||||
assert.Empty(t, scope.CreatedBy)
|
||||
assert.Empty(t, scope.UpdatedBy)
|
||||
assert.Equal(t, "team111", scope.TeamID)
|
||||
assert.Equal(t, "tenant222", scope.TenantID)
|
||||
}
|
||||
|
||||
func TestAccessScopeIntegration(t *testing.T) {
|
||||
info := &AuthorizedInfo{
|
||||
Subject: "subject123",
|
||||
ClientID: "client456",
|
||||
UserID: "user789",
|
||||
TeamID: "team000",
|
||||
TenantID: "tenant111",
|
||||
}
|
||||
|
||||
// Test CreateAccessScope
|
||||
createScope := info.CreateAccessScope()
|
||||
assert.Equal(t, "user789", createScope.CreatedBy)
|
||||
assert.Empty(t, createScope.UpdatedBy)
|
||||
assert.Equal(t, "team000", createScope.TeamID)
|
||||
assert.Equal(t, "tenant111", createScope.TenantID)
|
||||
|
||||
// Test UpdateAccessScope
|
||||
updateScope := info.UpdateAccessScope()
|
||||
assert.Empty(t, updateScope.CreatedBy)
|
||||
assert.Equal(t, "user789", updateScope.UpdatedBy)
|
||||
assert.Equal(t, "team000", updateScope.TeamID)
|
||||
assert.Equal(t, "tenant111", updateScope.TenantID)
|
||||
|
||||
// Test AccessScope
|
||||
queryScope := info.AccessScope()
|
||||
assert.Equal(t, "user789", queryScope.CreatedBy)
|
||||
assert.Equal(t, "user789", queryScope.UpdatedBy)
|
||||
assert.Equal(t, "team000", queryScope.TeamID)
|
||||
assert.Equal(t, "tenant111", queryScope.TenantID)
|
||||
}
|
||||
|
||||
func TestWithCreateScope(t *testing.T) {
|
||||
info := &AuthorizedInfo{
|
||||
UserID: "user123",
|
||||
TeamID: "team456",
|
||||
TenantID: "tenant789",
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"name": "Test Team",
|
||||
"description": "A test team",
|
||||
}
|
||||
|
||||
result := info.WithCreateScope(data)
|
||||
assert.Equal(t, "Test Team", result["name"])
|
||||
assert.Equal(t, "A test team", result["description"])
|
||||
assert.Equal(t, "user123", result["__yao_created_by"])
|
||||
assert.Equal(t, "team456", result["__yao_team_id"])
|
||||
assert.Equal(t, "tenant789", result["__yao_tenant_id"])
|
||||
assert.Nil(t, result["__yao_updated_by"])
|
||||
}
|
||||
|
||||
func TestWithCreateScopeNil(t *testing.T) {
|
||||
var info *AuthorizedInfo
|
||||
data := map[string]interface{}{
|
||||
"name": "Test",
|
||||
}
|
||||
|
||||
result := info.WithCreateScope(data)
|
||||
assert.Equal(t, "Test", result["name"])
|
||||
assert.Nil(t, result["__yao_created_by"])
|
||||
assert.Nil(t, result["__yao_team_id"])
|
||||
}
|
||||
|
||||
func TestWithCreateScopePartial(t *testing.T) {
|
||||
info := &AuthorizedInfo{
|
||||
UserID: "user123",
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"name": "Test",
|
||||
}
|
||||
|
||||
result := info.WithCreateScope(data)
|
||||
assert.Equal(t, "Test", result["name"])
|
||||
assert.Equal(t, "user123", result["__yao_created_by"])
|
||||
assert.Nil(t, result["__yao_team_id"])
|
||||
assert.Nil(t, result["__yao_tenant_id"])
|
||||
}
|
||||
|
||||
func TestWithUpdateScope(t *testing.T) {
|
||||
info := &AuthorizedInfo{
|
||||
UserID: "user456",
|
||||
TeamID: "team789",
|
||||
TenantID: "tenant000",
|
||||
}
|
||||
|
||||
data := map[string]interface{}{
|
||||
"name": "Updated Team",
|
||||
"description": "An updated team",
|
||||
}
|
||||
|
||||
result := info.WithUpdateScope(data)
|
||||
assert.Equal(t, "Updated Team", result["name"])
|
||||
assert.Equal(t, "An updated team", result["description"])
|
||||
assert.Equal(t, "user456", result["__yao_updated_by"])
|
||||
assert.Equal(t, "team789", result["__yao_team_id"])
|
||||
assert.Equal(t, "tenant000", result["__yao_tenant_id"])
|
||||
assert.Nil(t, result["__yao_created_by"])
|
||||
}
|
||||
|
||||
func TestWithUpdateScopeNil(t *testing.T) {
|
||||
var info *AuthorizedInfo
|
||||
data := map[string]interface{}{
|
||||
"name": "Test",
|
||||
}
|
||||
|
||||
result := info.WithUpdateScope(data)
|
||||
assert.Equal(t, "Test", result["name"])
|
||||
assert.Nil(t, result["__yao_updated_by"])
|
||||
assert.Nil(t, result["__yao_team_id"])
|
||||
}
|
||||
|
||||
func TestWithScopesIntegration(t *testing.T) {
|
||||
info := &AuthorizedInfo{
|
||||
UserID: "user999",
|
||||
TeamID: "team888",
|
||||
TenantID: "tenant777",
|
||||
}
|
||||
|
||||
// Create scenario
|
||||
createData := map[string]interface{}{
|
||||
"name": "New Record",
|
||||
}
|
||||
createResult := info.WithCreateScope(createData)
|
||||
assert.Equal(t, "New Record", createResult["name"])
|
||||
assert.Equal(t, "user999", createResult["__yao_created_by"])
|
||||
assert.Equal(t, "team888", createResult["__yao_team_id"])
|
||||
assert.Equal(t, "tenant777", createResult["__yao_tenant_id"])
|
||||
assert.Nil(t, createResult["__yao_updated_by"])
|
||||
|
||||
// Update scenario
|
||||
updateData := map[string]interface{}{
|
||||
"name": "Updated Record",
|
||||
"status": "active",
|
||||
}
|
||||
updateResult := info.WithUpdateScope(updateData)
|
||||
assert.Equal(t, "Updated Record", updateResult["name"])
|
||||
assert.Equal(t, "active", updateResult["status"])
|
||||
assert.Equal(t, "user999", updateResult["__yao_updated_by"])
|
||||
assert.Equal(t, "team888", updateResult["__yao_team_id"])
|
||||
assert.Equal(t, "tenant777", updateResult["__yao_tenant_id"])
|
||||
assert.Nil(t, updateResult["__yao_created_by"])
|
||||
}
|
||||
|
||||
func TestCopyCreateScope(t *testing.T) {
|
||||
source := map[string]interface{}{
|
||||
"id": 1,
|
||||
"name": "Original Record",
|
||||
"__yao_created_by": "user123",
|
||||
"__yao_team_id": "team456",
|
||||
"__yao_tenant_id": "tenant789",
|
||||
"__yao_updated_by": "user999", // Should not be copied
|
||||
}
|
||||
|
||||
dest := map[string]interface{}{
|
||||
"name": "New Record",
|
||||
"description": "A new record",
|
||||
}
|
||||
|
||||
result := CopyCreateScope(source, dest)
|
||||
assert.Equal(t, "New Record", result["name"])
|
||||
assert.Equal(t, "A new record", result["description"])
|
||||
assert.Equal(t, "user123", result["__yao_created_by"])
|
||||
assert.Equal(t, "team456", result["__yao_team_id"])
|
||||
assert.Equal(t, "tenant789", result["__yao_tenant_id"])
|
||||
assert.Nil(t, result["__yao_updated_by"]) // Should not be copied
|
||||
}
|
||||
|
||||
func TestCopyCreateScopePartial(t *testing.T) {
|
||||
source := map[string]interface{}{
|
||||
"name": "Original",
|
||||
"__yao_created_by": "user123",
|
||||
}
|
||||
|
||||
dest := map[string]interface{}{
|
||||
"name": "New",
|
||||
}
|
||||
|
||||
result := CopyCreateScope(source, dest)
|
||||
assert.Equal(t, "New", result["name"])
|
||||
assert.Equal(t, "user123", result["__yao_created_by"])
|
||||
assert.Nil(t, result["__yao_team_id"])
|
||||
assert.Nil(t, result["__yao_tenant_id"])
|
||||
}
|
||||
|
||||
func TestCopyCreateScopeEmpty(t *testing.T) {
|
||||
source := map[string]interface{}{
|
||||
"name": "Original",
|
||||
}
|
||||
|
||||
dest := map[string]interface{}{
|
||||
"name": "New",
|
||||
}
|
||||
|
||||
result := CopyCreateScope(source, dest)
|
||||
assert.Equal(t, "New", result["name"])
|
||||
assert.Nil(t, result["__yao_created_by"])
|
||||
assert.Nil(t, result["__yao_team_id"])
|
||||
assert.Nil(t, result["__yao_tenant_id"])
|
||||
}
|
||||
|
||||
func TestCopyUpdateScope(t *testing.T) {
|
||||
source := map[string]interface{}{
|
||||
"id": 1,
|
||||
"name": "Original Record",
|
||||
"__yao_updated_by": "user456",
|
||||
"__yao_team_id": "team789",
|
||||
"__yao_tenant_id": "tenant000",
|
||||
"__yao_created_by": "user123", // Should not be copied
|
||||
}
|
||||
|
||||
dest := map[string]interface{}{
|
||||
"name": "Updated Record",
|
||||
"status": "active",
|
||||
}
|
||||
|
||||
result := CopyUpdateScope(source, dest)
|
||||
assert.Equal(t, "Updated Record", result["name"])
|
||||
assert.Equal(t, "active", result["status"])
|
||||
assert.Equal(t, "user456", result["__yao_updated_by"])
|
||||
assert.Equal(t, "team789", result["__yao_team_id"])
|
||||
assert.Equal(t, "tenant000", result["__yao_tenant_id"])
|
||||
assert.Nil(t, result["__yao_created_by"]) // Should not be copied
|
||||
}
|
||||
|
||||
func TestCopyUpdateScopePartial(t *testing.T) {
|
||||
source := map[string]interface{}{
|
||||
"name": "Original",
|
||||
"__yao_updated_by": "user456",
|
||||
"__yao_team_id": "team789",
|
||||
}
|
||||
|
||||
dest := map[string]interface{}{
|
||||
"name": "Updated",
|
||||
}
|
||||
|
||||
result := CopyUpdateScope(source, dest)
|
||||
assert.Equal(t, "Updated", result["name"])
|
||||
assert.Equal(t, "user456", result["__yao_updated_by"])
|
||||
assert.Equal(t, "team789", result["__yao_team_id"])
|
||||
assert.Nil(t, result["__yao_tenant_id"])
|
||||
}
|
||||
|
||||
func TestCopyUpdateScopeEmpty(t *testing.T) {
|
||||
source := map[string]interface{}{
|
||||
"name": "Original",
|
||||
}
|
||||
|
||||
dest := map[string]interface{}{
|
||||
"name": "Updated",
|
||||
}
|
||||
|
||||
result := CopyUpdateScope(source, dest)
|
||||
assert.Equal(t, "Updated", result["name"])
|
||||
assert.Nil(t, result["__yao_updated_by"])
|
||||
assert.Nil(t, result["__yao_team_id"])
|
||||
assert.Nil(t, result["__yao_tenant_id"])
|
||||
}
|
||||
|
||||
func TestCopyCreateScopeRealWorld(t *testing.T) {
|
||||
// Simulate real-world scenario from team.go
|
||||
authInfo := &AuthorizedInfo{
|
||||
UserID: "063254760529",
|
||||
TeamID: "242182710786",
|
||||
TenantID: "tenant789",
|
||||
}
|
||||
|
||||
// This mimics: teamData := authInfo.WithCreateScope(...)
|
||||
teamData := authInfo.WithCreateScope(map[string]interface{}{
|
||||
"name": "A",
|
||||
"description": "AAAA",
|
||||
})
|
||||
|
||||
// Verify teamData has the scope fields
|
||||
assert.Equal(t, "063254760529", teamData["__yao_created_by"])
|
||||
assert.Equal(t, "242182710786", teamData["__yao_team_id"])
|
||||
assert.Equal(t, "tenant789", teamData["__yao_tenant_id"])
|
||||
|
||||
// Now copy from teamData to ownerMemberData
|
||||
ownerMemberData := CopyCreateScope(teamData, map[string]interface{}{
|
||||
"team_id": "242182710786",
|
||||
"user_id": "063254760529",
|
||||
"member_type": "user",
|
||||
"role_id": "owner:free",
|
||||
"status": "active",
|
||||
})
|
||||
|
||||
// Verify the scope fields were copied
|
||||
assert.Equal(t, "063254760529", ownerMemberData["__yao_created_by"], "created_by should be copied from source")
|
||||
assert.Equal(t, "242182710786", ownerMemberData["__yao_team_id"], "team_id should be copied from source")
|
||||
assert.Equal(t, "tenant789", ownerMemberData["__yao_tenant_id"], "tenant_id should be copied from source")
|
||||
|
||||
// Verify dest data is preserved
|
||||
assert.Equal(t, "242182710786", ownerMemberData["team_id"])
|
||||
assert.Equal(t, "063254760529", ownerMemberData["user_id"])
|
||||
assert.Equal(t, "user", ownerMemberData["member_type"])
|
||||
assert.Equal(t, "owner:free", ownerMemberData["role_id"])
|
||||
assert.Equal(t, "active", ownerMemberData["status"])
|
||||
}
|
||||
|
||||
func TestCopyScopesIntegration(t *testing.T) {
|
||||
// Simulate a create operation
|
||||
originalRecord := map[string]interface{}{
|
||||
"id": 1,
|
||||
"name": "Original Team",
|
||||
"__yao_created_by": "user123",
|
||||
"__yao_team_id": "team456",
|
||||
"__yao_tenant_id": "tenant789",
|
||||
}
|
||||
|
||||
// Copy to create a child record
|
||||
childData := map[string]interface{}{
|
||||
"name": "Child Record",
|
||||
"parent_id": 1,
|
||||
}
|
||||
childResult := CopyCreateScope(originalRecord, childData)
|
||||
assert.Equal(t, "Child Record", childResult["name"])
|
||||
assert.Equal(t, 1, childResult["parent_id"])
|
||||
assert.Equal(t, "user123", childResult["__yao_created_by"])
|
||||
assert.Equal(t, "team456", childResult["__yao_team_id"])
|
||||
assert.Equal(t, "tenant789", childResult["__yao_tenant_id"])
|
||||
|
||||
// Simulate an update operation
|
||||
updateRecord := map[string]interface{}{
|
||||
"id": 1,
|
||||
"name": "Updated Team",
|
||||
"__yao_created_by": "user123",
|
||||
"__yao_updated_by": "user999",
|
||||
"__yao_team_id": "team456",
|
||||
"__yao_tenant_id": "tenant789",
|
||||
}
|
||||
|
||||
updateData := map[string]interface{}{
|
||||
"description": "Updated description",
|
||||
}
|
||||
updateResult := CopyUpdateScope(updateRecord, updateData)
|
||||
assert.Equal(t, "Updated description", updateResult["description"])
|
||||
assert.Equal(t, "user999", updateResult["__yao_updated_by"])
|
||||
assert.Equal(t, "team456", updateResult["__yao_team_id"])
|
||||
assert.Equal(t, "tenant789", updateResult["__yao_tenant_id"])
|
||||
assert.Nil(t, updateResult["__yao_created_by"]) // Should not be copied
|
||||
}
|
||||
|
|
@ -16,6 +16,7 @@ import (
|
|||
"github.com/yaoapp/yao/openapi/oauth"
|
||||
"github.com/yaoapp/yao/openapi/oauth/authorized"
|
||||
"github.com/yaoapp/yao/openapi/oauth/providers/user"
|
||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
)
|
||||
|
||||
|
|
@ -149,14 +150,16 @@ func GinTeamGet(c *gin.Context) {
|
|||
// GinTeamCreate handles POST /teams - Create user team
|
||||
func GinTeamCreate(c *gin.Context) {
|
||||
// Get authorized user info
|
||||
authInfo := oauth.GetAuthorizedInfo(c)
|
||||
if authInfo == nil || authInfo.UserID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidClient.Code,
|
||||
ErrorDescription: "User not authenticated",
|
||||
authInfo := authorized.GetInfo(c)
|
||||
if authInfo.Constraints.OwnerOnly {
|
||||
if authInfo == nil || authInfo.UserID == "" {
|
||||
errorResp := &response.ErrorResponse{
|
||||
Code: response.ErrInvalidClient.Code,
|
||||
ErrorDescription: "User not authenticated",
|
||||
}
|
||||
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
||||
return
|
||||
}
|
||||
response.RespondWithError(c, response.StatusUnauthorized, errorResp)
|
||||
return
|
||||
}
|
||||
|
||||
// Parse request body
|
||||
|
|
@ -171,10 +174,10 @@ func GinTeamCreate(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Prepare team data
|
||||
teamData := maps.MapStrAny{
|
||||
teamData := authInfo.WithCreateScope(maps.MapStrAny{
|
||||
"name": req.Name,
|
||||
"description": req.Description,
|
||||
}
|
||||
})
|
||||
|
||||
// Add settings if provided
|
||||
if req.Settings != nil {
|
||||
|
|
@ -745,7 +748,7 @@ func teamCreate(ctx context.Context, userID string, teamData maps.MapStrAny) (st
|
|||
}
|
||||
|
||||
// Add the creator as an owner member of the team
|
||||
ownerMemberData := maps.MapStrAny{
|
||||
ownerMemberData := types.CopyCreateScope(teamData, maps.MapStrAny{
|
||||
"team_id": teamID,
|
||||
"user_id": userID,
|
||||
"member_type": "user",
|
||||
|
|
@ -754,7 +757,7 @@ func teamCreate(ctx context.Context, userID string, teamData maps.MapStrAny) (st
|
|||
"joined_at": time.Now(),
|
||||
"created_at": time.Now(),
|
||||
"updated_at": time.Now(),
|
||||
}
|
||||
})
|
||||
|
||||
_, err = provider.CreateMember(ctx, ownerMemberData)
|
||||
if err != nil {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue