Skip to content

Commit 3bfabda

Browse files
committed
feat: RBAC permission system (admin/developer/user)
Backend: - ProjectMember model for per-project user binding (owner/maintainer/viewer) - ProjectMemberHandler with CRUD endpoints - RoleRequired() middleware for flexible role checking - User role validation expanded to admin/developer/user - Project member routes registered in admin group Frontend: - permissions.ts: 3 roles + hasWriteAccess helper - usePermission hook: isDeveloper flag, canWrite for developer+admin - authStore: isDeveloper state - Users.tsx: role dropdown with 3 options, color-coded tags - i18n: developer role translations (en + zh)
1 parent ecc88ed commit 3bfabda

13 files changed

Lines changed: 250 additions & 12 deletions

File tree

backend/cmd/server/routes.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,13 @@ func registerRoutes(r *gin.Engine, svc *appServices) {
112112
admin.PUT("/projects/:id", projectHandler.Update)
113113
admin.DELETE("/projects/:id", projectHandler.Delete)
114114

115+
// Project Members
116+
projectMemberHandler := handlers.NewProjectMemberHandler(models.GetDB())
117+
admin.GET("/projects/:id/members", projectMemberHandler.List)
118+
admin.POST("/projects/:id/members", projectMemberHandler.Add)
119+
admin.PUT("/projects/:id/members/:memberID", projectMemberHandler.Update)
120+
admin.DELETE("/projects/:id/members/:memberID", projectMemberHandler.Remove)
121+
115122
// Review Logs (write operations)
116123
reviewLogHandler := handlers.NewReviewLogHandler(models.GetDB(), svc.openAICfg)
117124
admin.POST("/review-logs/:id/retry", reviewLogHandler.Retry)
Lines changed: 161 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,161 @@
1+
package handlers
2+
3+
import (
4+
"strconv"
5+
6+
"github.com/gin-gonic/gin"
7+
"github.com/huangang/codesentry/backend/internal/models"
8+
"github.com/huangang/codesentry/backend/pkg/response"
9+
"gorm.io/gorm"
10+
)
11+
12+
// ProjectMemberHandler provides CRUD endpoints for project members.
13+
type ProjectMemberHandler struct {
14+
db *gorm.DB
15+
}
16+
17+
func NewProjectMemberHandler(db *gorm.DB) *ProjectMemberHandler {
18+
return &ProjectMemberHandler{db: db}
19+
}
20+
21+
type AddMemberRequest struct {
22+
UserID uint `json:"user_id" binding:"required"`
23+
Role string `json:"role" binding:"required"` // owner, maintainer, viewer
24+
}
25+
26+
type UpdateMemberRequest struct {
27+
Role string `json:"role" binding:"required"`
28+
}
29+
30+
// List returns all members of a project.
31+
func (h *ProjectMemberHandler) List(c *gin.Context) {
32+
projectID, err := strconv.ParseUint(c.Param("id"), 10, 32)
33+
if err != nil {
34+
response.BadRequest(c, "invalid project id")
35+
return
36+
}
37+
38+
var members []models.ProjectMember
39+
if err := h.db.Where("project_id = ?", projectID).
40+
Preload("User").
41+
Find(&members).Error; err != nil {
42+
response.ServerError(c, err.Error())
43+
return
44+
}
45+
46+
response.Success(c, members)
47+
}
48+
49+
// Add adds a user to a project with the specified role.
50+
func (h *ProjectMemberHandler) Add(c *gin.Context) {
51+
projectID, err := strconv.ParseUint(c.Param("id"), 10, 32)
52+
if err != nil {
53+
response.BadRequest(c, "invalid project id")
54+
return
55+
}
56+
57+
var req AddMemberRequest
58+
if err := c.ShouldBindJSON(&req); err != nil {
59+
response.BadRequest(c, err.Error())
60+
return
61+
}
62+
63+
// Validate role
64+
if req.Role != "owner" && req.Role != "maintainer" && req.Role != "viewer" {
65+
response.BadRequest(c, "invalid role, must be 'owner', 'maintainer', or 'viewer'")
66+
return
67+
}
68+
69+
// Check project exists
70+
var project models.Project
71+
if err := h.db.First(&project, projectID).Error; err != nil {
72+
response.NotFound(c, "project not found")
73+
return
74+
}
75+
76+
// Check user exists
77+
var user models.User
78+
if err := h.db.First(&user, req.UserID).Error; err != nil {
79+
response.NotFound(c, "user not found")
80+
return
81+
}
82+
83+
// Check if member already exists
84+
var existing models.ProjectMember
85+
if err := h.db.Where("project_id = ? AND user_id = ?", projectID, req.UserID).First(&existing).Error; err == nil {
86+
response.BadRequest(c, "user is already a member of this project")
87+
return
88+
}
89+
90+
member := models.ProjectMember{
91+
ProjectID: uint(projectID),
92+
UserID: req.UserID,
93+
Role: req.Role,
94+
}
95+
96+
if err := h.db.Create(&member).Error; err != nil {
97+
response.ServerError(c, err.Error())
98+
return
99+
}
100+
101+
// Reload with user info
102+
h.db.Preload("User").First(&member, member.ID)
103+
response.Success(c, member)
104+
}
105+
106+
// Update updates a member's role.
107+
func (h *ProjectMemberHandler) Update(c *gin.Context) {
108+
memberID, err := strconv.ParseUint(c.Param("memberID"), 10, 32)
109+
if err != nil {
110+
response.BadRequest(c, "invalid member id")
111+
return
112+
}
113+
114+
var req UpdateMemberRequest
115+
if err := c.ShouldBindJSON(&req); err != nil {
116+
response.BadRequest(c, err.Error())
117+
return
118+
}
119+
120+
if req.Role != "owner" && req.Role != "maintainer" && req.Role != "viewer" {
121+
response.BadRequest(c, "invalid role, must be 'owner', 'maintainer', or 'viewer'")
122+
return
123+
}
124+
125+
var member models.ProjectMember
126+
if err := h.db.First(&member, memberID).Error; err != nil {
127+
response.NotFound(c, "member not found")
128+
return
129+
}
130+
131+
member.Role = req.Role
132+
if err := h.db.Save(&member).Error; err != nil {
133+
response.ServerError(c, err.Error())
134+
return
135+
}
136+
137+
h.db.Preload("User").First(&member, member.ID)
138+
response.Success(c, member)
139+
}
140+
141+
// Remove removes a member from a project.
142+
func (h *ProjectMemberHandler) Remove(c *gin.Context) {
143+
memberID, err := strconv.ParseUint(c.Param("memberID"), 10, 32)
144+
if err != nil {
145+
response.BadRequest(c, "invalid member id")
146+
return
147+
}
148+
149+
var member models.ProjectMember
150+
if err := h.db.First(&member, memberID).Error; err != nil {
151+
response.NotFound(c, "member not found")
152+
return
153+
}
154+
155+
if err := h.db.Delete(&member).Error; err != nil {
156+
response.ServerError(c, err.Error())
157+
return
158+
}
159+
160+
response.Success(c, gin.H{"message": "member removed"})
161+
}

backend/internal/handlers/user.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -91,8 +91,8 @@ func (h *UserHandler) Update(c *gin.Context) {
9191

9292
updates := make(map[string]interface{})
9393
if req.Role != nil {
94-
if *req.Role != "admin" && *req.Role != "user" {
95-
response.BadRequest(c, "invalid role, must be 'admin' or 'user'")
94+
if *req.Role != "admin" && *req.Role != "developer" && *req.Role != "user" {
95+
response.BadRequest(c, "invalid role, must be 'admin', 'developer', or 'user'")
9696
return
9797
}
9898
updates["role"] = *req.Role

backend/internal/middleware/auth.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,29 @@ func AdminRequired() gin.HandlerFunc {
6262
}
6363
}
6464

65+
// RoleRequired is a middleware that checks if the user has one of the allowed roles.
66+
func RoleRequired(allowedRoles ...string) gin.HandlerFunc {
67+
roleSet := make(map[string]bool, len(allowedRoles))
68+
for _, r := range allowedRoles {
69+
roleSet[r] = true
70+
}
71+
return func(c *gin.Context) {
72+
role, exists := c.Get(ContextRole)
73+
if !exists {
74+
response.Forbidden(c, "access denied")
75+
c.Abort()
76+
return
77+
}
78+
roleStr, ok := role.(string)
79+
if !ok || !roleSet[roleStr] {
80+
response.Forbidden(c, "insufficient permissions")
81+
c.Abort()
82+
return
83+
}
84+
c.Next()
85+
}
86+
}
87+
6588
// GetUserID gets the current user ID from context
6689
func GetUserID(c *gin.Context) uint {
6790
if id, exists := c.Get(ContextUserID); exists {

backend/internal/models/database.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ func AutoMigrate() error {
5757
&ReviewTemplate{},
5858
&ReviewFeedback{},
5959
&AIUsageLog{},
60+
&ProjectMember{},
6061
)
6162
}
6263

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
package models
2+
3+
import (
4+
"time"
5+
6+
"gorm.io/gorm"
7+
)
8+
9+
// ProjectMember represents a user's membership and role within a project.
10+
type ProjectMember struct {
11+
ID uint `gorm:"primaryKey" json:"id"`
12+
ProjectID uint `gorm:"uniqueIndex:idx_project_user;not null" json:"project_id"`
13+
Project *Project `gorm:"foreignKey:ProjectID" json:"project,omitempty"`
14+
UserID uint `gorm:"uniqueIndex:idx_project_user;not null" json:"user_id"`
15+
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
16+
Role string `gorm:"size:50;default:viewer" json:"role"` // owner, maintainer, viewer
17+
CreatedAt time.Time `json:"created_at"`
18+
UpdatedAt time.Time `json:"updated_at"`
19+
DeletedAt gorm.DeletedAt `gorm:"index" json:"-"`
20+
}
21+
22+
func (ProjectMember) TableName() string { return "project_members" }

backend/internal/models/user.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@ type User struct {
1414
Email string `gorm:"size:255" json:"email"`
1515
Nickname string `gorm:"size:100" json:"nickname"`
1616
Avatar string `gorm:"size:500" json:"avatar"`
17-
Role string `gorm:"size:50;default:user" json:"role"` // admin, user
17+
Role string `gorm:"size:50;default:user" json:"role"` // admin, developer, user
1818
AuthType string `gorm:"size:20;default:local" json:"auth_type"` // local, ldap
1919
IsActive bool `gorm:"default:true" json:"is_active"`
2020
LastLogin *time.Time `json:"last_login"`

frontend/src/constants/permissions.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
export const ROLES = {
22
ADMIN: 'admin',
3+
DEVELOPER: 'developer',
34
USER: 'user',
45
} as const;
56

@@ -18,3 +19,8 @@ export const ADMIN_ONLY_ROUTES = [
1819
export const isAdminOnlyRoute = (path: string): boolean => {
1920
return ADMIN_ONLY_ROUTES.some(route => path.startsWith(route));
2021
};
22+
23+
// Check if a role has write access (admin or developer)
24+
export const hasWriteAccess = (role: string): boolean => {
25+
return role === ROLES.ADMIN || role === ROLES.DEVELOPER;
26+
};
Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
import { useMemo } from 'react';
22
import { useAuthStore } from '../stores/authStore';
3-
import { ROLES, ADMIN_ONLY_ROUTES } from '../constants';
3+
import { ROLES, ADMIN_ONLY_ROUTES, hasWriteAccess } from '../constants';
44

55
export interface UsePermissionReturn {
66
isAdmin: boolean;
7+
isDeveloper: boolean;
78
canAccess: (route: string) => boolean;
89
canWrite: boolean;
910
}
@@ -12,6 +13,7 @@ export function usePermission(): UsePermissionReturn {
1213
const user = useAuthStore((state) => state.user);
1314

1415
const isAdmin = useMemo(() => user?.role === ROLES.ADMIN, [user?.role]);
16+
const isDeveloper = useMemo(() => user?.role === ROLES.DEVELOPER, [user?.role]);
1517

1618
const canAccess = useMemo(() => {
1719
return (route: string): boolean => {
@@ -20,7 +22,7 @@ export function usePermission(): UsePermissionReturn {
2022
};
2123
}, [isAdmin]);
2224

23-
const canWrite = isAdmin;
25+
const canWrite = useMemo(() => hasWriteAccess(user?.role || ''), [user?.role]);
2426

25-
return { isAdmin, canAccess, canWrite };
27+
return { isAdmin, isDeveloper, canAccess, canWrite };
2628
}

frontend/src/i18n/locales/en.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,6 +485,8 @@
485485
"isActive": "Active",
486486
"lastLogin": "Last Login",
487487
"admin": "Admin",
488+
"developer": "Developer",
489+
"viewer": "Viewer",
488490
"user": "User",
489491
"local": "Local",
490492
"ldap": "LDAP",

0 commit comments

Comments
 (0)