Skip to content

Commit 5da98b4

Browse files
cpcloudclaude
andauthored
docs(website): update architecture and relay docs for RLS and key.Binding (#876)
## Summary - **architecture.md**: Fix stale "kong" CLI framework reference (now Cobra), document `key.Binding` / `AppKeyMap` migration in key dispatch section and data flow diagram - **relay-architecture.md**: Add "Row-level security" section covering RLS policies, `rlsdb` package (`Tx` / `WithoutHousehold`), and protected tables matrix; add `rlsdb/` to package layout; cross-reference RLS in schema section - **self-hosting.md**: Add brief RLS callout with `relref` link to the relay architecture page Generated with [Claude Code](https://claude.ai/code) --------- Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 6622f36 commit 5da98b4

6 files changed

Lines changed: 109 additions & 17 deletions

File tree

docs/content/docs/development/architecture.md

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -11,10 +11,10 @@ application following The Elm Architecture (TEA): Model, Update, View.
1111
## Package layout
1212

1313
```
14-
cmd/micasa/ CLI entry point (kong argument parsing)
14+
cmd/micasa/ CLI entry point (Cobra argument parsing)
1515
internal/
1616
app/ Bubble Tea application layer
17-
model.go Model struct, Init, Update, key dispatch
17+
model.go Model struct, Init, Update, key dispatch (key.Binding)
1818
types.go Mode, Tab, cell, columnSpec, etc.
1919
handlers.go TabHandler interface + entity implementations
2020
tables.go Column specs, row builders, table construction
@@ -68,8 +68,9 @@ free.
6868

6969
### Modal key handling
7070

71-
micasa uses three modes: Nav, Edit, and Form. The key dispatch chain in
72-
`Update()` is:
71+
micasa uses three modes: Nav, Edit, and Form. Key dispatch uses structured
72+
`key.Binding` / `key.Matches()` from bubbles, centralized in an `AppKeyMap`
73+
struct. The dispatch chain in `Update()` is:
7374

7475
1. Window resize handling
7576
2. <kbd>ctrl+q</kbd> always quits
@@ -115,7 +116,7 @@ roles are defined in `styles.go`.
115116
User keystroke
116117
-> tea.KeyMsg
117118
-> Model.Update()
118-
-> key dispatch (mode-aware)
119+
-> key dispatch (mode-aware, via key.Matches)
119120
-> data mutation (Store CRUD)
120121
-> reloadAfterMutation() (refreshes effective tab, marks others stale)
121122
-> Model.View()

docs/content/docs/development/relay-architecture.md

Lines changed: 64 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
+++
22
title = "Encrypted Relay"
33
weight = 3
4-
description = "How the sync relay works: store interface, auth, encryption, key exchange."
4+
description = "How the sync relay works: store interface, auth, encryption, key exchange, row-level security."
55
linkTitle = "Encrypted Relay"
66
+++
77

@@ -136,6 +136,63 @@ Documents are synced as encrypted blobs, separate from the oplog:
136136
- Oplog entries reference blobs via `blob_ref` field, which is
137137
stripped before applying to the local database
138138

139+
## Row-level security
140+
141+
The relay enforces tenant isolation at the database level using
142+
[Postgres row-level security](https://www.postgresql.org/docs/current/ddl-rowsecurity.html)
143+
(RLS). Even if application code has a bug, one household's data
144+
cannot leak to another.
145+
146+
### How it works
147+
148+
RLS policies on `ops` and `blobs` restrict every query to rows
149+
matching the current session's `app.household_id`:
150+
151+
```sql
152+
CREATE POLICY ops_household_isolation ON ops
153+
USING (household_id = NULLIF(current_setting('app.household_id', true), ''))
154+
WITH CHECK (household_id = NULLIF(current_setting('app.household_id', true), ''));
155+
```
156+
157+
`FORCE ROW LEVEL SECURITY` ensures policies apply even to the
158+
table owner, so no connection can bypass them.
159+
160+
### rlsdb package
161+
162+
The `rlsdb` package (`internal/relay/rlsdb/`) wraps the raw
163+
`*gorm.DB` in an unexported struct, making direct database access
164+
structurally impossible from outside the package. All queries go
165+
through one of two methods:
166+
167+
- **`Tx(ctx, householdID, fn)`** — opens a transaction, calls
168+
`set_config('app.household_id', householdID, true)`, then
169+
executes `fn`. This is the standard path for all household-scoped
170+
operations.
171+
- **`WithoutHousehold(ctx, fn)`** — opens a transaction with
172+
`app.household_id` cleared to empty string. RLS treats this as
173+
NULL, so no `ops` or `blobs` rows are visible. Reserved for
174+
methods that only touch non-RLS tables (`households`, `devices`,
175+
`invites`, `key_exchanges`) where no household ID is available
176+
yet (e.g. device authentication, join flow).
177+
178+
Construction-time helpers:
179+
- **`Migrate(models...)`** — runs `AutoMigrate` with a dummy
180+
household ID so GORM's schema introspection works under `FORCE
181+
ROW LEVEL SECURITY`.
182+
- **`InitRLS(tables)`** — idempotently enables RLS and creates
183+
isolation policies for the given tables.
184+
185+
### Protected tables
186+
187+
| Table | Scoping column | RLS enforced |
188+
|-------|---------------|--------------|
189+
| `ops` | `household_id` | Yes |
190+
| `blobs` | `household_id` | Yes |
191+
| `households` || No (looked up by ID) |
192+
| `devices` || No (looked up by token hash) |
193+
| `invites` || No (looked up by code) |
194+
| `key_exchanges` || No (short-lived, scrubbed after use) |
195+
139196
## Database schema (PostgreSQL)
140197

141198
```
@@ -147,6 +204,9 @@ key_exchanges — id, household_id, joiner info, encrypted credentials
147204
blobs — household_id, hash, data, size_bytes
148205
```
149206

207+
RLS policies on `ops` and `blobs` enforce household isolation at the
208+
database level (see [Row-level security](#row-level-security) above).
209+
150210
All table names use bare English (not `pg_` prefixed). The Go
151211
struct names use a `pg` prefix (`pgHousehold`, `pgDevice`) to
152212
distinguish them from the `sync.Household` and `sync.Device`
@@ -183,7 +243,8 @@ sequenceDiagram
183243
R->>R: assign sequence numbers, store ciphertext
184244
end
185245
186-
Note over A,B: User runs `micasa pro invite`, shares code
246+
Note over A: User runs `micasa pro invite`
247+
Note over A,B: share invite code out-of-band
187248
188249
rect rgb(240, 235, 228)
189250
Note over A,B: Key exchange
@@ -233,6 +294,7 @@ internal/
233294
store.go Store interface (21 methods)
234295
memstore.go In-memory implementation (testing)
235296
pgstore.go PostgreSQL implementation (production)
297+
rlsdb/ RLS-aware DB wrapper (unexported *gorm.DB, Tx/WithoutHousehold)
236298
stripe.go Webhook signature verification
237299
tokencrypt.go AES-256-GCM token encryption at rest
238300
blob.go Blob storage constants and validation

docs/content/docs/getting-started/self-hosting.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,11 @@ The relay runs two containers: PostgreSQL for storage and the relay
1818
binary for sync traffic. PostgreSQL holds encrypted sync operations,
1919
encrypted document blobs, device registrations, and invite state.
2020
All household data is end-to-end encrypted — the relay never sees
21-
plaintext.
21+
plaintext. PostgreSQL
22+
[row-level security]({{< relref "/docs/development/relay-architecture#row-level-security" >}})
23+
provides an additional isolation layer, ensuring one household's
24+
data is invisible to queries from another even if application code
25+
has a bug.
2226

2327
## Quick start
2428

docs/layouts/_default/baseof.html

Lines changed: 21 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,9 @@
174174
fontFamily: '"Source Serif 4", Georgia, serif',
175175
fontSize: "14px",
176176
background: "transparent"
177+
},
178+
sequence: {
179+
noteMargin: 14
177180
}
178181
});
179182
await mermaid.run();
@@ -218,13 +221,30 @@
218221
parent.replaceChild(frag, textNode);
219222
});
220223

224+
// Widen note rects when monospace text overflows the original box.
225+
svg.querySelectorAll(".note").forEach(function(noteRect) {
226+
var g = noteRect.closest("g");
227+
if (!g) return;
228+
var text = g.querySelector(".noteText");
229+
if (!text) return;
230+
var textWidth = text.getBBox().width;
231+
var rectWidth = parseFloat(noteRect.getAttribute("width") || 0);
232+
var pad = 16;
233+
if (textWidth + pad > rectWidth) {
234+
var diff = textWidth + pad - rectWidth;
235+
noteRect.setAttribute("width", textWidth + pad);
236+
var x = parseFloat(noteRect.getAttribute("x") || 0);
237+
noteRect.setAttribute("x", x - diff / 2);
238+
}
239+
});
240+
221241
// Replace hardcoded rect group fills with a CSS variable so they
222242
// adapt to theme toggles without re-rendering.
223243
svg.querySelectorAll("rect").forEach(function(rect) {
224244
var fill = (rect.getAttribute("fill") || "").replace(/\s/g, "");
225245
if (fill === "rgb(240,235,228)") {
226246
rect.removeAttribute("fill");
227-
rect.style.fill = "var(--linen)";
247+
rect.style.fill = "var(--rect-fill)";
228248
}
229249
});
230250
});

docs/static/css/docs.css

Lines changed: 11 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -764,7 +764,7 @@ h2:hover .badge-experimental .badge-label {
764764
}
765765

766766
.docs-main pre.mermaid .actor-line {
767-
stroke: var(--rule) !important;
767+
stroke: var(--warm-gray) !important;
768768
}
769769

770770
.docs-main pre.mermaid .messageLine0,
@@ -783,25 +783,28 @@ h2:hover .badge-experimental .badge-label {
783783
}
784784

785785
.docs-main pre.mermaid .labelBox {
786-
stroke: var(--rule) !important;
787-
fill: var(--cream) !important;
786+
stroke: none !important;
787+
fill: var(--terracotta) !important;
788+
rx: 4;
788789
}
789790

790791
.docs-main pre.mermaid .labelText,
791792
.docs-main pre.mermaid .labelText > tspan,
792793
.docs-main pre.mermaid .loopText,
793794
.docs-main pre.mermaid .loopText > tspan {
794-
fill: var(--charcoal-soft) !important;
795+
fill: var(--cream) !important;
796+
dominant-baseline: central !important;
795797
}
796798

797799
.docs-main pre.mermaid .loopLine {
798-
stroke: var(--rule) !important;
799-
fill: var(--rule) !important;
800+
stroke: var(--terracotta) !important;
801+
stroke-opacity: 0.3 !important;
802+
fill: none !important;
800803
}
801804

802805
.docs-main pre.mermaid .note {
803-
stroke: var(--rule) !important;
804-
fill: var(--cream) !important;
806+
stroke: var(--warm-gray) !important;
807+
fill: var(--linen) !important;
805808
}
806809

807810
.docs-main pre.mermaid .noteText,

docs/static/css/variables.css

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
--toggle-bg-hover: #e8e2da;
2222
--toggle-hover-speed: 0.15s;
2323
--cloud: #b8b0a5;
24+
--rect-fill: rgba(192, 94, 60, 0.15);
2425
}
2526

2627
[data-theme="dark"] {
@@ -39,6 +40,7 @@
3940
--overlay-bg: rgba(0, 0, 0, 0.5);
4041
--toggle-bg: #342f29;
4142
--toggle-bg-hover: var(--rule);
43+
--rect-fill: rgba(212, 118, 78, 0.10);
4244
}
4345

4446
/* --- Theme toggle (shared across all pages) --- */

0 commit comments

Comments
 (0)