Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions cmd/cleanup/sql.go
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,9 @@ It is recommended to run this command close to the SQL instance (e.g. same subne
This decreases risk of failure and decreases time required.
You can read in the database URL using the -e flag, for example:
export DSN=...
export NETWORK_ID=...
kratos cleanup sql -e
NETWORK_ID (config key network.id) must name an existing row in table networks.
### WARNING ###
Before running this command on an existing database, create a back up!
`,
Expand Down
10 changes: 10 additions & 0 deletions driver/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,8 @@ const (
DefaultCipherAlgorithm = "noop"
UnknownVersion = "unknown version"
ViperKeyDSN = "dsn"
ViperKeyNetworkID = "network.id"
ViperKeyForbiddenNetworkID = "network.forbidden_id"
ViperKeyCourierSMTPURL = "courier.smtp.connection_uri"
ViperKeyCourierSMTPClientCertPath = "courier.smtp.client_cert_path"
ViperKeyCourierSMTPClientKeyPath = "courier.smtp.client_key_path"
Expand Down Expand Up @@ -658,6 +660,14 @@ func (p *Config) DSN(ctx context.Context) string {
return ""
}

func (p *Config) NetworkID(ctx context.Context) string {
return p.GetProvider(ctx).String(ViperKeyNetworkID)
}

func (p *Config) ForbiddenNetworkID(ctx context.Context) string {
return p.GetProvider(ctx).String(ViperKeyForbiddenNetworkID)
}

func (p *Config) DisableAPIFlowEnforcement(ctx context.Context) bool {
if p.IsInsecureDevMode(ctx) && os.Getenv("DEV_DISABLE_API_FLOW_ENFORCEMENT") == "true" {
p.l.Warn("Because \"DEV_DISABLE_API_FLOW_ENFORCEMENT=true\" and the \"--dev\" flag are set, self-service API flows will no longer check if the interaction is actually a browser flow. This is very dangerous as it allows bypassing of anti-CSRF measures, leaving the deployment highly vulnerable. This option should only be used for automated testing and never come close to real user data anywhere.")
Expand Down
18 changes: 15 additions & 3 deletions driver/factory_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ import (
"github.com/gofrs/uuid"

"github.com/ory/x/configx"
"github.com/ory/x/dbal"
"github.com/ory/x/networkx"

"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
Expand All @@ -23,21 +25,31 @@ import (

func TestDriverNew(t *testing.T) {
ctx := context.Background()
dsn := dbal.NewSQLiteTestDatabase(t)
nid := uuid.Must(uuid.NewV4())

migrator, err := driver.New(ctx, os.Stderr, servicelocatorx.NewOptions(), []driver.RegistryOption{driver.SkipNetworkInit},
[]configx.OptionModifier{configx.WithValue(config.ViperKeyDSN, dsn), configx.SkipValidation()})
require.NoError(t, err)
require.NoError(t, migrator.Persister().MigrateUp(ctx))
require.NoError(t, migrator.Persister().GetConnection(ctx).Create(&networkx.Network{ID: nid}))

r, err := driver.New(
context.Background(),
os.Stderr,
servicelocatorx.NewOptions(),
nil,
[]configx.OptionModifier{
configx.WithValue(config.ViperKeyDSN, config.DefaultSQLiteMemoryDSN),
configx.WithValue(config.ViperKeyDSN, dsn),
configx.WithValue(config.ViperKeyNetworkID, nid.String()),
configx.SkipValidation(),
})
require.NoError(t, err)

assert.EqualValues(t, config.DefaultSQLiteMemoryDSN, r.Config().DSN(ctx))
assert.EqualValues(t, dsn, r.Config().DSN(ctx))
require.NoError(t, r.Persister().Ping())

assert.NotEqual(t, uuid.Nil.String(), r.Persister().NetworkID(context.Background()).String())
assert.Equal(t, nid, r.Persister().NetworkID(context.Background()))

n, err := r.Persister().DetermineNetwork(context.Background())
require.NoError(t, err)
Expand Down
63 changes: 59 additions & 4 deletions driver/registry_default.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import (
"github.com/cenkalti/backoff"
"github.com/dgraph-io/ristretto"
"github.com/gobuffalo/pop/v6"
"github.com/gofrs/uuid"
"github.com/gorilla/sessions"
"github.com/hashicorp/go-retryablehttp"
"github.com/luna-duclos/instrumentedsql"
Expand Down Expand Up @@ -61,6 +62,7 @@ import (
"github.com/ory/x/jsonnetsecure"
"github.com/ory/x/jwksx"
"github.com/ory/x/logrusx"
"github.com/ory/x/networkx"
"github.com/ory/x/otelx"
otelsql "github.com/ory/x/otelx/sql"
"github.com/ory/x/popx"
Expand Down Expand Up @@ -643,6 +645,14 @@ func (m *RegistryDefault) Init(ctx context.Context, ctxer contextx.Contextualize
m.identitySchemaProvider = o.replaceIdentitySchemaProvider(m)
}

var nid uuid.UUID
if !o.skipNetworkInit {
var err error
if nid, err = m.configuredNetworkID(ctx); err != nil {
return err
}
}

bc := backoff.NewExponentialBackOff()
bc.MaxElapsedTime = time.Minute * 5
bc.Reset()
Expand Down Expand Up @@ -684,7 +694,8 @@ func (m *RegistryDefault) Init(ctx context.Context, ctxer contextx.Contextualize
}

// if dsn is memory we have to run the migrations on every start
if dbal.IsMemorySQLite(m.Config().DSN(ctx)) || m.Config().DSN(ctx) == "memory" {
isMemory := dbal.IsMemorySQLite(m.Config().DSN(ctx)) || m.Config().DSN(ctx) == "memory"
if isMemory {
m.Logger().Infoln("Ory Kratos is running migrations on every startup as DSN is memory. This means your data is lost when Kratos terminates.")
if err := p.MigrateUp(ctx); err != nil {
m.Logger().WithError(err).Warnf("Unable to run migrations, retrying.")
Expand All @@ -697,12 +708,22 @@ func (m *RegistryDefault) Init(ctx context.Context, ctxer contextx.Contextualize
return nil
}

net, err := p.DetermineNetwork(ctx)
if err != nil {
m.Logger().WithError(err).Warnf("Unable to determine network, retrying.")
net, err := p.NetworkByID(ctx, nid)
if errors.Is(err, sqlcon.ErrNoRows) && isMemory {
// Only this process can reach a memory database, so nothing else can seed its network.
m.Logger().WithField("network_id", nid.String()).Warn("Inserting the network from config, because the DSN is a memory database. This happens only for a memory DSN.")
net = &networkx.Network{ID: nid}
err = p.GetConnection(ctx).Create(net)
}
if errors.Is(err, sqlcon.ErrNoRows) {
// backoff v2 detects a permanent error only by type assertion, so it must not be wrapped.
return backoff.Permanent(errors.Errorf("config value %q names network %s, which is not in table networks", config.ViperKeyNetworkID, nid))
} else if err != nil {
m.Logger().WithError(err).Warnf("Unable to load network, retrying.")
return err
}

m.Logger().WithField("network_id", net.ID.String()).Info("Using the network from config.")
m.persister = p.WithNetworkID(net.ID)
return nil
}, bc)
Expand All @@ -718,6 +739,40 @@ func (m *RegistryDefault) Init(ctx context.Context, ctxer contextx.Contextualize
return nil
}

func (m *RegistryDefault) configuredNetworkID(ctx context.Context) (uuid.UUID, error) {
nid, err := parseNetworkID(config.ViperKeyNetworkID, m.Config().NetworkID(ctx))
if err != nil {
return uuid.Nil, err
}

raw := m.Config().ForbiddenNetworkID(ctx)
if raw == "" {
return nid, nil
}
forbidden, err := parseNetworkID(config.ViperKeyForbiddenNetworkID, raw)
if err != nil {
return uuid.Nil, err
}
if nid == forbidden {
return uuid.Nil, errors.Errorf("config value %q equals config value %q: this release must not use network %s", config.ViperKeyNetworkID, config.ViperKeyForbiddenNetworkID, nid)
}
return nid, nil
}

func parseNetworkID(key, raw string) (uuid.UUID, error) {
if raw == "" {
return uuid.Nil, errors.Errorf("required config value %q was not set", key)
}
id, err := uuid.FromString(raw)
if err != nil {
return uuid.Nil, errors.Errorf("config value %q is not a UUID: %q", key, raw)
}
if id == uuid.Nil {
return uuid.Nil, errors.Errorf("config value %q must not be the nil UUID", key)
}
return id, nil
}

func (m *RegistryDefault) SetPersister(p persistence.Persister) {
m.persister = p
}
Expand Down
182 changes: 182 additions & 0 deletions driver/registry_default_network_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,182 @@
// Copyright © 2026 Ory Corp
// SPDX-License-Identifier: Apache-2.0

// ABOUTME: Tests that registry Init takes the network ID from config and refuses to start on any doubt.

package driver_test

import (
"context"
"encoding/base64"
"os"
"path/filepath"
"testing"
"time"

"github.com/gobuffalo/pop/v6"
"github.com/gofrs/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/ory/kratos/driver"
"github.com/ory/kratos/driver/config"
"github.com/ory/kratos/identity"
"github.com/ory/x/configx"
"github.com/ory/x/dbal"
"github.com/ory/x/networkx"
"github.com/ory/x/servicelocatorx"
"github.com/ory/x/sqlcon/dockertest"
)

func TestInitNetworkFromConfig(t *testing.T) {
t.Run("config errors refuse before the database retry", testConfigErrorsRefuseBeforeRetry)

t.Run("db=sqlite", func(t *testing.T) {
testInitNetworkFromConfig(t, "sqlite://"+filepath.Join(t.TempDir(), "db.sqlite")+"?_fk=true&mode=rwc")
})

t.Run("db=cockroach", func(t *testing.T) {
if testing.Short() {
t.Skip("skipping testing in short mode")
}
testInitNetworkFromConfig(t, dockertest.RunTestCockroachDBWithVersion(t, "latest-v23.1"))
})

t.Run("db=memory", func(t *testing.T) {
ctx := context.Background()
nid := uuid.Must(uuid.NewV4())

reg, err := newRegistry(ctx, map[string]interface{}{
config.ViperKeyDSN: dbal.NewSQLiteTestDatabase(t),
config.ViperKeyNetworkID: nid.String(),
})
require.NoError(t, err, "a memory database lives only in this process, so nothing else can seed its network")
assert.Equal(t, nid, reg.Persister().NetworkID(ctx))

n, err := reg.Persister().(interface {
NetworkByID(context.Context, uuid.UUID) (*networkx.Network, error)
}).NetworkByID(ctx, nid)
require.NoError(t, err)
assert.Equal(t, nid, n.ID)
})
}

func newRegistry(ctx context.Context, values map[string]interface{}, dOpts ...driver.RegistryOption) (driver.Registry, error) {
return driver.New(ctx, os.Stderr, servicelocatorx.NewOptions(), dOpts,
[]configx.OptionModifier{configx.WithValues(values), configx.SkipValidation()})
}

func countNetworks(t *testing.T, c *pop.Connection) int {
n, err := c.Count(&networkx.Network{})
require.NoError(t, err)
return n
}

func testConfigErrorsRefuseBeforeRetry(t *testing.T) {
ctx := context.Background()
// Nothing listens on port 1, so any database call would retry for minutes.
unreachable := "postgres://kratos:secret@127.0.0.1:1/kratos?sslmode=disable"
valid := uuid.Must(uuid.NewV4()).String()

for _, tc := range []struct {
name string
values map[string]interface{}
error string
}{
{
name: "key missing",
values: map[string]interface{}{},
error: `required config value "network.id" was not set`,
},
{
name: "key not a UUID",
values: map[string]interface{}{config.ViperKeyNetworkID: "not-a-uuid"},
error: `config value "network.id" is not a UUID: "not-a-uuid"`,
},
{
name: "key is the nil UUID",
values: map[string]interface{}{config.ViperKeyNetworkID: uuid.Nil.String()},
error: `config value "network.id" must not be the nil UUID`,
},
{
name: "key equals the forbidden key",
values: map[string]interface{}{config.ViperKeyNetworkID: valid, config.ViperKeyForbiddenNetworkID: valid},
error: `config value "network.id" equals config value "network.forbidden_id": this release must not use network ` + valid,
},
{
name: "forbidden key not a UUID",
values: map[string]interface{}{config.ViperKeyNetworkID: valid, config.ViperKeyForbiddenNetworkID: "classic"},
error: `config value "network.forbidden_id" is not a UUID: "classic"`,
},
{
name: "forbidden key is the nil UUID",
values: map[string]interface{}{config.ViperKeyNetworkID: valid, config.ViperKeyForbiddenNetworkID: uuid.Nil.String()},
error: `config value "network.forbidden_id" must not be the nil UUID`,
},
} {
t.Run("case="+tc.name, func(t *testing.T) {
tc.values[config.ViperKeyDSN] = unreachable

start := time.Now()
_, err := newRegistry(ctx, tc.values)
require.Error(t, err)
assert.Equal(t, tc.error, err.Error())
assert.Less(t, time.Since(start), 10*time.Second, "a config error must not wait for the database retry")
})
}
}

func testInitNetworkFromConfig(t *testing.T, dsn string) {
ctx := context.Background()

migrator, err := newRegistry(ctx, map[string]interface{}{config.ViperKeyDSN: dsn}, driver.SkipNetworkInit)
require.NoError(t, err, "network init that is skipped, as migrate does, needs no network key")
require.NoError(t, migrator.Persister().MigrateUp(ctx))
conn := migrator.Persister().GetConnection(ctx)

older := networkx.Network{ID: uuid.Must(uuid.NewV4()), CreatedAt: time.Now().Add(-time.Hour)}
newer := networkx.Network{ID: uuid.Must(uuid.NewV4()), CreatedAt: time.Now()}
require.NoError(t, conn.Create(&older))
require.NoError(t, conn.Create(&newer))

t.Run("case=key names no row", func(t *testing.T) {
before := countNetworks(t, conn)
unknown := uuid.Must(uuid.NewV4())

start := time.Now()
_, err := newRegistry(ctx, map[string]interface{}{config.ViperKeyDSN: dsn, config.ViperKeyNetworkID: unknown.String()})
require.Error(t, err)
assert.Contains(t, err.Error(), `config value "network.id" names network `+unknown.String()+", which is not in table networks")
assert.Less(t, time.Since(start), 30*time.Second, "a missing row must not wait for the database retry")
assert.Equal(t, before, countNetworks(t, conn), "a refused start must not create a network")
})

for _, tc := range []struct {
name string
values map[string]interface{}
}{
{name: "valid key", values: map[string]interface{}{config.ViperKeyNetworkID: newer.ID.String()}},
{name: "valid key and other forbidden key", values: map[string]interface{}{
config.ViperKeyNetworkID: newer.ID.String(),
config.ViperKeyForbiddenNetworkID: older.ID.String(),
}},
} {
t.Run("case="+tc.name, func(t *testing.T) {
tc.values[config.ViperKeyDSN] = dsn
tc.values[config.ViperKeyIdentitySchemas] = config.Schemas{{
ID: config.DefaultIdentityTraitsSchemaID,
URL: "base64://" + base64.StdEncoding.EncodeToString([]byte(`{"type":"object","properties":{"traits":{"type":"object"}}}`)),
}}

reg, err := newRegistry(ctx, tc.values)
require.NoError(t, err)
assert.Equal(t, newer.ID, reg.Persister().NetworkID(ctx), "config must win over the oldest network row")

i := identity.NewIdentity(config.DefaultIdentityTraitsSchemaID)
require.NoError(t, reg.PrivilegedIdentityPool().CreateIdentity(ctx, i))
var stored identity.Identity
require.NoError(t, conn.Where("id = ?", i.ID).First(&stored))
assert.Equal(t, newer.ID, stored.NID)
})
}
}
20 changes: 20 additions & 0 deletions embedx/config.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -2023,6 +2023,26 @@
"sqlite:///var/lib/sqlite/db.sqlite?_fk=true&mode=rwc"
]
},
"network": {
"type": "object",
"title": "Network",
"description": "Selects the row in table `networks` that scopes all data of this process. Every command except `migrate` refuses to start when `id` is not set or names no row. For a memory DSN only, the process inserts the row itself, because no other process can reach that database.",
"properties": {
"id": {
"type": "string",
"format": "uuid",
"title": "Network ID",
"description": "The ID of an existing row in table `networks`."
},
"forbidden_id": {
"type": "string",
"format": "uuid",
"title": "Forbidden Network ID",
"description": "The process refuses to start when `id` equals this value."
}
},
"additionalProperties": false
},
"courier": {
"type": "object",
"title": "Courier configuration",
Expand Down
Loading
Loading