| 1 | package console |
| 2 | |
| 3 | // claim_sql_grants.go is the only production door into the SQL gate |
| 4 | // (store_gate.go). A grant is one app incarnation plus the canonical |
| 5 | // authority that must still hold before every write and every commit inside |
| 6 | // the callback. Grants are built here, from canonical admission and the |
| 7 | // bucket, by unexported constructors; there is no grant that means "trust the |
| 8 | // caller". |
| 9 | // |
| 10 | // Each grant binds one permit mode (grantModes): effect-time permits may |
| 11 | // write anything of their incarnation; the terminal replay grants (restore |
| 12 | // receipt, trash marker, purge receipt) bind restore, trash and purge |
| 13 | // permits, whose statement families SQLite's triggers enforce row by row. |
| 14 | // |
| 15 | // WithAppMutation lends the callback a Store view bound to one held |
| 16 | // connection. The callback must use only that view: the outer store's pool is |
| 17 | // the held connection, so an outer call deadlocks until its context ends, and |
| 18 | // it would bypass the permits anyway. Keep network calls out of callbacks; |
| 19 | // they hold the console's only SQLite connection. |
| 20 | |
| 21 | import ( |
| 22 | "context" |
| 23 | "encoding/json" |
| 24 | "errors" |
| 25 | "fmt" |
| 26 | "os" |
| 27 | "slices" |
| 28 | |
| 29 | "github.com/carlosframework/platform/internal/hostops" |
| 30 | "github.com/carlosframework/platform/internal/release" |
| 31 | ) |
| 32 | |
| 33 | // sqlGrant is one incarnation a WithAppMutation callback may write, and the |
| 34 | // authority that must keep holding while it does. |
| 35 | type sqlGrant struct { |
| 36 | scope claimSQLScope |
| 37 | kind string |
| 38 | check func(context.Context) error |
| 39 | local func(context.Context) error |
| 40 | prepared **hostops.PreparedSQLPermit |
| 41 | } |
| 42 | |
| 43 | // WithAppMutation runs fn on a view of s that may write exactly the granted |
| 44 | // incarnations' rows, re-validating every grant before each write and commit. |
| 45 | // |
| 46 | // On an ungated store (Open: flag-off, and package tests) there are no |
| 47 | // triggers to bind, so the grants are validated once and fn runs on s. The |
| 48 | // flag-off console never calls this: NewServer refuses HostClaim on an |
| 49 | // ungated store, and only claim code builds grants. |
| 50 | func (s *Store) WithAppMutation(ctx context.Context, grants []sqlGrant, fn func(*Store) error) (err error) { |
| 51 | if fn == nil || len(grants) == 0 { |
| 52 | return fmt.Errorf("%w: grants and fn are required", ErrClaimSQLScope) |
| 53 | } |
| 54 | // Refuse nesting before any remote preparation runs. |
| 55 | if s.bound != nil { |
| 56 | return fmt.Errorf("%w: nested WithAppMutation", ErrClaimSQLScope) |
| 57 | } |
| 58 | if s.outerInsideView() { |
| 59 | return errOuterStoreInView |
| 60 | } |
| 61 | byScope := map[claimSQLScope][]sqlGrant{} |
| 62 | scopes := make([]claimSQLScope, 0, len(grants)) |
| 63 | for _, g := range grants { |
| 64 | if g.check == nil || g.kind == "" || g.scope.AccountID <= 0 || g.scope.App == "" || g.scope.AppID == "" { |
| 65 | return fmt.Errorf("%w: a grant needs an account, app, app_id, kind and check", ErrClaimSQLScope) |
| 66 | } |
| 67 | if mode, ok := grantModes[g.kind]; !ok || g.scope.Mode != mode { |
| 68 | return fmt.Errorf("%w: grant kind %q cannot bind permit mode %q", ErrClaimSQLScope, g.kind, g.scope.Mode) |
| 69 | } |
| 70 | if _, seen := byScope[g.scope]; !seen { |
| 71 | scopes = append(scopes, g.scope) |
| 72 | } |
| 73 | byScope[g.scope] = append(byScope[g.scope], g) |
| 74 | } |
| 75 | check := func(ctx context.Context, sc claimSQLScope) error { |
| 76 | gs, ok := byScope[sc] |
| 77 | if !ok { |
| 78 | return ErrClaimSQLScope |
| 79 | } |
| 80 | for _, g := range gs { |
| 81 | if err := g.check(ctx); err != nil { |
| 82 | return fmt.Errorf("%s grant: %w", g.kind, err) |
| 83 | } |
| 84 | } |
| 85 | return nil |
| 86 | } |
| 87 | if s.gate == nil { |
| 88 | for _, sc := range scopes { |
| 89 | if err := check(ctx, sc); err != nil { |
| 90 | return fmt.Errorf("%w: %w", ErrClaimSQLDenied, err) |
| 91 | } |
| 92 | } |
| 93 | return fn(s) |
| 94 | } |
| 95 | local := func(ctx context.Context, sc claimSQLScope) error { |
| 96 | for _, g := range byScope[sc] { |
| 97 | if g.local != nil { |
| 98 | if err := g.local(ctx); err != nil { |
| 99 | return err |
| 100 | } |
| 101 | } |
| 102 | } |
| 103 | return ctx.Err() |
| 104 | } |
| 105 | // Every prepared permit that names a receipt effect is finished on every |
| 106 | // path, including a refusal before the connection is bound: the local |
| 107 | // fence proves at once that nothing ran, so the effect does not wait out |
| 108 | // its registration. |
| 109 | var permits []*hostops.PreparedSQLPermit |
| 110 | collect := func() { |
| 111 | for _, g := range grants { |
| 112 | if g.prepared != nil && *g.prepared != nil && !slices.Contains(permits, *g.prepared) { |
| 113 | permits = append(permits, *g.prepared) |
| 114 | } |
| 115 | } |
| 116 | } |
| 117 | defer func() { |
| 118 | if value := recover(); value != nil { |
| 119 | for _, p := range permits { |
| 120 | if p.Receipt().Effect != "" { |
| 121 | _ = p.FinishTransaction(errors.New("SQL callback panicked")) |
| 122 | } |
| 123 | } |
| 124 | panic(value) |
| 125 | } |
| 126 | for i := len(permits) - 1; i >= 0; i-- { |
| 127 | if p := permits[i]; p.Receipt().Effect != "" { |
| 128 | err = errors.Join(err, s.finishPreparedSQL(ctx, p, err)) |
| 129 | } |
| 130 | } |
| 131 | }() |
| 132 | // Prepare all remote checks before borrowing the SQLite connection. |
| 133 | for _, sc := range scopes { |
| 134 | if err := check(ctx, sc); err != nil { |
| 135 | collect() |
| 136 | return fmt.Errorf("%w: %w", ErrClaimSQLDenied, err) |
| 137 | } |
| 138 | } |
| 139 | collect() |
| 140 | database, err := s.sqlDatabaseID() |
| 141 | if err != nil { |
| 142 | return err |
| 143 | } |
| 144 | var receipts []hostops.SQLReceiptScope |
| 145 | for _, p := range permits { |
| 146 | if err = p.BindTransaction(database); err != nil { |
| 147 | return err |
| 148 | } |
| 149 | r := p.Receipt() |
| 150 | seen := false |
| 151 | for _, old := range receipts { |
| 152 | if old == r { |
| 153 | seen = true |
| 154 | } |
| 155 | } |
| 156 | if !seen { |
| 157 | receipts = append(receipts, r) |
| 158 | } |
| 159 | } |
| 160 | return s.withPreparedClaimSQL(ctx, scopes, func(context.Context, claimSQLScope) error { return nil }, local, receipts, true, fn) |
| 161 | } |
| 162 | |
| 163 | // readTombstoneVersion is release.ReadAppTombstone plus the marker's exact |
| 164 | // version, from one read. (nil, "", nil) when absent. |
| 165 | func readTombstoneVersion(ctx context.Context, rd release.Store, sqid, app string) (*release.AppTombstone, string, error) { |
| 166 | body, tag, err := rd.Get(ctx, release.AppTombstoneKey(sqid, app)) |
| 167 | if errors.Is(err, os.ErrNotExist) { |
| 168 | return nil, "", nil |
| 169 | } |
| 170 | if err != nil { |
| 171 | return nil, "", fmt.Errorf("release: read trash marker for %s/%s: %w", sqid, app, err) |
| 172 | } |
| 173 | var t release.AppTombstone |
| 174 | if err := json.Unmarshal(body, &t); err != nil { |
| 175 | return nil, "", fmt.Errorf("release: malformed trash marker for %s/%s: %w", sqid, app, err) |
| 176 | } |
| 177 | if t.Schema != 1 || t.Account != sqid || t.App != app || t.DeletedAt.IsZero() || tag == "" { |
| 178 | return nil, "", fmt.Errorf("release: trash marker for %s/%s: %w", sqid, app, hostops.ErrIdentity) |
| 179 | } |
| 180 | return &t, tag, nil |
| 181 | } |
| 182 | |
| 183 | // purgeJournalAbsent refuses while a purge journal exists: an in-flight purge |
| 184 | // stays authoritative over every projection. |
| 185 | func purgeJournalAbsent(ctx context.Context, rd release.Store, sqid, app string) error { |
| 186 | key, err := hostops.PurgeKey(sqid, app) |
| 187 | if err != nil { |
| 188 | return err |
| 189 | } |
| 190 | if _, _, err := rd.Get(ctx, key); !errors.Is(err, os.ErrNotExist) { |
| 191 | if err != nil { |
| 192 | return err |
| 193 | } |
| 194 | return hostops.ErrPending |
| 195 | } |
| 196 | return nil |
| 197 | } |
| 198 | |
| 199 | // Grant kinds, and the one permit mode each may bind (store_gate.go). The |
| 200 | // mode decides the statement family SQLite lets through: only an effect |
| 201 | // permit inserts or adopts; the terminal projections update or delete |
| 202 | // exactly the incarnation they name. |
| 203 | const ( |
| 204 | grantEffect = "effect-permit" |
| 205 | grantRestore = "restore-receipt" |
| 206 | grantTrash = "trash-marker" |
| 207 | grantPurge = "purge-receipt" |
| 208 | ) |
| 209 | |
| 210 | var grantModes = map[string]string{ |
| 211 | grantEffect: claimModeEffect, |
| 212 | grantRestore: claimModeRestore, |
| 213 | grantTrash: claimModeTrash, |
| 214 | grantPurge: claimModePurge, |
| 215 | } |
| 216 | |
| 217 | func scopeIn(sc claimSQLScope, mode string) claimSQLScope { |
| 218 | sc.Mode = mode |
| 219 | return sc |
| 220 | } |
| 221 | |
| 222 | // permitGrant is effect-time authority: the live permit EffectRunner.Issue |
| 223 | // put in pctx (an exclusive operation's issued effect, or a live |
| 224 | // registration's) still validates against canonical admission for this |
| 225 | // incarnation. pctx is captured, not taken from each statement: Store |
| 226 | // methods run ctx-less statements, and a request context must not be able to |
| 227 | // widen or narrow the permit set mid-callback. |
| 228 | func permitGrant(pctx context.Context, rd release.Store, sqid string, sc claimSQLScope) sqlGrant { |
| 229 | var prepared *hostops.PreparedSQLPermit |
| 230 | return sqlGrant{scope: scopeIn(sc, claimModeEffect), kind: grantEffect, prepared: &prepared, |
| 231 | check: func(context.Context) error { |
| 232 | var err error |
| 233 | prepared, err = hostops.PrepareSQLPermit(pctx, rd, sqid, sc.App, sc.AppID) |
| 234 | return err |
| 235 | }, |
| 236 | local: func(ctx context.Context) error { |
| 237 | if prepared == nil { |
| 238 | return hostops.ErrPending |
| 239 | } |
| 240 | return prepared.Check(ctx) |
| 241 | }} |
| 242 | } |
| 243 | |
| 244 | // restoreReceiptCurrent compares immutable incarnation and exact latest |
| 245 | // completion. Later completed operations may raise the admission epoch. The |
| 246 | // caller separately holds a live permit; this predicate grants no authority. |
| 247 | func restoreReceiptCurrent(a hostops.AppAdmission, r hostops.RestoreMark) bool { |
| 248 | cur := a.LastRestore |
| 249 | return cur != nil && *cur == r && r.State == "complete" && r.ID != "" && r.TombstoneETag != "" && r.DeletedAt > 0 && |
| 250 | r.AppID != "" && r.AppID == a.AppID && r.Epoch > 0 && r.Epoch <= a.Epoch |
| 251 | } |
| 252 | |
| 253 | // restoreReceiptGrant binds the narrow restore SQL mode to a live permit, |
| 254 | // the exact latest receipt and absence of both marker and purge journal. |
| 255 | // The row update also matches the receipt's deleted_at. Capture pctx because |
| 256 | // contextless Store methods still have to validate the operation's permit. |
| 257 | func restoreReceiptGrant(pctx context.Context, rd release.Store, sqid string, sc claimSQLScope, r hostops.RestoreMark) sqlGrant { |
| 258 | g := permitGrant(pctx, rd, sqid, sc) |
| 259 | return sqlGrant{scope: scopeIn(sc, claimModeRestore), kind: grantRestore, local: g.local, prepared: g.prepared, check: func(ctx context.Context) error { |
| 260 | a, err := hostops.ReadAdmission(ctx, rd, sqid, sc.App, sc.AppID) |
| 261 | if err != nil { |
| 262 | return err |
| 263 | } |
| 264 | if g.check(ctx) != nil || r.AppID != sc.AppID || !restoreReceiptCurrent(a.Value, r) { |
| 265 | return hostops.ErrPending |
| 266 | } |
| 267 | if err := purgeJournalAbsent(ctx, rd, sqid, sc.App); err != nil { |
| 268 | return err |
| 269 | } |
| 270 | tomb, _, err := readTombstoneVersion(ctx, rd, sqid, sc.App) |
| 271 | if err != nil { |
| 272 | return err |
| 273 | } |
| 274 | if tomb != nil { |
| 275 | return hostops.ErrPending |
| 276 | } |
| 277 | return nil |
| 278 | }} |
| 279 | } |
| 280 | |
| 281 | // tombstoneGrant requires a live permit for this incarnation and the exact |
| 282 | // current marker, with no purge journal. The SQL mode permits only the trash |
| 283 | // columns. The held app operation excludes another console's restore or purge |
| 284 | // through the entire local check and write; a local DB lock alone cannot. |
| 285 | func tombstoneGrant(pctx context.Context, rd release.Store, sqid string, sc claimSQLScope, etag string) sqlGrant { |
| 286 | g := permitGrant(pctx, rd, sqid, sc) |
| 287 | return sqlGrant{scope: scopeIn(sc, claimModeTrash), kind: grantTrash, local: g.local, prepared: g.prepared, check: func(ctx context.Context) error { |
| 288 | _, err := hostops.ReadAdmission(ctx, rd, sqid, sc.App, sc.AppID) |
| 289 | if err != nil { |
| 290 | return err |
| 291 | } |
| 292 | if g.check(ctx) != nil { |
| 293 | return hostops.ErrPending |
| 294 | } |
| 295 | if err := purgeJournalAbsent(ctx, rd, sqid, sc.App); err != nil { |
| 296 | return err |
| 297 | } |
| 298 | _, seen, err := readTombstoneVersion(ctx, rd, sqid, sc.App) |
| 299 | if err != nil { |
| 300 | return err |
| 301 | } |
| 302 | if etag == "" || seen != etag { |
| 303 | return hostops.ErrPending |
| 304 | } |
| 305 | return nil |
| 306 | }} |
| 307 | } |
| 308 | |
| 309 | // purgeRetired is positive evidence that appID was retired by purge op: |
| 310 | // last_purge names exactly that incarnation and operation, and either the |
| 311 | // admission is that incarnation's completed retirement, or a successor |
| 312 | // incarnation already replaced it. A successor exists only through |
| 313 | // EnrollNewApp's recreation transition, which requires the completed |
| 314 | // retirement and carries last_purge over, so an unequal app_id counts only |
| 315 | // together with a last_purge naming the retired one. |
| 316 | func purgeRetired(a hostops.AppAdmission, appID, op string) bool { |
| 317 | lp := a.LastPurge |
| 318 | if lp == nil || op == "" || appID == "" || lp.ID != op || lp.AppID != appID { |
| 319 | return false |
| 320 | } |
| 321 | if a.AppID == appID { |
| 322 | return hostops.CompletedAdmission(a) |
| 323 | } |
| 324 | return true |
| 325 | } |
| 326 | |
| 327 | // purgeReceiptGrant replays a completed purge's SQL cleanup on any console, |
| 328 | // keyed on the retired incarnation, including after the name was recreated: |
| 329 | // the canonical admission (whichever incarnation it now names) carries |
| 330 | // last_purge for scope.AppID and op, and no purge journal exists. Its permit |
| 331 | // is purge mode: DELETE of the retired incarnation's rows, the apps retire and |
| 332 | // claim-metadata UPDATE, and monotone door-key revocation. It cannot create |
| 333 | // an operation, reopen admission or touch a successor's row. |
| 334 | func purgeReceiptGrant(rd release.Store, sqid string, sc claimSQLScope, op string) sqlGrant { |
| 335 | return sqlGrant{scope: scopeIn(sc, claimModePurge), kind: grantPurge, check: func(ctx context.Context) error { |
| 336 | a, err := hostops.ReadCanonicalAdmission(ctx, rd, sqid, sc.App) |
| 337 | if err != nil { |
| 338 | return err |
| 339 | } |
| 340 | if !purgeRetired(a.Value, sc.AppID, op) { |
| 341 | return hostops.ErrPending |
| 342 | } |
| 343 | return purgeJournalAbsent(ctx, rd, sqid, sc.App) |
| 344 | }} |
| 345 | } |
| 346 | |