latticesql 3.0.0 → 3.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +4 -0
- package/dist/cli.js +4234 -1767
- package/dist/index.cjs +850 -154
- package/dist/index.d.cts +228 -26
- package/dist/index.d.ts +228 -26
- package/dist/index.js +842 -154
- package/docs/api-reference.md +1370 -0
- package/docs/architecture.md +331 -0
- package/docs/assistant.md +138 -0
- package/docs/cli.md +515 -0
- package/docs/cloud.md +675 -0
- package/docs/collaboration.md +85 -0
- package/docs/configuration.md +416 -0
- package/docs/entity-context.md +510 -0
- package/docs/examples/agent-system.md +313 -0
- package/docs/examples/cms.md +366 -0
- package/docs/examples/ticket-tracker.md +313 -0
- package/docs/migrations.md +272 -0
- package/docs/templates.md +338 -0
- package/docs/workspaces.md +81 -0
- package/package.json +3 -2
package/docs/cloud.md
ADDED
|
@@ -0,0 +1,675 @@
|
|
|
1
|
+
# Lattice Cloud
|
|
2
|
+
|
|
3
|
+
A **Lattice cloud** is a shared Postgres database protected by real Postgres
|
|
4
|
+
**Row-Level Security (RLS)**. Several people connect to the same database and each
|
|
5
|
+
sees only their own rows plus the rows others have explicitly shared — and that
|
|
6
|
+
boundary is enforced by Postgres itself, not by any application code.
|
|
7
|
+
|
|
8
|
+
There is exactly **one** concept here: the cloud. A cloud _is_ the set of people
|
|
9
|
+
who can connect to it; there is no separate "team" object to create and no "enable
|
|
10
|
+
sharing" step. We call the people on a cloud its **members**, the person who set it
|
|
11
|
+
up its **owner**, and the database itself the **cloud**.
|
|
12
|
+
|
|
13
|
+
> **SQLite is single-user and local.** RLS and the cloud model are Postgres-only.
|
|
14
|
+
> A local SQLite Lattice is just your private store; every cloud installer in the
|
|
15
|
+
> library is a no-op on SQLite. To go from a local Lattice to a shared one, you
|
|
16
|
+
> **migrate** it into a cloud Postgres (see below).
|
|
17
|
+
|
|
18
|
+
---
|
|
19
|
+
|
|
20
|
+
## What makes it different: no server
|
|
21
|
+
|
|
22
|
+
There is **no Lattice server process**. There is no HTTP API in front of Postgres,
|
|
23
|
+
no bearer tokens, no replica, and no sync client. A cloud is _only_ a Postgres
|
|
24
|
+
database with RLS installed.
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
┌─ alice (owner) ────────┐ ┌─ bob (member) ─────────┐ ┌─ carol (member) ──────┐
|
|
28
|
+
│ psql / lattice gui │ │ psql / lattice gui │ │ psql / lattice gui │
|
|
29
|
+
│ role: alice │ │ role: lm_bob_a91c │ │ role: lm_carol_3f70 │
|
|
30
|
+
│ password: •••••• │ │ password: •••••• │ │ password: •••••• │
|
|
31
|
+
└───────────┬────────────┘ └───────────┬────────────┘ └──────────┬────────────┘
|
|
32
|
+
│ direct postgres:// connection, each as its OWN scoped role │
|
|
33
|
+
└──────────────────────────────┬──────────────────────────────────┘
|
|
34
|
+
▼
|
|
35
|
+
┌─ Cloud Postgres (RLS installed) ────────────┐
|
|
36
|
+
│ your user-defined tables │
|
|
37
|
+
│ └ ENABLE + FORCE ROW LEVEL SECURITY │
|
|
38
|
+
│ __lattice_owners (row → owner role) │
|
|
39
|
+
│ __lattice_row_grants (custom grants) │
|
|
40
|
+
│ __lattice_changes (append-only feed) │
|
|
41
|
+
│ lattice_members (group role) │
|
|
42
|
+
│ SECURITY DEFINER fns: lattice_row_visible, │
|
|
43
|
+
│ lattice_set_row_visibility, _grant, … │
|
|
44
|
+
└─────────────────────────────────────────────┘
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
**Each member connects directly to Postgres as their own scoped, non-superuser
|
|
48
|
+
role** — never a shared owner/superuser connection string. Postgres RLS is the
|
|
49
|
+
security boundary: a member who opens a raw `psql` against their own connection
|
|
50
|
+
**physically cannot read or write another member's rows**. There is no privileged
|
|
51
|
+
layer to bypass because there is no layer at all — the database is the boundary.
|
|
52
|
+
|
|
53
|
+
The DBA's entire job is to **set up the Postgres database and create
|
|
54
|
+
usernames/passwords**. Lattice installs the security model on top using plain SQL:
|
|
55
|
+
`CREATE ROLE`, `CREATE POLICY`, `FORCE ROW LEVEL SECURITY`, and a handful of
|
|
56
|
+
`SECURITY DEFINER` functions.
|
|
57
|
+
|
|
58
|
+
### Identity is the Postgres role
|
|
59
|
+
|
|
60
|
+
A member's identity is simply **which Postgres role they authenticated as**.
|
|
61
|
+
Policies key on `session_user` / `current_user`, which Postgres resolves from the
|
|
62
|
+
login — it is reliable even behind a **transaction-mode connection pooler**, where
|
|
63
|
+
`SET LOCAL`-based identity schemes break because a pooled transaction can land on
|
|
64
|
+
any backend. Because the login role _is_ the identity, there is nothing to spoof:
|
|
65
|
+
to act as another member you would need that member's password.
|
|
66
|
+
|
|
67
|
+
---
|
|
68
|
+
|
|
69
|
+
## The security model
|
|
70
|
+
|
|
71
|
+
Lattice installs the cloud security model in two parts, both via plain SQL
|
|
72
|
+
migrations against the cloud Postgres.
|
|
73
|
+
|
|
74
|
+
### 1. Bootstrap (once per cloud)
|
|
75
|
+
|
|
76
|
+
`installCloudRls(db)` creates the shared machinery:
|
|
77
|
+
|
|
78
|
+
- **`lattice_members`** — a `NOLOGIN` group role. Table and schema privileges are
|
|
79
|
+
granted to the group, so adding a member or a shared table is a single `GRANT`.
|
|
80
|
+
The group grants _access_; RLS still filters _visibility_ per individual login
|
|
81
|
+
role. Membership in the group never lets you see another member's rows.
|
|
82
|
+
- **`__lattice_owners`** `(table_name, pk, owner_role, visibility, …)` — the
|
|
83
|
+
out-of-band record of who owns each row and how widely it's shared
|
|
84
|
+
(`private` | `everyone` | `custom`). It is never injected into your tables and
|
|
85
|
+
members cannot read or write it directly.
|
|
86
|
+
- **`__lattice_row_grants`** `(table_name, pk, grantee_role, granted_by, …)` — the
|
|
87
|
+
explicit grant list backing `custom` visibility.
|
|
88
|
+
- **`__lattice_changes`** — an append-only change feed (`seq`, `table_name`, `pk`,
|
|
89
|
+
`op`, `owner_role`, `created_at`). A per-row `AFTER INSERT` trigger fires
|
|
90
|
+
`pg_notify('lattice_changes', …)` carrying only _metadata_ (table, pk, op) — never
|
|
91
|
+
row content — so a connected GUI can refetch the affected row _through RLS_.
|
|
92
|
+
- **`SECURITY DEFINER` functions** that read the bookkeeping a member can't:
|
|
93
|
+
- `lattice_row_visible(table, pk)` — the visibility predicate the policies call,
|
|
94
|
+
keyed on `session_user`. A row with no ownership record is visible to nobody.
|
|
95
|
+
- `lattice_set_row_visibility(table, pk, visibility)` — owner-only; raises if the
|
|
96
|
+
caller isn't the row's owner.
|
|
97
|
+
- `lattice_grant_row(table, pk, grantee)` / `lattice_revoke_row(table, pk, grantee)`
|
|
98
|
+
— owner-only; manage the `custom` grant list.
|
|
99
|
+
|
|
100
|
+
Every cloud `SECURITY DEFINER` function pins `search_path = "<schema>", pg_temp`
|
|
101
|
+
(the cloud schema first, `pg_temp` **last**). Without the pin, a definer function
|
|
102
|
+
resolves unqualified table names via the _caller's_ `pg_temp` first, so a member
|
|
103
|
+
could `CREATE TEMP TABLE __lattice_owners(...)` to shadow the bookkeeping and make
|
|
104
|
+
the visibility check return whatever they like — a full RLS bypass. The pin forces
|
|
105
|
+
every unqualified name to resolve against the real schema; the installer also
|
|
106
|
+
revokes schema `CREATE` from `PUBLIC` as defense-in-depth.
|
|
107
|
+
|
|
108
|
+
### 2. Per-table RLS
|
|
109
|
+
|
|
110
|
+
`enableRlsForTable(db, table, pkCols)` secures one shared table:
|
|
111
|
+
|
|
112
|
+
```sql
|
|
113
|
+
ALTER TABLE "items" ENABLE ROW LEVEL SECURITY;
|
|
114
|
+
ALTER TABLE "items" FORCE ROW LEVEL SECURITY; -- applies even to the table owner
|
|
115
|
+
GRANT SELECT, INSERT, UPDATE, DELETE ON "items" TO lattice_members;
|
|
116
|
+
|
|
117
|
+
CREATE POLICY "lattice_sel" ON "items" FOR SELECT USING (lattice_row_visible('items', CAST("id" AS TEXT)));
|
|
118
|
+
CREATE POLICY "lattice_upd" ON "items" FOR UPDATE USING (...) WITH CHECK (...);
|
|
119
|
+
CREATE POLICY "lattice_del" ON "items" FOR DELETE USING (...);
|
|
120
|
+
CREATE POLICY "lattice_ins" ON "items" FOR INSERT WITH CHECK (true);
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
plus a per-table `SECURITY DEFINER` trigger that, on every write, stamps the
|
|
124
|
+
inserting member as the row's owner in `__lattice_owners` and records the change in
|
|
125
|
+
`__lattice_changes`. Members cannot write the bookkeeping tables directly — only the
|
|
126
|
+
definer-owned trigger can.
|
|
127
|
+
|
|
128
|
+
`FORCE ROW LEVEL SECURITY` is the critical flag: without it, the table's owner role
|
|
129
|
+
would bypass its own policies. With it, the policies apply to everyone, so the cloud
|
|
130
|
+
owner is bound by the same row rules as any member.
|
|
131
|
+
|
|
132
|
+
> **Composite keys.** The `pk` string written to `__lattice_owners.pk` uses
|
|
133
|
+
> Lattice's canonical serialization: a single-column key is the bare value; a
|
|
134
|
+
> composite key is its columns joined by a TAB (`chr(9)`). This is the same key the
|
|
135
|
+
> change feed and the row-visibility functions use.
|
|
136
|
+
|
|
137
|
+
> **Empirically verified.** Two non-superuser roles connecting directly cannot
|
|
138
|
+
> see, update, or delete each other's private rows; cannot read the bookkeeping
|
|
139
|
+
> tables; cannot `DISABLE ROW LEVEL SECURITY`; and cannot `SET ROLE` to another
|
|
140
|
+
> member.
|
|
141
|
+
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
## The role & privilege model
|
|
145
|
+
|
|
146
|
+
| Who | Postgres role attributes | Can do |
|
|
147
|
+
| ---------- | ------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------- |
|
|
148
|
+
| **Owner** | a normal login role with **`CREATEROLE`** | migrate a local Lattice in, install RLS, provision members, share/unshare their own rows |
|
|
149
|
+
| **Member** | a login role, **`NOSUPERUSER NOCREATEDB NOCREATEROLE`**, in the `lattice_members` group, with RLS **forced** | read/write only their own + shared rows; cannot escalate, cannot run DDL, cannot read another member's data |
|
|
150
|
+
|
|
151
|
+
A member's credential is a dead end for privilege escalation: the role is
|
|
152
|
+
`NOSUPERUSER`, can't create roles or databases, isn't granted DDL on the schema, and
|
|
153
|
+
RLS is forced on every shared table.
|
|
154
|
+
|
|
155
|
+
The DBA may either let an owner provision member roles (the owner connection needs
|
|
156
|
+
`CREATEROLE`), or pre-create the login roles by hand and skip provisioning entirely —
|
|
157
|
+
Lattice only needs the roles to exist and to be members of `lattice_members`.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
## Sharing: private by default
|
|
162
|
+
|
|
163
|
+
Every row is **private to its owner** the moment it's written — the per-table
|
|
164
|
+
trigger stamps `visibility = 'private'`. The owner opts a row into wider visibility:
|
|
165
|
+
|
|
166
|
+
- **`private`** — only the owner sees it (the default).
|
|
167
|
+
- **`everyone`** — every member of the cloud sees it.
|
|
168
|
+
- **`custom`** — only the owner plus an explicit grant list (`lattice_grant_row`).
|
|
169
|
+
|
|
170
|
+
Sharing is done through the owner-only `SECURITY DEFINER` function — Postgres raises
|
|
171
|
+
for anyone who isn't the row's owner:
|
|
172
|
+
|
|
173
|
+
```sql
|
|
174
|
+
-- Make one row visible to every member of the cloud:
|
|
175
|
+
SELECT lattice_set_row_visibility('items', 'item-42', 'everyone');
|
|
176
|
+
|
|
177
|
+
-- Take it private again:
|
|
178
|
+
SELECT lattice_set_row_visibility('items', 'item-42', 'private');
|
|
179
|
+
|
|
180
|
+
-- Or grant just one member (sets visibility = 'custom'):
|
|
181
|
+
SELECT lattice_grant_row('items', 'item-42', 'lm_bob_a91c');
|
|
182
|
+
SELECT lattice_revoke_row('items', 'item-42', 'lm_bob_a91c');
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
From the library, the same thing through `setRowVisibility` (validates `private` |
|
|
186
|
+
`everyone` before calling the function):
|
|
187
|
+
|
|
188
|
+
```ts
|
|
189
|
+
import { Lattice, setRowVisibility } from 'latticesql';
|
|
190
|
+
|
|
191
|
+
const db = new Lattice('postgres://alice:secret@cloud.example.com:5432/app');
|
|
192
|
+
await db.init();
|
|
193
|
+
|
|
194
|
+
// 'item-42' is the row's canonical primary-key string (composite keys are
|
|
195
|
+
// TAB-joined). Only the row's owner may call this; Postgres raises otherwise.
|
|
196
|
+
await setRowVisibility(db, 'items', 'item-42', 'everyone');
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
Because sharing lives in `__lattice_owners` (out of band), opting a row in or out
|
|
200
|
+
never touches your table's columns.
|
|
201
|
+
|
|
202
|
+
### Per-table defaults & never-share (v3.1)
|
|
203
|
+
|
|
204
|
+
The owner can set a table's policy in `__lattice_table_policy` — **stored and
|
|
205
|
+
enforced in Postgres**, so a direct `psql` insert obeys it too:
|
|
206
|
+
|
|
207
|
+
- **`default_row_visibility`** (`private` | `everyone`) — the visibility NEW rows in
|
|
208
|
+
that table are stamped with. The per-table insert trigger reads it; default
|
|
209
|
+
`private` (unchanged behavior). `setTableDefaultVisibility(db, table, vis)`.
|
|
210
|
+
- **`never_share`** — a hard exclusion. `lattice_set_row_visibility` /
|
|
211
|
+
`lattice_grant_row` / `lattice_grant_cell` RAISE for the table, and its new rows
|
|
212
|
+
are forced private regardless of the default. `setTableNeverShare(db, table, on)`.
|
|
213
|
+
Turning it **on is retroactive**: any already-shared row is reset to private and
|
|
214
|
+
every row/cell grant on the table is dropped, so flagging an existing table
|
|
215
|
+
never-share never leaves previously-shared rows visible. `secrets` is seeded
|
|
216
|
+
never-share by `secureCloud`.
|
|
217
|
+
|
|
218
|
+
```ts
|
|
219
|
+
import { setTableDefaultVisibility, setTableNeverShare } from 'latticesql';
|
|
220
|
+
await setTableDefaultVisibility(db, 'tickets', 'everyone'); // team-shared by default
|
|
221
|
+
await setTableNeverShare(db, 'secrets', true); // can never be shared, ever
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
**"Private mode"** in the GUI chat composer is a per-request override: when on, rows
|
|
225
|
+
the assistant creates that turn are forced private regardless of the table default.
|
|
226
|
+
The row is stamped private **atomically at insert** (`insertForcingVisibility` sets a
|
|
227
|
+
transaction-local GUC the insert trigger reads), so it is never momentarily visible
|
|
228
|
+
at the table default and the change-feed `NOTIFY` only fires once it is already
|
|
229
|
+
private — there is no create-then-demote window.
|
|
230
|
+
|
|
231
|
+
**Secret columns (`owner` audience).** Marking a column secret stores an `owner`
|
|
232
|
+
audience in `__lattice_column_policy`, so Postgres masks it to everyone but the row
|
|
233
|
+
owner via the generated `<table>_v` view — the DB is the boundary (the assistant-side
|
|
234
|
+
redaction is just model-context safety). See _Per-column audiences_ below; the spec
|
|
235
|
+
now lives canonically in the DB and the mask view regenerates from it on change.
|
|
236
|
+
|
|
237
|
+
---
|
|
238
|
+
|
|
239
|
+
## The three user flows
|
|
240
|
+
|
|
241
|
+
There are exactly three things you do with a cloud: **migrate** into one, **join**
|
|
242
|
+
an existing one, or **invite** someone to yours.
|
|
243
|
+
|
|
244
|
+
### 1. Migrate — turn a local Lattice into a cloud
|
|
245
|
+
|
|
246
|
+
You have a local SQLite (or single-user Postgres) Lattice and want to share it. You
|
|
247
|
+
point it at a fresh, empty Postgres database; Lattice copies your data in, installs
|
|
248
|
+
RLS, and stamps **you** as the owner of every migrated row.
|
|
249
|
+
|
|
250
|
+
From the GUI: **Workspace Settings → Migrate to cloud**, which posts to
|
|
251
|
+
`POST /api/dbconfig/migrate-to-cloud` with the target Postgres credentials. The
|
|
252
|
+
handler:
|
|
253
|
+
|
|
254
|
+
1. **Probes** the target with `probeCloud(url)`. It refuses if the database is
|
|
255
|
+
unreachable (`502`) or is _already_ a Lattice cloud (`409` — migrating into it
|
|
256
|
+
would mix two owners' data; you should **join** it instead).
|
|
257
|
+
2. Opens a target Lattice matching your schema and **copies every row** (your
|
|
258
|
+
tables plus the native `files` / `secrets`). Encrypted columns round-trip.
|
|
259
|
+
3. **Installs RLS** (`installCloudRls`) and, for each keyed table,
|
|
260
|
+
**backfills ownership** to your role _before_ forcing RLS (otherwise FORCE would
|
|
261
|
+
hide the not-yet-owned rows from your own backfill `SELECT`), then
|
|
262
|
+
`enableRlsForTable`.
|
|
263
|
+
4. Archives the local SQLite file, saves the connection string encrypted, and
|
|
264
|
+
rewrites the config's `db:` line to reference it by label.
|
|
265
|
+
|
|
266
|
+
After migration you're connected to the cloud as its owner, your rows are private by
|
|
267
|
+
default, and you can invite members.
|
|
268
|
+
|
|
269
|
+
The same flow from the library:
|
|
270
|
+
|
|
271
|
+
```ts
|
|
272
|
+
import {
|
|
273
|
+
Lattice,
|
|
274
|
+
openTargetLatticeForMigration,
|
|
275
|
+
migrateLatticeData,
|
|
276
|
+
installCloudRls,
|
|
277
|
+
backfillOwnership,
|
|
278
|
+
enableRlsForTable,
|
|
279
|
+
archiveLocalSqlite,
|
|
280
|
+
} from 'latticesql';
|
|
281
|
+
|
|
282
|
+
const encryptionKey = process.env.LATTICE_ENCRYPTION_KEY;
|
|
283
|
+
const cloudUrl = 'postgres://alice:secret@cloud.example.com:5432/app';
|
|
284
|
+
|
|
285
|
+
const source = new Lattice({ config: './lattice.config.yml' }, { encryptionKey });
|
|
286
|
+
await source.init();
|
|
287
|
+
|
|
288
|
+
const target = await openTargetLatticeForMigration('./lattice.config.yml', cloudUrl, encryptionKey);
|
|
289
|
+
await migrateLatticeData(source, target); // → { tablesCopied, rowsCopied }
|
|
290
|
+
|
|
291
|
+
// Owner-side RLS install. Backfill ownership BEFORE forcing RLS on each table.
|
|
292
|
+
await installCloudRls(target);
|
|
293
|
+
for (const table of target.getRegisteredTableNames()) {
|
|
294
|
+
if (table.startsWith('__lattice_')) continue;
|
|
295
|
+
const pk = target.getPrimaryKey(table);
|
|
296
|
+
if (pk.length === 0) continue; // unkeyable table — no per-row RLS
|
|
297
|
+
await backfillOwnership(target, table, pk);
|
|
298
|
+
await enableRlsForTable(target, table, pk);
|
|
299
|
+
}
|
|
300
|
+
target.close();
|
|
301
|
+
|
|
302
|
+
archiveLocalSqlite('./data/app.db'); // renames to .db.local-bak
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
### 2. Join — redeem your email-bound invite token
|
|
306
|
+
|
|
307
|
+
The owner sent you a single **invite token**, bound to your email. From the GUI:
|
|
308
|
+
**+ New workspace… → Join a cloud**, then enter your **email + the token**. The
|
|
309
|
+
token decrypts **locally** — the email is required to derive the key — to the
|
|
310
|
+
scoped connection details the owner minted, and the same connect path runs. The
|
|
311
|
+
member UI never handles a `postgres://` string. It posts to
|
|
312
|
+
`POST /api/cloud/redeem-invite` → the shared join path (`joinCloudAsMember`, the
|
|
313
|
+
same logic as `connect-existing`), which:
|
|
314
|
+
|
|
315
|
+
1. **Probes** the target as your role. The probe both authenticates the login and
|
|
316
|
+
confirms the database is actually a Lattice cloud (RLS installed). It refuses if
|
|
317
|
+
unreachable (`502`) or if the database isn't a cloud yet (`409` — the owner must
|
|
318
|
+
migrate a local Lattice into it first).
|
|
319
|
+
2. Saves the credential encrypted, rewrites the config, and opens the cloud. Your
|
|
320
|
+
role can't (and needn't) run DDL — the owner already created the role and RLS
|
|
321
|
+
confines it — so the cloud is opened in introspect-only mode.
|
|
322
|
+
|
|
323
|
+
From there you query the cloud directly and see exactly the rows RLS allows: your
|
|
324
|
+
own, anything shared to `everyone`, and anything granted to you specifically.
|
|
325
|
+
|
|
326
|
+
```ts
|
|
327
|
+
import { Lattice, probeCloud } from 'latticesql';
|
|
328
|
+
|
|
329
|
+
const url = 'postgres://lm_bob_a91c:the-password@cloud.example.com:5432/app';
|
|
330
|
+
|
|
331
|
+
const probe = await probeCloud(url);
|
|
332
|
+
// → { reachable: true, dialect: 'postgres', isCloud: true }
|
|
333
|
+
if (!probe.reachable) throw new Error(probe.error);
|
|
334
|
+
if (!probe.isCloud) throw new Error('Not a Lattice cloud yet — ask the owner to migrate into it.');
|
|
335
|
+
|
|
336
|
+
const db = new Lattice(url);
|
|
337
|
+
await db.init();
|
|
338
|
+
const visibleItems = await db.query('items'); // RLS-filtered to what you may see
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### 3. Invite — mint an email-bound token for a member
|
|
342
|
+
|
|
343
|
+
You own a cloud and want to add someone. As the owner (your connection holds
|
|
344
|
+
`CREATEROLE`), you provision a scoped member role and hand them a single
|
|
345
|
+
email-bound token that carries its credential.
|
|
346
|
+
|
|
347
|
+
From the GUI: **Workspace Settings → Database Connection → Invite a member**, and
|
|
348
|
+
enter the invitee's **email**. It posts to `POST /api/cloud/invite`, which verifies
|
|
349
|
+
the active database is a cloud and that your role can manage roles (`403`
|
|
350
|
+
otherwise), provisions a fresh scoped role, **asserts it is non-privileged** (never
|
|
351
|
+
a superuser / `CREATEROLE` / `BYPASSRLS` / the owner — refused otherwise), mints the
|
|
352
|
+
token, writes an owner-only audit row (`__lattice_member_invites`; email hashed, no
|
|
353
|
+
password stored), and returns just the token:
|
|
354
|
+
|
|
355
|
+
```jsonc
|
|
356
|
+
{ "ok": true, "token": "AaR…base64url…", "role": "lm_bob_a91c", "email": "bob@example.com" }
|
|
357
|
+
```
|
|
358
|
+
|
|
359
|
+
Send that token to the invitee (privately); they redeem it with their email in
|
|
360
|
+
flow 2. It expires in ~7 days.
|
|
361
|
+
|
|
362
|
+
**Token threat model (bearer / magic-link grade).** The token _is_ the secret. It
|
|
363
|
+
embeds a random secret plus the scoped credential, encrypted with AES-256-GCM under
|
|
364
|
+
a key derived via `HKDF(token secret)` salted by `scrypt(email)`, with the email as
|
|
365
|
+
GCM AAD — so it decrypts **only with the matching email**. The email _binds_ the
|
|
366
|
+
token (you cannot redeem it without knowing the email) but is not strong
|
|
367
|
+
confidentiality against an attacker holding _both_ the token and the email. The real
|
|
368
|
+
protections are: **private delivery**, **short expiry**, and the embedded credential
|
|
369
|
+
being a **scoped, RLS-confined, revocable** `lm_*` role — so a leaked token grants
|
|
370
|
+
only what that member could already see, and the owner can revoke it.
|
|
371
|
+
|
|
372
|
+
The same thing from the library:
|
|
373
|
+
|
|
374
|
+
```ts
|
|
375
|
+
import { Lattice, memberRoleName, generateMemberPassword, provisionMemberRole } from 'latticesql';
|
|
376
|
+
|
|
377
|
+
// owner connection — must hold CREATEROLE
|
|
378
|
+
const db = new Lattice('postgres://alice:secret@cloud.example.com:5432/app');
|
|
379
|
+
await db.init();
|
|
380
|
+
|
|
381
|
+
const role = memberRoleName('bob'); // e.g. 'lm_bob_a91c' — collision-safe, ≤63 bytes
|
|
382
|
+
const password = generateMemberPassword(); // 48 hex chars
|
|
383
|
+
await provisionMemberRole(db, role, password);
|
|
384
|
+
// Member is created NOSUPERUSER NOCREATEDB NOCREATEROLE and added to lattice_members.
|
|
385
|
+
|
|
386
|
+
// Hand off: host / port / dbname + user=role + password
|
|
387
|
+
```
|
|
388
|
+
|
|
389
|
+
Removing a member is the inverse — `revokeMemberRole(db, role)` drops the role.
|
|
390
|
+
(Rows the departed member owned stay in their tables but become unreachable until
|
|
391
|
+
you deliberately reassign or purge them — revoking access is not the same as
|
|
392
|
+
purging their data.)
|
|
393
|
+
|
|
394
|
+
```ts
|
|
395
|
+
import { Lattice, revokeMemberRole } from 'latticesql';
|
|
396
|
+
await revokeMemberRole(db, 'lm_bob_a91c');
|
|
397
|
+
```
|
|
398
|
+
|
|
399
|
+
---
|
|
400
|
+
|
|
401
|
+
## How a member opens the cloud
|
|
402
|
+
|
|
403
|
+
A member opens the cloud by **connecting directly as their scoped role** — there is
|
|
404
|
+
no separate sign-in. The connection string the owner handed over (host / port /
|
|
405
|
+
database / username / password) is the whole credential. Lattice opens the cloud in
|
|
406
|
+
introspect-only mode for a member: the role is `NOSUPERUSER` without DDL on the
|
|
407
|
+
schema, so it never tries to create or alter tables — it reads the existing schema
|
|
408
|
+
and works against the rows RLS lets it see. Every query, insert, update, and delete
|
|
409
|
+
runs as that role, so RLS scopes the member to their own rows plus whatever has been
|
|
410
|
+
shared with them. Nothing about being a member requires elevated privileges or a
|
|
411
|
+
side channel; the database does all the gatekeeping.
|
|
412
|
+
|
|
413
|
+
---
|
|
414
|
+
|
|
415
|
+
## GUI cloud endpoints
|
|
416
|
+
|
|
417
|
+
`lattice gui` drives all three flows from the browser. The relevant endpoints (all
|
|
418
|
+
localhost-only, same model as the rest of the GUI):
|
|
419
|
+
|
|
420
|
+
| Method | Route | Does |
|
|
421
|
+
| ------ | -------------------------------- | -------------------------------------------------------------------- |
|
|
422
|
+
| POST | `/api/dbconfig/migrate-to-cloud` | Migrate the active local Lattice into a fresh cloud (you = owner) |
|
|
423
|
+
| POST | `/api/dbconfig/connect-existing` | Join a cloud directly with scoped credentials (the invite) |
|
|
424
|
+
| POST | `/api/cloud/invite` | Owner provisions a scoped member role; returns the connection blob |
|
|
425
|
+
| POST | `/api/cloud/share` | Owner sets a row's visibility (`private` \| `everyone`) |
|
|
426
|
+
| GET | `/api/cloud/s3-config` | Read this member's S3 config (secret redacted) |
|
|
427
|
+
| POST | `/api/cloud/s3-config` | Owner sets S3 for the cloud (see **S3-backed file bytes** below) |
|
|
428
|
+
| GET | `/api/cloud/system-prompt` | Owner reads the chat system prompt (members get no text) |
|
|
429
|
+
| POST | `/api/cloud/system-prompt` | Owner sets the chat system prompt (see **Chat system prompt** below) |
|
|
430
|
+
|
|
431
|
+
`POST /api/cloud/share` body is `{ table, pk, visibility }` and calls
|
|
432
|
+
`setRowVisibility` under the hood; Postgres raises if you aren't the row's owner.
|
|
433
|
+
|
|
434
|
+
The probe used throughout is `probeCloud(url)`, returning
|
|
435
|
+
`{ reachable, dialect, isCloud }` — `isCloud` is `true` when the target Postgres
|
|
436
|
+
already has the RLS machinery installed.
|
|
437
|
+
|
|
438
|
+
---
|
|
439
|
+
|
|
440
|
+
## Offline editing
|
|
441
|
+
|
|
442
|
+
Offline editing is preserved as a **client-side local edit queue**: edits you make
|
|
443
|
+
while disconnected are held locally and replayed when you reconnect. This is a
|
|
444
|
+
client behavior only — it is **not** tied to any replica or sync server (there is
|
|
445
|
+
no server). When you reconnect, the queued writes go to the cloud as your role and
|
|
446
|
+
land under the same RLS rules as any other write.
|
|
447
|
+
|
|
448
|
+
---
|
|
449
|
+
|
|
450
|
+
## Chat system prompt (owner-set, per workspace)
|
|
451
|
+
|
|
452
|
+
A cloud **owner** can set a **chat system prompt** that's bundled into every
|
|
453
|
+
member's assistant chat for that workspace — house style, domain facts, a fiscal
|
|
454
|
+
calendar, whatever the team should always have in context. The owner edits it in
|
|
455
|
+
**workspace settings** ("Edit chat system prompt"); members never see the control.
|
|
456
|
+
It's stored in the shared DB (`__lattice_cloud_settings`, key `chat_system_prompt`)
|
|
457
|
+
and injected into the system message of each member's turn, alongside the live
|
|
458
|
+
schema.
|
|
459
|
+
|
|
460
|
+
**Owner-only to view and edit, app-mediated.** Two `SECURITY DEFINER` helpers back
|
|
461
|
+
it (`src/cloud/settings.ts`): `lattice_set_cloud_setting` raises unless the caller
|
|
462
|
+
can create roles (the owner gate), and `lattice_get_cloud_setting` is the read path.
|
|
463
|
+
The member group has **no grant on the table**, so a member's `SELECT` is denied —
|
|
464
|
+
the prompt **value** is unreadable to them through the data API (the System view may
|
|
465
|
+
still list the table's existence + column names from the catalog, like every other
|
|
466
|
+
`__lattice_*` table, but never its contents). And `GET /api/cloud/system-prompt`
|
|
467
|
+
returns the text **only to an owner** (a member gets `canEdit: false` and no text).
|
|
468
|
+
|
|
469
|
+
> **Ceiling — same shape as the S3 feature.** This is **app-mediated** secrecy, not
|
|
470
|
+
> cryptographic. Each member's chat call is assembled in their OWN local process
|
|
471
|
+
> over their OWN scoped connection, so the prompt must be readable by that
|
|
472
|
+
> connection to be injected — which means `lattice_get_cloud_setting` is callable by
|
|
473
|
+
> a member, and a member who goes looking (calling the function in `psql`, or
|
|
474
|
+
> inspecting their app's outbound request to the model) **can** read the prompt.
|
|
475
|
+
> What this guarantees is that the prompt is hidden from the product surface (the
|
|
476
|
+
> UI and every API response), not that it is secret from a member's own SQL session.
|
|
477
|
+
> True secrecy would require a server-side chat relay members can't reach, which the
|
|
478
|
+
> no-server v3 model deliberately doesn't have.
|
|
479
|
+
|
|
480
|
+
On a local SQLite workspace there are no members and nothing to keep secret, so the
|
|
481
|
+
editor is hidden (`GET` reports `supported: false`).
|
|
482
|
+
|
|
483
|
+
---
|
|
484
|
+
|
|
485
|
+
## S3-backed file bytes (opt-in)
|
|
486
|
+
|
|
487
|
+
A `files` row is shared by RLS like any other row, but a file's **bytes** are
|
|
488
|
+
written to the uploader's local disk (`<root>/data/blobs/<sha256>`). On a cloud
|
|
489
|
+
that means another member can SELECT the row yet can't fetch the bytes — they live
|
|
490
|
+
on someone else's machine. Enabling S3 closes that gap: uploaded bytes also go to
|
|
491
|
+
an S3 bucket, and any member who can see the row pulls them down in the viewer.
|
|
492
|
+
|
|
493
|
+
**It adds no new access machinery — it rides the same `files`-row RLS.** The serve
|
|
494
|
+
route does `db.get('files', id)` as the member's own scoped role; a row RLS won't
|
|
495
|
+
let them SELECT returns NULL → 404 _before S3 is ever touched_. The object key is
|
|
496
|
+
content-addressed (`<prefix>/<sha256>`) and appears **only** inside that
|
|
497
|
+
RLS-gated row. The bucket credential grants `GetObject`+`PutObject` and nothing
|
|
498
|
+
else — **no `ListBucket`, no `Delete`** — so the key is the only handle to an
|
|
499
|
+
object and there's no way to enumerate the bucket. A member learns a key exactly
|
|
500
|
+
when, and only when, RLS lets them read the row that holds it.
|
|
501
|
+
|
|
502
|
+
### Enabling it
|
|
503
|
+
|
|
504
|
+
S3 config is **per member, machine-local, and encrypted** — it sits in the same
|
|
505
|
+
AES-GCM store as your scoped DB credential (`db-credentials.enc`), never in the
|
|
506
|
+
shared database. Set it from the GUI (owner-only `POST /api/cloud/s3-config`) or
|
|
507
|
+
via environment variables for headless/CI use:
|
|
508
|
+
|
|
509
|
+
| Variable | Meaning |
|
|
510
|
+
| --------------------------------------------- | ----------------------------------------------------- |
|
|
511
|
+
| `LATTICE_S3_BUCKET` | Bucket name (enables S3 when set) |
|
|
512
|
+
| `LATTICE_S3_REGION` | Region (falls back to `AWS_REGION`) |
|
|
513
|
+
| `LATTICE_S3_PREFIX` | Key prefix; defaults to `blobs` |
|
|
514
|
+
| `LATTICE_S3_ENDPOINT` | Custom endpoint for S3-compatible stores (R2 / MinIO) |
|
|
515
|
+
| `AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` | Credentials (or the standard AWS chain) |
|
|
516
|
+
|
|
517
|
+
`@aws-sdk/client-s3` is an **optional dependency**, lazy-imported only when S3 is
|
|
518
|
+
enabled. If it isn't installed, uploads fall back to local-only and the serve route
|
|
519
|
+
reports that the bytes aren't reachable here — it never 500s. Setting
|
|
520
|
+
`LATTICE_S3_ENDPOINT` switches the client to path-style addressing, which is what
|
|
521
|
+
R2, MinIO, and LocalStack expect.
|
|
522
|
+
|
|
523
|
+
When enabled, an upload keeps the **local blob too** (hybrid): the uploader gets
|
|
524
|
+
instant local preview while every other member streams from S3. The row records
|
|
525
|
+
`ref_kind='cloud_ref'`, `ref_provider='s3'`, `ref_uri='s3://<bucket>/<key>'`, and
|
|
526
|
+
`source_json={bucket,key,region,size_bytes}` — no schema migration; these fields
|
|
527
|
+
already exist.
|
|
528
|
+
|
|
529
|
+
The fallback is **never silent**: if S3 is enabled but the upload's PUT fails
|
|
530
|
+
(rotated credential, outage, region mismatch, SDK absent), the upload still
|
|
531
|
+
succeeds locally — but the response carries `s3: { status: 'failed', error }` so
|
|
532
|
+
the uploader is told the bytes did **not** reach the shared bucket. Other members
|
|
533
|
+
fetch from S3, so a file shared after a failed PUT would 404 for everyone but the
|
|
534
|
+
uploader; surfacing the failure lets the GUI prompt a re-upload instead of implying
|
|
535
|
+
a clean share. A clean upload returns `s3: { status: 'stored', key }`.
|
|
536
|
+
|
|
537
|
+
The serve route (`GET /api/files/:id/blob`) sends bytes inline with
|
|
538
|
+
`X-Content-Type-Options: nosniff` and a no-allowances `Content-Security-Policy:
|
|
539
|
+
sandbox`. Both the bytes and the row's `mime` are member-writable (PutObject to the
|
|
540
|
+
shared bucket + the generic row CRUD), so without these headers a member could stage
|
|
541
|
+
`text/html` and have it execute in another member's GUI origin. Image/PDF previews
|
|
542
|
+
still render (they load `/blob` as a subresource); a hostile HTML blob opened
|
|
543
|
+
directly is inert.
|
|
544
|
+
|
|
545
|
+
### Least-privilege IAM policy
|
|
546
|
+
|
|
547
|
+
Grant the workspace credential exactly this — `GetObject` + `PutObject` scoped to
|
|
548
|
+
the prefix, and nothing that can list or delete:
|
|
549
|
+
|
|
550
|
+
```json
|
|
551
|
+
{
|
|
552
|
+
"Version": "2012-10-17",
|
|
553
|
+
"Statement": [
|
|
554
|
+
{
|
|
555
|
+
"Sid": "LatticeCloudBlobsReadWrite",
|
|
556
|
+
"Effect": "Allow",
|
|
557
|
+
"Action": ["s3:GetObject", "s3:PutObject"],
|
|
558
|
+
"Resource": "arn:aws:s3:::YOUR_BUCKET/blobs/*"
|
|
559
|
+
}
|
|
560
|
+
]
|
|
561
|
+
}
|
|
562
|
+
```
|
|
563
|
+
|
|
564
|
+
Do **not** add `s3:ListBucket` or `s3:DeleteObject`. ListBucket would let a holder
|
|
565
|
+
enumerate every key (defeating the "the key only appears in an RLS row" boundary);
|
|
566
|
+
Delete isn't needed by the app and widens the blast radius of a leaked credential.
|
|
567
|
+
Enable default bucket encryption (SSE-S3 or SSE-KMS) and block public access.
|
|
568
|
+
|
|
569
|
+
### Security model & caveats (read before enabling)
|
|
570
|
+
|
|
571
|
+
This is **app-mediated** access control, which is the honest ceiling for a model
|
|
572
|
+
with no Lattice server in front of the bytes. Understand what it does and does not
|
|
573
|
+
guarantee:
|
|
574
|
+
|
|
575
|
+
1. **Byte-access is app-mediated, not S3-enforced.** The boundary is "the
|
|
576
|
+
RLS-gated `files` row is the only place the object key appears." S3 itself can't
|
|
577
|
+
tell one member from another — both present the same shared credential.
|
|
578
|
+
2. **Revocation doesn't retract bytes.** Un-sharing a row (`visibility → private`)
|
|
579
|
+
stops future DB reads, but a member who already fetched the object (or just
|
|
580
|
+
learned its key) keeps access to the content-addressed object. Rotate/relocate
|
|
581
|
+
the object if you need true retraction.
|
|
582
|
+
3. **The shared workspace credential is a single point of compromise.** Treat it
|
|
583
|
+
exactly like the owner's Postgres URL — machine-local, `0600`, encrypted. Anyone
|
|
584
|
+
with the credential _and_ a key can read that object.
|
|
585
|
+
4. **No per-member S3 audit.** CloudTrail sees one shared principal, so S3 access
|
|
586
|
+
logs can't attribute a fetch to a person. DB-layer provenance still attributes
|
|
587
|
+
the row itself.
|
|
588
|
+
5. **Security depends on never granting `ListBucket` and always using the
|
|
589
|
+
unguessable sha256 keys.** Both are required; relaxing either collapses the
|
|
590
|
+
boundary.
|
|
591
|
+
|
|
592
|
+
For teams that need true per-member byte enforcement and per-member audit, the
|
|
593
|
+
documented upgrade path is an online, RLS-checking presign edge (it reintroduces a
|
|
594
|
+
small server that signs per-member, time-boxed URLs only after re-checking RLS) —
|
|
595
|
+
out of scope here. Crypto-shred of S3 objects on row delete is likewise an
|
|
596
|
+
owner-run admin follow-up.
|
|
597
|
+
|
|
598
|
+
---
|
|
599
|
+
|
|
600
|
+
## Limits & notes
|
|
601
|
+
|
|
602
|
+
- **Cloud is Postgres-only.** SQLite has no roles or RLS; a local SQLite Lattice is
|
|
603
|
+
your private single-user store. The bridge between them is **migrate**.
|
|
604
|
+
- **Identity is the Postgres login.** There is no separate account system. A member
|
|
605
|
+
_is_ their role; to act as someone else you'd need their password. Provision
|
|
606
|
+
one role per person so you can revoke individually.
|
|
607
|
+
- **Owner needs `CREATEROLE`** to provision members from Lattice. Alternatively the
|
|
608
|
+
DBA pre-creates the login roles by hand and adds them to `lattice_members`.
|
|
609
|
+
- **Departed members' rows persist.** `revokeMemberRole` drops the role but leaves
|
|
610
|
+
the rows it owned (now unreachable). Reassign or purge them deliberately.
|
|
611
|
+
- **Postgres SSL** is governed by your connection string — pass `?sslmode=require`
|
|
612
|
+
(or your provider's equivalent). Lattice doesn't override it.
|
|
613
|
+
- **Connection strings are secrets.** A member's scoped string is safe to hand to
|
|
614
|
+
that member (RLS confines it), but the **owner's** string is a superuser-adjacent
|
|
615
|
+
credential to the whole cloud — keep it encrypted and never share it.
|
|
616
|
+
|
|
617
|
+
---
|
|
618
|
+
|
|
619
|
+
## Per-column audiences & per-viewer values (experimental)
|
|
620
|
+
|
|
621
|
+
RLS is whole-row. Two layered primitives take it to the **cell** and to
|
|
622
|
+
**per-viewer values**, so different members can legitimately see different
|
|
623
|
+
versions of the same entity. Both are off by default — a column with no
|
|
624
|
+
`audience` behaves exactly as before.
|
|
625
|
+
|
|
626
|
+
**1. Per-column masking — a generated view.** Declare an `audience` on a column
|
|
627
|
+
and Lattice generates a cell-masking view `<table>_v` beside the base table:
|
|
628
|
+
|
|
629
|
+
```yaml
|
|
630
|
+
person:
|
|
631
|
+
fields:
|
|
632
|
+
id: { type: uuid, primaryKey: true }
|
|
633
|
+
name: { type: text }
|
|
634
|
+
comp: { type: text, audience: 'subject:subject_role+role:hr' }
|
|
635
|
+
subject_role: { type: text }
|
|
636
|
+
```
|
|
637
|
+
|
|
638
|
+
The audience is a `+`-joined (OR) set of clauses — `role:<name>`,
|
|
639
|
+
`subject:<col>`, `source:<col>`, or `everyone`. Each compiles to a
|
|
640
|
+
`session_user`-keyed `SECURITY DEFINER` predicate (`lattice_has_role` /
|
|
641
|
+
`lattice_is_subject` / `lattice_source_visible`), so the mask binds to the real
|
|
642
|
+
member even though the view runs with its owner's rights. Members `SELECT` the
|
|
643
|
+
view (base `SELECT` is revoked); a masked cell reads as `NULL`. App roles are
|
|
644
|
+
owner-managed via `lattice_assign_role(member, role)` — members can't
|
|
645
|
+
self-promote. Generated by `enableAudienceView`, never hand-edited.
|
|
646
|
+
|
|
647
|
+
**2. Per-viewer values — a local fold.** When a value is _derived_ from a source
|
|
648
|
+
(e.g. an enrichment computed from a file), it is only valid for a member who can
|
|
649
|
+
see that source. `foldEntity(ground, observations, viewer)` (in `latticesql`)
|
|
650
|
+
overlays the observations a viewer is allowed to see onto the ground-truth
|
|
651
|
+
projection — latest visible observation per attribute wins. It is a pure,
|
|
652
|
+
deterministic, additive fold, so a member who can't reach a source simply never
|
|
653
|
+
sees its derived value, and **un-sharing the source reverts the value with no
|
|
654
|
+
residue** (revocation is structural, not a cleanup job). Run it on the member's
|
|
655
|
+
local replica over already-gated observations, cached with `FoldCache` and
|
|
656
|
+
re-rendered only when an observation changes — egress is paid once at pull.
|
|
657
|
+
|
|
658
|
+
**Change-log visibility.** The observation substrate (`__lattice_changelog`) is RLS-
|
|
659
|
+
protected with two cases. A **derived** observation is visible only to a member who
|
|
660
|
+
can reach every source it was derived from (the source-visibility predicate above) —
|
|
661
|
+
existence-hiding is structural. A **ground-truth / audit** entry is **owner-only**
|
|
662
|
+
(`lattice_is_owner`): it carries the full row in cleartext, including columns the
|
|
663
|
+
`<table>_v` mask hides from a non-owner, so the row's raw history is an owner artifact
|
|
664
|
+
— a member who can merely see a shared row reads it through the masked view, never its
|
|
665
|
+
change-log.
|
|
666
|
+
|
|
667
|
+
**3. Forgetting a source — crypto-shred.** For a legally sensitive source, seal
|
|
668
|
+
its derived values under a per-source key (`sealUnderSource`) and call
|
|
669
|
+
`shredSource` to destroy the key — the values become unrecoverable everywhere
|
|
670
|
+
the ciphertext exists, backups included.
|
|
671
|
+
|
|
672
|
+
**Source sharing is direct (for now).** Source visibility reduces to the
|
|
673
|
+
source row's own RLS. Transitive source-sharing (folders / teams / inheritance,
|
|
674
|
+
a ReBAC-style model) is intentionally deferred until that shape is actually
|
|
675
|
+
needed — the primitives above compose with it when it lands.
|