snapshot of https://github.com/ory/kratos.git (master, 2026-09-15; blobs > 131072B omitted)
Some checks failed
CI / Run tests and lints (push) Failing after 29s
CI / Build CLI docs (push) Has been skipped
CI / Run Playwright end-to-end tests (mysql) (push) Failing after 3s
CI / Run Playwright end-to-end tests (postgres) (push) Failing after 2s
CI / Run Playwright end-to-end tests (sqlite) (push) Failing after 3s
CodeQL / Analyze (go) (push) Failing after 4s
CodeQL / Analyze (javascript) (push) Failing after 3s
Docker Image Scanners / scanners (push) Failing after 4s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Run end-to-end tests (postgres) (push) Failing after 19s
CI / Run end-to-end tests (sqlite) (push) Failing after 2s
CI / Run Playwright end-to-end tests (cockroach) (push) Failing after 2s
CI / Draft newsletter (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Pending approval Slack notification (push) Has been skipped
CI / Send newsletter (push) Has been skipped

This commit is contained in:
upstream-mirror
2026-09-15 21:19:12 +05:00
commit c60cc7ca75
8092 changed files with 394160 additions and 0 deletions

105
cmd/migrate/handler.go Normal file
View File

@@ -0,0 +1,105 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package migrate
import (
"fmt"
"github.com/pkg/errors"
"github.com/ory/x/popx"
"github.com/ory/x/contextx"
"github.com/ory/x/configx"
"github.com/spf13/cobra"
"github.com/ory/kratos/driver"
"github.com/ory/kratos/driver/config"
"github.com/ory/x/cmdx"
"github.com/ory/x/flagx"
)
type MigrateHandler struct{}
func NewMigrateHandler() *MigrateHandler {
return &MigrateHandler{}
}
func (h *MigrateHandler) getPersister(cmd *cobra.Command, args []string, opts []driver.RegistryOption) (driver.Registry, error) {
return getPersister(cmd, args, opts)
}
func getPersister(cmd *cobra.Command, args []string, opts []driver.RegistryOption) (d driver.Registry, err error) {
if flagx.MustGetBool(cmd, "read-from-env") {
d, err = driver.NewWithoutInit(
cmd.Context(),
cmd.ErrOrStderr(),
driver.WithConfigOptions(
configx.WithFlags(cmd.Flags()),
configx.SkipValidation(),
))
if err != nil {
return nil, err
}
if len(d.Config().DSN(cmd.Context())) == 0 {
fmt.Println(cmd.UsageString())
fmt.Println("")
fmt.Println("When using flag -e, environment variable DSN must be set")
return nil, cmdx.FailSilently(cmd)
}
} else {
if len(args) != 1 {
fmt.Println(cmd.UsageString())
return nil, cmdx.FailSilently(cmd)
}
d, err = driver.NewWithoutInit(
cmd.Context(),
cmd.ErrOrStderr(),
driver.WithConfigOptions(
configx.WithFlags(cmd.Flags()),
configx.SkipValidation(),
configx.WithValue(config.ViperKeyDSN, args[0]),
))
if err != nil {
return nil, err
}
}
err = d.Init(cmd.Context(), &contextx.Default{}, append(opts, driver.SkipNetworkInit)...)
if err != nil {
return nil, errors.Wrap(err, "an error occurred initializing migrations")
}
if err := popx.VerifyDialect(cmd.Context(), d.Persister().GetConnection(cmd.Context())); err != nil {
return nil, err
}
return d, nil
}
func (h *MigrateHandler) MigrateSQLDown(cmd *cobra.Command, args []string, opts ...driver.RegistryOption) error {
p, err := h.getPersister(cmd, args, opts)
if err != nil {
return err
}
return popx.MigrateSQLDown(cmd, p.Persister())
}
func (h *MigrateHandler) MigrateSQLStatus(cmd *cobra.Command, args []string, opts ...driver.RegistryOption) error {
p, err := h.getPersister(cmd, args, opts)
if err != nil {
return err
}
return popx.MigrateStatus(cmd, p.Persister())
}
func (h *MigrateHandler) MigrateSQLUp(cmd *cobra.Command, args []string, opts ...driver.RegistryOption) error {
p, err := h.getPersister(cmd, args, opts)
if err != nil {
return err
}
return popx.MigrateSQLUp(cmd, p.Persister())
}

View File

@@ -0,0 +1,55 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package migrate
import (
"fmt"
"time"
"github.com/spf13/cobra"
"github.com/ory/kratos/driver"
"github.com/ory/x/configx"
)
func NewNormalizePhoneCmd(opts ...driver.RegistryOption) *cobra.Command {
c := &cobra.Command{
Use: "normalize-phone-numbers [database-url]",
Short: "Normalize phone numbers to E.164 format in the database",
Long: `Normalizes all phone numbers in identity_credential_identifiers,
identity_verifiable_addresses, and identity_recovery_addresses to E.164 format.
This command uses keyset pagination to iterate over the database in batches.
It is safe to run multiple times (idempotent) and can be interrupted and resumed
using the --start-after flag with the last ID printed in the progress output.
Run this command AFTER deploying the code changes that normalize phone numbers
on write, to ensure all legacy data is also normalized.
You can read in the database URL using the -e flag, for example:
export DSN=...
kratos migrate normalize-phone-numbers -e
### WARNING ###
Before running this command on an existing database, create a back up!
`,
RunE: func(cmd *cobra.Command, args []string) error {
err := NewNormalizePhoneHandler().NormalizePhoneNumbers(cmd, args, opts...)
if err != nil {
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), err)
return err
}
return nil
},
}
configx.RegisterFlags(c.PersistentFlags())
c.Flags().BoolP("read-from-env", "e", false, "If set, reads the database connection string from the environment variable DSN or config file key dsn.")
c.Flags().IntP("batch-size", "b", 1000, "Number of rows to process per batch")
c.Flags().Bool("dry-run", false, "If set, only report what would change without writing")
c.Flags().Duration("batch-delay", time.Second, "Delay between batches to reduce database load (e.g. 100ms, 1s)")
c.Flags().StringSlice("start-after", nil, "Resume after a table's last processed ID, e.g. --start-after credentials=<id> --start-after verifiable=<id>")
return c
}

