snapshot of https://github.com/ory/keto.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
CI / Run lints and checks (push) Failing after 47s
CI / Run tests (push) Failing after 1s
CI / Run race tests (push) Failing after 5s
CI / Build CLI docs (push) Has been skipped
CI / Run buf actions (push) Failing after 5s
CodeQL / Analyze (go) (push) Failing after 3s
CodeQL / Analyze (javascript) (push) Failing after 2s
Docker Image Scanners / scanners (push) Failing after 3s
Format / format (push) Failing after 3s
Synchronize Issue Labels / Synchronize Issue Labels (push) Has been skipped
CI / Generate release (push) Has been skipped
CI / Draft newsletter (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-16 01:08:34 +05:00
commit 9456a4e922
2560 changed files with 120911 additions and 0 deletions

View File

@@ -0,0 +1,10 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
var (
Version = "master"
Date = "undefined"
Commit = "undefined"
)

View File

@@ -0,0 +1,87 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"reflect"
"sync"
"github.com/ory/herodot"
"github.com/pkg/errors"
"github.com/ory/keto/internal/namespace"
)
type (
memoryNamespaceManager struct {
byName map[string]*namespace.Namespace
sync.RWMutex
}
)
var _ namespace.Manager = &memoryNamespaceManager{}
func NewMemoryNamespaceManager(nn ...*namespace.Namespace) *memoryNamespaceManager {
s := &memoryNamespaceManager{}
s.set(nn)
return s
}
func (s *memoryNamespaceManager) GetNamespaceByName(_ context.Context, name string) (*namespace.Namespace, error) {
s.RLock()
defer s.RUnlock()
if n, ok := s.byName[name]; ok {
return n, nil
}
return nil, errors.WithStack(herodot.ErrNotFound().WithReasonf("Unknown namespace with name %q.", name))
}
func (s *memoryNamespaceManager) GetNamespaceByConfigID(_ context.Context, id int32) (*namespace.Namespace, error) {
s.RLock()
defer s.RUnlock()
for _, n := range s.byName {
//lint:ignore SA1019 backwards compatibility
//nolint:staticcheck
if n.ID == id {
return n, nil
}
}
return nil, errors.WithStack(herodot.ErrNotFound().WithReasonf("Unknown namespace with id %d.", id))
}
func (s *memoryNamespaceManager) Namespaces(_ context.Context) ([]*namespace.Namespace, error) {
s.RLock()
defer s.RUnlock()
nn := make([]*namespace.Namespace, 0, len(s.byName))
for _, n := range s.byName {
nn = append(nn, n)
}
return nn, nil
}
func (s *memoryNamespaceManager) ShouldReload(newValue interface{}) bool {
s.RLock()
defer s.RUnlock()
nn, _ := s.Namespaces(context.Background())
return !reflect.DeepEqual(newValue, nn)
}
func (s *memoryNamespaceManager) set(nn []*namespace.Namespace) {
s.Lock()
defer s.Unlock()
s.byName = make(map[string]*namespace.Namespace, len(nn))
for _, n := range nn {
s.byName[n.Name] = n
}
}

View File

@@ -0,0 +1,25 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/ory/keto/internal/namespace"
)
func TestNamespaceMemoryManager(t *testing.T) {
t.Run("method=should reload", func(t *testing.T) {
n := &namespace.Namespace{Name: "2"}
nm := NewMemoryNamespaceManager(n)
assert.False(t, nm.ShouldReload([]*namespace.Namespace{n}))
assert.False(t, nm.ShouldReload([]*namespace.Namespace{{Name: "2"}}))
assert.True(t, nm.ShouldReload([]*namespace.Namespace{{Name: "3"}}))
assert.True(t, nm.ShouldReload("foo"))
})
}

View File

@@ -0,0 +1,295 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sync"
"github.com/ghodss/yaml"
"github.com/ory/herodot"
"github.com/ory/x/logrusx"
"github.com/ory/x/stringsx"
"github.com/ory/x/urlx"
"github.com/ory/x/watcherx"
"github.com/pelletier/go-toml"
"github.com/pkg/errors"
"github.com/ory/keto/internal/namespace"
)
type (
Parser func([]byte, interface{}) error
NamespaceFile struct {
Name string
Contents []byte
Parser Parser
namespace *namespace.Namespace // last successfully parsed namespace. May be nil if there was a parse error
}
NamespaceWatcher struct {
sync.RWMutex
namespaces map[string]*NamespaceFile
logger *logrusx.Logger
target string
}
eventHandler interface {
handleRemove(*watcherx.RemoveEvent)
handleChange(*watcherx.ChangeEvent)
handleError(*watcherx.ErrorEvent)
}
)
var _ namespace.Manager = (*NamespaceWatcher)(nil)
func NewNamespaceWatcher(ctx context.Context, l *logrusx.Logger, target string) (*NamespaceWatcher, error) {
nw := NamespaceWatcher{
logger: l,
target: target,
namespaces: make(map[string]*NamespaceFile),
}
return &nw, watchTarget(ctx, target, &nw, l)
}
func (nw *NamespaceWatcher) handleRemove(e *watcherx.RemoveEvent) {
nw.Lock()
defer nw.Unlock()
delete(nw.namespaces, e.Source())
}
func (nw *NamespaceWatcher) handleChange(e *watcherx.ChangeEvent) {
// the lock is acquired before parsing to ensure that the getters are
// waiting for the updated values
nw.Lock()
defer nw.Unlock()
n := nw.readNamespaceFile(e.Reader(), e.Source())
if n == nil {
return
} else if n.namespace == nil {
// parse failed, rolling back to previous working version
if existing, ok := nw.namespaces[e.Source()]; ok {
existing.Contents = n.Contents
} else {
nw.namespaces[e.Source()] = n
}
} else {
nw.namespaces[e.Source()] = n
}
}
func (nw *NamespaceWatcher) handleError(e *watcherx.ErrorEvent) {
nw.logger.
WithError(e).
Errorf("Received error while watching namespace files at target %s.", nw.target)
}
func watchTarget(ctx context.Context, target string, handler eventHandler, log *logrusx.Logger) error {
var (
eventCh = make(watcherx.EventChannel)
watcher watcherx.Watcher
)
targetUrl, err := urlx.Parse(target)
if err != nil {
return errors.WithStack(err)
}
info, err := os.Stat(targetUrl.Path) //nolint:gosec // Path is from a validated configuration URL, not user input.
if err != nil {
return errors.WithStack(err)
}
if info.IsDir() {
watcher, err = watcherx.WatchDirectory(ctx, urlx.GetURLFilePath(targetUrl), eventCh)
} else {
watcher, err = watcherx.Watch(ctx, targetUrl, eventCh)
}
// this handles the watcher init error
if err != nil {
return err
}
// trigger initial load
done, err := watcher.DispatchNow()
if err != nil {
return errors.WithStack(err)
}
initialEventsProcessed := make(chan struct{})
go startEventHandler(ctx, eventCh, handler, done, initialEventsProcessed, log)
// wait for initial load to be done
<-initialEventsProcessed
return nil
}
func startEventHandler(ctx context.Context,
eventCh watcherx.EventChannel,
handler eventHandler,
done <-chan int,
initialEventsProcessed chan<- struct{},
log *logrusx.Logger) {
initialDone := false
for {
select {
// because we use an unbuffered chan we can be sure that at least all
// initial events are handled
case <-done:
initialDone = true
close(initialEventsProcessed)
// Disable this case after it fires once. watcherx closes done when
// the watcher shuts down (e.g. directoryWatcher on ctx cancel); a
// closed channel is always ready, so leaving done set would re-enter
// this branch and close(initialEventsProcessed) a second time (panic).
done = nil
case <-ctx.Done():
return
case e, open := <-eventCh:
if !open {
return
}
if initialDone {
log.
WithField("file", e.Source()).
WithField("event_type", fmt.Sprintf("%T", e)).
Info("A change to a namespace file was detected.")
}
switch e := e.(type) {
case *watcherx.RemoveEvent:
handler.handleRemove(e)
case *watcherx.ChangeEvent:
handler.handleChange(e)
case *watcherx.ErrorEvent:
handler.handleError(e)
default:
log.Warnf("Ignored unknown event %T", e)
}
}
}
}
func (nw *NamespaceWatcher) readNamespaceFile(r io.Reader, source string) *NamespaceFile {
parse, err := GetParser(source)
if err != nil {
nw.logger.
WithError(err).
WithField("file_name", source).
Warn("could not infer format from file extension")
return nil
}
raw, err := io.ReadAll(r)
if err != nil {
nw.logger.
WithError(errors.WithStack(err)).
WithField("file_name", source).
Error("could not read namespace file")
return nil
}
n := namespace.Namespace{}
if err := parse(raw, &n); err != nil {
nw.logger.
WithError(errors.WithStack(err)).
WithField("file_name", source).
Error("could not parse namespace file")
return &NamespaceFile{Name: source, Contents: raw, Parser: parse}
}
return &NamespaceFile{Name: source, Contents: raw, Parser: parse, namespace: &n}
}
func (nw *NamespaceWatcher) GetNamespaceByName(_ context.Context, name string) (*namespace.Namespace, error) {
nw.RLock()
defer nw.RUnlock()
for _, nsf := range nw.namespaces {
if nsf.namespace != nil && nsf.namespace.Name == name {
return nsf.namespace, nil
}
}
return nil, errors.WithStack(herodot.ErrNotFound().WithErrorf(
"Unknown namespace with name %s", name))
}
func (nw *NamespaceWatcher) GetNamespaceByConfigID(_ context.Context, id int32) (*namespace.Namespace, error) {
nw.RLock()
defer nw.RUnlock()
for _, nspace := range nw.namespaces {
//lint:ignore SA1019 backwards compatibility
//nolint:staticcheck
if nspace.namespace.ID == id {
return nspace.namespace, nil
}
}
return nil, errors.WithStack(herodot.ErrNotFound().WithErrorf(
"Unknown namespace with ID %d", id))
}
func (nw *NamespaceWatcher) Namespaces(_ context.Context) ([]*namespace.Namespace, error) {
nw.RLock()
defer nw.RUnlock()
nspaces := make([]*namespace.Namespace, 0, len(nw.namespaces))
for _, nsf := range nw.namespaces {
if nsf.namespace != nil {
nspaces = append(nspaces, nsf.namespace)
}
}
return nspaces, nil
}
func (nw *NamespaceWatcher) NamespaceFiles() []*NamespaceFile {
nw.RLock()
defer nw.RUnlock()
nsfs := make([]*NamespaceFile, 0, len(nw.namespaces))
for _, nsf := range nw.namespaces {
nsfs = append(nsfs, nsf)
}
return nsfs
}
func (nw *NamespaceWatcher) ShouldReload(newValue interface{}) bool {
v, ok := newValue.(string)
if !ok {
// the manager type changed
return true
}
// reload if target changed
return v != nw.target
}
func GetParser(fn string) (Parser, error) {
switch ext := stringsx.SwitchExact(filepath.Ext(fn)); {
case ext.AddCase(".yaml"), ext.AddCase(".yml"):
return func(b []byte, i interface{}) error {
return yaml.Unmarshal(b, i)
}, nil
case ext.AddCase(".json"):
return json.Unmarshal, nil
case ext.AddCase(".toml"):
return toml.Unmarshal, nil
default:
return nil, ext.ToUnknownCaseErr()
}
}

View File

