权限管理:RBAC和ACL在XIAOJUSURVEY的应用

引言

随着互联网技术的发展,问卷系统在各个领域得到了广泛应用。

我参与的XIAOJUSURVEY的开源问卷系统项目,是一个很不错的问卷开源项目,前后端都开源,该项目包含B端和C端,服务端采用NestJS,前端采用Vue3。

在项目的迭代过程中,我们引入了协作和空间功能,以提升用户体验和功能扩展性。

作者:luch1994

本文将详细介绍这些功能的设计与实现方案。

设计与实现

在设计问卷系统时,我们引入了空间概念,以更好地管理和组织问卷。空间功能分为个人空间和团队空间,两者在使用场景和权限管理上有显著区别。

定义

  • 个人空间:用于存储个人创建的问卷。每个用户在个人空间中独立工作,创建的问卷默认是私有的,但可以通过协作功能共享给其他用户。

  • 团队空间:允许用户创建和管理团队。每个团队空间可以包含多个成员,成员在团队空间中协作管理问卷。

企业级系统往往涉及复杂的组织管理和数据隐私安全,引入空间的概念是为了做权限切割。

团队空间

第一期做的每个用户只能看到自己的问卷:

在此基础上拓展空间功能:

  • 个人创建的问卷归属到个人空间

  • 在空间下创建的问卷,归属于空间,可以添加空间用户

表结构调整:

1、新增两张表:

空间表、空间成员表,用于记录空间相关的信息

2、meta表

新增workspaceId字段用于记录问卷所属空间,为空则不属于任何空间

其中,空间的权限设计,采用了RBAC(基于角色的访问控制)模型,添加空间成员时给用户分配角色即可。权限点比较多,且便于继续拓展,采用此模型能够降低成员的管理成本,优化用户体验。

后续更多的业务场景可以自行拓展。

问卷协作功能的设计与实现

协作功能允许用户将个人空间下的问卷共享给其他用户,并为协作者分配不同的权限。

当权限系统体量小,用户直接对应具体功能点即可满足系统诉求时,可以考虑使用ACL模型作为参考。

协作权限的设计

我们设计了三种协作权限,以满足不同的协作需求:

  • 问卷管理:配置问卷的内容和设置。

  • 问卷数据管理:分析和查看问卷数据。

  • 问卷协作人管理:管理问卷的协作者。

协作的权限设计,我们采用了ACL模型,这种权限设计使得权限配置更加灵活,如果引入角色的概念,会产生7种角色,反而更加难理解和使用。

协作功能的技术实现

1、新增一个model

import { Entity, Column } from 'typeorm';
import { BaseEntity } from './base.entity';


@Entity({ name: 'collaborator' })
export class Collaborator extends BaseEntity {
  @Column()
  surveyId: string;


  @Column()
  userId: string;


  @Column('jsonb')
  permissions: Array<string>;
}

2、新增对collaborator操作的service,因为交互上我们是批量管理协作者,所以核心的功能是批量添加协作者、批量修改权限和批量删除这几个功能

import { Injectable } from '@nestjs/common';
import { Collaborator } from 'src/models/collaborator.entity';
import { InjectRepository } from '@nestjs/typeorm';
import { MongoRepository } from 'typeorm';
import { ObjectId } from 'mongodb';
import { Logger } from 'src/logger';


@Injectable()
export class CollaboratorService {
  constructor(
    @InjectRepository(Collaborator)
    private readonly collaboratorRepository: MongoRepository<Collaborator>,
    private readonly logger: Logger,
  ) {}
  ...
  async batchCreate({ surveyId, collaboratorList }) {
    const res = await this.collaboratorRepository.insertMany(
      collaboratorList.map((item) => {
        return {
          ...item,
          surveyId,
        };
      }),
    );
    return res;
  }


  async changeUserPermission({ userId, surveyId, permission }) {
    const updateRes = await this.collaboratorRepository.updateOne(
      {
        surveyId,
        userId,
      },
      {
        $set: {
          permission,
        },
      },
    );
    return updateRes;
  }
  
  async batchDelete({
    idList,
    neIdList,
    userIdList,
    surveyId,
  }: {
    idList?: Array<string>;
    neIdList?: Array<string>;
    userIdList?: Array<string>;
    surveyId: string;
  }) {
    const query: Record<string, any> = {
      surveyId,
      $or: [],
    };


    if (Array.isArray(userIdList) && userIdList.length > 0) {
      query.$or.push({
        userId: {
          $in: userIdList,
        },
      });
    }


    if (
      (Array.isArray(idList) && idList.length > 0) ||
      (Array.isArray(neIdList) && neIdList.length > 0)
    ) {
      const idQuery: Record<string, any> = {
        _id: {},
      };
      if (idList && idList.length > 0) {
        idQuery._id.$in = idList.map((item) => new ObjectId(item));
      }
      if (neIdList && neIdList.length > 0) {
        idQuery._id.$nin = neIdList.map((item) => new ObjectId(item));
      }
      query.$or.push(idQuery);
    }
    this.logger.info(JSON.stringify(query));
    const delRes = await this.collaboratorRepository.deleteMany(query);
    return delRes;
  }




