| 1 | package console |
| 2 | |
| 3 | import ( |
| 4 | "context" |
| 5 | "database/sql" |
| 6 | "errors" |
| 7 | "os" |
| 8 | |
| 9 | "github.com/carlosframework/platform/internal/hostops" |
| 10 | ) |
| 11 | |
| 12 | func (s *Store) BindClaimAppIdentity(accountID int64, app, appID string) error { |
| 13 | if appID == "" { |
| 14 | return hostops.ErrIdentity |
| 15 | } |
| 16 | result, err := s.db.Exec(`UPDATE apps SET app_id=? WHERE account_id=? AND app=? AND (app_id='' OR app_id=?)`, appID, accountID, app, appID) |
| 17 | if err != nil { |
| 18 | return err |
| 19 | } |
| 20 | n, err := result.RowsAffected() |
| 21 | if err != nil { |
| 22 | return err |
| 23 | } |
| 24 | if n != 1 { |
| 25 | return hostops.ErrPending |
| 26 | } |
| 27 | return nil |
| 28 | } |
| 29 | |
| 30 | func (s *Server) claimControl() (*hostops.ControlWriter, error) { |
| 31 | if !s.cfg.HostClaim || s.cfg.ClaimAuthority == nil || s.cfg.ClaimAuthority.Control == nil { |
| 32 | return nil, hostops.ErrContract |
| 33 | } |
| 34 | return s.cfg.ClaimAuthority.Control, nil |
| 35 | } |
| 36 | |
| 37 | // claimAdmissionNameFree is claimApp's read-only exemption for its own |
| 38 | // prefix: only a fully completed, retained admission (and a request naming |
| 39 | // that retired incarnation) may remain. It never writes; EnrollNewApp |
| 40 | // repeats the census and discards the request itself. |
| 41 | func (s *Server) claimAdmissionNameFree(ctx context.Context, account, app string) (bool, error) { |
| 42 | prefix := "apps/" + account + "/" + app + "/" |
| 43 | keys, err := s.cfg.Releases.List(ctx, prefix) |
| 44 | if err != nil { |
| 45 | return false, err |
| 46 | } |
| 47 | if len(keys) == 0 { |
| 48 | return true, nil |
| 49 | } |
| 50 | a, err := hostops.ReadCanonicalAdmission(ctx, s.cfg.Releases, account, app) |
| 51 | if err != nil || !hostops.CompletedAdmission(a.Value) { |
| 52 | return false, err |
| 53 | } |
| 54 | for _, key := range keys { |
| 55 | if key != prefix+"admission.json" && key != prefix+"purge-request.json" { |
| 56 | return false, nil |
| 57 | } |
| 58 | } |
| 59 | return true, nil |
| 60 | } |
| 61 | |
| 62 | // bindClaimAppIdentity writes the canonical app_id into this console's row |
| 63 | // as an effect of a short exclusive "enroll" operation: the SQL gate admits |
| 64 | // it on that permit, and no purge or other mutation can interleave. |
| 65 | func (s *Server) bindClaimAppIdentity(ctx context.Context, acct *Account, app, appID string) error { |
| 66 | var err error |
| 67 | ctx, err = s.claimEnrollmentContext(ctx, acct, app, appID) |
| 68 | if err != nil { |
| 69 | return err |
| 70 | } |
| 71 | if _, err := s.claimControl(); err != nil { |
| 72 | return err |
| 73 | } |
| 74 | scope := claimSQLScope{AccountID: acct.ID, App: app, AppID: appID} |
| 75 | return s.claimAppMutation(ctx, acct, app, "enroll", func(ctx context.Context) error { |
| 76 | // This local SQL call has joined when it returns. Its error cannot |
| 77 | // authorize a late remote effect, and the next sweep retries the bind. |
| 78 | return s.store.WithAppMutation(ctx, []sqlGrant{permitGrant(ctx, s.cfg.Releases, acct.Sqid, scope)}, func(st *Store) error { |
| 79 | return st.BindClaimAppIdentity(acct.ID, app, appID) |
| 80 | }) |
| 81 | }) |
| 82 | } |
| 83 | |
| 84 | // reserveClaimAppAdmission runs only after this request won SQL new-row |
| 85 | // creation. The canonical identity is chosen by the bucket CAS; SQL binds it. |
| 86 | func (s *Server) reserveClaimAppAdmission(ctx context.Context, acct *Account, app string) error { |
| 87 | w, err := s.claimControl() |
| 88 | if err != nil { |
| 89 | return err |
| 90 | } |
| 91 | appID, err := w.EnrollNewApp(ctx, acct.Sqid, app) |
| 92 | if err != nil { |
| 93 | return err |
| 94 | } |
| 95 | return s.bindClaimAppIdentity(ctx, acct, app, appID) |
| 96 | } |
| 97 | |
| 98 | func (s *Server) initializeBackfillClaimAppAdmission(ctx context.Context, acct *Account, app string) error { |
| 99 | w, err := s.claimControl() |
| 100 | if err != nil { |
| 101 | return err |
| 102 | } |
| 103 | grant, err := s.cfg.ClaimAuthority.BackfillAppGrant(ctx, acct.Sqid, app) |
| 104 | if err != nil { |
| 105 | return err |
| 106 | } |
| 107 | appID, err := w.EnrollBackfillApp(ctx, grant) |
| 108 | if err != nil { |
| 109 | return err |
| 110 | } |
| 111 | return s.bindClaimAppIdentity(ctx, acct, app, appID) |
| 112 | } |
| 113 | |
| 114 | func (s *Server) bindExistingClaimAppAdmission(ctx context.Context, acct *Account, app string) error { |
| 115 | a, err := hostops.ReadCanonicalAdmission(ctx, s.cfg.Releases, acct.Sqid, app) |
| 116 | if err != nil { |
| 117 | return err |
| 118 | } |
| 119 | if hostops.CompletedAdmission(a.Value) { |
| 120 | return hostops.ErrPending |
| 121 | } |
| 122 | bound, err := s.store.ClaimAppID(acct.ID, app) |
| 123 | if err != nil && !errors.Is(err, hostops.ErrIdentity) { |
| 124 | return err |
| 125 | } |
| 126 | if bound != "" { |
| 127 | if bound != a.Value.AppID { |
| 128 | return hostops.ErrPending |
| 129 | } |
| 130 | return nil |
| 131 | } |
| 132 | // A legacy unbound row carries no evidence that it belongs to a name's |
| 133 | // new incarnation. Only creation binds new rows after a completed purge. |
| 134 | if a.Value.LastPurge != nil { |
| 135 | return hostops.ErrPending |
| 136 | } |
| 137 | return s.bindClaimAppIdentity(ctx, acct, app, a.Value.AppID) |
| 138 | } |
| 139 | |
| 140 | func (s *Server) discardCompletedPurgeRequest(ctx context.Context, account, app string) (bool, error) { |
| 141 | w, err := s.claimControl() |
| 142 | if err != nil { |
| 143 | return false, err |
| 144 | } |
| 145 | return w.DiscardPurgeRequest(ctx, account, app) |
| 146 | } |
| 147 | |
| 148 | func (s *Server) SweepClaimAppIdentities(ctx context.Context) error { |
| 149 | if !s.cfg.HostClaim { |
| 150 | return nil |
| 151 | } |
| 152 | rows, err := s.store.AppRows() |
| 153 | if err != nil { |
| 154 | return err |
| 155 | } |
| 156 | var failures []error |
| 157 | for _, row := range rows { |
| 158 | if err := ctx.Err(); err != nil { |
| 159 | return errors.Join(append(failures, err)...) |
| 160 | } |
| 161 | acct, err := s.store.AccountByID(row.AccountID) |
| 162 | if err != nil { |
| 163 | failures = append(failures, err) |
| 164 | continue |
| 165 | } |
| 166 | if acct == nil || acct.Sqid != row.Sqid { |
| 167 | failures = append(failures, hostops.ErrIdentity) |
| 168 | continue |
| 169 | } |
| 170 | _, err = hostops.ReadCanonicalAdmission(ctx, s.cfg.Releases, row.Sqid, row.App) |
| 171 | if errors.Is(err, os.ErrNotExist) { |
| 172 | err = s.initializeBackfillClaimAppAdmission(ctx, acct, row.App) |
| 173 | } else if err == nil { |
| 174 | err = s.bindExistingClaimAppAdmission(ctx, acct, row.App) |
| 175 | } |
| 176 | if err != nil && !errors.Is(err, hostops.ErrPending) { |
| 177 | failures = append(failures, err) |
| 178 | } |
| 179 | } |
| 180 | return errors.Join(failures...) |
| 181 | } |
| 182 | |
| 183 | // Enrollment alone may supply an identity before the local row is bound. |
| 184 | // The value is private to this package and scoped to one account and app. |
| 185 | // Existing nonempty identities must match; errors never authorize enrollment. |
| 186 | type claimEnrollmentKey struct{} |
| 187 | type claimEnrollmentIdentity struct{ account, app, id string } |
| 188 | |
| 189 | func (s *Server) claimEnrollmentContext(ctx context.Context, acct *Account, app, appID string) (context.Context, error) { |
| 190 | if acct == nil || appID == "" { |
| 191 | return ctx, hostops.ErrIdentity |
| 192 | } |
| 193 | id, err := s.store.ClaimAppID(acct.ID, app) |
| 194 | if err != nil && !errors.Is(err, sql.ErrNoRows) && !errors.Is(err, hostops.ErrIdentity) { |
| 195 | return ctx, err |
| 196 | } |
| 197 | if id != "" && id != appID { |
| 198 | return ctx, hostops.ErrIdentity |
| 199 | } |
| 200 | return context.WithValue(ctx, claimEnrollmentKey{}, claimEnrollmentIdentity{acct.Sqid, app, appID}), nil |
| 201 | } |
| 202 | |
| 203 | func (s *Server) mutationAppID(ctx context.Context, account, app string) (string, error) { |
| 204 | acct, err := s.store.AccountBySqid(account) |
| 205 | if err != nil { |
| 206 | return "", err |
| 207 | } |
| 208 | if acct == nil { |
| 209 | return "", hostops.ErrIdentity |
| 210 | } |
| 211 | id, err := s.store.ClaimAppID(acct.ID, app) |
| 212 | if enrollment, ok := ctx.Value(claimEnrollmentKey{}).(claimEnrollmentIdentity); ok && enrollment.account == account && enrollment.app == app { |
| 213 | if err != nil && !errors.Is(err, sql.ErrNoRows) && !errors.Is(err, hostops.ErrIdentity) { |
| 214 | return "", err |
| 215 | } |
| 216 | if id != "" && id != enrollment.id { |
| 217 | return "", hostops.ErrIdentity |
| 218 | } |
| 219 | return enrollment.id, nil |
| 220 | } |
| 221 | return id, err |
| 222 | } |
| 223 | |