feat(admin): 引入细粒度权限组管理
- 新增权限目录、系统权限组与用户权限组分配,后台路由改为按权限粒度控制。 - 支持创建、编辑、查看和删除自定义权限组,并为用户绑定权限组。 - 前端后台界面按权限动态展示菜单与操作项,补充用户/权限组相关类型与交互。
This commit is contained in:
@@ -73,21 +73,37 @@ func (a *App) handleListUsers(w http.ResponseWriter, r *http.Request) {
|
||||
item.Mailboxes = splitCSV(mailboxCSV)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list users")
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
if err := a.attachUserAuthorization(r.Context(), &items[i].User); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user permissions")
|
||||
return
|
||||
}
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items})
|
||||
}
|
||||
|
||||
func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Password string `json:"password"`
|
||||
Disabled bool `json:"disabled"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
actor := currentUser(r)
|
||||
email := normalizeEmail(req.Email)
|
||||
if email == "" || !strings.Contains(email, "@") {
|
||||
badRequest(w, errors.New("invalid email"))
|
||||
@@ -105,6 +121,10 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
if role == "admin" && (actor == nil || actor.Role != "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
return
|
||||
}
|
||||
if len(req.Password) < 8 {
|
||||
badRequest(w, errors.New("password must be at least 8 characters"))
|
||||
return
|
||||
@@ -116,12 +136,29 @@ func (a *App) handleCreateUser(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
id := newID("usr")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
_, err = a.db.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now)
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err = tx.ExecContext(r.Context(), `INSERT INTO users(id,email,display_name,role,password_hash,disabled,created_at,updated_at)
|
||||
VALUES(?,?,?,?,?,?,?,?)`, id, email, displayName, role, string(passwordHash), boolInt(req.Disabled), now, now); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
permissionGroupIDs := req.PermissionGroupIDs
|
||||
if role == "admin" {
|
||||
permissionGroupIDs = nil
|
||||
}
|
||||
if err := a.setUserPermissionGroups(r.Context(), tx, id, permissionGroupIDs, actor); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to create user")
|
||||
return
|
||||
}
|
||||
user, err := a.adminUserByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load user")
|
||||
@@ -134,9 +171,10 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
current := currentUser(r)
|
||||
var req struct {
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled *bool `json:"disabled"`
|
||||
PermissionGroupIDs *[]string `json:"permissionGroupIds"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
@@ -155,21 +193,79 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
disabled := false
|
||||
existing, err := a.userByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
}
|
||||
if current == nil || (current.Role != "admin" && (existing.Role == "admin" || role == "admin")) {
|
||||
respondError(w, http.StatusForbidden, "only administrators can modify administrator users")
|
||||
return
|
||||
}
|
||||
disabled := existing.Disabled
|
||||
if req.Disabled != nil {
|
||||
disabled = *req.Disabled
|
||||
}
|
||||
if current != nil && current.ID == id && (disabled || role != "admin") {
|
||||
badRequest(w, errors.New("cannot remove your own admin access"))
|
||||
if a.isDefaultAdminUser(existing) && (role != "admin" || disabled) {
|
||||
badRequest(w, errors.New("default administrator must remain an active super administrator"))
|
||||
return
|
||||
}
|
||||
if err := a.ensureAdminRemains(r.Context(), id, role, disabled); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
_, err := a.db.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
|
||||
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id)
|
||||
shouldUpdatePermissionGroups := role == "admin" || existing.Role == "admin" || req.PermissionGroupIDs != nil
|
||||
var permissionGroupIDs []string
|
||||
if role == "user" {
|
||||
if req.PermissionGroupIDs != nil {
|
||||
permissionGroupIDs = *req.PermissionGroupIDs
|
||||
} else if existing.Role == "user" {
|
||||
for _, groupID := range existing.PermissionGroupIDs {
|
||||
if isAssignablePermissionGroupID(groupID) {
|
||||
permissionGroupIDs = append(permissionGroupIDs, groupID)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if current != nil && current.ID == id {
|
||||
next := *existing
|
||||
next.Role = role
|
||||
next.Disabled = disabled
|
||||
if shouldUpdatePermissionGroups {
|
||||
if role == "admin" {
|
||||
next.Permissions = allPermissionKeys()
|
||||
} else {
|
||||
permissions, err := a.permissionsForGroupIDs(r.Context(), nil, permissionGroupIDs)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
next.Permissions = permissions
|
||||
}
|
||||
}
|
||||
if next.Disabled || !userHasAdminAccess(&next) {
|
||||
badRequest(w, errors.New("cannot remove your own admin access"))
|
||||
return
|
||||
}
|
||||
}
|
||||
tx, err := a.db.BeginTx(r.Context(), nil)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to start transaction")
|
||||
return
|
||||
}
|
||||
defer tx.Rollback()
|
||||
if _, err := tx.ExecContext(r.Context(), `UPDATE users SET display_name=?, role=?, disabled=?, updated_at=? WHERE id=?`,
|
||||
displayName, role, boolInt(disabled), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
if shouldUpdatePermissionGroups {
|
||||
if err := a.setUserPermissionGroups(r.Context(), tx, id, permissionGroupIDs, current); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
if err := tx.Commit(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to update user")
|
||||
return
|
||||
}
|
||||
@@ -183,6 +279,16 @@ func (a *App) handleUpdateUser(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
func (a *App) handleResetUserPassword(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
if target, err := a.userByID(r.Context(), id); err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
} else if target.Role == "admin" {
|
||||
current := currentUser(r)
|
||||
if current == nil || current.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "only administrators can reset administrator passwords")
|
||||
return
|
||||
}
|
||||
}
|
||||
var req struct {
|
||||
Password string `json:"password"`
|
||||
}
|
||||
@@ -234,6 +340,16 @@ func (a *App) handleDeleteUser(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("cannot delete your own user"))
|
||||
return
|
||||
}
|
||||
if target, err := a.userByID(r.Context(), id); err != nil {
|
||||
respondError(w, http.StatusNotFound, "user not found")
|
||||
return
|
||||
} else if a.isDefaultAdminUser(target) {
|
||||
badRequest(w, errors.New("default administrator cannot be deleted"))
|
||||
return
|
||||
} else if target.Role == "admin" && (current == nil || current.Role != "admin") {
|
||||
respondError(w, http.StatusForbidden, "only administrators can delete administrator users")
|
||||
return
|
||||
}
|
||||
if err := a.ensureAdminRemains(r.Context(), id, "user", true); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
@@ -409,6 +525,13 @@ func (a *App) handleCreateMailbox(w http.ResponseWriter, r *http.Request) {
|
||||
badRequest(w, errors.New("invalid role"))
|
||||
return
|
||||
}
|
||||
if role == "admin" {
|
||||
current := currentUser(r)
|
||||
if current == nil || current.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "only administrators can create administrator users")
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
domain, err := a.domainByID(r.Context(), req.DomainID)
|
||||
if err != nil {
|
||||
@@ -836,6 +959,9 @@ func (a *App) adminUserByID(ctx context.Context, id string) (*AdminUser, error)
|
||||
item.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.Mailboxes = splitCSV(mailboxCSV)
|
||||
if err := a.attachUserAuthorization(ctx, &item.User); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &item, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -115,6 +115,22 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS permission_groups (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL UNIQUE,
|
||||
description TEXT NOT NULL DEFAULT '',
|
||||
permissions_json TEXT NOT NULL DEFAULT '[]',
|
||||
system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at TEXT NOT NULL,
|
||||
updated_at TEXT NOT NULL
|
||||
)`,
|
||||
`CREATE TABLE IF NOT EXISTS user_permission_groups (
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
group_id TEXT NOT NULL REFERENCES permission_groups(id) ON DELETE CASCADE,
|
||||
created_at TEXT NOT NULL,
|
||||
PRIMARY KEY(user_id, group_id)
|
||||
)`,
|
||||
`CREATE INDEX IF NOT EXISTS idx_user_permission_groups_group ON user_permission_groups(group_id, user_id)`,
|
||||
`CREATE TABLE IF NOT EXISTS sessions (
|
||||
id TEXT PRIMARY KEY,
|
||||
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
|
||||
@@ -327,6 +343,9 @@ func (a *App) migrate(ctx context.Context) error {
|
||||
if err := a.migrateLegacyBootstrapMailbox(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := a.ensureDefaultPermissionGroups(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -679,7 +698,7 @@ func (a *App) seed(ctx context.Context) error {
|
||||
return err
|
||||
}
|
||||
if count > 0 {
|
||||
return nil
|
||||
return a.ensureConfiguredAdminSuperAdmin(ctx)
|
||||
}
|
||||
|
||||
adminPassword := a.cfg.AdminPassword
|
||||
@@ -736,6 +755,16 @@ func (a *App) seed(ctx context.Context) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) ensureConfiguredAdminSuperAdmin(ctx context.Context) error {
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
if adminEmail == "" || !strings.Contains(adminEmail, "@") {
|
||||
return nil
|
||||
}
|
||||
_, err := a.db.ExecContext(ctx, `UPDATE users SET role='admin', disabled=0, updated_at=? WHERE email=?`,
|
||||
a.now().UTC().Format(time.RFC3339Nano), adminEmail)
|
||||
return err
|
||||
}
|
||||
|
||||
func (a *App) createDomainTx(ctx context.Context, tx *sql.Tx, name string) (string, error) {
|
||||
name = normalizeDomain(name)
|
||||
if name == "" || !strings.Contains(name, ".") {
|
||||
|
||||
@@ -886,6 +886,231 @@ func TestDNSRecords(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestFixedRolesProtectAdminRoutesAndDefaultAdmin(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ts := httptest.NewServer(a.Router())
|
||||
defer ts.Close()
|
||||
admin := &testClient{t: t, server: ts}
|
||||
|
||||
var login map[string]any
|
||||
if code := admin.do("POST", "/api/auth/login", map[string]string{"email": "admin@lanqin.local", "password": "ChangeMe123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("admin login code=%d body=%v", code, login)
|
||||
}
|
||||
|
||||
var groups struct {
|
||||
Items []PermissionGroup `json:"items"`
|
||||
}
|
||||
if code := admin.do("GET", "/api/admin/permission-groups", nil, &groups); code != http.StatusOK || len(groups.Items) != len(defaultPermissionGroups()) {
|
||||
t.Fatalf("fixed permission groups code=%d groups=%+v", code, groups.Items)
|
||||
}
|
||||
groupByID := map[string]PermissionGroup{}
|
||||
for _, group := range groups.Items {
|
||||
groupByID[group.ID] = group
|
||||
}
|
||||
for _, group := range defaultPermissionGroups() {
|
||||
if _, ok := groupByID[group.ID]; !ok {
|
||||
t.Fatalf("missing fixed permission group %s in %+v", group.ID, groups.Items)
|
||||
}
|
||||
}
|
||||
if groups.Items[0].ID != PermissionGroupSuperAdmin || groups.Items[1].ID != PermissionGroupRegular || groupByID[PermissionGroupMailboxAdmin].UserCount != 0 {
|
||||
t.Fatalf("unexpected fixed permission groups: %+v", groups.Items)
|
||||
}
|
||||
|
||||
var errBody map[string]any
|
||||
var users struct {
|
||||
Items []AdminUser `json:"items"`
|
||||
}
|
||||
var customGroup PermissionGroup
|
||||
if code := admin.do("POST", "/api/admin/permission-groups", map[string]any{
|
||||
"name": "Mailbox Viewers",
|
||||
"description": "Can view mailboxes only",
|
||||
"permissions": []string{PermissionAdminOverview, PermissionMailboxesView},
|
||||
}, &customGroup); code != http.StatusCreated {
|
||||
t.Fatalf("custom permission group creation code=%d group=%+v", code, customGroup)
|
||||
}
|
||||
if customGroup.System || customGroup.ID == "" || !userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesView) || userHasPermission(&User{Role: "user", Permissions: customGroup.Permissions}, PermissionMailboxesCreate) {
|
||||
t.Fatalf("custom permission group permissions=%+v", customGroup)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/permission-groups/"+PermissionGroupMailboxAdmin, map[string]any{
|
||||
"name": "Changed",
|
||||
"description": "Should not change",
|
||||
"permissions": []string{PermissionMailboxesView},
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("system permission group update should be forbidden code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := admin.do("DELETE", "/api/admin/permission-groups/"+PermissionGroupMailboxAdmin, nil, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("system permission group delete should be forbidden code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "invalid-group@lanqin.local",
|
||||
"displayName": "Invalid Group",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{PermissionGroupSuperAdmin},
|
||||
}, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("assigning super admin group should be rejected code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
var mailboxUser AdminUser
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "mailbox-admin@lanqin.local",
|
||||
"displayName": "Mailbox Admin",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{PermissionGroupMailboxAdmin},
|
||||
}, &mailboxUser); code != http.StatusCreated {
|
||||
t.Fatalf("create mailbox admin user code=%d user=%+v", code, mailboxUser)
|
||||
}
|
||||
if mailboxUser.Role != "user" || len(mailboxUser.PermissionGroupIDs) != 1 || mailboxUser.PermissionGroupIDs[0] != PermissionGroupMailboxAdmin || !userHasPermission(&mailboxUser.User, PermissionMailboxesManage) || userHasPermission(&mailboxUser.User, PermissionSystemSettings) {
|
||||
t.Fatalf("mailbox admin authorization=%+v", mailboxUser.User)
|
||||
}
|
||||
|
||||
var customUser AdminUser
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "mailbox-viewer@lanqin.local",
|
||||
"displayName": "Mailbox Viewer",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{customGroup.ID},
|
||||
}, &customUser); code != http.StatusCreated {
|
||||
t.Fatalf("create custom group user code=%d user=%+v", code, customUser)
|
||||
}
|
||||
if !userHasPermission(&customUser.User, PermissionMailboxesView) || userHasPermission(&customUser.User, PermissionMailboxesCreate) {
|
||||
t.Fatalf("custom group user authorization=%+v", customUser.User)
|
||||
}
|
||||
if code := admin.do("DELETE", "/api/admin/permission-groups/"+customGroup.ID, nil, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("assigned custom permission group delete should be rejected code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
mailboxAdmin := &testClient{t: t, server: ts}
|
||||
if code := mailboxAdmin.do("POST", "/api/auth/login", map[string]string{"email": "mailbox-admin@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("mailbox admin login code=%d", code)
|
||||
}
|
||||
var mailboxList struct {
|
||||
Items []Mailbox `json:"items"`
|
||||
}
|
||||
if code := mailboxAdmin.do("GET", "/api/admin/mailboxes", nil, &mailboxList); code != http.StatusOK {
|
||||
t.Fatalf("mailbox admin should access mailboxes code=%d", code)
|
||||
}
|
||||
if code := mailboxAdmin.do("GET", "/api/admin/settings", nil, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("mailbox admin settings should be forbidden code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := mailboxAdmin.do("GET", "/api/admin/users", nil, &errBody); code != http.StatusOK {
|
||||
t.Fatalf("mailbox admin should read users for mailbox ownership code=%d body=%v", code, errBody)
|
||||
}
|
||||
viewer := &testClient{t: t, server: ts}
|
||||
if code := viewer.do("POST", "/api/auth/login", map[string]string{"email": "mailbox-viewer@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("mailbox viewer login code=%d", code)
|
||||
}
|
||||
if code := viewer.do("GET", "/api/admin/mailboxes", nil, &mailboxList); code != http.StatusOK {
|
||||
t.Fatalf("mailbox viewer should read mailboxes code=%d", code)
|
||||
}
|
||||
if code := viewer.do("POST", "/api/admin/mailboxes", map[string]any{
|
||||
"domainId": mustDefaultDomainID(t, a),
|
||||
"localPart": "blocked-create",
|
||||
"displayName": "Blocked Create",
|
||||
"password": "Password123!",
|
||||
"quotaMb": 1024,
|
||||
"role": "user",
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("mailbox viewer should not create mailboxes code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := mailboxAdmin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "blocked-by-mailbox-admin@lanqin.local",
|
||||
"displayName": "Blocked",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{PermissionGroupMailboxAdmin},
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("mailbox admin should not create users code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
var userManager AdminUser
|
||||
if code := admin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "user-admin@lanqin.local",
|
||||
"displayName": "User Admin",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{PermissionGroupUserAdmin},
|
||||
}, &userManager); code != http.StatusCreated {
|
||||
t.Fatalf("create user admin code=%d user=%+v", code, userManager)
|
||||
}
|
||||
userAdmin := &testClient{t: t, server: ts}
|
||||
if code := userAdmin.do("POST", "/api/auth/login", map[string]string{"email": "user-admin@lanqin.local", "password": "Password123!"}, &login); code != http.StatusOK {
|
||||
t.Fatalf("user admin login code=%d", code)
|
||||
}
|
||||
if code := userAdmin.do("GET", "/api/admin/users", nil, &users); code != http.StatusOK {
|
||||
t.Fatalf("user admin users code=%d body=%v", code, users)
|
||||
}
|
||||
if code := userAdmin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "delegated-mailbox@lanqin.local",
|
||||
"displayName": "Delegated Mailbox",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{PermissionGroupMailboxAdmin},
|
||||
}, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("user admin should not assign mailbox admin group code=%d body=%v", code, errBody)
|
||||
}
|
||||
var regularUser AdminUser
|
||||
if code := userAdmin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "delegated-user@lanqin.local",
|
||||
"displayName": "Delegated User",
|
||||
"role": "user",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{PermissionGroupUserAdmin},
|
||||
}, ®ularUser); code != http.StatusCreated {
|
||||
t.Fatalf("user admin should assign own group code=%d user=%+v", code, regularUser)
|
||||
}
|
||||
if code := userAdmin.do("POST", "/api/admin/users", map[string]any{
|
||||
"email": "delegated-super@lanqin.local",
|
||||
"displayName": "Delegated Super",
|
||||
"role": "admin",
|
||||
"password": "Password123!",
|
||||
"disabled": false,
|
||||
"permissionGroupIds": []string{},
|
||||
}, &errBody); code != http.StatusForbidden {
|
||||
t.Fatalf("user admin should not create super admin code=%d body=%v", code, errBody)
|
||||
}
|
||||
|
||||
if code := admin.do("GET", "/api/admin/users", nil, &users); code != http.StatusOK || len(users.Items) == 0 {
|
||||
t.Fatalf("admin users code=%d items=%d", code, len(users.Items))
|
||||
}
|
||||
var defaultAdmin AdminUser
|
||||
for _, user := range users.Items {
|
||||
if user.Email == "admin@lanqin.local" {
|
||||
defaultAdmin = user
|
||||
break
|
||||
}
|
||||
}
|
||||
if defaultAdmin.ID == "" || !defaultAdmin.Protected || defaultAdmin.Role != "admin" {
|
||||
t.Fatalf("default admin should be protected super admin: %+v", defaultAdmin.User)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+defaultAdmin.ID, map[string]any{
|
||||
"displayName": "LanQin Admin",
|
||||
"role": "user",
|
||||
"disabled": false,
|
||||
}, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("default admin downgrade should be rejected code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := admin.do("POST", "/api/admin/users/"+defaultAdmin.ID, map[string]any{
|
||||
"displayName": "LanQin Admin",
|
||||
"role": "admin",
|
||||
"disabled": true,
|
||||
}, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("default admin disable should be rejected code=%d body=%v", code, errBody)
|
||||
}
|
||||
if code := admin.do("DELETE", "/api/admin/users/"+defaultAdmin.ID, nil, &errBody); code != http.StatusBadRequest {
|
||||
t.Fatalf("default admin delete should be rejected code=%d body=%v", code, errBody)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaildirSyncImportsRFC822(t *testing.T) {
|
||||
a := newTestApp(t)
|
||||
ctx := context.Background()
|
||||
|
||||
@@ -0,0 +1,201 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/go-chi/chi/v5"
|
||||
)
|
||||
|
||||
func (a *App) handlePermissionCatalog(w http.ResponseWriter, r *http.Request) {
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": permissionCatalog()})
|
||||
}
|
||||
|
||||
func (a *App) handleListPermissionGroups(w http.ResponseWriter, r *http.Request) {
|
||||
rows, err := a.db.QueryContext(r.Context(), `SELECT id,name,description,permissions_json,system,created_at,updated_at
|
||||
FROM permission_groups
|
||||
ORDER BY created_at ASC,name ASC`)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
|
||||
return
|
||||
}
|
||||
|
||||
items := []PermissionGroup{}
|
||||
for rows.Next() {
|
||||
var item PermissionGroup
|
||||
var raw, created, updated string
|
||||
var system int
|
||||
if err := rows.Scan(&item.ID, &item.Name, &item.Description, &raw, &system, &created, &updated); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to scan permission groups")
|
||||
return
|
||||
}
|
||||
item.Permissions = decodeStoredPermissions(raw)
|
||||
item.System = intBool(system)
|
||||
item.CreatedAt = parseTime(created)
|
||||
item.UpdatedAt = parseTime(updated)
|
||||
items = append(items, item)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
rows.Close()
|
||||
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
|
||||
return
|
||||
}
|
||||
if err := rows.Close(); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to list permission groups")
|
||||
return
|
||||
}
|
||||
var adminCount, regularCount int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users WHERE role='admin'`).Scan(&adminCount); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to count users")
|
||||
return
|
||||
}
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM users u
|
||||
WHERE u.role<>'admin'
|
||||
AND NOT EXISTS (
|
||||
SELECT 1 FROM user_permission_groups upg
|
||||
WHERE upg.user_id=u.id AND upg.group_id NOT IN (?,?)
|
||||
)`, PermissionGroupSuperAdmin, PermissionGroupRegular,
|
||||
).Scan(®ularCount); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to count users")
|
||||
return
|
||||
}
|
||||
for i := range items {
|
||||
switch items[i].ID {
|
||||
case PermissionGroupSuperAdmin:
|
||||
items[i].UserCount = adminCount
|
||||
case PermissionGroupRegular:
|
||||
items[i].UserCount = regularCount
|
||||
default:
|
||||
var count int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_permission_groups WHERE group_id=?`, items[i].ID).Scan(&count); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to count users")
|
||||
return
|
||||
}
|
||||
items[i].UserCount = count
|
||||
}
|
||||
}
|
||||
sortPermissionGroups(items)
|
||||
respondJSON(w, http.StatusOK, map[string]any{"items": items, "catalog": permissionCatalog()})
|
||||
}
|
||||
|
||||
func (a *App) handleCreatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
badRequest(w, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
permissions, err := normalizePermissionList(req.Permissions)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if !actorCanGrantPermissions(currentUser(r), permissions) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
|
||||
return
|
||||
}
|
||||
id := newID("pg")
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
if _, err := a.db.ExecContext(r.Context(), `INSERT INTO permission_groups(id,name,description,permissions_json,system,created_at,updated_at)
|
||||
VALUES(?,?,?,?,0,?,?)`, id, name, strings.TrimSpace(req.Description), encodePermissions(permissions), now, now); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
group, err := a.permissionGroupByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load permission group")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusCreated, group)
|
||||
}
|
||||
|
||||
func (a *App) handleUpdatePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var existingSystem int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&existingSystem); err != nil {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
if intBool(existingSystem) {
|
||||
respondError(w, http.StatusForbidden, "system permission groups cannot be edited")
|
||||
return
|
||||
}
|
||||
var req struct {
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
}
|
||||
if err := decodeJSON(r, &req); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
name := strings.TrimSpace(req.Name)
|
||||
if name == "" {
|
||||
badRequest(w, errors.New("name is required"))
|
||||
return
|
||||
}
|
||||
permissions, err := normalizePermissionList(req.Permissions)
|
||||
if err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
if !actorCanGrantPermissions(currentUser(r), permissions) {
|
||||
respondError(w, http.StatusForbidden, "cannot grant permissions you do not hold")
|
||||
return
|
||||
}
|
||||
if _, err := a.db.ExecContext(r.Context(), `UPDATE permission_groups SET name=?,description=?,permissions_json=?,updated_at=? WHERE id=?`,
|
||||
name, strings.TrimSpace(req.Description), encodePermissions(permissions), a.now().UTC().Format(time.RFC3339Nano), id); err != nil {
|
||||
badRequest(w, err)
|
||||
return
|
||||
}
|
||||
group, err := a.permissionGroupByID(r.Context(), id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to load permission group")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, group)
|
||||
}
|
||||
|
||||
func (a *App) handleDeletePermissionGroup(w http.ResponseWriter, r *http.Request) {
|
||||
id := chi.URLParam(r, "id")
|
||||
var system int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT system FROM permission_groups WHERE id=?`, id).Scan(&system); err != nil {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
if intBool(system) {
|
||||
respondError(w, http.StatusForbidden, "system permission groups cannot be deleted")
|
||||
return
|
||||
}
|
||||
var userCount int
|
||||
if err := a.db.QueryRowContext(r.Context(), `SELECT COUNT(*) FROM user_permission_groups WHERE group_id=?`, id).Scan(&userCount); err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to check permission group")
|
||||
return
|
||||
}
|
||||
if userCount > 0 {
|
||||
badRequest(w, errors.New("permission group is assigned to users"))
|
||||
return
|
||||
}
|
||||
res, err := a.db.ExecContext(r.Context(), `DELETE FROM permission_groups WHERE id=?`, id)
|
||||
if err != nil {
|
||||
respondError(w, http.StatusInternalServerError, "failed to delete permission group")
|
||||
return
|
||||
}
|
||||
affected, _ := res.RowsAffected()
|
||||
if affected == 0 {
|
||||
respondError(w, http.StatusNotFound, "permission group not found")
|
||||
return
|
||||
}
|
||||
respondJSON(w, http.StatusOK, map[string]any{"ok": true})
|
||||
}
|
||||
@@ -0,0 +1,731 @@
|
||||
package app
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionAdminOverview = "admin.overview.view"
|
||||
|
||||
PermissionUsersView = "admin.users.view"
|
||||
PermissionUsersCreate = "admin.users.create"
|
||||
PermissionUsersUpdate = "admin.users.update"
|
||||
PermissionUsersDelete = "admin.users.delete"
|
||||
PermissionUsersResetPassword = "admin.users.reset_password"
|
||||
|
||||
PermissionGroupsView = "admin.permission_groups.view"
|
||||
PermissionGroupsCreate = "admin.permission_groups.create"
|
||||
PermissionGroupsUpdate = "admin.permission_groups.update"
|
||||
PermissionGroupsDelete = "admin.permission_groups.delete"
|
||||
|
||||
PermissionDomainsView = "admin.domains.view"
|
||||
PermissionDomainsCreate = "admin.domains.create"
|
||||
PermissionDomainsUpdate = "admin.domains.update"
|
||||
PermissionDomainsDelete = "admin.domains.delete"
|
||||
|
||||
PermissionDNSView = "admin.dns.view"
|
||||
PermissionDNSCheck = "admin.dns.check"
|
||||
|
||||
PermissionMailboxesView = "admin.mailboxes.view"
|
||||
PermissionMailboxesCreate = "admin.mailboxes.create"
|
||||
PermissionMailboxesUpdate = "admin.mailboxes.update"
|
||||
PermissionMailboxesDelete = "admin.mailboxes.delete"
|
||||
|
||||
PermissionAliasesView = "admin.aliases.view"
|
||||
PermissionAliasesCreate = "admin.aliases.create"
|
||||
PermissionAliasesUpdate = "admin.aliases.update"
|
||||
PermissionAliasesDelete = "admin.aliases.delete"
|
||||
|
||||
PermissionMessagesView = "admin.messages.view"
|
||||
PermissionMessagesRead = "admin.messages.read"
|
||||
PermissionMessagesAttachment = "admin.messages.attachments"
|
||||
|
||||
PermissionSettingsView = "admin.settings.view"
|
||||
PermissionSettingsUpdate = "admin.settings.update"
|
||||
PermissionSettingsTestSMTP = "admin.settings.test_smtp"
|
||||
|
||||
PermissionTemplatesView = "admin.templates.view"
|
||||
PermissionTemplatesUpdate = "admin.templates.update"
|
||||
PermissionTemplatesReset = "admin.templates.reset"
|
||||
|
||||
PermissionUsersManage = PermissionUsersUpdate
|
||||
PermissionGroupsManage = PermissionGroupsUpdate
|
||||
PermissionDomainsManage = PermissionDomainsUpdate
|
||||
PermissionDNSManage = PermissionDNSCheck
|
||||
PermissionMailboxesManage = PermissionMailboxesUpdate
|
||||
PermissionAliasesManage = PermissionAliasesUpdate
|
||||
PermissionSystemSettings = PermissionSettingsUpdate
|
||||
)
|
||||
|
||||
const (
|
||||
PermissionGroupSuperAdmin = "pg_super_admin"
|
||||
PermissionGroupRegular = "pg_regular_user"
|
||||
PermissionGroupUserAdmin = "pg_user_admin"
|
||||
PermissionGroupPermissionAdmin = "pg_permission_group_admin"
|
||||
PermissionGroupDomainAdmin = "pg_domain_admin"
|
||||
PermissionGroupDNSAdmin = "pg_dns_admin"
|
||||
PermissionGroupMailboxAdmin = "pg_mailbox_admin"
|
||||
PermissionGroupAliasAdmin = "pg_alias_admin"
|
||||
PermissionGroupMessageAudit = "pg_message_audit"
|
||||
PermissionGroupSystemAdmin = "pg_system_admin"
|
||||
)
|
||||
|
||||
type PermissionInfo struct {
|
||||
Key string `json:"key"`
|
||||
Label string `json:"label"`
|
||||
Description string `json:"description"`
|
||||
Category string `json:"category"`
|
||||
}
|
||||
|
||||
type PermissionGroupSummary struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
}
|
||||
|
||||
type PermissionGroup struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Description string `json:"description"`
|
||||
Permissions []string `json:"permissions"`
|
||||
System bool `json:"system"`
|
||||
UserCount int `json:"userCount"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
UpdatedAt time.Time `json:"updatedAt"`
|
||||
}
|
||||
|
||||
var legacyPermissionExpansions = map[string][]string{
|
||||
"admin.overview": {PermissionAdminOverview},
|
||||
"admin.users": {PermissionUsersView, PermissionUsersCreate, PermissionUsersUpdate, PermissionUsersDelete, PermissionUsersResetPassword, PermissionGroupsView},
|
||||
"admin.permission_groups": {PermissionGroupsView, PermissionGroupsCreate, PermissionGroupsUpdate, PermissionGroupsDelete},
|
||||
"admin.domains": {PermissionDomainsView, PermissionDomainsCreate, PermissionDomainsUpdate, PermissionDomainsDelete},
|
||||
"admin.dns": {PermissionDomainsView, PermissionDNSView, PermissionDNSCheck},
|
||||
"admin.mailboxes": {PermissionUsersView, PermissionDomainsView, PermissionMailboxesView, PermissionMailboxesCreate, PermissionMailboxesUpdate, PermissionMailboxesDelete},
|
||||
"admin.aliases": {PermissionDomainsView, PermissionAliasesView, PermissionAliasesCreate, PermissionAliasesUpdate, PermissionAliasesDelete},
|
||||
"admin.messages": {PermissionMailboxesView, PermissionMessagesView, PermissionMessagesRead, PermissionMessagesAttachment},
|
||||
"admin.settings": {PermissionDomainsView, PermissionSettingsView, PermissionSettingsUpdate, PermissionSettingsTestSMTP, PermissionTemplatesView, PermissionTemplatesUpdate, PermissionTemplatesReset},
|
||||
}
|
||||
|
||||
var permissionCatalogItems = []PermissionInfo{
|
||||
{Key: PermissionAdminOverview, Label: "查看概览", Description: "查看后台统计和首次配置检查。", Category: "概览"},
|
||||
|
||||
{Key: PermissionUsersView, Label: "查看用户", Description: "查看用户列表、状态和绑定邮箱。", Category: "用户"},
|
||||
{Key: PermissionUsersCreate, Label: "创建用户", Description: "创建普通用户并分配权限组。", Category: "用户"},
|
||||
{Key: PermissionUsersUpdate, Label: "编辑用户", Description: "修改用户显示名称、状态和权限组。", Category: "用户"},
|
||||
{Key: PermissionUsersDelete, Label: "删除用户", Description: "删除非受保护用户。", Category: "用户"},
|
||||
{Key: PermissionUsersResetPassword, Label: "重置用户密码", Description: "为用户重置登录密码。", Category: "用户"},
|
||||
|
||||
{Key: PermissionGroupsView, Label: "查看权限组", Description: "查看权限组、权限目录和使用人数。", Category: "权限组"},
|
||||
{Key: PermissionGroupsCreate, Label: "创建权限组", Description: "创建自定义权限组。", Category: "权限组"},
|
||||
{Key: PermissionGroupsUpdate, Label: "编辑权限组", Description: "修改自定义权限组名称、说明和权限。", Category: "权限组"},
|
||||
{Key: PermissionGroupsDelete, Label: "删除权限组", Description: "删除未被用户使用的自定义权限组。", Category: "权限组"},
|
||||
|
||||
{Key: PermissionDomainsView, Label: "查看域名", Description: "查看邮件域名和 DKIM 配置。", Category: "域名"},
|
||||
{Key: PermissionDomainsCreate, Label: "添加域名", Description: "添加新的邮件域名。", Category: "域名"},
|
||||
{Key: PermissionDomainsUpdate, Label: "启停域名", Description: "启用或停用邮件域名。", Category: "域名"},
|
||||
{Key: PermissionDomainsDelete, Label: "删除域名", Description: "删除未被邮箱使用的域名。", Category: "域名"},
|
||||
|
||||
{Key: PermissionDNSView, Label: "查看 DNS", Description: "查看域名需要配置的 DNS 记录。", Category: "DNS"},
|
||||
{Key: PermissionDNSCheck, Label: "执行 DNS 检测", Description: "触发 MX、SPF、DKIM、DMARC 检测。", Category: "DNS"},
|
||||
|
||||
{Key: PermissionMailboxesView, Label: "查看邮箱账号", Description: "查看邮箱账号列表和归属用户。", Category: "邮箱账号"},
|
||||
{Key: PermissionMailboxesCreate, Label: "创建邮箱账号", Description: "创建邮箱账号并准备归属用户。", Category: "邮箱账号"},
|
||||
{Key: PermissionMailboxesUpdate, Label: "编辑邮箱账号", Description: "修改邮箱归属、显示名、配额和状态。", Category: "邮箱账号"},
|
||||
{Key: PermissionMailboxesDelete, Label: "删除邮箱账号", Description: "删除邮箱账号及关联邮件文件。", Category: "邮箱账号"},
|
||||
|
||||
{Key: PermissionAliasesView, Label: "查看别名转发", Description: "查看别名转发规则。", Category: "别名转发"},
|
||||
{Key: PermissionAliasesCreate, Label: "创建别名转发", Description: "创建新的别名转发。", Category: "别名转发"},
|
||||
{Key: PermissionAliasesUpdate, Label: "编辑别名转发", Description: "修改别名转发来源、目标和启用状态。", Category: "别名转发"},
|
||||
{Key: PermissionAliasesDelete, Label: "删除别名转发", Description: "删除别名转发规则。", Category: "别名转发"},
|
||||
|
||||
{Key: PermissionMessagesView, Label: "查看邮件列表", Description: "查看全局邮件列表和搜索结果。", Category: "邮件审计"},
|
||||
{Key: PermissionMessagesRead, Label: "查看邮件正文", Description: "查看任意邮箱及未注册收件人的邮件正文。", Category: "邮件审计"},
|
||||
{Key: PermissionMessagesAttachment, Label: "下载邮件附件", Description: "下载全局邮件中的附件。", Category: "邮件审计"},
|
||||
|
||||
{Key: PermissionSettingsView, Label: "查看系统设置", Description: "查看系统、SMTP、安全和邮件设置。", Category: "系统设置"},
|
||||
{Key: PermissionSettingsUpdate, Label: "修改系统设置", Description: "保存系统、SMTP、安全和邮件设置。", Category: "系统设置"},
|
||||
{Key: PermissionSettingsTestSMTP, Label: "测试 SMTP", Description: "发送 SMTP 测试邮件。", Category: "系统设置"},
|
||||
{Key: PermissionTemplatesView, Label: "查看邮件模板", Description: "查看系统邮件模板。", Category: "邮件模板"},
|
||||
{Key: PermissionTemplatesUpdate, Label: "编辑邮件模板", Description: "修改系统邮件模板内容。", Category: "邮件模板"},
|
||||
{Key: PermissionTemplatesReset, Label: "恢复邮件模板", Description: "将系统邮件模板恢复默认。", Category: "邮件模板"},
|
||||
}
|
||||
|
||||
func permissionCatalog() []PermissionInfo {
|
||||
out := make([]PermissionInfo, len(permissionCatalogItems))
|
||||
copy(out, permissionCatalogItems)
|
||||
return out
|
||||
}
|
||||
|
||||
func allPermissionKeys() []string {
|
||||
out := make([]string, 0, len(permissionCatalogItems))
|
||||
for _, item := range permissionCatalogItems {
|
||||
out = append(out, item.Key)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func permissionSet() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, item := range permissionCatalogItems {
|
||||
out[item.Key] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func addPermissionToSet(item string, seen map[string]bool) bool {
|
||||
item = strings.TrimSpace(item)
|
||||
if item == "" {
|
||||
return true
|
||||
}
|
||||
if permissionSet()[item] {
|
||||
seen[item] = true
|
||||
return true
|
||||
}
|
||||
if expanded, ok := legacyPermissionExpansions[item]; ok {
|
||||
for _, permission := range expanded {
|
||||
seen[permission] = true
|
||||
}
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func normalizePermissionList(items []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
for _, item := range items {
|
||||
item = strings.TrimSpace(item)
|
||||
if !addPermissionToSet(item, seen) {
|
||||
return nil, fmt.Errorf("invalid permission: %s", item)
|
||||
}
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for _, item := range allPermissionKeys() {
|
||||
if seen[item] {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func decodeStoredPermissions(value string) []string {
|
||||
var raw []string
|
||||
if err := json.Unmarshal([]byte(value), &raw); err != nil {
|
||||
return nil
|
||||
}
|
||||
seen := map[string]bool{}
|
||||
for _, item := range raw {
|
||||
addPermissionToSet(item, seen)
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for _, item := range allPermissionKeys() {
|
||||
if seen[item] {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func encodePermissions(items []string) string {
|
||||
normalized, err := normalizePermissionList(items)
|
||||
if err != nil {
|
||||
normalized = nil
|
||||
}
|
||||
data, _ := json.Marshal(normalized)
|
||||
return string(data)
|
||||
}
|
||||
|
||||
func defaultPermissionGroups() []PermissionGroup {
|
||||
return []PermissionGroup{
|
||||
{
|
||||
ID: PermissionGroupSuperAdmin,
|
||||
Name: "超级管理员",
|
||||
Description: "拥有全部后台权限,由用户身份决定,不通过权限组分配。",
|
||||
Permissions: allPermissionKeys(),
|
||||
System: true,
|
||||
},
|
||||
{
|
||||
ID: PermissionGroupRegular,
|
||||
Name: "普通用户",
|
||||
Description: "仅可使用自己的邮箱功能,不包含后台权限。",
|
||||
Permissions: []string{},
|
||||
System: true,
|
||||
},
|
||||
{
|
||||
ID: PermissionGroupUserAdmin,
|
||||
Name: "用户管理员",
|
||||
Description: "查看并管理用户账号、状态、密码和用户权限组分配。",
|
||||
Permissions: []string{
|
||||
PermissionAdminOverview,
|
||||
PermissionUsersView,
|
||||
PermissionUsersCreate,
|
||||
PermissionUsersUpdate,
|
||||
PermissionUsersDelete,
|
||||
PermissionUsersResetPassword,
|
||||
PermissionGroupsView,
|
||||
},
|
||||
System: true,
|
||||
},
|
||||
{
|
||||
ID: PermissionGroupPermissionAdmin,
|
||||
Name: "权限组管理员",
|
||||
Description: "管理自定义权限组和权限目录。",
|
||||
Permissions: []string{
|
||||
PermissionAdminOverview,
|
||||
PermissionGroupsView,
|
||||
PermissionGroupsCreate,
|
||||
PermissionGroupsUpdate,
|
||||
PermissionGroupsDelete,
|
||||
},
|
||||
System: true,
|
||||
},
|
||||
{
|
||||
ID: PermissionGroupDomainAdmin,
|
||||
Name: "域名管理员",
|
||||
Description: "查看、添加、启停和删除邮件域名。",
|
||||
Permissions: []string{
|
||||
PermissionAdminOverview,
|
||||
PermissionDomainsView,
|
||||
PermissionDomainsCreate,
|
||||
PermissionDomainsUpdate,
|
||||
PermissionDomainsDelete,
|
||||
},
|
||||
System: true,
|
||||
},
|
||||
{
|
||||
ID: PermissionGroupDNSAdmin,
|
||||
Name: "DNS 检测员",
|
||||
Description: "查看 DNS 记录并执行 DNS 检测。",
|
||||
Permissions: []string{
|
||||
PermissionAdminOverview,
|
||||
PermissionDomainsView,
|
||||
PermissionDNSView,
|
||||
PermissionDNSCheck,
|
||||
},
|
||||
System: true,
|
||||
},
|
||||
{
|
||||
ID: PermissionGroupMailboxAdmin,
|
||||
Name: "邮箱账号管理员",
|
||||
Description: "查看用户和域名,创建、编辑和删除邮箱账号。",
|
||||
Permissions: []string{
|
||||
PermissionAdminOverview,
|
||||
PermissionUsersView,
|
||||
PermissionDomainsView,
|
||||
PermissionMailboxesView,
|
||||
PermissionMailboxesCreate,
|
||||
PermissionMailboxesUpdate,
|
||||
PermissionMailboxesDelete,
|
||||
},
|
||||
System: true,
|
||||
},
|
||||
{
|
||||
ID: PermissionGroupAliasAdmin,
|
||||
Name: "别名转发管理员",
|
||||
Description: "查看域名,创建、编辑和删除别名转发。",
|
||||
Permissions: []string{
|
||||
PermissionAdminOverview,
|
||||
PermissionDomainsView,
|
||||
PermissionAliasesView,
|
||||
PermissionAliasesCreate,
|
||||
PermissionAliasesUpdate,
|
||||
PermissionAliasesDelete,
|
||||
},
|
||||
System: true,
|
||||
},
|
||||
{
|
||||
ID: PermissionGroupMessageAudit,
|
||||
Name: "邮件审计员",
|
||||
Description: "查看全局邮件列表、正文和附件。",
|
||||
Permissions: []string{
|
||||
PermissionAdminOverview,
|
||||
PermissionMailboxesView,
|
||||
PermissionMessagesView,
|
||||
PermissionMessagesRead,
|
||||
PermissionMessagesAttachment,
|
||||
},
|
||||
System: true,
|
||||
},
|
||||
{
|
||||
ID: PermissionGroupSystemAdmin,
|
||||
Name: "系统设置管理员",
|
||||
Description: "查看并修改系统设置、SMTP 测试和邮件模板。",
|
||||
Permissions: []string{
|
||||
PermissionAdminOverview,
|
||||
PermissionDomainsView,
|
||||
PermissionSettingsView,
|
||||
PermissionSettingsUpdate,
|
||||
PermissionSettingsTestSMTP,
|
||||
PermissionTemplatesView,
|
||||
PermissionTemplatesUpdate,
|
||||
PermissionTemplatesReset,
|
||||
},
|
||||
System: true,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func fixedPermissionGroupIDs() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
for _, group := range defaultPermissionGroups() {
|
||||
out[group.ID] = true
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func assignablePermissionGroupIDs() map[string]bool {
|
||||
out := fixedPermissionGroupIDs()
|
||||
delete(out, PermissionGroupSuperAdmin)
|
||||
delete(out, PermissionGroupRegular)
|
||||
return out
|
||||
}
|
||||
|
||||
func isAssignablePermissionGroupID(groupID string) bool {
|
||||
return groupID != "" && groupID != PermissionGroupSuperAdmin && groupID != PermissionGroupRegular
|
||||
}
|
||||
|
||||
func permissionGroupOrder() map[string]int {
|
||||
out := map[string]int{}
|
||||
for index, group := range defaultPermissionGroups() {
|
||||
out[group.ID] = index
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func permissionGroupNames() map[string]string {
|
||||
out := map[string]string{}
|
||||
for _, group := range defaultPermissionGroups() {
|
||||
out[group.ID] = group.Name
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (a *App) ensureDefaultPermissionGroups(ctx context.Context) error {
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, item := range defaultPermissionGroups() {
|
||||
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
|
||||
}
|
||||
if _, err := a.db.ExecContext(ctx, `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, 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 {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) attachUserAuthorization(ctx context.Context, u *User) error {
|
||||
if u == nil {
|
||||
return nil
|
||||
}
|
||||
permissions, err := a.permissionsForUser(ctx, u.ID, u.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
groupIDs, groups, err := a.permissionGroupsForUser(ctx, u.ID, u.Role)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
u.Permissions = permissions
|
||||
u.PermissionGroupIDs = groupIDs
|
||||
u.PermissionGroups = groups
|
||||
u.Protected = a.isDefaultAdminUser(u)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) permissionsForUser(ctx context.Context, userID, role string) ([]string, error) {
|
||||
if role == "admin" {
|
||||
return allPermissionKeys(), nil
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT pg.id,pg.permissions_json
|
||||
FROM permission_groups pg
|
||||
JOIN user_permission_groups upg ON upg.group_id=pg.id
|
||||
WHERE upg.user_id=?`, userID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
seen := map[string]bool{}
|
||||
for rows.Next() {
|
||||
var groupID, raw string
|
||||
if err := rows.Scan(&groupID, &raw); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !isAssignablePermissionGroupID(groupID) {
|
||||
continue
|
||||
}
|
||||
for _, permission := range decodeStoredPermissions(raw) {
|
||||
seen[permission] = true
|
||||
}
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]string, 0, len(seen))
|
||||
for _, item := range allPermissionKeys() {
|
||||
if seen[item] {
|
||||
out = append(out, item)
|
||||
}
|
||||
}
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func (a *App) permissionGroupsForUser(ctx context.Context, userID, role string) ([]string, []PermissionGroupSummary, error) {
|
||||
if role == "admin" {
|
||||
group := PermissionGroupSummary{ID: PermissionGroupSuperAdmin, Name: "超级管理员"}
|
||||
return []string{group.ID}, []PermissionGroupSummary{group}, nil
|
||||
}
|
||||
rows, err := a.db.QueryContext(ctx, `SELECT pg.id,pg.name
|
||||
FROM permission_groups pg
|
||||
JOIN user_permission_groups upg ON upg.group_id=pg.id
|
||||
WHERE upg.user_id=?`, userID)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
order := permissionGroupOrder()
|
||||
ids := []string{}
|
||||
groups := []PermissionGroupSummary{}
|
||||
for rows.Next() {
|
||||
var group PermissionGroupSummary
|
||||
if err := rows.Scan(&group.ID, &group.Name); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
if !isAssignablePermissionGroupID(group.ID) {
|
||||
continue
|
||||
}
|
||||
ids = append(ids, group.ID)
|
||||
groups = append(groups, group)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
sort.SliceStable(groups, func(i, j int) bool {
|
||||
left, leftOK := order[groups[i].ID]
|
||||
right, rightOK := order[groups[j].ID]
|
||||
if leftOK && rightOK {
|
||||
return left < right
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
return strings.ToLower(groups[i].Name) < strings.ToLower(groups[j].Name)
|
||||
})
|
||||
sort.SliceStable(ids, func(i, j int) bool {
|
||||
left, leftOK := order[ids[i]]
|
||||
right, rightOK := order[ids[j]]
|
||||
if leftOK && rightOK {
|
||||
return left < right
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func userHasPermission(user *User, permission string) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
for _, item := range user.Permissions {
|
||||
if item == permission {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func userHasAnyPermission(user *User, permissions ...string) bool {
|
||||
if user == nil {
|
||||
return false
|
||||
}
|
||||
if user.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
for _, permission := range permissions {
|
||||
if userHasPermission(user, permission) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func userHasAdminAccess(user *User) bool {
|
||||
return userHasAnyPermission(user, allPermissionKeys()...)
|
||||
}
|
||||
|
||||
func (a *App) requireAdminAccess(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !userHasAdminAccess(currentUser(r)) {
|
||||
respondError(w, http.StatusForbidden, "admin permission required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) requirePermission(permission string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !userHasPermission(currentUser(r), permission) {
|
||||
respondError(w, http.StatusForbidden, "permission required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) requireAnyPermission(permissions ...string) func(http.Handler) http.Handler {
|
||||
return func(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if !userHasAnyPermission(currentUser(r), permissions...) {
|
||||
respondError(w, http.StatusForbidden, "permission required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func actorCanGrantPermissions(actor *User, permissions []string) bool {
|
||||
if actor == nil {
|
||||
return false
|
||||
}
|
||||
if actor.Role == "admin" {
|
||||
return true
|
||||
}
|
||||
for _, permission := range permissions {
|
||||
if !userHasPermission(actor, permission) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) permissionsForGroupIDs(ctx context.Context, tx *sql.Tx, groupIDs []string) ([]string, error) {
|
||||
seen := map[string]bool{}
|
||||
for _, groupID := range cleanIDList(groupIDs) {
|
||||
if !isAssignablePermissionGroupID(groupID) {
|
||||
return nil, fmt.Errorf("permission group not assignable: %s", groupID)
|
||||
}
|
||||
var raw string
|
||||
query := `SELECT permissions_json FROM permission_groups WHERE id=?`
|
||||
var err error
|
||||
if tx != nil {
|
||||
err = tx.QueryRowContext(ctx, query, groupID).Scan(&raw)
|
||||
} else {
|
||||
err = a.db.QueryRowContext(ctx, query, groupID).Scan(&raw)
|
||||
}
|
||||
if err != nil {
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return nil, fmt.Errorf("permission group not found: %s", groupID)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
for _, permission := range decodeStoredPermissions(raw) {
|
||||
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) setUserPermissionGroups(ctx context.Context, tx *sql.Tx, userID string, groupIDs []string, actor *User) error {
|
||||
groupIDs = cleanIDList(groupIDs)
|
||||
for _, groupID := range groupIDs {
|
||||
if !isAssignablePermissionGroupID(groupID) {
|
||||
return fmt.Errorf("permission group not assignable: %s", groupID)
|
||||
}
|
||||
}
|
||||
groupPermissions, err := a.permissionsForGroupIDs(ctx, tx, groupIDs)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !actorCanGrantPermissions(actor, groupPermissions) {
|
||||
return errors.New("cannot assign permissions you do not hold")
|
||||
}
|
||||
exec := func(query string, args ...any) error {
|
||||
var err error
|
||||
if tx != nil {
|
||||
_, err = tx.ExecContext(ctx, query, args...)
|
||||
} else {
|
||||
_, err = a.db.ExecContext(ctx, query, args...)
|
||||
}
|
||||
return err
|
||||
}
|
||||
if err := exec(`DELETE FROM user_permission_groups WHERE user_id=?`, userID); err != nil {
|
||||
return err
|
||||
}
|
||||
now := a.now().UTC().Format(time.RFC3339Nano)
|
||||
for _, groupID := range groupIDs {
|
||||
if err := exec(`INSERT INTO user_permission_groups(user_id,group_id,created_at) VALUES(?,?,?)`, userID, groupID, now); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) permissionGroupByID(ctx context.Context, id string) (*PermissionGroup, error) {
|
||||
row := a.db.QueryRowContext(ctx, `SELECT pg.id,pg.name,pg.description,pg.permissions_json,pg.system,pg.created_at,pg.updated_at,COUNT(upg.user_id)
|
||||
FROM permission_groups pg
|
||||
LEFT JOIN user_permission_groups upg ON upg.group_id=pg.id
|
||||
WHERE pg.id=?
|
||||
GROUP BY pg.id,pg.name,pg.description,pg.permissions_json,pg.system,pg.created_at,pg.updated_at`, id)
|
||||
var group PermissionGroup
|
||||
var raw, created, updated string
|
||||
var system int
|
||||
if err := row.Scan(&group.ID, &group.Name, &group.Description, &raw, &system, &created, &updated, &group.UserCount); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
group.Permissions = decodeStoredPermissions(raw)
|
||||
group.System = intBool(system)
|
||||
group.CreatedAt = parseTime(created)
|
||||
group.UpdatedAt = parseTime(updated)
|
||||
return &group, nil
|
||||
}
|
||||
|
||||
func (a *App) isDefaultAdminUser(u *User) bool {
|
||||
if u == nil {
|
||||
return false
|
||||
}
|
||||
adminEmail := normalizeEmail(a.cfg.AdminEmail)
|
||||
return adminEmail != "" && strings.EqualFold(normalizeEmail(u.Email), adminEmail)
|
||||
}
|
||||
|
||||
func sortPermissionGroups(items []PermissionGroup) {
|
||||
order := permissionGroupOrder()
|
||||
sort.SliceStable(items, func(i, j int) bool {
|
||||
left, leftOK := order[items[i].ID]
|
||||
right, rightOK := order[items[j].ID]
|
||||
if leftOK && rightOK {
|
||||
return left < right
|
||||
}
|
||||
if leftOK != rightOK {
|
||||
return leftOK
|
||||
}
|
||||
return strings.ToLower(items[i].Name) < strings.ToLower(items[j].Name)
|
||||
})
|
||||
}
|
||||
@@ -87,36 +87,41 @@ func (a *App) Router() http.Handler {
|
||||
|
||||
r.Group(func(r chi.Router) {
|
||||
r.Use(a.requireAuth)
|
||||
r.Use(a.requireAdmin)
|
||||
r.Get("/admin/overview", a.handleAdminOverview)
|
||||
r.Get("/admin/users", a.handleListUsers)
|
||||
r.Post("/admin/users", a.handleCreateUser)
|
||||
r.Post("/admin/users/{id}", a.handleUpdateUser)
|
||||
r.Post("/admin/users/{id}/password", a.handleResetUserPassword)
|
||||
r.Delete("/admin/users/{id}", a.handleDeleteUser)
|
||||
r.Get("/admin/domains", a.handleListDomains)
|
||||
r.Post("/admin/domains", a.handleCreateDomain)
|
||||
r.Post("/admin/domains/{id}", a.handleUpdateDomain)
|
||||
r.Delete("/admin/domains/{id}", a.handleDeleteDomain)
|
||||
r.Get("/admin/mailboxes", a.handleListMailboxes)
|
||||
r.Post("/admin/mailboxes", a.handleCreateMailbox)
|
||||
r.Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
|
||||
r.Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
|
||||
r.Get("/admin/aliases", a.handleListAliases)
|
||||
r.Post("/admin/aliases", a.handleCreateAlias)
|
||||
r.Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||
r.Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
||||
r.Get("/admin/messages", a.handleAdminMessages)
|
||||
r.Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||
r.Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||
r.Get("/admin/settings", a.handleGetSystemSettings)
|
||||
r.Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||
r.Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||
r.Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||
r.Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
||||
r.Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
||||
r.Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
|
||||
r.Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
|
||||
r.Use(a.requireAdminAccess)
|
||||
r.With(a.requirePermission(PermissionAdminOverview)).Get("/admin/overview", a.handleAdminOverview)
|
||||
r.With(a.requireAnyPermission(PermissionUsersView, PermissionMailboxesView)).Get("/admin/users", a.handleListUsers)
|
||||
r.With(a.requirePermission(PermissionUsersCreate)).Post("/admin/users", a.handleCreateUser)
|
||||
r.With(a.requirePermission(PermissionUsersUpdate)).Post("/admin/users/{id}", a.handleUpdateUser)
|
||||
r.With(a.requirePermission(PermissionUsersResetPassword)).Post("/admin/users/{id}/password", a.handleResetUserPassword)
|
||||
r.With(a.requirePermission(PermissionUsersDelete)).Delete("/admin/users/{id}", a.handleDeleteUser)
|
||||
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permissions", a.handlePermissionCatalog)
|
||||
r.With(a.requireAnyPermission(PermissionGroupsView, PermissionUsersView)).Get("/admin/permission-groups", a.handleListPermissionGroups)
|
||||
r.With(a.requirePermission(PermissionGroupsCreate)).Post("/admin/permission-groups", a.handleCreatePermissionGroup)
|
||||
r.With(a.requirePermission(PermissionGroupsUpdate)).Post("/admin/permission-groups/{id}", a.handleUpdatePermissionGroup)
|
||||
r.With(a.requirePermission(PermissionGroupsDelete)).Delete("/admin/permission-groups/{id}", a.handleDeletePermissionGroup)
|
||||
r.With(a.requireAnyPermission(PermissionDomainsView, PermissionDNSView, PermissionMailboxesView, PermissionAliasesView, PermissionSettingsView, PermissionTemplatesView)).Get("/admin/domains", a.handleListDomains)
|
||||
r.With(a.requirePermission(PermissionDomainsCreate)).Post("/admin/domains", a.handleCreateDomain)
|
||||
r.With(a.requirePermission(PermissionDomainsUpdate)).Post("/admin/domains/{id}", a.handleUpdateDomain)
|
||||
r.With(a.requirePermission(PermissionDomainsDelete)).Delete("/admin/domains/{id}", a.handleDeleteDomain)
|
||||
r.With(a.requireAnyPermission(PermissionMailboxesView, PermissionMessagesView)).Get("/admin/mailboxes", a.handleListMailboxes)
|
||||
r.With(a.requirePermission(PermissionMailboxesCreate)).Post("/admin/mailboxes", a.handleCreateMailbox)
|
||||
r.With(a.requirePermission(PermissionMailboxesUpdate)).Post("/admin/mailboxes/{id}", a.handleUpdateMailbox)
|
||||
r.With(a.requirePermission(PermissionMailboxesDelete)).Delete("/admin/mailboxes/{id}", a.handleDeleteMailbox)
|
||||
r.With(a.requirePermission(PermissionAliasesView)).Get("/admin/aliases", a.handleListAliases)
|
||||
r.With(a.requirePermission(PermissionAliasesCreate)).Post("/admin/aliases", a.handleCreateAlias)
|
||||
r.With(a.requirePermission(PermissionAliasesUpdate)).Post("/admin/aliases/{id}", a.handleUpdateAlias)
|
||||
r.With(a.requirePermission(PermissionAliasesDelete)).Delete("/admin/aliases/{id}", a.handleDeleteAlias)
|
||||
r.With(a.requirePermission(PermissionMessagesView)).Get("/admin/messages", a.handleAdminMessages)
|
||||
r.With(a.requirePermission(PermissionMessagesRead)).Get("/admin/messages/{id}", a.handleAdminMessage)
|
||||
r.With(a.requirePermission(PermissionMessagesAttachment)).Get("/admin/attachments/{id}", a.handleAdminAttachment)
|
||||
r.With(a.requirePermission(PermissionSettingsView)).Get("/admin/settings", a.handleGetSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsUpdate)).Post("/admin/settings", a.handleUpdateSystemSettings)
|
||||
r.With(a.requirePermission(PermissionSettingsTestSMTP)).Post("/admin/settings/test-smtp", a.handleTestSMTP)
|
||||
r.With(a.requirePermission(PermissionTemplatesView)).Get("/admin/mail-templates", a.handleListMailTemplates)
|
||||
r.With(a.requirePermission(PermissionTemplatesUpdate)).Post("/admin/mail-templates/{key}", a.handleUpdateMailTemplate)
|
||||
r.With(a.requirePermission(PermissionTemplatesReset)).Post("/admin/mail-templates/{key}/reset", a.handleResetMailTemplate)
|
||||
r.With(a.requirePermission(PermissionDNSView)).Get("/admin/domains/{id}/dns-records", a.handleDNSRecords)
|
||||
r.With(a.requirePermission(PermissionDNSCheck)).Post("/admin/domains/{id}/check-dns", a.handleDNSCheck)
|
||||
})
|
||||
})
|
||||
|
||||
@@ -152,17 +157,6 @@ func (a *App) requireAuth(next http.Handler) http.Handler {
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) requireAdmin(next http.Handler) http.Handler {
|
||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
user := currentUser(r)
|
||||
if user == nil || user.Role != "admin" {
|
||||
respondError(w, http.StatusForbidden, "admin role required")
|
||||
return
|
||||
}
|
||||
next.ServeHTTP(w, r)
|
||||
})
|
||||
}
|
||||
|
||||
func currentUser(r *http.Request) *User {
|
||||
user, _ := r.Context().Value(userContextKey).(*User)
|
||||
return user
|
||||
@@ -188,6 +182,9 @@ func (a *App) authenticateRequest(r *http.Request) (*User, error) {
|
||||
if u.Disabled {
|
||||
return nil, errors.New("disabled")
|
||||
}
|
||||
if err := a.attachUserAuthorization(r.Context(), &u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
@@ -206,6 +203,9 @@ func (a *App) userByEmail(ctx context.Context, email string) (*User, string, err
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &u, passwordHash, nil
|
||||
}
|
||||
|
||||
@@ -223,5 +223,8 @@ func (a *App) userByID(ctx context.Context, id string) (*User, error) {
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &u, nil
|
||||
}
|
||||
|
||||
@@ -135,6 +135,9 @@ func (a *App) loadUserAuthByID(ctx context.Context, id string) (*User, string, e
|
||||
u.Disabled = intBool(disabled)
|
||||
u.TwoFactorEnabled = intBool(twoFactorEnabled)
|
||||
u.CreatedAt = parseTime(created)
|
||||
if err := a.attachUserAuthorization(ctx, &u); err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
return &u, secret, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -3,13 +3,17 @@ package app
|
||||
import "time"
|
||||
|
||||
type User struct {
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
ID string `json:"id"`
|
||||
Email string `json:"email"`
|
||||
DisplayName string `json:"displayName"`
|
||||
Role string `json:"role"`
|
||||
Disabled bool `json:"disabled"`
|
||||
Protected bool `json:"protected"`
|
||||
TwoFactorEnabled bool `json:"twoFactorEnabled"`
|
||||
Permissions []string `json:"permissions"`
|
||||
PermissionGroupIDs []string `json:"permissionGroupIds"`
|
||||
PermissionGroups []PermissionGroupSummary `json:"permissionGroups"`
|
||||
CreatedAt time.Time `json:"createdAt"`
|
||||
}
|
||||
|
||||
type AdminUser struct {
|
||||
|
||||
Reference in New Issue
Block a user