@@ -0,0 +1,250 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"time"
"github.com/ghodss/yaml"
"github.com/ory/x/logrusx"
"github.com/ory/x/watcherx"
"github.com/pelletier/go-toml"
"github.com/sirupsen/logrus"
"github.com/sirupsen/logrus/hooks/test"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/internal/namespace"
)
func TestNamespaceProvider(t *testing.T) {
setup := func(t *testing.T, target string) (*NamespaceWatcher, *test.Hook) {
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
hook := &test.Hook{}
l := logrusx.New("", "", logrusx.WithHook(hook))
nw, err := NewNamespaceWatcher(ctx, l, target)
require.NoError(t, err)
return nw, hook
}
writeNamespace := func(t *testing.T, n *namespace.Namespace, fn string) {
var marshal func(interface{}) ([]byte, error)
switch ext := filepath.Ext(fn); ext {
case ".yaml", ".yml":
marshal = yaml.Marshal
case ".toml":
marshal = toml.Marshal
case ".json":
marshal = json.Marshal
default:
t.Logf("got unexpected file extension %s", ext)
t.FailNow()
}
nEnc, err := marshal(n)
require.NoError(t, err)
require.NoError(t, os.WriteFile(fn, nEnc, 0600))
}
writeDir := func(t *testing.T, dir string, files map[string]interface{}) {
for fn, fc := range files {
fp := filepath.Join(dir, fn)
require.NoError(t, os.MkdirAll(filepath.Dir(fp), 0700))
if n, ok := fc.(*namespace.Namespace); ok {
writeNamespace(t, n, fp)
continue
}
s, ok := fc.(string)
if !ok {
t.Logf("expected file content to be string or *namespace.Namespace but got %T", fc)
t.FailNow()
}
require.NoError(t, os.WriteFile(fp, []byte(s), 0600))
}
}
writeJsonNamespace := func(t *testing.T) (string, *namespace.Namespace) {
dir := t.TempDir()
n := &namespace.Namespace{
Name: "test namespace 1",
}
fn := filepath.Join(dir, "n.json")
writeNamespace(t, n, fn)
return fn, n
}
t.Run("case=loads JSON namespace file", func(t *testing.T) {
fn, n := writeJsonNamespace(t)
ws, _ := setup(t, "file://"+fn)
nspaces, err := ws.Namespaces(context.Background())
require.NoError(t, err)
assert.Equal(t, []*namespace.Namespace{n}, nspaces)
})
t.Run("case=reads namespace files from directory", func(t *testing.T) {
dir := t.TempDir()
files := map[string]interface{}{
"b.yml": &namespace.Namespace{
Name: "b",
},
"a.toml": &namespace.Namespace{
Name: "a",
},
"c.json": &namespace.Namespace{
Name: "c",
},
}
writeDir(t, dir, files)
nw, _ := setup(t, "file://"+dir)
nspaces, err := nw.Namespaces(context.Background())
require.NoError(t, err)
for _, n := range files {
assert.Contains(t, nspaces, n.(*namespace.Namespace))
}
nsfs := nw.NamespaceFiles()
assert.Equal(t, len(nspaces), len(nsfs))
for _, n := range nsfs {
assert.NotNil(t, n.Contents)
assert.NotNil(t, n.Parser)
}
})
t.Run("case=ignores but warns about unsupported file extensions", func(t *testing.T) {
dir := t.TempDir()
n := &namespace.Namespace{
Name: "some name",
}
nJson, err := json.Marshal(n)
require.NoError(t, err)
writeDir(t, dir, map[string]interface{}{
"unsupported.file": "foo bar\n",
"supported.json": string(nJson),
})
nw, hook := setup(t, "file://"+dir)
require.Len(t, hook.Entries, 1)
assert.Equal(t, logrus.WarnLevel, hook.Entries[0].Level, "%+v", hook.Entries[0])
assert.True(t, strings.HasSuffix(hook.Entries[0].Data["file_name"].(string), "unsupported.file"))
namespaces, err := nw.Namespaces(context.Background())
require.NoError(t, err)
require.Len(t, namespaces, 1)
assert.Equal(t, n, namespaces[0])
})
t.Run("case=still returns successful namespace if one errors", func(t *testing.T) {
dir := t.TempDir()
n := &namespace.Namespace{
Name: "some name",
}
nJson, err := json.Marshal(n)
require.NoError(t, err)
writeDir(t, dir, map[string]interface{}{
"malformed.yml": "foo bar\n",
"correct.json": string(nJson),
})
nw, hook := setup(t, "file://"+dir)
require.Len(t, hook.Entries, 1)
assert.Equal(t, logrus.ErrorLevel, hook.Entries[0].Level, "%+v", hook.Entries[0])
assert.True(t, strings.HasSuffix(hook.Entries[0].Data["file_name"].(string), "malformed.yml"))
namespaces, err := nw.Namespaces(context.Background())
require.NoError(t, err)
require.Len(t, namespaces, 1)
assert.Equal(t, n, namespaces[0])
// files are included even if ns is unparsable
nsfs := nw.NamespaceFiles()
assert.Equal(t, 2, len(nsfs))
})
t.Run("method=should reload", func(t *testing.T) {
nw := &NamespaceWatcher{
target: "foo",
}
assert.False(t, nw.ShouldReload("foo"))
assert.True(t, nw.ShouldReload("bar"))
assert.True(t, nw.ShouldReload([]*namespace.Namespace{}))
})
}
type noopEventHandler struct{}
func (noopEventHandler) handleRemove(*watcherx.RemoveEvent) {}
func (noopEventHandler) handleChange(*watcherx.ChangeEvent) {}
func (noopEventHandler) handleError(*watcherx.ErrorEvent) {}
func TestStartEventHandlerDoesNotCloseInitialDoneTwice(t *testing.T) {
ctx, cancel := context.WithCancel(t.Context())
t.Cleanup(cancel)
eventCh := make(watcherx.EventChannel)
done := make(chan int, 1)
initialEventsProcessed := make(chan struct{})
handlerReturned := make(chan any, 1)
go func() {
defer func() {
handlerReturned <- recover()
}()
startEventHandler(ctx, eventCh, noopEventHandler{}, done, initialEventsProcessed, logrusx.New("", ""))
}()
done <- 0
select {
case <-initialEventsProcessed:
case <-time.After(time.Second):
t.Fatal("timed out waiting for initial events to be processed")
}
close(done)
select {
case recovered := <-handlerReturned:
require.Nil(t, recovered)
t.Fatal("event handler returned before context cancellation")
case <-time.After(100 * time.Millisecond):
}
cancel()
select {
case recovered := <-handlerReturned:
require.Nil(t, recovered)
case <-time.After(time.Second):
t.Fatal("timed out waiting for event handler to stop")
}
}

View File

@@ -0,0 +1,193 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"io"
"net/http"
"net/http/httptest"
"strings"
"sync/atomic"
"testing"
"testing/synctest"
"github.com/hashicorp/go-retryablehttp"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"github.com/ory/herodot"
"github.com/ory/x/configx"
"github.com/ory/x/fetcher"
"github.com/ory/x/logrusx"
)
func TestNewOPLConfigWatcher(t *testing.T) {
var hits atomic.Int64
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
if _, err := io.WriteString(w, testOPL); err != nil {
w.WriteHeader(http.StatusInternalServerError)
}
}))
t.Cleanup(ts.Close)
ctx := context.Background()
cfg, err := NewDefault(ctx, nil, logrusx.New("", ""), configx.SkipValidation())
require.NoError(t, err)
cw, err := newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.NoError(t, err)
require.EqualValues(t, 1, hits.Load(), "HTTP request made")
_, err = cw.GetNamespaceByName(ctx, "User")
require.NoError(t, err)
_, err = cw.GetNamespaceByName(ctx, "Document")
require.NoError(t, err)
cache.Wait()
cw, err = newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.NoError(t, err)
require.EqualValues(t, 1, hits.Load(), "content was cached")
_, err = cw.GetNamespaceByName(ctx, "User")
require.NoError(t, err)
_, err = cw.GetNamespaceByName(ctx, "Document")
require.NoError(t, err)
}
func TestOPLConfigWatcher(t *testing.T) {
t.Parallel()
t.Run("caches parsed namespaces", func(t *testing.T) {
t.Parallel()
ts, hits := newCountingServer(t, http.StatusOK, testOPL)
cfg := newTestConfig(t)
ctx := t.Context()
first, err := newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.NoError(t, err)
cache.Wait()
second, err := newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.NoError(t, err)
require.EqualValues(t, 1, hits.Load(), "content was fetched only once")
firstUser, err := first.GetNamespaceByName(ctx, "User")
require.NoError(t, err)
secondUser, err := second.GetNamespaceByName(ctx, "User")
require.NoError(t, err)
require.Same(t, firstUser, secondUser, "the parsed namespaces are shared, not re-parsed per construction")
})
t.Run("deduplicates concurrent fetches", func(t *testing.T) {
const parallel = 5
const target = "https://opl.invalid/namespaces.ts"
// The cache is process-global and outlives the test binary's runs.
cache.Del(target)
// The config warms package-level caches whose goroutines live outside
// the bubble, so build it here.
cfg := newTestConfig(t)
synctest.Test(t, func(t *testing.T) {
var hits atomic.Int64
release := make(chan struct{})
hc := retryablehttp.NewClient()
hc.Logger = nil
hc.HTTPClient = &http.Client{Transport: roundTripFunc(func(r *http.Request) (*http.Response, error) {
hits.Add(1)
<-release
return &http.Response{
StatusCode: http.StatusOK,
Body: io.NopCloser(strings.NewReader(testOPL)),
Header: make(http.Header),
Request: r,
}, nil
})}
newFetcher := func() *fetcher.Fetcher { return fetcher.NewFetcher(fetcher.WithClient(hc)) }
ctx := t.Context()
watchers := make([]*oplConfigWatcher, parallel)
var eg errgroup.Group
for i := range parallel {
eg.Go(func() error {
cw, err := newOPLConfigWatcher(ctx, cfg.l, newFetcher, target)
watchers[i] = cw
return err
})
}
synctest.Wait() // One caller is inside the fetch, the rest joined its flight.
close(release)
require.NoError(t, eg.Wait())
require.EqualValues(t, 1, hits.Load(), "concurrent constructions share a single fetch")
for _, cw := range watchers {
_, err := cw.GetNamespaceByName(ctx, "User")
require.NoError(t, err)
}
})
})
t.Run("retries failed fetches", func(t *testing.T) {
t.Parallel()
ts, hits := newCountingServer(t, http.StatusNotFound, "")
cfg := newTestConfig(t)
ctx := t.Context()
_, err := newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.ErrorContains(t, err, "404")
cache.Wait()
_, err = newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.ErrorContains(t, err, "404")
require.EqualValues(t, 2, hits.Load(), "failed fetches are not cached")
})
t.Run("caches parse failures", func(t *testing.T) {
t.Parallel()
ts, hits := newCountingServer(t, http.StatusOK, "/* unclosed comment")
cfg := newTestConfig(t)
ctx := t.Context()
first, err := newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.NoError(t, err, "a parse failure does not fail construction")
_, err = first.GetNamespaceByName(ctx, "User")
require.ErrorIs(t, err, herodot.ErrNotFound())
cache.Wait()
second, err := newOPLConfigWatcher(ctx, cfg.l, cfg.Fetcher, ts.URL)
require.NoError(t, err)
require.EqualValues(t, 1, hits.Load(), "a parse failure is cached and does not trigger a fetch per construction")
_, err = second.GetNamespaceByName(ctx, "User")
require.ErrorIs(t, err, herodot.ErrNotFound())
})
}
func newTestConfig(t *testing.T) *Config {
t.Helper()
cfg, err := NewDefault(t.Context(), nil, logrusx.New("", ""), configx.SkipValidation())
require.NoError(t, err)
return cfg
}
func newCountingServer(t *testing.T, status int, body string) (*httptest.Server, *atomic.Int64) {
t.Helper()
hits := new(atomic.Int64)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
hits.Add(1)
w.WriteHeader(status)
_, _ = io.WriteString(w, body)
}))
t.Cleanup(ts.Close)
return ts, hits
}
var testOPL = `
import { Namespace } from "@ory/keto-namespace-types"
class User implements Namespace {}
class Document implements Namespace {}
`
type roundTripFunc func(*http.Request) (*http.Response, error)
func (f roundTripFunc) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) }

View File