  updateById({ collaboratorId, permissions }) {
    return this.collaboratorRepository.updateOne(
      {
        _id: new ObjectId(collaboratorId),
      },
      {
        $set: {
          permissions,
        },
      },
    );
  }
  ...
}

权限控制的设计与实现

通过权限守卫(Guard)来校验用户权限,确保用户只能进行授权范围内的操作。

我们设计了两个守卫:空间守卫(WorkspaceGuard)和问卷守卫(SurveyGuard),并借助nestjs提供的装饰器@SetMetadata来给接口配置权限。

空间守卫

具体实现如下:

import { Injectable, CanActivate, ExecutionContext } from '@nestjs/common';
import { Reflector } from '@nestjs/core';
import { get } from 'lodash';


import { NoPermissionException } from '../exceptions/noPermissionException';


import { WorkspaceMemberService } from 'src/modules/workspace/services/workspaceMember.service';
import { ROLE_PERMISSION as WORKSPACE_ROLE_PERMISSION } from 'src/enums/workspace';


@Injectable()
export class WorkspaceGuard implements CanActivate {
  constructor(
    private reflector: Reflector,
    private readonly workspaceMemberService: WorkspaceMemberService,
  ) {}


  async canActivate(context: ExecutionContext): Promise<boolean> {
    const allowPermissions = this.reflector.get<string[]>(
      'workspacePermissions',
      context.getHandler(),
    );


    if (!allowPermissions) {
      return true;
    }


    const request = context.switchToHttp().getRequest();
    const user = request.user;
    const workspaceIdInfo = this.reflector.get(
      'workspaceId',
      context.getHandler(),
    );


    let workspaceIdKey, optional;
    if (typeof workspaceIdInfo === 'string') {
      workspaceIdKey = workspaceIdInfo;
      optional = false;
    } else {
      workspaceIdKey = workspaceIdInfo?.key;
      optional = workspaceIdInfo?.optional || false;
    }


    const workspaceId = get(request, workspaceIdKey);


    if (!workspaceId && optional === false) {
      throw new NoPermissionException('没有空间权限');
    }


    if (workspaceId) {
      const membersInfo = await this.workspaceMemberService.findOne({
        workspaceId,
        userId: user._id.toString(),
      });


      if (!membersInfo) {
        throw new NoPermissionException('没有空间权限');
      }


      const userPermissions = WORKSPACE_ROLE_PERMISSION[membersInfo.role] || [];
      if (
        allowPermissions.some((permission) =>
          userPermissions.includes(permission),
        )
      ) {
        return true;
      }
      throw new NoPermissionException('没有权限');
    }


    return true;
  }
}

在接口配置守卫:

@Post(':id')
@HttpCode(200)
@UseGuards(WorkspaceGuard)
@SetMetadata('workspacePermissions', [WORKSPACE_PERMISSION.WRITE_WORKSPACE])
@SetMetadata('workspaceId', 'params.id')
async update(@Param('id') id: string, @Body() workspace: CreateWorkspaceDto) {
  ....
}

问卷守卫

问卷守卫的代码相对比较多,大家有兴趣可以查看工程:GitHub - didi/xiaoju-survey: 「快速」打造「专属」问卷系统, 让调研「更轻松」

数据隔离方案

我们上线空间和协作功能后,更多的是对问卷的配置管理做了权限控制,但是我们的回收数据实际上更重要,我们可以把回收数据理解成资产,对于一个SaaS化的产品来说,资产是需要进行隔离的,以保障安全性,不同空间下的问卷,回收数据需要进行隔离。

数据库设计方案

数据隔离有几个方案:

  • 数据库表隔离:每个租户使用独立的数据库表,简化权限管理。此方案实现简单,对当前代码的改动也小,我们后续开源计划也是使用此方案。

  • 数据库的隔离:每个租户使用独立的数据库,确保数据隔离性和安全性。此方案稍微复杂,每创建一个空间,需要手动或者自动给改空间分配一个数据库,如果没有现成的数据库,还需要申请或创建数据库,并和空间进行关联,改动相对较大。

  • 数据库集群与分区策略:通过数据库集群和分区提高系统性能和扩展性。此方案更加复杂,如果是有比较成熟的商业化方案,可以考虑此方案,本文暂不考虑此方案。

实现

数据隔离作为迭代的Feature进行建设,也欢迎大家认领:server侧系统优化 — 空间数据隔离优化:不同空间进行数据表隔离

结尾

本文介绍了问卷系统的协作和空间功能设计与实现,希望能够给大家带来一些有价值的参考和启发,欢迎大家一起讨论反馈。

关于我们

感谢看到最后,我们是一个多元、包容的社区,我们已有非常多的小伙伴在共建,欢迎你的加入。

Github:XIAOJUSURVEY

社区交流群

微信:

Star

开源不易,请star 一下 ❤️❤️❤️,你的支持是我们最大的动力。
​​​​

  • 10
    点赞
  • 5
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值