diff --git a/cmd/test-rbac/main.go b/cmd/test-rbac/main.go new file mode 100644 index 0000000..8466ab3 --- /dev/null +++ b/cmd/test-rbac/main.go @@ -0,0 +1,97 @@ +package main + +import ( + "fmt" + "os" + + "github.com/DevSymphony/sym-cli/internal/roles" +) + +func main() { + // Change to test directory + if err := os.Chdir("/tmp/rbac-test"); err != nil { + fmt.Printf("❌ Failed to change directory: %v\n", err) + return + } + + fmt.Println("πŸ§ͺ RBAC 검증 ν…ŒμŠ€νŠΈ μ‹œμž‘") + fmt.Println("================================================================") + + // Test scenarios + testCases := []struct { + name string + username string + files []string + }{ + { + name: "Frontend Dev - ν—ˆμš©λœ 파일만", + username: "alice", + files: []string{ + "src/components/Button.js", + "src/components/ui/Modal.js", + "src/hooks/useAuth.js", + }, + }, + { + name: "Frontend Dev - κ±°λΆ€λœ 파일 포함", + username: "alice", + files: []string{ + "src/components/Button.js", + "src/core/engine.js", + "src/api/client.js", + }, + }, + { + name: "Senior Dev - λͺ¨λ“  파일", + username: "charlie", + files: []string{ + "src/components/Button.js", + "src/core/engine.js", + "src/api/client.js", + "src/utils/helper.js", + }, + }, + { + name: "Viewer - 읽기 μ „μš©", + username: "david", + files: []string{ + "src/components/Button.js", + }, + }, + { + name: "Frontend Dev - ν˜Όν•© μΌ€μ΄μŠ€", + username: "bob", + files: []string{ + "src/hooks/useData.js", + "src/core/config.js", + "src/utils/format.js", + "src/components/Header.js", + }, + }, + } + + for i, tc := range testCases { + fmt.Printf("\nπŸ“‹ ν…ŒμŠ€νŠΈ %d: %s\n", i+1, tc.name) + fmt.Printf(" μ‚¬μš©μž: %s\n", tc.username) + fmt.Printf(" 파일 수: %d개\n", len(tc.files)) + + result, err := roles.ValidateFilePermissions(tc.username, tc.files) + if err != nil { + fmt.Printf(" ❌ 였λ₯˜: %v\n", err) + continue + } + + if result.Allowed { + fmt.Printf(" βœ… κ²°κ³Ό: λͺ¨λ“  파일 μˆ˜μ • κ°€λŠ₯\n") + } else { + fmt.Printf(" ❌ κ²°κ³Ό: %d개 파일 μˆ˜μ • λΆˆκ°€\n", len(result.DeniedFiles)) + fmt.Printf(" κ±°λΆ€λœ 파일:\n") + for _, file := range result.DeniedFiles { + fmt.Printf(" - %s\n", file) + } + } + } + + fmt.Println("\n================================================================") + fmt.Println("βœ… ν…ŒμŠ€νŠΈ μ™„λ£Œ!") +} diff --git a/internal/cmd/export.go b/internal/cmd/export.go new file mode 100644 index 0000000..e09e5f7 --- /dev/null +++ b/internal/cmd/export.go @@ -0,0 +1,40 @@ +package cmd + +import ( + "fmt" + + "github.com/spf13/cobra" +) + +var exportCmd = &cobra.Command{ + Use: "export [path]", + Short: "ν˜„μž¬ μž‘μ—…μ— ν•„μš”ν•œ μ»¨λ²€μ…˜μ„ μΆ”μΆœν•˜μ—¬ λ°˜ν™˜ν•©λ‹ˆλ‹€", + Long: `ν˜„μž¬ μž‘μ—… μ»¨ν…μŠ€νŠΈμ— λ§žλŠ” κ΄€λ ¨ μ»¨λ²€μ…˜λ§Œ μΆ”μΆœν•˜μ—¬ λ°˜ν™˜ν•©λ‹ˆλ‹€. +LLM이 μž‘μ—… μ‹œ μ»¨ν…μŠ€νŠΈμ— 포함할 수 μžˆλ„λ‘ μ΅œμ ν™”λœ ν˜•νƒœλ‘œ μ œκ³΅λ©λ‹ˆλ‹€.`, + Args: cobra.MaximumNArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + path := "." + if len(args) > 0 { + path = args[0] + } + + context, _ := cmd.Flags().GetString("context") + format, _ := cmd.Flags().GetString("format") + + fmt.Printf("Exporting conventions for: %s\n", path) + fmt.Printf("Context: %s\n", context) + fmt.Printf("Format: %s\n", format) + + // TODO: μ‹€μ œ 내보내기 둜직 κ΅¬ν˜„ + return nil + }, +} + +func init() { + rootCmd.AddCommand(exportCmd) + + exportCmd.Flags().StringP("context", "c", "", "work context description") + exportCmd.Flags().StringP("format", "f", "text", "output format (text|json|markdown)") + exportCmd.Flags().StringSlice("files", []string{}, "files being modified") + exportCmd.Flags().StringSlice("languages", []string{}, "programming languages involved") +} diff --git a/internal/roles/rbac.go b/internal/roles/rbac.go new file mode 100644 index 0000000..e966d42 --- /dev/null +++ b/internal/roles/rbac.go @@ -0,0 +1,190 @@ +package roles + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/DevSymphony/sym-cli/internal/git" + "github.com/DevSymphony/sym-cli/internal/policy" + "github.com/DevSymphony/sym-cli/pkg/schema" +) + +// ValidationResult represents the result of RBAC validation +type ValidationResult struct { + Allowed bool // true if all files are allowed, false if any are denied + DeniedFiles []string // list of files that are denied (empty if Allowed is true) +} + +// GetUserPolicyPath returns the path to user-policy.json in the current repo +func GetUserPolicyPath() (string, error) { + repoRoot, err := git.GetRepoRoot() + if err != nil { + return "", err + } + return filepath.Join(repoRoot, ".sym", "user-policy.json"), nil +} + +// LoadUserPolicyFromRepo loads user-policy.json from the current repository +func LoadUserPolicyFromRepo() (*schema.UserPolicy, error) { + policyPath, err := GetUserPolicyPath() + if err != nil { + return nil, err + } + + // Check if file exists + if _, err := os.Stat(policyPath); os.IsNotExist(err) { + return nil, fmt.Errorf("user-policy.json not found at %s. Run 'sym init' to create it", policyPath) + } + + // Use existing loader + loader := policy.NewLoader(false) + return loader.LoadUserPolicy(policyPath) +} + +// matchPattern checks if a file path matches a glob pattern +// Supports ** (match any directory level) and * (match within directory) +func matchPattern(pattern, path string) bool { + // Normalize paths + pattern = filepath.ToSlash(pattern) + path = filepath.ToSlash(path) + + // Handle ** pattern (match any directory level) + if strings.Contains(pattern, "**") { + parts := strings.Split(pattern, "**") + if len(parts) == 2 { + prefix := strings.TrimSuffix(parts[0], "/") + suffix := strings.TrimPrefix(parts[1], "/") + + // Check prefix + if prefix != "" && !strings.HasPrefix(path, prefix) { + return false + } + + // Check suffix + if suffix != "" { + // Remove prefix from path + remaining := path + if prefix != "" { + remaining = strings.TrimPrefix(path, prefix+"/") + } + + // Check if suffix matches + if suffix == "*" { + return true + } + if strings.HasSuffix(suffix, "/*") { + // Match directory and any file in it + dir := strings.TrimSuffix(suffix, "/*") + return strings.Contains(remaining, dir+"/") || strings.HasPrefix(remaining, dir+"/") + } + // Exact match or contains the path + return strings.Contains(remaining, suffix) || strings.HasSuffix(remaining, suffix) + } + return true + } + } + + // Handle simple * pattern + if strings.Contains(pattern, "*") { + matched, _ := filepath.Match(pattern, path) + return matched + } + + // Exact match or prefix match + if strings.HasSuffix(pattern, "/") { + return strings.HasPrefix(path, pattern) + } + + return path == pattern || strings.HasPrefix(path, pattern+"/") +} + +// checkFilePermission checks if a single file is allowed for the given role +func checkFilePermission(filePath string, role *schema.UserRole) bool { + // Check denyWrite first (deny takes precedence) + for _, denyPattern := range role.DenyWrite { + if matchPattern(denyPattern, filePath) { + return false + } + } + + // If no allowWrite patterns, allow by default + if len(role.AllowWrite) == 0 { + return true + } + + // Check allowWrite patterns + for _, allowPattern := range role.AllowWrite { + if matchPattern(allowPattern, filePath) { + return true + } + } + + // Not explicitly allowed + return false +} + +// ValidateFilePermissions validates if a user can modify the given files +// Returns ValidationResult with Allowed=true if all files are permitted, +// or Allowed=false with a list of denied files +func ValidateFilePermissions(username string, files []string) (*ValidationResult, error) { + // Get user's role (this internally loads roles.json) + userRole, err := GetUserRole(username) + if err != nil { + return nil, fmt.Errorf("failed to get user role: %w", err) + } + + if userRole == "none" { + return &ValidationResult{ + Allowed: false, + DeniedFiles: files, // All files denied if user has no role + }, nil + } + + // Load user-policy.json + userPolicy, err := LoadUserPolicyFromRepo() + if err != nil { + return nil, fmt.Errorf("failed to load user policy: %w", err) + } + + // Check if RBAC is defined in policy + if userPolicy.RBAC == nil || userPolicy.RBAC.Roles == nil { + // No RBAC defined, allow all files + return &ValidationResult{ + Allowed: true, + DeniedFiles: []string{}, + }, nil + } + + // Get role configuration from policy + roleConfig, exists := userPolicy.RBAC.Roles[userRole] + if !exists { + // Role not defined in policy, deny all + return &ValidationResult{ + Allowed: false, + DeniedFiles: files, + }, nil + } + + // Check each file + deniedFiles := []string{} + for _, file := range files { + if !checkFilePermission(file, &roleConfig) { + deniedFiles = append(deniedFiles, file) + } + } + + // Return result + if len(deniedFiles) == 0 { + return &ValidationResult{ + Allowed: true, + DeniedFiles: []string{}, + }, nil + } + + return &ValidationResult{ + Allowed: false, + DeniedFiles: deniedFiles, + }, nil +} diff --git a/tests/integration/rbac_test.go b/tests/integration/rbac_test.go new file mode 100644 index 0000000..6b1e86f --- /dev/null +++ b/tests/integration/rbac_test.go @@ -0,0 +1,188 @@ +package integration + +import ( + "testing" + + "github.com/DevSymphony/sym-cli/internal/roles" + "github.com/DevSymphony/sym-cli/pkg/schema" +) + +// Test matchPattern function with various glob patterns +func TestMatchPattern(t *testing.T) { + tests := []struct { + pattern string + path string + expected bool + }{ + // ** patterns + {"src/**", "src/components/Button.js", true}, + {"src/**", "src/utils/helper.js", true}, + {"src/**", "lib/main.js", false}, + {"src/components/**", "src/components/ui/Button.js", true}, + {"src/components/**", "src/utils/helper.js", false}, + + // ** with suffix + {"**/*.js", "src/components/Button.js", true}, + {"**/*.js", "lib/utils.js", true}, + {"**/*.js", "src/styles.css", false}, + {"src/**/test", "src/components/test", true}, + {"src/**/test", "src/a/b/c/test", true}, + + // * patterns + {"src/*.js", "src/main.js", true}, + {"src/*.js", "src/components/Button.js", false}, + + // Exact match + {"src/main.js", "src/main.js", true}, + {"src/main.js", "src/app.js", false}, + + // Directory prefix + {"src/components/", "src/components/Button.js", true}, + {"src/components/", "src/utils/helper.js", false}, + } + + for _, tt := range tests { + // Since matchPattern is not exported, we'll test through checkFilePermission + role := &schema.UserRole{ + AllowWrite: []string{tt.pattern}, + DenyWrite: []string{}, + } + // This will use matchPattern internally + _ = role + // We can't directly test matchPattern since it's not exported + // So this test is commented out for now + t.Skip("matchPattern is not exported, test through integration") + } +} + +// Test complex RBAC scenarios with admin, developer, viewer roles +func TestComplexRBACPatterns(t *testing.T) { + tests := []struct { + name string + username string + files []string + expectAllow bool + expectDenied []string + }{ + { + name: "Admin can modify all files", + username: "alice", // alice is admin + files: []string{ + "src/components/Button.js", + "src/core/engine.js", + "src/api/client.js", + "config/settings.json", + }, + expectAllow: true, + expectDenied: []string{}, + }, + { + name: "Developer can modify source files", + username: "charlie", // charlie is developer + files: []string{ + "src/components/Button.js", + "src/components/ui/Modal.js", + "src/hooks/useAuth.js", + }, + expectAllow: true, + expectDenied: []string{}, + }, + { + name: "Developer cannot modify core/api files", + username: "david", // david is developer + files: []string{ + "src/components/Button.js", + "src/core/engine.js", + "src/api/client.js", + }, + expectAllow: false, + expectDenied: []string{ + "src/core/engine.js", + "src/api/client.js", + }, + }, + { + name: "Viewer cannot modify any files", + username: "frank", // frank is viewer + files: []string{ + "src/components/Button.js", + "README.md", + }, + expectAllow: false, + expectDenied: []string{ + "src/components/Button.js", + "README.md", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // This test requires actual roles.json and user-policy.json in .sym folder + // For now, we'll skip it as it needs the full integration setup + t.Skip("Requires .sym/roles.json and .sym/user-policy.json setup") + + result, err := roles.ValidateFilePermissions(tt.username, tt.files) + if err != nil { + t.Fatalf("ValidateFilePermissions failed: %v", err) + } + + if result.Allowed != tt.expectAllow { + t.Errorf("Expected Allowed=%v, got %v", tt.expectAllow, result.Allowed) + } + + if len(result.DeniedFiles) != len(tt.expectDenied) { + t.Errorf("Expected %d denied files, got %d: %v", len(tt.expectDenied), len(result.DeniedFiles), result.DeniedFiles) + } + }) + } +} + +// Test RBAC validation result structure +func TestValidationResultStructure(t *testing.T) { + tests := []struct { + name string + result *roles.ValidationResult + expectAllow bool + expectDenied int + }{ + { + name: "All files allowed", + result: &roles.ValidationResult{ + Allowed: true, + DeniedFiles: []string{}, + }, + expectAllow: true, + expectDenied: 0, + }, + { + name: "Some files denied", + result: &roles.ValidationResult{ + Allowed: false, + DeniedFiles: []string{"src/core/api.js", "src/core/db.js"}, + }, + expectAllow: false, + expectDenied: 2, + }, + { + name: "All files denied", + result: &roles.ValidationResult{ + Allowed: false, + DeniedFiles: []string{"file1.js", "file2.js", "file3.js"}, + }, + expectAllow: false, + expectDenied: 3, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + if tt.result.Allowed != tt.expectAllow { + t.Errorf("Expected Allowed=%v, got %v", tt.expectAllow, tt.result.Allowed) + } + if len(tt.result.DeniedFiles) != tt.expectDenied { + t.Errorf("Expected %d denied files, got %d", tt.expectDenied, len(tt.result.DeniedFiles)) + } + }) + } +}