@@ -0,0 +1,181 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"bytes"
"context"
"fmt"
"io"
"sync"
"time"
"github.com/dgraph-io/ristretto/v2"
"github.com/ory/x/fetcher"
"github.com/ory/x/logrusx"
"github.com/ory/x/urlx"
"github.com/ory/x/watcherx"
"github.com/pkg/errors"
"golang.org/x/sync/singleflight"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/schema"
)
type (
configFiles struct {
byPath map[string]io.Reader
sync.Mutex
}
oplConfigWatcher struct {
logger *logrusx.Logger
target string
files configFiles
memoryNamespaceManager
}
)
var (
_ namespace.Manager = (*oplConfigWatcher)(nil)
// cache holds the parsed namespaces per remote OPL location. Content at a location is
// assumed to be immutable, therefore, a change to the OPL should create a new file location,
// therefore a new cache entry.
cache, _ = ristretto.NewCache(&ristretto.Config[string, []*namespace.Namespace]{
MaxCost: 20_000_000, // 20 MB of estimated heap, each item ca. 10 KB => max 2000 items
NumCounters: 20_000, // max 2000 items => 20000 counters
BufferItems: 64,
Cost: namespace.EstimatedSize,
})
fetchGroup singleflight.Group
)
func newOPLConfigWatcher(ctx context.Context, l *logrusx.Logger, newFetcher func() *fetcher.Fetcher, target string) (*oplConfigWatcher, error) {
nw := &oplConfigWatcher{
logger: l,
target: target,
files: configFiles{byPath: make(map[string]io.Reader)},
memoryNamespaceManager: *NewMemoryNamespaceManager(),
}
targetUrl, err := urlx.Parse(target)
if err != nil {
return nil, errors.WithStack(err)
}
switch targetUrl.Scheme {
case "file", "":
return nw, watchTarget(ctx, target, nw, l)
case "base64":
file, err := newFetcher().FetchContext(ctx, target)
if err != nil {
return nil, err
}
nw.files.Lock()
defer nw.files.Unlock()
nw.files.byPath[targetUrl.String()] = file
nw.parseFiles()
return nw, err
case "http", "https":
if namespaces, ok := cache.Get(target); ok {
nw.set(namespaces)
return nw, nil
}
v, err, _ := fetchGroup.Do(target, func() (any, error) {
// Detach from the leader's context so that its cancellation does not
// fail the concurrent callers sharing this fetch.
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), 15*time.Second)
defer cancel()
buf, err := newFetcher().FetchContext(ctx, target)
if err != nil {
return nil, err
}
b := buf.Bytes()
nw.files.Lock()
defer nw.files.Unlock()
nw.files.byPath[targetUrl.String()] = bytes.NewReader(b)
namespaces, ok := nw.parseFiles()
if !ok {
// Cache a parse failure as an empty result, so that a broken
// OPL file does not trigger a fetch per construction.
namespaces = []*namespace.Namespace{}
}
// Cost 0 lets the cache's Cost function compute the entry size.
cache.SetWithTTL(target, namespaces, 0, 30*time.Minute)
return namespaces, nil
})
if err != nil {
return nil, err
}
if namespaces, ok := v.([]*namespace.Namespace); ok {
nw.set(namespaces)
}
return nw, nil
default:
return nil, fmt.Errorf("unexpected url scheme: %q", targetUrl.Scheme)
}
}
func (nw *oplConfigWatcher) handleChange(e *watcherx.ChangeEvent) {
// the lock is acquired before parsing to ensure that the getters are
// waiting for the updated values
nw.files.Lock()
defer nw.files.Unlock()
nw.files.byPath[e.Source()] = e.Reader()
nw.parseFiles()
}
func (nw *oplConfigWatcher) handleRemove(e *watcherx.RemoveEvent) {
nw.files.Lock()
defer nw.files.Unlock()
delete(nw.files.byPath, e.Source())
nw.parseFiles()
}
func (nw *oplConfigWatcher) handleError(e *watcherx.ErrorEvent) {
nw.logger.
WithError(e).
Errorf("Received error while watching OPL config files at target %s.",
nw.target)
}
// parseFiles loops through all files, parsing each and getting the namespaces.
// It then sets and returns the namespaces only if there were no errors.
//
// The caller must hold the lock to nw.files.
func (nw *oplConfigWatcher) parseFiles() ([]*namespace.Namespace, bool) {
var (
namespaces = make([]*namespace.Namespace, 0)
errs []error
)
for _, reader := range nw.files.byPath {
content, err := io.ReadAll(reader)
if err != nil {
errs = append(errs, err)
continue
}
nn, ee := schema.Parse(string(content))
for _, e := range ee {
errs = append(errs, e)
}
for _, n := range nn {
namespaces = append(namespaces, &n)
}
}
if len(errs) > 0 {
for _, err := range errs {
nw.logger.
WithError(err).
Errorf("Failed to parse OPL config files at target %s.",
nw.target)
}
return nil, false
}
nw.set(namespaces)
return namespaces, true
}

View File

@@ -0,0 +1,397 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"crypto/sha512"
_ "embed"
"encoding/json"
"fmt"
"sync"
"github.com/ory/herodot"
_ "github.com/ory/jsonschema/v3/httploader"
"github.com/ory/x/configx"
"github.com/ory/x/fetcher"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/watcherx"
"github.com/pkg/errors"
"github.com/rs/cors"
"github.com/spf13/pflag"
"github.com/ory/keto/embedx"
"github.com/ory/keto/internal/namespace"
)
type EndpointType string
const (
EndpointRead EndpointType = "read"
EndpointWrite EndpointType = "write"
EndpointMetrics EndpointType = "metrics"
EndpointOPLSyntax EndpointType = "opl"
KeyDSN = "dsn"
KeyLimitMaxReadDepth = "limit.max_read_depth"
KeyLimitMaxReadWidth = "limit.max_read_width"
KeyLimitExpandMaxSize = "limit.max_expand_size"
KeyBatchCheckMaxBatchSize = "limit.max_batch_check_size"
KeyBatchCheckParallelizationLimit = "limit.batch_check_max_parallelization"
KeyReadAPIHost = "serve." + string(EndpointRead) + ".host"
KeyReadAPIPort = "serve." + string(EndpointRead) + ".port"
KeyReadAPIListenFile = "serve." + string(EndpointRead) + ".write_listen_file"
KeyWriteAPIHost = "serve." + string(EndpointWrite) + ".host"
KeyWriteAPIPort = "serve." + string(EndpointWrite) + ".port"
KeyWriteAPIListenFile = "serve." + string(EndpointWrite) + ".write_listen_file"
KeyOPLSyntaxAPIHost = "serve." + string(EndpointOPLSyntax) + ".host"
KeyOPLSyntaxAPIPort = "serve." + string(EndpointOPLSyntax) + ".port"
KeyOPLSyntaxListenFile = "serve." + string(EndpointOPLSyntax) + ".write_listen_file"
KeyMetricsHost = "serve." + string(EndpointMetrics) + ".host"
KeyMetricsPort = "serve." + string(EndpointMetrics) + ".port"
KeyMetricsListenFile = "serve." + string(EndpointMetrics) + ".write_listen_file"
KeyNamespaces = "namespaces"
KeyNamespacesExperimentalStrictMode = KeyNamespaces + ".experimental_strict_mode"
KeyFeatureFlagExpandRewrites = "feature_flags.expand_rewrites"
KeyFeatureFlagStrictMode = "feature_flags.strict_mode"
DSNMemory = "sqlite://file::memory:?_fk=true&"
KeySecretsPagination = "secrets.pagination"
)
type (
Config struct {
p *configx.Provider
l *logrusx.Logger
ctx context.Context
nm namespace.Manager
cancelNamespaceManager context.CancelFunc
nmLock sync.Mutex
}
Provider interface {
Config(ctx context.Context) *Config
}
)
func New(ctx context.Context, l *logrusx.Logger, p *configx.Provider) *Config {
return &Config{
p: p,
l: l,
ctx: ctx,
}
}
func NewDefault(ctx context.Context, flags *pflag.FlagSet, l *logrusx.Logger, opts ...configx.OptionModifier) (*Config, error) {
c := New(ctx, l, nil)
cp, err := NewProvider(ctx, flags, c, opts...)
if err != nil {
return nil, err
}
c.WithSource(cp)
return c, nil
}
func NewProvider(ctx context.Context, flags *pflag.FlagSet, config *Config, opts ...configx.OptionModifier) (*configx.Provider, error) {
p, err := configx.New(
ctx,
embedx.ConfigSchema,
append(opts,
configx.WithFlags(flags),
configx.WithStderrValidationReporter(),
configx.WithImmutables(KeyDSN, "serve"),
configx.OmitKeysFromTracing(KeyDSN),
configx.WithLogrusWatcher(config.l),
configx.WithContext(ctx),
configx.AttachWatcher(config.namespaceWatcher),
)...,
)
if err != nil {
return nil, err
}
return p, nil
}
func (k *Config) Source() *configx.Provider {
return k.p
}
func (k *Config) WithSource(p *configx.Provider) {
k.p = p
k.l.UseConfig(p)
}
func (k *Config) namespaceWatcher(_ watcherx.Event, err error) {
if err != nil {
return
}
nm, err := k.NamespaceManager()
if err != nil {
k.l.WithError(err).Error("got internal error in config namespace watcher: could not get namespace manager")
return
}
nnCfg, err := k.namespaceConfig()
if err != nil {
k.l.WithError(err).Error("could not get namespaces from config")
return
}
if nm.ShouldReload(nnCfg.value()) {
k.resetNamespaceManager()
}
}
func (k *Config) resetNamespaceManager() {
k.nmLock.Lock()
defer k.nmLock.Unlock()
if k.cancelNamespaceManager == nil {
return
}
// cancel and delete old namespace manager
// the next read request will result in a new one being created
k.cancelNamespaceManager()
k.nm, k.cancelNamespaceManager = nil, nil
}
func (k *Config) Set(key string, v any) error {
if err := k.p.Set(key, v); err != nil {
return err
}
if key == KeyNamespaces {
k.resetNamespaceManager()
}
return nil
}
func (k *Config) addressFor(endpoint EndpointType) (addr string, listenFile string) {
return fmt.Sprintf(
"%s:%d",
k.p.StringF("serve."+string(endpoint)+".host", ""),
k.p.IntF("serve."+string(endpoint)+".port", 0),
), k.p.StringF("serve."+string(endpoint)+".write_listen_file", "")
}
func (k *Config) ReadAPIListenOn() (addr string, listenFile string) {
return k.addressFor(EndpointRead)
}
func (k *Config) WriteAPIListenOn() (addr string, listenFile string) {
return k.addressFor(EndpointWrite)
}
func (k *Config) MetricsListenOn() (addr string, listenFile string) {
return k.addressFor(EndpointMetrics)
}
func (k *Config) OPLSyntaxAPIListenOn() (addr string, listenFile string) {
return k.addressFor(EndpointOPLSyntax)
}
func (k *Config) MaxReadDepth() int {
return k.p.Int(KeyLimitMaxReadDepth)
}
func (k *Config) MaxReadWidth() int {
return k.p.Int(KeyLimitMaxReadWidth)
}
func (k *Config) BatchCheckMaxBatchSize() int {
return k.p.Int(KeyBatchCheckMaxBatchSize)
}
func (k *Config) BatchCheckParallelizationLimit() int {
return k.p.Int(KeyBatchCheckParallelizationLimit)
}
func (k *Config) ExpandMaxTupleCount() int {
return k.p.Int(KeyLimitExpandMaxSize)
}
func (k *Config) CORS(iface string) (cors.Options, bool) {
switch iface {
case "read", "write", "metrics":
default:
panic("expected interface 'read' or 'write', but got unknown interface " + iface)
}
return k.p.CORS("serve."+iface, cors.Options{
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE"},
AllowedHeaders: []string{"Authorization", "Content-Type"},
ExposedHeaders: []string{"Content-Type"},
AllowCredentials: true,
})
}
func (k *Config) DSN() string {
dsn := k.p.String(KeyDSN)
if dsn == "memory" {
return DSNMemory
}
return dsn
}
func (k *Config) Fetcher() *fetcher.Fetcher {
// Tracing still works correctly even though we pass a no-op tracer
// here, because the otelhttp package will preferentially use the
// tracer from the incoming request context over this one.
opts := []httpx.ResilientOptions{}
if k.p.Bool("clients.http.disallow_private_ip_ranges") {
opts = append(opts, httpx.ResilientClientDisallowInternalIPs())
}
return fetcher.NewFetcher(
fetcher.WithClient(httpx.NewResilientClient(opts...)),
)
}
func (k *Config) TracingServiceName() string {
return k.p.StringF("tracing.service_name", "Ory Keto")
}
func (k *Config) TracingProvider() string {
return k.p.StringF("tracing.provider", "")
}
func (k *Config) TracingConfig() *otelx.Config {
return k.p.TracingConfig("Ory Keto")
}
func (k *Config) NamespaceManager() (namespace.Manager, error) {
k.nmLock.Lock()
defer k.nmLock.Unlock()
if k.nm == nil {
var ctx context.Context
ctx, k.cancelNamespaceManager = context.WithCancel(k.ctx)
nnCfg, err := k.namespaceConfig()
if err != nil {
return nil, err
}
k.nm, err = nnCfg.newManager()(ctx, k)
if err != nil {
return nil, err
}
}
return k.nm, nil
}
func (k *Config) StrictMode() bool {
return k.p.Bool(KeyFeatureFlagStrictMode) || k.p.Bool(KeyNamespacesExperimentalStrictMode)
}
func (k *Config) ExpandRewrites() bool {
return k.p.Bool(KeyFeatureFlagExpandRewrites)
}
type (
buildNamespaceFn func(context.Context, *Config) (namespace.Manager, error)
namespaceConfig interface {
// newManager builds a new namespace manager.
newManager() buildNamespaceFn
// value returns the wrapped value (for comparing if we should reload)
value() any
}
legacyURINamespaceConfig string
literalNamespaceConfig []*namespace.Namespace
oplNamespaceConfig map[string]any
)
func (uri legacyURINamespaceConfig) newManager() buildNamespaceFn {
return func(ctx context.Context, c *Config) (namespace.Manager, error) {
return NewNamespaceWatcher(ctx, c.l, string(uri))
}
}
func (uri legacyURINamespaceConfig) value() any {
return string(uri)
}
func (namespaces literalNamespaceConfig) newManager() buildNamespaceFn {
return func(ctx context.Context, _ *Config) (namespace.Manager, error) {
return NewMemoryNamespaceManager(namespaces...), nil
}
}
func (namespaces literalNamespaceConfig) value() any {
return []*namespace.Namespace(namespaces)
}
func (oplConfig oplNamespaceConfig) newManager() buildNamespaceFn {
return func(ctx context.Context, c *Config) (namespace.Manager, error) {
entry, ok := oplConfig["location"]
if !ok {
return nil, errors.New("location key not found")
}
target, ok := entry.(string)
if !ok {
return nil, fmt.Errorf("config value must be string, was %T", entry)
}
return newOPLConfigWatcher(ctx, c.l, c.Fetcher, target)
}
}
func (oplConfig oplNamespaceConfig) value() any {
return map[string]any(oplConfig)
}
// namespaceConfig returns a namespace config, which can be either a URI (in
// which case we want to watch that URI), or a literal list of namespaces (in
// which case we just load them into memory), or a list of URIs referencing OPL
// definitions (in which case we want to watch each URI and parse the content).
func (k *Config) namespaceConfig() (namespaceConfig, error) {
switch nTyped := k.p.GetF(KeyNamespaces, "file://./keto_namespaces").(type) {
case string:
return legacyURINamespaceConfig(nTyped), nil
case []*namespace.Namespace:
return literalNamespaceConfig(nTyped), nil
case []any:
nEnc, err := json.Marshal(nTyped)
if err != nil {
return nil, errors.WithStack(err)
}
nn := make([]*namespace.Namespace, len(nTyped))
if err := json.Unmarshal(nEnc, &nn); err != nil {
return nil, errors.WithStack(err)
}
return literalNamespaceConfig(nn), nil
case map[string]any:
return oplNamespaceConfig(nTyped), nil
default:
return nil, errors.WithStack(herodot.ErrInternalServerError().WithReasonf("could not infer namespaces for type %T", nTyped))
}
}
func (k *Config) PaginationEncryptionKeys() [][32]byte {
secrets := k.p.Strings(KeySecretsPagination)
encryptionKeys := make([][32]byte, len(secrets))
for i, key := range secrets {
encryptionKeys[i] = sha512.Sum512_256([]byte(key))
}
return encryptionKeys
}

