carlos / platform Public

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

Plain git — no account needed to clone.

Download

Download this file

1package console
2
3// 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
21import (
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.
35type 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.
50func (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.
165func 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.
185func 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.
203const (
204 grantEffect = "effect-permit"
205 grantRestore = "restore-receipt"
206 grantTrash = "trash-marker"
207 grantPurge = "purge-receipt"
208)
209
210var grantModes = map[string]string{
211 grantEffect: claimModeEffect,
212 grantRestore: claimModeRestore,
213 grantTrash: claimModeTrash,
214 grantPurge: claimModePurge,
215}
216
217func 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.
228func 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.
247func 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.
257func 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.
285func 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.
316func 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.
334func 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