View File

@@ -0,0 +1,221 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package migrate
import (
"fmt"
"regexp"
"strings"
"text/tabwriter"
"time"
"github.com/gofrs/uuid"
"github.com/pkg/errors"
"github.com/spf13/cobra"
"github.com/ory/kratos/driver"
"github.com/ory/kratos/x"
"github.com/ory/pop/v6"
"github.com/ory/x/flagx"
)
// phonePattern matches values that look like phone numbers: + followed by at least one digit,
// possibly with spaces, dashes, or parens. This filters out OIDC subjects and other
// identifiers that happen to start with +.
var phonePattern = regexp.MustCompile(`^\+[\d\s\-().]+$`)
type NormalizePhoneHandler struct{}
func NewNormalizePhoneHandler() *NormalizePhoneHandler {
return &NormalizePhoneHandler{}
}
type normalizeStats struct {
scanned int
updated int
skipped int
errors int
}
func (h *NormalizePhoneHandler) NormalizePhoneNumbers(cmd *cobra.Command, args []string, opts ...driver.RegistryOption) error {
d, err := getPersister(cmd, args, opts)
if err != nil {
return err
}
conn := d.Persister().GetConnection(cmd.Context())
batchSize := flagx.MustGetInt(cmd, "batch-size")
dryRun := flagx.MustGetBool(cmd, "dry-run")
batchDelay := flagx.MustGetDuration(cmd, "batch-delay")
if dryRun {
_, _ = fmt.Fprintln(cmd.ErrOrStderr(), "Dry run mode enabled. No changes will be written.")
}
tables := []tableConfig{
{
key: "ci",
name: "Credential identifiers",
selectQuery: `
SELECT id, identifier AS value
FROM identity_credential_identifiers
WHERE identifier LIKE '+%'
AND id > ?
ORDER BY id ASC LIMIT ?`,
updateQuery: `UPDATE identity_credential_identifiers SET identifier = ?, updated_at = ? WHERE id = ? AND identifier = ?`,
},
{
key: "va",
name: "Verifiable addresses",
selectQuery: `
SELECT id, value
FROM identity_verifiable_addresses
WHERE via = 'sms'
AND id > ?
AND value LIKE '+%'
ORDER BY id ASC LIMIT ?`,
updateQuery: `UPDATE identity_verifiable_addresses SET value = ?, updated_at = ? WHERE id = ? AND value = ?`,
},
{
key: "ra",
name: "Recovery addresses",
selectQuery: `
SELECT id, value
FROM identity_recovery_addresses
WHERE via = 'sms'
AND id > ?
AND value LIKE '+%'
ORDER BY id ASC LIMIT ?`,
updateQuery: `UPDATE identity_recovery_addresses SET value = ?, updated_at = ? WHERE id = ? AND value = ?`,
},
}
startAfterMap, err := parseStartAfter(flagx.MustGetStringSlice(cmd, "start-after"))
if err != nil {
return err
}
allStats := make([]normalizeStats, len(tables))
for i, table := range tables {
startAfter := startAfterMap[table.key]
if startAfter != uuid.Nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s: resuming after ID %s\n", table.name, startAfter)
}
stats, err := normalizeTable(conn, batchSize, batchDelay, startAfter, dryRun, cmd, table)
if err != nil {
return errors.Wrapf(err, "normalizing %s", table.name)
}
allStats[i] = stats
}
printSummary(cmd, tables, allStats)
return nil
}
type tableConfig struct {
key string
name string
selectQuery string
updateQuery string
}
// parseStartAfter parses --start-after flags of the form "key=uuid".
func parseStartAfter(args []string) (map[string]uuid.UUID, error) {
result := make(map[string]uuid.UUID)
for _, arg := range args {
key, val, ok := strings.Cut(arg, "=")
if !ok {
return nil, errors.Errorf("invalid --start-after format %q: expected key=uuid (e.g. credentials=<id>)", arg)
}
id, err := uuid.FromString(val)
if err != nil {
return nil, errors.Wrapf(err, "invalid UUID in --start-after %q", arg)
}
result[key] = id
}
return result, nil
}
func printSummary(cmd *cobra.Command, tables []tableConfig, allStats []normalizeStats) {
out := cmd.OutOrStdout()
_, _ = fmt.Fprintln(out)
tw := tabwriter.NewWriter(out, 0, 0, 2, ' ', 0)
_, _ = fmt.Fprintln(tw, "\tSCANNED\tUPDATED\tSKIPPED\tERRORS")
var total normalizeStats
for i, table := range tables {
s := allStats[i]
_, _ = fmt.Fprintf(tw, "%s\t%d\t%d\t%d\t%d\n", table.name, s.scanned, s.updated, s.skipped, s.errors)
total.scanned += s.scanned
total.updated += s.updated
total.skipped += s.skipped
total.errors += s.errors
}
_, _ = fmt.Fprintf(tw, "TOTAL\t%d\t%d\t%d\t%d\n", total.scanned, total.updated, total.skipped, total.errors)
_ = tw.Flush()
}
func normalizeTable(conn *pop.Connection, batchSize int, batchDelay time.Duration, startAfter uuid.UUID, dryRun bool, cmd *cobra.Command, table tableConfig) (normalizeStats, error) {
var stats normalizeStats
lastID := startAfter
for {
var rows []struct {
ID uuid.UUID `db:"id"`
Value string `db:"value"`
}
if err := conn.RawQuery(table.selectQuery, lastID, batchSize).All(&rows); err != nil {
return stats, errors.Wrapf(err, "querying %s", table.name)
}
if len(rows) == 0 {
break
}
for _, row := range rows {
lastID = row.ID
stats.scanned++
// The SQL query pre-filters with LIKE '+%'. This Go-side regex additionally
// rejects non-phone identifiers (e.g. OIDC subjects) that happen to start
// with '+'. We cannot use SQL regex because SQLite does not support it.
if !phonePattern.MatchString(row.Value) {
stats.skipped++
continue
}
normalized := x.NormalizePhoneIdentifier(row.Value)
if normalized == row.Value {
stats.skipped++
continue
}
if dryRun {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "[dry-run] %s %s: %q -> %q\n", table.name, row.ID, row.Value, normalized)
stats.updated++
continue
}
now := time.Now().UTC().Truncate(time.Second)
if err := conn.RawQuery(table.updateQuery, normalized, now, row.ID, row.Value).Exec(); err != nil {
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "error updating %s %s (%q -> %q): %v\n", table.name, row.ID, row.Value, normalized, err)
stats.errors++
continue
}
stats.updated++
}
_, _ = fmt.Fprintf(cmd.ErrOrStderr(), "%s\tprocessed %d rows so far (--start-after %s=%s)\n", table.name, stats.scanned, table.key, lastID)
if batchDelay > 0 {
time.Sleep(batchDelay)
}
}
return stats, nil
}