View File

@@ -0,0 +1,302 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package config
import (
"context"
"encoding/json"
"fmt"
"net/http"
"os"
"testing"
"time"
"github.com/gobuffalo/httptest"
"github.com/ory/x/configx"
"github.com/ory/x/logrusx"
"github.com/sirupsen/logrus/hooks/test"
"github.com/spf13/pflag"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/ory/keto/embedx"
"github.com/ory/keto/internal/namespace"
)
// createFile writes the content to a temporary file, returning the path.
// Good for testing config files.
func createFile(t *testing.T, content string) (path string) {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "config-*.yaml")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Remove(f.Name()) }) //nolint:gosec // Test file path from os.CreateTemp, not user input.
n, err := f.WriteString(content)
if err != nil {
t.Fatal(err)
}
if n != len(content) {
t.Fatal("failed to write the complete content")
}
return f.Name()
}
// createFileF writes the content format string with the applied args to a
// temporary file, returning the path. Good for testing config files.
func createFileF(t *testing.T, contentF string, args ...any) (path string) {
return createFile(t, fmt.Sprintf(contentF, args...))
}
func setup(t *testing.T, configFile string) (*test.Hook, *Config) {
t.Helper()
hook := test.Hook{}
l := logrusx.New("test", "today", logrusx.WithHook(&hook))
ctx, cancel := context.WithTimeout(context.Background(), 500*time.Millisecond)
t.Cleanup(cancel)
config, err := NewDefault(
ctx,
pflag.NewFlagSet("test", pflag.ContinueOnError),
l,
configx.WithConfigFiles(configFile),
)
require.NoError(t, err)
return &hook, config
}
func assertNamespaces(t *testing.T, p *Config, nn ...*namespace.Namespace) {
t.Helper()
nm, err := p.NamespaceManager()
require.NoError(t, err)
actualNamespaces, err := nm.Namespaces(context.Background())
require.NoError(t, err)
assert.Equal(t, len(nn), len(actualNamespaces))
assert.ElementsMatch(t, nn, actualNamespaces)
}
// The new way to configure namespaces is through the Ory Permissions Language.
// We check here that we still support enumerating the namespaces directly in
// the config or through a file reference, in which case there should be no
// rewrites configured.
func TestLegacyNamespaceConfig(t *testing.T) {
t.Run("case=creates memory namespace manager when namespaces are set", func(t *testing.T) {
config := createFile(t, `
dsn: memory
namespaces:
- name: n0
- name: n1
- name: n2`)
run := func(namespaces []*namespace.Namespace) func(*testing.T) {
return func(t *testing.T) {
_, p := setup(t, config)
assertNamespaces(t, p, namespaces...)
nm, err := p.NamespaceManager()
require.NoError(t, err)
_, ok := nm.(*memoryNamespaceManager)
assert.True(t, ok)
assert.False(t, p.StrictMode())
}
}
nn := []*namespace.Namespace{
{Name: "n0"},
{Name: "n1"},
{Name: "n2"},
}
nnJson, err := json.Marshal(nn)
require.NoError(t, err)
nnValue := make([]interface{}, 0)
require.NoError(t, json.Unmarshal(nnJson, &nnValue))
t.Run(
"type=[]*namespace.Namespace",
run(nn),
)
})
t.Run("case=reloads namespace manager when namespaces are updated using Set()", func(t *testing.T) {
_, p := setup(t, createFile(t, "dsn: memory"))
n0 := &namespace.Namespace{
Name: "n0",
}
n1 := &namespace.Namespace{
Name: "n1",
}
require.NoError(t, p.Set(KeyNamespaces, []*namespace.Namespace{n0}))
assertNamespaces(t, p, n0)
require.NoError(t, p.Set(KeyNamespaces, []*namespace.Namespace{n1}))
assertNamespaces(t, p, n1)
})
t.Run("case=creates watcher manager when namespaces is string URL", func(t *testing.T) {
_, p := setup(t, createFileF(t, `
dsn: memory
namespaces: file://%s`,
t.TempDir()))
nm, err := p.NamespaceManager()
require.NoError(t, err)
_, ok := nm.(*NamespaceWatcher)
assert.True(t, ok)
assert.False(t, p.StrictMode())
})
t.Run("case=uses passed configx provider", func(t *testing.T) {
ctx := context.Background()
cp, err := configx.New(ctx, embedx.ConfigSchema, configx.WithValue(KeyDSN, "foobar"))
require.NoError(t, err)
p := New(ctx, logrusx.New("test", "today"), cp)
assert.Equal(t, "foobar", p.DSN())
assert.Same(t, cp, p.p)
})
}
func TestInvalidNamespaceConfig(t *testing.T) {
config := createFile(t, `
dsn: memory
namespaces:
- name: n0
id: 2147483648
`)
hook := test.Hook{}
l := logrusx.New("test", "today", logrusx.WithHook(&hook))
_, err := NewDefault(
context.Background(),
pflag.NewFlagSet("test", pflag.ContinueOnError),
l,
configx.WithConfigFiles(config),
)
assert.Error(t, err)
assert.Contains(t, err.Error(), "must be <= 2.147483647e+09 but found 2147483648")
}
// Test that the namespaces can be configured through the Ory Permission
// Language.
func TestRewritesNamespaceConfig(t *testing.T) {
oplContent := `
class User implements Namespace {
related: {
manager: User[]
}
}
class Group implements Namespace {
related: {
members: (User | Group)[]
}
}`
oplConfigFile := createFile(t, oplContent)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
_, err := w.Write([]byte(oplContent))
if err != nil {
t.Fatal(err)
}
}))
t.Cleanup(func() { srv.Close() })
cases := []struct {
name string
location string
disallowPrivateIPRanges bool
expectErr bool
}{{
name: "local file",
location: "file://" + oplConfigFile,
}, {
name: "HTTP url forbidden",
location: srv.URL,
disallowPrivateIPRanges: true,
expectErr: true,
}, {
name: "HTTP url allowed",
location: srv.URL,
}}
for _, tc := range cases {
t.Run("case="+tc.name, func(t *testing.T) {
config := createFileF(t, `
dsn: memory
clients:
http:
disallow_private_ip_ranges: %v
namespaces:
location: %s`, tc.disallowPrivateIPRanges, tc.location)
_, p := setup(t, config)
nm, err := p.NamespaceManager()
if tc.expectErr {
assert.Error(t, err)
return
}
require.NoError(t, err)
namespaces, err := nm.Namespaces(context.Background())
require.NoError(t, err)
require.Len(t, namespaces, 2)
names, relationNames := []string{namespaces[0].Name, namespaces[1].Name}, []string{namespaces[0].Relations[0].Name, namespaces[1].Relations[0].Name}
assert.False(t, p.StrictMode())
assert.ElementsMatch(t, names, []string{"User", "Group"})
assert.ElementsMatch(t, relationNames, []string{"manager", "members"})
})
}
for _, tt := range []struct {
name string
deprecated bool
new bool
want bool
}{
{name: "neither key set", deprecated: false, new: false, want: false},
{name: "deprecated key only", deprecated: true, new: false, want: true},
{name: "new key only", deprecated: false, new: true, want: true},
{name: "both keys true", deprecated: true, new: true, want: true},
} {
t.Run("case=strict_mode/"+tt.name, func(t *testing.T) {
cfg := createFileF(t, `
dsn: memory
namespaces:
location: file://%s
experimental_strict_mode: %v
feature_flags:
strict_mode: %v`, oplConfigFile, tt.deprecated, tt.new)
_, p := setup(t, cfg)
assert.Equal(t, tt.want, p.StrictMode())
})
}
}
func TestProvider_DefaultReadAPIListenOn(t *testing.T) {
ctx := context.Background()
config, err := NewDefault(
ctx,
pflag.NewFlagSet("test", pflag.ContinueOnError),
logrusx.New("", ""),
configx.WithValue("dsn", "foo"),
)
require.NoError(t, err)
addr, listenFile := config.ReadAPIListenOn()
assert.Equal(t, ":4466", addr)
assert.Zero(t, listenFile)
}

570
internal/driver/daemon.go Normal file
View File

