carlos / platform Public

Clone
git clone https://amadan.net/carlos/platform

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// store_gate.go is the structural SQL boundary for host-claimed apps.
4//
5// With the gate armed, every INSERT, UPDATE and DELETE on an app-scoped
6// table is refused by SQLite itself unless the connection that runs it holds
7// a permit row for that exact (account, app, app_id). The refusal lives in
8// per-connection TEMP BEFORE triggers that read a TEMP permit table, so it
9// applies to every path that reaches the connection: Exec, Query ... RETURNING,
10// prepared statements, transactions, INSERT OR REPLACE and ON DELETE CASCADE.
11//
12// Every permit row carries a mode, and the triggers decide per mode which
13// statement family it covers:
14//
15// effect any INSERT, UPDATE or DELETE of the incarnation's rows, and
16// adoption of an unidentified (app_id '') row
17// purge DELETE of the retired incarnation's rows, the apps retire and
18// claim-metadata UPDATE (claim_op, claim_revision, deleted_at), and
19// the monotone door-key revocation (revoked_at, revision upward)
20// restore UPDATE of apps' restore projection columns only
21// trash UPDATE of apps' trash projection columns only
22//
23// A terminal mode (purge, restore, trash) never inserts, never adopts, and
24// needs OLD.app_id = NEW.app_id = permit.app_id <> ''. An unknown mode is
25// refused by the permit table's CHECK and matches no trigger branch. Tables
26// whose rows cannot be bound to one account (owners) refuse every write.
27//
28// Permits are only ever written by Store.withClaimSQL, on a connection it
29// holds exclusively, and only after the caller's check callback has accepted
30// the scope. Only local checks run before each write and commit; remote
31// authority is prepared before taking the connection.
32// Production code reaches withClaimSQL only through Store.WithAppMutation
33// (claim_sql_grants.go), whose grants are built from canonical admission; a
34// source census keeps it that way.
35//
36// Everything is enforced one layer below database/sql, in a driver.Connector
37// wrapper, because the console's call sites take a concrete *sql.Tx and
38// *sql.Rows and a wrapper above database/sql could not cover them. The same
39// layer refuses DDL, PRAGMA, ATTACH, the permit table and sqlite_* on every
40// ordinary handle, so request code cannot switch the triggers off or forge a
41// permit.
42//
43// Boot is the one trusted seam: the schema is migrated on an unarmed
44// connection, the table census runs, the triggers are armed, and the pool is
45// recycled so no unarmed connection survives. The private boot handle is for
46// idempotent schema checks only.
47//
48// Flag-off is untouched: Open still returns a plain *sql.DB-backed Store with
49// no gate.
50
51import (
52 "context"
53 "database/sql"
54 "database/sql/driver"
55 "errors"
56 "fmt"
57 "github.com/carlosframework/platform/internal/hostops"
58 "os"
59 "path/filepath"
60 "sort"
61 "strings"
62 "sync"
63 "sync/atomic"
64 "time"
65
66 sqlite "modernc.org/sqlite"
67)
68
69var (
70 // ErrClaimSQLForbidden is a statement an ordinary handle may not run:
71 // DDL, PRAGMA, ATTACH, transaction control, the permit table, sqlite_*.
72 ErrClaimSQLForbidden = errors.New("console: SQL statement not permitted on this handle")
73 // ErrClaimSQLDenied is a scope the check callback refused.
74 ErrClaimSQLDenied = errors.New("console: claim SQL scope denied")
75 // ErrClaimSQLRevoked is a scope whose callback has already returned.
76 ErrClaimSQLRevoked = errors.New("console: claim SQL scope revoked")
77 // ErrClaimSQLScope is a malformed withClaimSQL call.
78 ErrClaimSQLScope = errors.New("console: invalid claim SQL scope")
79 // ErrClaimSQLCensus is a schema the gate refuses to arm over.
80 ErrClaimSQLCensus = errors.New("console: SQL schema census failed")
81)
82
83// Permit modes (see the file comment).
84const (
85 claimModeEffect = "effect"
86 claimModePurge = "purge"
87 claimModeRestore = "restore"
88 claimModeTrash = "trash"
89)
90
91func claimKnownMode(m string) bool {
92 switch m {
93 case claimModeEffect, claimModePurge, claimModeRestore, claimModeTrash:
94 return true
95 }
96 return false
97}
98
99// claimSQLScope names one app incarnation a withClaimSQL callback may write,
100// and the statement family (Mode) it may write it with.
101type claimSQLScope struct {
102 AccountID int64
103 App string
104 AppID string
105 Mode string
106}
107
108// sqlHandle is the slice of *sql.DB the Store uses. *sql.DB satisfies it, so
109// flag-off is the plain pool and every existing call site compiles unchanged.
110type sqlHandle interface {
111 Exec(query string, args ...any) (sql.Result, error)
112 ExecContext(ctx context.Context, query string, args ...any) (sql.Result, error)
113 Query(query string, args ...any) (*sql.Rows, error)
114 QueryContext(ctx context.Context, query string, args ...any) (*sql.Rows, error)
115 QueryRow(query string, args ...any) *sql.Row
116 QueryRowContext(ctx context.Context, query string, args ...any) *sql.Row
117 Begin() (*sql.Tx, error)
118 BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error)
119 Prepare(query string) (*sql.Stmt, error)
120 PrepareContext(ctx context.Context, query string) (*sql.Stmt, error)
121}
122
123var (
124 _ sqlHandle = (*sql.DB)(nil)
125 _ sqlHandle = (*claimBoundDB)(nil)
126)
127
128// ---- table classification ---------------------------------------------------
129
130// claimSQLTable classifies one table. NonApp is the written reason a table is
131// not app-scoped; empty means the table is gated and the column fields say
132// where its (account, app, app_id) live.
133type claimSQLTable struct {
134 NonApp string
135
136 Account string // integer account id column; "" when the table has none
137 Sqid string // account sqid column; "" when the table has none
138 App string // app name column (required when gated)
139 AppID string // app_id column; "" when the table has none
140
141 // FreeWhenNoApp lets a row whose app is '' through: it is not app-scoped.
142 // Moving a row INTO an app (UPDATE ... SET app = 'x') still needs a permit,
143 // because UPDATE checks NEW as well as OLD.
144 FreeWhenNoApp bool
145
146 // Via resolves the scope columns on a parent table, for a child that only
147 // carries a foreign key. The scope column names above then name PARENT
148 // columns. A child whose parent row is missing has no scope and is refused.
149 Via *claimSQLVia
150
151 // Refuse is the written reason every write to this gated table is refused
152 // while the gate is armed, whatever the permit: its rows cannot be bound
153 // to exactly one account and incarnation.
154 Refuse string
155
156 // Terminal lists, per terminal mode, the only columns an UPDATE under
157 // that mode may change. A mode not listed has no UPDATE on this table.
158 Terminal map[string][]string
159
160 // PurgeMonotone is the one column a purge permit may raise (never lower)
161 // in an UPDATE of the retired incarnation's rows; "" means none.
162 PurgeMonotone string
163
164 // cols is the table's column list, filled in by claimSQLCensus.
165 cols []string
166}
167
168type claimSQLVia struct{ Parent, ChildCol, ParentCol string }
169
170// claimAppsTerminal is what each terminal mode may change on an apps row:
171// purge binds the retiring operation and hides the row; restore and trash
172// are the per-console projections of a settled restore or trash marker.
173var claimAppsTerminal = map[string][]string{
174 claimModePurge: {"claim_op", "claim_revision", "deleted_at"},
175 claimModeRestore: {"deleted_at", "restoring", "purge_after", "purge_for", "expires_at"},
176 claimModeTrash: {"deleted_at", "restoring", "purge_after", "purge_for"},
177}
178
179// claimSQLTables is the explicit classification of every table the console
180// creates. An unlisted table refuses arming (claimSQLCensus), so a new table
181// cannot silently ship ungated. There is deliberately no exemption class:
182// audit and mail telemetry are app-scoped and are gated like the rest.
183var claimSQLTables = map[string]claimSQLTable{
184 // ---- app-scoped: gated ----
185 "apps": {Account: "account_id", App: "app", AppID: "app_id", Terminal: claimAppsTerminal},
186 // Legacy per-app grants carry no account column, so a permit for app
187 // "web" in one account would cover every account's "web". Flag-on they
188 // are refused outright; enabled access follows account membership.
189 "owners": {App: "app", Refuse: "legacy per-app grants name no account"},
190 "audit": {Account: "account_id", App: "app"},
191 "cli_tokens": {Account: "account_id", App: "app", FreeWhenNoApp: true}, // user tokens carry app ''
192 "door_keys": {Account: "account_id", App: "app", PurgeMonotone: "revoked_at"},
193 "door_key_revisions": {Account: "account_id", App: "app", PurgeMonotone: "revision"},
194 "pending_changes": {Account: "account_id", App: "app"},
195 "pending_confirms": {Account: "account_id", App: "app", Via: &claimSQLVia{Parent: "pending_changes", ChildCol: "change_id", ParentCol: "id"}},
196 "mail_counts": {Sqid: "account", App: "app"},
197 "mail_sender_counts": {Sqid: "account", App: "app"},
198 "mail_events": {Sqid: "account", App: "app"},
199 "mail_event_hooks": {Sqid: "account", App: "app"},
200 "mail_event_deliveries": {Sqid: "account", App: "app"},
201
202 // ---- not app-scoped: each with its reason ----
203 "sessions": {NonApp: "browser sessions, keyed by token hash and address"},
204 "users": {NonApp: "people, keyed by address"},
205 "grants": {NonApp: "keymail grants, keyed by address"},
206 "confirmations": {NonApp: "sign-in confirmation links, keyed by token hash and address"},
207 "keys": {NonApp: "the console's own signing keys"},
208 "accounts": {NonApp: "the tenancy primitive itself; names no app"},
209 "memberships": {NonApp: "account membership, keyed by account and address; names no app"},
210 "invites": {NonApp: "account invites, keyed by account and address; names no app"},
211 "account_plans": {NonApp: "an account's plan and packs, keyed by account; names no app"},
212 "alert_seen": {NonApp: "operator alert memory, keyed by box, rule and address"},
213 "claim_loss_pages": {NonApp: "claim recovery page attempts per loss record and condition, and scan progress; no app state or lifecycle authority"},
214 "move_pages": {NonApp: "the move pass's last page attempt per finding (rule names the host and the move); no app column"},
215 "move_log_outbox": {NonApp: "a moves audit outbox that stores no app name: rows are keyed by host, move and entry and bound to an instance incarnation, verified against the host's claim and record at every write, written only by the moves recovery and log passes, and purged with the incarnation"},
216 "edge_proof_seen": {NonApp: "single-row edge proof marker"},
217 "cli_auth_requests": {NonApp: "device-code logins in flight, keyed by code hash"},
218 "step_ups": {NonApp: "passkey step-up marks, keyed by session hash"},
219 "claim_sql_identity": {NonApp: "private receipt database identity"},
220 "claim_sql_receipts": {NonApp: "private SQL effect receipts and fences"},
221 "email_challenges": {NonApp: "emailed sign-in and step-up links and codes in flight, keyed by id hash; carries no app column"},
222 "pending_actions": {NonApp: "replay-by-token actions, keyed by token hash; carries no app column and re-enters the ordinary handlers"},
223}
224
225// claimSQLReservedName is the TEMP permit table. A main-schema table by that
226// name is refused so it can never shadow the permit table.
227const claimSQLReservedName = "claim_permit"
228
229func claimQ(name string) string { return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` }
230
231func claimAppish(col string) bool {
232 c := strings.ToLower(col)
233 return c == "app" || c == "apps" || c == "app_id" ||
234 strings.HasPrefix(c, "app_") || strings.HasSuffix(c, "_app") || strings.HasSuffix(c, "_app_id")
235}
236
237// claimSQLCensus reads sqlite_master and refuses any schema the gate cannot
238// account for: an unclassified table, a view, a trigger, a virtual table, an
239// attached database, a NonApp table that carries an app reference or a foreign
240// key to a gated table, a gated table whose columns are missing, or an app
241// column the spec does not enforce. db must be a trusted (unvalidated) handle.
242// It returns the classified specs of the tables that exist.
243func claimSQLCensus(db *sql.DB, tables map[string]claimSQLTable) (map[string]claimSQLTable, error) {
244 var problems []string
245 bad := func(format string, args ...any) { problems = append(problems, fmt.Sprintf(format, args...)) }
246
247 type obj struct{ kind, name, ddl string }
248 var objs []obj
249 rows, err := db.Query(`SELECT type, name, COALESCE(sql, '') FROM main.sqlite_master ORDER BY type, name`)
250 if err != nil {
251 return nil, err
252 }
253 for rows.Next() {
254 var o obj
255 if err := rows.Scan(&o.kind, &o.name, &o.ddl); err != nil {
256 rows.Close()
257 return nil, err
258 }
259 objs = append(objs, o)
260 }
261 err = rows.Err()
262 rows.Close()
263 if err != nil {
264 return nil, err
265 }
266
267 var dbs []string
268 rows, err = db.Query(`SELECT name FROM pragma_database_list`)
269 if err != nil {
270 return nil, err
271 }
272 for rows.Next() {
273 var n string
274 if err := rows.Scan(&n); err != nil {
275 rows.Close()
276 return nil, err
277 }
278 dbs = append(dbs, n)
279 }
280 err = rows.Err()
281 rows.Close()
282 if err != nil {
283 return nil, err
284 }
285 for _, n := range dbs {
286 if n != "main" && n != "temp" {
287 bad("attached database %q", n)
288 }
289 }
290
291 present := map[string]claimSQLTable{}
292 for _, o := range objs {
293 switch o.kind {
294 case "index":
295 continue
296 case "table":
297 if strings.HasPrefix(o.name, "sqlite_") {
298 continue // sqlite_sequence and friends: engine bookkeeping
299 }
300 if strings.EqualFold(o.name, claimSQLReservedName) {
301 bad("table %q uses the reserved permit table name", o.name)
302 continue
303 }
304 if strings.HasPrefix(strings.ToUpper(strings.TrimSpace(o.ddl)), "CREATE VIRTUAL") {
305 bad("table %q is a virtual table", o.name)
306 continue
307 }
308 spec, ok := tables[o.name]
309 if !ok {
310 bad("table %q is not classified in claimSQLTables", o.name)
311 continue
312 }
313 present[o.name] = spec
314 default:
315 bad("unexpected %s %q in the main schema", o.kind, o.name)
316 }
317 }
318
319 cols := map[string]map[string]bool{}
320 parents := map[string][]string{}
321 for name := range present {
322 rows, err := db.Query(`SELECT name FROM pragma_table_info(?)`, name)
323 if err != nil {
324 return nil, err
325 }
326 cols[name] = map[string]bool{}
327 for rows.Next() {
328 var c string
329 if err := rows.Scan(&c); err != nil {
330 rows.Close()
331 return nil, err
332 }
333 cols[name][c] = true
334 }
335 err = rows.Err()
336 rows.Close()
337 if err != nil {
338 return nil, err
339 }
340 rows, err = db.Query(`SELECT "table" FROM pragma_foreign_key_list(?)`, name)
341 if err != nil {
342 return nil, err
343 }
344 for rows.Next() {
345 var p string
346 if err := rows.Scan(&p); err != nil {
347 rows.Close()
348 return nil, err
349 }
350 parents[name] = append(parents[name], p)
351 }
352 err = rows.Err()
353 rows.Close()
354 if err != nil {
355 return nil, err
356 }
357 }
358
359 names := make([]string, 0, len(present))
360 for n := range present {
361 names = append(names, n)
362 }
363 sort.Strings(names)
364 gated := func(n string) bool { s, ok := present[n]; return ok && s.NonApp == "" }
365 for _, name := range names {
366 spec := present[name]
367 spec.cols = make([]string, 0, len(cols[name]))
368 for c := range cols[name] {
369 spec.cols = append(spec.cols, c)
370 }
371 sort.Strings(spec.cols)
372 present[name] = spec
373 var appCols []string
374 for c := range cols[name] {
375 if claimAppish(c) {
376 appCols = append(appCols, c)
377 }
378 }
379 sort.Strings(appCols)
380 if spec.NonApp != "" {
381 for _, p := range parents[name] {
382 if gated(p) {
383 bad("table %q is NonApp but has a foreign key to gated table %q", name, p)
384 }
385 }
386 if len(appCols) > 0 {
387 bad("table %q is NonApp but has app column(s) %v", name, appCols)
388 }
389 continue
390 }
391 if spec.App == "" {
392 bad("gated table %q has no App column in its spec", name)
393 continue
394 }
395 scopeTable, scopeCols := name, cols[name]
396 if spec.Via != nil {
397 v := spec.Via
398 if !gated(v.Parent) || present[v.Parent].Via != nil {
399 bad("table %q: Via parent %q must be a gated table without Via", name, v.Parent)
400 continue
401 }
402 if !cols[name][v.ChildCol] {
403 bad("table %q: missing Via child column %q", name, v.ChildCol)
404 }
405 if !cols[v.Parent][v.ParentCol] {
406 bad("table %q: missing Via parent column %q.%q", name, v.Parent, v.ParentCol)
407 }
408 scopeTable, scopeCols = v.Parent, cols[v.Parent]
409 }
410 for _, c := range []string{spec.Account, spec.Sqid, spec.App, spec.AppID} {
411 if c != "" && !scopeCols[c] {
412 bad("table %q: spec column %q is missing from %q", name, c, scopeTable)
413 }
414 }
415 if spec.Via == nil {
416 for _, c := range appCols {
417 if c != spec.App && c != spec.AppID {
418 bad("gated table %q has app column %q that its spec does not enforce", name, c)
419 }
420 }
421 } else if len(appCols) > 0 {
422 bad("Via table %q carries its own app column(s) %v; give it a direct spec", name, appCols)
423 }
424 if spec.Account == "" && spec.Sqid == "" && spec.AppID == "" && spec.Refuse == "" {
425 // A permit for an app name alone would cover every account's app
426 // of that name. Such a table is only acceptable when it refuses
427 // every write (the legacy owners shape).
428 bad("gated table %q is matched on app name alone; add an account column to its spec or refuse it", name)
429 }
430 for mode, allowed := range spec.Terminal {
431 if mode == claimModeEffect || !claimKnownMode(mode) {
432 bad("table %q: Terminal names %q, which is not a terminal mode", name, mode)
433 }
434 if spec.Via != nil || spec.AppID == "" {
435 bad("table %q: Terminal updates need a direct app_id column", name)
436 }
437 for _, c := range allowed {
438 if !cols[name][c] {
439 bad("table %q: Terminal column %q is missing", name, c)
440 }
441 if c == spec.Account || c == spec.Sqid || c == spec.App || c == spec.AppID {
442 bad("table %q: Terminal column %q is a scope column", name, c)
443 }
444 }
445 }
446 if c := spec.PurgeMonotone; c != "" && (!cols[name][c] || c == spec.Account || c == spec.Sqid || c == spec.App || c == spec.AppID || spec.Via != nil) {
447 bad("table %q: PurgeMonotone column %q is missing or a scope column", name, c)
448 }
449 }
450 if len(problems) > 0 {
451 return nil, fmt.Errorf("%w: %s", ErrClaimSQLCensus, strings.Join(problems, "; "))
452 }
453 return present, nil
454}
455
456// ---- trigger generation -----------------------------------------------------
457
458func (t claimSQLTable) col(row, column string) string {
459 if t.Via == nil {
460 return row + "." + claimQ(column)
461 }
462 v := t.Via
463 return "(SELECT x." + claimQ(column) + " FROM " + claimQ(v.Parent) + " x WHERE x." + claimQ(v.ParentCol) + " = " + row + "." + claimQ(v.ChildCol) + ")"
464}
465
466// match is the permit predicate (over alias p) that row ("NEW" or "OLD")
467// belongs to p's scope under mode. A row that has an app_id belongs to that
468// incarnation only; a predecessor's or successor's permit does not cover it.
469// An effect permit may also adopt an unidentified (empty app_id) row; a terminal
470// permit never does, and needs a non-empty app_id equal to its own.
471func (t claimSQLTable) match(row, mode string) string {
472 parts := []string{"p.mode = '" + mode + "'", "p.app = " + t.col(row, t.App)}
473 if t.Account != "" {
474 parts = append(parts, "p.account_id = "+t.col(row, t.Account))
475 }
476 if t.Sqid != "" {
477 parts = append(parts, "p.sqid = "+t.col(row, t.Sqid))
478 }
479 if t.AppID != "" {
480 e := t.col(row, t.AppID)
481 if mode == claimModeEffect {
482 parts = append(parts, "(p.app_id = "+e+" OR "+e+" = '')")
483 } else {
484 parts = append(parts, "p.app_id <> ''", "p.app_id = "+e)
485 }
486 }
487 return strings.Join(parts, " AND ")
488}
489
490// retired is the purge predicate for row: the retired incarnation's exact
491// app_id where the table has one. A table without app_id is keyed by
492// (account, app) alone, so its rows count as the retired incarnation's only
493// while no apps row names a different incarnation of that name (the claim
494// purge deletes the retired apps row first, in the same transaction). Without
495// an apps table to consult there is no purge branch at all.
496func (t claimSQLTable) retired(row string, hasApps bool) (string, bool) {
497 w := t.match(row, claimModePurge)
498 if t.AppID == "" {
499 if !hasApps {
500 return "", false
501 }
502 w += " AND NOT EXISTS (SELECT 1 FROM apps a WHERE a.account_id = p.account_id AND a.app = p.app AND a.app_id <> p.app_id)"
503 }
504 return w, true
505}
506
507func claimPermitExists(where string) string {
508 return "EXISTS (SELECT 1 FROM " + claimSQLReservedName + " p WHERE " + where + ")"
509}
510
511// claimTrue wraps c in COALESCE so a NULL can never read as "not denied":
512// the trigger raises on `NOT cond`, and NOT NULL is NULL.
513func claimTrue(c string) string { return "COALESCE(" + c + ", 0)" }
514
515// unchanged is "every column except allowed is the same in OLD and NEW".
516func (t claimSQLTable) unchanged(allowed ...string) string {
517 skip := map[string]bool{}
518 for _, c := range allowed {
519 skip[c] = true
520 }
521 var parts []string
522 for _, c := range t.cols {
523 if !skip[c] {
524 parts = append(parts, "OLD."+claimQ(c)+" IS NEW."+claimQ(c))
525 }
526 }
527 if len(parts) == 0 {
528 return "1"
529 }
530 return "(" + strings.Join(parts, " AND ") + ")"
531}
532
533// effect is 1 when an effect permit covers row.
534func (t claimSQLTable) effect(row string) string {
535 c := claimPermitExists(t.match(row, claimModeEffect))
536 if t.FreeWhenNoApp {
537 c = "(" + t.col(row, t.App) + " = '' OR " + c + ")"
538 }
539 return claimTrue(c)
540}
541
542const claimDenyMessage = "claim: app write not permitted"
543
544// claimTriggerSQL is the three BEFORE triggers of one gated table:
545//
546// INSERT an effect permit covers NEW
547// UPDATE effect permits cover OLD and NEW; or one terminal permit covers
548// OLD and NEW with the same app_id and only its columns change; or
549// one purge permit covers both and only PurgeMonotone rises
550// DELETE an effect permit covers OLD, or a purge permit's retired rows do
551func claimTriggerSQL(name string, t claimSQLTable, hasApps bool) []string {
552 on := " ON " + claimQ(name) + " BEGIN SELECT RAISE(ABORT, '" + claimDenyMessage + "') WHERE "
553 mk := func(event, where string) string {
554 return "CREATE TEMP TRIGGER IF NOT EXISTS " + claimQ("claim_"+event+"_"+name) + " BEFORE " + strings.ToUpper(event) + on + where + "; END"
555 }
556 if t.Refuse != "" {
557 return []string{mk("insert", "1"), mk("update", "1"), mk("delete", "1")}
558 }
559 update := []string{"(" + t.effect("OLD") + " AND " + t.effect("NEW") + ")"}
560 modes := make([]string, 0, len(t.Terminal))
561 for m := range t.Terminal {
562 modes = append(modes, m)
563 }
564 sort.Strings(modes)
565 for _, m := range modes {
566 same := claimTrue(claimPermitExists(t.match("OLD", m) + " AND " + t.match("NEW", m)))
567 update = append(update, "("+same+" AND "+t.unchanged(t.Terminal[m]...)+")")
568 }
569 del := []string{t.effect("OLD")}
570 oldRetired, okOld := t.retired("OLD", hasApps)
571 if okOld {
572 del = append(del, claimTrue(claimPermitExists(oldRetired)))
573 if c := t.PurgeMonotone; c != "" {
574 newRetired, _ := t.retired("NEW", hasApps)
575 both := claimTrue(claimPermitExists(oldRetired + " AND " + newRetired))
576 rises := claimTrue("NEW." + claimQ(c) + " > OLD." + claimQ(c))
577 update = append(update, "("+both+" AND "+rises+" AND "+t.unchanged(c)+")")
578 }
579 }
580 return []string{
581 mk("insert", "NOT "+t.effect("NEW")),
582 mk("update", "NOT ("+strings.Join(update, " OR ")+")"),
583 mk("delete", "NOT ("+strings.Join(del, " OR ")+")"),
584 }
585}
586
587type claimArm struct {
588 stmts []string
589 triggers int
590 needSqid bool
591}
592
593func buildClaimArm(present map[string]claimSQLTable) claimArm {
594 a := claimArm{stmts: []string{
595 // REPLACE's implicit delete only fires DELETE triggers with this on.
596 "PRAGMA recursive_triggers=ON",
597 "CREATE TEMP TABLE IF NOT EXISTS " + claimSQLReservedName + " (account_id INTEGER NOT NULL, sqid TEXT, app TEXT NOT NULL, app_id TEXT NOT NULL, mode TEXT NOT NULL CHECK (mode IN ('" +
598 claimModeEffect + "', '" + claimModePurge + "', '" + claimModeRestore + "', '" + claimModeTrash + "')))",
599 }}
600 apps, ok := present["apps"]
601 hasApps := ok && apps.NonApp == "" && apps.Via == nil && apps.Account == "account_id" && apps.App == "app" && apps.AppID == "app_id"
602 names := make([]string, 0, len(present))
603 for n, t := range present {
604 if t.NonApp == "" {
605 names = append(names, n)
606 if t.Sqid != "" {
607 a.needSqid = true
608 }
609 }
610 }
611 sort.Strings(names)
612 for _, n := range names {
613 ts := claimTriggerSQL(n, present[n], hasApps)
614 a.stmts = append(a.stmts, ts...)
615 a.triggers += len(ts)
616 }
617 return a
618}
619
620// ---- SQL inspection ---------------------------------------------------------
621
622type claimSQLInfo struct{ write bool }
623
624var claimSQLFirstWords = map[string]bool{
625 "select": true, "values": true, "with": true,
626 "insert": true, "replace": true, "update": true, "delete": true,
627}
628
629var claimSQLWriteWords = map[string]bool{"insert": true, "replace": true, "update": true, "delete": true}
630
631func claimBlockedWord(w string) bool {
632 return w == claimSQLReservedName || strings.HasPrefix(w, "claim_sql_") || strings.HasPrefix(w, "sqlite_") || w == "pragma" || strings.HasPrefix(w, "pragma_") || w == "load_extension"
633}
634
635func claimBlockedLiteral(s string) bool {
636 l := strings.ToLower(strings.TrimSpace(s))
637 // SQLite accepts a string literal where an identifier is expected, so the
638 // permit table and sqlite_* are refused in literals too.
639 return strings.Contains(l, claimSQLReservedName) || strings.Contains(l, "claim_sql_") || strings.HasPrefix(l, "sqlite_")
640}
641
642func claimWordByte(b byte) bool {
643 return b == '_' || b == '$' || b >= 0x80 || (b >= '0' && b <= '9') || (b >= 'a' && b <= 'z') || (b >= 'A' && b <= 'Z')
644}
645
646// readClaimQuoted reads a quote-delimited run starting at q[i] (the opening
647// quote) and returns its unescaped content and the index after the closer.
648func readClaimQuoted(q string, i int, quote byte) (string, int) {
649 var b strings.Builder
650 for j := i + 1; j < len(q); j++ {
651 if q[j] == quote {
652 if j+1 < len(q) && q[j+1] == quote {
653 b.WriteByte(quote)
654 j++
655 continue
656 }
657 return b.String(), j + 1
658 }
659 b.WriteByte(q[j])
660 }
661 return b.String(), len(q)
662}
663
664// inspectClaimSQL splits q into statements exactly as SQLite would (string
665// literals, quoted identifiers and comments never hide a `;`), and refuses the
666// whole text if any statement is not a plain SELECT/INSERT/UPDATE/DELETE/
667// REPLACE/WITH/VALUES, or names the permit table or sqlite_*. This is what
668// shuts the multi-statement loopholes. write reports whether any statement
669// may mutate; it is conservative (the scalar replace() counts).
670//
671// TEMP is not blocked by name: claim_permit is the only TEMP table, CREATE is
672// not an allowed first word, and sqlite_temp_master is caught by its prefix.
673func inspectClaimSQL(q string) (claimSQLInfo, error) {
674 var info claimSQLInfo
675 var words []string
676 flush := func() error {
677 defer func() { words = words[:0] }()
678 if len(words) == 0 {
679 return nil
680 }
681 if !claimSQLFirstWords[words[0]] {
682 return fmt.Errorf("%w: %s statement", ErrClaimSQLForbidden, words[0])
683 }
684 for _, w := range words {
685 if claimBlockedWord(w) {
686 return fmt.Errorf("%w: reference to %s", ErrClaimSQLForbidden, w)
687 }
688 if claimSQLWriteWords[w] {
689 info.write = true
690 }
691 }
692 return nil
693 }
694 n := len(q)
695 for i := 0; i < n; {
696 ch := q[i]
697 switch {
698 case ch == ';':
699 if err := flush(); err != nil {
700 return info, err
701 }
702 i++
703 case ch == '-' && i+1 < n && q[i+1] == '-':
704 j := strings.IndexByte(q[i:], '\n')
705 if j < 0 {
706 i = n
707 } else {
708 i += j + 1
709 }
710 case ch == '/' && i+1 < n && q[i+1] == '*':
711 j := strings.Index(q[i+2:], "*/")
712 if j < 0 {
713 i = n
714 } else {
715 i += j + 4
716 }
717 case ch == '\'':
718 lit, next := readClaimQuoted(q, i, '\'')
719 if claimBlockedLiteral(lit) {
720 return info, fmt.Errorf("%w: reference in string literal", ErrClaimSQLForbidden)
721 }
722 i = next
723 case ch == '"' || ch == '`':
724 id, next := readClaimQuoted(q, i, ch)
725 words = append(words, strings.ToLower(id))
726 i = next
727 case ch == '[':
728 j := strings.IndexByte(q[i+1:], ']')
729 if j < 0 {
730 words = append(words, strings.ToLower(q[i+1:]))
731 i = n
732 } else {
733 words = append(words, strings.ToLower(q[i+1:i+1+j]))
734 i += j + 2
735 }
736 case claimWordByte(ch):
737 j := i
738 for j < n && claimWordByte(q[j]) {
739 j++
740 }
741 words = append(words, strings.ToLower(q[i:j]))
742 i = j
743 default:
744 i++
745 }
746 }
747 if err := flush(); err != nil {
748 return info, err
749 }
750 return info, nil
751}
752
753// ---- the gate ---------------------------------------------------------------
754
755type claimGate struct {
756 path string
757 physical string
758 instance string
759 d *sqlite.Driver
760 dsn string
761
762 armed atomic.Bool
763 mu sync.RWMutex
764 arm claimArm
765 // viewG is the goroutine holding the bound view's connection, 0 when
766 // none (claim_sql_outer.go).
767 viewG atomic.Uint64
768}
769
770func (g *claimGate) armSnapshot() claimArm {
771 g.mu.RLock()
772 defer g.mu.RUnlock()
773 return g.arm
774}
775
776func (g *claimGate) needSqid() bool { return g.armSnapshot().needSqid }
777
778// hook is the sqlite connection hook: once armed it installs the permit table
779// and triggers on every new connection, before database/sql ever sees it.
780func (g *claimGate) hook(conn sqlite.ExecQuerierContext, _ string) error {
781 if !g.armed.Load() {
782 return nil
783 }
784 for _, s := range g.armSnapshot().stmts {
785 if _, err := conn.ExecContext(context.Background(), s, nil); err != nil {
786 return fmt.Errorf("claim sql arm: %w", err)
787 }
788 }
789 return nil
790}
791
792type claimInnerConn interface {
793 driver.Conn
794 driver.ConnBeginTx
795 driver.ConnPrepareContext
796 driver.ExecerContext
797 driver.QueryerContext
798}
799
800type claimConnector struct {
801 g *claimGate
802 trusted bool // boot handle: armed triggers, but no statement vetting
803}
804
805func (k claimConnector) Driver() driver.Driver { return k.g.d }
806
807func (k claimConnector) Connect(ctx context.Context) (driver.Conn, error) {
808 raw, err := k.g.d.Open(k.g.dsn)
809 if err != nil {
810 return nil, err
811 }
812 inner, ok := raw.(claimInnerConn)
813 if !ok {
814 raw.Close()
815 return nil, errors.New("console: sqlite driver connection lacks the context interfaces the claim gate needs")
816 }
817 // Per-connection settings Open applies once to its single connection;
818 // a recycled connection must not lose them.
819 for _, p := range []string{"PRAGMA busy_timeout=5000", "PRAGMA synchronous=FULL"} {
820 if _, err := inner.ExecContext(ctx, p, nil); err != nil {
821 inner.Close()
822 return nil, err
823 }
824 }
825 return &claimConn{g: k.g, inner: inner, trusted: k.trusted}, nil
826}
827
828// claimConn wraps one sqlite connection. database/sql serializes calls into
829// it, so its fields need no lock of their own.
830//
831// sealed outlives the binding: a public operation that was already queued on
832// this connection when its scope ended is refused until the pool resets the
833// connection for its next user.
834type claimConn struct {
835 g *claimGate
836 inner claimInnerConn
837 trusted bool
838 armed bool
839 bad bool
840 sealed bool
841 tx *claimTx
842 bind atomic.Pointer[claimBinding]
843 rows map[*claimRows]struct{}
844}
845
846// usable is the public-operation gate. Every statement, prepare, begin,
847// prepared execution and row step that reaches the driver passes it under the
848// driver-connection lock, so a handle sealed before a commit can never run
849// after that commit begins.
850func (c *claimConn) usable() error {
851 if c.bad {
852 return driver.ErrBadConn
853 }
854 if c.sealed {
855 return ErrClaimSQLRevoked
856 }
857 if b := c.bind.Load(); b != nil && b.closed() {
858 return ErrClaimSQLRevoked
859 }
860 return nil
861}
862
863// trackRows wraps rows opened while a binding is attached, so the batch
864// commit and unbind can finish every statement still in progress.
865func (c *claimConn) trackRows(r driver.Rows) driver.Rows {
866 if c.bind.Load() == nil {
867 return r
868 }
869 w := &claimRows{c: c, inner: r}
870 if c.rows == nil {
871 c.rows = map[*claimRows]struct{}{}
872 }
873 c.rows[w] = struct{}{}
874 return w
875}
876
877// closeRows finishes every tracked statement. Called under the driver lock.
878func (c *claimConn) closeRows() {
879 for r := range c.rows {
880 r.closeInner()
881 }
882}
883
884func (c *claimConn) vet(q string) (claimSQLInfo, error) {
885 if c.trusted || !c.g.armed.Load() {
886 return claimSQLInfo{}, nil
887 }
888 return inspectClaimSQL(q)
889}
890
891// ensureArmed installs the permit table and triggers if this connection was
892// opened before the gate armed. The statements are idempotent, so a
893// connection the hook already armed just repeats them once.
894func (c *claimConn) ensureArmed(ctx context.Context) error {
895 if c.armed || !c.g.armed.Load() {
896 return nil
897 }
898 ctx = context.WithoutCancel(ctx)
899 for _, s := range c.g.armSnapshot().stmts {
900 if _, err := c.inner.ExecContext(ctx, s, nil); err != nil {
901 return err
902 }
903 }
904 c.armed = true
905 return nil
906}
907
908func (c *claimConn) authorizeWrite(ctx context.Context, write bool) error {
909 if !write {
910 return nil
911 }
912 if c.bad {
913 return driver.ErrBadConn
914 }
915 if c.tx != nil {
916 c.tx.wrote = true
917 }
918 if b := c.bind.Load(); b != nil {
919 return b.authorize(ctx, c)
920 }
921 return nil
922}
923
924func (c *claimConn) Prepare(q string) (driver.Stmt, error) {
925 return c.PrepareContext(context.Background(), q)
926}
927
928func (c *claimConn) Close() error { return c.inner.Close() }
929
930func (c *claimConn) Begin() (driver.Tx, error) {
931 return c.BeginTx(context.Background(), driver.TxOptions{})
932}
933
934func (c *claimConn) BeginTx(ctx context.Context, opts driver.TxOptions) (driver.Tx, error) {
935 if err := c.usable(); err != nil {
936 return nil, err
937 }
938 if err := c.ensureArmed(ctx); err != nil {
939 return nil, err
940 }
941 var inner driver.Tx
942 var err error
943 if b := c.bind.Load(); b != nil && b.outer != nil {
944 if c.tx != nil {
945 return nil, ErrClaimSQLScope
946 }
947 err = c.innerExec(ctx, "SAVEPOINT claim_batch_statement")
948 inner = &claimSavepoint{c: c}
949 } else {
950 inner, err = c.inner.BeginTx(ctx, opts)
951 }
952 if err != nil {
953 return nil, err
954 }
955 t := &claimTx{c: c, inner: inner, bind: c.bind.Load(), ctx: ctx}
956 c.tx = t
957 return t, nil
958}
959
960func (c *claimConn) PrepareContext(ctx context.Context, q string) (driver.Stmt, error) {
961 if err := c.usable(); err != nil {
962 return nil, err
963 }
964 info, err := c.vet(q)
965 if err != nil {
966 return nil, err
967 }
968 if err := c.ensureArmed(ctx); err != nil {
969 return nil, err
970 }
971 st, err := c.inner.PrepareContext(ctx, q)
972 if err != nil {
973 return nil, err
974 }
975 _, view := ctx.Value(claimViewStmt{}).(bool)
976 return &claimStmt{c: c, inner: st, write: info.write, view: view}, nil
977}
978
979func (c *claimConn) ExecContext(ctx context.Context, q string, args []driver.NamedValue) (driver.Result, error) {
980 if err := c.usable(); err != nil {
981 return nil, err
982 }
983 info, err := c.vet(q)
984 if err != nil {
985 return nil, err
986 }
987 if err := c.ensureArmed(ctx); err != nil {
988 return nil, err
989 }
990 if err := c.authorizeWrite(ctx, info.write); err != nil {
991 return nil, err
992 }
993 return c.inner.ExecContext(ctx, q, args)
994}
995
996func (c *claimConn) QueryContext(ctx context.Context, q string, args []driver.NamedValue) (driver.Rows, error) {
997 if err := c.usable(); err != nil {
998 return nil, err
999 }
1000 info, err := c.vet(q)
1001 if err != nil {
1002 return nil, err
1003 }
1004 if err := c.ensureArmed(ctx); err != nil {
1005 return nil, err
1006 }
1007 if err := c.authorizeWrite(ctx, info.write); err != nil {
1008 return nil, err
1009 }
1010 rows, err := c.inner.QueryContext(ctx, q, args)
1011 if err != nil {
1012 return nil, err
1013 }
1014 return c.trackRows(rows), nil
1015}
1016
1017// ResetSession runs when the pool hands the connection out again. A
1018// connection that still carries a binding, or whose permits could not be
1019// cleared, must never be reused.
1020func (c *claimConn) ResetSession(ctx context.Context) error {
1021 if c.bad || c.bind.Load() != nil {
1022 return driver.ErrBadConn
1023 }
1024 // A new user: handles of the previous scope are closed by now.
1025 c.sealed = false
1026 if r, ok := c.inner.(driver.SessionResetter); ok {
1027 return r.ResetSession(ctx)
1028 }
1029 return nil
1030}
1031
1032func (c *claimConn) IsValid() bool {
1033 if c.bad || c.bind.Load() != nil {
1034 return false
1035 }
1036 if v, ok := c.inner.(driver.Validator); ok {
1037 return v.IsValid()
1038 }
1039 return true
1040}
1041
1042// innerInt runs a one-integer query straight on the sqlite connection.
1043func (c *claimConn) innerInt(ctx context.Context, q string) (int64, error) {
1044 rows, err := c.inner.QueryContext(ctx, q, nil)
1045 if err != nil {
1046 return 0, err
1047 }
1048 defer rows.Close()
1049 dest := make([]driver.Value, len(rows.Columns()))
1050 if err := rows.Next(dest); err != nil {
1051 return 0, err
1052 }
1053 n, ok := dest[0].(int64)
1054 if !ok {
1055 return 0, fmt.Errorf("claim sql: %q returned %T", q, dest[0])
1056 }
1057 return n, nil
1058}
1059
1060func (c *claimConn) innerExec(ctx context.Context, q string, args ...driver.Value) error {
1061 nv := make([]driver.NamedValue, len(args))
1062 for i, a := range args {
1063 nv[i] = driver.NamedValue{Ordinal: i + 1, Value: a}
1064 }
1065 _, err := c.inner.ExecContext(ctx, q, nv)
1066 return err
1067}
1068
1069// clearPermits empties the permit table and proves it. Outside a transaction
1070// only: a TEMP table is transactional, so a delete inside a transaction that
1071// later rolls back would bring the rows back.
1072func (c *claimConn) clearPermits() error {
1073 ctx := context.Background()
1074 if err := c.innerExec(ctx, "DELETE FROM "+claimSQLReservedName); err != nil {
1075 return err
1076 }
1077 n, err := c.innerInt(ctx, "SELECT count(*) FROM "+claimSQLReservedName)
1078 if err != nil {
1079 return err
1080 }
1081 if n != 0 {
1082 return errors.New("claim sql: permit table not empty after clear")
1083 }
1084 return nil
1085}
1086
1087// bindScopes writes the permit rows and attaches the binding. The caller has
1088// already admitted every scope.
1089func (c *claimConn) bindScopes(ctx context.Context, b *claimBinding, sqids []string) error {
1090 if c.bad || c.tx != nil || c.bind.Load() != nil {
1091 return driver.ErrBadConn
1092 }
1093 if err := c.ensureArmed(ctx); err != nil {
1094 return err
1095 }
1096 for i, sc := range b.scopes {
1097 var sqid driver.Value // NULL when the account has no row: it then matches nothing
1098 if sqids[i] != "" {
1099 sqid = sqids[i]
1100 }
1101 if !claimKnownMode(sc.Mode) {
1102 if cerr := c.clearPermits(); cerr != nil {
1103 c.bad = true
1104 return fmt.Errorf("%w: %w", driver.ErrBadConn, cerr)
1105 }
1106 return fmt.Errorf("%w: unknown permit mode %q", ErrClaimSQLScope, sc.Mode)
1107 }
1108 if err := c.innerExec(ctx, "INSERT INTO "+claimSQLReservedName+" (account_id, sqid, app, app_id, mode) VALUES (?, ?, ?, ?, ?)",
1109 sc.AccountID, sqid, sc.App, sc.AppID, sc.Mode); err != nil {
1110 if cerr := c.clearPermits(); cerr != nil {
1111 c.bad = true
1112 return fmt.Errorf("%w: %w", driver.ErrBadConn, cerr)
1113 }
1114 return err
1115 }
1116 }
1117 c.bind.Store(b)
1118 return nil
1119}
1120
1121// unbind revokes the binding, abandons any transaction still open on the
1122// connection, and clears the permits. If that cannot be proven the connection
1123// is poisoned and the pool discards it.
1124func (c *claimConn) unbind(b *claimBinding) error {
1125 b.revoked.Store(true)
1126 c.sealed = true
1127 c.closeRows()
1128 if t := c.tx; t != nil {
1129 // The public handle may already report ErrTxDone while its context's
1130 // rollback is queued. Abandoning written driver state is still a loss.
1131 if t.wrote && t.bind != nil {
1132 t.bind.lost.Store(true)
1133 }
1134 t.abort()
1135 }
1136 if b.outer != nil {
1137 if err := b.outer.Rollback(); err != nil {
1138 c.bad = true
1139 }
1140 b.outer = nil
1141 }
1142 c.bind.Store(nil)
1143 if err := c.clearPermits(); err != nil {
1144 c.bad = true
1145 return fmt.Errorf("%w: %w", driver.ErrBadConn, err)
1146 }
1147 return nil
1148}
1149
1150// purgeDead removes the permit rows of scopes the callback has refused.
1151func (c *claimConn) purgeDead(b *claimBinding) error {
1152 for i, sc := range b.scopes {
1153 if !b.dead[i] {
1154 continue
1155 }
1156 if err := c.innerExec(context.Background(),
1157 "DELETE FROM "+claimSQLReservedName+" WHERE account_id = ? AND app = ? AND app_id = ? AND mode = ?",
1158 sc.AccountID, sc.App, sc.AppID, sc.Mode); err != nil {
1159 return err
1160 }
1161 }
1162 return nil
1163}
1164
1165// armedState reports whether the permit table, every trigger and
1166// recursive_triggers are in place on this connection.
1167func (c *claimConn) armedState(ctx context.Context, wantTriggers int) error {
1168 if err := c.ensureArmed(ctx); err != nil {
1169 return err
1170 }
1171 n, err := c.innerInt(ctx, "SELECT count(*) FROM temp.sqlite_master WHERE type = 'trigger' AND name LIKE 'claim\\_%' ESCAPE '\\'")
1172 if err != nil {
1173 return err
1174 }
1175 if int(n) != wantTriggers {
1176 return fmt.Errorf("claim sql: %d triggers armed, want %d", n, wantTriggers)
1177 }
1178 r, err := c.innerInt(ctx, "PRAGMA recursive_triggers")
1179 if err != nil {
1180 return err
1181 }
1182 if r != 1 {
1183 return errors.New("claim sql: recursive_triggers is off")
1184 }
1185 return nil
1186}
1187
1188// ---- binding ----------------------------------------------------------------
1189
1190// claimBinding is one withClaimSQL scope on one connection.
1191type claimBinding struct {
1192 deadline time.Time
1193 batch bool
1194 receipts []hostops.SQLReceiptScope
1195 outer driver.Tx
1196 ctx context.Context
1197 scopes []claimSQLScope
1198 check func(context.Context, claimSQLScope) error
1199
1200 revoked atomic.Bool
1201 // lost records a public transaction that wrote and then ended without
1202 // committing other than by the callback's own rollback: its context
1203 // ended (database/sql rolled it back), or in a batch its commit failed.
1204 // The callback may have ignored that, so the scope fails and a batch
1205 // commits nothing.
1206 lost atomic.Bool
1207 // sealed refuses every public handle of the scope while the internal
1208 // batch commit still runs; only authorizeCommit passes it.
1209 sealed atomic.Bool
1210 mu sync.Mutex
1211 dead []bool
1212}
1213
1214func (b *claimBinding) closed() bool { return b.revoked.Load() || b.sealed.Load() }
1215
1216func (b *claimBinding) lostErr() error {
1217 if b.lost.Load() {
1218 return fmt.Errorf("%w: a transaction's writes were rolled back by its context or a failed commit", ErrClaimSQLScope)
1219 }
1220 return nil
1221}
1222
1223// authorize runs the check callback for every live scope. The first refusal
1224// kills that scope (its permit row goes) and refuses the statement; once every
1225// scope is dead nothing is written at all.
1226func (b *claimBinding) authorize(ctx context.Context, c *claimConn) error {
1227 return b.admit(ctx, c, false)
1228}
1229
1230// authorizeCommit is the batch commit's own authorization: the same checks,
1231// but it runs after the public handles were sealed.
1232func (b *claimBinding) authorizeCommit(ctx context.Context, c *claimConn) error {
1233 return b.admit(ctx, c, true)
1234}
1235
1236func (b *claimBinding) admit(ctx context.Context, c *claimConn, commit bool) error {
1237 b.mu.Lock()
1238 defer b.mu.Unlock()
1239 if b.revoked.Load() || (!commit && b.sealed.Load()) {
1240 return ErrClaimSQLRevoked
1241 }
1242 if ctx == nil {
1243 ctx = b.ctx
1244 }
1245 if err := b.ctx.Err(); err != nil {
1246 return err
1247 }
1248 if err := ctx.Err(); err != nil {
1249 return err
1250 }
1251 if !time.Now().Before(b.deadline) {
1252 return context.DeadlineExceeded
1253 }
1254 if ctx == nil {
1255 ctx = b.ctx
1256 }
1257 if err := c.purgeDead(b); err != nil {
1258 c.bad = true
1259 return err
1260 }
1261 live := 0
1262 for i, sc := range b.scopes {
1263 if b.dead[i] {
1264 continue
1265 }
1266 if err := b.check(ctx, sc); err != nil {
1267 b.dead[i] = true
1268 if perr := c.purgeDead(b); perr != nil {
1269 c.bad = true
1270 }
1271 return fmt.Errorf("%w: %w", ErrClaimSQLDenied, err)
1272 }
1273 live++
1274 }
1275 if live == 0 {
1276 return ErrClaimSQLDenied
1277 }
1278 return nil
1279}
1280
1281// purge re-deletes dead scopes' permit rows after a transaction ends: a
1282// rollback undoes a delete made inside the transaction.
1283func (b *claimBinding) purge(c *claimConn) {
1284 b.mu.Lock()
1285 defer b.mu.Unlock()
1286 if err := c.purgeDead(b); err != nil {
1287 c.bad = true
1288 }
1289}
1290
1291// ---- transactions and statements -------------------------------------------
1292
1293// claimTx.ctx is the context database/sql began the transaction with. When it
1294// ends, database/sql rolls the transaction back on its own and the callback
1295// only sees ErrTxDone, so a rollback under a dead context is a loss, not a
1296// choice.
1297type claimTx struct {
1298 c *claimConn
1299 inner driver.Tx
1300 bind *claimBinding
1301 ctx context.Context
1302 wrote bool
1303 done bool
1304}
1305
1306// lose marks the binding when a transaction that wrote ends uncommitted
1307// without the callback having chosen that. A failed commit returns its error
1308// to the Commit call, so outside a batch the callback owns that outcome; in a
1309// batch the receipt would hide it. A context rollback may reach no call at
1310// all, so it always counts.
1311func (t *claimTx) lose(commit bool) {
1312 if !t.wrote || t.bind == nil || (commit && !t.bind.batch) {
1313 return
1314 }
1315 t.bind.lost.Store(true)
1316}
1317
1318func (t *claimTx) Commit() error {
1319 if t.done {
1320 return sql.ErrTxDone
1321 }
1322 if b := t.bind; b != nil {
1323 if b.closed() {
1324 t.lose(true)
1325 t.abortAndPurge()
1326 return ErrClaimSQLRevoked
1327 }
1328 if t.wrote {
1329 if err := b.authorize(b.ctx, t.c); err != nil {
1330 t.lose(true)
1331 t.abortAndPurge()
1332 return err
1333 }
1334 }
1335 }
1336 err := t.inner.Commit()
1337 t.done = true
1338 t.c.tx = nil
1339 if err != nil {
1340 t.lose(true)
1341 }
1342 if t.bind != nil {
1343 t.bind.purge(t.c)
1344 }
1345 return err
1346}
1347
1348func (t *claimTx) Rollback() error {
1349 if t.done {
1350 return nil
1351 }
1352 if t.ctx != nil && t.ctx.Err() != nil {
1353 t.lose(false)
1354 }
1355 t.done = true
1356 err := t.inner.Rollback()
1357 t.c.tx = nil
1358 if t.bind != nil {
1359 t.bind.purge(t.c)
1360 }
1361 return err
1362}
1363
1364// abort rolls back without touching the permit table (unbind clears it).
1365func (t *claimTx) abort() {
1366 if t.done {
1367 return
1368 }
1369 t.done = true
1370 _ = t.inner.Rollback()
1371 t.c.tx = nil
1372}
1373
1374func (t *claimTx) abortAndPurge() {
1375 t.abort()
1376 if t.bind != nil {
1377 t.bind.purge(t.c)
1378 }
1379}
1380
1381// claimStmt.view marks a statement prepared on a bound view. database/sql
1382// ties its rows to the caller's context alone, and finalises the statement
1383// when the view closes it even while those rows are open, so a retained
1384// result set would pin the connection or be stepped after finalisation. Such
1385// statements may execute but never return rows.
1386type claimStmt struct {
1387 c *claimConn
1388 inner driver.Stmt
1389 write bool
1390 view bool
1391}
1392
1393// claimViewStmt is the prepare-context marker claimBoundDB sets.
1394type claimViewStmt struct{}
1395
1396func (s *claimStmt) Close() error { return s.inner.Close() }
1397func (s *claimStmt) NumInput() int { return s.inner.NumInput() }
1398
1399func claimNamedToValues(args []driver.NamedValue) ([]driver.Value, error) {
1400 out := make([]driver.Value, len(args))
1401 for i, a := range args {
1402 if a.Name != "" {
1403 return nil, errors.New("console: named parameters are not supported through the claim gate")
1404 }
1405 out[i] = a.Value
1406 }
1407 return out, nil
1408}
1409
1410func claimValuesToNamed(args []driver.Value) []driver.NamedValue {
1411 out := make([]driver.NamedValue, len(args))
1412 for i, a := range args {
1413 out[i] = driver.NamedValue{Ordinal: i + 1, Value: a}
1414 }
1415 return out
1416}
1417
1418func (s *claimStmt) Exec(args []driver.Value) (driver.Result, error) {
1419 return s.ExecContext(context.Background(), claimValuesToNamed(args))
1420}
1421
1422func (s *claimStmt) Query(args []driver.Value) (driver.Rows, error) {
1423 return s.QueryContext(context.Background(), claimValuesToNamed(args))
1424}
1425
1426func (s *claimStmt) ExecContext(ctx context.Context, args []driver.NamedValue) (driver.Result, error) {
1427 if err := s.c.usable(); err != nil {
1428 return nil, err
1429 }
1430 if err := s.c.ensureArmed(ctx); err != nil {
1431 return nil, err
1432 }
1433 if err := s.c.authorizeWrite(ctx, s.write); err != nil {
1434 return nil, err
1435 }
1436 if e, ok := s.inner.(driver.StmtExecContext); ok {
1437 return e.ExecContext(ctx, args)
1438 }
1439 vals, err := claimNamedToValues(args)
1440 if err != nil {
1441 return nil, err
1442 }
1443 return s.inner.Exec(vals) //nolint:staticcheck // legacy driver path
1444}
1445
1446func (s *claimStmt) QueryContext(ctx context.Context, args []driver.NamedValue) (driver.Rows, error) {
1447 if s.view {
1448 return nil, fmt.Errorf("%w: rows from a statement prepared on a claim SQL view; query the view directly", ErrClaimSQLForbidden)
1449 }
1450 if err := s.c.usable(); err != nil {
1451 return nil, err
1452 }
1453 if err := s.c.ensureArmed(ctx); err != nil {
1454 return nil, err
1455 }
1456 if err := s.c.authorizeWrite(ctx, s.write); err != nil {
1457 return nil, err
1458 }
1459 var rows driver.Rows
1460 var err error
1461 if q, ok := s.inner.(driver.StmtQueryContext); ok {
1462 rows, err = q.QueryContext(ctx, args)
1463 } else {
1464 vals, verr := claimNamedToValues(args)
1465 if verr != nil {
1466 return nil, verr
1467 }
1468 rows, err = s.inner.Query(vals) //nolint:staticcheck // legacy driver path
1469 }
1470 if err != nil {
1471 return nil, err
1472 }
1473 return s.c.trackRows(rows), nil
1474}
1475
1476// claimRows is a result set opened under a binding. A step after the scope
1477// is sealed is refused, and the commit finishes the statement first.
1478type claimRows struct {
1479 c *claimConn
1480 inner driver.Rows
1481 closed bool
1482}
1483
1484func (r *claimRows) Columns() []string { return r.inner.Columns() }
1485
1486func (r *claimRows) Next(dest []driver.Value) error {
1487 if r.closed {
1488 return ErrClaimSQLRevoked
1489 }
1490 if err := r.c.usable(); err != nil {
1491 r.closeInner()
1492 return err
1493 }
1494 return r.inner.Next(dest)
1495}
1496
1497func (r *claimRows) Close() error {
1498 if r.closed {
1499 return nil
1500 }
1501 return r.closeInner()
1502}
1503
1504func (r *claimRows) closeInner() error {
1505 if r.closed {
1506 return nil
1507 }
1508 r.closed = true
1509 delete(r.c.rows, r)
1510 return r.inner.Close()
1511}
1512
1513// ---- the bound view ---------------------------------------------------------
1514
1515// claimBoundDB is the sqlHandle a withClaimSQL callback sees: it runs on the
1516// one held connection. It tracks what it hands out so revoke can release
1517// everything, and hands out no driver handle.
1518type claimBoundDB struct {
1519 conn *sql.Conn
1520 b *claimBinding
1521 life context.Context
1522 cancel context.CancelFunc
1523
1524 mu sync.Mutex
1525 closed bool
1526 txs []*sql.Tx
1527 stmts []*sql.Stmt
1528}
1529
1530// opCtx lets revoke cancel an operation whose result outlives the call
1531// (rows, a transaction): database/sql closes them when their context ends,
1532// which is what releases the pinned connection.
1533func (d *claimBoundDB) opCtx(ctx context.Context) context.Context {
1534 c, cancel := context.WithCancel(ctx)
1535 context.AfterFunc(d.life, cancel)
1536 return c
1537}
1538
1539func (d *claimBoundDB) Exec(q string, args ...any) (sql.Result, error) {
1540 return d.ExecContext(context.Background(), q, args...)
1541}
1542
1543func (d *claimBoundDB) ExecContext(ctx context.Context, q string, args ...any) (sql.Result, error) {
1544 if d.b.closed() {
1545 return nil, ErrClaimSQLRevoked
1546 }
1547 return d.conn.ExecContext(ctx, q, args...)
1548}
1549
1550func (d *claimBoundDB) Query(q string, args ...any) (*sql.Rows, error) {
1551 return d.QueryContext(context.Background(), q, args...)
1552}
1553
1554func (d *claimBoundDB) QueryContext(ctx context.Context, q string, args ...any) (*sql.Rows, error) {
1555 if d.b.closed() {
1556 return nil, ErrClaimSQLRevoked
1557 }
1558 return d.conn.QueryContext(d.opCtx(ctx), q, args...)
1559}
1560
1561func (d *claimBoundDB) QueryRow(q string, args ...any) *sql.Row {
1562 return d.QueryRowContext(context.Background(), q, args...)
1563}
1564
1565func (d *claimBoundDB) QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row {
1566 return d.conn.QueryRowContext(d.opCtx(ctx), q, args...)
1567}
1568
1569func (d *claimBoundDB) Begin() (*sql.Tx, error) { return d.BeginTx(context.Background(), nil) }
1570
1571func (d *claimBoundDB) BeginTx(ctx context.Context, opts *sql.TxOptions) (*sql.Tx, error) {
1572 if d.b.closed() {
1573 return nil, ErrClaimSQLRevoked
1574 }
1575 tx, err := d.conn.BeginTx(d.opCtx(ctx), opts)
1576 if err != nil {
1577 return nil, err
1578 }
1579 // Registration window: a handle created after closeHandles took its
1580 // snapshot is never joined, so release it here.
1581 d.mu.Lock()
1582 if d.closed || d.b.closed() {
1583 d.mu.Unlock()
1584 _ = tx.Rollback()
1585 return nil, ErrClaimSQLRevoked
1586 }
1587 d.txs = append(d.txs, tx)
1588 d.mu.Unlock()
1589 return tx, nil
1590}
1591
1592func (d *claimBoundDB) Prepare(q string) (*sql.Stmt, error) {
1593 return d.PrepareContext(context.Background(), q)
1594}
1595
1596func (d *claimBoundDB) PrepareContext(ctx context.Context, q string) (*sql.Stmt, error) {
1597 if d.b.closed() {
1598 return nil, ErrClaimSQLRevoked
1599 }
1600 st, err := d.conn.PrepareContext(context.WithValue(ctx, claimViewStmt{}, true), q)
1601 if err != nil {
1602 return nil, err
1603 }
1604 d.mu.Lock()
1605 if d.closed || d.b.closed() {
1606 d.mu.Unlock()
1607 _ = st.Close()
1608 return nil, ErrClaimSQLRevoked
1609 }
1610 d.stmts = append(d.stmts, st)
1611 d.mu.Unlock()
1612 return st, nil
1613}
1614
1615// closeHandles rolls back open transactions (so a retained *sql.Tx cannot
1616// pin the pool or replay a permit) and closes statements. A transaction still
1617// open here lost its writes to that rollback, so it is reported: the scope
1618// must not succeed, and a batch must not commit its receipt. Cleanup failures
1619// are reported too.
1620func (d *claimBoundDB) closeHandles() error {
1621 d.mu.Lock()
1622 d.closed = true
1623 txs, stmts := d.txs, d.stmts
1624 d.txs, d.stmts = nil, nil
1625 d.mu.Unlock()
1626 var errs []error
1627 for _, tx := range txs {
1628 switch err := tx.Rollback(); {
1629 case err == nil:
1630 errs = append(errs, fmt.Errorf("%w: transaction left open when the scope ended was rolled back", ErrClaimSQLScope))
1631 case !errors.Is(err, sql.ErrTxDone):
1632 errs = append(errs, err)
1633 }
1634 }
1635 for _, st := range stmts {
1636 if err := st.Close(); err != nil {
1637 errs = append(errs, err)
1638 }
1639 }
1640 d.cancel()
1641 return errors.Join(errs...)
1642}
1643
1644// revoke ends the scope: later commits and writes are refused, handles are
1645// closed, the permits are cleared and proven gone, and the connection goes
1646// back to the pool.
1647
1648func (d *claimBoundDB) revoke() error {
1649 d.b.revoked.Store(true)
1650 herr := d.closeHandles()
1651 err := d.conn.Raw(func(dc any) error {
1652 c, ok := dc.(*claimConn)
1653 if !ok {
1654 return errors.New("console: claim SQL connection is not gated")
1655 }
1656 return c.unbind(d.b)
1657 })
1658 return errors.Join(herr, err, d.conn.Close())
1659}
1660
1661// ---- Store entry points -----------------------------------------------------
1662
1663func normalizeClaimScopes(in []claimSQLScope) ([]claimSQLScope, error) {
1664 seen := map[claimSQLScope]bool{}
1665 out := make([]claimSQLScope, 0, len(in))
1666 for _, sc := range in {
1667 if sc.AccountID <= 0 || sc.App == "" {
1668 return nil, fmt.Errorf("%w: account and app are required", ErrClaimSQLScope)
1669 }
1670 if !claimKnownMode(sc.Mode) {
1671 return nil, fmt.Errorf("%w: unknown permit mode %q", ErrClaimSQLScope, sc.Mode)
1672 }
1673 if sc.Mode != claimModeEffect && sc.AppID == "" {
1674 return nil, fmt.Errorf("%w: a %s permit needs an app_id", ErrClaimSQLScope, sc.Mode)
1675 }
1676 if !seen[sc] {
1677 seen[sc] = true
1678 out = append(out, sc)
1679 }
1680 }
1681 return out, nil
1682}
1683
1684// withClaimSQL runs fn against a narrow view of the store that may write the
1685// given scopes' app rows. On a flag-off store it is just fn(s).
1686//
1687// Every scope must pass the remote check before the connection is acquired.
1688// Context, deadline and local capability checks guard each write and commit. The view holds the pool's connection for the duration,
1689// so fn must use only the *Store it is given (an outer store call from fn's
1690// goroutine is refused with errOuterStoreInView; one from a goroutine fn
1691// starts waits for that connection until its context ends), and neither fn
1692// nor check may call withClaimSQL again. Transactions, statements and rows from the view die when
1693// fn returns.
1694func (s *Store) withClaimSQL(ctx context.Context, scopes []claimSQLScope, check func(context.Context, claimSQLScope) error, fn func(*Store) error) error {
1695 return s.withPreparedClaimSQL(ctx, scopes, check, func(ctx context.Context, _ claimSQLScope) error { return ctx.Err() }, nil, false, fn)
1696}
1697
1698func (s *Store) withPreparedClaimSQL(ctx context.Context, scopes []claimSQLScope, check, local func(context.Context, claimSQLScope) error, receipts []hostops.SQLReceiptScope, batch bool, fn func(*Store) error) (err error) {
1699 if s.gate == nil {
1700 return fn(s)
1701 }
1702 if s.bound != nil {
1703 return fmt.Errorf("%w: nested withClaimSQL", ErrClaimSQLScope)
1704 }
1705 if s.outerInsideView() {
1706 return errOuterStoreInView
1707 }
1708 if fn == nil || check == nil || local == nil || len(scopes) == 0 {
1709 return fmt.Errorf("%w: scopes, check and fn are required", ErrClaimSQLScope)
1710 }
1711 scopes, err = normalizeClaimScopes(scopes)
1712 if err != nil {
1713 return err
1714 }
1715 for _, sc := range scopes {
1716 if cerr := check(ctx, sc); cerr != nil {
1717 return fmt.Errorf("%w: %w", ErrClaimSQLDenied, cerr)
1718 }
1719 }
1720 conn, err := s.rawPool.Conn(ctx)
1721 if err != nil {
1722 return err
1723 }
1724 sqids := make([]string, len(scopes))
1725 if s.gate.needSqid() {
1726 for i, sc := range scopes {
1727 qerr := conn.QueryRowContext(ctx, `SELECT sqid FROM accounts WHERE id = ?`, sc.AccountID).Scan(&sqids[i])
1728 if qerr != nil && !errors.Is(qerr, sql.ErrNoRows) {
1729 conn.Close()
1730 return qerr
1731 }
1732 }
1733 }
1734 b := &claimBinding{ctx: ctx, scopes: scopes, check: local, deadline: time.Now().Add(hostops.SQLTransactionLifetime), batch: batch, receipts: receipts, dead: make([]bool, len(scopes))}
1735 berr := conn.Raw(func(dc any) error {
1736 c, ok := dc.(*claimConn)
1737 if !ok {
1738 return errors.New("console: claim SQL connection is not gated")
1739 }
1740 return c.bindScopes(ctx, b, sqids)
1741 })
1742 if berr != nil {
1743 conn.Close()
1744 return berr
1745 }
1746 life, cancel := context.WithCancel(context.Background())
1747 view := &claimBoundDB{conn: conn, b: b, life: life, cancel: cancel}
1748 vs := *s
1749 vs.db = view
1750 vs.bound = b
1751 s.gate.viewG.Store(goroutineID())
1752 defer func() {
1753 s.gate.viewG.Store(0)
1754 err = errors.Join(err, view.revoke())
1755 if !batch {
1756 // A batch reports this from endBatch, before its receipt.
1757 err = errors.Join(err, b.lostErr())
1758 }
1759 }()
1760 if batch {
1761 if err = conn.Raw(func(dc any) error { return dc.(*claimConn).beginBatch(ctx, b) }); err != nil {
1762 return err
1763 }
1764 }
1765 err = fn(&vs)
1766 if batch {
1767 // Seal before joining: from here no public handle reaches the driver,
1768 // whether retained, concurrent or already queued on the connection.
1769 // endBatch then commits under authorizeCommit alone.
1770 b.sealed.Store(true)
1771 err = errors.Join(err, view.closeHandles())
1772 err = errors.Join(err, conn.Raw(func(dc any) error { return dc.(*claimConn).endBatch(b, err == nil) }))
1773 }
1774 return err
1775}
1776
1777// bootDB is the trusted, unvetted handle for idempotent schema checks and
1778// operator DDL. Flag-off it is the same pool as db. Keep callers to the boot
1779// seam: it is on the permit-seam AST list.
1780func (s *Store) bootDB() *sql.DB { return s.rawBoot }
1781
1782// claimGated reports whether the structural SQL gate is armed on s.
1783func (s *Store) claimGated() bool { return s.gate != nil }
1784
1785// OpenGated opens the console DB with the structural SQL gate armed. Open is
1786// unchanged and stays the flag-off path.
1787// Claim-mode SQL requires FS_IOC_GETFSUUID on the console DB filesystem:
1788// Linux 6.5+ with ext4, xfs or btrfs; tmpfs does not support it. Carloku's
1789// console must be checked before the flag goes on.
1790func OpenGated(path string) (*Store, error) {
1791 return openGated(path, path, claimSQLTables, func(s *Store) error {
1792 if err := s.migrate(); err != nil {
1793 return err
1794 }
1795 if _, err := s.db.Exec(`CREATE TABLE IF NOT EXISTS claim_loss_pages (
1796 rule TEXT PRIMARY KEY,
1797 first_attempted_at INTEGER NOT NULL,
1798 attempted_at INTEGER NOT NULL
1799);`); err != nil {
1800 return err
1801 }
1802 // The earlier journal retained only the last attempt. Preserve that
1803 // durable time as the start for existing rows; record age is irrelevant.
1804 if _, err := s.db.Exec(`ALTER TABLE claim_loss_pages ADD COLUMN first_attempted_at INTEGER`); err != nil && !strings.Contains(err.Error(), "duplicate column") {
1805 return err
1806 }
1807 if _, err := s.db.Exec(`UPDATE claim_loss_pages SET first_attempted_at = attempted_at WHERE first_attempted_at IS NULL`); err != nil {
1808 return err
1809 }
1810 if _, err := s.db.Exec(`ALTER TABLE claim_loss_pages ADD COLUMN last_key TEXT NOT NULL DEFAULT ''`); err != nil && !strings.Contains(err.Error(), "duplicate column") {
1811 return err
1812 }
1813 return s.migrateClaimAppIdentity()
1814 })
1815}
1816
1817func openGated(path, dsn string, tables map[string]claimSQLTable, migrate func(*Store) error) (st *Store, err error) {
1818 if dir := filepath.Dir(path); dir != "." && dir != "" {
1819 if err := os.MkdirAll(dir, 0o755); err != nil {
1820 return nil, err
1821 }
1822 }
1823 g := &claimGate{d: &sqlite.Driver{}, dsn: dsn, path: path}
1824 g.d.RegisterConnectionHook(g.hook)
1825 pool := sql.OpenDB(claimConnector{g: g})
1826 boot := sql.OpenDB(claimConnector{g: g, trusted: true})
1827 pool.SetMaxOpenConns(1) // one writer at a time, as Open
1828 boot.SetMaxOpenConns(1)
1829 defer func() {
1830 if err != nil {
1831 pool.Close()
1832 boot.Close()
1833 }
1834 }()
1835 if _, err = pool.Exec("PRAGMA journal_mode=WAL"); err != nil {
1836 return nil, err
1837 }
1838 s := newStore(pool)
1839 s.db = claimOuterDB{DB: pool, g: g}
1840 s.rawBoot, s.gate = boot, g
1841 // Trusted boot seam: unarmed, so migrations may run DDL and seed rows.
1842 if err = migrate(s); err != nil {
1843 return nil, err
1844 }
1845 if g.physical, err = sqlPhysicalIdentity(path); err != nil {
1846 return nil, err
1847 }
1848 if g.instance, err = sqlInstance(); err != nil {
1849 return nil, err
1850 }
1851 if err = initSQLReceipts(boot, g.instance); err != nil {
1852 return nil, err
1853 }
1854 tables = cloneSQLReceiptTables(tables)
1855 present, err := claimSQLCensus(boot, tables)
1856 if err != nil {
1857 return nil, err
1858 }
1859 arm := buildClaimArm(present)
1860 g.mu.Lock()
1861 g.arm = arm
1862 g.mu.Unlock()
1863 g.armed.Store(true)
1864 // Recycle: no unarmed connection may survive the boot.
1865 for _, db := range []*sql.DB{pool, boot} {
1866 db.SetMaxIdleConns(0)
1867 db.SetMaxIdleConns(2)
1868 }
1869 // Prove the arming on a fresh pool connection before handing the store out.
1870 conn, err := pool.Conn(context.Background())
1871 if err != nil {
1872 return nil, err
1873 }
1874 verr := conn.Raw(func(dc any) error {
1875 c, ok := dc.(*claimConn)
1876 if !ok {
1877 return errors.New("console: claim SQL connection is not gated")
1878 }
1879 return c.armedState(context.Background(), arm.triggers)
1880 })
1881 conn.Close()
1882 if verr != nil {
1883 return nil, verr
1884 }
1885 return s, nil
1886}
1887