Skip to content

Multi-tenant ​

หน้านี้สอนอะไร

การใช้ CMS ตัวเดียวกับหลายเว็บหรือหลายลูกค้า: แต่ละ tenant มีเนื้อหา สมาชิก และการตั้งค่าของตัวเอง มีตัวสลับ tenant ในหน้า admin และ frontend อ่านเฉพาะ tenant ของตัวเอง

ควรอ่านก่อน: Access control, บทบาท และ Plugins

@easy-cms/plugin-multi-tenant เพิ่ม tenant ให้ CMS ตัวเดียว ฐานข้อมูลเดียว และ admin ชุดเดียว tenant คือแบรนด์ เว็บ สาขา หรือลูกค้า แต่ละ tenant มีเอกสารของตัวเองใน collection ที่คุณระบุ มีค่าของตัวเองใน global ที่คุณระบุ และมีสมาชิกที่มีบทบาทใน tenant นั้น ส่วนที่เหลือใช้ร่วมกัน

เหมาะกับบริษัทที่มีหลายแบรนด์หรือหลายเว็บ และ agency ที่ดูแลเว็บของลูกค้า ทีมทำงานได้ทุก tenant ส่วนลูกค้าแต่ละรายเข้าได้เฉพาะของตัวเอง ถ้าลูกค้าห้ามใช้ฐานข้อมูลร่วมกันเลย ให้ใช้ CMS แยกตัวต่อลูกค้าแทน

ติดตั้ง ​

bash
npm install @easy-cms/plugin-multi-tenant
bash
pnpm add @easy-cms/plugin-multi-tenant
bash
yarn add @easy-cms/plugin-multi-tenant
bash
bun add @easy-cms/plugin-multi-tenant
ts
import { multiTenantPlugin } from '@easy-cms/plugin-multi-tenant'

export default defineConfig({
  // …
  auth: { rbac: true }, // ไม่บังคับ: บทบาทจากหน้า admin ให้แยกตาม tenant
  plugins: [
    multiTenantPlugin({
      collections: ['posts', 'pages', 'media'], // เอกสารแต่ละชิ้นเป็นของ tenant เดียว
      globals: ['site-settings'], // ค่าแยกต่อ tenant
    }),
  ],
})

plugin เพิ่มให้:

  • ตั้งค่า › ผู้ใช้และสิทธิ์ › Tenants: ชื่อ slug และโดเมนของแต่ละ tenant
  • field tenant ในทุก collection ที่ระบุ ใส่ให้เองจาก tenant ที่กำลังทำงานอยู่ ถ้ามี media และ upload.folders โฟลเดอร์สื่อก็เป็นของ tenant ด้วย
  • ตัวสลับ tenant ด้านบนของเมนู admin สำหรับคนที่อยู่มากกว่าหนึ่ง tenant
  • ตั้งค่า › ผู้ใช้และสิทธิ์ › สมาชิก: คนใน tenant ที่เลือกและบทบาทของแต่ละคน
  • tenants ในผู้ใช้: tenant ที่แต่ละคนอยู่และบทบาทในแต่ละ tenant

จากนั้นสร้าง migration ตามปกติ npx easy-cms migrate:create tenants ถ้าเว็บมีเนื้อหาอยู่แล้ว ให้ย้ายไปไว้ใน tenant หนึ่งครั้งเดียว:

bash
npx easy-cms tenants:assign brand-a

ใครเห็นอะไร ​

