diff --git a/.changeset/managed-datasource-readonly-documented.md b/.changeset/managed-datasource-readonly-documented.md new file mode 100644 index 0000000000..e24f48f9c9 --- /dev/null +++ b/.changeset/managed-datasource-readonly-documented.md @@ -0,0 +1,40 @@ +--- +"@objectstack/spec": patch +"@objectstack/example-crm": patch +--- + +docs(spec): managed-datasource read-only is a database privilege, and the platform will not add a flag (#4584) + +#4583 removed `datasource.capabilities.readOnly` and left a gap open in its +rejection message: `external.allowWrites: false` is the one enforced write gate +and it covers only FEDERATED datasources, so a **managed** datasource had no +read-only gate at all. The rejection pointed at #4584 and said "tracked". #4584 +is now answered, and the answer is that this stays so **on purpose**: + +> **方案 B —— 不建平台层只读闸门,文档明确记录**。 +> 一个只拦 ObjectQL 写路径、拦不住直连/迁移/DDL 的位,是「看起来存在的能力」—— +> #4583 刚删掉的 `capabilities.readOnly` 就是这个形状,不再造第二遍。真只读属于 +> 数据库账号权限(GRANT SELECT),那里没有绕行面。 + +Read-only for a database ObjectStack owns is a **database account privilege** — +`GRANT SELECT`. An ObjectQL-level flag would stop writes on one path and leave a +direct `psql` session, a migration, a `syncSchema()` DDL statement and any +process sharing the connection string untouched. A boundary that holds in one +path is not a boundary, and one that looks like a boundary is worse than none +because it gets trusted — which is exactly the defect #4583 removed. + +Documentation-only. No schema shape changes; the `capabilities.readOnly` +tombstone now carries the answer instead of an open issue reference: + +- **Database Drivers** gains *Read-only: grant it at the database, not in + metadata* (a worked `GRANT SELECT` account, the DDL/schema-sync consequence, why + the platform declines the flag, and a table of what actually enforces what) + and *Read replicas: the platform does not route* — the #4479 dual conclusion: + no query path separates reads from writes, so put replicas behind pgpool / + ProxySQL / an RDS reader endpoint and point `config` there. That is the + correct answer, not a stopgap. +- **External Datasources** now says plainly that the double opt-in write gate is + federation-only, and that the parse rejects an `external` block on a `managed` + datasource. +- `example-crm`'s `crm_analytics` header comment recorded the ruling instead of + waiting on it. diff --git a/content/docs/data-modeling/drivers.mdx b/content/docs/data-modeling/drivers.mdx index f80e20083a..f0fd0ebe99 100644 --- a/content/docs/data-modeling/drivers.mdx +++ b/content/docs/data-modeling/drivers.mdx @@ -540,3 +540,110 @@ export const AuditLog = ObjectSchema.create({ fields: { /* ... */ }, }); ``` + +### Read-only: grant it at the database, not in metadata + + +**A managed datasource has no platform-level read-only gate, and this is +deliberate.** Read-only for a database ObjectStack owns is a **database account +privilege** — `GRANT SELECT` — not a key on the datasource. There is no +metadata you can write that makes a managed connection read-only. + + +Point the datasource's `config` at an account that can only read: + +```sql +-- PostgreSQL: a login that can read the schema and nothing else. +CREATE USER analytics_ro PASSWORD '…'; +GRANT CONNECT ON DATABASE analytics TO analytics_ro; +GRANT USAGE ON SCHEMA public TO analytics_ro; +GRANT SELECT ON ALL TABLES IN SCHEMA public TO analytics_ro; +ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO analytics_ro; +``` + +```typescript +import { defineDatasource } from '@objectstack/spec/data'; + +export const Analytics = defineDatasource({ + name: 'analytics', + label: 'Analytics (read-only account)', + driver: 'postgres', + // The connection itself cannot write. Nothing in the app can talk past it. + config: { url: process.env.ANALYTICS_RO_URL }, + active: true, +}); +``` + +(`external.credentialsRef` is a *federation* key — the parse rejects an +`external` block on a `managed` datasource — so a managed connection carries its +credentials in `config`, from the environment as above.) + +Note that a read-only account also refuses **DDL**, so such a datasource cannot +run ObjectStack's boot-time schema sync or migrations. That is the honest +consequence of a real boundary: a database you only read is a database you do +not own the schema of. If you want ObjectStack to keep the schema in step, it +needs a writable account — or the datasource belongs on the federation path +(`schemaMode: 'external'`), where DDL is forbidden by design and the write gate +is enforced (see below). + +#### Why the platform does not offer the flag + +The obvious-looking alternative — a `readOnly` boolean on the datasource — is +the exact shape [#4583](https://github.com/objectstack-ai/objectstack/issues/4583) +removed. `datasource.capabilities.readOnly` shipped for three releases, read as +a safety property, and gated nothing: no write path consulted it, so a +datasource labelled a read replica accepted inserts exactly like the primary. +The shipped CRM example called one of its datasources a "Read Replica" on the +strength of it. + +Rebuilding it as a *working* ObjectQL check would not fix the underlying +problem, only make it harder to see. Such a gate stops writes that go through +`ObjectQLEngine`; it cannot stop a direct `psql` session, a migration, a +`syncSchema()` DDL statement, a background job holding its own driver handle, or +any other process on the same connection string. A boundary that holds in one +path and not the others is not a boundary — and a flag that *looks* like one is +worse than no flag at all, because it is trusted. The database account has no +such gap: there is no code path in ObjectStack, or anywhere else, that can write +through a connection the server will not let write +([#4584](https://github.com/objectstack-ai/objectstack/issues/4584)). + +#### The one enforced write gate is federation-only + +`external.allowWrites: false` **is** enforced, by +`ObjectQLEngine.assertWriteAllowed` before every insert/update/delete — but it +answers a question about *ownership*, not about connections: which side may +write to a database ObjectStack does not own. You cannot reach for it on a local +database: the parse rejects an `external` block whose `schemaMode` is `managed`, +and the engine check itself returns early for `managed` (and for a definition +that declares no `schemaMode`) before it ever reads `allowWrites`. See +[Writes (double opt-in)](/docs/data-modeling/external-datasources#5-writes-double-opt-in). + +| What you want | What actually does it | +| :--- | :--- | +| A **managed** datasource that cannot be written | A database account with `SELECT` only. No metadata key. | +| A **federated** datasource that cannot be written | `external: { allowWrites: false }` — the default — enforced by the engine. | +| A federated datasource writable for **some** objects | `external.allowWrites: true` on the datasource **and** `external.writable: true` on each object. | + +### Read replicas: the platform does not route + + +**There is no read/write splitting in ObjectStack.** No query path distinguishes +a read from a write, so there is nothing to route to a replica. `datasource.readReplicas` +was removed in 17.0.0 ([#4468](https://github.com/objectstack-ai/objectstack/issues/4468)) +because it described replica connections nothing ever opened. + + +Put the replicas behind a single endpoint and let the database tier route: +**pgpool-II**, **ProxySQL**, or an **RDS / Aurora reader endpoint**. Point +`config` at that endpoint. + +This is the correct answer, not a stopgap +([#4479](https://github.com/objectstack-ai/objectstack/issues/4479)). The hard +parts of read/write splitting are not the replica connections — they are +deciding what counts as a read (a `find` inside a transaction that just issued +an `update` must go to the primary, or the app cannot read its own writes), +declaring the staleness a query will tolerate, and ejecting a replica that falls +behind. A proxy is a component built to do exactly that, and it does it better +than a field on a datasource could. Should the platform ever need to pick a +consistency level from business semantics, the schema shape will be decided by +that routing path — it will not be bolted on ahead of it. diff --git a/content/docs/data-modeling/external-datasources.mdx b/content/docs/data-modeling/external-datasources.mdx index 2c95e566ea..1b950f860d 100644 --- a/content/docs/data-modeling/external-datasources.mdx +++ b/content/docs/data-modeling/external-datasources.mdx @@ -255,6 +255,21 @@ ObjectSchema.create({ /* ... */ external: { remoteName: 'orders', writable: true With either gate off, insert/update/delete on the federated object is rejected. + +**This gate is federation-only — it does nothing on a managed datasource.** +`allowWrites` answers *who owns this external database*, not *is this connection +read-only*. You cannot even declare it on a local database — the parse rejects +an `external` block whose `schemaMode` is `managed` — and +`ObjectQLEngine.assertWriteAllowed` returns early for `managed` (or an absent +`schemaMode`) before it reads the flag at all. + +A managed datasource has **no** platform read-only gate, deliberately: read-only +for a database ObjectStack owns is a database account privilege (`GRANT SELECT`). +See [Read-only: grant it at the database, not in metadata](/docs/data-modeling/drivers#read-only-grant-it-at-the-database-not-in-metadata) +for why an application-layer flag is the wrong boundary +([#4584](https://github.com/objectstack-ai/objectstack/issues/4584)). + + ## 6. Analytics over external objects Dashboards and reports over a federated object aggregate against the **correct** @@ -272,7 +287,9 @@ default allows all connects (subject to the gating above). ## See also -- [Database Drivers](/docs/data-modeling/drivers) — managed multi-datasource routing. +- [Database Drivers](/docs/data-modeling/drivers) — managed multi-datasource routing, + [read-only via database privileges](/docs/data-modeling/drivers#read-only-grant-it-at-the-database-not-in-metadata), + and [why the platform does not route read replicas](/docs/data-modeling/drivers#read-replicas-the-platform-does-not-route). - [Datasource reference](/docs/references/data/datasource) — every `defineDatasource` field. - The `examples/app-showcase` `showcase_external` datasource — a runnable end-to-end demo. - ADR-0015 (federation spec) and ADR-0062 (external-datasource runtime). diff --git a/examples/app-crm/src/datasources/crm.datasource.ts b/examples/app-crm/src/datasources/crm.datasource.ts index 7f6b560285..c0889de11c 100644 --- a/examples/app-crm/src/datasources/crm.datasource.ts +++ b/examples/app-crm/src/datasources/crm.datasource.ts @@ -33,9 +33,13 @@ export const CrmDatasource = defineDatasource({ * * The label no longer promises read-only, because nothing here can deliver it: * `external.allowWrites: false` is the one enforced write gate and it applies - * only to FEDERATED datasources, while this one is local and managed. Whether a - * managed datasource should have a read-only gate at all is #4584 — until that - * is answered, the honest demo is routing, not a safety claim. + * only to FEDERATED datasources, while this one is local and managed. #4584 + * settled that a managed datasource gets NO platform read-only gate, on purpose: + * read-only is a database account privilege (`GRANT SELECT`), because an + * ObjectQL-only check cannot stop a direct connection, a migration or DDL, and a + * gate that holds in one path is worse than none. So the honest demo here is + * routing, not a safety claim — see "Read-only: grant it at the database, not in + * metadata" in the Database Drivers guide. */ export const CrmAnalyticsDatasource = defineDatasource({ name: 'crm_analytics', diff --git a/packages/spec/src/data/datasource.zod.ts b/packages/spec/src/data/datasource.zod.ts index 66895cea55..d039341771 100644 --- a/packages/spec/src/data/datasource.zod.ts +++ b/packages/spec/src/data/datasource.zod.ts @@ -64,8 +64,11 @@ const RETIRED_CAPABILITIES: Record = { + 'so a datasource labelled a read replica accepted writes exactly like any other. The one ' + 'enforced datasource-wide write gate is `external.allowWrites: false`, and it applies ONLY ' + 'to a federated datasource (`schemaMode` other than `managed`) — for a managed datasource ' - + 'there is currently no read-only gate at all, so delete the key rather than trusting it. ' - + 'Tracked in #4584.', + + 'there is no read-only gate at all, so delete the key rather than trusting it. #4584 ' + + 'settled that this stays so ON PURPOSE: grant the connection SELECT-only at the database ' + + '(`GRANT SELECT`), which no direct connection, migration or DDL can talk past — an ' + + 'application-layer flag holds in the ObjectQL path only, and one that looks like a boundary ' + + 'without being one is worse than none.', }; /**