DDD วิธีแยกสิทธิ์และบทบาทอย่างเหมาะสม

สมมติว่าฉันมีแอปพลิเคชันที่อนุญาตให้ผู้ใช้สร้าง มอบหมาย และแก้ไขงานได้ แอปพลิเคชันนี้สร้างขึ้นเพื่อรองรับบัญชีลูกค้าหลายบัญชี แอปพลิเคชันนี้มีบทบาทที่หลากหลาย แต่เราจะมุ่งเน้นไปที่สามบทบาทและการอนุญาตที่เกี่ยวข้องกับการดำเนินการและทรัพยากรเฉพาะ (การอัปเดตข้อความในงาน) - ผู้ดูแลระบบ (มีสิทธิ์ในการอัปเดตข้อความในงานทั้งหมดในทุกบัญชี) ผู้ดูแลบัญชี (มีสิทธิ์ในการอัปเดตข้อความในงานใด ๆ ที่เป็นของบัญชีของพวกเขา แต่อาจเป็นของหลายบัญชี) และผู้ใช้บัญชี (มีสิทธิ์ในการอัปเดตข้อความในงานเฉพาะในกรณีที่งานนั้นได้รับมอบหมายให้พวกเขา)

ตัวอย่างนี้ค่อนข้างซับซ้อนและชื่อบทบาทก็ดูกว้างเกินไปนิดหน่อย แต่ก็ต้องทนกับฉัน

เป้าหมายที่นี่คือพยายามค้นหาวิธีที่เรียบร้อยในการแยกบทบาทและการอนุญาต แต่ดูเหมือนว่าบทบาทจะเชื่อมโยงกับการอนุญาตอย่างหลีกเลี่ยงไม่ได้ (ดูโค้ดด้านล่าง)

บางทีการอนุญาตควรเป็น task:updateText แต่ฉันจะตรวจสอบบทบาทได้อย่างไร ฉันจะย้ายบล็อก switch (actor.type) ของฉันในรูปแบบโดเมนไปยังบริการโดเมนและตรวจสอบว่าผู้ใช้เชื่อมโยงกับผู้ดูแลระบบ ผู้ดูแลบัญชี หรือผู้ใช้บัญชีในบัญชีนั้นหรือไม่ ข้อมูลสามารถแคชได้ แต่ผู้ดูแลบัญชี (และผู้ใช้รายอื่น) สามารถเชื่อมโยงกับหลายบัญชีได้ ซึ่งหมายความว่าการโหลดข้อมูลนี้ล่วงหน้าอาจต้องใช้ข้อมูลมากเกินไปในบริบท และอาจเกิดปัญหาได้เนื่องจากข้อมูลนี้ถูกส่งผ่านระหว่างบริการต่างๆ

การตรวจสอบความเป็นเจ้าของ/การมอบหมายจะดำเนินการโดยเป็นส่วนหนึ่งของโดเมน เนื่องจากจะขึ้นอยู่กับสถานะปัจจุบันของโมเดล ไม่ครอบคลุมในที่นี้ แต่มีการใช้กลไกการกำหนดเวอร์ชันอย่างง่ายเพื่อให้แน่ใจว่าโมเดลจะไม่เปลี่ยนแปลงระหว่างเวลาที่ดึงข้อมูลและเมื่อมีการใช้การอัปเดต ดูเหมือนว่านโยบายอาจทำให้ตรรกะนี้สะอาดขึ้นเป็นอย่างน้อย แต่ถ้าฉันจะย้ายตรรกะนี้ออกไปเป็นนโยบาย ฉันไม่แน่ใจว่าฉันจะรับประกันต่อไปได้อย่างไร เว้นแต่ว่านโยบายและวิธีการบริการจะมีวิธีรับประกันว่าพวกเขาแชร์เวอร์ชันเดียวกัน ของทรัพยากร

ฉันมีตัวเลือกอะไรบ้างที่นี่? คำแนะนำใด ๆ ที่จะได้รับการชื่นชมมาก

