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
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:
10
internal/driver/config/buildinfo.go
Normal file
10
internal/driver/config/buildinfo.go
Normal file
@@ -0,0 +1,10 @@
|
||||
// Copyright © 2023 Ory Corp
|
||||
// SPDX-License-Identifier: Apache-2.0
|
||||
|
||||
package config
|
||||
|
||||
var (
|
||||
Version = "master"
|
||||
Date = "undefined"
|
||||
Commit = "undefined"
|
||||
)
|
||||
87
internal/driver/config/namespace_memory.go
Normal file
87
internal/driver/config/namespace_memory.go
Normal 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
|
||||
}
|
||||
}
|
||||
25
internal/driver/config/namespace_memory_test.go
Normal file
25
internal/driver/config/namespace_memory_test.go
Normal 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"))
|
||||
})
|
||||
}
|
||||
295
internal/driver/config/namespace_watcher.go
Normal file
295
internal/driver/config/namespace_watcher.go
Normal 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()
|
||||
}
|
||||
}
|
||||
250
internal/driver/config/namespace_watcher_test.go
Normal file
250
internal/driver/config/namespace_watcher_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
193
internal/driver/config/opl_cache_test.go
Normal file
193
internal/driver/config/opl_cache_test.go
Normal 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) }
|
||||
181
internal/driver/config/opl_config_namespace_watcher.go
Normal file
181
internal/driver/config/opl_config_namespace_watcher.go
Normal 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
|
||||
}
|
||||
397
internal/driver/config/provider.go
Normal file
397
internal/driver/config/provider.go
Normal 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
|
||||
}
|
||||
302
internal/driver/config/provider_test.go
Normal file
302
internal/driver/config/provider_test.go
Normal 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
570
internal/driver/daemon.go
Normal 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
|
||||
}
|
||||
182
internal/driver/daemon_test.go
Normal file
182
internal/driver/daemon_test.go
Normal 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
|
||||
}
|
||||
40
internal/driver/logrus_interceptor.go
Normal file
40
internal/driver/logrus_interceptor.go
Normal 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
84
internal/driver/pop_connection.go
Normal file
84
internal/driver/pop_connection.go
Normal 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
|
||||
}
|
||||
63
internal/driver/registry.go
Normal file
63
internal/driver/registry.go
Normal 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"
|
||||
)
|
||||
392
internal/driver/registry_default.go
Normal file
392
internal/driver/registry_default.go
Normal 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
|
||||
}
|
||||
242
internal/driver/registry_factory.go
Normal file
242
internal/driver/registry_factory.go
Normal 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...)
|
||||
}
|
||||
41
internal/driver/selfsigned_cert_bench_test.go
Normal file
41
internal/driver/selfsigned_cert_bench_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
84
internal/driver/testhelpers.go
Normal file
84
internal/driver/testhelpers.go
Normal 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})
|
||||
}
|
||||
Reference in New Issue
Block a user