diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7b75593e..2774b6ff 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -132,7 +132,8 @@ jobs: needs: scope if: needs.scope.outputs.full == 'true' # The same suites through PgBouncer in transaction mode, as serverless hosts' poolers run: - # any server connection per transaction, no prepared statements. + # any server connection per transaction, no prepared statements. They connect as a role + # whose search_path is its own schema (database isolation L1), set up as the docs show. runs-on: ubuntu-24.04 services: postgres: @@ -141,21 +142,29 @@ jobs: POSTGRES_USER: easycms POSTGRES_PASSWORD: easycms POSTGRES_DB: easycms + ports: ['5432:5432'] options: >- --health-cmd "pg_isready -U easycms" --health-interval 5s --health-timeout 5s --health-retries 10 pgbouncer: image: edoburu/pgbouncer:v1.26.0-p0 env: - DATABASE_URL: postgres://easycms:easycms@postgres:5432/easycms + DATABASE_URL: postgres://cms:cms@postgres:5432/easycms POOL_MODE: transaction AUTH_TYPE: scram-sha-256 MAX_CLIENT_CONN: 200 DEFAULT_POOL_SIZE: 20 ports: ['6432:5432'] env: - POSTGRES_URL: postgres://easycms:easycms@localhost:6432/easycms + POSTGRES_URL: postgres://cms:cms@localhost:6432/easycms POSTGRES_PREPARE: 'false' + POSTGRES_SCHEMA: easy_cms steps: + - name: Create the role and its schema + run: >- + psql postgres://easycms:easycms@localhost:5432/easycms -v ON_ERROR_STOP=1 + -c "CREATE ROLE cms LOGIN PASSWORD 'cms'" + -c "CREATE SCHEMA easy_cms AUTHORIZATION cms" + -c "ALTER ROLE cms SET search_path = easy_cms" - uses: actions/checkout@v7 - uses: pnpm/action-setup@v6 - uses: actions/setup-node@v7 diff --git a/packages/integration/test/helpers.ts b/packages/integration/test/helpers.ts index 82cab505..8914dba2 100644 --- a/packages/integration/test/helpers.ts +++ b/packages/integration/test/helpers.ts @@ -73,7 +73,7 @@ afterAll(async () => { // Leave the shared server clean for the next file. for (const prefix of prefixes.values()) { const tables = - await server`select tablename from pg_tables where schemaname = 'public' and tablename like ${`${prefix}%`}` + await server`select tablename from pg_tables where schemaname = current_schema() and tablename like ${`${prefix}%`}` for (const { tablename } of tables) await server.unsafe(`drop table if exists "${tablename}" cascade`) } diff --git a/packages/integration/test/postgres-schema.test.ts b/packages/integration/test/postgres-schema.test.ts new file mode 100644 index 00000000..5d3c7f25 --- /dev/null +++ b/packages/integration/test/postgres-schema.test.ts @@ -0,0 +1,52 @@ +import { createEasyCMS, defineConfig, silentLogger } from '@easy-cms/core' +import { describe, expect, it } from 'vitest' +import { DIALECT, db, open, rawQuery, SECRET, table, tempProject } from './helpers.js' + +// Database isolation L1: the role's search_path is its own schema (CI's pgbouncer job). +const schema = process.env.POSTGRES_SCHEMA + +const config = defineConfig({ + secret: SECRET, + db: db(), + collections: [ + { + slug: 'posts', + fields: [ + { name: 'title', type: 'text', localized: true }, + { name: 'tags', type: 'select', options: ['a', 'b'], hasMany: true }, + ], + }, + ], + localization: { locales: ['en', 'th'], defaultLocale: 'en' }, +}) + +async function schemasOf(cwd: string) { + const rows = await rawQuery( + cwd, + `select distinct schemaname from pg_tables where tablename like '${table(cwd, '')}%'`, + ) + return rows.map((row) => row.schemaname) +} + +describe.runIf(DIALECT === 'postgres' && schema)('a schema of its own (search_path)', () => { + it('creates every table there, none in public', async () => { + const cwd = tempProject() + const cms = await open(config, cwd) + await cms.create('posts', { title: 'Hello', tags: ['a'] }) + expect(await schemasOf(cwd)).toEqual([schema]) + await cms.destroy() + }) + + it('applies and verifies migration files there', async () => { + const cwd = tempProject() + const tool = await createEasyCMS(config, { cwd, schema: 'skip', logger: silentLogger }) + await tool.db.createMigration({ name: 'init' }) + await tool.db.migrate() + await tool.destroy() + const cms = await open(config, cwd, 'verify') + const post = await cms.create('posts', { title: 'Hello', tags: ['b'] }) + expect((await cms.findById('posts', post.id))?.tags).toEqual(['b']) + expect(await schemasOf(cwd)).toEqual([schema]) + await cms.destroy() + }) +}) diff --git a/website/guide/database-isolation.md b/website/guide/database-isolation.md new file mode 100644 index 00000000..dfb52741 --- /dev/null +++ b/website/guide/database-isolation.md @@ -0,0 +1,110 @@ +# Database isolation + +::: info What you'll learn +How far apart to keep Easy CMS from your app's data on Postgres, from sharing its database to +a server of its own, and how to set up a schema of its own. + +**Before this page:** [Databases](./databases). +::: + +By default Easy CMS creates its tables, all starting with `ecms_`, in your app's database. That +is simple and fine for most sites. Organisations often want the CMS kept apart from business +data: so its database role can't read customer tables, and so a busy CMS can't slow the app +down. Pick the level with the config and the database alone; no code changes. + +| Level | Where the CMS data lives | How | +|---|---|---| +| **L0** shared | The app's database and schema, apart by `tablePrefix` | The default | +| **L1** own schema | The app's database, a schema and a role of its own | [The role's `search_path`](#a-schema-of-its-own-l1) | +| **L2** own database | Another database on the same server | Its own `url` | +| **L3** own server | Another Postgres server | Its own `url` | + +## Which level + +- **L0** for a site whose CMS and app are run by the same people. +- **L1 or above** when the CMS should only reach its own tables: its role can't read business + data even if something goes wrong in the CMS, and a CMS migration can't touch the app's tables. +- **L3** when the CMS must not affect the app's performance. Levels L0 to L2 share the + server's CPU, memory, disk and locks: a heavy import or a backup on the CMS side slows the app + too. + +A separate pool doesn't isolate anything but connections. Easy CMS always opens its own pool +(`max`, 10 by default) and never shares the app's, but both draw on the same server. + +None of the levels replaces safe queries: Easy CMS uses parameterised queries throughout. +Isolation limits the damage if something goes wrong. + +## A schema of its own (L1) + +As a superuser, or the database owner, create a role and a schema it owns, and make it the +role's default schema: + +```sql +CREATE ROLE cms LOGIN PASSWORD '…'; +CREATE SCHEMA easy_cms AUTHORIZATION cms; +ALTER ROLE cms SET search_path = easy_cms; +``` + +Then connect as that role: + +```ts +db: postgres({ url: 'postgres://cms:…@db.example.com:5432/app' }) +``` + +Every table Easy CMS creates, and every migration it runs, lands in `easy_cms`. CI runs the +integration suites this way, through PgBouncer. The setting belongs to the role, not the +connection, so it also works behind poolers that refuse startup parameters. + +- **Forgetting the role.** Without its own `search_path`, a role creates tables in `public`. On + Postgres 15 and later a role that doesn't own `public` gets "permission denied for schema + public" instead, so the mistake shows on the first start. +- **The app reading CMS tables.** Grant the app's role read access: + + ```sql + GRANT USAGE ON SCHEMA easy_cms TO app; + GRANT SELECT ON ALL TABLES IN SCHEMA easy_cms TO app; + ALTER DEFAULT PRIVILEGES FOR ROLE cms IN SCHEMA easy_cms GRANT SELECT ON TABLES TO app; + ``` + + The last line covers tables later migrations create. Then refer to them as + `easy_cms.ecms_posts`, or add the schema to the app role's `search_path`. + +A `schema` option for `postgres()`, independent of `search_path`, and running with a role that +can only read and write data are planned. + +## Moving an existing install to L1 + +[Back up](./backups) first and stop the app, then move the tables as a superuser: + +```sql +CREATE ROLE cms LOGIN PASSWORD '…'; +CREATE SCHEMA easy_cms AUTHORIZATION cms; +ALTER ROLE cms SET search_path = easy_cms; + +DO $$ +DECLARE t text; +BEGIN + FOR t IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE 'ecms\_%' + LOOP + EXECUTE format('ALTER TABLE public.%I SET SCHEMA easy_cms', t); + EXECUTE format('ALTER TABLE easy_cms.%I OWNER TO cms', t); + END LOOP; +END $$; +``` + +Sequences and indexes move with their tables. Use your `tablePrefix` if you changed it. Point +`url` at the new role and start the app: `verify()` finds the migrations table in the new +schema and nothing is re-applied. + +## Levels L2 and L3 + +Give the CMS its own `url`: another database (`CREATE DATABASE cms OWNER cms`) or another +server. Nothing else changes. Easy CMS never joins its tables with the app's, so the app reads +CMS content through the [Local API](./local-api) or [REST](./rest-api), not SQL. + +On SQLite, a file of its own is already a database of its own. + +## Next steps + +- [Databases](./databases#connections): pool size, poolers and serverless. +- [Migrations & deployment](./deployment): running migrations in production. diff --git a/website/guide/databases.md b/website/guide/databases.md index 2c98ae55..819505c3 100644 --- a/website/guide/databases.md +++ b/website/guide/databases.md @@ -97,7 +97,8 @@ db: postgres({ url: process.env.DATABASE_POOLER_URL, max: 2, prepare: false }) ## Sharing a database with your app Easy CMS creates and changes only tables with its prefix, so it can use your app's database. -Your own tables are never touched, in development or by migrations. +Your own tables are never touched, in development or by migrations. To keep it further apart, +in a schema, database or server of its own, see [Database isolation](./database-isolation). ## Differences to know @@ -141,5 +142,6 @@ What that means for your site: ## Next steps +- [Database isolation](./database-isolation): a schema, database or server of its own. - [Migrations & deployment](./deployment): migrations in production. - [Backups & upgrades](./backups): back up the database. diff --git a/website/sidebar.json b/website/sidebar.json index 44c3c3fa..928b0036 100644 --- a/website/sidebar.json +++ b/website/sidebar.json @@ -298,6 +298,13 @@ "th": "ฐานข้อมูล" } }, + { + "path": "guide/database-isolation", + "title": { + "en": "Database isolation", + "th": "แยกฐานข้อมูลจากแอป" + } + }, { "path": "guide/deployment", "title": { diff --git a/website/th/guide/database-isolation.md b/website/th/guide/database-isolation.md new file mode 100644 index 00000000..4078ffd5 --- /dev/null +++ b/website/th/guide/database-isolation.md @@ -0,0 +1,101 @@ +# แยกฐานข้อมูลจากแอป {#database-isolation} + +::: info หน้านี้สอนอะไร +เลือกว่าจะแยก Easy CMS ออกจากข้อมูลของแอปบน Postgres แค่ไหน ตั้งแต่ใช้ฐานข้อมูลร่วมกันไปจนถึงแยก server +และวิธีตั้ง schema ของ CMS เอง + +**ควรอ่านก่อน:** [ฐานข้อมูล](./databases) +::: + +โดยปกติ Easy CMS สร้างตารางของตัวเอง (ขึ้นต้นด้วย `ecms_` ทั้งหมด) ในฐานข้อมูลของแอปคุณ ซึ่งง่ายและเหมาะกับเว็บส่วนใหญ่ +แต่องค์กรมักต้องการแยก CMS ออกจากข้อมูลธุรกิจ เพื่อไม่ให้ role ของ CMS อ่านตารางลูกค้าได้ และไม่ให้ CMS ที่ทำงานหนักทำให้แอปช้า +เลือกระดับได้ด้วย config และการตั้งค่าฐานข้อมูลอย่างเดียว ไม่ต้องแก้โค้ด + +| ระดับ | ข้อมูลของ CMS อยู่ที่ไหน | วิธี | +|---|---|---| +| **L0** ใช้ร่วมกัน | ฐานข้อมูลและ schema เดียวกับแอป แยกด้วย `tablePrefix` | ค่าเริ่มต้น | +| **L1** แยก schema | ฐานข้อมูลของแอป แต่มี schema และ role ของตัวเอง | [`search_path` ของ role](#a-schema-of-its-own-l1) | +| **L2** แยกฐานข้อมูล | ฐานข้อมูลอื่นบน server เดียวกัน | `url` ของตัวเอง | +| **L3** แยก server | Postgres อีกเครื่อง | `url` ของตัวเอง | + +## เลือกระดับไหน {#which-level} + +- **L0** เมื่อคนดูแล CMS กับแอปเป็นทีมเดียวกัน +- **L1 ขึ้นไป** เมื่อ CMS ควรเข้าถึงได้แค่ตารางของตัวเอง: role ของ CMS อ่านข้อมูลธุรกิจไม่ได้แม้มีอะไรผิดพลาดใน CMS + และ migration ของ CMS แตะตารางของแอปไม่ได้ +- **L3** เมื่อ CMS ต้องไม่กระทบ performance ของแอป ระดับ L0 ถึง L2 ใช้ CPU, memory, disk และ lock ของ server ร่วมกัน + การ import ข้อมูลจำนวนมากหรือ backup ฝั่ง CMS จึงทำให้แอปช้าลงด้วย + +pool ที่แยกกันแยกได้แค่ connection Easy CMS เปิด pool ของตัวเองเสมอ (`max` ค่าเริ่มต้น 10) และไม่ใช้ pool ร่วมกับแอป +แต่ทั้งคู่ยังใช้ทรัพยากรของ server เดียวกัน + +ไม่มีระดับไหนแทนการเขียน query ที่ปลอดภัยได้ Easy CMS ใช้ query แบบ parameter ทุกที่ การแยกช่วยจำกัดความเสียหายเมื่อมีอะไรผิดพลาด + +## Schema ของตัวเอง (L1) {#a-schema-of-its-own-l1} + +ใช้ superuser หรือเจ้าของฐานข้อมูล สร้าง role และ schema ที่ role นั้นเป็นเจ้าของ แล้วตั้งให้เป็น schema ปริยายของ role: + +```sql +CREATE ROLE cms LOGIN PASSWORD '…'; +CREATE SCHEMA easy_cms AUTHORIZATION cms; +ALTER ROLE cms SET search_path = easy_cms; +``` + +แล้วต่อด้วย role นั้น: + +```ts +db: postgres({ url: 'postgres://cms:…@db.example.com:5432/app' }) +``` + +ทุกตารางที่ Easy CMS สร้างและทุก migration ที่รันจะไปอยู่ใน `easy_cms` CI รัน integration suite แบบนี้ผ่าน PgBouncer +ค่านี้อยู่ที่ role ไม่ใช่ที่ connection จึงใช้ได้หลัง pooler ที่ไม่รับ startup parameter ด้วย + +- **ลืมตั้ง role** role ที่ไม่มี `search_path` ของตัวเองจะสร้างตารางใน `public` แต่ตั้งแต่ Postgres 15 role ที่ไม่ได้เป็นเจ้าของ + `public` จะได้ error "permission denied for schema public" แทน จึงเห็นความผิดพลาดตั้งแต่เริ่มระบบครั้งแรก +- **แอปอ่านตารางของ CMS** ให้สิทธิ์อ่านกับ role ของแอป: + + ```sql + GRANT USAGE ON SCHEMA easy_cms TO app; + GRANT SELECT ON ALL TABLES IN SCHEMA easy_cms TO app; + ALTER DEFAULT PRIVILEGES FOR ROLE cms IN SCHEMA easy_cms GRANT SELECT ON TABLES TO app; + ``` + + บรรทัดสุดท้ายครอบคลุมตารางที่ migration สร้างทีหลัง แล้วอ้างถึงเป็น `easy_cms.ecms_posts` หรือเพิ่ม schema นี้ใน + `search_path` ของ role ของแอป + +ตัวเลือก `schema` ของ `postgres()` ที่ไม่ขึ้นกับ `search_path` และการรันด้วย role ที่อ่านเขียนข้อมูลได้อย่างเดียวอยู่ในแผนแล้ว + +## ย้ายระบบที่ติดตั้งแล้วไป L1 {#moving-an-existing-install-to-l1} + +[backup](./backups) ก่อนและหยุดแอป แล้วย้ายตารางด้วย superuser: + +```sql +CREATE ROLE cms LOGIN PASSWORD '…'; +CREATE SCHEMA easy_cms AUTHORIZATION cms; +ALTER ROLE cms SET search_path = easy_cms; + +DO $$ +DECLARE t text; +BEGIN + FOR t IN SELECT tablename FROM pg_tables WHERE schemaname = 'public' AND tablename LIKE 'ecms\_%' + LOOP + EXECUTE format('ALTER TABLE public.%I SET SCHEMA easy_cms', t); + EXECUTE format('ALTER TABLE easy_cms.%I OWNER TO cms', t); + END LOOP; +END $$; +``` + +sequence และ index ย้ายตามตารางไปเอง ถ้าเปลี่ยน `tablePrefix` ไว้ให้ใช้ prefix ของคุณ จากนั้นชี้ `url` ไปที่ role ใหม่แล้วเริ่มแอป +`verify()` จะเจอตาราง migration ใน schema ใหม่และไม่รันอะไรซ้ำ + +## ระดับ L2 และ L3 {#levels-l2-and-l3} + +ให้ CMS มี `url` ของตัวเอง: ฐานข้อมูลอื่น (`CREATE DATABASE cms OWNER cms`) หรือ server อื่น ไม่ต้องเปลี่ยนอะไรอีก +Easy CMS ไม่ join ตารางของตัวเองกับของแอป แอปจึงอ่านเนื้อหาผ่าน [Local API](./local-api) หรือ [REST](./rest-api) ไม่ใช่ SQL + +บน SQLite ไฟล์แยกก็คือฐานข้อมูลแยกอยู่แล้ว + +## ขั้นต่อไป {#next-steps} + +- [ฐานข้อมูล](./databases#connections): ขนาด pool, pooler และ serverless +- [Migration และการ deploy](./deployment): รัน migration บน production diff --git a/website/th/guide/databases.md b/website/th/guide/databases.md index 9da144a0..62df85cd 100644 --- a/website/th/guide/databases.md +++ b/website/th/guide/databases.md @@ -90,7 +90,8 @@ db: postgres({ url: process.env.DATABASE_POOLER_URL, max: 2, prepare: false }) ## ใช้ฐานข้อมูลร่วมกับแอปของคุณ {#sharing-a-database-with-your-app} Easy CMS สร้างและแก้ไขเฉพาะตารางที่มี prefix ของตัวเอง จึงใช้ฐานข้อมูลของแอปคุณได้ -ตารางของคุณเองจะไม่ถูกแตะต้อง ทั้งในช่วงพัฒนา (development) และโดย migration +ตารางของคุณเองจะไม่ถูกแตะต้อง ทั้งในช่วงพัฒนา (development) และโดย migration ถ้าต้องการแยกมากกว่านี้ (schema, ฐานข้อมูล หรือ server ของตัวเอง) +ดู[แยกฐานข้อมูลจากแอป](./database-isolation) ## ข้อแตกต่างที่ควรรู้ {#differences-to-know} @@ -132,5 +133,6 @@ Easy CMS สร้างและแก้ไขเฉพาะตาราง ## ขั้นต่อไป {#next-steps} +- [แยกฐานข้อมูลจากแอป](./database-isolation): schema, ฐานข้อมูล หรือ server ของตัวเอง - [Migration และการ deploy](./deployment): migration บน production - [Backup และการอัปเกรด](./backups): สำรองฐานข้อมูล