Skip to content

Commit c6b6bb4

Browse files
os-zhuangclaude
andauthored
docs(spec,drivers): managed-datasource read-only is a database privilege, not a platform gate (#4584) (#7241)
* docs(spec,drivers): managed-datasource read-only is a database privilege, not a platform gate (#4584) #4583 removed `datasource.capabilities.readOnly` — a key that read as a safety property and gated nothing — and left the gap it exposed pointing at #4584: `external.allowWrites: false` is the one enforced datasource-wide write gate and it covers only FEDERATED datasources, so a managed datasource had no read-only gate at all. #4584 ruled 方案 B: that stays so on purpose, and the docs say so. 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 merely looks like a boundary is worse than none because it gets trusted — the exact defect #4583 removed. Read-only belongs to the database account (`GRANT SELECT`), where there is no bypass surface. Docs-only; no schema shape changes. - content/docs/data-modeling/drivers.mdx: two new sections under Multi-Datasource. "Read-only: grant it at the database, not in metadata" — a worked `GRANT SELECT` role, the managed datasource that carries its credentials in `config` (an `external` block is rejected there), the DDL / schema-sync consequence of a read-only account, why the platform declines the flag, and a table of what actually enforces what. "Read replicas: the platform does not route" — the #4479 dual conclusion: no query path separates reads from writes, so put the replicas behind pgpool / ProxySQL / an RDS reader endpoint and point `config` there; that is the correct answer, not a stopgap. - content/docs/data-modeling/external-datasources.mdx: the double opt-in write gate now says plainly that it is federation-only, and links across. - packages/spec/src/data/datasource.zod.ts: the `capabilities.readOnly` tombstone carried "Tracked in #4584". It now carries the answer. Prose only — no key, shape or default changed, and `check:docs` confirms no generated page moves. - examples/app-crm: the `crm_analytics` header comment recorded the ruling instead of waiting on it. Closes #4584 * docs(drivers): say "account", not "role", in the GRANT SELECT example (#4584) The ADR-0090 D3 reserved-word ratchet (`check:role-word`) rejected two new uses of "role" in the read-only section. Both are avoidable rather than genuine boundaries, so this drops the word instead of taking a baseline waiver: - prose: "at a role that can only read" → "at an account that can only read"; - SQL: `CREATE ROLE analytics_ro LOGIN PASSWORD …` → `CREATE USER analytics_ro PASSWORD …`, which in PostgreSQL is exactly the same statement — `CREATE USER` is `CREATE ROLE` with `LOGIN` implied — so the example is unchanged in effect. `check:role-word` is green (44 baselined files, no new occurrences), as are check:quick-reference-counts / adr-anchors / org-identifier / release-notes / release-body and eslint over the changed sources. --------- Co-authored-by: Claude <noreply@anthropic.com>
1 parent b8e9fe2 commit c6b6bb4

5 files changed

Lines changed: 177 additions & 6 deletions

File tree

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,40 @@
1+
---
2+
"@objectstack/spec": patch
3+
"@objectstack/example-crm": patch
4+
---
5+
6+
docs(spec): managed-datasource read-only is a database privilege, and the platform will not add a flag (#4584)
7+
8+
#4583 removed `datasource.capabilities.readOnly` and left a gap open in its
9+
rejection message: `external.allowWrites: false` is the one enforced write gate
10+
and it covers only FEDERATED datasources, so a **managed** datasource had no
11+
read-only gate at all. The rejection pointed at #4584 and said "tracked". #4584
12+
is now answered, and the answer is that this stays so **on purpose**:
13+
14+
> **方案 B —— 不建平台层只读闸门,文档明确记录**
15+
> 一个只拦 ObjectQL 写路径、拦不住直连/迁移/DDL 的位,是「看起来存在的能力」——
16+
> #4583 刚删掉的 `capabilities.readOnly` 就是这个形状,不再造第二遍。真只读属于
17+
> 数据库账号权限(GRANT SELECT),那里没有绕行面。
18+
19+
Read-only for a database ObjectStack owns is a **database account privilege**
20+
`GRANT SELECT`. An ObjectQL-level flag would stop writes on one path and leave a
21+
direct `psql` session, a migration, a `syncSchema()` DDL statement and any
22+
process sharing the connection string untouched. A boundary that holds in one
23+
path is not a boundary, and one that looks like a boundary is worse than none
24+
because it gets trusted — which is exactly the defect #4583 removed.
25+
26+
Documentation-only. No schema shape changes; the `capabilities.readOnly`
27+
tombstone now carries the answer instead of an open issue reference:
28+
29+
- **Database Drivers** gains *Read-only: grant it at the database, not in
30+
metadata* (a worked `GRANT SELECT` account, the DDL/schema-sync consequence, why
31+
the platform declines the flag, and a table of what actually enforces what)
32+
and *Read replicas: the platform does not route* — the #4479 dual conclusion:
33+
no query path separates reads from writes, so put replicas behind pgpool /
34+
ProxySQL / an RDS reader endpoint and point `config` there. That is the
35+
correct answer, not a stopgap.
36+
- **External Datasources** now says plainly that the double opt-in write gate is
37+
federation-only, and that the parse rejects an `external` block on a `managed`
38+
datasource.
39+
- `example-crm`'s `crm_analytics` header comment recorded the ruling instead of
40+
waiting on it.

content/docs/data-modeling/drivers.mdx

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -540,3 +540,110 @@ export const AuditLog = ObjectSchema.create({
540540
fields: { /* ... */ },
541541
});
542542
```
543+
544+
### Read-only: grant it at the database, not in metadata
545+
546+
<Callout type="warn">
547+
**A managed datasource has no platform-level read-only gate, and this is
548+
deliberate.** Read-only for a database ObjectStack owns is a **database account
549+
privilege** — `GRANT SELECT`not a key on the datasource. There is no
550+
metadata you can write that makes a managed connection read-only.
551+
</Callout>
552+
553+
Point the datasource's `config` at an account that can only read:
554+
555+
```sql
556+
-- PostgreSQL: a login that can read the schema and nothing else.
557+
CREATE USER analytics_ro PASSWORD '…';
558+
GRANT CONNECT ON DATABASE analytics TO analytics_ro;
559+
GRANT USAGE ON SCHEMA public TO analytics_ro;
560+
GRANT SELECT ON ALL TABLES IN SCHEMA public TO analytics_ro;
561+
ALTER DEFAULT PRIVILEGES IN SCHEMA public GRANT SELECT ON TABLES TO analytics_ro;
562+
```
563+
564+
```typescript
565+
import { defineDatasource } from '@objectstack/spec/data';
566+
567+
export const Analytics = defineDatasource({
568+
name: 'analytics',
569+
label: 'Analytics (read-only account)',
570+
driver: 'postgres',
571+
// The connection itself cannot write. Nothing in the app can talk past it.
572+
config: { url: process.env.ANALYTICS_RO_URL },
573+
active: true,
574+
});
575+
```
576+
577+
(`external.credentialsRef` is a *federation* keythe parse rejects an
578+
`external` block on a `managed` datasourceso a managed connection carries its
579+
credentials in `config`, from the environment as above.)
580+
581+
Note that a read-only account also refuses **DDL**, so such a datasource cannot
582+
run ObjectStack's boot-time schema sync or migrations. That is the honest
583+
consequence of a real boundary: a database you only read is a database you do
584+
not own the schema of. If you want ObjectStack to keep the schema in step, it
585+
needs a writable accountor the datasource belongs on the federation path
586+
(`schemaMode: 'external'`), where DDL is forbidden by design and the write gate
587+
is enforced (see below).
588+
589+
#### Why the platform does not offer the flag
590+
591+
The obvious-looking alternativea `readOnly` boolean on the datasourceis
592+
the exact shape [#4583](https://github.com/objectstack-ai/objectstack/issues/4583)
593+
removed. `datasource.capabilities.readOnly` shipped for three releases, read as
594+
a safety property, and gated nothing: no write path consulted it, so a
595+
datasource labelled a read replica accepted inserts exactly like the primary.
596+
The shipped CRM example called one of its datasources a "Read Replica" on the
597+
strength of it.
598+
599+
Rebuilding it as a *working* ObjectQL check would not fix the underlying
600+
problem, only make it harder to see. Such a gate stops writes that go through
601+
`ObjectQLEngine`; it cannot stop a direct `psql` session, a migration, a
602+
`syncSchema()` DDL statement, a background job holding its own driver handle, or
603+
any other process on the same connection string. A boundary that holds in one
604+
path and not the others is not a boundaryand a flag that *looks* like one is
605+
worse than no flag at all, because it is trusted. The database account has no
606+
such gap: there is no code path in ObjectStack, or anywhere else, that can write
607+
through a connection the server will not let write
608+
([#4584](https://github.com/objectstack-ai/objectstack/issues/4584)).
609+
610+
#### The one enforced write gate is federation-only
611+
612+
`external.allowWrites: false` **is** enforced, by
613+
`ObjectQLEngine.assertWriteAllowed` before every insert/update/deletebut it
614+
answers a question about *ownership*, not about connections: which side may
615+
write to a database ObjectStack does not own. You cannot reach for it on a local
616+
database: the parse rejects an `external` block whose `schemaMode` is `managed`,
617+
and the engine check itself returns early for `managed` (and for a definition
618+
that declares no `schemaMode`) before it ever reads `allowWrites`. See
619+
[Writes (double opt-in)](/docs/data-modeling/external-datasources#5-writes-double-opt-in).
620+
621+
| What you want | What actually does it |
622+
| :--- | :--- |
623+
| A **managed** datasource that cannot be written | A database account with `SELECT` only. No metadata key. |
624+
| A **federated** datasource that cannot be written | `external: { allowWrites: false }`the defaultenforced by the engine. |
625+
| A federated datasource writable for **some** objects | `external.allowWrites: true` on the datasource **and** `external.writable: true` on each object. |
626+
627+
### Read replicas: the platform does not route
628+
629+
<Callout type="warn">
630+
**There is no read/write splitting in ObjectStack.** No query path distinguishes
631+
a read from a write, so there is nothing to route to a replica. `datasource.readReplicas`
632+
was removed in 17.0.0 ([#4468](https://github.com/objectstack-ai/objectstack/issues/4468))
633+
because it described replica connections nothing ever opened.
634+
</Callout>
635+
636+
Put the replicas behind a single endpoint and let the database tier route:
637+
**pgpool-II**, **ProxySQL**, or an **RDS / Aurora reader endpoint**. Point
638+
`config` at that endpoint.
639+
640+
This is the correct answer, not a stopgap
641+
([#4479](https://github.com/objectstack-ai/objectstack/issues/4479)). The hard
642+
parts of read/write splitting are not the replica connectionsthey are
643+
deciding what counts as a read (a `find` inside a transaction that just issued
644+
an `update` must go to the primary, or the app cannot read its own writes),
645+
declaring the staleness a query will tolerate, and ejecting a replica that falls
646+
behind. A proxy is a component built to do exactly that, and it does it better
647+
than a field on a datasource could. Should the platform ever need to pick a
648+
consistency level from business semantics, the schema shape will be decided by
649+
that routing pathit will not be bolted on ahead of it.

content/docs/data-modeling/external-datasources.mdx

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -255,6 +255,21 @@ ObjectSchema.create({ /* ... */ external: { remoteName: 'orders', writable: true
255255

256256
With either gate off, insert/update/delete on the federated object is rejected.
257257

258+
<Callout type="warn">
259+
**This gate is federation-only — it does nothing on a managed datasource.**
260+
`allowWrites` answers *who owns this external database*, not *is this connection
261+
read-only*. You cannot even declare it on a local database — the parse rejects
262+
an `external` block whose `schemaMode` is `managed` — and
263+
`ObjectQLEngine.assertWriteAllowed` returns early for `managed` (or an absent
264+
`schemaMode`) before it reads the flag at all.
265+
266+
A managed datasource has **no** platform read-only gate, deliberately: read-only
267+
for a database ObjectStack owns is a database account privilege (`GRANT SELECT`).
268+
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)
269+
for why an application-layer flag is the wrong boundary
270+
([#4584](https://github.com/objectstack-ai/objectstack/issues/4584)).
271+
</Callout>
272+
258273
## 6. Analytics over external objects
259274

260275
Dashboards and reports over a federated object aggregate against the **correct**
@@ -272,7 +287,9 @@ default allows all connects (subject to the gating above).
272287

273288
## See also
274289

275-
- [Database Drivers](/docs/data-modeling/drivers) — managed multi-datasource routing.
290+
- [Database Drivers](/docs/data-modeling/drivers) — managed multi-datasource routing,
291+
[read-only via database privileges](/docs/data-modeling/drivers#read-only-grant-it-at-the-database-not-in-metadata),
292+
and [why the platform does not route read replicas](/docs/data-modeling/drivers#read-replicas-the-platform-does-not-route).
276293
- [Datasource reference](/docs/references/data/datasource) — every `defineDatasource` field.
277294
- The `examples/app-showcase` `showcase_external` datasource — a runnable end-to-end demo.
278295
- ADR-0015 (federation spec) and ADR-0062 (external-datasource runtime).

examples/app-crm/src/datasources/crm.datasource.ts

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,13 @@ export const CrmDatasource = defineDatasource({
3333
*
3434
* The label no longer promises read-only, because nothing here can deliver it:
3535
* `external.allowWrites: false` is the one enforced write gate and it applies
36-
* only to FEDERATED datasources, while this one is local and managed. Whether a
37-
* managed datasource should have a read-only gate at all is #4584 — until that
38-
* is answered, the honest demo is routing, not a safety claim.
36+
* only to FEDERATED datasources, while this one is local and managed. #4584
37+
* settled that a managed datasource gets NO platform read-only gate, on purpose:
38+
* read-only is a database account privilege (`GRANT SELECT`), because an
39+
* ObjectQL-only check cannot stop a direct connection, a migration or DDL, and a
40+
* gate that holds in one path is worse than none. So the honest demo here is
41+
* routing, not a safety claim — see "Read-only: grant it at the database, not in
42+
* metadata" in the Database Drivers guide.
3943
*/
4044
export const CrmAnalyticsDatasource = defineDatasource({
4145
name: 'crm_analytics',

packages/spec/src/data/datasource.zod.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -64,8 +64,11 @@ const RETIRED_CAPABILITIES: Record<string, string> = {
6464
+ 'so a datasource labelled a read replica accepted writes exactly like any other. The one '
6565
+ 'enforced datasource-wide write gate is `external.allowWrites: false`, and it applies ONLY '
6666
+ 'to a federated datasource (`schemaMode` other than `managed`) — for a managed datasource '
67-
+ 'there is currently no read-only gate at all, so delete the key rather than trusting it. '
68-
+ 'Tracked in #4584.',
67+
+ 'there is no read-only gate at all, so delete the key rather than trusting it. #4584 '
68+
+ 'settled that this stays so ON PURPOSE: grant the connection SELECT-only at the database '
69+
+ '(`GRANT SELECT`), which no direct connection, migration or DDL can talk past — an '
70+
+ 'application-layer flag holds in the ObjectQL path only, and one that looks like a boundary '
71+
+ 'without being one is worse than none.',
6972
};
7073

7174
/**

0 commit comments

Comments
 (0)