View File

@@ -0,0 +1,70 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package migrate
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/ory/kratos/x"
)
func TestNormalizePhoneIdentifier(t *testing.T) {
for _, tc := range []struct {
name string
input string
expected string
shouldSkip bool
}{
{
name: "German phone with spaces",
input: "+49 176 671 11 638",
expected: "+4917667111638",
},
{
name: "German phone with dashes",
input: "+49-176-671-11-638",
expected: "+4917667111638",
},
{
name: "Already normalized E.164",
input: "+4917667111638",
expected: "+4917667111638",
shouldSkip: true,
},
{
name: "US phone with parens",
input: "+1 (415) 555-2671",
expected: "+14155552671",
},
{
name: "Malaysian phone with extra zero",
input: "+600136866982",
expected: "+60136866982",
},
{
name: "Not a valid phone number",
input: "+invalid",
expected: "+invalid",
shouldSkip: true,
},
{
name: "Email address (not a phone)",
input: "+foo@bar.com",
expected: "+foo@bar.com",
shouldSkip: true,
},
} {
t.Run(tc.name, func(t *testing.T) {
normalized := x.NormalizePhoneIdentifier(tc.input)
assert.Equal(t, tc.expected, normalized)
if tc.shouldSkip {
assert.Equal(t, tc.input, normalized, "should be unchanged (skip)")
} else {
assert.NotEqual(t, tc.input, normalized, "should be changed (update)")
}
})
}
}

