Skip to content

Commit 61612b5

Browse files
authored
feat: add RBAC (Role-Based Access Control) support (#9)
* feat: add RBAC (Role-Based Access Control) support * fix: update RBAC tests for admin/developer/viewer roles and fix fmt.Println
1 parent 8896df7 commit 61612b5

File tree

4 files changed

+515
-0
lines changed

4 files changed

+515
-0
lines changed

cmd/test-rbac/main.go

Lines changed: 97 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,97 @@
1+
package main
2+
3+
import (
4+
"fmt"
5+
"os"
6+
7+
"github.com/DevSymphony/sym-cli/internal/roles"
8+
)
9+
10+
func main() {
11+
// Change to test directory
12+
if err := os.Chdir("/tmp/rbac-test"); err != nil {
13+
fmt.Printf("❌ Failed to change directory: %v\n", err)
14+
return
15+
}
16+
17+
fmt.Println("🧪 RBAC 검증 테스트 시작")
18+
fmt.Println("================================================================")
19+
20+
// Test scenarios
21+
testCases := []struct {
22+
name string
23+
username string
24+
files []string
25+
}{
26+
{
27+
name: "Frontend Dev - 허용된 파일만",
28+
username: "alice",
29+
files: []string{
30+
"src/components/Button.js",
31+
"src/components/ui/Modal.js",
32+
"src/hooks/useAuth.js",
33+
},
34+
},
35+
{
36+
name: "Frontend Dev - 거부된 파일 포함",
37+
username: "alice",
38+
files: []string{
39+
"src/components/Button.js",
40+
"src/core/engine.js",
41+
"src/api/client.js",
42+
},
43+
},
44+
{
45+
name: "Senior Dev - 모든 파일",
46+
username: "charlie",
47+
files: []string{
48+
"src/components/Button.js",
49+
"src/core/engine.js",
50+
"src/api/client.js",
51+
"src/utils/helper.js",
52+
},
53+
},
54+
{
55+
name: "Viewer - 읽기 전용",
56+
username: "david",
57+
files: []string{
58+
"src/components/Button.js",
59+
},
60+
},
61+
{
62+
name: "Frontend Dev - 혼합 케이스",
63+
username: "bob",
64+
files: []string{
65+
"src/hooks/useData.js",
66+
"src/core/config.js",
67+
"src/utils/format.js",
68+
"src/components/Header.js",
69+
},
70+
},
71+
}
72+
73+
for i, tc := range testCases {
74+
fmt.Printf("\n📋 테스트 %d: %s\n", i+1, tc.name)
75+
fmt.Printf(" 사용자: %s\n", tc.username)
76+
fmt.Printf(" 파일 수: %d개\n", len(tc.files))
77+
78+
result, err := roles.ValidateFilePermissions(tc.username, tc.files)
79+
if err != nil {
80+
fmt.Printf(" ❌ 오류: %v\n", err)
81+
continue
82+
}
83+
84+
if result.Allowed {
85+
fmt.Printf(" ✅ 결과: 모든 파일 수정 가능\n")
86+
} else {
87+
fmt.Printf(" ❌ 결과: %d개 파일 수정 불가\n", len(result.DeniedFiles))
88+
fmt.Printf(" 거부된 파일:\n")
89+
for _, file := range result.DeniedFiles {
90+
fmt.Printf(" - %s\n", file)
91+
}
92+
}
93+
}
94+
95+
fmt.Println("\n================================================================")
96+
fmt.Println("✅ 테스트 완료!")
97+
}

internal/cmd/export.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
package cmd
2+
3+
import (
4+
"fmt"
5+
6+
"github.com/spf13/cobra"
7+
)
8+
9+
var exportCmd = &cobra.Command{
10+
Use: "export [path]",
11+
Short: "현재 작업에 필요한 컨벤션을 추출하여 반환합니다",
12+
Long: `현재 작업 컨텍스트에 맞는 관련 컨벤션만 추출하여 반환합니다.
13+
LLM이 작업 시 컨텍스트에 포함할 수 있도록 최적화된 형태로 제공됩니다.`,
14+
Args: cobra.MaximumNArgs(1),
15+
RunE: func(cmd *cobra.Command, args []string) error {
16+
path := "."
17+
if len(args) > 0 {
18+
path = args[0]
19+
}
20+
21+
context, _ := cmd.Flags().GetString("context")
22+
format, _ := cmd.Flags().GetString("format")
23+
24+
fmt.Printf("Exporting conventions for: %s\n", path)
25+
fmt.Printf("Context: %s\n", context)
26+
fmt.Printf("Format: %s\n", format)
27+
28+
// TODO: 실제 내보내기 로직 구현
29+
return nil
30+
},
31+
}
32+
33+
func init() {
34+
rootCmd.AddCommand(exportCmd)
35+
36+
exportCmd.Flags().StringP("context", "c", "", "work context description")
37+
exportCmd.Flags().StringP("format", "f", "text", "output format (text|json|markdown)")
38+
exportCmd.Flags().StringSlice("files", []string{}, "files being modified")
39+
exportCmd.Flags().StringSlice("languages", []string{}, "programming languages involved")
40+
}

internal/roles/rbac.go

Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
package roles
2+
3+
import (
4+
"fmt"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
9+
"github.com/DevSymphony/sym-cli/internal/git"
10+
"github.com/DevSymphony/sym-cli/internal/policy"
11+
"github.com/DevSymphony/sym-cli/pkg/schema"
12+
)
13+
14+
// ValidationResult represents the result of RBAC validation
15+
type ValidationResult struct {
16+
Allowed bool // true if all files are allowed, false if any are denied
17+
DeniedFiles []string // list of files that are denied (empty if Allowed is true)
18+
}
19+
20+
// GetUserPolicyPath returns the path to user-policy.json in the current repo
21+
func GetUserPolicyPath() (string, error) {
22+
repoRoot, err := git.GetRepoRoot()
23+
if err != nil {
24+
return "", err
25+
}
26+
return filepath.Join(repoRoot, ".sym", "user-policy.json"), nil
27+
}
28+
29+
// LoadUserPolicyFromRepo loads user-policy.json from the current repository
30+
func LoadUserPolicyFromRepo() (*schema.UserPolicy, error) {
31+
policyPath, err := GetUserPolicyPath()
32+
if err != nil {
33+
return nil, err
34+
}
35+
36+
// Check if file exists
37+
if _, err := os.Stat(policyPath); os.IsNotExist(err) {
38+
return nil, fmt.Errorf("user-policy.json not found at %s. Run 'sym init' to create it", policyPath)
39+
}
40+
41+
// Use existing loader
42+
loader := policy.NewLoader(false)
43+
return loader.LoadUserPolicy(policyPath)
44+
}
45+
46+
// matchPattern checks if a file path matches a glob pattern
47+
// Supports ** (match any directory level) and * (match within directory)
48+
func matchPattern(pattern, path string) bool {
49+
// Normalize paths
50+
pattern = filepath.ToSlash(pattern)
51+
path = filepath.ToSlash(path)
52+
53+
// Handle ** pattern (match any directory level)
54+
if strings.Contains(pattern, "**") {
55+
parts := strings.Split(pattern, "**")
56+
if len(parts) == 2 {
57+
prefix := strings.TrimSuffix(parts[0], "/")
58+
suffix := strings.TrimPrefix(parts[1], "/")
59+
60+
// Check prefix
61+
if prefix != "" && !strings.HasPrefix(path, prefix) {
62+
return false
63+
}
64+
65+
// Check suffix
66+
if suffix != "" {
67+
// Remove prefix from path
68+
remaining := path
69+
if prefix != "" {
70+
remaining = strings.TrimPrefix(path, prefix+"/")
71+
}
72+
73+
// Check if suffix matches
74+
if suffix == "*" {
75+
return true
76+
}
77+
if strings.HasSuffix(suffix, "/*") {
78+
// Match directory and any file in it
79+
dir := strings.TrimSuffix(suffix, "/*")
80+
return strings.Contains(remaining, dir+"/") || strings.HasPrefix(remaining, dir+"/")
81+
}
82+
// Exact match or contains the path
83+
return strings.Contains(remaining, suffix) || strings.HasSuffix(remaining, suffix)
84+
}
85+
return true
86+
}
87+
}
88+
89+
// Handle simple * pattern
90+
if strings.Contains(pattern, "*") {
91+
matched, _ := filepath.Match(pattern, path)
92+
return matched
93+
}
94+
95+
// Exact match or prefix match
96+
if strings.HasSuffix(pattern, "/") {
97+
return strings.HasPrefix(path, pattern)
98+
}
99+
100+
return path == pattern || strings.HasPrefix(path, pattern+"/")
101+
}
102+
103+
// checkFilePermission checks if a single file is allowed for the given role
104+
func checkFilePermission(filePath string, role *schema.UserRole) bool {
105+
// Check denyWrite first (deny takes precedence)
106+
for _, denyPattern := range role.DenyWrite {
107+
if matchPattern(denyPattern, filePath) {
108+
return false
109+
}
110+
}
111+
112+
// If no allowWrite patterns, allow by default
113+
if len(role.AllowWrite) == 0 {
114+
return true
115+
}
116+
117+
// Check allowWrite patterns
118+
for _, allowPattern := range role.AllowWrite {
119+
if matchPattern(allowPattern, filePath) {
120+
return true
121+
}
122+
}
123+
124+
// Not explicitly allowed
125+
return false
126+
}
127+
128+
// ValidateFilePermissions validates if a user can modify the given files
129+
// Returns ValidationResult with Allowed=true if all files are permitted,
130+
// or Allowed=false with a list of denied files
131+
func ValidateFilePermissions(username string, files []string) (*ValidationResult, error) {
132+
// Get user's role (this internally loads roles.json)
133+
userRole, err := GetUserRole(username)
134+
if err != nil {
135+
return nil, fmt.Errorf("failed to get user role: %w", err)
136+
}
137+
138+
if userRole == "none" {
139+
return &ValidationResult{
140+
Allowed: false,
141+
DeniedFiles: files, // All files denied if user has no role
142+
}, nil
143+
}
144+
145+
// Load user-policy.json
146+
userPolicy, err := LoadUserPolicyFromRepo()
147+
if err != nil {
148+
return nil, fmt.Errorf("failed to load user policy: %w", err)
149+
}
150+
151+
// Check if RBAC is defined in policy
152+
if userPolicy.RBAC == nil || userPolicy.RBAC.Roles == nil {
153+
// No RBAC defined, allow all files
154+
return &ValidationResult{
155+
Allowed: true,
156+
DeniedFiles: []string{},
157+
}, nil
158+
}
159+
160+
// Get role configuration from policy
161+
roleConfig, exists := userPolicy.RBAC.Roles[userRole]
162+
if !exists {
163+
// Role not defined in policy, deny all
164+
return &ValidationResult{
165+
Allowed: false,
166+
DeniedFiles: files,
167+
}, nil
168+
}
169+
170+
// Check each file
171+
deniedFiles := []string{}
172+
for _, file := range files {
173+
if !checkFilePermission(file, &roleConfig) {
174+
deniedFiles = append(deniedFiles, file)
175+
}
176+
}
177+
178+
// Return result
179+
if len(deniedFiles) == 0 {
180+
return &ValidationResult{
181+
Allowed: true,
182+
DeniedFiles: []string{},
183+
}, nil
184+
}
185+
186+
return &ValidationResult{
187+
Allowed: false,
188+
DeniedFiles: deniedFiles,
189+
}, nil
190+
}

0 commit comments

Comments
 (0)