@@ -0,0 +1,570 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"context"
stderrors "errors"
"fmt"
"net"
"net/http"
"os"
"os/signal"
"runtime/debug"
"strings"
"syscall"
"time"
"connectrpc.com/connect"
"connectrpc.com/grpchealth"
"connectrpc.com/grpcreflect"
"github.com/ory/analytics-go/v5"
"github.com/ory/graceful"
"github.com/ory/herodot"
"github.com/ory/x/healthx"
"github.com/ory/x/httprouterx"
"github.com/ory/x/logrusx"
"github.com/ory/x/metricsx"
"github.com/ory/x/otelx"
"github.com/ory/x/otelx/semconv"
"github.com/ory/x/prometheusx"
"github.com/ory/x/reqlog"
"github.com/ory/x/urlx"
"github.com/pkg/errors"
"github.com/rs/cors"
"github.com/spf13/cobra"
"github.com/urfave/negroni"
"go.opentelemetry.io/contrib/instrumentation/net/http/otelhttp"
"golang.org/x/sync/errgroup"
"google.golang.org/genproto/googleapis/rpc/errdetails"
"google.golang.org/protobuf/reflect/protoregistry"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2/relationtuplesconnect"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/expand"
"github.com/ory/keto/internal/namespace/namespacehandler"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/schema"
)
func (r *RegistryDefault) enableSqa(cmd *cobra.Command) {
ctx := cmd.Context()
var urls []string
addr, _ := r.Config(ctx).ReadAPIListenOn()
urls = append(urls, addr)
addr, _ = r.Config(ctx).WriteAPIListenOn()
urls = append(urls, addr)
addr, _ = r.Config(ctx).MetricsListenOn()
urls = append(urls, addr)
addr, _ = r.Config(ctx).OPLSyntaxAPIListenOn()
urls = append(urls, addr)
if c, y := r.Config(ctx).CORS("read"); y {
urls = append(urls, c.AllowedOrigins...)
}
if c, y := r.Config(ctx).CORS("write"); y {
urls = append(urls, c.AllowedOrigins...)
}
if c, y := r.Config(ctx).CORS("metrics"); y {
urls = append(urls, c.AllowedOrigins...)
}
host := urlx.ExtractPublicAddress(urls...)
r.sqaService = metricsx.New(
cmd,
r.Logger(),
r.Config(ctx).Source(),
&metricsx.Options{
Service: "keto",
DeploymentId: metricsx.Hash(r.Persister().NetworkID(ctx).String()),
IsDevelopment: strings.HasPrefix(r.Config(ctx).DSN(), "sqlite"),
WriteKey: "jk32cFATnj9GKbQdFL7fBB9qtKZdX9j7",
WhitelistedPaths: []string{
"/",
healthx.AliveCheckPath,
healthx.ReadyCheckPath,
healthx.VersionPath,
relationtuplesconnect.VersionServiceGetVersionProcedure,
"/" + grpchealth.HealthV1ServiceName + "/Check",
"/" + grpchealth.HealthV1ServiceName + "/Watch",
relationtuple.ReadRouteBase,
relationtuplesconnect.ReadServiceListRelationTuplesProcedure,
check.RouteBase,
relationtuplesconnect.CheckServiceCheckProcedure,
relationtuplesconnect.CheckServiceBatchCheckProcedure,
expand.RouteBase,
relationtuplesconnect.ExpandServiceExpandProcedure,
relationtuplesconnect.WriteServiceTransactRelationTuplesProcedure,
relationtuplesconnect.WriteServiceDeleteRelationTuplesProcedure,
},
BuildVersion: config.Version,
BuildHash: config.Commit,
BuildTime: config.Date,
Config: &analytics.Config{
Endpoint: "https://sqa.ory.sh",
GzipCompressionLevel: 6,
BatchMaxSize: 500 * 1000,
BatchSize: 1000,
Interval: time.Hour * 6,
},
Hostname: host,
},
)
}
func (r *RegistryDefault) ServeAllSQA(cmd *cobra.Command) error {
r.enableSqa(cmd)
return r.ServeAll(cmd.Context())
}
func (r *RegistryDefault) ServeAll(ctx context.Context) error {
innerCtx, cancel := context.WithCancel(ctx)
defer cancel()
serveFuncs := []func(context.Context, chan<- struct{}) func() error{
r.serveRead,
r.serveWrite,
r.serveOPLSyntax,
r.serveMetrics,
}
doneShutdown := make(chan struct{}, len(serveFuncs))
go func() {
osSignals := make(chan os.Signal, 1)
signal.Notify(osSignals, os.Interrupt, syscall.SIGTERM)
select {
case <-osSignals:
cancel()
case <-innerCtx.Done():
}
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), graceful.DefaultShutdownTimeout)
defer cancel()
nWaitingForShutdown := len(serveFuncs)
for {
select {
case <-ctx.Done():
return
case <-doneShutdown:
nWaitingForShutdown--
if nWaitingForShutdown == 0 {
// graceful shutdown done
return
}
}
}
}()
eg := &errgroup.Group{}
// We need to separate the setup (invoking the functions that return the serve functions) from running the serve
// functions to mitigate race conditions in the HTTP router.
for _, serve := range serveFuncs {
eg.Go(serve(innerCtx, doneShutdown))
}
return eg.Wait()
}
func (r *RegistryDefault) serveRead(ctx context.Context, done chan<- struct{}) func() error {
rt := r.ReadRouter(ctx)
if tracer := r.Tracer(ctx); tracer.IsLoaded() {
rt = otelx.NewMiddleware(rt, "serveRead",
otelhttp.WithTracerProvider(tracer.Provider()),
)
}
return func() error {
addr, listenFile := r.Config(ctx).ReadAPIListenOn()
return servePort(ctx, r.Logger().WithField("endpoint", "read"), addr, listenFile, rt, done)
}
}
func (r *RegistryDefault) serveWrite(ctx context.Context, done chan<- struct{}) func() error {
rt := r.WriteRouter(ctx)
if tracer := r.Tracer(ctx); tracer.IsLoaded() {
rt = otelx.NewMiddleware(rt, "serveWrite",
otelhttp.WithTracerProvider(tracer.Provider()),
)
}
return func() error {
addr, listenFile := r.Config(ctx).WriteAPIListenOn()
return servePort(ctx, r.Logger().WithField("endpoint", "write"), addr, listenFile, rt, done)
}
}
func (r *RegistryDefault) serveOPLSyntax(ctx context.Context, done chan<- struct{}) func() error {
rt := r.OPLSyntaxRouter(ctx)
if tracer := r.Tracer(ctx); tracer.IsLoaded() {
rt = otelx.NewMiddleware(rt, "serveOPLSyntax",
otelhttp.WithTracerProvider(tracer.Provider()),
)
}
return func() error {
addr, listenFile := r.Config(ctx).OPLSyntaxAPIListenOn()
return servePort(ctx, r.Logger().WithField("endpoint", "opl"), addr, listenFile, rt, done)
}
}
func (r *RegistryDefault) serveMetrics(ctx context.Context, done chan<- struct{}) func() error {
ctx, cancel := context.WithCancel(ctx)
//nolint:gosec // graceful.WithDefaults already sets a timeout
s := graceful.WithDefaults(&http.Server{
Handler: r.metricsRouter(ctx),
})
return func() error {
defer cancel()
eg := &errgroup.Group{}
addr, listenFile := r.Config(ctx).MetricsListenOn()
l, err := listenAndWriteFile(ctx, addr, listenFile)
if err != nil {
return err
}
eg.Go(func() error {
if err := s.Serve(l); !errors.Is(err, http.ErrServerClosed) {
return errors.WithStack(err)
}
return nil
})
eg.Go(func() (err error) {
defer func() {
l := r.Logger().WithField("endpoint", "metrics")
if err != nil {
l.WithError(err).Error("graceful shutdown failed")
} else {
l.Info("gracefully shutdown server")
}
done <- struct{}{}
}()
<-ctx.Done()
ctx, cancel := context.WithTimeout(context.Background(), graceful.DefaultShutdownTimeout)
defer cancel()
return s.Shutdown(ctx)
})
return eg.Wait()
}
}
func servePort(ctx context.Context, log *logrusx.Logger, addr, listenFile string, router http.Handler, done chan<- struct{}) error {
l, err := listenAndWriteFile(ctx, addr, listenFile)
if err != nil {
return err
}
p := new(http.Protocols)
p.SetHTTP1(true)
// For gRPC clients, it's convenient to support HTTP/2 without TLS.
p.SetUnencryptedHTTP2(true)
p.SetHTTP2(true)
//nolint:gosec // graceful.WithDefaults already sets a timeout
restS := graceful.WithDefaults(&http.Server{
Handler: router,
Protocols: p,
})
eg := &errgroup.Group{}
eg.Go(func() error {
if err := restS.Serve(l); !errors.Is(err, http.ErrServerClosed) {
return errors.WithStack(err)
}
return nil
})
eg.Go(func() (err error) {
defer func() {
if err != nil {
log.WithError(err).Error("graceful shutdown failed")
} else {
log.Info("gracefully shutdown server")
}
done <- struct{}{}
}()
<-ctx.Done()
ctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), graceful.DefaultShutdownTimeout)
defer cancel()
shutdownEg := errgroup.Group{}
shutdownEg.Go(func() error {
// we ignore net.ErrClosed, because a cmux listener's close func is actually the one of the root listener (which is closed in a racy fashion)
if err := restS.Shutdown(ctx); err != nil && !errors.Is(err, http.ErrServerClosed) && !errors.Is(err, net.ErrClosed) {
// unexpected error
return errors.WithStack(err)
}
return nil
})
shutdownEg.Go(func() error {
gracefulDone := make(chan struct{})
go func() {
close(gracefulDone)
}()
select {
case <-gracefulDone:
return nil
case <-ctx.Done():
return errors.New("graceful stop of gRPC server timed out, had to force it")
}
})
return shutdownEg.Wait()
})
return eg.Wait()
}
func (r *RegistryDefault) allHandlers() []Handler {
return []Handler{
relationtuple.NewReadHandler(r),
relationtuple.NewWriteHandler(r),
check.NewHandler(r),
expand.NewHandler(r),
namespacehandler.New(r),
schema.NewHandler(r),
}
}
func listenAndWriteFile(ctx context.Context, addr, listenFile string) (net.Listener, error) {
l, err := (&net.ListenConfig{}).Listen(ctx, "tcp", addr)
if err != nil {
return nil, errors.WithStack(fmt.Errorf("unable to listen on %q: %w", addr, err))
}
const filePrefix = "file://"
if strings.HasPrefix(listenFile, filePrefix) {
if err := os.WriteFile(listenFile[len(filePrefix):], []byte(l.Addr().String()), 0o600); err != nil {
return nil, errors.WithStack(fmt.Errorf("unable to write listen file %q: %w", listenFile, err))
}
}
return l, nil
}
var httpMetrics = prometheusx.NewHTTPMetrics("keto", prometheusx.HTTPPrefix, config.Version, config.Commit, config.Date)
func (r *RegistryDefault) ReadRouter(ctx context.Context) http.Handler {
recovery := negroni.NewRecovery()
recovery.Logger = r.Logger()
router := httprouterx.NewRouterPublic()
n := negroni.New(
recovery,
httpMetrics,
)
for _, f := range r.defaultHttpMiddlewares {
n.UseFunc(f)
}
n.UseFunc(semconv.Middleware)
n.Use(reqlog.NewMiddlewareFromLogger(r.l, "read#Ory Keto").ExcludePaths(healthx.AliveCheckPath, healthx.ReadyCheckPath))
prometheusx.SetMuxRoutes(router)
r.HealthHandler().SetHealthRoutes(router, false)
r.HealthHandler().SetVersionRoutes(router)
for _, h := range registerReflectionAndHealth[ReadHandler](r, router) {
h.RegisterReadRoutes(router)
}
if r.sqaService != nil {
n.Use(r.sqaService)
}
n.UseHandler(router)
var handler http.Handler = n
options, enabled := r.Config(ctx).CORS("read")
if enabled {
handler = cors.New(options).Handler(handler)
}
return handler
}
func (r *RegistryDefault) WriteRouter(ctx context.Context) http.Handler {
recovery := negroni.NewRecovery()
recovery.Logger = r.Logger()
router := httprouterx.NewRouterAdmin()
n := negroni.New(
recovery,
httpMetrics,
)
for _, f := range r.defaultHttpMiddlewares {
n.UseFunc(f)
}
n.UseFunc(semconv.Middleware)
n.Use(reqlog.NewMiddlewareFromLogger(r.l, "write#Ory Keto").ExcludePaths(healthx.AliveCheckPath, healthx.ReadyCheckPath))
prometheusx.SetMuxRoutes(router)
r.HealthHandler().SetHealthRoutes(router, false)
r.HealthHandler().SetVersionRoutes(router)
for _, h := range registerReflectionAndHealth[WriteHandler](r, router) {
h.RegisterWriteRoutes(router)
}
if r.sqaService != nil {
n.Use(r.sqaService)
}
n.UseHandler(router)
var handler http.Handler = n
options, enabled := r.Config(ctx).CORS("write")
if enabled {
handler = cors.New(options).Handler(handler)
}
return handler
}
func (r *RegistryDefault) OPLSyntaxRouter(ctx context.Context) http.Handler {
recovery := negroni.NewRecovery()
recovery.Logger = r.Logger()
router := httprouterx.NewRouter()
n := negroni.New(
recovery,
httpMetrics,
)
for _, f := range r.defaultHttpMiddlewares {
n.UseFunc(f)
}
n.UseFunc(semconv.Middleware)
n.Use(reqlog.NewMiddlewareFromLogger(r.l, "syntax#Ory Keto").ExcludePaths(healthx.AliveCheckPath, healthx.ReadyCheckPath))
prometheusx.SetMuxRoutes(router)
r.HealthHandler().SetHealthRoutes(router, false)
r.HealthHandler().SetVersionRoutes(router)
for _, h := range registerReflectionAndHealth[OPLSyntaxHandler](r, router) {
h.RegisterSyntaxRoutes(router)
}
if r.sqaService != nil {
n.Use(r.sqaService)
}
n.UseHandler(router)
var handler http.Handler = n
options, enabled := r.Config(ctx).CORS("write")
if enabled {
handler = cors.New(options).Handler(handler)
}
return handler
}
func (r *RegistryDefault) recoveryHandler(_ context.Context, spec connect.Spec, _ http.Header, val any) error {
r.Logger().
WithField("reason", val).
WithField("procedure", spec.Procedure).
WithField("stack_trace", string(debug.Stack())).
Error("panic recovered")
return connect.NewError(connect.CodeInternal, fmt.Errorf("%v", val))
}
func (r *RegistryDefault) HandlerOptions() []connect.HandlerOption {
return append([]connect.HandlerOption{
connect.WithRecover(r.recoveryHandler),
connect.WithInterceptors(connect.UnaryInterceptorFunc(func(next connect.UnaryFunc) connect.UnaryFunc {
return func(ctx context.Context, req connect.AnyRequest) (connect.AnyResponse, error) {
resp, err := next(ctx, req)
if hErr, ok := stderrors.AsType[*herodot.DefaultError](err); ok {
cErr := connect.NewError(connect.Code(hErr.GRPCCodeField), hErr)
if reason := hErr.Reason(); reason != "" {
d, err := connect.NewErrorDetail(&errdetails.ErrorInfo{Reason: reason})
if err == nil {
cErr.AddDetail(d)
}
}
return resp, cErr
}
return resp, err
}
})),
connect.WithInterceptors(r.defaultConnectInterceptors...),
},
r.defaultHandlerOptions...)
}
func (r *RegistryDefault) metricsRouter(ctx context.Context) http.Handler {
recovery := negroni.NewRecovery()
recovery.Logger = r.Logger()
n := negroni.New(
recovery,
reqlog.NewMiddlewareFromLogger(r.Logger(), "keto").ExcludePaths(prometheusx.MetricsPrometheusPath),
)
router := http.NewServeMux()
prometheusx.SetMuxRoutes(router)
n.UseHandler(router)
var handler http.Handler = n
options, enabled := r.Config(ctx).CORS("metrics")
if enabled {
handler = cors.New(options).Handler(handler)
}
return handler
}
func registerReflectionAndHealth[H Handler](r *RegistryDefault, router httprouterx.Router) (handlers []H) {
protoFiles := &protoregistry.Files{}
var protoServices []string
for _, h := range r.allHandlers() {
if h, ok := h.(H); ok {
handlers = append(handlers, h)
for _, f := range h.ProtoFiles() {
if err := protoFiles.RegisterFile(f); err != nil && !strings.Contains(err.Error(), "is already registered") {
r.Logger().WithError(err).
WithField("file_path", f.Path()).
Error("unable to register proto file for reflection")
}
s := f.Services()
for i := range s.Len() {
protoServices = append(protoServices, string(s.Get(i).FullName()))
}
}
}
}
router.Handle(relationtuplesconnect.NewVersionServiceHandler(r, r.HandlerOptions()...))
protoServices = append(protoServices, relationtuplesconnect.VersionServiceName)
if err := protoFiles.RegisterFile(rts.File_ory_keto_relation_tuples_v1alpha2_version_proto); err != nil {
r.Logger().WithError(err).Error("unable to register version proto file for reflection")
}
reflector := grpcreflect.NewReflector(
grpcreflect.NamerFunc(func() []string { return protoServices }),
grpcreflect.WithDescriptorResolver(protoFiles),
)
router.Handle(grpcreflect.NewHandlerV1(reflector, r.HandlerOptions()...))
router.Handle(grpcreflect.NewHandlerV1Alpha(reflector, r.HandlerOptions()...))
router.Handle(grpchealth.NewHandler(grpchealth.NewStaticChecker(protoServices...), r.HandlerOptions()...))
return
}