47
cmd/migrate/root.go Normal file
View File

@@ -0,0 +1,47 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package migrate
import (
"github.com/spf13/cobra"
"github.com/ory/kratos/driver"
"github.com/ory/x/configx"
"github.com/ory/x/popx"
)
func NewMigrateCmd() *cobra.Command {
return &cobra.Command{
Use: "migrate",
Short: "Various migration helpers",
}
}
func RegisterCommandRecursive(parent *cobra.Command) {
c := NewMigrateCmd()
configx.RegisterFlags(c.PersistentFlags())
c.AddCommand(NewMigrateSQLCmd())
c.AddCommand(NewNormalizePhoneCmd())
parent.AddCommand(c)
}
func NewMigrateSQLDownCmd(opts ...driver.RegistryOption) *cobra.Command {
return popx.NewMigrateSQLDownCmd(func(cmd *cobra.Command, args []string) error {
return NewMigrateHandler().MigrateSQLDown(cmd, args, opts...)
})
}
func NewMigrateSQLUpCmd(opts ...driver.RegistryOption) *cobra.Command {
return popx.NewMigrateSQLUpCmd(func(cmd *cobra.Command, args []string) error {
return NewMigrateHandler().MigrateSQLUp(cmd, args, opts...)
})
}
func NewMigrateSQLStatusCmd(opts ...driver.RegistryOption) *cobra.Command {
return popx.NewMigrateSQLStatusCmd(func(cmd *cobra.Command, args []string) error {
return NewMigrateHandler().MigrateSQLStatus(cmd, args, opts...)
})
}

45
cmd/migrate/sql.go Normal file
View File

@@ -0,0 +1,45 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package migrate
import (
"github.com/spf13/cobra"
"github.com/ory/kratos/driver"
"github.com/ory/x/configx"
)
func NewMigrateSQLCmd(opts ...driver.RegistryOption) *cobra.Command {
c := &cobra.Command{
Use: "sql [database-url]",
Deprecated: "Please use `kratos migrate sql up|down|status` instead.",
Short: "Create SQL schemas and apply migration plans",
Long: `Run this command on a fresh SQL installation and when you upgrade Ory Kratos to a new minor version.
It is recommended to run this command close to the SQL instance (e.g. same subnet) instead of over the public internet.
This decreases risk of failure and decreases time required.
You can read in the database URL using the -e flag, for example:
export DSN=...
kratos migrate sql -e
### WARNING ###
Before running this command on an existing database, create a back up!
`,
RunE: func(cmd *cobra.Command, args []string) error {
return NewMigrateHandler().MigrateSQLUp(cmd, args, opts...)
},
}
configx.RegisterFlags(c.PersistentFlags())
c.PersistentFlags().BoolP("read-from-env", "e", false, "If set, reads the database connection string from the environment variable DSN or config file key dsn.")
c.Flags().BoolP("yes", "y", false, "If set all confirmation requests are accepted without user interaction.")
c.AddCommand(NewMigrateSQLStatusCmd(opts...))
c.AddCommand(NewMigrateSQLUpCmd(opts...))
c.AddCommand(NewMigrateSQLDownCmd(opts...))
return c
}