| 1 | package 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 | |
| 51 | import ( |
| 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 | |
| 69 | var ( |
| 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). |
| 84 | const ( |
| 85 | claimModeEffect = "effect" |
| 86 | claimModePurge = "purge" |
| 87 | claimModeRestore = "restore" |
| 88 | claimModeTrash = "trash" |
| 89 | ) |
| 90 | |
| 91 | func 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. |
| 101 | type 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. |
| 110 | type 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 | |
| 123 | var ( |
| 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. |
| 133 | type 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 | |
| 168 | type 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. |
| 173 | var 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. |
| 183 | var 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. |
| 227 | const claimSQLReservedName = "claim_permit" |
| 228 | |
| 229 | func claimQ(name string) string { return `"` + strings.ReplaceAll(name, `"`, `""`) + `"` } |
| 230 | |
| 231 | func 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. |
| 243 | func 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 | |
| 458 | func (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. |
| 471 | func (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. |
| 496 | func (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 | |
| 507 | func 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. |
| 513 | func claimTrue(c string) string { return "COALESCE(" + c + ", 0)" } |
| 514 | |
| 515 | // unchanged is "every column except allowed is the same in OLD and NEW". |
| 516 | func (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. |
| 534 | func (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 | |
| 542 | const 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 |
| 551 | func 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 | |
| 587 | type claimArm struct { |
| 588 | stmts []string |
| 589 | triggers int |
| 590 | needSqid bool |
| 591 | } |
| 592 | |
| 593 | func 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 | |
| 622 | type claimSQLInfo struct{ write bool } |
| 623 | |
| 624 | var claimSQLFirstWords = map[string]bool{ |
| 625 | "select": true, "values": true, "with": true, |
| 626 | "insert": true, "replace": true, "update": true, "delete": true, |
| 627 | } |
| 628 | |
| 629 | var claimSQLWriteWords = map[string]bool{"insert": true, "replace": true, "update": true, "delete": true} |
| 630 | |
| 631 | func 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 | |
| 635 | func 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 | |
| 642 | func 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. |
| 648 | func 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. |
| 673 | func 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 | |
| 755 | type 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 | |
| 770 | func (g *claimGate) armSnapshot() claimArm { |
| 771 | g.mu.RLock() |
| 772 | defer g.mu.RUnlock() |
| 773 | return g.arm |
| 774 | } |
| 775 | |
| 776 | func (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. |
| 780 | func (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 | |
| 792 | type claimInnerConn interface { |
| 793 | driver.Conn |
| 794 | driver.ConnBeginTx |
| 795 | driver.ConnPrepareContext |
| 796 | driver.ExecerContext |
| 797 | driver.QueryerContext |
| 798 | } |
| 799 | |
| 800 | type claimConnector struct { |
| 801 | g *claimGate |
| 802 | trusted bool // boot handle: armed triggers, but no statement vetting |
| 803 | } |
| 804 | |
| 805 | func (k claimConnector) Driver() driver.Driver { return k.g.d } |
| 806 | |
| 807 | func (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. |
| 834 | type 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. |
| 850 | func (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. |
| 865 | func (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. |
| 878 | func (c *claimConn) closeRows() { |
| 879 | for r := range c.rows { |
| 880 | r.closeInner() |
| 881 | } |
| 882 | } |
| 883 | |
| 884 | func (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. |
| 894 | func (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 | |
| 908 | func (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 | |
| 924 | func (c *claimConn) Prepare(q string) (driver.Stmt, error) { |
| 925 | return c.PrepareContext(context.Background(), q) |
| 926 | } |
| 927 | |
| 928 | func (c *claimConn) Close() error { return c.inner.Close() } |
| 929 | |
| 930 | func (c *claimConn) Begin() (driver.Tx, error) { |
| 931 | return c.BeginTx(context.Background(), driver.TxOptions{}) |
| 932 | } |
| 933 | |
| 934 | func (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 | |
| 960 | func (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 | |
| 979 | func (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 | |
| 996 | func (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. |
| 1020 | func (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 | |
| 1032 | func (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. |
| 1043 | func (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 | |
| 1060 | func (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. |
| 1072 | func (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. |
| 1089 | func (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. |
| 1124 | func (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. |
| 1151 | func (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. |
| 1167 | func (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. |
| 1191 | type 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 | |
| 1214 | func (b *claimBinding) closed() bool { return b.revoked.Load() || b.sealed.Load() } |
| 1215 | |
| 1216 | func (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. |
| 1226 | func (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. |
| 1232 | func (b *claimBinding) authorizeCommit(ctx context.Context, c *claimConn) error { |
| 1233 | return b.admit(ctx, c, true) |
| 1234 | } |
| 1235 | |
| 1236 | func (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. |
| 1283 | func (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. |
| 1297 | type 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. |
| 1311 | func (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 | |
| 1318 | func (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 | |
| 1348 | func (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). |
| 1365 | func (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 | |
| 1374 | func (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. |
| 1386 | type 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. |
| 1394 | type claimViewStmt struct{} |
| 1395 | |
| 1396 | func (s *claimStmt) Close() error { return s.inner.Close() } |
| 1397 | func (s *claimStmt) NumInput() int { return s.inner.NumInput() } |
| 1398 | |
| 1399 | func 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 | |
| 1410 | func 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 | |
| 1418 | func (s *claimStmt) Exec(args []driver.Value) (driver.Result, error) { |
| 1419 | return s.ExecContext(context.Background(), claimValuesToNamed(args)) |
| 1420 | } |
| 1421 | |
| 1422 | func (s *claimStmt) Query(args []driver.Value) (driver.Rows, error) { |
| 1423 | return s.QueryContext(context.Background(), claimValuesToNamed(args)) |
| 1424 | } |
| 1425 | |
| 1426 | func (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 | |
| 1446 | func (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. |
| 1478 | type claimRows struct { |
| 1479 | c *claimConn |
| 1480 | inner driver.Rows |
| 1481 | closed bool |
| 1482 | } |
| 1483 | |
| 1484 | func (r *claimRows) Columns() []string { return r.inner.Columns() } |
| 1485 | |
| 1486 | func (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 | |
| 1497 | func (r *claimRows) Close() error { |
| 1498 | if r.closed { |
| 1499 | return nil |
| 1500 | } |
| 1501 | return r.closeInner() |
| 1502 | } |
| 1503 | |
| 1504 | func (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. |
| 1518 | type 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. |
| 1533 | func (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 | |
| 1539 | func (d *claimBoundDB) Exec(q string, args ...any) (sql.Result, error) { |
| 1540 | return d.ExecContext(context.Background(), q, args...) |
| 1541 | } |
| 1542 | |
| 1543 | func (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 | |
| 1550 | func (d *claimBoundDB) Query(q string, args ...any) (*sql.Rows, error) { |
| 1551 | return d.QueryContext(context.Background(), q, args...) |
| 1552 | } |
| 1553 | |
| 1554 | func (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 | |
| 1561 | func (d *claimBoundDB) QueryRow(q string, args ...any) *sql.Row { |
| 1562 | return d.QueryRowContext(context.Background(), q, args...) |
| 1563 | } |
| 1564 | |
| 1565 | func (d *claimBoundDB) QueryRowContext(ctx context.Context, q string, args ...any) *sql.Row { |
| 1566 | return d.conn.QueryRowContext(d.opCtx(ctx), q, args...) |
| 1567 | } |
| 1568 | |
| 1569 | func (d *claimBoundDB) Begin() (*sql.Tx, error) { return d.BeginTx(context.Background(), nil) } |
| 1570 | |
| 1571 | func (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 | |
| 1592 | func (d *claimBoundDB) Prepare(q string) (*sql.Stmt, error) { |
| 1593 | return d.PrepareContext(context.Background(), q) |
| 1594 | } |
| 1595 | |
| 1596 | func (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. |
| 1620 | func (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 | |
| 1648 | func (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 | |
| 1663 | func 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. |
| 1694 | func (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 | |
| 1698 | func (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. |
| 1780 | func (s *Store) bootDB() *sql.DB { return s.rawBoot } |
| 1781 | |
| 1782 | // claimGated reports whether the structural SQL gate is armed on s. |
| 1783 | func (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. |
| 1790 | func 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 | |
| 1817 | func 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 | |