ใครเห็นและแก้ไขได้
ผู้ใช้ที่เข้าได้ทุก tenant (ค่าเริ่มต้น: role admin)ทุก tenant หรือ tenant ที่เลือก รวมถึง Tenants, ตั้งค่า, สำรองข้อมูล และบทบาท
สมาชิกtenant ที่กำลังทำงาน ด้วยบทบาทใน tenant นั้น: หนึ่งใน tenant ของเขา เลือกจากตัวสลับ
admin ของ tenant (role admin ใน tenant นั้น)เนื้อหาและสมาชิกของ tenant นั้น ไม่รวมบทบาท สำรองข้อมูล อีเมล และ tenant อื่น
ผู้เยี่ยมชมtenant ที่ request ระบุ ถ้าไม่ระบุจะไม่เห็นอะไร
  • เอกสารใหม่ได้ tenant ที่ request กำลังทำงานอยู่ เฉพาะผู้ใช้ที่เข้าได้ทุก tenant ที่เลือก tenant อื่นหรือย้ายเอกสารได้
  • relationship และ upload ที่ชี้ไป collection ของ tenant เลือกและบันทึกได้เฉพาะเอกสารของ tenant เดียวกัน เอกสารที่ plugin สร้างโดยไม่มี tenant (ข้อมูลที่ส่งจากฟอร์ม, redirect ของหน้า) ได้ tenant ตามเอกสารที่มันชี้ไป
  • โฟลเดอร์สื่อเป็นของ tenant field upload ที่ตั้ง folder: 'banners' จะเปิดโฟลเดอร์ banners ของแต่ละ tenant
  • ค่าที่ unique รวมถึง slug ห้ามซ้ำแค่ภายใน tenant สองแบรนด์จึงมี /posts/hello ได้ทั้งคู่
  • เพิ่มคนที่ สมาชิก ด้วยอีเมล บัญชีหนึ่งอยู่ได้หลาย tenant คนใหม่จะได้อีเมลให้ตั้งรหัสผ่านเมื่อตั้งค่าอีเมลแล้ว admin ของ tenant แก้บัญชีของคนอื่นไม่ได้ แก้ได้แค่การเป็นสมาชิก ในบัญชีของคนอื่น staff ของ tenant เห็นเฉพาะการเป็นสมาชิก ของ tenant นั้น ส่วนเจ้าของบัญชีเห็นของตัวเองทั้งหมด
  • API key จำ tenant ที่สร้างไว้ตลอด
  • Audit log เก็บ tenant ของแต่ละ entry admin ของ tenant เห็นของ tenant ตัวเอง ผู้ที่เข้าได้ทุก tenant เห็นทั้งหมด หรือของ tenant ที่เลือก การตรวจความถูกต้องของ log ทั้งหมดทำได้เฉพาะผู้ที่เข้าได้ทุก tenant
  • ตอนแสดงทุก tenant หน้ารายการมีคอลัมน์ Tenant และตัวสลับใช้กรองเหลือ tenant เดียว

ใช้กับ plugin อื่น ​

ใส่ collection ของ plugin เหล่านั้นใน collections เพื่อแยกต่อ tenant และวาง multiTenantPlugin ไว้หลัง plugin ที่สร้าง collection:

ts
plugins: [
  nestedDocsPlugin({ collections: ['pages'] }),
  redirectsPlugin({ collections: ['pages'], url }),
  formBuilderPlugin(),
  multiTenantPlugin({
    collections: ['pages', 'redirects', 'forms', 'form-submissions', 'media'],
  }),
]
  • หน้าย่อย: path ห้ามซ้ำแค่ภายใน tenant แต่ละ tenant จึงมี /about ได้ ส่ง tenant ให้ helper: findByPath(cms, 'pages', path, { context }) และ getTree(cms, 'pages', { context })
  • Redirects: แต่ละ tenant มีของตัวเอง หน้าที่ย้ายจะได้ redirect ใน tenant ของมัน resolveRedirect(cms, url, { context }) ใช้ของ tenant ของ request
  • ฟอร์ม: <easy-form> และ /api/cms/form/<slug> หาฟอร์มของ tenant ของ request (โดเมนหรือ header) และข้อมูลที่ส่งมาเป็นของ tenant นั้น
  • SEO: ดูด้านล่าง

ในที่นี้ context คือ await tenantContext(cms, { host }) หรือ (await cms.forRequest(request)).context

ใส่ collection ของ plugin หนึ่งให้ครบทุกตัวหรือไม่ใส่เลย ถ้า forms แยกตาม tenant แต่ form-submissions ใช้ร่วมกัน (หรือ collection บางตัวของร้านค้า) ข้อมูลใน collection ที่ใช้ร่วมจะข้าม tenant ได้ server จะขึ้นคำเตือนตอน request แรกเมื่อเป็นแบบนี้

Frontend ​

request ระบุ tenant ตามลำดับนี้:

  1. header x-easy-cms-tenant ด้วย slug หรือ id ของ tenant
  2. ?tenant= ใน URL
  3. cookie ของ admin (ตัวสลับ)
  4. โดเมน: tenant ที่มี host ของ request อยู่ใน Domains
ts
// แอปอื่นหรือเว็บ static
const posts = await fetch('https://cms.example.com/api/cms/posts', {
  headers: { 'x-easy-cms-tenant': 'brand-a' },
}).then((r) => r.json())

ในหน้า Nuxt หรือ Next.js อ่านด้วย Local API ใน tenant ของโดเมนของหน้า:

ts
import { tenantContext } from '@easy-cms/plugin-multi-tenant'

const context = await tenantContext(cms, { host: request.headers.get('host') ?? '' })
const posts = await cms.find('posts', { overrideAccess: false, user: null, context })
const site = await cms.findGlobal('site-settings', { overrideAccess: false, user: null, context })

