package console import ( "context" "database/sql" "time" "github.com/carlosframework/platform/internal/hostops" ) // migrateClaimAppIdentity is boot-seam schema work: it runs on the trusted, // unvetted boot handle, before OpenGated's census arms the gate (and is an // idempotent check afterwards). func (s *Store) migrateClaimAppIdentity() error { rows, err := s.bootDB().Query(`PRAGMA table_info(apps)`) if err != nil { return err } found := map[string]bool{} for rows.Next() { var cid, notnull, pk int var name, kind string var defaultValue sql.NullString if err = rows.Scan(&cid, &name, &kind, ¬null, &defaultValue, &pk); err != nil { rows.Close() return err } found[name] = true } err = rows.Err() rows.Close() if err != nil { return err } for _, column := range []struct{ name, ddl string }{ {"app_id", `ALTER TABLE apps ADD COLUMN app_id TEXT NOT NULL DEFAULT ''`}, {"claim_op", `ALTER TABLE apps ADD COLUMN claim_op TEXT NOT NULL DEFAULT ''`}, {"claim_revision", `ALTER TABLE apps ADD COLUMN claim_revision INTEGER NOT NULL DEFAULT 0`}, } { if !found[column.name] { if _, err = s.bootDB().Exec(column.ddl); err != nil { return err } } } for _, statement := range []string{ `CREATE UNIQUE INDEX IF NOT EXISTS apps_claim_identity ON apps(app_id) WHERE app_id!=''`, } { if _, err = s.bootDB().Exec(statement); err != nil { return err } } return nil } // ClaimAppID is the row's bound incarnation. sql.ErrNoRows when there is no // row; hostops.ErrIdentity when the row is not bound yet. func (s *Store) ClaimAppID(accountID int64, app string) (string, error) { var id string err := s.db.QueryRow(`SELECT app_id FROM apps WHERE account_id=? AND app=?`, accountID, app).Scan(&id) if err == nil && id == "" { return "", hostops.ErrIdentity } return id, err } func (s *Store) BindClaimAppOperation(accountID int64, app, appID, op string, revision uint64) error { result, err := s.db.Exec(`UPDATE apps SET claim_op=?,claim_revision=? WHERE account_id=? AND app=? AND app_id=? AND (claim_revision 0 THEN ? ELSE 0 END, purge_after = CASE WHEN expires_at > 0 THEN ? ELSE 0 END WHERE account_id = ? AND app = ? AND app_id = ? AND deleted_at = 0`, sec, sec, sec+temporaryCooloffSeconds, accountID, app, appID) } // SetClaimRestoring is SetRestoring bound to the incarnation. func (s *Store) SetClaimRestoring(accountID int64, app, appID string, on bool) (bool, error) { if on { return s.changed(`UPDATE apps SET restoring = 1 WHERE account_id = ? AND app = ? AND app_id = ? AND deleted_at != 0`, accountID, app, appID) } return s.changed(`UPDATE apps SET restoring = 0 WHERE account_id = ? AND app = ? AND app_id = ? AND restoring != 0`, accountID, app, appID) } // BumpClaimTrashedExpiry is BumpTrashedExpiry bound to the incarnation. func (s *Store) BumpClaimTrashedExpiry(accountID int64, app, appID string, now time.Time) (bool, error) { return s.changed(`UPDATE apps SET expires_at = ? WHERE account_id = ? AND app = ? AND app_id = ? AND expires_at > 0 AND expires_at < ? AND deleted_at != 0`, now.Add(temporaryTTLDefault).Unix(), accountID, app, appID, now.Add(temporaryRestoreFloor).Unix()) } // FinishClaimRestoreRow is FinishRestoreRow (restore step 5, with the // temporary app's expiry bump folded in as projection) bound to the // incarnation and to the receipt: only a row trashed by exactly the receipt's // marker (deleted_at = the marker's clock) is restored. A row trashed by any // other trash keeps its trash. func (s *Store) FinishClaimRestoreRow(accountID int64, app, appID string, receiptDeletedAt int64, now time.Time) (bool, error) { if receiptDeletedAt <= 0 { return false, hostops.ErrIdentity } return s.changed(`UPDATE apps SET deleted_at = 0, restoring = 0, purge_after = 0, purge_for = 0, expires_at = CASE WHEN expires_at > 0 AND expires_at < ? THEN ? ELSE expires_at END WHERE account_id = ? AND app = ? AND app_id = ? AND deleted_at = ?`, now.Add(temporaryRestoreFloor).Unix(), now.Add(temporaryTTLDefault).Unix(), accountID, app, appID, receiptDeletedAt) } // MarkClaimAppRetired hides a row whose incarnation a completed purge // retired, so PurgeClaimAppRow's deleted_at != 0 guard holds on a console // that never saw the marker. func (s *Store) MarkClaimAppRetired(accountID int64, app, appID string, now time.Time) error { _, err := s.db.Exec(`UPDATE apps SET deleted_at=? WHERE account_id=? AND app=? AND app_id=? AND deleted_at=0`, now.Unix(), accountID, app, appID) return err } func (s *Store) PurgeClaimAppRow(ctx context.Context, accountID int64, app, appID, op string) error { tx, err := s.db.BeginTx(ctx, nil) if err != nil { return err } defer tx.Rollback() var current string err = tx.QueryRowContext(ctx, `SELECT app_id FROM apps WHERE account_id=? AND app=?`, accountID, app).Scan(¤t) if err == sql.ErrNoRows { return tx.Commit() } if err != nil { return err } if current != appID { return hostops.ErrIdentity } res, err := tx.ExecContext(ctx, `DELETE FROM apps WHERE account_id=? AND app=? AND app_id=? AND claim_op=? AND deleted_at!=0`, accountID, app, appID, op) if err != nil { return err } n, err := res.RowsAffected() if err != nil { return err } if n != 1 { return hostops.ErrPending } var sqid string if err = tx.QueryRowContext(ctx, `SELECT sqid FROM accounts WHERE id=?`, accountID).Scan(&sqid); err != nil { return err } if err = forgetMailApp(ctx, tx, sqid, app); err != nil { return err } if _, err = tx.ExecContext(ctx, `DELETE FROM cli_tokens WHERE kind IN (?,?) AND account_id=? AND app=?`, cliKindApp, cliKindService, accountID, app); err != nil { return err } if err = revokeClaimDoorKeys(tx, accountID, app); err != nil { return err } return tx.Commit() } // revokeClaimDoorKeys is revokeAllDoorKeys for the claim purge. A purge // permit may only raise door_keys.revoked_at and door_key_revisions.revision // (never insert), so the revision moves by a plain UPDATE: an app that has // keys always has its revision row, written in the same transaction as the // key (DoorKeyInsert). The revision still climbs past what boxes admitted. func revokeClaimDoorKeys(tx *sql.Tx, accountID int64, app string) error { res, err := tx.Exec(`UPDATE door_keys SET revoked_at = ? WHERE account_id = ? AND app = ? AND revoked_at = 0`, time.Now().UnixNano(), accountID, app) if err != nil { return err } if n, err := res.RowsAffected(); err != nil || n == 0 { return err } _, err = tx.Exec(`UPDATE door_key_revisions SET revision = revision + 1 WHERE account_id = ? AND app = ?`, accountID, app) return err }