View File

@@ -0,0 +1,182 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"bytes"
"context"
"fmt"
"io"
"net/http"
"testing"
"time"
"connectrpc.com/connect"
ioprometheusclient "github.com/prometheus/client_model/go"
"github.com/prometheus/common/expfmt"
"github.com/prometheus/common/model"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"golang.org/x/sync/errgroup"
"google.golang.org/grpc"
"google.golang.org/grpc/codes"
"google.golang.org/grpc/credentials/insecure"
grpcHealthV1 "google.golang.org/grpc/health/grpc_health_v1"
"google.golang.org/grpc/status"
prometheus "github.com/ory/x/prometheusx"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
)
func TestScrapingEndpoint(t *testing.T) {
t.Parallel()
r := NewSqliteTestRegistry(t)
getAddr := UseDynamicPorts(t, r)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
eg := errgroup.Group{}
doneShutdown := make(chan struct{})
eg.Go(r.serveRead(ctx, doneShutdown))
eg.Go(r.serveMetrics(ctx, doneShutdown))
_, readPort, _ := getAddr(t, "read")
_, metricsPort, _ := getAddr(t, "metrics")
t.Logf("write port: %s, metrics port: %s", readPort, metricsPort)
assert.EventuallyWithT(t, func(t *assert.CollectT) {
conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%s", readPort), grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
defer func() { require.NoError(t, conn.Close()) }()
cl := grpcHealthV1.NewHealthClient(conn)
resp, err := cl.Check(ctx, &grpcHealthV1.HealthCheckRequest{})
require.NoError(t, err)
assert.Equal(t, grpcHealthV1.HealthCheckResponse_SERVING, resp.Status)
}, 2*time.Second, 10*time.Millisecond)
conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%s", readPort), grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
defer func() { require.NoError(t, conn.Close()) }()
cl := rts.NewReadServiceClient(conn)
resp, err := cl.ListRelationTuples(ctx, &rts.ListRelationTuplesRequest{RelationQuery: &rts.RelationQuery{}})
require.NoError(t, err)
require.Len(t, resp.RelationTuples, 0)
// A dedicated transport keeps httptest.Server.Close in parallel tests from
// breaking these requests via http.DefaultTransport.CloseIdleConnections.
transport := http.DefaultTransport.(*http.Transport).Clone()
t.Cleanup(transport.CloseIdleConnections)
hc := &http.Client{Transport: transport}
respx, err := hc.Get(fmt.Sprintf("http://127.0.0.1:%s/relation-tuples", readPort))
require.NoError(t, err)
t.Cleanup(func() { _ = respx.Body.Close() })
require.EqualValues(t, http.StatusOK, respx.StatusCode)
body, err := io.ReadAll(respx.Body)
require.NoError(t, err)
require.Contains(t, string(body), `"relation_tuples":[]`)
promresp, err := hc.Get(fmt.Sprintf("http://127.0.0.1:%s%s", metricsPort, prometheus.MetricsPrometheusPath))
require.NoError(t, err)
t.Cleanup(func() { _ = promresp.Body.Close() })
require.EqualValues(t, http.StatusOK, promresp.StatusCode)
promrespBody, err := io.ReadAll(promresp.Body)
require.NoError(t, err)
textParser := expfmt.NewTextParser(model.UTF8Validation)
text, err := textParser.TextToMetricFamilies(bytes.NewReader(promrespBody))
require.NoError(t, err)
require.EqualValuesf(t, "http_requests_total", *text["http_requests_total"].Name, "%s", promrespBody)
require.ElementsMatch(t,
[]string{"/grpc.health.v1.Health/", "/ory.keto.relation_tuples.v1alpha2.ReadService/ListRelationTuples", "/relation-tuples"},
getLabelValues("endpoint", text["http_requests_total"].Metric),
)
cancel()
<-doneShutdown
<-doneShutdown
require.NoError(t, eg.Wait())
}
type panicInterceptor struct{}
func (panicInterceptor) WrapUnary(connect.UnaryFunc) connect.UnaryFunc {
return func(context.Context, connect.AnyRequest) (connect.AnyResponse, error) { panic("test panic") }
}
func (panicInterceptor) WrapStreamingClient(connect.StreamingClientFunc) connect.StreamingClientFunc {
return func(context.Context, connect.Spec) connect.StreamingClientConn { panic("test panic") }
}
func (panicInterceptor) WrapStreamingHandler(connect.StreamingHandlerFunc) connect.StreamingHandlerFunc {
return func(context.Context, connect.StreamingHandlerConn) error { panic("test panic") }
}
var _ connect.Interceptor = (*panicInterceptor)(nil)
func TestPanicRecovery(t *testing.T) {
t.Parallel()
r := NewSqliteTestRegistry(t, WithHandlerOptions(connect.WithInterceptors(panicInterceptor{})))
getAddr := UseDynamicPorts(t, r)
ctx, cancel := context.WithCancel(t.Context())
defer cancel()
eg := errgroup.Group{}
doneShutdown := make(chan struct{})
eg.Go(r.serveWrite(ctx, doneShutdown))
_, port, _ := getAddr(t, "write")
conn, err := grpc.NewClient(fmt.Sprintf("127.0.0.1:%s", port), grpc.WithTransportCredentials(insecure.NewCredentials()))
require.NoError(t, err)
defer func() { require.NoError(t, conn.Close()) }()
assert.EventuallyWithT(t, func(t *assert.CollectT) {
cl := grpcHealthV1.NewHealthClient(conn)
watcher, err := cl.Watch(ctx, &grpcHealthV1.HealthCheckRequest{})
require.NoError(t, err)
require.NoError(t, watcher.CloseSend())
for err := status.Error(codes.Unavailable, "init"); status.Code(err) != codes.Unavailable; _, err = watcher.Recv() {
}
}, 2*time.Second, 10*time.Millisecond)
cl := grpcHealthV1.NewHealthClient(conn)
// we want to ensure the server is still running after the panic
for range 10 {
// Unary call
resp, err := cl.Check(ctx, &grpcHealthV1.HealthCheckRequest{})
require.Errorf(t, err, "%+v", resp)
assert.Equal(t, codes.Internal, status.Code(err))
// Streaming call
wResp, err := cl.Watch(ctx, &grpcHealthV1.HealthCheckRequest{})
require.NoError(t, err)
err = wResp.RecvMsg(nil)
require.Error(t, err)
assert.Equal(t, codes.Internal, status.Code(err))
}
cancel()
<-doneShutdown
require.NoError(t, eg.Wait())
}
func getLabelValues(name string, metric []*ioprometheusclient.Metric) (vals []string) {
for _, m := range metric {
for _, label := range m.Label {
if *label.Name == name {
vals = append(vals, *label.Value)
}
}
}
return vals
}

View File

@@ -0,0 +1,40 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"context"
"github.com/grpc-ecosystem/go-grpc-middleware/v2/interceptors/logging"
"github.com/sirupsen/logrus"
)
// InterceptorLogger adapts logrus logger to interceptor logger.
// This code is simple enough to be copied and not imported.
//
// Source from: https://github.com/grpc-ecosystem/go-grpc-middleware/blob/main/interceptors/logging/examples/logrus/example_test.go
func InterceptorLogger(l logrus.FieldLogger) logging.Logger {
return logging.LoggerFunc(func(_ context.Context, lvl logging.Level, msg string, fields ...any) {
f := make(map[string]any, len(fields)/2)
i := logging.Fields(fields).Iterator()
if i.Next() {
k, v := i.At()
f[k] = v
}
l := l.WithFields(f)
switch lvl {
case logging.LevelDebug:
l.Debug(msg)
case logging.LevelInfo:
l.Info(msg)
case logging.LevelWarn:
l.Warn(msg)
case logging.LevelError:
l.Error(msg)
default:
l.Info(msg)
}
})
}

View File

