From 34151c00953a97790bff1db8b9a031254b2315b9 Mon Sep 17 00:00:00 2001 From: leoil <31148569+leoil@users.noreply.github.com> Date: Sun, 28 May 2023 11:29:43 +0800 Subject: [PATCH] feat: Support uploading roles and permssions via xlsx files. (#1899) * Support uploading roles and permissions via xlsx file. * Template xlsx file for uploading users and permissions. * reformat according to gofumpt. * fix typo. --- controllers/permission_upload.go | 50 ++++++++++++++++++++ controllers/role_upload.go | 50 ++++++++++++++++++++ object/permission.go | 51 ++++++++++++++++++++ object/permission_upload.go | 77 +++++++++++++++++++++++++++++++ object/role.go | 42 +++++++++++++++++ object/role_upload.go | 63 +++++++++++++++++++++++++ object/user_upload.go | 23 +++++++++ routers/router.go | 2 + web/src/PermissionListPage.js | 42 ++++++++++++++++- web/src/RoleListPage.js | 44 +++++++++++++++++- xlsx/permission_test.xlsx | Bin 0 -> 10595 bytes xlsx/role_test.xlsx | Bin 0 -> 10134 bytes 12 files changed, 440 insertions(+), 4 deletions(-) create mode 100644 controllers/permission_upload.go create mode 100644 controllers/role_upload.go create mode 100644 object/permission_upload.go create mode 100644 object/role_upload.go create mode 100644 xlsx/permission_test.xlsx create mode 100644 xlsx/role_test.xlsx diff --git a/controllers/permission_upload.go b/controllers/permission_upload.go new file mode 100644 index 00000000..5770f383 --- /dev/null +++ b/controllers/permission_upload.go @@ -0,0 +1,50 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controllers + +import ( + "fmt" + + "github.com/casdoor/casdoor/object" + "github.com/casdoor/casdoor/util" +) + +func (c *ApiController) UploadPermissions() { + userId := c.GetSessionUsername() + owner, user := util.GetOwnerAndNameFromId(userId) + + file, header, err := c.Ctx.Request.FormFile("file") + if err != nil { + c.ResponseError(err.Error()) + return + } + + fileId := fmt.Sprintf("%s_%s_%s", owner, user, util.RemoveExt(header.Filename)) + + path := util.GetUploadXlsxPath(fileId) + util.EnsureFileFolderExists(path) + err = saveFile(path, &file) + if err != nil { + c.ResponseError(err.Error()) + return + } + + affected := object.UploadPermissions(owner, fileId) + if affected { + c.ResponseOk() + } else { + c.ResponseError(c.T("user_upload:Failed to import users")) + } +} diff --git a/controllers/role_upload.go b/controllers/role_upload.go new file mode 100644 index 00000000..8b53c28c --- /dev/null +++ b/controllers/role_upload.go @@ -0,0 +1,50 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package controllers + +import ( + "fmt" + + "github.com/casdoor/casdoor/object" + "github.com/casdoor/casdoor/util" +) + +func (c *ApiController) UploadRoles() { + userId := c.GetSessionUsername() + owner, user := util.GetOwnerAndNameFromId(userId) + + file, header, err := c.Ctx.Request.FormFile("file") + if err != nil { + c.ResponseError(err.Error()) + return + } + + fileId := fmt.Sprintf("%s_%s_%s", owner, user, util.RemoveExt(header.Filename)) + + path := util.GetUploadXlsxPath(fileId) + util.EnsureFileFolderExists(path) + err = saveFile(path, &file) + if err != nil { + c.ResponseError(err.Error()) + return + } + + affected := object.UploadRoles(owner, fileId) + if affected { + c.ResponseOk() + } else { + c.ResponseError(c.T("user_upload:Failed to import users")) + } +} diff --git a/object/permission.go b/object/permission.go index cf5e268e..006f9d55 100644 --- a/object/permission.go +++ b/object/permission.go @@ -15,6 +15,9 @@ package object import ( + "strings" + + "github.com/casdoor/casdoor/conf" "github.com/casdoor/casdoor/util" "github.com/xorm-io/core" ) @@ -188,6 +191,54 @@ func AddPermission(permission *Permission) bool { return affected != 0 } +func AddPermissions(permissions []*Permission) bool { + if len(permissions) == 0 { + return false + } + + affected, err := adapter.Engine.Insert(permissions) + if err != nil { + if !strings.Contains(err.Error(), "Duplicate entry") { + panic(err) + } + } + + for _, permission := range permissions { + // add using for loop + if affected != 0 { + addGroupingPolicies(permission) + addPolicies(permission) + } + } + return affected != 0 +} + +func AddPermissionsInBatch(permissions []*Permission) bool { + batchSize := conf.GetConfigBatchSize() + + if len(permissions) == 0 { + return false + } + + affected := false + for i := 0; i < (len(permissions)-1)/batchSize+1; i++ { + start := i * batchSize + end := (i + 1) * batchSize + if end > len(permissions) { + end = len(permissions) + } + + tmp := permissions[start:end] + // TODO: save to log instead of standard output + // fmt.Printf("Add Permissions: [%d - %d].\n", start, end) + if AddPermissions(tmp) { + affected = true + } + } + + return affected +} + func DeletePermission(permission *Permission) bool { affected, err := adapter.Engine.ID(core.PK{permission.Owner, permission.Name}).Delete(&Permission{}) if err != nil { diff --git a/object/permission_upload.go b/object/permission_upload.go new file mode 100644 index 00000000..15e94801 --- /dev/null +++ b/object/permission_upload.go @@ -0,0 +1,77 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package object + +import ( + "github.com/casdoor/casdoor/xlsx" +) + +func getPermissionMap(owner string) map[string]*Permission { + m := map[string]*Permission{} + + permissions := GetPermissions(owner) + for _, permission := range permissions { + m[permission.GetId()] = permission + } + + return m +} + +func UploadPermissions(owner string, fileId string) bool { + table := xlsx.ReadXlsxFile(fileId) + + oldUserMap := getPermissionMap(owner) + newPermissions := []*Permission{} + for index, line := range table { + if index == 0 || parseLineItem(&line, 0) == "" { + continue + } + + permission := &Permission{ + Owner: parseLineItem(&line, 0), + Name: parseLineItem(&line, 1), + CreatedTime: parseLineItem(&line, 2), + DisplayName: parseLineItem(&line, 3), + + Users: parseListItem(&line, 4), + Roles: parseListItem(&line, 5), + Domains: parseListItem(&line, 6), + + Model: parseLineItem(&line, 7), + Adapter: parseLineItem(&line, 8), + ResourceType: parseLineItem(&line, 9), + + Resources: parseListItem(&line, 10), + Actions: parseListItem(&line, 11), + + Effect: parseLineItem(&line, 12), + IsEnabled: parseLineItemBool(&line, 13), + + Submitter: parseLineItem(&line, 14), + Approver: parseLineItem(&line, 15), + ApproveTime: parseLineItem(&line, 16), + State: parseLineItem(&line, 17), + } + + if _, ok := oldUserMap[permission.GetId()]; !ok { + newPermissions = append(newPermissions, permission) + } + } + + if len(newPermissions) == 0 { + return false + } + return AddPermissionsInBatch(newPermissions) +} diff --git a/object/role.go b/object/role.go index 5d744166..ec1c9493 100644 --- a/object/role.go +++ b/object/role.go @@ -16,6 +16,9 @@ package object import ( "fmt" + "strings" + + "github.com/casdoor/casdoor/conf" "github.com/casdoor/casdoor/util" "github.com/xorm-io/core" @@ -160,6 +163,45 @@ func AddRole(role *Role) bool { return affected != 0 } +func AddRoles(roles []*Role) bool { + if len(roles) == 0 { + return false + } + affected, err := adapter.Engine.Insert(roles) + if err != nil { + if !strings.Contains(err.Error(), "Duplicate entry") { + panic(err) + } + } + return affected != 0 +} + +func AddRolesInBatch(roles []*Role) bool { + batchSize := conf.GetConfigBatchSize() + + if len(roles) == 0 { + return false + } + + affected := false + for i := 0; i < (len(roles)-1)/batchSize+1; i++ { + start := i * batchSize + end := (i + 1) * batchSize + if end > len(roles) { + end = len(roles) + } + + tmp := roles[start:end] + // TODO: save to log instead of standard output + // fmt.Printf("Add users: [%d - %d].\n", start, end) + if AddRoles(tmp) { + affected = true + } + } + + return affected +} + func DeleteRole(role *Role) bool { roleId := role.GetId() permissions := GetPermissionsByRole(roleId) diff --git a/object/role_upload.go b/object/role_upload.go new file mode 100644 index 00000000..a91908ab --- /dev/null +++ b/object/role_upload.go @@ -0,0 +1,63 @@ +// Copyright 2023 The Casdoor Authors. All Rights Reserved. +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package object + +import ( + "github.com/casdoor/casdoor/xlsx" +) + +func getRoleMap(owner string) map[string]*Role { + m := map[string]*Role{} + + roles := GetRoles(owner) + for _, role := range roles { + m[role.GetId()] = role + } + + return m +} + +func UploadRoles(owner string, fileId string) bool { + table := xlsx.ReadXlsxFile(fileId) + + oldUserMap := getRoleMap(owner) + newRoles := []*Role{} + for index, line := range table { + if index == 0 || parseLineItem(&line, 0) == "" { + continue + } + + role := &Role{ + Owner: parseLineItem(&line, 0), + Name: parseLineItem(&line, 1), + CreatedTime: parseLineItem(&line, 2), + DisplayName: parseLineItem(&line, 3), + + Users: parseListItem(&line, 4), + Roles: parseListItem(&line, 5), + Domains: parseListItem(&line, 6), + IsEnabled: parseLineItemBool(&line, 7), + } + + if _, ok := oldUserMap[role.GetId()]; !ok { + newRoles = append(newRoles, role) + } + } + + if len(newRoles) == 0 { + return false + } + return AddRolesInBatch(newRoles) +} diff --git a/object/user_upload.go b/object/user_upload.go index cdefb34d..e19ef701 100644 --- a/object/user_upload.go +++ b/object/user_upload.go @@ -15,6 +15,9 @@ package object import ( + "sort" + "strings" + "github.com/casdoor/casdoor/util" "github.com/casdoor/casdoor/xlsx" ) @@ -47,6 +50,26 @@ func parseLineItemBool(line *[]string, i int) bool { return parseLineItemInt(line, i) != 0 } +func parseListItem(lines *[]string, i int) []string { + if i >= len(*lines) { + return nil + } + line := (*lines)[i] + items := strings.Split(line, ";") + trimmedItems := make([]string, 0, len(items)) + + for _, item := range items { + trimmedItem := strings.TrimSpace(item) + if trimmedItem != "" { + trimmedItems = append(trimmedItems, trimmedItem) + } + } + + sort.Strings(trimmedItems) + + return trimmedItems +} + func UploadUsers(owner string, fileId string) bool { table := xlsx.ReadXlsxFile(fileId) diff --git a/routers/router.go b/routers/router.go index 264da792..3b30c295 100644 --- a/routers/router.go +++ b/routers/router.go @@ -82,6 +82,7 @@ func initAPI() { beego.Router("/api/update-role", &controllers.ApiController{}, "POST:UpdateRole") beego.Router("/api/add-role", &controllers.ApiController{}, "POST:AddRole") beego.Router("/api/delete-role", &controllers.ApiController{}, "POST:DeleteRole") + beego.Router("/api/upload-roles", &controllers.ApiController{}, "POST:UploadRoles") beego.Router("/api/get-permissions", &controllers.ApiController{}, "GET:GetPermissions") beego.Router("/api/get-permissions-by-submitter", &controllers.ApiController{}, "GET:GetPermissionsBySubmitter") @@ -90,6 +91,7 @@ func initAPI() { beego.Router("/api/update-permission", &controllers.ApiController{}, "POST:UpdatePermission") beego.Router("/api/add-permission", &controllers.ApiController{}, "POST:AddPermission") beego.Router("/api/delete-permission", &controllers.ApiController{}, "POST:DeletePermission") + beego.Router("/api/upload-permissions", &controllers.ApiController{}, "POST:UploadPermissions") beego.Router("/api/enforce", &controllers.ApiController{}, "POST:Enforce") beego.Router("/api/batch-enforce", &controllers.ApiController{}, "POST:BatchEnforce") diff --git a/web/src/PermissionListPage.js b/web/src/PermissionListPage.js index fb817ac9..e12c8315 100644 --- a/web/src/PermissionListPage.js +++ b/web/src/PermissionListPage.js @@ -14,13 +14,14 @@ import React from "react"; import {Link} from "react-router-dom"; -import {Button, Switch, Table} from "antd"; +import {Button, Switch, Table, Upload} from "antd"; import moment from "moment"; import * as Setting from "./Setting"; import * as PermissionBackend from "./backend/PermissionBackend"; import i18next from "i18next"; import BaseListPage from "./BaseListPage"; import PopconfirmModal from "./common/modal/PopconfirmModal"; +import {UploadOutlined} from "@ant-design/icons"; class PermissionListPage extends BaseListPage { newPermission() { @@ -79,6 +80,40 @@ class PermissionListPage extends BaseListPage { }); } + uploadPermissionFile(info) { + const {status, response: res} = info.file; + if (status === "done") { + if (res.status === "ok") { + Setting.showMessage("success", "Users uploaded successfully, refreshing the page"); + + const {pagination} = this.state; + this.fetch({pagination}); + } else { + Setting.showMessage("error", `Users failed to upload: ${res.msg}`); + } + } else if (status === "error") { + Setting.showMessage("error", "File failed to upload"); + } + } + renderPermissionUpload() { + const props = { + name: "file", + accept: ".xlsx", + method: "post", + action: `${Setting.ServerUrl}/api/upload-permissions`, + withCredentials: true, + onChange: (info) => { + this.uploadPermissionFile(info); + }, + }; + + return ( + + + ); + } renderTable(permissions) { const columns = [ // https://github.com/ant-design/ant-design/issues/22184 @@ -325,7 +360,10 @@ class PermissionListPage extends BaseListPage { title={() => (
{i18next.t("general:Permissions")}     - + + { + this.renderPermissionUpload() + }
)} loading={this.state.loading} diff --git a/web/src/RoleListPage.js b/web/src/RoleListPage.js index 57f9e1be..d3d31112 100644 --- a/web/src/RoleListPage.js +++ b/web/src/RoleListPage.js @@ -14,13 +14,14 @@ import React from "react"; import {Link} from "react-router-dom"; -import {Button, Switch, Table} from "antd"; +import {Button, Switch, Table, Upload} from "antd"; import moment from "moment"; import * as Setting from "./Setting"; import * as RoleBackend from "./backend/RoleBackend"; import i18next from "i18next"; import BaseListPage from "./BaseListPage"; import PopconfirmModal from "./common/modal/PopconfirmModal"; +import {UploadOutlined} from "@ant-design/icons"; class RoleListPage extends BaseListPage { newRole() { @@ -71,6 +72,42 @@ class RoleListPage extends BaseListPage { }); } + uploadRoleFile(info) { + const {status, response: res} = info.file; + if (status === "done") { + if (res.status === "ok") { + Setting.showMessage("success", "Users uploaded successfully, refreshing the page"); + + const {pagination} = this.state; + this.fetch({pagination}); + } else { + Setting.showMessage("error", `Users failed to upload: ${res.msg}`); + } + } else if (status === "error") { + Setting.showMessage("error", "File failed to upload"); + } + } + + renderRoleUpload() { + const props = { + name: "file", + accept: ".xlsx", + method: "post", + action: `${Setting.ServerUrl}/api/upload-roles`, + withCredentials: true, + onChange: (info) => { + this.uploadRoleFile(info); + }, + }; + + return ( + + + + ); + } renderTable(roles) { const columns = [ { @@ -200,7 +237,10 @@ class RoleListPage extends BaseListPage { title={() => (
{i18next.t("general:Roles")}     - + + { + this.renderRoleUpload() + }
)} loading={this.state.loading} diff --git a/xlsx/permission_test.xlsx b/xlsx/permission_test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..639da2b751998b64abd5c593efdaac0034f68d37 GIT binary patch literal 10595 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 literal 0 HcmV?d00001 diff --git a/xlsx/role_test.xlsx b/xlsx/role_test.xlsx new file mode 100644 index 0000000000000000000000000000000000000000..87c748392002206cfb5c4da439c8e5913a505a0a GIT binary patch literal 10134 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