tenantContext(cms, { slug }) ใช้กับ slug จาก URL ได้ เช่น route /[tenant]/[slug] ส่วน findTenantFor(cms, { slug | host }) คืนตัว tenant หรือ null ถ้าเปลี่ยน slugs.tenants ให้ส่งเป็น collection ด้วย: tenantContext(cms, { host, collection: 'sites' }) ส่วน cms.forRequest(request) คืนผู้ใช้และ context ของ request แบบเดียวกับ REST

เมื่อใช้ plugin SEO /sitemap.xml และ /llms.txt แสดงหน้าของ tenant ตามโดเมนที่ขอเข้ามา และ helper ของมันรับ context เดียวกัน: sitemap(cms, { context: await tenantContext(cms, { host }) })

request ที่ไม่ระบุ tenant จะไม่พบอะไรใน collection ของ tenant ถ้าตั้ง publicReads: 'all' จะพบของทุก tenant แทน

ตัวเลือก ​

ตัวเลือกค่าเริ่มต้น
collections—ต้องระบุ collection ที่เอกสารเป็นของ tenant เดียว
globals[]global ที่มีค่าแยกต่อ tenant
slugs.tenantstenantscollection ของ tenant ประกาศเองเพื่อเพิ่ม field ได้
userHasAccessToAllTenantsrole admin(user) => boolean: ใครเห็นทุก tenant และส่วนของระบบ
publicReadsnoneall: request ที่ไม่ระบุ tenant อ่านของทุก tenant แต่การเขียนยังต้องระบุ tenant
editShared[]collection และ global ที่ใช้ร่วมกันซึ่งคนของ tenant (รวม admin ของ tenant) แก้ได้ การแก้มีผลกับทุก tenant ถ้าไม่ระบุ แก้ได้เฉพาะผู้มีสิทธิ์ทุก tenant
headerx-easy-cms-tenantheader ที่ใช้ระบุ tenant
cookieecms-tenantcookie ที่ admin ใช้จำ tenant ที่เลือก

ลบ tenant ​

การลบ tenant ที่ ตั้งค่า › ผู้ใช้และสิทธิ์ › Tenants จะลบเอกสารและไฟล์ใน collection ของ tenant นั้น และเอา tenant ออกจากรายการของสมาชิก หน้า admin บอกว่าจะหายไปเท่าไร และให้พิมพ์ชื่อ tenant ก่อนลบ ย้อนกลับไม่ได้ สำรองข้อมูลก่อน

ทำงานอย่างไร ​

plugin ใช้ส่วนของ core ที่คุณใช้เองได้:

  • onRequest ใน config หา context ของแต่ละ request (ในที่นี้คือ tenant) และผู้ใช้ใน context นั้น (บทบาทใน tenant, scoped) กฎสิทธิ์ hook และ filterOptions ได้รับ context และ Local API รับเป็นตัวเลือกได้ onRequest เป็นรายการได้: plugin เพิ่มของตัวเองต่อจากของคุณ เห็นผู้ใช้ที่ของคุณคืนมา และรวม context เข้ากับของคุณ
  • ผู้ใช้ที่ scoped เป็น admin เฉพาะส่วนของตัวเอง ตั้งค่า สำรองข้อมูล บทบาท และ API key ของคนอื่นต้องเป็น admin ของทั้งระบบ
  • global ที่มี scope เก็บค่าแยกตาม scope
  • uniqueWithin ใช้ได้กับทุก field ที่ unique พร้อม unique index ต่อ scope ในฐานข้อมูล
  • admin.switcher เพิ่มตัวเลือกด้านบนของเมนู
  • audit.scope เก็บส่วนของเว็บของแต่ละ entry ใน audit log
  • admin.confirmDelete, admin.initialValue, admin.column และ admin.allowCreate ปรับการลบ เอกสารใหม่ และหน้ารายการใน admin
  • filterOptions ของ field upload จำกัดไฟล์ที่เลือกได้
  • cms.uniqueScope() บอก plugin ว่า field ที่ unique ห้ามซ้ำภายในอะไรสำหรับ request นั้น

ยังไม่มี: สำรองหรือ export ข้อมูลทีละ tenant, webhook ต่อ tenant, tenant ที่สมัครเอง และ single sign-on ที่ใส่คนเข้า tenant ตามโดเมนของอีเมล

ขั้นต่อไป ​

  • บทบาท: แต่ละบทบาททำอะไรได้ สมาชิกได้บทบาทหนึ่งต่อ tenant
  • Access control: กฎของคุณเองเห็น context ของ request
  • API keys: key สำหรับสคริปต์ของ tenant เดียว

เผยแพร่ภายใต้สัญญาอนุญาต MIT