@@ -0,0 +1,84 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"context"
"time"
"github.com/cenkalti/backoff/v3"
"github.com/pkg/errors"
"github.com/ory/pop/v6"
"github.com/ory/x/sqlcon"
)
func (r *RegistryDefault) PopConnectionWithOpts(ctx context.Context, popOpts ...func(*pop.ConnectionDetails)) (*pop.Connection, error) {
pool, idlePool, connMaxLifetime, connMaxIdleTime, cleanedDSN := sqlcon.ParseConnectionOptions(r.Logger(), r.Config(ctx).DSN())
connDetails := &pop.ConnectionDetails{
URL: sqlcon.FinalizeDSN(r.Logger(), cleanedDSN),
IdlePool: idlePool,
ConnMaxLifetime: connMaxLifetime,
ConnMaxIdleTime: connMaxIdleTime,
Pool: pool,
TracerProvider: r.Tracer(ctx).Provider(),
}
for _, o := range popOpts {
o(connDetails)
}
bc := backoff.NewExponentialBackOff()
bc.MaxElapsedTime = time.Minute * 5
bc.Reset()
var conn *pop.Connection
if err := backoff.Retry(func() (err error) {
conn, err = pop.NewConnection(connDetails)
if err != nil {
r.Logger().WithError(err).Error("Unable to connect to database, retrying.")
return errors.WithStack(err)
}
if err := conn.Open(); err != nil {
r.Logger().WithError(err).Error("Unable to open the database connection, retrying.")
return errors.WithStack(err)
}
if err := conn.Store.(interface{ Ping() error }).Ping(); err != nil {
r.Logger().WithError(err).Error("Unable to ping the database connection, retrying.")
return errors.WithStack(err)
}
return nil
}, bc); err != nil {
return nil, errors.WithStack(err)
}
// Wrap the connection with the context before starting the close
// goroutine. WithContext reads conn.Store (via copy), and conn.Close sets
// conn.Store to nil; if the context is already canceled, the goroutine
// would otherwise race the read. Ordering the read first makes it
// happen-before the goroutine starts.
wrapped := conn.WithContext(ctx)
// Close this connection when the context is closed.
go func() {
<-ctx.Done()
if err := conn.Close(); err != nil {
r.Logger().WithError(err).Error("Unable to close database connection")
}
}()
return wrapped, nil
}
// PopConnection returns the standard connection that is kept for the whole time.
func (r *RegistryDefault) PopConnection(ctx context.Context) (*pop.Connection, error) {
if r.conn == nil {
var err error
r.conn, err = r.PopConnectionWithOpts(ctx, r.dbOpts...)
return r.conn, err
}
return r.conn, nil
}

View File

@@ -0,0 +1,63 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"context"
"net/http"
"connectrpc.com/connect"
"github.com/ory/pop/v6"
"github.com/ory/x/healthx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/spf13/cobra"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/expand"
"github.com/ory/keto/internal/persistence"
"github.com/ory/keto/internal/relationtuple"
)
type (
Registry interface {
Init(context.Context) error
config.Provider
logrusx.Provider
httpx.WriterProvider
relationtuple.ManagerProvider
relationtuple.MapperProvider
expand.EngineProvider
check.CheckerProvider
check.EngineProvider
persistence.Migrator
persistence.Provider
PopConnection(ctx context.Context) (*pop.Connection, error)
PopConnectionWithOpts(ctx context.Context, f ...func(*pop.ConnectionDetails)) (*pop.Connection, error)
HealthHandler() *healthx.Handler
Tracer(ctx context.Context) *otelx.Tracer
ReadRouter(ctx context.Context) http.Handler
WriteRouter(ctx context.Context) http.Handler
OPLSyntaxRouter(ctx context.Context) http.Handler
ServeAll(ctx context.Context) error
ServeAllSQA(cmd *cobra.Command) error
HandlerOptions() []connect.HandlerOption
}
contextKeys string
)
const (
LogrusHookContextKey contextKeys = "logrus hook"
RegistryContextKey contextKeys = "registry"
)

View File

@@ -0,0 +1,392 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"context"
"io/fs"
"maps"
"net/http"
"sync"
"connectrpc.com/connect"
"github.com/gofrs/uuid"
"github.com/ory/herodot"
"github.com/pkg/errors"
"google.golang.org/grpc/credentials"
"google.golang.org/grpc/health"
"google.golang.org/protobuf/reflect/protoreflect"
"github.com/ory/pop/v6"
"github.com/ory/x/contextx"
"github.com/ory/x/dbal"
"github.com/ory/x/fsx"
"github.com/ory/x/healthx"
"github.com/ory/x/httprouterx"
"github.com/ory/x/httpx"
"github.com/ory/x/logrusx"
"github.com/ory/x/metricsx"
"github.com/ory/x/networkx"
"github.com/ory/x/otelx"
"github.com/ory/x/popx"
rts "github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2"
"github.com/ory/keto/gen/go/ory/keto/relation_tuples/v1alpha2/relationtuplesconnect"
"github.com/ory/keto/internal/check"
"github.com/ory/keto/internal/check/step"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/expand"
"github.com/ory/keto/internal/persistence"
"github.com/ory/keto/internal/persistence/sql"
"github.com/ory/keto/internal/persistence/sql/migrations/uuidmapping"
"github.com/ory/keto/internal/relationtuple"
"github.com/ory/keto/ketoctx"
)
var (
_ relationtuple.ManagerProvider = (*RegistryDefault)(nil)
_ relationtuple.MapperProvider = (*RegistryDefault)(nil)
_ relationtuple.MappingManagerProvider = (*RegistryDefault)(nil)
_ httpx.WriterProvider = (*RegistryDefault)(nil)
_ logrusx.Provider = (*RegistryDefault)(nil)
_ Registry = (*RegistryDefault)(nil)
_ relationtuplesconnect.VersionServiceHandler = (*RegistryDefault)(nil)
)
type (
RegistryDefault struct {
relationtuplesconnect.UnimplementedVersionServiceHandler
p persistence.Persister
mb *popx.MigrationBox
extraMigrations []fs.FS
l *logrusx.Logger
w herodot.Writer
ce *check.Engine
ck *step.Executor
ee *expand.Engine
c *config.Config
conn *pop.Connection
ctxer contextx.Contextualizer
mapper *relationtuple.Mapper
readOnlyMapper *relationtuple.Mapper
replaceMapperNs func(ctx context.Context) uuid.UUID
replaceShardID func() uuid.UUID
init1, init2 sync.Once
init1err, init2err error
healthH *healthx.Handler
healthServer *health.Server
sqaService *metricsx.Service
tracer *otelx.Tracer
tracerWrapper ketoctx.TracerWrapper
defaultHandlerOptions []connect.HandlerOption
defaultConnectInterceptors []connect.Interceptor
defaultHttpMiddlewares []func(rw http.ResponseWriter, r *http.Request, next http.HandlerFunc)
grpcTransportCredentials credentials.TransportCredentials
defaultMigrationOptions []popx.MigrationBoxOption
healthReadyCheckers healthx.ReadyCheckers
dbOpts []func(details *pop.ConnectionDetails)
}
ReadHandler interface {
Handler
RegisterReadRoutes(r *httprouterx.RouterPublic)
}
WriteHandler interface {
Handler
RegisterWriteRoutes(r *httprouterx.RouterAdmin)
}
OPLSyntaxHandler interface {
Handler
RegisterSyntaxRoutes(r httprouterx.Router)
}
Handler interface {
ProtoFiles() []protoreflect.FileDescriptor
}
)
func (r *RegistryDefault) Mapper() *relationtuple.Mapper {
if r.mapper == nil {
r.mapper = &relationtuple.Mapper{D: r}
}
return r.mapper
}
func (r *RegistryDefault) ReadOnlyMapper() *relationtuple.Mapper {
if r.readOnlyMapper == nil {
r.readOnlyMapper = &relationtuple.Mapper{D: r, ReadOnly: true}
}
return r.readOnlyMapper
}
func (r *RegistryDefault) Contextualizer() contextx.Contextualizer {
return r.ctxer
}
func (r *RegistryDefault) Config(ctx context.Context) *config.Config {
if provider := r.ctxer.Config(ctx, r.c.Source()); provider != r.c.Source() {
return config.New(ctx, r.Logger(), provider)
}
return r.c
}
func (r *RegistryDefault) HealthHandler() *healthx.Handler {
if r.healthH != nil {
return r.healthH
}
h := healthx.ReadyCheckers{
"database": func(req *http.Request) error {
return r.Persister().Connection(req.Context()).Store.PingContext(req.Context())
},
"migrations": func(req *http.Request) error {
mb, err := r.MigrationBox(req.Context())
if err != nil {
return err
}
status, err := mb.Status(req.Context())
if err != nil {
return err
}
if status.HasPending() {
return errors.Errorf("migrations have not yet been fully applied")
}
return nil
},
}
maps.Copy(h, r.healthReadyCheckers) // possibly override default checkers
r.healthH = healthx.NewHandler(r.Writer(), config.Version, h)
return r.healthH
}
func (r *RegistryDefault) HealthServer() *health.Server {
if r.healthServer == nil {
r.healthServer = health.NewServer()
}
return r.healthServer
}
func (r *RegistryDefault) GetVersion(_ context.Context, _ *connect.Request[rts.GetVersionRequest]) (*connect.Response[rts.GetVersionResponse], error) {
return connect.NewResponse(&rts.GetVersionResponse{Version: config.Version}), nil
}
func (r *RegistryDefault) Tracer(ctx context.Context) *otelx.Tracer {
if r.tracer == nil {
// Tracing is initialized only once, so it can not be hot reloaded or context-aware.
t, err := otelx.New("Ory Keto", r.Logger(), r.Config(ctx).TracingConfig())
if err != nil {
r.Logger().WithError(err).Fatalf("Unable to initialize Tracer.")
}
// Wrap the tracer if required
if r.tracerWrapper != nil {
t = r.tracerWrapper(t)
}
r.tracer = t
}
return r.tracer
}
func (r *RegistryDefault) Logger() *logrusx.Logger {
if r.l == nil {
r.l = logrusx.New("Ory Keto", config.Version)
}
return r.l
}
func (r *RegistryDefault) Writer() herodot.Writer {
if r.w == nil {
r.w = herodot.NewJSONWriter(r.Logger())
}
return r.w
}
func (r *RegistryDefault) RelationTupleManager() relationtuple.Manager {
if r.p == nil {
panic("no relation tuple manager, but expected to have one")
}
return r.p
}
func (r *RegistryDefault) MappingManager() relationtuple.MappingManager {
if r.p == nil {
panic("no relation tuple manager, but expected to have one")
}
return r.p
}
func (r *RegistryDefault) Persister() persistence.Persister {
if r.p == nil {
panic("no persister, but expected to have one")
}
return r.p
}
func (r *RegistryDefault) NetworkID(ctx context.Context) uuid.UUID {
if r.p == nil {
panic("no persister, but expected to have one")
}
return r.p.NetworkID(ctx)
}
func (r *RegistryDefault) NewShardID() uuid.UUID {
if r.replaceShardID != nil {
return r.replaceShardID()
}
return uuid.Must(uuid.NewV4())
}
func (r *RegistryDefault) MapperNamespace(ctx context.Context) uuid.UUID {
if r.replaceMapperNs != nil {
return r.replaceMapperNs(ctx)
}
return r.p.NetworkID(ctx)
}
func (r *RegistryDefault) PermissionEngine() *check.Engine {
if r.ce == nil {
r.ce = check.NewEngine(r)
}
return r.ce
}
func (r *RegistryDefault) ExpandEngine() *expand.Engine {
if r.ee == nil {
r.ee = expand.NewEngine(r)
}
return r.ee
}
func (r *RegistryDefault) Checker() check.Checker {
if r.ck == nil {
r.ck = step.NewExecutor(r, step.CallLimitMiddleware())
}
return r.ck
}
func (r *RegistryDefault) MigrationBox(ctx context.Context, opts ...popx.MigrationBoxOption) (*popx.MigrationBox, error) {
if r.mb == nil {
c, err := r.PopConnection(ctx)
if err != nil {
return nil, err
}
namespaces, err := r.Config(ctx).NamespaceManager()
if err != nil {
return nil, err
}
migrationBoxOptions := append(opts,
popx.WithGoMigrations(uuidmapping.Migrations(namespaces)))
migrationBoxOptions = append(migrationBoxOptions, r.defaultMigrationOptions...)
mb, err := popx.NewMigrationBox(
fsx.Merge(append([]fs.FS{sql.Migrations, networkx.Migrations}, r.extraMigrations...)...),
c, r.Logger(),
migrationBoxOptions...,
)
if err != nil {
return nil, err
}
r.mb = mb
}
return r.mb, nil
}
func (r *RegistryDefault) MigrateUp(ctx context.Context) error {
mb, err := r.MigrationBox(ctx)
if err != nil {
return err
}
if err := mb.Up(ctx); err != nil {
return err
}
return r.Init(ctx)
}
func (r *RegistryDefault) MigrateDown(ctx context.Context) error {
mb, err := r.MigrationBox(ctx)
if err != nil {
return err
}
return mb.Down(ctx, -1)
}
func (r *RegistryDefault) DetermineNetwork(ctx context.Context) (*networkx.Network, error) {
c, err := r.PopConnection(ctx)
if err != nil {
return nil, err
}
mb, err := popx.NewMigrationBox(networkx.Migrations, c, r.Logger())
if err != nil {
return nil, err
}
s, err := mb.Status(ctx)
if err != nil {
return nil, err
}
if s.HasPending() {
return nil, errors.WithStack(persistence.ErrNetworkMigrationsMissing)
}
return networkx.Determine(c.WithContext(ctx))
}
func (r *RegistryDefault) InitWithoutNetworkID(ctx context.Context) error {
r.init1.Do(func() {
if dbal.IsMemorySQLite(r.Config(ctx).DSN()) {
mb, err := r.MigrationBox(ctx)
if err != nil {
r.init1err = err
return
}
if err := mb.Up(ctx); err != nil {
r.init1err = err
return
}
}
p, err := sql.NewPersister(ctx, r, uuid.Nil)
if err != nil {
r.init1err = err
return
}
r.p = p
r.initEngines()
})
return r.init1err
}
func (r *RegistryDefault) initEngines() {
_ = r.PermissionEngine()
_ = r.ExpandEngine()
_ = r.Checker()
}
func (r *RegistryDefault) Init(ctx context.Context) (err error) {
r.init2.Do(func() {
if err := r.InitWithoutNetworkID(ctx); err != nil {
r.init2err = err
return
}
network, err := r.DetermineNetwork(ctx)
if err != nil {
r.init2err = err
return
}
r.p.SetNetwork(network.ID)
})
return r.init2err
}

