fix(permission): 调整默认权限组与用户权限继承
- 将“普通用户”权限组纳入默认权限继承,创建和更新用户时自动合并其权限。 - 放宽“普通用户”系统组的可编辑性,但仍禁止删除;继续限制超级管理员组被修改。 - 更新管理端权限组操作状态,避免对不可删除或不可编辑项提供错误入口。 - 补充相关测试,覆盖默认权限组编辑、删除与用户权限继承场景。
This commit is contained in:
@@ -235,7 +235,7 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
next.Permissions = allPermissionKeys()
|
next.Permissions = allPermissionKeys()
|
||||||
} else {
|
} else {
|
||||||
permissions, err := a.permissionsForGroupIDs(r.Context(), nil, permissionGroupIDs)
|
permissions, err := a.effectivePermissionsForUserGroups(r.Context(), nil, permissionGroupIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
badRequest(w, err)
|
badRequest(w, err)
|
||||||
return
|
return
|
||||||
|
|||||||
@@ -938,9 +938,23 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
|||||||
}, &errBody); code != http.StatusForbidden {
|
}, &errBody); code != http.StatusForbidden {
|
||||||
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
|
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
|
||||||
}
|
}
|
||||||
|
var regularGroup PermissionGroup
|
||||||
|
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupRegular, map[string]any{
|
||||||
|
"name": "普通用户",
|
||||||
|
"description": "Default permissions for regular users",
|
||||||
|
"permissions": []string{PermissionAdminOverview},
|
||||||
|
}, ®ularGroup); code != http.StatusOK {
|
||||||
|
t.Fatalf("regular user group should be editable code=%d group=%+v", code, regularGroup)
|
||||||
|
}
|
||||||
|
if !regularGroup.System || !userHasPermission(&User{Role: "user", Permissions: regularGroup.Permissions}, PermissionAdminOverview) {
|
||||||
|
t.Fatalf("regular group update did not persist permissions=%+v", regularGroup)
|
||||||
|
}
|
||||||
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden {
|
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupSuperAdmin, nil, &errBody); code != http.StatusForbidden {
|
||||||
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
|
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
|
||||||
}
|
}
|
||||||
|
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupRegular, nil, &errBody); code != http.StatusForbidden {
|
||||||
|
t.Fatalf("regular user group delete should be forbidden code=%d body=%v", code, errBody)
|
||||||
|
}
|
||||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||||
"email": "invalid-group@lanqin.local",
|
"email": "invalid-group@lanqin.local",
|
||||||
"displayName": "Invalid Group",
|
"displayName": "Invalid Group",
|
||||||
@@ -997,10 +1011,25 @@ func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
|||||||
}, &mailboxUser); code != http.StatusCreated {
|
}, &mailboxUser); code != http.StatusCreated {
|
||||||
t.Fatalf("create mailbox admin user code=%d user=%+v", code, mailboxUser)
|
t.Fatalf("create mailbox admin user code=%d user=%+v", code, mailboxUser)
|
||||||
}
|
}
|
||||||
if mailboxUser.Role != "user" || len(mailboxUser.PermissionGroupIDs) != 1 || mailboxUser.PermissionGroupIDs[0] != mailboxAdminGroup.ID || !userHasPermission(&mailboxUser.User, PermissionMailboxesManage) || userHasPermission(&mailboxUser.User, PermissionSystemSettings) {
|
if mailboxUser.Role != "user" || !containsString(mailboxUser.PermissionGroupIDs, PermissionGroupRegular) || !containsString(mailboxUser.PermissionGroupIDs, mailboxAdminGroup.ID) || !userHasPermission(&mailboxUser.User, PermissionMailboxesManage) || userHasPermission(&mailboxUser.User, PermissionSystemSettings) {
|
||||||
t.Fatalf("mailbox admin authorization=%+v", mailboxUser.User)
|
t.Fatalf("mailbox admin authorization=%+v", mailboxUser.User)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var plainUser AdminUser
|
||||||
|
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||||
|
"email": "plain-user@lanqin.local",
|
||||||
|
"displayName": "Plain User",
|
||||||
|
"role": "user",
|
||||||
|
"password": "Password123!",
|
||||||
|
"disabled": false,
|
||||||
|
"permissionGroupIds": []string{},
|
||||||
|
}, &plainUser); code != http.StatusCreated {
|
||||||
|
t.Fatalf("create plain user code=%d user=%+v", code, plainUser)
|
||||||
|
}
|
||||||
|
if len(plainUser.PermissionGroupIDs) != 1 || plainUser.PermissionGroupIDs[0] != PermissionGroupRegular || !userHasPermission(&plainUser.User, PermissionAdminOverview) {
|
||||||
|
t.Fatalf("plain user should inherit regular permissions: %+v", plainUser.User)
|
||||||
|
}
|
||||||
|
|
||||||
var customUser AdminUser
|
var customUser AdminUser
|
||||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||||
"email": "mailbox-viewer@lanqin.local",
|
"email": "mailbox-viewer@lanqin.local",
|
||||||
@@ -1308,3 +1337,12 @@ func mustDefaultDomainID(t *testing.T, a *App) string {
|
|||||||
}
|
}
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func containsString(items []string, needle string) bool {
|
||||||
|
for _, item := range items {
|
||||||
|
if item == needle {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|||||||
@@ -127,8 +127,8 @@ func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request
|
|||||||
respondError(w, http.StatusNotFound, "permission group not found")
|
respondError(w, http.StatusNotFound, "permission group not found")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
if intBool(existingSystem) {
|
if id == PermissionGroupSuperAdmin {
|
||||||
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
|
respondError(w, http.StatusForbidden, "super administrator group cannot be edited")
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
var req struct {
|
var req struct {
|
||||||
|
|||||||
@@ -312,10 +312,15 @@ func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
|
|||||||
if _, err := a.db.ExecContext(ctx, `UPDATE permission_groups SET name=name || ' (' || id || ')' WHERE name=? AND id<>?`, item.Name, item.ID); err != nil {
|
if _, err := a.db.ExecContext(ctx, `UPDATE permission_groups SET name=name || ' (' || id || ')' WHERE name=? AND id<>?`, item.Name, item.ID); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
if _, err := a.db.ExecContext(ctx, `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
query := `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
||||||
VALUES(?,?,?,?,?,?,?)
|
VALUES(?,?,?,?,?,?,?)
|
||||||
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, permissions_json=excluded.permissions_json, system=excluded.system, updated_at=excluded.updated_at`,
|
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, permissions_json=excluded.permissions_json, system=excluded.system, updated_at=excluded.updated_at`
|
||||||
item.ID, item.Name, item.Description, encodePermissions(item.Permissions), boolInt(item.System), now, now); err != nil {
|
if item.ID == PermissionGroupRegular {
|
||||||
|
query = `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
||||||
|
VALUES(?,?,?,?,?,?,?)
|
||||||
|
ON CONFLICT(id) DO UPDATE SET name=excluded.name, description=excluded.description, system=excluded.system, updated_at=excluded.updated_at`
|
||||||
|
}
|
||||||
|
if _, err := a.db.ExecContext(ctx, query, item.ID, item.Name, item.Description, encodePermissions(item.Permissions), boolInt(item.System), now, now); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -367,6 +372,10 @@ func (a *App) permissionsForUser(ctx context.Context, userID, role string) ([]st
|
|||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
return allPermissionKeys(), nil
|
return allPermissionKeys(), nil
|
||||||
}
|
}
|
||||||
|
seen := map[string]bool{}
|
||||||
|
if err := a.addRegularGroupPermissions(ctx, nil, seen); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
rows, err := a.db.QueryContext(ctx, `SELECT pg.id,pg.permissions_json
|
rows, err := a.db.QueryContext(ctx, `SELECT pg.id,pg.permissions_json
|
||||||
FROM permission_groups pg
|
FROM permission_groups pg
|
||||||
JOIN user_permission_groups upg ON upg.group_id=pg.id
|
JOIN user_permission_groups upg ON upg.group_id=pg.id
|
||||||
@@ -375,7 +384,6 @@ func (a *App) permissionsForUser(ctx context.Context, userID, role string) ([]st
|
|||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
seen := map[string]bool{}
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var groupID, raw string
|
var groupID, raw string
|
||||||
if err := rows.Scan(&groupID, &raw); err != nil {
|
if err := rows.Scan(&groupID, &raw); err != nil {
|
||||||
@@ -400,11 +408,58 @@ func (a *App) permissionsForUser(ctx context.Context, userID, role string) ([]st
|
|||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (a *App) addRegularGroupPermissions(ctx context.Context, tx *sql.Tx, seen map[string]bool) error {
|
||||||
|
var raw string
|
||||||
|
query := `SELECT permissions_json FROM permission_groups WHERE id=?`
|
||||||
|
var err error
|
||||||
|
if tx != nil {
|
||||||
|
err = tx.QueryRowContext(ctx, query, PermissionGroupRegular).Scan(&raw)
|
||||||
|
} else {
|
||||||
|
err = a.db.QueryRowContext(ctx, query, PermissionGroupRegular).Scan(&raw)
|
||||||
|
}
|
||||||
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
for _, permission := range decodeStoredPermissions(raw) {
|
||||||
|
seen[permission] = true
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (a *App) effectivePermissionsForUserGroups(ctx context.Context, tx *sql.Tx, groupIDs []string) ([]string, error) {
|
||||||
|
seen := map[string]bool{}
|
||||||
|
if err := a.addRegularGroupPermissions(ctx, tx, seen); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
groupPermissions, err := a.permissionsForGroupIDs(ctx, tx, groupIDs)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, permission := range groupPermissions {
|
||||||
|
seen[permission] = true
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(seen))
|
||||||
|
for _, permission := range allPermissionKeys() {
|
||||||
|
if seen[permission] {
|
||||||
|
out = append(out, permission)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) {
|
func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) {
|
||||||
if role == "admin" {
|
if role == "admin" {
|
||||||
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"}
|
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"}
|
||||||
return []string{group.ID}, []PermissionGroupSummary{group}, nil
|
return []string{group.ID}, []PermissionGroupSummary{group}, nil
|
||||||
}
|
}
|
||||||
|
ids := []string{PermissionGroupRegular}
|
||||||
|
groups := []PermissionGroupSummary{{ID: PermissionGroupRegular, Name: "普通用户"}}
|
||||||
|
if err := a.db.QueryRowContext(ctx, `SELECT name FROM permission_groups WHERE id=?`, PermissionGroupRegular).Scan(&groups[0].Name); err != nil && !errors.Is(err, sql.ErrNoRows) {
|
||||||
|
return nil, nil, err
|
||||||
|
}
|
||||||
rows, err := a.db.QueryContext(ctx, `SELECT pg.id,pg.name
|
rows, err := a.db.QueryContext(ctx, `SELECT pg.id,pg.name
|
||||||
FROM permission_groups pg
|
FROM permission_groups pg
|
||||||
JOIN user_permission_groups upg ON upg.group_id=pg.id
|
JOIN user_permission_groups upg ON upg.group_id=pg.id
|
||||||
@@ -414,8 +469,6 @@ func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string)
|
|||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
order := permissionGroupOrder()
|
order := permissionGroupOrder()
|
||||||
ids := []string{}
|
|
||||||
groups := []PermissionGroupSummary{}
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var group PermissionGroupSummary
|
var group PermissionGroupSummary
|
||||||
if err := rows.Scan(&group.ID, &group.Name); err != nil {
|
if err := rows.Scan(&group.ID, &group.Name); err != nil {
|
||||||
@@ -452,10 +505,6 @@ func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string)
|
|||||||
}
|
}
|
||||||
return ids[i] < ids[j]
|
return ids[i] < ids[j]
|
||||||
})
|
})
|
||||||
if len(groups) == 0 {
|
|
||||||
group := PermissionGroupSummary{ID: PermissionGroupRegular, Name: "普通用户"}
|
|
||||||
return []string{group.ID}, []PermissionGroupSummary{group}, nil
|
|
||||||
}
|
|
||||||
return ids, groups, nil
|
return ids, groups, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -582,7 +631,7 @@ func (a *App) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID st
|
|||||||
return fmt.Errorf("permission group not assignable: %s", groupID)
|
return fmt.Errorf("permission group not assignable: %s", groupID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
groupPermissions, err := a.permissionsForGroupIDs(ctx, tx, groupIDs)
|
groupPermissions, err := a.effectivePermissionsForUserGroups(ctx, tx, groupIDs)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -305,6 +305,8 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
|||||||
if (!keyword) return true
|
if (!keyword) return true
|
||||||
return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword))
|
return [group.name, group.description, ...group.permissions].some((value) => value.toLowerCase().includes(keyword))
|
||||||
})
|
})
|
||||||
|
const isEditable = (group: PermissionGroup) => group.id !== "pg_super_admin"
|
||||||
|
const isDeletable = (group: PermissionGroup) => !group.system && group.userCount === 0
|
||||||
return (
|
return (
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
@@ -334,11 +336,11 @@ function PermissionGroupsSection({ groups, catalog }: { groups: PermissionGroup[
|
|||||||
{(canUpdate || canDelete) && <DropdownMenu>
|
{(canUpdate || canDelete) && <DropdownMenu>
|
||||||
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
<DropdownMenuTrigger asChild><Button variant="ghost" size="icon"><MoreHorizontal className="h-4 w-4" /></Button></DropdownMenuTrigger>
|
||||||
<DropdownMenuContent align="end">
|
<DropdownMenuContent align="end">
|
||||||
<DropdownMenuItem disabled={group.system || !canUpdate} onSelect={() => setEditing(group)}>编辑权限组</DropdownMenuItem>
|
<DropdownMenuItem disabled={!isEditable(group) || !canUpdate} onSelect={() => setEditing(group)}>编辑权限组</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
disabled={group.system || group.userCount > 0 || !canDelete}
|
disabled={!isDeletable(group) || !canDelete}
|
||||||
onSelect={() => setPendingConfirm({ title: "删除权限组?", description: `${group.name} 删除后不能再分配给用户。`, confirmText: "删除权限组", onConfirm: () => remove.mutate(group.id) })}
|
onSelect={() => setPendingConfirm({ title: "删除权限组?", description: `${group.name} 删除后不能再分配给用户。`, confirmText: "删除权限组", onConfirm: () => remove.mutate(group.id) })}
|
||||||
>
|
>
|
||||||
删除权限组
|
删除权限组
|
||||||
|
|||||||
Reference in New Issue
Block a user