Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 12 additions & 3 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand All @@ -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
Expand Down
2 changes: 1 addition & 1 deletion packages/integration/test/helpers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`)
}
Expand Down
52 changes: 52 additions & 0 deletions packages/integration/test/postgres-schema.test.ts
Original file line number Diff line number Diff line change
@@ -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()
})
})
110 changes: 110 additions & 0 deletions website/guide/database-isolation.md
Original file line number Diff line number Diff line change
@@ -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.
4 changes: 3 additions & 1 deletion website/guide/databases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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.
7 changes: 7 additions & 0 deletions website/sidebar.json
Original file line number Diff line number Diff line change
Expand Up @@ -298,6 +298,13 @@
"th": "ฐานข้อมูล"
}
},
{
"path": "guide/database-isolation",
"title": {
"en": "Database isolation",
"th": "แยกฐานข้อมูลจากแอป"
}
},
{
"path": "guide/deployment",
"title": {
Expand Down
101 changes: 101 additions & 0 deletions website/th/guide/database-isolation.md
Original file line number Diff line number Diff line change
@@ -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
4 changes: 3 additions & 1 deletion website/th/guide/databases.md
Original file line number Diff line number Diff line change
Expand Up @@ -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}

Expand Down Expand Up @@ -132,5 +133,6 @@ Easy CMS สร้างและแก้ไขเฉพาะตาราง

## ขั้นต่อไป {#next-steps}

- [แยกฐานข้อมูลจากแอป](./database-isolation): schema, ฐานข้อมูล หรือ server ของตัวเอง
- [Migration และการ deploy](./deployment): migration บน production
- [Backup และการอัปเกรด](./backups): สำรองฐานข้อมูล
Loading