class TaskApplicationService {
  constructor(private taskRepository: TaskRepository) { }
  async updateText({ taskId, text, accountId, context }: { taskId: string, text: string, accountId?: string, context: Context }) {
    let actor: Actor;
    const userId = context.user.id;
    // permissions follow pattern resource:action:qualifier
    if (await hasPermission('task:updateText:all')) {
      actor = await anAdmin({ userId });
    } else if (await hasPermission('task:updateText:account')) {
      actor = await anAccountAdmin({ accountId, userId });
    } else if (await hasPermission('task.updateText:assigned')) {
      actor = await anAccountUser({ accountId, userId });
    } else {
      throw new Error('not authorized');
    }

    const task = await this.taskRepository.findOne({ taskId });
    task.updateText({ text, actor });
    await this.taskRepository.save(task);
    // return TaskMapper.toDto(task);
  }
}

class TaskDomainModel {
  private props: {
    text: string,
    accountId: string,
    assignedAccountUserId: string;
  };

  get text(): string {
    return this.props.text;
  }

  updateText({ text, actor }: { text: string, actor: Actor }) {
    switch (actor.type) {
      case ActorType.ADMIN:
        break;
      case ActorType.ACCOUNT_ADMIN:
        assert(this.props.accountId === actor.tenantId);
        break;
      case ActorType.ACCOUNT_USER:
        assert(this.props.accountId === actor.tenantId);
        assert(this.props.assignedAccountUserId === actor.tenantUserId);
        break;
      default:
        // note assertions and throwing errors are here for brevity,
        // but normally would use something similar to this:
        // https://khalilstemmler.com/articles/enterprise-typescript-nodejs/handling-errors-result-class/
        throw new Error('unknown actor type');
    }

    this.props.text = text;
  }
}

// supporting cast

interface User {
  id: string;
}

interface Context {
  user: User;
}

enum ActorType {
  ADMIN,
  ACCOUNT_ADMIN,
  ACCOUNT_USER
}

interface Admin {
  type: ActorType.ADMIN,
  userId: string
}

interface AccountAdmin {
  type: ActorType.ACCOUNT_ADMIN,
  tenantId: string,
  userId: string
}

interface AccountUser {
  type: ActorType.ACCOUNT_USER,
  tenantUserId: string,
  tenantId: string,
  userId: string
}

async function anAdmin({ userId }: { userId: string }): Promise<Admin> {
  // gets an admin
}

async function anAccountAdmin({ accountId, userId }: { accountId: string, userId: string }): Promise<AccountAdmin> {
  // gets an account admin
}

async function anAccountUser({ accountId, userId }: { accountId: string, userId: string }): Promise<AccountUser> {
  // gets an account user
}

async function hasPermission(permission: string) {
  // checks permissions in cache or calls to external service
}

type Actor = Admin | AccountAdmin | AccountUser;

interface TaskRepository {
  findOne({ taskId }: { taskId: string }): Promise<TaskModel>;
  save(task: TaskModel): Promise<TaskModel>;
}

comment
ทำให้คำถามของคุณกระชับยิ่งขึ้นอาจได้รับคำตอบเร็วกว่านี้   -  person xiemeilong    schedule 04.12.2019


คำตอบ (1)


ฉันคิดว่าไม่จำเป็นต้องมีบทบาทในการอนุญาตอย่างหนักขนาดนี้ แต่คุณอาจมีคลาสบทบาทที่มีรายการสิทธิ์ทั่วไปดังเช่นด้านล่างแทน

class Permission {
    name: String;
}

class Role {
    name: String;
    permissions: Permission[];
}

class User {
    id: String;
    role: Role;
}

วิธีนี้ทำให้คุณสามารถเปลี่ยนแปลงบทบาทสิทธิ์ที่มีอยู่ได้จริง

อีกสิ่งหนึ่งที่ทำให้ฉันกังวลก็คือตรรกะการตรวจสอบการเข้าถึงของคุณล้นไปทั่ว ซึ่งส่งผลเสียต่อการบำรุงรักษาโค้ด คุณสามารถแยกออกเป็นบริการเดียวแทนได้ นี่เป็นภาพร่างคร่าวๆ