View File

@@ -0,0 +1,242 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"context"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"fmt"
"os"
"sync"
"testing"
"connectrpc.com/connect"
"github.com/gofrs/uuid"
"github.com/ory/x/contextx"
"github.com/ory/x/configx"
"github.com/ory/x/logrusx"
"github.com/ory/x/otelx"
"github.com/ory/x/tlsx"
"github.com/pkg/errors"
"github.com/sirupsen/logrus"
"github.com/spf13/pflag"
"github.com/stretchr/testify/require"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/credentials"
"github.com/ory/keto/internal/driver/config"
"github.com/ory/keto/internal/namespace"
"github.com/ory/keto/internal/x/dbx"
"github.com/ory/keto/ketoctx"
)
// createFile writes the content to a temporary file, returning the path.
// Good for testing config files.
func createFile(t testing.TB, content string) (path string) {
t.Helper()
f, err := os.CreateTemp(t.TempDir(), "config-*.yaml")
if err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = os.Remove(f.Name()) }) //nolint:gosec // Test file path from os.CreateTemp, not user input.
n, err := f.WriteString(content)
if err != nil {
t.Fatal(err)
}
if n != len(content) {
t.Fatal("failed to write the complete content")
}
return f.Name()
}
func NewDefaultRegistry(ctx context.Context, flags *pflag.FlagSet, withoutNetwork bool, opts []ketoctx.Option) (Registry, error) {
reg, ok := ctx.Value(RegistryContextKey).(Registry)
if ok {
return reg, nil
}
options := ketoctx.Options(opts...)
l := options.Logger()
if l == nil {
l = newLogger(ctx)
}
c := config.New(ctx, l, nil)
cp, err := config.NewProvider(ctx, flags, c)
if err != nil {
return nil, errors.Wrap(err, "unable to initialize config provider")
}
c.WithSource(options.Contextualizer().Config(ctx, cp))
r := &RegistryDefault{
c: c,
l: l,
tracerWrapper: options.TracerWrapper,
ctxer: options.Contextualizer(),
defaultConnectInterceptors: options.ConnectInterceptors(),
defaultHttpMiddlewares: options.HTTPMiddlewares(),
extraMigrations: options.ExtraMigrations(),
defaultMigrationOptions: options.MigrationOptions(),
healthReadyCheckers: options.ReadyCheckers(),
dbOpts: options.DBOptionsModifiers(),
}
init := r.Init
if withoutNetwork {
init = r.InitWithoutNetworkID
}
if err := init(ctx); err != nil {
return nil, errors.Wrap(err, "unable to initialize service registry")
}
if inspect := options.Inspect(); inspect != nil {
if err := inspect(r.Persister().Connection(ctx)); err != nil {
return nil, errors.Wrap(err, "inspect")
}
}
return r, nil
}
func NewSqliteTestRegistry(t testing.TB, opts ...TestRegistryOption) *RegistryDefault {
return NewTestRegistry(t, dbx.GetSqlite(t), opts...)
}
type TestRegistryOption func(t testing.TB, r *RegistryDefault)
func WithConfig(key string, value any) TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
require.NoError(t, r.c.Set(key, value))
}
}
func WithMapperNamespace(f func(ctx context.Context) uuid.UUID) TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
r.replaceMapperNs = f
}
}
// WithOrderedShardID switches shard ID generation to UUID v7 so that inserts
// are monotonically ordered and predictable in tests.
func WithOrderedShardID() TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
r.replaceShardID = func() uuid.UUID { return uuid.Must(uuid.NewV7()) }
}
}
func WithNamespaces(namespaces []*namespace.Namespace) TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
require.NoError(t, r.c.Set(config.KeyNamespaces, namespaces))
}
}
func WithOPL(opl string) TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
f := createFile(t, opl)
require.NoError(t, r.c.Set(config.KeyNamespaces+".location", "file://"+f))
}
}
func WithHandlerOptions(opts ...connect.HandlerOption) TestRegistryOption {
return func(_ testing.TB, r *RegistryDefault) {
r.defaultHandlerOptions = append(r.defaultHandlerOptions, opts...)
}
}
func WithTracer(tracer trace.Tracer) TestRegistryOption {
return func(_ testing.TB, r *RegistryDefault) {
r.tracer = new(otelx.Tracer).WithOTLP(tracer)
}
}
func WithLogLevel(level string) TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
require.NoError(t, r.c.Set("log.level", level))
}
}
type selfSignedCert struct {
once sync.Once
cert *tls.Certificate
err error
}
var sharedTestCert selfSignedCert
func (s *selfSignedCert) generate() {
s.once.Do(func() {
key, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
s.err = fmt.Errorf("could not create key: %v", err)
return
}
s.cert, err = tlsx.CreateSelfSignedTLSCertificate(key)
if err != nil {
s.err = fmt.Errorf("could not create TLS certificate: %v", err)
}
})
}
func WithSelfSignedTransportCredentials() TestRegistryOption {
return func(t testing.TB, r *RegistryDefault) {
sharedTestCert.generate()
require.NoError(t, sharedTestCert.err)
r.grpcTransportCredentials = credentials.NewServerTLSFromCert(sharedTestCert.cert)
}
}
func NewTestRegistry(t testing.TB, dsn *dbx.DsnT, opts ...TestRegistryOption) *RegistryDefault {
l := logrusx.New("Ory Keto", "testing")
ctx, cancel := context.WithCancel(context.Background())
t.Cleanup(cancel)
ctx = configx.ContextWithConfigOptions(ctx, configx.WithValues(map[string]interface{}{
config.KeyDSN: dsn.Conn,
"log.level": "info",
config.KeyNamespaces: []*namespace.Namespace{},
config.KeySecretsPagination: []string{uuid.Must(uuid.NewV4()).String()},
}))
c, err := config.NewDefault(ctx, nil, l)
require.NoError(t, err)
r := &RegistryDefault{
c: c,
l: l,
ctxer: &contextx.Default{},
}
for _, opt := range opts {
opt(t, r)
}
if dsn.MigrateUp {
require.NoError(t, r.MigrateUp(ctx))
}
require.NoError(t, r.Init(ctx))
return r
}
func newLogger(ctx context.Context) *logrusx.Logger {
hook, ok := ctx.Value(LogrusHookContextKey).(logrus.Hook)
var opts []logrusx.Option
if ok {
opts = append(opts, logrusx.WithHook(hook))
}
return logrusx.New("Ory Keto", config.Version, opts...)
}

View File

@@ -0,0 +1,41 @@
// Copyright © 2023 Ory Corp
// SPDX-License-Identifier: Apache-2.0
package driver
import (
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"testing"
"github.com/ory/x/tlsx"
)
func BenchmarkCertificateGeneration(b *testing.B) {
cases := []struct {
name string
curve elliptic.Curve
}{
{"P256", elliptic.P256()},
{"P224", elliptic.P224()},
{"P384", elliptic.P384()},
{"P521", elliptic.P521()},
}
for _, tc := range cases {
tc := tc
b.Run(tc.name, func(b *testing.B) {
b.ResetTimer()
for i := 0; i < b.N; i++ {
key, err := ecdsa.GenerateKey(tc.curve, rand.Reader)
if err != nil {
b.Fatalf("could not create key: %v", err)
}
if _, err = tlsx.CreateSelfSignedTLSCertificate(key); err != nil {
b.Fatalf("could not create TLS certificate: %v", err)
}
}
})
}
}

View File

@@ -0,0 +1,84 @@
package driver
import (
"crypto/tls"
"crypto/x509"
"fmt"
"net"
"net/http"
"net/http/httptest"
"os"
"testing"
"time"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"google.golang.org/grpc/credentials"
"github.com/ory/keto/internal/driver/config"
)
type GetAddr = func(t testing.TB, endpoint string) (host string, port string, fullAddr string)
func UseDynamicPorts(t testing.TB, r Registry) GetAddr {
t.Helper()
listenDir := t.TempDir()
readListenFile := fmt.Sprintf("%s/read.addr", listenDir)
writeListenFile := fmt.Sprintf("%s/write.addr", listenDir)
metricsListenFile := fmt.Sprintf("%s/metrics.addr", listenDir)
oplListenFile := fmt.Sprintf("%s/opl.addr", listenDir)
require.NoError(t, r.Config(t.Context()).Set(config.KeyReadAPIPort, 0))
require.NoError(t, r.Config(t.Context()).Set(config.KeyReadAPIListenFile, "file://"+readListenFile))
require.NoError(t, r.Config(t.Context()).Set(config.KeyWriteAPIPort, 0))
require.NoError(t, r.Config(t.Context()).Set(config.KeyWriteAPIListenFile, "file://"+writeListenFile))
require.NoError(t, r.Config(t.Context()).Set(config.KeyMetricsPort, 0))
require.NoError(t, r.Config(t.Context()).Set(config.KeyMetricsListenFile, "file://"+metricsListenFile))
require.NoError(t, r.Config(t.Context()).Set(config.KeyOPLSyntaxAPIPort, 0))
require.NoError(t, r.Config(t.Context()).Set(config.KeyOPLSyntaxListenFile, "file://"+oplListenFile))
return func(t testing.TB, endpoint string) (string, string, string) {
fp := ""
switch endpoint {
case "read":
fp = readListenFile
case "write":
fp = writeListenFile
case "metrics":
fp = metricsListenFile
case "opl":
fp = oplListenFile
default:
t.Fatalf("unknown endpoint: %q", endpoint)
}
var addr []byte
var host, port string
require.EventuallyWithT(t, func(t *assert.CollectT) {
var err error
addr, err = os.ReadFile(fp)
require.NotEmpty(t, addr)
require.NoError(t, err)
host, port, err = net.SplitHostPort(string(addr))
require.NoError(t, err)
require.NotEmpty(t, host)
require.NotEmpty(t, port)
}, 2*time.Second, 10*time.Millisecond)
return host, port, string(addr)
}
}
func HTTP2TestServer(t testing.TB, handler http.Handler) (*httptest.Server, credentials.TransportCredentials) {
ts := httptest.NewUnstartedServer(handler)
ts.EnableHTTP2 = true
ts.StartTLS()
ts.Client()
t.Cleanup(ts.Close)
certPool := x509.NewCertPool()
certPool.AddCert(ts.Certificate())
return ts, credentials.NewTLS(&tls.Config{RootCAs: certPool})
}