class PermissionService {
    userHasRightToUpdateText = (task : Task, user: User) => {
        return (user.role.permissions.some(p => p.name == 'task:updateText:all')
            || (user.role.permissions.some(p => p.name == 'task:updateText:account') && task.accountId == user.id));
            //or whatever you auth logic is
    }
}

class TaskApplicationService {
    constructor(private taskRepository: TaskRepository,
        private userRepository: UserRepository,
        private permissionService : PermissionService) { }
    async updateText({ taskId, text, accountId, context }: { taskId: string, text: string, accountId?: string, context: Context }) {
      const userId = context.user.id;
      const user = await this.userRepository.findOne({ userId });

      const task = await this.taskRepository.findOne({ taskId });

      if (this.permissionService.userHasRightToUpdateText(task, user)) {
        task.updateText({ text });
        await this.taskRepository.save(task);
      }
      else {
        throw new Error('not authorized');
      }
      // return TaskMapper.toDto(task);
    }
  }

หวังว่านี่จะช่วยได้

person Bohdan Stupak    schedule 10.12.2019
comment
ฉันกำลังคิดที่จะใช้นโยบายในลักษณะนี้ และจะช่วยแก้ปัญหาข้อกังวลด้านธุรกรรมและการกำหนดเวอร์ชันของฉันได้ แม้ว่าฉันจะสนใจว่า .NET ทำสิ่งนี้อย่างไร (docs.microsoft.com/en-us/aspnet/core /security/authorization/) และหากยังสามารถรับประกันได้เหมือนเดิม การแยกตรรกะออกเป็นนโยบายเป็นส่วนที่ง่ายสำหรับฉัน ส่วนที่ยากคือการกำหนดประเภทของผู้ใช้ที่ฉันติดต่อด้วย ในตัวอย่างของฉันฉันมีผู้ใช้สามประเภท - ผู้ใช้ที่เป็นผู้ดูแลระบบที่สามารถอัปเดตข้อความในงานใดก็ได้... - person Jordan; 11.12.2019
comment
ผู้ดูแลบัญชีที่สามารถอัปเดตข้อความเฉพาะสำหรับงานภายใต้บัญชีของตน จากนั้นผู้ใช้บัญชีที่สามารถอัปเดตเฉพาะงานในบัญชีของตนและที่ได้รับมอบหมาย ในแบบจำลองของฉัน และฉันไม่แน่ใจว่านี่เป็นแนวทางที่ถูกต้องหรือไม่ เอนทิตีผู้ใช้บัญชี (ไม่ใช่ผู้ใช้) คือเอนทิตีที่จะได้รับมอบหมายให้กับงาน บางทีฉันควรใช้ userId สำหรับการมอบหมายงาน แต่หากฉันลบผู้ใช้นั้นออกจากบทบาทผู้ใช้บัญชี (แต่พวกเขายังอยู่ในบทบาทอื่น) ฉันจะรู้ได้อย่างไรว่าพวกเขาเกี่ยวข้องกับเอนทิตีใดในฐานะผู้ใช้บัญชี / ฉันต้องทำความสะอาดอะไร? - person Jordan; 11.12.2019
comment
เพื่อความชัดเจน งานที่ได้รับมอบหมายในแนวทางปัจจุบันของฉันคือ task.accountUserId = accountUser.id - person Jordan; 11.12.2019
comment
แนวคิดเบื้องหลังตัวอย่างของฉันคือคุณไม่จำเป็นต้องทราบว่าคุณกำลังติดต่อกับผู้ใช้รายใด ใน PermissionsService ก็เพียงพอที่จะบอกได้ว่าผู้ใช้มีสิทธิ์ใดบ้าง และตรวจสอบข้อจำกัดเพิ่มเติม เช่น งานเป็นของผู้ใช้ที่เป็นปัญหาหรือไม่ บทบาทของผู้ใช้ในตัวอย่างเป็นเพียงคอนเทนเนอร์สำหรับการอนุญาตเท่านั้น - person Bohdan Stupak; 11.12.2019