diff --git a/app/version.go b/app/version.go index 179d84c4..d07d3e59 100644 --- a/app/version.go +++ b/app/version.go @@ -29,7 +29,7 @@ func (v versionInfo) MinorString() string { // Version of the app var Version = versionInfo{ Major: 16, - Minor: 15, + Minor: 16, Patch: 0, } diff --git a/cluster/docs/ClusterAPI_docs.go b/cluster/docs/ClusterAPI_docs.go index 38376ca2..04eb41dc 100644 --- a/cluster/docs/ClusterAPI_docs.go +++ b/cluster/docs/ClusterAPI_docs.go @@ -2003,11 +2003,17 @@ const docTemplateClusterAPI = `{ "auth": { "$ref": "#/definitions/identity.UserAuth" }, + "created_at": { + "type": "string" + }, "name": { "type": "string" }, "superuser": { "type": "boolean" + }, + "updated_at": { + "type": "string" } } }, diff --git a/cluster/docs/ClusterAPI_swagger.json b/cluster/docs/ClusterAPI_swagger.json index 9a86fdb5..a1701feb 100644 --- a/cluster/docs/ClusterAPI_swagger.json +++ b/cluster/docs/ClusterAPI_swagger.json @@ -1995,11 +1995,17 @@ "auth": { "$ref": "#/definitions/identity.UserAuth" }, + "created_at": { + "type": "string" + }, "name": { "type": "string" }, "superuser": { "type": "boolean" + }, + "updated_at": { + "type": "string" } } }, diff --git a/cluster/docs/ClusterAPI_swagger.yaml b/cluster/docs/ClusterAPI_swagger.yaml index 5a23830d..2b1342fe 100644 --- a/cluster/docs/ClusterAPI_swagger.yaml +++ b/cluster/docs/ClusterAPI_swagger.yaml @@ -587,10 +587,14 @@ definitions: type: string auth: $ref: '#/definitions/identity.UserAuth' + created_at: + type: string name: type: string superuser: type: boolean + updated_at: + type: string type: object identity.UserAuth: properties: diff --git a/cluster/store/identity.go b/cluster/store/identity.go new file mode 100644 index 00000000..17498745 --- /dev/null +++ b/cluster/store/identity.go @@ -0,0 +1,127 @@ +package store + +import ( + "fmt" + "time" +) + +func (s *store) addIdentity(cmd CommandAddIdentity) error { + s.lock.Lock() + defer s.lock.Unlock() + + err := s.data.Users.userlist.Add(cmd.Identity) + if err != nil { + return fmt.Errorf("the identity with the name '%s' already exists", cmd.Identity.Name) + } + + now := time.Now() + + s.data.Users.UpdatedAt = now + + cmd.Identity.CreatedAt = now + cmd.Identity.UpdatedAt = now + s.data.Users.Users[cmd.Identity.Name] = cmd.Identity + + return nil +} + +func (s *store) updateIdentity(cmd CommandUpdateIdentity) error { + s.lock.Lock() + defer s.lock.Unlock() + + if cmd.Name == "$anon" { + return fmt.Errorf("the identity with the name '%s' can't be updated", cmd.Name) + } + + oldUser, err := s.data.Users.userlist.Get(cmd.Name) + if err != nil { + return fmt.Errorf("the identity with the name '%s' doesn't exist", cmd.Name) + } + + o, ok := s.data.Users.Users[oldUser.Name] + if !ok { + return fmt.Errorf("the identity with the name '%s' doesn't exist", cmd.Name) + } + + err = s.data.Users.userlist.Update(cmd.Name, cmd.Identity) + if err != nil { + return err + } + + user, err := s.data.Users.userlist.Get(cmd.Identity.Name) + if err != nil { + return fmt.Errorf("the identity with the name '%s' doesn't exist", cmd.Identity.Name) + } + + now := time.Now() + + user.CreatedAt = o.CreatedAt + user.UpdatedAt = now + + s.data.Users.UpdatedAt = now + delete(s.data.Users.Users, oldUser.Name) + s.data.Users.Users[user.Name] = user + + s.data.Policies.UpdatedAt = now + policies := s.data.Policies.Policies[oldUser.Name] + delete(s.data.Policies.Policies, oldUser.Name) + s.data.Policies.Policies[user.Name] = policies + + return nil +} + +func (s *store) removeIdentity(cmd CommandRemoveIdentity) error { + s.lock.Lock() + defer s.lock.Unlock() + + user, err := s.data.Users.userlist.Get(cmd.Name) + if err != nil { + return nil + } + + s.data.Users.userlist.Delete(user.Name) + + delete(s.data.Users.Users, user.Name) + s.data.Users.UpdatedAt = time.Now() + delete(s.data.Policies.Policies, user.Name) + s.data.Policies.UpdatedAt = time.Now() + + return nil +} + +func (s *store) ListUsers() Users { + s.lock.RLock() + defer s.lock.RUnlock() + + u := Users{ + UpdatedAt: s.data.Users.UpdatedAt, + } + + for _, user := range s.data.Users.Users { + u.Users = append(u.Users, user) + } + + return u +} + +func (s *store) GetUser(name string) Users { + s.lock.RLock() + defer s.lock.RUnlock() + + u := Users{ + UpdatedAt: s.data.Users.UpdatedAt, + } + + user, err := s.data.Users.userlist.Get(name) + if err != nil { + return u + } + + u.UpdatedAt = user.UpdatedAt + + if user, ok := s.data.Users.Users[user.Name]; ok { + u.Users = append(u.Users, user) + } + + return u +} diff --git a/cluster/store/identity_test.go b/cluster/store/identity_test.go new file mode 100644 index 00000000..e3d82061 --- /dev/null +++ b/cluster/store/identity_test.go @@ -0,0 +1,445 @@ +package store + +import ( + "testing" + "time" + + "github.com/datarhei/core/v16/iam/access" + "github.com/datarhei/core/v16/iam/identity" + "github.com/stretchr/testify/require" +) + +func TestAddIdentityCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + identity := identity.User{ + Name: "foobar", + } + + err = s.applyCommand(Command{ + Operation: OpAddIdentity, + Data: CommandAddIdentity{ + Identity: identity, + }, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) +} + +func TestAddIdentity(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + idty := identity.User{ + Name: "foobar", + } + + err = s.addIdentity(CommandAddIdentity{ + Identity: idty, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + require.Equal(t, 0, len(s.data.Policies.Policies)) + + err = s.addIdentity(CommandAddIdentity{ + Identity: idty, + }) + require.Error(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + require.Equal(t, 0, len(s.data.Policies.Policies)) + + u := s.GetUser("foobar") + require.Equal(t, 1, len(u.Users)) + + user := u.Users[0] + + require.Equal(t, user.CreatedAt, user.UpdatedAt) + require.NotEqual(t, time.Time{}, user.CreatedAt) +} + +func TestAddIdentityWithAlias(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + idty := identity.User{ + Name: "foobar", + } + + err = s.addIdentity(CommandAddIdentity{ + Identity: idty, + }) + require.NoError(t, err) + + idty = identity.User{ + Name: "foobaz", + Alias: "foobar", + } + + err = s.addIdentity(CommandAddIdentity{ + Identity: idty, + }) + require.Error(t, err) + + idty = identity.User{ + Name: "foobaz", + Alias: "foobaz", + } + + err = s.addIdentity(CommandAddIdentity{ + Identity: idty, + }) + require.NoError(t, err) + + idty = identity.User{ + Name: "barfoo", + Alias: "foobaz", + } + + err = s.addIdentity(CommandAddIdentity{ + Identity: idty, + }) + require.Error(t, err) +} + +func TestRemoveIdentityCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + identity := identity.User{ + Name: "foobar", + } + + err = s.applyCommand(Command{ + Operation: OpAddIdentity, + Data: CommandAddIdentity{ + Identity: identity, + }, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + + err = s.applyCommand(Command{ + Operation: OpRemoveIdentity, + Data: CommandRemoveIdentity{ + Name: "foobar", + }, + }) + require.NoError(t, err) + require.Equal(t, 0, len(s.data.Users.Users)) +} + +func TestRemoveIdentity(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + identity := identity.User{ + Name: "foobar", + } + + err = s.addIdentity(CommandAddIdentity{ + Identity: identity, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + require.Equal(t, 0, len(s.data.Policies.Policies)) + + err = s.removeIdentity(CommandRemoveIdentity{ + Name: "foobar", + }) + require.NoError(t, err) + require.Equal(t, 0, len(s.data.Users.Users)) + require.Equal(t, 0, len(s.data.Policies.Policies)) + + err = s.removeIdentity(CommandRemoveIdentity{ + Name: "foobar", + }) + require.NoError(t, err) + require.Equal(t, 0, len(s.data.Users.Users)) + require.Equal(t, 0, len(s.data.Policies.Policies)) +} + +func TestRemoveIdentityWithAlias(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + idty := identity.User{ + Name: "foobar", + Alias: "foobaz", + } + + err = s.addIdentity(CommandAddIdentity{ + Identity: idty, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + + err = s.removeIdentity(CommandRemoveIdentity{ + Name: "foobaz", + }) + require.NoError(t, err) + require.Equal(t, 0, len(s.data.Users.Users)) + + err = s.removeIdentity(CommandRemoveIdentity{ + Name: "foobar", + }) + require.NoError(t, err) + require.Equal(t, 0, len(s.data.Users.Users)) +} + +func TestUpdateUserCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + idty1 := identity.User{ + Name: "foobar1", + } + + idty2 := identity.User{ + Name: "foobar2", + } + + err = s.applyCommand(Command{ + Operation: OpAddIdentity, + Data: CommandAddIdentity{ + Identity: idty1, + }, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + + err = s.applyCommand(Command{ + Operation: OpAddIdentity, + Data: CommandAddIdentity{ + Identity: idty2, + }, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Users.Users)) + + err = s.applyCommand(Command{ + Operation: OpUpdateIdentity, + Data: CommandUpdateIdentity{ + Name: "foobar1", + Identity: identity.User{ + Name: "foobar3", + }, + }, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Users.Users)) +} + +func TestUpdateIdentity(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + idty1 := identity.User{ + Name: "foobar1", + } + + idty2 := identity.User{ + Name: "foobar2", + } + + err = s.addIdentity(CommandAddIdentity{ + Identity: idty1, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + + err = s.addIdentity(CommandAddIdentity{ + Identity: idty2, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Users.Users)) + + foobar := s.GetUser("foobar1").Users[0] + require.True(t, foobar.CreatedAt.Equal(foobar.UpdatedAt)) + require.False(t, time.Time{}.Equal(foobar.CreatedAt)) + + idty := identity.User{ + Name: "foobaz", + } + + err = s.updateIdentity(CommandUpdateIdentity{ + Name: "foobar", + Identity: idty, + }) + require.Error(t, err) + require.Equal(t, 2, len(s.data.Users.Users)) + + idty.Name = "foobar2" + + err = s.updateIdentity(CommandUpdateIdentity{ + Name: "foobar1", + Identity: idty, + }) + require.Error(t, err) + require.Equal(t, 2, len(s.data.Users.Users)) + + idty.Name = "foobaz" + + err = s.updateIdentity(CommandUpdateIdentity{ + Name: "foobar1", + Identity: idty, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Users.Users)) + + u := s.GetUser("foobar1") + require.Empty(t, u.Users) + + u = s.GetUser("foobar2") + require.NotEmpty(t, u.Users) + + u = s.GetUser("foobaz") + require.NotEmpty(t, u.Users) + + require.True(t, u.Users[0].CreatedAt.Equal(foobar.CreatedAt)) + require.False(t, u.Users[0].UpdatedAt.Equal(foobar.UpdatedAt)) + require.False(t, time.Time{}.Equal(foobar.CreatedAt)) +} + +func TestUpdateIdentityWithAlias(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + idty1 := identity.User{ + Name: "foobar1", + Alias: "fooalias1", + } + + idty2 := identity.User{ + Name: "foobar2", + Alias: "fooalias2", + } + + err = s.addIdentity(CommandAddIdentity{ + Identity: idty1, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + + err = s.addIdentity(CommandAddIdentity{ + Identity: idty2, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Users.Users)) + + idty := identity.User{ + Name: "foobaz", + } + + err = s.updateIdentity(CommandUpdateIdentity{ + Name: "foobar", + Identity: idty, + }) + require.Error(t, err) + require.Equal(t, 2, len(s.data.Users.Users)) + + idty.Name = "foobar2" + + err = s.updateIdentity(CommandUpdateIdentity{ + Name: "foobar1", + Identity: idty, + }) + require.Error(t, err) + require.Equal(t, 2, len(s.data.Users.Users)) + + idty.Name = "foobaz" + idty.Alias = "fooalias2" + + err = s.updateIdentity(CommandUpdateIdentity{ + Name: "foobar1", + Identity: idty, + }) + require.Error(t, err) + require.Equal(t, 2, len(s.data.Users.Users)) + + idty.Name = "foobaz" + idty.Alias = "fooalias" + + err = s.updateIdentity(CommandUpdateIdentity{ + Name: "fooalias1", + Identity: idty, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Users.Users)) + + u := s.GetUser("foobar1") + require.Empty(t, u.Users) + + u = s.GetUser("fooalias1") + require.Empty(t, u.Users) + + u = s.GetUser("foobar2") + require.NotEmpty(t, u.Users) + + u = s.GetUser("fooalias2") + require.NotEmpty(t, u.Users) + + u = s.GetUser("foobaz") + require.NotEmpty(t, u.Users) + + u = s.GetUser("fooalias") + require.NotEmpty(t, u.Users) +} + +func TestUpdateIdentityWithPolicies(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + idty1 := identity.User{ + Name: "foobar", + } + + policies := []access.Policy{ + { + Name: "bla", + Domain: "bla", + Resource: "bla", + Actions: []string{}, + }, + { + Name: "foo", + Domain: "foo", + Resource: "foo", + Actions: []string{}, + }, + } + + err = s.addIdentity(CommandAddIdentity{ + Identity: idty1, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + + err = s.setPolicies(CommandSetPolicies{ + Name: "foobar", + Policies: policies, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Policies.Policies["foobar"])) + + err = s.updateIdentity(CommandUpdateIdentity{ + Name: "foobar", + Identity: idty1, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + require.Equal(t, 2, len(s.data.Policies.Policies["foobar"])) + + idty2 := identity.User{ + Name: "foobaz", + } + + err = s.updateIdentity(CommandUpdateIdentity{ + Name: "foobar", + Identity: idty2, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + require.Equal(t, 0, len(s.data.Policies.Policies["foobar"])) + require.Equal(t, 2, len(s.data.Policies.Policies["foobaz"])) +} diff --git a/cluster/store/kvs.go b/cluster/store/kvs.go new file mode 100644 index 00000000..37bcb682 --- /dev/null +++ b/cluster/store/kvs.go @@ -0,0 +1,63 @@ +package store + +import ( + "io/fs" + "strings" + "time" +) + +func (s *store) setKV(cmd CommandSetKV) error { + s.lock.Lock() + defer s.lock.Unlock() + + value := s.data.KVS[cmd.Key] + + value.Value = cmd.Value + value.UpdatedAt = time.Now() + + s.data.KVS[cmd.Key] = value + + return nil +} + +func (s *store) unsetKV(cmd CommandUnsetKV) error { + s.lock.Lock() + defer s.lock.Unlock() + + if _, ok := s.data.KVS[cmd.Key]; !ok { + return fs.ErrNotExist + } + + delete(s.data.KVS, cmd.Key) + + return nil +} + +func (s *store) ListKVS(prefix string) map[string]Value { + s.lock.RLock() + defer s.lock.RUnlock() + + m := map[string]Value{} + + for key, value := range s.data.KVS { + if !strings.HasPrefix(key, prefix) { + continue + } + + m[key] = value + } + + return m +} + +func (s *store) GetFromKVS(key string) (Value, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + value, ok := s.data.KVS[key] + if !ok { + return Value{}, fs.ErrNotExist + } + + return value, nil +} diff --git a/cluster/store/kvs_test.go b/cluster/store/kvs_test.go new file mode 100644 index 00000000..1b560d4c --- /dev/null +++ b/cluster/store/kvs_test.go @@ -0,0 +1,113 @@ +package store + +import ( + "io/fs" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestSetKVCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + err = s.applyCommand(Command{ + Operation: OpSetKV, + Data: CommandSetKV{ + Key: "foo", + Value: "bar", + }, + }) + require.NoError(t, err) + + _, ok := s.data.KVS["foo"] + require.True(t, ok) +} + +func TestSetKV(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + err = s.setKV(CommandSetKV{ + Key: "foo", + Value: "bar", + }) + require.NoError(t, err) + + value, err := s.GetFromKVS("foo") + require.NoError(t, err) + require.Equal(t, "bar", value.Value) + + updatedAt := value.UpdatedAt + + err = s.setKV(CommandSetKV{ + Key: "foo", + Value: "baz", + }) + require.NoError(t, err) + + value, err = s.GetFromKVS("foo") + require.NoError(t, err) + require.Equal(t, "baz", value.Value) + require.Greater(t, value.UpdatedAt, updatedAt) +} + +func TestUnsetKVCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + err = s.applyCommand(Command{ + Operation: OpSetKV, + Data: CommandSetKV{ + Key: "foo", + Value: "bar", + }, + }) + require.NoError(t, err) + + _, ok := s.data.KVS["foo"] + require.True(t, ok) + + err = s.applyCommand(Command{ + Operation: OpUnsetKV, + Data: CommandUnsetKV{ + Key: "foo", + }, + }) + require.NoError(t, err) + + _, ok = s.data.KVS["foo"] + require.False(t, ok) + + err = s.applyCommand(Command{ + Operation: OpUnsetKV, + Data: CommandUnsetKV{ + Key: "foo", + }, + }) + require.Error(t, err) + require.Equal(t, fs.ErrNotExist, err) +} + +func TestUnsetKV(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + err = s.setKV(CommandSetKV{ + Key: "foo", + Value: "bar", + }) + require.NoError(t, err) + + _, err = s.GetFromKVS("foo") + require.NoError(t, err) + + err = s.unsetKV(CommandUnsetKV{ + Key: "foo", + }) + require.NoError(t, err) + + _, err = s.GetFromKVS("foo") + require.Error(t, err) + require.Equal(t, fs.ErrNotExist, err) +} diff --git a/cluster/store/lock.go b/cluster/store/lock.go new file mode 100644 index 00000000..72101767 --- /dev/null +++ b/cluster/store/lock.go @@ -0,0 +1,74 @@ +package store + +import ( + "fmt" + "time" +) + +func (s *store) createLock(cmd CommandCreateLock) error { + s.lock.Lock() + defer s.lock.Unlock() + + validUntil, ok := s.data.Locks[cmd.Name] + + if ok { + if time.Now().Before(validUntil) { + return fmt.Errorf("the lock with the ID '%s' already exists", cmd.Name) + } + } + + s.data.Locks[cmd.Name] = cmd.ValidUntil + + return nil +} + +func (s *store) deleteLock(cmd CommandDeleteLock) error { + s.lock.Lock() + defer s.lock.Unlock() + + if _, ok := s.data.Locks[cmd.Name]; !ok { + return nil + } + + delete(s.data.Locks, cmd.Name) + + return nil +} + +func (s *store) clearLocks(cmd CommandClearLocks) error { + s.lock.Lock() + defer s.lock.Unlock() + + for name, validUntil := range s.data.Locks { + if time.Now().Before(validUntil) { + // Lock is still valid + continue + } + + delete(s.data.Locks, name) + } + + return nil +} + +func (s *store) HasLock(name string) bool { + s.lock.RLock() + defer s.lock.RUnlock() + + _, ok := s.data.Locks[name] + + return ok +} + +func (s *store) ListLocks() map[string]time.Time { + s.lock.RLock() + defer s.lock.RUnlock() + + m := map[string]time.Time{} + + for key, value := range s.data.Locks { + m[key] = value + } + + return m +} diff --git a/cluster/store/lock_test.go b/cluster/store/lock_test.go new file mode 100644 index 00000000..b1c7aa3b --- /dev/null +++ b/cluster/store/lock_test.go @@ -0,0 +1,167 @@ +package store + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestCreateLockCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + err = s.applyCommand(Command{ + Operation: OpCreateLock, + Data: CommandCreateLock{ + Name: "foobar", + ValidUntil: time.Now().Add(3 * time.Second), + }, + }) + require.NoError(t, err) + + _, ok := s.data.Locks["foobar"] + require.True(t, ok) +} + +func TestCreateLock(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + cmd := CommandCreateLock{ + Name: "foobar", + ValidUntil: time.Now().Add(3 * time.Second), + } + + err = s.createLock(cmd) + require.NoError(t, err) + + err = s.createLock(cmd) + require.Error(t, err) + + require.Eventually(t, func() bool { + err = s.createLock(cmd) + return err == nil + }, 5*time.Second, time.Second) +} + +func TestDeleteLockCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + err = s.applyCommand(Command{ + Operation: OpCreateLock, + Data: CommandCreateLock{ + Name: "foobar", + ValidUntil: time.Now().Add(10 * time.Second), + }, + }) + require.NoError(t, err) + + _, ok := s.data.Locks["foobar"] + require.True(t, ok) + + err = s.applyCommand(Command{ + Operation: OpDeleteLock, + Data: CommandDeleteLock{ + Name: "foobar", + }, + }) + require.NoError(t, err) + + _, ok = s.data.Locks["foobar"] + require.False(t, ok) +} + +func TestDeleteLock(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + err = s.deleteLock(CommandDeleteLock{ + Name: "foobar", + }) + require.NoError(t, err) + + cmd := CommandCreateLock{ + Name: "foobar", + ValidUntil: time.Now().Add(10 * time.Second), + } + + err = s.createLock(cmd) + require.NoError(t, err) + + err = s.createLock(cmd) + require.Error(t, err) + + err = s.deleteLock(CommandDeleteLock{ + Name: "foobar", + }) + require.NoError(t, err) + + err = s.createLock(cmd) + require.NoError(t, err) +} + +func TestClearLocksCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + err = s.applyCommand(Command{ + Operation: OpCreateLock, + Data: CommandCreateLock{ + Name: "foobar", + ValidUntil: time.Now().Add(3 * time.Second), + }, + }) + require.NoError(t, err) + + _, ok := s.data.Locks["foobar"] + require.True(t, ok) + + err = s.applyCommand(Command{ + Operation: OpClearLocks, + Data: CommandClearLocks{}, + }) + require.NoError(t, err) + + _, ok = s.data.Locks["foobar"] + require.True(t, ok) + + time.Sleep(3 * time.Second) + + err = s.applyCommand(Command{ + Operation: OpClearLocks, + Data: CommandClearLocks{}, + }) + require.NoError(t, err) + + _, ok = s.data.Locks["foobar"] + require.False(t, ok) +} + +func TestClearLocks(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + cmd := CommandCreateLock{ + Name: "foobar", + ValidUntil: time.Now().Add(3 * time.Second), + } + + err = s.createLock(cmd) + require.NoError(t, err) + + err = s.clearLocks(CommandClearLocks{}) + require.NoError(t, err) + + err = s.createLock(cmd) + require.Error(t, err) + + time.Sleep(3 * time.Second) + + err = s.clearLocks(CommandClearLocks{}) + require.NoError(t, err) + + err = s.createLock(cmd) + require.NoError(t, err) +} diff --git a/cluster/store/policy.go b/cluster/store/policy.go new file mode 100644 index 00000000..c809c1c0 --- /dev/null +++ b/cluster/store/policy.go @@ -0,0 +1,77 @@ +package store + +import ( + "fmt" + "time" +) + +func (s *store) setPolicies(cmd CommandSetPolicies) error { + s.lock.Lock() + defer s.lock.Unlock() + + now := time.Now() + + if cmd.Name != "$anon" { + user, err := s.data.Users.userlist.Get(cmd.Name) + if err != nil { + return fmt.Errorf("the identity with the name '%s' doesn't exist", cmd.Name) + } + + u, ok := s.data.Users.Users[user.Name] + if !ok { + return fmt.Errorf("the identity with the name '%s' doesn't exist", cmd.Name) + } + + u.UpdatedAt = now + s.data.Users.Users[user.Name] = u + } + + for i, p := range cmd.Policies { + if len(p.Domain) != 0 { + continue + } + + p.Domain = "$none" + cmd.Policies[i] = p + } + + delete(s.data.Policies.Policies, cmd.Name) + s.data.Policies.Policies[cmd.Name] = cmd.Policies + s.data.Policies.UpdatedAt = now + + return nil +} + +func (s *store) ListPolicies() Policies { + s.lock.RLock() + defer s.lock.RUnlock() + + p := Policies{ + UpdatedAt: s.data.Policies.UpdatedAt, + } + + for _, policies := range s.data.Policies.Policies { + p.Policies = append(p.Policies, policies...) + } + + return p +} + +func (s *store) ListUserPolicies(name string) Policies { + s.lock.RLock() + defer s.lock.RUnlock() + + p := Policies{ + UpdatedAt: s.data.Policies.UpdatedAt, + } + + user, err := s.data.Users.userlist.Get(name) + if err != nil { + return p + } + + p.UpdatedAt = user.UpdatedAt + p.Policies = append(p.Policies, s.data.Policies.Policies[user.Name]...) + + return p +} diff --git a/cluster/store/policy_test.go b/cluster/store/policy_test.go new file mode 100644 index 00000000..5c0dd3a7 --- /dev/null +++ b/cluster/store/policy_test.go @@ -0,0 +1,108 @@ +package store + +import ( + "testing" + + "github.com/datarhei/core/v16/iam/access" + "github.com/datarhei/core/v16/iam/identity" + "github.com/stretchr/testify/require" +) + +func TestSetPoliciesCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + identity := identity.User{ + Name: "foobar", + } + + err = s.applyCommand(Command{ + Operation: OpAddIdentity, + Data: CommandAddIdentity{ + Identity: identity, + }, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + require.Equal(t, 0, len(s.data.Policies.Policies)) + + err = s.applyCommand(Command{ + Operation: OpSetPolicies, + Data: CommandSetPolicies{ + Name: "foobar", + Policies: []access.Policy{ + { + Name: "bla", + Domain: "bla", + Resource: "bla", + Actions: []string{}, + }, + { + Name: "foo", + Domain: "foo", + Resource: "foo", + Actions: []string{}, + }, + }, + }, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + require.Equal(t, 2, len(s.data.Policies.Policies["foobar"])) +} + +func TestSetPolicies(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + identity := identity.User{ + Name: "foobar", + } + + policies := []access.Policy{ + { + Name: "bla", + Domain: "bla", + Resource: "bla", + Actions: []string{}, + }, + { + Name: "foo", + Domain: "foo", + Resource: "foo", + Actions: []string{}, + }, + } + + err = s.setPolicies(CommandSetPolicies{ + Name: "foobar", + Policies: policies, + }) + require.Error(t, err) + require.Equal(t, 0, len(s.data.Policies.Policies["foobar"])) + + err = s.addIdentity(CommandAddIdentity{ + Identity: identity, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + require.Equal(t, 0, len(s.data.Policies.Policies)) + + users := s.GetUser("foobar") + require.NotEmpty(t, users.Users) + + updatedAt := users.Users[0].UpdatedAt + + err = s.setPolicies(CommandSetPolicies{ + Name: "foobar", + Policies: policies, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Users.Users)) + require.Equal(t, 2, len(s.data.Policies.Policies["foobar"])) + + users = s.GetUser("foobar") + require.NotEmpty(t, users.Users) + + require.False(t, updatedAt.Equal(users.Users[0].UpdatedAt)) +} diff --git a/cluster/store/process.go b/cluster/store/process.go new file mode 100644 index 00000000..8707bee6 --- /dev/null +++ b/cluster/store/process.go @@ -0,0 +1,228 @@ +package store + +import ( + "fmt" + "time" + + "github.com/datarhei/core/v16/restream/app" +) + +func (s *store) addProcess(cmd CommandAddProcess) error { + s.lock.Lock() + defer s.lock.Unlock() + + id := cmd.Config.ProcessID().String() + + if cmd.Config.LimitCPU <= 0 || cmd.Config.LimitMemory <= 0 { + return fmt.Errorf("the process with the ID '%s' must have limits defined", id) + } + + _, ok := s.data.Process[id] + if ok { + return fmt.Errorf("the process with the ID '%s' already exists", id) + } + + order := "stop" + if cmd.Config.Autostart { + order = "start" + cmd.Config.Autostart = false + } + + now := time.Now() + s.data.Process[id] = Process{ + CreatedAt: now, + UpdatedAt: now, + Config: cmd.Config, + Order: order, + Metadata: map[string]interface{}{}, + } + + return nil +} + +func (s *store) removeProcess(cmd CommandRemoveProcess) error { + s.lock.Lock() + defer s.lock.Unlock() + + id := cmd.ID.String() + + _, ok := s.data.Process[id] + if !ok { + return fmt.Errorf("the process with the ID '%s' doesn't exist", id) + } + + delete(s.data.Process, id) + + return nil +} + +func (s *store) updateProcess(cmd CommandUpdateProcess) error { + s.lock.Lock() + defer s.lock.Unlock() + + srcid := cmd.ID.String() + dstid := cmd.Config.ProcessID().String() + + if cmd.Config.LimitCPU <= 0 || cmd.Config.LimitMemory <= 0 { + return fmt.Errorf("the process with the ID '%s' must have limits defined", dstid) + } + + p, ok := s.data.Process[srcid] + if !ok { + return fmt.Errorf("the process with the ID '%s' doesn't exists", srcid) + } + + if p.Config.Equal(cmd.Config) { + return nil + } + + if srcid == dstid { + p.UpdatedAt = time.Now() + p.Config = cmd.Config + + s.data.Process[srcid] = p + + return nil + } + + _, ok = s.data.Process[dstid] + if ok { + return fmt.Errorf("the process with the ID '%s' already exists", dstid) + } + + now := time.Now() + + p.CreatedAt = now + p.UpdatedAt = now + p.Config = cmd.Config + + delete(s.data.Process, srcid) + s.data.Process[dstid] = p + + return nil +} + +func (s *store) setProcessOrder(cmd CommandSetProcessOrder) error { + s.lock.Lock() + defer s.lock.Unlock() + + id := cmd.ID.String() + + p, ok := s.data.Process[id] + if !ok { + return fmt.Errorf("the process with the ID '%s' doesn't exists", cmd.ID) + } + + p.Order = cmd.Order + p.UpdatedAt = time.Now() + + s.data.Process[id] = p + + return nil +} + +func (s *store) setProcessMetadata(cmd CommandSetProcessMetadata) error { + s.lock.Lock() + defer s.lock.Unlock() + + id := cmd.ID.String() + + p, ok := s.data.Process[id] + if !ok { + return fmt.Errorf("the process with the ID '%s' doesn't exists", cmd.ID) + } + + if p.Metadata == nil { + p.Metadata = map[string]interface{}{} + } + + if cmd.Data == nil { + delete(p.Metadata, cmd.Key) + } else { + p.Metadata[cmd.Key] = cmd.Data + } + p.UpdatedAt = time.Now() + + s.data.Process[id] = p + + return nil +} + +func (s *store) setProcessError(cmd CommandSetProcessError) error { + s.lock.Lock() + defer s.lock.Unlock() + + id := cmd.ID.String() + + p, ok := s.data.Process[id] + if !ok { + return fmt.Errorf("the process with the ID '%s' doesn't exists", cmd.ID) + } + + p.Error = cmd.Error + + s.data.Process[id] = p + + return nil +} + +func (s *store) setProcessNodeMap(cmd CommandSetProcessNodeMap) error { + s.lock.Lock() + defer s.lock.Unlock() + + s.data.ProcessNodeMap = cmd.Map + + return nil +} + +func (s *store) ListProcesses() []Process { + s.lock.RLock() + defer s.lock.RUnlock() + + processes := []Process{} + + for _, p := range s.data.Process { + processes = append(processes, Process{ + CreatedAt: p.CreatedAt, + UpdatedAt: p.UpdatedAt, + Config: p.Config.Clone(), + Order: p.Order, + Metadata: p.Metadata, + Error: p.Error, + }) + } + + return processes +} + +func (s *store) GetProcess(id app.ProcessID) (Process, error) { + s.lock.RLock() + defer s.lock.RUnlock() + + process, ok := s.data.Process[id.String()] + if !ok { + return Process{}, fmt.Errorf("not found") + } + + return Process{ + CreatedAt: process.CreatedAt, + UpdatedAt: process.UpdatedAt, + Config: process.Config.Clone(), + Order: process.Order, + Metadata: process.Metadata, + Error: process.Error, + }, nil +} + +func (s *store) GetProcessNodeMap() map[string]string { + s.lock.RLock() + defer s.lock.RUnlock() + + m := map[string]string{} + + for key, value := range s.data.ProcessNodeMap { + m[key] = value + } + + return m +} diff --git a/cluster/store/process_test.go b/cluster/store/process_test.go new file mode 100644 index 00000000..080707e3 --- /dev/null +++ b/cluster/store/process_test.go @@ -0,0 +1,647 @@ +package store + +import ( + "testing" + + "github.com/datarhei/core/v16/restream/app" + "github.com/stretchr/testify/require" +) + +func TestAddProcessCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + config := &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.applyCommand(Command{ + Operation: OpAddProcess, + Data: CommandAddProcess{ + Config: config, + }, + }) + require.NoError(t, err) + require.NotEmpty(t, s.data.Process) + + p, ok := s.data.Process["foobar@"] + require.True(t, ok) + + require.NotZero(t, p.CreatedAt) + require.NotZero(t, p.UpdatedAt) + require.NotNil(t, p.Config) + require.True(t, p.Config.Equal(config)) + require.NotNil(t, p.Metadata) +} + +func TestAddProcess(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + config := &app.Config{ + ID: "foobar", + } + + err = s.addProcess(CommandAddProcess{ + Config: config, + }) + require.Error(t, err) + require.Empty(t, s.data.Process) + + config = &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.addProcess(CommandAddProcess{ + Config: config, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Process)) + + config = &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.addProcess(CommandAddProcess{ + Config: config, + }) + require.Error(t, err) + require.Equal(t, 1, len(s.data.Process)) + + config = &app.Config{ + ID: "foobar", + Domain: "barfoo", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.addProcess(CommandAddProcess{ + Config: config, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Process)) +} + +func TestRemoveProcessCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + config := &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.applyCommand(Command{ + Operation: OpAddProcess, + Data: CommandAddProcess{ + Config: config, + }, + }) + require.NoError(t, err) + require.NotEmpty(t, s.data.Process) + + err = s.applyCommand(Command{ + Operation: OpRemoveProcess, + Data: CommandRemoveProcess{ + ID: config.ProcessID(), + }, + }) + require.NoError(t, err) + require.Empty(t, s.data.Process) + + err = s.applyCommand(Command{ + Operation: OpRemoveProcess, + Data: CommandRemoveProcess{ + ID: config.ProcessID(), + }, + }) + require.Error(t, err) +} + +func TestRemoveProcess(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + config1 := &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + config2 := &app.Config{ + ID: "foobar", + Domain: "barfoo", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.removeProcess(CommandRemoveProcess{ + ID: config1.ProcessID(), + }) + require.Error(t, err) + + err = s.removeProcess(CommandRemoveProcess{ + ID: config2.ProcessID(), + }) + require.Error(t, err) + + err = s.addProcess(CommandAddProcess{ + Config: config1, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Process)) + + err = s.addProcess(CommandAddProcess{ + Config: config2, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Process)) + + err = s.removeProcess(CommandRemoveProcess{ + ID: config1.ProcessID(), + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Process)) + + err = s.removeProcess(CommandRemoveProcess{ + ID: config2.ProcessID(), + }) + require.NoError(t, err) + require.Empty(t, s.data.Process) +} + +func TestUpdateProcessCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + config := &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + pid := config.ProcessID() + + err = s.applyCommand(Command{ + Operation: OpAddProcess, + Data: CommandAddProcess{ + Config: config, + }, + }) + require.NoError(t, err) + require.NotEmpty(t, s.data.Process) + + config = &app.Config{ + ID: "foobaz", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.applyCommand(Command{ + Operation: OpUpdateProcess, + Data: CommandUpdateProcess{ + ID: pid, + Config: config, + }, + }) + require.NoError(t, err) + require.NotEmpty(t, s.data.Process) +} + +func TestUpdateProcess(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + config1 := &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + config2 := &app.Config{ + ID: "fooboz", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.addProcess(CommandAddProcess{ + Config: config1, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Process)) + + err = s.addProcess(CommandAddProcess{ + Config: config2, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Process)) + + config := &app.Config{ + ID: "foobaz", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.updateProcess(CommandUpdateProcess{ + ID: config.ProcessID(), + Config: config, + }) + require.Error(t, err) + + config.ID = "fooboz" + + err = s.updateProcess(CommandUpdateProcess{ + ID: config1.ProcessID(), + Config: config, + }) + require.Error(t, err) + + config.ID = "foobaz" + config.LimitCPU = 0 + + err = s.updateProcess(CommandUpdateProcess{ + ID: config1.ProcessID(), + Config: config, + }) + require.Error(t, err) + + config.LimitCPU = 1 + + err = s.updateProcess(CommandUpdateProcess{ + ID: config1.ProcessID(), + Config: config, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Process)) + + err = s.updateProcess(CommandUpdateProcess{ + ID: config.ProcessID(), + Config: config, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Process)) + + config3 := &app.Config{ + ID: config.ID, + LimitCPU: 1, + LimitMemory: 2, + } + + err = s.updateProcess(CommandUpdateProcess{ + ID: config.ProcessID(), + Config: config3, + }) + require.NoError(t, err) + require.Equal(t, 2, len(s.data.Process)) + + _, err = s.GetProcess(config1.ProcessID()) + require.Error(t, err) + + _, err = s.GetProcess(config2.ProcessID()) + require.NoError(t, err) + + _, err = s.GetProcess(config.ProcessID()) + require.NoError(t, err) +} + +func TestSetProcessOrderCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + config := &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.applyCommand(Command{ + Operation: OpAddProcess, + Data: CommandAddProcess{ + Config: config, + }, + }) + require.NoError(t, err) + require.NotEmpty(t, s.data.Process) + + p, err := s.GetProcess(config.ProcessID()) + require.NoError(t, err) + require.Equal(t, "stop", p.Order) + + err = s.applyCommand(Command{ + Operation: OpSetProcessOrder, + Data: CommandSetProcessOrder{ + ID: config.ProcessID(), + Order: "start", + }, + }) + require.NoError(t, err) + + p, err = s.GetProcess(config.ProcessID()) + require.NoError(t, err) + require.Equal(t, "start", p.Order) +} + +func TestSetProcessOrder(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + config := &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.setProcessOrder(CommandSetProcessOrder{ + ID: config.ProcessID(), + Order: "start", + }) + require.Error(t, err) + + err = s.addProcess(CommandAddProcess{ + Config: config, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Process)) + + err = s.setProcessOrder(CommandSetProcessOrder{ + ID: config.ProcessID(), + Order: "start", + }) + require.NoError(t, err) + + err = s.setProcessOrder(CommandSetProcessOrder{ + ID: config.ProcessID(), + Order: "stop", + }) + require.NoError(t, err) + + p, err := s.GetProcess(config.ProcessID()) + require.NoError(t, err) + require.Equal(t, "stop", p.Order) + + err = s.setProcessOrder(CommandSetProcessOrder{ + ID: config.ProcessID(), + Order: "start", + }) + require.NoError(t, err) + + p, err = s.GetProcess(config.ProcessID()) + require.NoError(t, err) + require.Equal(t, "start", p.Order) +} + +func TestSetProcessMetadataCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + config := &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.applyCommand(Command{ + Operation: OpAddProcess, + Data: CommandAddProcess{ + Config: config, + }, + }) + require.NoError(t, err) + require.NotEmpty(t, s.data.Process) + + p, err := s.GetProcess(config.ProcessID()) + require.NoError(t, err) + require.Empty(t, p.Metadata) + + metadata := "bar" + + err = s.applyCommand(Command{ + Operation: OpSetProcessMetadata, + Data: CommandSetProcessMetadata{ + ID: config.ProcessID(), + Key: "foo", + Data: metadata, + }, + }) + require.NoError(t, err) + + p, err = s.GetProcess(config.ProcessID()) + require.NoError(t, err) + + require.NotEmpty(t, p.Metadata) + require.Equal(t, 1, len(p.Metadata)) + require.Equal(t, "bar", p.Metadata["foo"]) +} + +func TestSetProcessMetadata(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + config := &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.setProcessMetadata(CommandSetProcessMetadata{ + ID: config.ProcessID(), + Key: "foo", + Data: "bar", + }) + require.Error(t, err) + + err = s.addProcess(CommandAddProcess{ + Config: config, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Process)) + + err = s.setProcessMetadata(CommandSetProcessMetadata{ + ID: config.ProcessID(), + Key: "foo", + Data: "bar", + }) + require.NoError(t, err) + + err = s.setProcessMetadata(CommandSetProcessMetadata{ + ID: config.ProcessID(), + Key: "faa", + Data: "boz", + }) + require.NoError(t, err) + + p, err := s.GetProcess(config.ProcessID()) + require.NoError(t, err) + + require.NotEmpty(t, p.Metadata) + require.Equal(t, 2, len(p.Metadata)) + require.Equal(t, "bar", p.Metadata["foo"]) + require.Equal(t, "boz", p.Metadata["faa"]) + + err = s.setProcessMetadata(CommandSetProcessMetadata{ + ID: config.ProcessID(), + Key: "faa", + Data: nil, + }) + require.NoError(t, err) + + p, err = s.GetProcess(config.ProcessID()) + require.NoError(t, err) + + require.NotEmpty(t, p.Metadata) + require.Equal(t, 1, len(p.Metadata)) + require.Equal(t, "bar", p.Metadata["foo"]) + + err = s.setProcessMetadata(CommandSetProcessMetadata{ + ID: config.ProcessID(), + Key: "foo", + Data: "bor", + }) + require.NoError(t, err) + + p, err = s.GetProcess(config.ProcessID()) + require.NoError(t, err) + + require.NotEmpty(t, p.Metadata) + require.Equal(t, 1, len(p.Metadata)) + require.Equal(t, "bor", p.Metadata["foo"]) +} + +func TestSetProcessErrorCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + config := &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.applyCommand(Command{ + Operation: OpAddProcess, + Data: CommandAddProcess{ + Config: config, + }, + }) + require.NoError(t, err) + require.NotEmpty(t, s.data.Process) + + p, err := s.GetProcess(config.ProcessID()) + require.NoError(t, err) + require.Equal(t, "", p.Error) + + err = s.applyCommand(Command{ + Operation: OpSetProcessError, + Data: CommandSetProcessError{ + ID: config.ProcessID(), + Error: "foobar", + }, + }) + require.NoError(t, err) + + p, err = s.GetProcess(config.ProcessID()) + require.NoError(t, err) + require.Equal(t, "foobar", p.Error) +} + +func TestSetProcessError(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + config := &app.Config{ + ID: "foobar", + LimitCPU: 1, + LimitMemory: 1, + } + + err = s.setProcessError(CommandSetProcessError{ + ID: config.ProcessID(), + Error: "foobar", + }) + require.Error(t, err) + + err = s.addProcess(CommandAddProcess{ + Config: config, + }) + require.NoError(t, err) + require.Equal(t, 1, len(s.data.Process)) + + err = s.setProcessError(CommandSetProcessError{ + ID: config.ProcessID(), + Error: "foobar", + }) + require.NoError(t, err) + + err = s.setProcessError(CommandSetProcessError{ + ID: config.ProcessID(), + Error: "", + }) + require.NoError(t, err) + + p, err := s.GetProcess(config.ProcessID()) + require.NoError(t, err) + require.Equal(t, "", p.Error) + + err = s.setProcessError(CommandSetProcessError{ + ID: config.ProcessID(), + Error: "foobar", + }) + require.NoError(t, err) + + p, err = s.GetProcess(config.ProcessID()) + require.NoError(t, err) + require.Equal(t, "foobar", p.Error) +} + +func TestSetProcessNodeMapCommand(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + m1 := map[string]string{ + "key": "value1", + } + + err = s.applyCommand(Command{ + Operation: OpSetProcessNodeMap, + Data: CommandSetProcessNodeMap{ + Map: m1, + }, + }) + require.NoError(t, err) + require.Equal(t, m1, s.data.ProcessNodeMap) +} + +func TestSetProcessNodeMap(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + m1 := map[string]string{ + "key": "value1", + } + + err = s.setProcessNodeMap(CommandSetProcessNodeMap{ + Map: m1, + }) + require.NoError(t, err) + require.Equal(t, m1, s.data.ProcessNodeMap) + + m2 := map[string]string{ + "key": "value2", + } + + err = s.setProcessNodeMap(CommandSetProcessNodeMap{ + Map: m2, + }) + require.NoError(t, err) + require.Equal(t, m2, s.data.ProcessNodeMap) + + m := s.GetProcessNodeMap() + require.Equal(t, m2, m) +} diff --git a/cluster/store/store.go b/cluster/store/store.go index af4ba102..58db3bb2 100644 --- a/cluster/store/store.go +++ b/cluster/store/store.go @@ -4,8 +4,6 @@ import ( "encoding/json" "fmt" "io" - "io/fs" - "strings" "sync" "time" @@ -167,6 +165,7 @@ type storeData struct { Users struct { UpdatedAt time.Time Users map[string]identity.User + userlist identity.UserList } Policies struct { @@ -187,6 +186,7 @@ func (s *storeData) init() { s.ProcessNodeMap = map[string]string{} s.Users.UpdatedAt = now s.Users.Users = map[string]identity.User{} + s.Users.userlist = identity.NewUserList() s.Policies.UpdatedAt = now s.Policies.Policies = map[string][]access.Policy{} s.Locks = map[string]time.Time{} @@ -244,7 +244,6 @@ func (s *store) Apply(entry *raft.Log) interface{} { logger.Debug().WithField("operation", c.Operation).Log("") err = s.applyCommand(c) - if err != nil { logger.Debug().WithError(err).WithField("operation", c.Operation).Log("") return err @@ -410,342 +409,6 @@ func (s *store) applyCommand(c Command) error { return err } -func (s *store) addProcess(cmd CommandAddProcess) error { - s.lock.Lock() - defer s.lock.Unlock() - - id := cmd.Config.ProcessID().String() - - if cmd.Config.LimitCPU <= 0 || cmd.Config.LimitMemory <= 0 { - return fmt.Errorf("the process with the ID '%s' must have limits defined", id) - } - - _, ok := s.data.Process[id] - if ok { - return fmt.Errorf("the process with the ID '%s' already exists", id) - } - - order := "stop" - if cmd.Config.Autostart { - order = "start" - cmd.Config.Autostart = false - } - - now := time.Now() - s.data.Process[id] = Process{ - CreatedAt: now, - UpdatedAt: now, - Config: cmd.Config, - Order: order, - Metadata: map[string]interface{}{}, - } - - return nil -} - -func (s *store) removeProcess(cmd CommandRemoveProcess) error { - s.lock.Lock() - defer s.lock.Unlock() - - id := cmd.ID.String() - - _, ok := s.data.Process[id] - if !ok { - return fmt.Errorf("the process with the ID '%s' doesn't exist", id) - } - - delete(s.data.Process, id) - - return nil -} - -func (s *store) updateProcess(cmd CommandUpdateProcess) error { - s.lock.Lock() - defer s.lock.Unlock() - - srcid := cmd.ID.String() - dstid := cmd.Config.ProcessID().String() - - if cmd.Config.LimitCPU <= 0 || cmd.Config.LimitMemory <= 0 { - return fmt.Errorf("the process with the ID '%s' must have limits defined", dstid) - } - - p, ok := s.data.Process[srcid] - if !ok { - return fmt.Errorf("the process with the ID '%s' doesn't exists", srcid) - } - - if p.Config.Equal(cmd.Config) { - return nil - } - - if srcid == dstid { - p.UpdatedAt = time.Now() - p.Config = cmd.Config - - s.data.Process[srcid] = p - - return nil - } - - _, ok = s.data.Process[dstid] - if ok { - return fmt.Errorf("the process with the ID '%s' already exists", dstid) - } - - now := time.Now() - - p.CreatedAt = now - p.UpdatedAt = now - p.Config = cmd.Config - - delete(s.data.Process, srcid) - s.data.Process[dstid] = p - - return nil -} - -func (s *store) setProcessOrder(cmd CommandSetProcessOrder) error { - s.lock.Lock() - defer s.lock.Unlock() - - id := cmd.ID.String() - - p, ok := s.data.Process[id] - if !ok { - return fmt.Errorf("the process with the ID '%s' doesn't exists", cmd.ID) - } - - p.Order = cmd.Order - p.UpdatedAt = time.Now() - - s.data.Process[id] = p - - return nil -} - -func (s *store) setProcessMetadata(cmd CommandSetProcessMetadata) error { - s.lock.Lock() - defer s.lock.Unlock() - - id := cmd.ID.String() - - p, ok := s.data.Process[id] - if !ok { - return fmt.Errorf("the process with the ID '%s' doesn't exists", cmd.ID) - } - - if p.Metadata == nil { - p.Metadata = map[string]interface{}{} - } - - if cmd.Data == nil { - delete(p.Metadata, cmd.Key) - } else { - p.Metadata[cmd.Key] = cmd.Data - } - p.UpdatedAt = time.Now() - - s.data.Process[id] = p - - return nil -} - -func (s *store) setProcessError(cmd CommandSetProcessError) error { - s.lock.Lock() - defer s.lock.Unlock() - - id := cmd.ID.String() - - p, ok := s.data.Process[id] - if !ok { - return fmt.Errorf("the process with the ID '%s' doesn't exists", cmd.ID) - } - - p.Error = cmd.Error - - s.data.Process[id] = p - - return nil -} - -func (s *store) addIdentity(cmd CommandAddIdentity) error { - s.lock.Lock() - defer s.lock.Unlock() - - if cmd.Identity.Name == "$anon" { - return fmt.Errorf("the identity with the name '%s' can't be created", cmd.Identity.Name) - } - - _, ok := s.data.Users.Users[cmd.Identity.Name] - if ok { - return fmt.Errorf("the identity with the name '%s' already exists", cmd.Identity.Name) - } - - s.data.Users.UpdatedAt = time.Now() - s.data.Users.Users[cmd.Identity.Name] = cmd.Identity - - return nil -} - -func (s *store) updateIdentity(cmd CommandUpdateIdentity) error { - s.lock.Lock() - defer s.lock.Unlock() - - if cmd.Name == "$anon" { - return fmt.Errorf("the identity with the name '%s' can't be updated", cmd.Name) - } - - _, ok := s.data.Users.Users[cmd.Name] - if !ok { - return fmt.Errorf("the identity with the name '%s' doesn't exist", cmd.Name) - } - - if cmd.Name == cmd.Identity.Name { - s.data.Users.UpdatedAt = time.Now() - s.data.Users.Users[cmd.Identity.Name] = cmd.Identity - - return nil - } - - _, ok = s.data.Users.Users[cmd.Identity.Name] - if ok { - return fmt.Errorf("the identity with the name '%s' already exists", cmd.Identity.Name) - } - - now := time.Now() - - s.data.Users.UpdatedAt = now - s.data.Users.Users[cmd.Identity.Name] = cmd.Identity - s.data.Policies.UpdatedAt = now - s.data.Policies.Policies[cmd.Identity.Name] = s.data.Policies.Policies[cmd.Name] - - delete(s.data.Users.Users, cmd.Name) - delete(s.data.Policies.Policies, cmd.Name) - - return nil -} - -func (s *store) removeIdentity(cmd CommandRemoveIdentity) error { - s.lock.Lock() - defer s.lock.Unlock() - - delete(s.data.Users.Users, cmd.Name) - s.data.Users.UpdatedAt = time.Now() - delete(s.data.Policies.Policies, cmd.Name) - s.data.Policies.UpdatedAt = time.Now() - - return nil -} - -func (s *store) setPolicies(cmd CommandSetPolicies) error { - s.lock.Lock() - defer s.lock.Unlock() - - if cmd.Name != "$anon" { - if _, ok := s.data.Users.Users[cmd.Name]; !ok { - return fmt.Errorf("the identity with the name '%s' doesn't exist", cmd.Name) - } - } - - for i, p := range cmd.Policies { - if len(p.Domain) != 0 { - continue - } - - p.Domain = "$none" - cmd.Policies[i] = p - } - - delete(s.data.Policies.Policies, cmd.Name) - s.data.Policies.Policies[cmd.Name] = cmd.Policies - s.data.Policies.UpdatedAt = time.Now() - - return nil -} - -func (s *store) setProcessNodeMap(cmd CommandSetProcessNodeMap) error { - s.lock.Lock() - defer s.lock.Unlock() - - s.data.ProcessNodeMap = cmd.Map - - return nil -} - -func (s *store) createLock(cmd CommandCreateLock) error { - s.lock.Lock() - defer s.lock.Unlock() - - validUntil, ok := s.data.Locks[cmd.Name] - - if ok { - if time.Now().Before(validUntil) { - return fmt.Errorf("the lock with the ID '%s' already exists", cmd.Name) - } - } - - s.data.Locks[cmd.Name] = cmd.ValidUntil - - return nil -} - -func (s *store) deleteLock(cmd CommandDeleteLock) error { - s.lock.Lock() - defer s.lock.Unlock() - - if _, ok := s.data.Locks[cmd.Name]; !ok { - return nil - } - - delete(s.data.Locks, cmd.Name) - - return nil -} - -func (s *store) clearLocks(cmd CommandClearLocks) error { - s.lock.Lock() - defer s.lock.Unlock() - - for name, validUntil := range s.data.Locks { - if time.Now().Before(validUntil) { - // Lock is still valid - continue - } - - delete(s.data.Locks, name) - } - - return nil -} - -func (s *store) setKV(cmd CommandSetKV) error { - s.lock.Lock() - defer s.lock.Unlock() - - value := s.data.KVS[cmd.Key] - - value.Value = cmd.Value - value.UpdatedAt = time.Now() - - s.data.KVS[cmd.Key] = value - - return nil -} - -func (s *store) unsetKV(cmd CommandUnsetKV) error { - s.lock.Lock() - defer s.lock.Unlock() - - if _, ok := s.data.KVS[cmd.Key]; !ok { - return fs.ErrNotExist - } - - delete(s.data.KVS, cmd.Key) - - return nil -} - func (s *store) OnApply(fn func(op Operation)) { s.lock.Lock() defer s.lock.Unlock() @@ -794,6 +457,22 @@ func (s *store) Restore(snapshot io.ReadCloser) error { data.Process[id] = p } + now := time.Now() + + for name, u := range data.Users.Users { + data.Users.userlist.Add(u) + + if u.CreatedAt.IsZero() { + u.CreatedAt = now + } + + if u.UpdatedAt.IsZero() { + u.UpdatedAt = now + } + + data.Users.Users[name] = u + } + if data.Version == 0 { data.Version = 1 } @@ -803,167 +482,6 @@ func (s *store) Restore(snapshot io.ReadCloser) error { return nil } -func (s *store) ListProcesses() []Process { - s.lock.RLock() - defer s.lock.RUnlock() - - processes := []Process{} - - for _, p := range s.data.Process { - processes = append(processes, Process{ - CreatedAt: p.CreatedAt, - UpdatedAt: p.UpdatedAt, - Config: p.Config.Clone(), - Order: p.Order, - Metadata: p.Metadata, - Error: p.Error, - }) - } - - return processes -} - -func (s *store) GetProcess(id app.ProcessID) (Process, error) { - s.lock.RLock() - defer s.lock.RUnlock() - - process, ok := s.data.Process[id.String()] - if !ok { - return Process{}, fmt.Errorf("not found") - } - - return Process{ - CreatedAt: process.CreatedAt, - UpdatedAt: process.UpdatedAt, - Config: process.Config.Clone(), - Order: process.Order, - Metadata: process.Metadata, - Error: process.Error, - }, nil -} - -func (s *store) ListUsers() Users { - s.lock.RLock() - defer s.lock.RUnlock() - - u := Users{ - UpdatedAt: s.data.Users.UpdatedAt, - } - - for _, user := range s.data.Users.Users { - u.Users = append(u.Users, user) - } - - return u -} - -func (s *store) GetUser(name string) Users { - s.lock.RLock() - defer s.lock.RUnlock() - - u := Users{ - UpdatedAt: s.data.Users.UpdatedAt, - } - - if user, ok := s.data.Users.Users[name]; ok { - u.Users = append(u.Users, user) - } - - return u -} - -func (s *store) ListPolicies() Policies { - s.lock.RLock() - defer s.lock.RUnlock() - - p := Policies{ - UpdatedAt: s.data.Policies.UpdatedAt, - } - - for _, policies := range s.data.Policies.Policies { - p.Policies = append(p.Policies, policies...) - } - - return p -} - -func (s *store) ListUserPolicies(name string) Policies { - s.lock.RLock() - defer s.lock.RUnlock() - - p := Policies{ - UpdatedAt: s.data.Policies.UpdatedAt, - } - - p.Policies = append(p.Policies, s.data.Policies.Policies[name]...) - - return p -} - -func (s *store) GetProcessNodeMap() map[string]string { - s.lock.RLock() - defer s.lock.RUnlock() - - m := map[string]string{} - - for key, value := range s.data.ProcessNodeMap { - m[key] = value - } - - return m -} - -func (s *store) HasLock(name string) bool { - s.lock.RLock() - defer s.lock.RUnlock() - - _, ok := s.data.Locks[name] - - return ok -} - -func (s *store) ListLocks() map[string]time.Time { - s.lock.RLock() - defer s.lock.RUnlock() - - m := map[string]time.Time{} - - for key, value := range s.data.Locks { - m[key] = value - } - - return m -} - -func (s *store) ListKVS(prefix string) map[string]Value { - s.lock.RLock() - defer s.lock.RUnlock() - - m := map[string]Value{} - - for key, value := range s.data.KVS { - if !strings.HasPrefix(key, prefix) { - continue - } - - m[key] = value - } - - return m -} - -func (s *store) GetFromKVS(key string) (Value, error) { - s.lock.RLock() - defer s.lock.RUnlock() - - value, ok := s.data.KVS[key] - if !ok { - return Value{}, fs.ErrNotExist - } - - return value, nil -} - type fsmSnapshot struct { data []byte } diff --git a/cluster/store/store_test.go b/cluster/store/store_test.go index 9ff49615..e0e071ca 100644 --- a/cluster/store/store_test.go +++ b/cluster/store/store_test.go @@ -2,13 +2,8 @@ package store import ( "encoding/json" - "io/fs" "testing" - "time" - "github.com/datarhei/core/v16/iam/access" - "github.com/datarhei/core/v16/iam/identity" - "github.com/datarhei/core/v16/restream/app" "github.com/hashicorp/raft" "github.com/stretchr/testify/require" @@ -39,1260 +34,6 @@ func TestCreateStore(t *testing.T) { require.NotNil(t, s.data.KVS) } -func TestAddProcessCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - config := &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.applyCommand(Command{ - Operation: OpAddProcess, - Data: CommandAddProcess{ - Config: config, - }, - }) - require.NoError(t, err) - require.NotEmpty(t, s.data.Process) - - p, ok := s.data.Process["foobar@"] - require.True(t, ok) - - require.NotZero(t, p.CreatedAt) - require.NotZero(t, p.UpdatedAt) - require.NotNil(t, p.Config) - require.True(t, p.Config.Equal(config)) - require.NotNil(t, p.Metadata) -} - -func TestAddProcess(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - config := &app.Config{ - ID: "foobar", - } - - err = s.addProcess(CommandAddProcess{ - Config: config, - }) - require.Error(t, err) - require.Empty(t, s.data.Process) - - config = &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.addProcess(CommandAddProcess{ - Config: config, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Process)) - - config = &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.addProcess(CommandAddProcess{ - Config: config, - }) - require.Error(t, err) - require.Equal(t, 1, len(s.data.Process)) - - config = &app.Config{ - ID: "foobar", - Domain: "barfoo", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.addProcess(CommandAddProcess{ - Config: config, - }) - require.NoError(t, err) - require.Equal(t, 2, len(s.data.Process)) -} - -func TestRemoveProcessCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - config := &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.applyCommand(Command{ - Operation: OpAddProcess, - Data: CommandAddProcess{ - Config: config, - }, - }) - require.NoError(t, err) - require.NotEmpty(t, s.data.Process) - - err = s.applyCommand(Command{ - Operation: OpRemoveProcess, - Data: CommandRemoveProcess{ - ID: config.ProcessID(), - }, - }) - require.NoError(t, err) - require.Empty(t, s.data.Process) - - err = s.applyCommand(Command{ - Operation: OpRemoveProcess, - Data: CommandRemoveProcess{ - ID: config.ProcessID(), - }, - }) - require.Error(t, err) -} - -func TestRemoveProcess(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - config1 := &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - config2 := &app.Config{ - ID: "foobar", - Domain: "barfoo", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.removeProcess(CommandRemoveProcess{ - ID: config1.ProcessID(), - }) - require.Error(t, err) - - err = s.removeProcess(CommandRemoveProcess{ - ID: config2.ProcessID(), - }) - require.Error(t, err) - - err = s.addProcess(CommandAddProcess{ - Config: config1, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Process)) - - err = s.addProcess(CommandAddProcess{ - Config: config2, - }) - require.NoError(t, err) - require.Equal(t, 2, len(s.data.Process)) - - err = s.removeProcess(CommandRemoveProcess{ - ID: config1.ProcessID(), - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Process)) - - err = s.removeProcess(CommandRemoveProcess{ - ID: config2.ProcessID(), - }) - require.NoError(t, err) - require.Empty(t, s.data.Process) -} - -func TestUpdateProcessCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - config := &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - pid := config.ProcessID() - - err = s.applyCommand(Command{ - Operation: OpAddProcess, - Data: CommandAddProcess{ - Config: config, - }, - }) - require.NoError(t, err) - require.NotEmpty(t, s.data.Process) - - config = &app.Config{ - ID: "foobaz", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.applyCommand(Command{ - Operation: OpUpdateProcess, - Data: CommandUpdateProcess{ - ID: pid, - Config: config, - }, - }) - require.NoError(t, err) - require.NotEmpty(t, s.data.Process) -} - -func TestUpdateProcess(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - config1 := &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - config2 := &app.Config{ - ID: "fooboz", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.addProcess(CommandAddProcess{ - Config: config1, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Process)) - - err = s.addProcess(CommandAddProcess{ - Config: config2, - }) - require.NoError(t, err) - require.Equal(t, 2, len(s.data.Process)) - - config := &app.Config{ - ID: "foobaz", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.updateProcess(CommandUpdateProcess{ - ID: config.ProcessID(), - Config: config, - }) - require.Error(t, err) - - config.ID = "fooboz" - - err = s.updateProcess(CommandUpdateProcess{ - ID: config1.ProcessID(), - Config: config, - }) - require.Error(t, err) - - config.ID = "foobaz" - config.LimitCPU = 0 - - err = s.updateProcess(CommandUpdateProcess{ - ID: config1.ProcessID(), - Config: config, - }) - require.Error(t, err) - - config.LimitCPU = 1 - - err = s.updateProcess(CommandUpdateProcess{ - ID: config1.ProcessID(), - Config: config, - }) - require.NoError(t, err) - require.Equal(t, 2, len(s.data.Process)) - - err = s.updateProcess(CommandUpdateProcess{ - ID: config.ProcessID(), - Config: config, - }) - require.NoError(t, err) - require.Equal(t, 2, len(s.data.Process)) - - config3 := &app.Config{ - ID: config.ID, - LimitCPU: 1, - LimitMemory: 2, - } - - err = s.updateProcess(CommandUpdateProcess{ - ID: config.ProcessID(), - Config: config3, - }) - require.NoError(t, err) - require.Equal(t, 2, len(s.data.Process)) - - _, err = s.GetProcess(config1.ProcessID()) - require.Error(t, err) - - _, err = s.GetProcess(config2.ProcessID()) - require.NoError(t, err) - - _, err = s.GetProcess(config.ProcessID()) - require.NoError(t, err) -} - -func TestSetProcessOrderCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - config := &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.applyCommand(Command{ - Operation: OpAddProcess, - Data: CommandAddProcess{ - Config: config, - }, - }) - require.NoError(t, err) - require.NotEmpty(t, s.data.Process) - - p, err := s.GetProcess(config.ProcessID()) - require.NoError(t, err) - require.Equal(t, "stop", p.Order) - - err = s.applyCommand(Command{ - Operation: OpSetProcessOrder, - Data: CommandSetProcessOrder{ - ID: config.ProcessID(), - Order: "start", - }, - }) - require.NoError(t, err) - - p, err = s.GetProcess(config.ProcessID()) - require.NoError(t, err) - require.Equal(t, "start", p.Order) -} - -func TestSetProcessOrder(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - config := &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.setProcessOrder(CommandSetProcessOrder{ - ID: config.ProcessID(), - Order: "start", - }) - require.Error(t, err) - - err = s.addProcess(CommandAddProcess{ - Config: config, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Process)) - - err = s.setProcessOrder(CommandSetProcessOrder{ - ID: config.ProcessID(), - Order: "start", - }) - require.NoError(t, err) - - err = s.setProcessOrder(CommandSetProcessOrder{ - ID: config.ProcessID(), - Order: "stop", - }) - require.NoError(t, err) - - p, err := s.GetProcess(config.ProcessID()) - require.NoError(t, err) - require.Equal(t, "stop", p.Order) - - err = s.setProcessOrder(CommandSetProcessOrder{ - ID: config.ProcessID(), - Order: "start", - }) - require.NoError(t, err) - - p, err = s.GetProcess(config.ProcessID()) - require.NoError(t, err) - require.Equal(t, "start", p.Order) -} - -func TestSetProcessMetadataCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - config := &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.applyCommand(Command{ - Operation: OpAddProcess, - Data: CommandAddProcess{ - Config: config, - }, - }) - require.NoError(t, err) - require.NotEmpty(t, s.data.Process) - - p, err := s.GetProcess(config.ProcessID()) - require.NoError(t, err) - require.Empty(t, p.Metadata) - - metadata := "bar" - - err = s.applyCommand(Command{ - Operation: OpSetProcessMetadata, - Data: CommandSetProcessMetadata{ - ID: config.ProcessID(), - Key: "foo", - Data: metadata, - }, - }) - require.NoError(t, err) - - p, err = s.GetProcess(config.ProcessID()) - require.NoError(t, err) - - require.NotEmpty(t, p.Metadata) - require.Equal(t, 1, len(p.Metadata)) - require.Equal(t, "bar", p.Metadata["foo"]) -} - -func TestSetProcessMetadata(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - config := &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.setProcessMetadata(CommandSetProcessMetadata{ - ID: config.ProcessID(), - Key: "foo", - Data: "bar", - }) - require.Error(t, err) - - err = s.addProcess(CommandAddProcess{ - Config: config, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Process)) - - err = s.setProcessMetadata(CommandSetProcessMetadata{ - ID: config.ProcessID(), - Key: "foo", - Data: "bar", - }) - require.NoError(t, err) - - err = s.setProcessMetadata(CommandSetProcessMetadata{ - ID: config.ProcessID(), - Key: "faa", - Data: "boz", - }) - require.NoError(t, err) - - p, err := s.GetProcess(config.ProcessID()) - require.NoError(t, err) - - require.NotEmpty(t, p.Metadata) - require.Equal(t, 2, len(p.Metadata)) - require.Equal(t, "bar", p.Metadata["foo"]) - require.Equal(t, "boz", p.Metadata["faa"]) - - err = s.setProcessMetadata(CommandSetProcessMetadata{ - ID: config.ProcessID(), - Key: "faa", - Data: nil, - }) - require.NoError(t, err) - - p, err = s.GetProcess(config.ProcessID()) - require.NoError(t, err) - - require.NotEmpty(t, p.Metadata) - require.Equal(t, 1, len(p.Metadata)) - require.Equal(t, "bar", p.Metadata["foo"]) - - err = s.setProcessMetadata(CommandSetProcessMetadata{ - ID: config.ProcessID(), - Key: "foo", - Data: "bor", - }) - require.NoError(t, err) - - p, err = s.GetProcess(config.ProcessID()) - require.NoError(t, err) - - require.NotEmpty(t, p.Metadata) - require.Equal(t, 1, len(p.Metadata)) - require.Equal(t, "bor", p.Metadata["foo"]) -} - -func TestSetProcessErrorCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - config := &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.applyCommand(Command{ - Operation: OpAddProcess, - Data: CommandAddProcess{ - Config: config, - }, - }) - require.NoError(t, err) - require.NotEmpty(t, s.data.Process) - - p, err := s.GetProcess(config.ProcessID()) - require.NoError(t, err) - require.Equal(t, "", p.Error) - - err = s.applyCommand(Command{ - Operation: OpSetProcessError, - Data: CommandSetProcessError{ - ID: config.ProcessID(), - Error: "foobar", - }, - }) - require.NoError(t, err) - - p, err = s.GetProcess(config.ProcessID()) - require.NoError(t, err) - require.Equal(t, "foobar", p.Error) -} - -func TestSetProcessError(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - config := &app.Config{ - ID: "foobar", - LimitCPU: 1, - LimitMemory: 1, - } - - err = s.setProcessError(CommandSetProcessError{ - ID: config.ProcessID(), - Error: "foobar", - }) - require.Error(t, err) - - err = s.addProcess(CommandAddProcess{ - Config: config, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Process)) - - err = s.setProcessError(CommandSetProcessError{ - ID: config.ProcessID(), - Error: "foobar", - }) - require.NoError(t, err) - - err = s.setProcessError(CommandSetProcessError{ - ID: config.ProcessID(), - Error: "", - }) - require.NoError(t, err) - - p, err := s.GetProcess(config.ProcessID()) - require.NoError(t, err) - require.Equal(t, "", p.Error) - - err = s.setProcessError(CommandSetProcessError{ - ID: config.ProcessID(), - Error: "foobar", - }) - require.NoError(t, err) - - p, err = s.GetProcess(config.ProcessID()) - require.NoError(t, err) - require.Equal(t, "foobar", p.Error) -} - -func TestAddIdentityCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - identity := identity.User{ - Name: "foobar", - } - - err = s.applyCommand(Command{ - Operation: OpAddIdentity, - Data: CommandAddIdentity{ - Identity: identity, - }, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) -} - -func TestAddIdentity(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - identity := identity.User{ - Name: "foobar", - } - - err = s.addIdentity(CommandAddIdentity{ - Identity: identity, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - require.Equal(t, 0, len(s.data.Policies.Policies)) - - err = s.addIdentity(CommandAddIdentity{ - Identity: identity, - }) - require.Error(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - require.Equal(t, 0, len(s.data.Policies.Policies)) -} - -func TestRemoveIdentityCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - identity := identity.User{ - Name: "foobar", - } - - err = s.applyCommand(Command{ - Operation: OpAddIdentity, - Data: CommandAddIdentity{ - Identity: identity, - }, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - - err = s.applyCommand(Command{ - Operation: OpRemoveIdentity, - Data: CommandRemoveIdentity{ - Name: "foobar", - }, - }) - require.NoError(t, err) - require.Equal(t, 0, len(s.data.Users.Users)) -} - -func TestRemoveIdentity(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - identity := identity.User{ - Name: "foobar", - } - - err = s.addIdentity(CommandAddIdentity{ - Identity: identity, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - require.Equal(t, 0, len(s.data.Policies.Policies)) - - err = s.removeIdentity(CommandRemoveIdentity{ - Name: "foobar", - }) - require.NoError(t, err) - require.Equal(t, 0, len(s.data.Users.Users)) - require.Equal(t, 0, len(s.data.Policies.Policies)) - - err = s.removeIdentity(CommandRemoveIdentity{ - Name: "foobar", - }) - require.NoError(t, err) - require.Equal(t, 0, len(s.data.Users.Users)) - require.Equal(t, 0, len(s.data.Policies.Policies)) -} - -func TestSetPoliciesCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - identity := identity.User{ - Name: "foobar", - } - - err = s.applyCommand(Command{ - Operation: OpAddIdentity, - Data: CommandAddIdentity{ - Identity: identity, - }, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - require.Equal(t, 0, len(s.data.Policies.Policies)) - - err = s.applyCommand(Command{ - Operation: OpSetPolicies, - Data: CommandSetPolicies{ - Name: "foobar", - Policies: []access.Policy{ - { - Name: "bla", - Domain: "bla", - Resource: "bla", - Actions: []string{}, - }, - { - Name: "foo", - Domain: "foo", - Resource: "foo", - Actions: []string{}, - }, - }, - }, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - require.Equal(t, 2, len(s.data.Policies.Policies["foobar"])) -} - -func TestSetPolicies(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - identity := identity.User{ - Name: "foobar", - } - - policies := []access.Policy{ - { - Name: "bla", - Domain: "bla", - Resource: "bla", - Actions: []string{}, - }, - { - Name: "foo", - Domain: "foo", - Resource: "foo", - Actions: []string{}, - }, - } - - err = s.setPolicies(CommandSetPolicies{ - Name: "foobar", - Policies: policies, - }) - require.Error(t, err) - require.Equal(t, 0, len(s.data.Policies.Policies["foobar"])) - - err = s.addIdentity(CommandAddIdentity{ - Identity: identity, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - require.Equal(t, 0, len(s.data.Policies.Policies)) - - err = s.setPolicies(CommandSetPolicies{ - Name: "foobar", - Policies: policies, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - require.Equal(t, 2, len(s.data.Policies.Policies["foobar"])) -} - -func TestUpdateUserCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - idty1 := identity.User{ - Name: "foobar1", - } - - idty2 := identity.User{ - Name: "foobar2", - } - - err = s.applyCommand(Command{ - Operation: OpAddIdentity, - Data: CommandAddIdentity{ - Identity: idty1, - }, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - - err = s.applyCommand(Command{ - Operation: OpAddIdentity, - Data: CommandAddIdentity{ - Identity: idty2, - }, - }) - require.NoError(t, err) - require.Equal(t, 2, len(s.data.Users.Users)) - - err = s.applyCommand(Command{ - Operation: OpUpdateIdentity, - Data: CommandUpdateIdentity{ - Name: "foobar1", - Identity: identity.User{ - Name: "foobar3", - }, - }, - }) - require.NoError(t, err) - require.Equal(t, 2, len(s.data.Users.Users)) -} - -func TestUpdateIdentity(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - idty1 := identity.User{ - Name: "foobar1", - } - - idty2 := identity.User{ - Name: "foobar2", - } - - err = s.addIdentity(CommandAddIdentity{ - Identity: idty1, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - - err = s.addIdentity(CommandAddIdentity{ - Identity: idty2, - }) - require.NoError(t, err) - require.Equal(t, 2, len(s.data.Users.Users)) - - idty := identity.User{ - Name: "foobaz", - } - - err = s.updateIdentity(CommandUpdateIdentity{ - Name: "foobar", - Identity: idty, - }) - require.Error(t, err) - require.Equal(t, 2, len(s.data.Users.Users)) - - idty.Name = "foobar2" - - err = s.updateIdentity(CommandUpdateIdentity{ - Name: "foobar1", - Identity: idty, - }) - require.Error(t, err) - require.Equal(t, 2, len(s.data.Users.Users)) - - idty.Name = "foobaz" - - err = s.updateIdentity(CommandUpdateIdentity{ - Name: "foobar1", - Identity: idty, - }) - require.NoError(t, err) - require.Equal(t, 2, len(s.data.Users.Users)) - - u := s.GetUser("foobar1") - require.Empty(t, u.Users) - - u = s.GetUser("foobar2") - require.NotEmpty(t, u.Users) - - u = s.GetUser("foobaz") - require.NotEmpty(t, u.Users) -} - -func TestUpdateIdentityWithPolicies(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - idty1 := identity.User{ - Name: "foobar", - } - - policies := []access.Policy{ - { - Name: "bla", - Domain: "bla", - Resource: "bla", - Actions: []string{}, - }, - { - Name: "foo", - Domain: "foo", - Resource: "foo", - Actions: []string{}, - }, - } - - err = s.addIdentity(CommandAddIdentity{ - Identity: idty1, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - - err = s.setPolicies(CommandSetPolicies{ - Name: "foobar", - Policies: policies, - }) - require.NoError(t, err) - require.Equal(t, 2, len(s.data.Policies.Policies["foobar"])) - - err = s.updateIdentity(CommandUpdateIdentity{ - Name: "foobar", - Identity: idty1, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - require.Equal(t, 2, len(s.data.Policies.Policies["foobar"])) - - idty2 := identity.User{ - Name: "foobaz", - } - - err = s.updateIdentity(CommandUpdateIdentity{ - Name: "foobar", - Identity: idty2, - }) - require.NoError(t, err) - require.Equal(t, 1, len(s.data.Users.Users)) - require.Equal(t, 0, len(s.data.Policies.Policies["foobar"])) - require.Equal(t, 2, len(s.data.Policies.Policies["foobaz"])) -} - -func TestSetProcessNodeMapCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - m1 := map[string]string{ - "key": "value1", - } - - err = s.applyCommand(Command{ - Operation: OpSetProcessNodeMap, - Data: CommandSetProcessNodeMap{ - Map: m1, - }, - }) - require.NoError(t, err) - require.Equal(t, m1, s.data.ProcessNodeMap) -} - -func TestSetProcessNodeMap(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - m1 := map[string]string{ - "key": "value1", - } - - err = s.setProcessNodeMap(CommandSetProcessNodeMap{ - Map: m1, - }) - require.NoError(t, err) - require.Equal(t, m1, s.data.ProcessNodeMap) - - m2 := map[string]string{ - "key": "value2", - } - - err = s.setProcessNodeMap(CommandSetProcessNodeMap{ - Map: m2, - }) - require.NoError(t, err) - require.Equal(t, m2, s.data.ProcessNodeMap) - - m := s.GetProcessNodeMap() - require.Equal(t, m2, m) -} - -func TestCreateLockCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - err = s.applyCommand(Command{ - Operation: OpCreateLock, - Data: CommandCreateLock{ - Name: "foobar", - ValidUntil: time.Now().Add(3 * time.Second), - }, - }) - require.NoError(t, err) - - _, ok := s.data.Locks["foobar"] - require.True(t, ok) -} - -func TestCreateLock(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - cmd := CommandCreateLock{ - Name: "foobar", - ValidUntil: time.Now().Add(3 * time.Second), - } - - err = s.createLock(cmd) - require.NoError(t, err) - - err = s.createLock(cmd) - require.Error(t, err) - - require.Eventually(t, func() bool { - err = s.createLock(cmd) - return err == nil - }, 5*time.Second, time.Second) -} - -func TestDeleteLockCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - err = s.applyCommand(Command{ - Operation: OpCreateLock, - Data: CommandCreateLock{ - Name: "foobar", - ValidUntil: time.Now().Add(10 * time.Second), - }, - }) - require.NoError(t, err) - - _, ok := s.data.Locks["foobar"] - require.True(t, ok) - - err = s.applyCommand(Command{ - Operation: OpDeleteLock, - Data: CommandDeleteLock{ - Name: "foobar", - }, - }) - require.NoError(t, err) - - _, ok = s.data.Locks["foobar"] - require.False(t, ok) -} - -func TestDeleteLock(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - err = s.deleteLock(CommandDeleteLock{ - Name: "foobar", - }) - require.NoError(t, err) - - cmd := CommandCreateLock{ - Name: "foobar", - ValidUntil: time.Now().Add(10 * time.Second), - } - - err = s.createLock(cmd) - require.NoError(t, err) - - err = s.createLock(cmd) - require.Error(t, err) - - err = s.deleteLock(CommandDeleteLock{ - Name: "foobar", - }) - require.NoError(t, err) - - err = s.createLock(cmd) - require.NoError(t, err) -} - -func TestClearLocksCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - err = s.applyCommand(Command{ - Operation: OpCreateLock, - Data: CommandCreateLock{ - Name: "foobar", - ValidUntil: time.Now().Add(3 * time.Second), - }, - }) - require.NoError(t, err) - - _, ok := s.data.Locks["foobar"] - require.True(t, ok) - - err = s.applyCommand(Command{ - Operation: OpClearLocks, - Data: CommandClearLocks{}, - }) - require.NoError(t, err) - - _, ok = s.data.Locks["foobar"] - require.True(t, ok) - - time.Sleep(3 * time.Second) - - err = s.applyCommand(Command{ - Operation: OpClearLocks, - Data: CommandClearLocks{}, - }) - require.NoError(t, err) - - _, ok = s.data.Locks["foobar"] - require.False(t, ok) -} - -func TestClearLocks(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - cmd := CommandCreateLock{ - Name: "foobar", - ValidUntil: time.Now().Add(3 * time.Second), - } - - err = s.createLock(cmd) - require.NoError(t, err) - - err = s.clearLocks(CommandClearLocks{}) - require.NoError(t, err) - - err = s.createLock(cmd) - require.Error(t, err) - - time.Sleep(3 * time.Second) - - err = s.clearLocks(CommandClearLocks{}) - require.NoError(t, err) - - err = s.createLock(cmd) - require.NoError(t, err) -} - -func TestSetKVCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - err = s.applyCommand(Command{ - Operation: OpSetKV, - Data: CommandSetKV{ - Key: "foo", - Value: "bar", - }, - }) - require.NoError(t, err) - - _, ok := s.data.KVS["foo"] - require.True(t, ok) -} - -func TestSetKV(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - err = s.setKV(CommandSetKV{ - Key: "foo", - Value: "bar", - }) - require.NoError(t, err) - - value, err := s.GetFromKVS("foo") - require.NoError(t, err) - require.Equal(t, "bar", value.Value) - - updatedAt := value.UpdatedAt - - err = s.setKV(CommandSetKV{ - Key: "foo", - Value: "baz", - }) - require.NoError(t, err) - - value, err = s.GetFromKVS("foo") - require.NoError(t, err) - require.Equal(t, "baz", value.Value) - require.Greater(t, value.UpdatedAt, updatedAt) -} - -func TestUnsetKVCommand(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - err = s.applyCommand(Command{ - Operation: OpSetKV, - Data: CommandSetKV{ - Key: "foo", - Value: "bar", - }, - }) - require.NoError(t, err) - - _, ok := s.data.KVS["foo"] - require.True(t, ok) - - err = s.applyCommand(Command{ - Operation: OpUnsetKV, - Data: CommandUnsetKV{ - Key: "foo", - }, - }) - require.NoError(t, err) - - _, ok = s.data.KVS["foo"] - require.False(t, ok) - - err = s.applyCommand(Command{ - Operation: OpUnsetKV, - Data: CommandUnsetKV{ - Key: "foo", - }, - }) - require.Error(t, err) - require.Equal(t, fs.ErrNotExist, err) -} - -func TestUnsetKV(t *testing.T) { - s, err := createStore() - require.NoError(t, err) - - err = s.setKV(CommandSetKV{ - Key: "foo", - Value: "bar", - }) - require.NoError(t, err) - - _, err = s.GetFromKVS("foo") - require.NoError(t, err) - - err = s.unsetKV(CommandUnsetKV{ - Key: "foo", - }) - require.NoError(t, err) - - _, err = s.GetFromKVS("foo") - require.Error(t, err) - require.Equal(t, fs.ErrNotExist, err) -} - func TestApplyCommand(t *testing.T) { s, err := createStore() require.NoError(t, err) @@ -1382,3 +123,22 @@ func TestApplyWithCallback(t *testing.T) { require.Equal(t, OpSetProcessNodeMap, op) } + +func TestSnapshot(t *testing.T) { + s, err := createStore() + require.NoError(t, err) + + snapshot, err := s.Snapshot() + require.NoError(t, err) + + sshot := snapshot.(*fsmSnapshot) + + data, err := json.Marshal(s.data) + require.NoError(t, err) + + require.Equal(t, data, sshot.data) + + snapshot.Release() + + require.Equal(t, []byte(nil), sshot.data) +} diff --git a/docs/docs.go b/docs/docs.go index 59818015..a58259fc 100644 --- a/docs/docs.go +++ b/docs/docs.go @@ -5701,6 +5701,10 @@ const docTemplate = `{ "auth": { "$ref": "#/definitions/api.IAMUserAuth" }, + "created_at": { + "type": "integer", + "format": "int64" + }, "name": { "type": "string" }, @@ -5712,6 +5716,10 @@ const docTemplate = `{ }, "superuser": { "type": "boolean" + }, + "updated_at": { + "type": "integer", + "format": "int64" } } }, diff --git a/docs/swagger.json b/docs/swagger.json index 8c0f3aa2..037f1cf0 100644 --- a/docs/swagger.json +++ b/docs/swagger.json @@ -5693,6 +5693,10 @@ "auth": { "$ref": "#/definitions/api.IAMUserAuth" }, + "created_at": { + "type": "integer", + "format": "int64" + }, "name": { "type": "string" }, @@ -5704,6 +5708,10 @@ }, "superuser": { "type": "boolean" + }, + "updated_at": { + "type": "integer", + "format": "int64" } } }, diff --git a/docs/swagger.yaml b/docs/swagger.yaml index 804a5819..dcc719a2 100644 --- a/docs/swagger.yaml +++ b/docs/swagger.yaml @@ -777,6 +777,9 @@ definitions: type: string auth: $ref: '#/definitions/api.IAMUserAuth' + created_at: + format: int64 + type: integer name: type: string policies: @@ -785,6 +788,9 @@ definitions: type: array superuser: type: boolean + updated_at: + format: int64 + type: integer type: object api.IAMUserAuth: properties: diff --git a/http/api/iam.go b/http/api/iam.go index 78866e6b..1632310d 100644 --- a/http/api/iam.go +++ b/http/api/iam.go @@ -1,11 +1,15 @@ package api import ( + "time" + "github.com/datarhei/core/v16/iam/access" "github.com/datarhei/core/v16/iam/identity" ) type IAMUser struct { + CreatedAt int64 `json:"created_at" format:"int64"` + UpdatedAt int64 `json:"updated_at" format:"int64"` Name string `json:"name"` Alias string `json:"alias"` Superuser bool `json:"superuser"` @@ -14,6 +18,8 @@ type IAMUser struct { } func (u *IAMUser) Marshal(user identity.User, policies []access.Policy) { + u.CreatedAt = user.CreatedAt.Unix() + u.UpdatedAt = user.UpdatedAt.Unix() u.Name = user.Name u.Alias = user.Alias u.Superuser = user.Superuser @@ -47,6 +53,8 @@ func (u *IAMUser) Marshal(user identity.User, policies []access.Policy) { func (u *IAMUser) Unmarshal() (identity.User, []access.Policy) { iamuser := identity.User{ + CreatedAt: time.Unix(u.CreatedAt, 0), + UpdatedAt: time.Unix(u.UpdatedAt, 0), Name: u.Name, Alias: u.Alias, Superuser: u.Superuser, diff --git a/iam/iam.go b/iam/iam.go index 9b422781..0e8c3b13 100644 --- a/iam/iam.go +++ b/iam/iam.go @@ -215,10 +215,24 @@ func (i *iam) AddPolicy(name, domain, resource string, actions []string) error { domain = "$none" } + if name != "$anon" { + if user, err := i.im.Get(name); err == nil { + // Update the "updatedAt" field + i.im.Update(name, user) + } + } + return i.am.AddPolicy(name, domain, resource, actions) } func (i *iam) RemovePolicy(name, domain, resource string, actions []string) error { + if len(name) != 0 && name != "$anon" { + if user, err := i.im.Get(name); err == nil { + // Update the "updatedAt" field + i.im.Update(name, user) + } + } + return i.am.RemovePolicy(name, domain, resource, actions) } diff --git a/iam/identity/identity.go b/iam/identity/identity.go index 5e5d8c39..64eb1240 100644 --- a/iam/identity/identity.go +++ b/iam/identity/identity.go @@ -3,14 +3,10 @@ package identity import ( "fmt" "net/url" - "strings" "sync" "time" enctoken "github.com/datarhei/core/v16/encoding/token" - "github.com/datarhei/core/v16/log" - "github.com/datarhei/core/v16/slices" - "github.com/google/uuid" jwtgo "github.com/golang-jwt/jwt/v5" ) @@ -20,79 +16,6 @@ import ( // the same Auth0.User can't have multiple identities // the whole jwks will be part of this package -type User struct { - Name string `json:"name"` - Alias string `json:"alias"` - Superuser bool `json:"superuser"` - Auth UserAuth `json:"auth"` -} - -type UserAuth struct { - API UserAuthAPI `json:"api"` - Services UserAuthServices `json:"services"` -} - -type UserAuthAPI struct { - Password string `json:"password"` - Auth0 UserAuthAPIAuth0 `json:"auth0"` -} - -type UserAuthAPIAuth0 struct { - User string `json:"user"` - Tenant Auth0Tenant `json:"tenant"` -} - -type UserAuthServices struct { - Basic []string `json:"basic"` // Passwords for BasicAuth - Token []string `json:"token"` // Tokens/Streamkey for RTMP and SRT - Session []string `json:"session"` // Secrets for session JWT -} - -func (u *User) Validate() error { - if len(u.Name) == 0 { - return fmt.Errorf("a name is required") - } - - if strings.HasPrefix(u.Name, "$") { - return fmt.Errorf("name is not allowed to start with $") - } - - if len(u.Alias) != 0 { - if strings.HasPrefix(u.Alias, "$") { - return fmt.Errorf("alias is not allowed to start with $") - } - } - - if len(u.Auth.API.Auth0.User) != 0 { - t, err := newAuth0Tenant(u.Auth.API.Auth0.Tenant) - if err != nil { - return fmt.Errorf("auth0: %w", err) - } - - t.Cancel() - } - - return nil -} - -func (u *User) marshalIdentity() *identity { - i := &identity{ - user: *u, - } - - return i -} - -func (u *User) clone() User { - user := *u - - user.Auth.Services.Basic = slices.Copy(u.Auth.Services.Basic) - user.Auth.Services.Token = slices.Copy(u.Auth.Services.Token) - user.Auth.Services.Session = slices.Copy(u.Auth.Services.Session) - - return user -} - type Verifier interface { Name() string // Name returns the name of the identity. Alias() string // Alias returns the alias of the identity, or an empty string if no alias has been set. @@ -484,554 +407,3 @@ func (i *identity) IsSuperuser() bool { return i.user.Superuser } - -type Manager interface { - Create(identity User) error - Update(name string, identity User) error - Delete(name string) error - - Get(name string) (User, error) - GetVerifier(name string) (Verifier, error) - GetVerifierFromAuth0(name string) (Verifier, error) - GetDefaultVerifier() (Verifier, error) - - Reload() error // Reload users from adapter - Save() error // Save users to adapter - List() []User // List all users - - Validators() []string - CreateJWT(name string) (string, string, error) - - Close() -} - -type identityManager struct { - root *identity - - identities map[string]*identity - tenants map[string]*auth0Tenant - - auth0UserIdentityMap map[string]string - - adapter Adapter - autosave bool - logger log.Logger - - jwtRealm string - jwtSecret []byte - - lock sync.RWMutex -} - -type Config struct { - Adapter Adapter - Superuser User - JWTRealm string - JWTSecret string - Logger log.Logger -} - -func New(config Config) (Manager, error) { - im := &identityManager{ - identities: map[string]*identity{}, - tenants: map[string]*auth0Tenant{}, - auth0UserIdentityMap: map[string]string{}, - adapter: config.Adapter, - jwtRealm: config.JWTRealm, - jwtSecret: []byte(config.JWTSecret), - logger: config.Logger, - } - - if im.logger == nil { - im.logger = log.New("") - } - - if im.adapter == nil { - return nil, fmt.Errorf("no adapter provided") - } - - config.Superuser.Superuser = true - identity, err := im.create(config.Superuser) - if err != nil { - return nil, err - } - - im.root = identity - - err = im.Reload() - if err != nil { - return nil, err - } - - im.Save() - - return im, nil -} - -func (im *identityManager) Close() { - im.lock.Lock() - defer im.lock.Unlock() - - im.adapter = nil - im.auth0UserIdentityMap = map[string]string{} - im.identities = map[string]*identity{} - im.root = nil - - for _, t := range im.tenants { - t.Cancel() - } - - im.tenants = map[string]*auth0Tenant{} -} - -func (im *identityManager) Reload() error { - users, err := im.adapter.LoadIdentities() - if err != nil { - return fmt.Errorf("load users from adapter: %w", err) - } - - im.lock.Lock() - defer im.lock.Unlock() - - im.autosave = false - defer func() { - im.autosave = true - }() - - names := []string{} - - for name := range im.identities { - im.delete(name) - } - - for _, name := range names { - im.delete(name) - } - - for _, u := range users { - if ok, _ := im.isNameAvailable(u.Name, u.Alias); !ok { - continue - } - - if err := u.Validate(); err != nil { - return fmt.Errorf("invalid user from adapter: %s, %w", u.Name, err) - } - - identity, err := im.create(u) - if err != nil { - continue - } - - im.identities[identity.user.Name] = identity - if len(identity.user.Alias) != 0 { - im.identities[identity.user.Alias] = identity - } - } - - return nil -} - -func (im *identityManager) isNameAvailable(name, alias string) (bool, error) { - if im.root == nil { - return true, nil - } - - if name == im.root.user.Name { - return false, fmt.Errorf("name already exists") - } - - if name == im.root.user.Alias { - return false, fmt.Errorf("name already exists") - } - - if _, ok := im.identities[name]; ok { - return false, fmt.Errorf("name already exists") - } - - if len(alias) != 0 { - if alias == im.root.user.Name { - return false, fmt.Errorf("alias already exists") - } - - if alias == im.root.user.Alias { - return false, fmt.Errorf("alias already exists") - } - - if _, ok := im.identities[alias]; ok { - return false, fmt.Errorf("alias already exists") - } - } - - return true, nil -} - -func (im *identityManager) Create(u User) error { - if err := u.Validate(); err != nil { - return err - } - - im.lock.Lock() - defer im.lock.Unlock() - - if ok, err := im.isNameAvailable(u.Name, u.Alias); !ok { - return err - } - - identity, err := im.create(u) - if err != nil { - return err - } - - im.identities[identity.user.Name] = identity - if len(identity.user.Alias) != 0 { - im.identities[identity.user.Alias] = identity - } - - if im.autosave { - im.save() - } - - return nil -} - -func (im *identityManager) create(u User) (*identity, error) { - u = u.clone() - identity := u.marshalIdentity() - - if len(identity.user.Auth.API.Auth0.User) != 0 { - if _, ok := im.auth0UserIdentityMap[identity.user.Auth.API.Auth0.User]; ok { - return nil, fmt.Errorf("the Auth0 user has already an identity") - } - - auth0Key := identity.user.Auth.API.Auth0.Tenant.key() - - if tenant, ok := im.tenants[auth0Key]; !ok { - tenant, err := newAuth0Tenant(identity.user.Auth.API.Auth0.Tenant) - if err != nil { - return nil, err - } - - im.tenants[auth0Key] = tenant - identity.tenant = tenant - } else { - tenant.AddClientID(identity.user.Auth.API.Auth0.Tenant.ClientID) - identity.tenant = tenant - } - - im.auth0UserIdentityMap[identity.user.Auth.API.Auth0.User] = u.Name - } - - identity.valid = true - - im.logger.Debug().WithField("name", identity.Name()).Log("Identity created") - - return identity, nil -} - -func (im *identityManager) Update(name string, u User) error { - if err := u.Validate(); err != nil { - return err - } - - im.lock.Lock() - defer im.lock.Unlock() - - if im.root.user.Name == name || im.root.user.Alias == name { - return fmt.Errorf("this identity cannot be updated") - } - - oldidentity, ok := im.identities[name] - if !ok { - return fmt.Errorf("identity not found") - } - - delete(im.identities, oldidentity.user.Name) - delete(im.identities, oldidentity.user.Alias) - - ok, err := im.isNameAvailable(u.Name, u.Alias) - - im.identities[oldidentity.user.Name] = oldidentity - if len(oldidentity.user.Alias) != 0 { - im.identities[oldidentity.user.Alias] = oldidentity - } - - if !ok { - return err - } - - err = im.delete(name) - if err != nil { - return err - } - - identity, err := im.create(u) - if err != nil { - // restore old identity - im.create(oldidentity.user) - im.identities[oldidentity.user.Name] = oldidentity - if len(oldidentity.user.Alias) != 0 { - im.identities[oldidentity.user.Alias] = oldidentity - } - - return err - } - - im.identities[identity.user.Name] = identity - if len(identity.user.Alias) != 0 { - im.identities[identity.user.Alias] = identity - } - - im.logger.Debug().WithFields(log.Fields{ - "oldname": name, - "newname": identity.Name(), - }).Log("Identity updated") - - if im.autosave { - im.save() - } - - return nil -} - -func (im *identityManager) Delete(name string) error { - im.lock.Lock() - defer im.lock.Unlock() - - err := im.delete(name) - if err != nil { - return err - } - - return nil -} - -func (im *identityManager) delete(name string) error { - if im.root.user.Name == name || im.root.user.Alias == name { - return fmt.Errorf("this identity can't be removed") - } - - identity, ok := im.identities[name] - if !ok { - return fmt.Errorf("identity not found") - } - - delete(im.identities, identity.user.Name) - delete(im.identities, identity.user.Alias) - - identity.lock.Lock() - identity.valid = false - identity.lock.Unlock() - - if len(identity.user.Auth.API.Auth0.User) == 0 { - if im.autosave { - im.save() - } - - return nil - } - - delete(im.auth0UserIdentityMap, identity.user.Auth.API.Auth0.User) - - // find out if the tenant is still used somewhere else - found := false - for _, i := range im.identities { - if i.tenant == identity.tenant { - found = true - break - } - } - - if !found { - identity.tenant.Cancel() - delete(im.tenants, identity.user.Auth.API.Auth0.Tenant.key()) - - if im.autosave { - im.save() - } - - return nil - } - - // find out if the tenant's clientid is still used somewhere else - found = false - for _, i := range im.identities { - if len(i.user.Auth.API.Auth0.User) == 0 { - continue - } - - if i.user.Auth.API.Auth0.Tenant.ClientID == identity.user.Auth.API.Auth0.Tenant.ClientID { - found = true - break - } - } - - if !found { - identity.tenant.RemoveClientID(identity.user.Auth.API.Auth0.Tenant.ClientID) - } - - if im.autosave { - if err := im.save(); err != nil { - return err - } - } - - return nil -} - -func (im *identityManager) getIdentity(name string) (*identity, error) { - var identity *identity = nil - - if im.root.user.Name == name || im.root.user.Alias == name { - identity = im.root - } else { - identity = im.identities[name] - } - - if identity == nil { - return nil, fmt.Errorf("identity not found") - } - - identity.jwtRealm = im.jwtRealm - identity.jwtKeyFunc = func(*jwtgo.Token) (interface{}, error) { return im.jwtSecret, nil } - - return identity, nil -} - -func (im *identityManager) Get(name string) (User, error) { - im.lock.RLock() - defer im.lock.RUnlock() - - identity, err := im.getIdentity(name) - if err != nil { - return User{}, err - } - - user := identity.user.clone() - - return user, nil -} - -func (im *identityManager) GetVerifier(name string) (Verifier, error) { - im.lock.RLock() - defer im.lock.RUnlock() - - return im.getIdentity(name) -} - -func (im *identityManager) GetVerifierFromAuth0(name string) (Verifier, error) { - im.lock.RLock() - defer im.lock.RUnlock() - - name, ok := im.auth0UserIdentityMap[name] - if !ok { - return nil, fmt.Errorf("not found") - } - - return im.getIdentity(name) -} - -func (im *identityManager) GetDefaultVerifier() (Verifier, error) { - return im.root, nil -} - -func (im *identityManager) List() []User { - im.lock.RLock() - defer im.lock.RUnlock() - - users := []User{} - - for _, identity := range im.identities { - users = append(users, identity.user.clone()) - } - - return users -} - -func (im *identityManager) Save() error { - im.lock.RLock() - defer im.lock.RUnlock() - - return im.save() -} - -func (im *identityManager) save() error { - users := []User{} - - for _, u := range im.identities { - users = append(users, u.user) - } - - return im.adapter.SaveIdentities(users) -} - -func (im *identityManager) Autosave(auto bool) { - im.lock.Lock() - defer im.lock.Unlock() - - im.autosave = auto -} - -func (im *identityManager) Validators() []string { - validators := []string{"localjwt"} - - im.lock.RLock() - defer im.lock.RUnlock() - - for _, t := range im.tenants { - for _, clientid := range t.clientIDs { - validators = append(validators, fmt.Sprintf("auth0 domain=%s audience=%s clientid=%s", t.domain, t.audience, clientid)) - } - } - - return validators -} - -func (im *identityManager) CreateJWT(name string) (string, string, error) { - im.lock.RLock() - defer im.lock.RUnlock() - - identity, err := im.getIdentity(name) - if err != nil { - return "", "", err - } - - now := time.Now() - accessExpires := now.Add(time.Minute * 10) - refreshExpires := now.Add(time.Hour * 24) - - // Create access token - accessToken := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, jwtgo.MapClaims{ - "iss": im.jwtRealm, - "sub": identity.Name(), - "usefor": "access", - "iat": now.Unix(), - "exp": accessExpires.Unix(), - "exi": uint64(accessExpires.Sub(now).Seconds()), - "jti": uuid.New().String(), - }) - - // Generate encoded access token - at, err := accessToken.SignedString(im.jwtSecret) - if err != nil { - return "", "", err - } - - // Create refresh token - refreshToken := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, jwtgo.MapClaims{ - "iss": im.jwtRealm, - "sub": identity.Name(), - "usefor": "refresh", - "iat": now.Unix(), - "exp": refreshExpires.Unix(), - "exi": uint64(refreshExpires.Sub(now).Seconds()), - "jti": uuid.New().String(), - }) - - // Generate encoded refresh token - rt, err := refreshToken.SignedString(im.jwtSecret) - if err != nil { - return "", "", err - } - - return at, rt, nil -} diff --git a/iam/identity/identity_test.go b/iam/identity/identity_test.go index ed6c717b..903a823b 100644 --- a/iam/identity/identity_test.go +++ b/iam/identity/identity_test.go @@ -17,42 +17,6 @@ func createAdapter() (Adapter, error) { return NewJSONAdapter(dummyfs, "./users.json", nil) } -func TestUserName(t *testing.T) { - user := User{} - - err := user.Validate() - require.Error(t, err) - - user.Name = "foobar_5" - err = user.Validate() - require.NoError(t, err) - - user.Name = "foobar:5" - err = user.Validate() - require.NoError(t, err) - - user.Name = "$foob:ar" - err = user.Validate() - require.Error(t, err) -} - -func TestUserAlias(t *testing.T) { - user := User{ - Name: "foober", - } - - err := user.Validate() - require.NoError(t, err) - - user.Alias = "foobar" - err = user.Validate() - require.NoError(t, err) - - user.Alias = "$foob:ar" - err = user.Validate() - require.Error(t, err) -} - func TestIdentity(t *testing.T) { user := User{ Name: "foobar", @@ -91,26 +55,6 @@ func TestIdentity(t *testing.T) { require.Nil(t, id) } -func TestDefaultIdentity(t *testing.T) { - adapter, err := createAdapter() - require.NoError(t, err) - - im, err := New(Config{ - Adapter: adapter, - Superuser: User{Name: "foobar"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - identity, err := im.GetDefaultVerifier() - require.NoError(t, err) - require.NotNil(t, identity) - require.Equal(t, "foobar", identity.Name()) -} - func TestIdentityAPIAuth(t *testing.T) { user := User{ Name: "foobar", @@ -245,706 +189,3 @@ func TestIdentityServiceSessionAuth(t *testing.T) { require.Equal(t, nil, data) require.NoError(t, err) } - -func TestJWT(t *testing.T) { - adapter, err := createAdapter() - require.NoError(t, err) - - im, err := New(Config{ - Adapter: adapter, - Superuser: User{Name: "foobar"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - access, refresh, err := im.CreateJWT("foobaz") - require.Error(t, err) - require.Equal(t, "", access) - require.Equal(t, "", refresh) - - access, refresh, err = im.CreateJWT("foobar") - require.NoError(t, err) - - identity, err := im.GetVerifier("foobar") - require.NoError(t, err) - require.NotNil(t, identity) - - ok, err := identity.VerifyJWT("something") - require.Error(t, err) - require.False(t, ok) - - ok, err = identity.VerifyJWT(access) - require.NoError(t, err) - require.True(t, ok) - - ok, err = identity.VerifyJWT(refresh) - require.NoError(t, err) - require.True(t, ok) - - err = im.Create(User{Name: "foobaz"}) - require.NoError(t, err) - - access, refresh, err = im.CreateJWT("foobaz") - require.NoError(t, err) - - ok, err = identity.VerifyJWT(access) - require.Error(t, err) - require.False(t, ok) - - ok, err = identity.VerifyJWT(refresh) - require.Error(t, err) - require.False(t, ok) -} - -func TestCreateUser(t *testing.T) { - adapter, err := createAdapter() - require.NoError(t, err) - - im, err := New(Config{ - Adapter: adapter, - Superuser: User{Name: "foobar", Alias: "foobalias"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - err = im.Create(User{Name: "foobar"}) - require.Error(t, err) - - err = im.Create(User{Name: "foobaz", Alias: "foobalias"}) - require.Error(t, err) - - err = im.Create(User{Name: "foobaz", Alias: "alias"}) - require.NoError(t, err) - - err = im.Create(User{Name: "fooboz", Alias: "alias"}) - require.Error(t, err) - - err = im.Create(User{Name: "foobaz", Alias: "somealias"}) - require.Error(t, err) -} - -func TestAlias(t *testing.T) { - adapter, err := createAdapter() - require.NoError(t, err) - - im, err := New(Config{ - Adapter: adapter, - Superuser: User{Name: "foobar", Alias: "foobalias"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - err = im.Create(User{Name: "foobaz", Alias: "alias"}) - require.NoError(t, err) - - identity, err := im.Get("foobar") - require.NoError(t, err) - require.Equal(t, "foobar", identity.Name) - require.Equal(t, "foobalias", identity.Alias) - - identity, err = im.Get("foobalias") - require.NoError(t, err) - require.Equal(t, "foobar", identity.Name) - require.Equal(t, "foobalias", identity.Alias) - - identity, err = im.Get("foobaz") - require.NoError(t, err) - require.Equal(t, "foobaz", identity.Name) - require.Equal(t, "alias", identity.Alias) - - identity, err = im.Get("alias") - require.NoError(t, err) - require.Equal(t, "foobaz", identity.Name) - require.Equal(t, "alias", identity.Alias) - - err = im.Create(User{Name: "alias", Alias: "foobaz"}) - require.Error(t, err) -} - -func TestCreateUserAuth0(t *testing.T) { - adapter, err := createAdapter() - require.NoError(t, err) - - im, err := New(Config{ - Adapter: adapter, - Superuser: User{Name: "foobar"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - require.ElementsMatch(t, []string{"localjwt"}, im.Validators()) - - err = im.Create(User{ - Name: "foobaz", - Superuser: false, - Auth: UserAuth{ - API: UserAuthAPI{ - Auth0: UserAuthAPIAuth0{ - User: "auth0|123456", - Tenant: Auth0Tenant{ - Domain: "example.com", - Audience: "https://api.example.com/", - ClientID: "123456", - }, - }, - }, - }, - }) - require.Error(t, err) - - err = im.Create(User{ - Name: "foobaz", - Superuser: false, - Auth: UserAuth{ - API: UserAuthAPI{ - Auth0: UserAuthAPIAuth0{ - User: "auth0|123456", - Tenant: Auth0Tenant{ - Domain: "datarhei-demo.eu.auth0.com", - Audience: "https://datarhei-demo.eu.auth0.com/api/v2/", - ClientID: "123456", - }, - }, - }, - }, - }) - require.NoError(t, err) - - identity, err := im.GetVerifierFromAuth0("foobaz") - require.Error(t, err) - require.Nil(t, identity) - - identity, err = im.GetVerifierFromAuth0("auth0|123456") - require.NoError(t, err) - require.NotNil(t, identity) - - manager, ok := im.(*identityManager) - require.True(t, ok) - require.NotNil(t, manager) - - require.Equal(t, 1, len(manager.tenants)) - require.Equal(t, map[string]string{"auth0|123456": "foobaz"}, manager.auth0UserIdentityMap) - - require.ElementsMatch(t, []string{ - "localjwt", - "auth0 domain=datarhei-demo.eu.auth0.com audience=https://datarhei-demo.eu.auth0.com/api/v2/ clientid=123456", - }, im.Validators()) - - err = im.Create(User{ - Name: "fooboz", - Superuser: false, - Auth: UserAuth{ - API: UserAuthAPI{ - Auth0: UserAuthAPIAuth0{ - User: "auth0|123456", - Tenant: Auth0Tenant{ - Domain: "datarhei-demo.eu.auth0.com", - Audience: "https://datarhei-demo.eu.auth0.com/api/v2/", - ClientID: "123456", - }, - }, - }, - }, - }) - require.Error(t, err) - - err = im.Create(User{ - Name: "fooboz", - Superuser: false, - Auth: UserAuth{ - API: UserAuthAPI{ - Auth0: UserAuthAPIAuth0{ - User: "auth0|987654", - Tenant: Auth0Tenant{ - Domain: "datarhei-demo.eu.auth0.com", - Audience: "https://datarhei-demo.eu.auth0.com/api/v2/", - ClientID: "987654", - }, - }, - }, - }, - }) - require.NoError(t, err) - - require.Equal(t, 1, len(manager.tenants)) - require.Equal(t, map[string]string{"auth0|123456": "foobaz", "auth0|987654": "fooboz"}, manager.auth0UserIdentityMap) - - require.ElementsMatch(t, []string{ - "localjwt", - "auth0 domain=datarhei-demo.eu.auth0.com audience=https://datarhei-demo.eu.auth0.com/api/v2/ clientid=123456", - "auth0 domain=datarhei-demo.eu.auth0.com audience=https://datarhei-demo.eu.auth0.com/api/v2/ clientid=987654", - }, im.Validators()) - - im.Close() -} - -func TestLoadAndSave(t *testing.T) { - adptr, err := createAdapter() - require.NoError(t, err) - - dummyfs := adptr.(*fileAdapter).fs - - im, err := New(Config{ - Adapter: adptr, - Superuser: User{Name: "foobar"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - err = im.Save() - require.NoError(t, err) - - _, err = dummyfs.Stat("./users.json") - require.NoError(t, err) - - data, err := dummyfs.ReadFile("./users.json") - require.NoError(t, err) - require.Equal(t, []byte("[]"), data) - - err = im.Create(User{Name: "foobaz", Alias: "alias"}) - require.NoError(t, err) - - identity, err := im.GetVerifier("foobaz") - require.NoError(t, err) - require.NotNil(t, identity) - - identity, err = im.GetVerifier("alias") - require.NoError(t, err) - require.NotNil(t, identity) - - err = im.Save() - require.NoError(t, err) - - im, err = New(Config{ - Adapter: adptr, - Superuser: User{Name: "foobar"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - identity, err = im.GetVerifier("foobaz") - require.NoError(t, err) - require.NotNil(t, identity) - - identity, err = im.GetVerifier("alias") - require.NoError(t, err) - require.NotNil(t, identity) -} - -func TestUpdateUser(t *testing.T) { - adapter, err := createAdapter() - require.NoError(t, err) - - im, err := New(Config{ - Adapter: adapter, - Superuser: User{Name: "foobar"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - err = im.Create(User{Name: "fooboz"}) - require.NoError(t, err) - - err = im.Update("unknown", User{Name: "fooboz"}) - require.Error(t, err) - - err = im.Update("foobar", User{Name: "foobar"}) - require.Error(t, err) - - err = im.Update("foobar", User{Name: "fooboz"}) - require.Error(t, err) - - identity, err := im.GetVerifier("foobar") - require.NoError(t, err) - require.NotNil(t, identity) - require.Equal(t, "foobar", identity.Name()) - - err = im.Update("foobar", User{Name: "foobaz"}) - require.Error(t, err) - require.Equal(t, "foobar", identity.Name()) - - identity, err = im.GetVerifier("foobaz") - require.Error(t, err) - require.Nil(t, identity) - - identity, err = im.GetVerifier("fooboz") - require.NoError(t, err) - require.NotNil(t, identity) - require.Equal(t, "fooboz", identity.Name()) - - err = im.Update("fooboz", User{Name: "foobaz"}) - require.NoError(t, err) -} - -func TestUpdateUserAlias(t *testing.T) { - adapter, err := createAdapter() - require.NoError(t, err) - - im, err := New(Config{ - Adapter: adapter, - Superuser: User{Name: "foobar", Alias: "superalias"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - err = im.Create(User{Name: "fooboz"}) - require.NoError(t, err) - - identity, err := im.GetVerifier("fooboz") - require.NoError(t, err) - require.NotNil(t, identity) - require.Equal(t, "fooboz", identity.Name()) - - _, err = im.GetVerifier("alias") - require.Error(t, err) - - err = im.Update("fooboz", User{Name: "fooboz", Alias: "alias"}) - require.NoError(t, err) - - identity, err = im.GetVerifier("fooboz") - require.NoError(t, err) - require.NotNil(t, identity) - require.Equal(t, "fooboz", identity.Name()) - require.Equal(t, "alias", identity.Alias()) - - err = im.Create(User{Name: "barfoo", Alias: "alias2"}) - require.NoError(t, err) - - err = im.Update("fooboz", User{Name: "fooboz", Alias: "alias2"}) - require.Error(t, err) - - err = im.Update("fooboz", User{Name: "barfoo", Alias: "alias"}) - require.Error(t, err) - - err = im.Update("fooboz", User{Name: "fooboz", Alias: "superalias"}) - require.Error(t, err) - - err = im.Update("fooboz", User{Name: "foobar", Alias: ""}) - require.Error(t, err) -} - -func TestUpdateUserAuth0(t *testing.T) { - adapter, err := createAdapter() - require.NoError(t, err) - - im, err := New(Config{ - Adapter: adapter, - Superuser: User{Name: "foobar"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - err = im.Create(User{ - Name: "foobaz", - Superuser: false, - Auth: UserAuth{ - API: UserAuthAPI{ - Auth0: UserAuthAPIAuth0{ - User: "auth0|123456", - Tenant: Auth0Tenant{ - Domain: "datarhei-demo.eu.auth0.com", - Audience: "https://datarhei-demo.eu.auth0.com/api/v2/", - ClientID: "123456", - }, - }, - }, - }, - }) - require.NoError(t, err) - - identity, err := im.GetVerifierFromAuth0("auth0|123456") - require.NoError(t, err) - require.NotNil(t, identity) - - identity, err = im.GetVerifier("foobaz") - require.NoError(t, err) - require.NotNil(t, identity) - - user, err := im.Get("foobaz") - require.NoError(t, err) - - user.Name = "fooboz" - - err = im.Update("foobaz", user) - require.NoError(t, err) - - identity, err = im.GetVerifierFromAuth0("auth0|123456") - require.NoError(t, err) - require.NotNil(t, identity) - - identity, err = im.GetVerifier("fooboz") - require.NoError(t, err) - require.NotNil(t, identity) -} - -func TestRemoveUser(t *testing.T) { - adapter, err := createAdapter() - require.NoError(t, err) - - im, err := New(Config{ - Adapter: adapter, - Superuser: User{Name: "foobar"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - err = im.Delete("fooboz") - require.Error(t, err) - - err = im.Delete("foobar") - require.Error(t, err) - - err = im.Create(User{ - Name: "foobaz", - Superuser: false, - Auth: UserAuth{ - API: UserAuthAPI{ - Password: "apisecret", - Auth0: UserAuthAPIAuth0{}, - }, - Services: UserAuthServices{ - Basic: []string{"secret"}, - Token: []string{"tokensecret"}, - Session: []string{"sessionsecret"}, - }, - }, - }) - require.NoError(t, err) - - identity, err := im.GetVerifier("foobaz") - require.NoError(t, err) - require.NotNil(t, identity) - - ok, err := identity.VerifyAPIPassword("apisecret") - require.True(t, ok) - require.NoError(t, err) - - ok, err = identity.VerifyServiceBasicAuth("secret") - require.True(t, ok) - require.NoError(t, err) - - ok, err = identity.VerifyServiceToken("tokensecret") - require.True(t, ok) - require.NoError(t, err) - - session := identity.GetServiceSession(nil, time.Hour) - - ok, data, err := identity.VerifyServiceSession(session) - require.True(t, ok) - require.Equal(t, nil, data) - require.NoError(t, err) - - access, refresh, err := im.CreateJWT("foobaz") - require.NoError(t, err) - - ok, err = identity.VerifyJWT(access) - require.True(t, ok) - require.NoError(t, err) - - ok, err = identity.VerifyJWT(refresh) - require.True(t, ok) - require.NoError(t, err) - - err = im.Delete("foobaz") - require.NoError(t, err) - - ok, err = identity.VerifyAPIPassword("apisecret") - require.False(t, ok) - require.Error(t, err) - - ok, err = identity.VerifyServiceBasicAuth("secret") - require.False(t, ok) - require.Error(t, err) - - ok, err = identity.VerifyServiceToken("tokensecret") - require.False(t, ok) - require.Error(t, err) - - ok, data, err = identity.VerifyServiceSession(session) - require.False(t, ok) - require.Equal(t, nil, data) - require.Error(t, err) - - ok, err = identity.VerifyJWT(access) - require.False(t, ok) - require.Error(t, err) - - ok, err = identity.VerifyJWT(refresh) - require.False(t, ok) - require.Error(t, err) - - identity, err = im.GetVerifier("foobaz") - require.Error(t, err) - require.Nil(t, identity) - - access, refresh, err = im.CreateJWT("foobaz") - require.Error(t, err) - require.Empty(t, access) - require.Empty(t, refresh) -} - -func TestRemoveUserAuth0(t *testing.T) { - adapter, err := createAdapter() - require.NoError(t, err) - - im, err := New(Config{ - Adapter: adapter, - Superuser: User{Name: "foobar"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - err = im.Create(User{ - Name: "foobaz", - Superuser: false, - Auth: UserAuth{ - API: UserAuthAPI{ - Auth0: UserAuthAPIAuth0{ - User: "auth0|123456", - Tenant: Auth0Tenant{ - Domain: "datarhei-demo.eu.auth0.com", - Audience: "https://datarhei-demo.eu.auth0.com/api/v2/", - ClientID: "123456", - }, - }, - }, - }, - }) - require.NoError(t, err) - - err = im.Create(User{ - Name: "fooboz", - Superuser: false, - Auth: UserAuth{ - API: UserAuthAPI{ - Auth0: UserAuthAPIAuth0{ - User: "auth0|987654", - Tenant: Auth0Tenant{ - Domain: "datarhei-demo.eu.auth0.com", - Audience: "https://datarhei-demo.eu.auth0.com/api/v2/", - ClientID: "987654", - }, - }, - }, - }, - }) - require.NoError(t, err) - - manager, ok := im.(*identityManager) - require.True(t, ok) - require.NotNil(t, manager) - - require.Equal(t, 1, len(manager.tenants)) - require.Equal(t, map[string]string{"auth0|123456": "foobaz", "auth0|987654": "fooboz"}, manager.auth0UserIdentityMap) - - require.ElementsMatch(t, []string{ - "localjwt", - "auth0 domain=datarhei-demo.eu.auth0.com audience=https://datarhei-demo.eu.auth0.com/api/v2/ clientid=123456", - "auth0 domain=datarhei-demo.eu.auth0.com audience=https://datarhei-demo.eu.auth0.com/api/v2/ clientid=987654", - }, im.Validators()) - - err = im.Delete("foobaz") - require.NoError(t, err) - - require.Equal(t, 1, len(manager.tenants)) - require.Equal(t, map[string]string{"auth0|987654": "fooboz"}, manager.auth0UserIdentityMap) - - require.ElementsMatch(t, []string{ - "localjwt", - "auth0 domain=datarhei-demo.eu.auth0.com audience=https://datarhei-demo.eu.auth0.com/api/v2/ clientid=987654", - }, im.Validators()) - - err = im.Delete("fooboz") - require.NoError(t, err) - - require.Equal(t, 0, len(manager.tenants)) - require.ElementsMatch(t, []string{ - "localjwt", - }, im.Validators()) -} - -func TestAutosave(t *testing.T) { - adptr, err := createAdapter() - require.NoError(t, err) - - dummyfs := adptr.(*fileAdapter).fs - - im, err := New(Config{ - Adapter: adptr, - Superuser: User{Name: "foobar"}, - JWTRealm: "test-realm", - JWTSecret: "abc123", - Logger: nil, - }) - require.NoError(t, err) - require.NotNil(t, im) - - err = im.Save() - require.NoError(t, err) - - _, err = dummyfs.Stat("./users.json") - require.NoError(t, err) - - data, err := dummyfs.ReadFile("./users.json") - require.NoError(t, err) - require.Equal(t, []byte("[]"), data) - - err = im.Create(User{Name: "foobaz"}) - require.NoError(t, err) - - data, err = dummyfs.ReadFile("./users.json") - require.NoError(t, err) - require.NotEqual(t, []byte("[]"), data) - - user, err := im.Get("foobaz") - require.NoError(t, err) - - user.Name = "fooboz" - - err = im.Update("foobaz", user) - require.NoError(t, err) - - data, err = dummyfs.ReadFile("./users.json") - require.NoError(t, err) - require.NotEqual(t, []byte("[]"), data) - - err = im.Delete("fooboz") - require.NoError(t, err) - - data, err = dummyfs.ReadFile("./users.json") - require.NoError(t, err) - require.Equal(t, []byte("[]"), data) -} diff --git a/iam/identity/manager.go b/iam/identity/manager.go new file mode 100644 index 00000000..01c14c9a --- /dev/null +++ b/iam/identity/manager.go @@ -0,0 +1,536 @@ +package identity + +import ( + "fmt" + "sync" + "time" + + "github.com/datarhei/core/v16/log" + jwtgo "github.com/golang-jwt/jwt/v5" + "github.com/google/uuid" +) + +type Manager interface { + Create(identity User) error + Update(name string, identity User) error + Delete(name string) error + + Get(name string) (User, error) + GetVerifier(name string) (Verifier, error) + GetVerifierFromAuth0(name string) (Verifier, error) + GetDefaultVerifier() (Verifier, error) + + Reload() error // Reload users from adapter + Save() error // Save users to adapter + List() []User // List all users + + Validators() []string + CreateJWT(name string) (string, string, error) + + Close() +} + +type identityManager struct { + root *identity + + userlist UserList + + identities map[string]*identity + tenants map[string]*auth0Tenant + + auth0UserIdentityMap map[string]string + + adapter Adapter + autosave bool + logger log.Logger + + jwtRealm string + jwtSecret []byte + + lock sync.RWMutex +} + +type Config struct { + Adapter Adapter + Superuser User + JWTRealm string + JWTSecret string + Logger log.Logger +} + +func New(config Config) (Manager, error) { + im := &identityManager{ + userlist: NewUserList(), + identities: map[string]*identity{}, + tenants: map[string]*auth0Tenant{}, + auth0UserIdentityMap: map[string]string{}, + adapter: config.Adapter, + jwtRealm: config.JWTRealm, + jwtSecret: []byte(config.JWTSecret), + logger: config.Logger, + } + + if im.logger == nil { + im.logger = log.New("") + } + + if im.adapter == nil { + return nil, fmt.Errorf("no adapter provided") + } + + config.Superuser.Superuser = true + identity, err := im.createIdentity(config.Superuser) + if err != nil { + return nil, err + } + + im.root = identity + + err = im.Reload() + if err != nil { + return nil, err + } + + im.Save() + + return im, nil +} + +func (im *identityManager) Close() { + im.lock.Lock() + defer im.lock.Unlock() + + im.adapter = nil + im.userlist = nil + im.identities = map[string]*identity{} + im.root = nil + + for _, t := range im.tenants { + t.Cancel() + } + + im.tenants = map[string]*auth0Tenant{} +} + +func (im *identityManager) Reload() error { + users, err := im.adapter.LoadIdentities() + if err != nil { + return fmt.Errorf("load users from adapter: %w", err) + } + + userlist := NewUserList() + + now := time.Now() + + for _, u := range users { + if u.CreatedAt.IsZero() { + u.CreatedAt = now + } + + if u.UpdatedAt.IsZero() { + u.UpdatedAt = now + } + + err := userlist.Add(u) + if err != nil { + return fmt.Errorf("invalid user %s from adapter: %w", u.Name, err) + } + } + + userlist.Delete(im.root.user.Name) + userlist.Delete(im.root.user.Alias) + + im.lock.Lock() + defer im.lock.Unlock() + + im.autosave = false + defer func() { + im.autosave = true + }() + + for name := range im.identities { + im.delete(name) + } + + im.userlist = userlist + + for _, u := range userlist.List() { + identity, err := im.createIdentity(u) + if err != nil { + continue + } + + im.identities[u.Name] = identity + } + + return nil +} + +func (im *identityManager) Create(u User) error { + im.lock.Lock() + defer im.lock.Unlock() + + if im.root.user.Name == u.Name || im.root.user.Alias == u.Name { + return fmt.Errorf("the identity %s already exists", u.Name) + } + + if len(u.Alias) != 0 { + if im.root.user.Name == u.Alias || im.root.user.Alias == u.Alias { + return fmt.Errorf("the identity %s already exists", u.Alias) + } + } + + now := time.Now() + + u.CreatedAt = now + u.UpdatedAt = now + + err := im.userlist.Add(u) + if err != nil { + return err + } + + identity, err := im.createIdentity(u) + if err != nil { + return err + } + + im.identities[identity.user.Name] = identity + + if im.autosave { + im.save() + } + + return nil +} + +func (im *identityManager) createIdentity(u User) (*identity, error) { + u = u.clone() + identity := u.marshalIdentity() + + if len(identity.user.Auth.API.Auth0.User) != 0 { + auth0Key := identity.user.Auth.API.Auth0.Tenant.key() + + if tenant, ok := im.tenants[auth0Key]; !ok { + tenant, err := newAuth0Tenant(identity.user.Auth.API.Auth0.Tenant) + if err != nil { + return nil, err + } + + im.tenants[auth0Key] = tenant + identity.tenant = tenant + } else { + tenant.AddClientID(identity.user.Auth.API.Auth0.Tenant.ClientID) + identity.tenant = tenant + } + + im.auth0UserIdentityMap[identity.user.Auth.API.Auth0.User] = identity.user.Name + } + + identity.valid = true + + im.logger.Debug().WithField("name", identity.Name()).Log("Identity created") + + return identity, nil +} + +func (im *identityManager) Update(nameOrAlias string, u User) error { + im.lock.Lock() + defer im.lock.Unlock() + + if im.root.user.Name == nameOrAlias || im.root.user.Alias == nameOrAlias { + return fmt.Errorf("the identity %s cannot be updated", nameOrAlias) + } + + if im.root.user.Name == u.Name || im.root.user.Alias == u.Name { + return fmt.Errorf("the identity %s already exists", u.Name) + } + + if len(u.Alias) != 0 { + if im.root.user.Name == u.Alias || im.root.user.Alias == u.Alias { + return fmt.Errorf("the identity %s already exists", u.Alias) + } + } + + oldUser, err := im.userlist.Get(nameOrAlias) + if err != nil { + return err + } + + u.CreatedAt = oldUser.CreatedAt + u.UpdatedAt = time.Now() + + if err = im.userlist.Update(nameOrAlias, u); err != nil { + return err + } + + user, err := im.userlist.Get(u.Name) + if err != nil { + return err + } + + _, ok := im.identities[oldUser.Name] + if !ok { + return fmt.Errorf("identity not found") + } + + identity, err := im.createIdentity(u) + if err != nil { + return err + } + + im.identities[user.Name] = identity + + im.logger.Debug().WithFields(log.Fields{ + "oldname": oldUser.Name, + "newname": user.Name, + }).Log("Identity updated") + + if im.autosave { + im.save() + } + + return nil +} + +func (im *identityManager) Delete(nameOrAlias string) error { + im.lock.Lock() + defer im.lock.Unlock() + + err := im.delete(nameOrAlias) + if err != nil { + return err + } + + return nil +} + +func (im *identityManager) delete(nameOrAlias string) error { + if im.root.user.Name == nameOrAlias || im.root.user.Alias == nameOrAlias { + return fmt.Errorf("this identity can't be removed") + } + + user, err := im.userlist.Get(nameOrAlias) + if err != nil { + return err + } + + identity, ok := im.identities[user.Name] + if !ok { + return fmt.Errorf("identity not found") + } + + im.userlist.Delete(user.Name) + delete(im.identities, user.Name) + + identity.lock.Lock() + identity.valid = false + identity.lock.Unlock() + + if len(user.Auth.API.Auth0.User) == 0 { + if im.autosave { + im.save() + } + + return nil + } + + delete(im.auth0UserIdentityMap, user.Auth.API.Auth0.User) + + // find out if the tenant is still used somewhere else + found := false + for _, i := range im.identities { + if i.tenant == identity.tenant { + found = true + break + } + } + + if !found { + identity.tenant.Cancel() + delete(im.tenants, identity.user.Auth.API.Auth0.Tenant.key()) + + if im.autosave { + im.save() + } + + return nil + } + + // find out if the tenant's clientid is still used somewhere else + found = false + for _, i := range im.identities { + if len(i.user.Auth.API.Auth0.User) == 0 { + continue + } + + if i.user.Auth.API.Auth0.Tenant.ClientID == identity.user.Auth.API.Auth0.Tenant.ClientID { + found = true + break + } + } + + if !found { + identity.tenant.RemoveClientID(identity.user.Auth.API.Auth0.Tenant.ClientID) + } + + if im.autosave { + if err := im.save(); err != nil { + return err + } + } + + return nil +} + +func (im *identityManager) getIdentity(nameOrAlias string) (*identity, error) { + var identity *identity = nil + + if im.root.user.Name == nameOrAlias || im.root.user.Alias == nameOrAlias { + identity = im.root + } else { + user, err := im.userlist.Get(nameOrAlias) + if err != nil { + return nil, fmt.Errorf("identity not found") + } + identity = im.identities[user.Name] + } + + if identity == nil { + return nil, fmt.Errorf("identity not found") + } + + identity.jwtRealm = im.jwtRealm + identity.jwtKeyFunc = func(*jwtgo.Token) (interface{}, error) { return im.jwtSecret, nil } + + return identity, nil +} + +func (im *identityManager) Get(nameOrAlias string) (User, error) { + im.lock.RLock() + defer im.lock.RUnlock() + + identity, err := im.getIdentity(nameOrAlias) + if err != nil { + return User{}, err + } + + user := identity.user.clone() + + return user, nil +} + +func (im *identityManager) GetVerifier(nameOrAlias string) (Verifier, error) { + im.lock.RLock() + defer im.lock.RUnlock() + + return im.getIdentity(nameOrAlias) +} + +func (im *identityManager) GetVerifierFromAuth0(auth0Name string) (Verifier, error) { + im.lock.RLock() + defer im.lock.RUnlock() + + name, ok := im.auth0UserIdentityMap[auth0Name] + if !ok { + return nil, fmt.Errorf("not found") + } + + return im.getIdentity(name) +} + +func (im *identityManager) GetDefaultVerifier() (Verifier, error) { + return im.root, nil +} + +func (im *identityManager) List() []User { + im.lock.RLock() + defer im.lock.RUnlock() + + return im.userlist.List() +} + +func (im *identityManager) Save() error { + im.lock.RLock() + defer im.lock.RUnlock() + + return im.save() +} + +func (im *identityManager) save() error { + users := im.userlist.List() + + return im.adapter.SaveIdentities(users) +} + +func (im *identityManager) Autosave(auto bool) { + im.lock.Lock() + defer im.lock.Unlock() + + im.autosave = auto +} + +func (im *identityManager) Validators() []string { + validators := []string{"localjwt"} + + im.lock.RLock() + defer im.lock.RUnlock() + + for _, t := range im.tenants { + for _, clientid := range t.clientIDs { + validators = append(validators, fmt.Sprintf("auth0 domain=%s audience=%s clientid=%s", t.domain, t.audience, clientid)) + } + } + + return validators +} + +func (im *identityManager) CreateJWT(nameOrAlias string) (string, string, error) { + im.lock.RLock() + defer im.lock.RUnlock() + + identity, err := im.getIdentity(nameOrAlias) + if err != nil { + return "", "", err + } + + now := time.Now() + accessExpires := now.Add(time.Minute * 10) + refreshExpires := now.Add(time.Hour * 24) + + // Create access token + accessToken := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, jwtgo.MapClaims{ + "iss": im.jwtRealm, + "sub": identity.Name(), + "usefor": "access", + "iat": now.Unix(), + "exp": accessExpires.Unix(), + "exi": uint64(accessExpires.Sub(now).Seconds()), + "jti": uuid.New().String(), + }) + + // Generate encoded access token + at, err := accessToken.SignedString(im.jwtSecret) + if err != nil { + return "", "", err + } + + // Create refresh token + refreshToken := jwtgo.NewWithClaims(jwtgo.SigningMethodHS256, jwtgo.MapClaims{ + "iss": im.jwtRealm, + "sub": identity.Name(), + "usefor": "refresh", + "iat": now.Unix(), + "exp": refreshExpires.Unix(), + "exi": uint64(refreshExpires.Sub(now).Seconds()), + "jti": uuid.New().String(), + }) + + // Generate encoded refresh token + rt, err := refreshToken.SignedString(im.jwtSecret) + if err != nil { + return "", "", err + } + + return at, rt, nil +} diff --git a/iam/identity/manager_test.go b/iam/identity/manager_test.go new file mode 100644 index 00000000..3c91525a --- /dev/null +++ b/iam/identity/manager_test.go @@ -0,0 +1,824 @@ +package identity + +import ( + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestDefaultIdentity(t *testing.T) { + adapter, err := createAdapter() + require.NoError(t, err) + + im, err := New(Config{ + Adapter: adapter, + Superuser: User{Name: "foobar"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + identity, err := im.GetDefaultVerifier() + require.NoError(t, err) + require.NotNil(t, identity) + require.Equal(t, "foobar", identity.Name()) +} + +func TestJWT(t *testing.T) { + adapter, err := createAdapter() + require.NoError(t, err) + + im, err := New(Config{ + Adapter: adapter, + Superuser: User{Name: "foobar"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + access, refresh, err := im.CreateJWT("foobaz") + require.Error(t, err) + require.Equal(t, "", access) + require.Equal(t, "", refresh) + + access, refresh, err = im.CreateJWT("foobar") + require.NoError(t, err) + + identity, err := im.GetVerifier("foobar") + require.NoError(t, err) + require.NotNil(t, identity) + + ok, err := identity.VerifyJWT("something") + require.Error(t, err) + require.False(t, ok) + + ok, err = identity.VerifyJWT(access) + require.NoError(t, err) + require.True(t, ok) + + ok, err = identity.VerifyJWT(refresh) + require.NoError(t, err) + require.True(t, ok) + + err = im.Create(User{Name: "foobaz"}) + require.NoError(t, err) + + access, refresh, err = im.CreateJWT("foobaz") + require.NoError(t, err) + + ok, err = identity.VerifyJWT(access) + require.Error(t, err) + require.False(t, ok) + + ok, err = identity.VerifyJWT(refresh) + require.Error(t, err) + require.False(t, ok) +} + +func TestCreateUser(t *testing.T) { + adapter, err := createAdapter() + require.NoError(t, err) + + im, err := New(Config{ + Adapter: adapter, + Superuser: User{Name: "foobar", Alias: "foobalias"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + err = im.Create(User{Name: "foobar"}) + require.Error(t, err) + + err = im.Create(User{Name: "foobaz", Alias: "foobalias"}) + require.Error(t, err) + + err = im.Create(User{Name: "foobaz", Alias: "alias"}) + require.NoError(t, err) + + user, err := im.Get("foobaz") + require.NoError(t, err) + require.Equal(t, user.CreatedAt, user.UpdatedAt) + require.NotEqual(t, time.Time{}, user.CreatedAt) + + err = im.Create(User{Name: "fooboz", Alias: "alias"}) + require.Error(t, err) + + err = im.Create(User{Name: "foobaz", Alias: "somealias"}) + require.Error(t, err) +} + +func TestAlias(t *testing.T) { + adapter, err := createAdapter() + require.NoError(t, err) + + im, err := New(Config{ + Adapter: adapter, + Superuser: User{Name: "foobar", Alias: "foobalias"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + err = im.Create(User{Name: "foobaz", Alias: "alias"}) + require.NoError(t, err) + + identity, err := im.Get("foobar") + require.NoError(t, err) + require.Equal(t, "foobar", identity.Name) + require.Equal(t, "foobalias", identity.Alias) + + identity, err = im.Get("foobalias") + require.NoError(t, err) + require.Equal(t, "foobar", identity.Name) + require.Equal(t, "foobalias", identity.Alias) + + identity, err = im.Get("foobaz") + require.NoError(t, err) + require.Equal(t, "foobaz", identity.Name) + require.Equal(t, "alias", identity.Alias) + + identity, err = im.Get("alias") + require.NoError(t, err) + require.Equal(t, "foobaz", identity.Name) + require.Equal(t, "alias", identity.Alias) + + err = im.Create(User{Name: "alias", Alias: "foobaz"}) + require.Error(t, err) +} + +func TestCreateUserAuth0(t *testing.T) { + adapter, err := createAdapter() + require.NoError(t, err) + + im, err := New(Config{ + Adapter: adapter, + Superuser: User{Name: "foobar"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + require.ElementsMatch(t, []string{"localjwt"}, im.Validators()) + + err = im.Create(User{ + Name: "foobaz", + Superuser: false, + Auth: UserAuth{ + API: UserAuthAPI{ + Auth0: UserAuthAPIAuth0{ + User: "auth0|123456", + Tenant: Auth0Tenant{ + Domain: "example.com", + Audience: "https://api.example.com/", + ClientID: "123456", + }, + }, + }, + }, + }) + require.Error(t, err) + + err = im.Create(User{ + Name: "foobaz", + Superuser: false, + Auth: UserAuth{ + API: UserAuthAPI{ + Auth0: UserAuthAPIAuth0{ + User: "auth0|123456", + Tenant: Auth0Tenant{ + Domain: "datarhei-demo.eu.auth0.com", + Audience: "https://datarhei-demo.eu.auth0.com/api/v2/", + ClientID: "123456", + }, + }, + }, + }, + }) + require.NoError(t, err) + + identity, err := im.GetVerifierFromAuth0("foobaz") + require.Error(t, err) + require.Nil(t, identity) + + identity, err = im.GetVerifierFromAuth0("auth0|123456") + require.NoError(t, err) + require.NotNil(t, identity) + + manager, ok := im.(*identityManager) + require.True(t, ok) + require.NotNil(t, manager) + + require.Equal(t, 1, len(manager.tenants)) + require.Equal(t, map[string]string{"auth0|123456": "foobaz"}, manager.auth0UserIdentityMap) + + require.ElementsMatch(t, []string{ + "localjwt", + "auth0 domain=datarhei-demo.eu.auth0.com audience=https://datarhei-demo.eu.auth0.com/api/v2/ clientid=123456", + }, im.Validators()) + + err = im.Create(User{ + Name: "fooboz", + Superuser: false, + Auth: UserAuth{ + API: UserAuthAPI{ + Auth0: UserAuthAPIAuth0{ + User: "auth0|123456", + Tenant: Auth0Tenant{ + Domain: "datarhei-demo.eu.auth0.com", + Audience: "https://datarhei-demo.eu.auth0.com/api/v2/", + ClientID: "123456", + }, + }, + }, + }, + }) + require.Error(t, err) + + err = im.Create(User{ + Name: "fooboz", + Superuser: false, + Auth: UserAuth{ + API: UserAuthAPI{ + Auth0: UserAuthAPIAuth0{ + User: "auth0|987654", + Tenant: Auth0Tenant{ + Domain: "datarhei-demo.eu.auth0.com", + Audience: "https://datarhei-demo.eu.auth0.com/api/v2/", + ClientID: "987654", + }, + }, + }, + }, + }) + require.NoError(t, err) + + require.Equal(t, 1, len(manager.tenants)) + require.Equal(t, map[string]string{"auth0|123456": "foobaz", "auth0|987654": "fooboz"}, manager.auth0UserIdentityMap) + + require.ElementsMatch(t, []string{ + "localjwt", + "auth0 domain=datarhei-demo.eu.auth0.com audience=https://datarhei-demo.eu.auth0.com/api/v2/ clientid=123456", + "auth0 domain=datarhei-demo.eu.auth0.com audience=https://datarhei-demo.eu.auth0.com/api/v2/ clientid=987654", + }, im.Validators()) + + im.Close() +} + +func TestLoadAndSave(t *testing.T) { + adptr, err := createAdapter() + require.NoError(t, err) + + dummyfs := adptr.(*fileAdapter).fs + + im, err := New(Config{ + Adapter: adptr, + Superuser: User{Name: "foobar"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + err = im.Save() + require.NoError(t, err) + + _, err = dummyfs.Stat("./users.json") + require.NoError(t, err) + + data, err := dummyfs.ReadFile("./users.json") + require.NoError(t, err) + require.Equal(t, []byte("[]"), data) + + err = im.Create(User{Name: "foobaz", Alias: "alias"}) + require.NoError(t, err) + + identity, err := im.GetVerifier("foobaz") + require.NoError(t, err) + require.NotNil(t, identity) + + identity, err = im.GetVerifier("alias") + require.NoError(t, err) + require.NotNil(t, identity) + + user, err := im.Get("foobaz") + require.NoError(t, err) + + createdAt := user.CreatedAt + updatedAt := user.UpdatedAt + + err = im.Save() + require.NoError(t, err) + + im, err = New(Config{ + Adapter: adptr, + Superuser: User{Name: "foobar"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + identity, err = im.GetVerifier("foobaz") + require.NoError(t, err) + require.NotNil(t, identity) + + identity, err = im.GetVerifier("alias") + require.NoError(t, err) + require.NotNil(t, identity) + + user, err = im.Get("foobaz") + require.NoError(t, err) + + require.True(t, createdAt.Equal(user.CreatedAt)) + require.True(t, updatedAt.Equal(user.UpdatedAt)) +} + +func TestReloadIdempotent(t *testing.T) { + adapter, err := createAdapter() + require.NoError(t, err) + + im, err := New(Config{ + Adapter: adapter, + Superuser: User{Name: "foobar"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + err = im.Create(User{Name: "foobaz", Alias: "alias"}) + require.NoError(t, err) + + err = im.Save() + require.NoError(t, err) + + _, err = im.Get("foobaz") + require.NoError(t, err) + + err = im.Reload() + require.NoError(t, err) + + _, err = im.Get("foobaz") + require.NoError(t, err) + + err = im.Reload() + require.NoError(t, err) + + _, err = im.Get("foobaz") + require.NoError(t, err) +} + +func TestUpdateUser(t *testing.T) { + adapter, err := createAdapter() + require.NoError(t, err) + + im, err := New(Config{ + Adapter: adapter, + Superuser: User{Name: "foobar"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + err = im.Create(User{Name: "fooboz"}) + require.NoError(t, err) + + user, err := im.Get("fooboz") + require.NoError(t, err) + require.True(t, user.CreatedAt.Equal(user.UpdatedAt)) + require.False(t, time.Time{}.Equal(user.CreatedAt)) + + err = im.Update("unknown", User{Name: "fooboz"}) + require.Error(t, err) + + err = im.Update("foobar", User{Name: "foobar"}) + require.Error(t, err) + + err = im.Update("foobar", User{Name: "fooboz"}) + require.Error(t, err) + + identity, err := im.GetVerifier("foobar") + require.NoError(t, err) + require.NotNil(t, identity) + require.Equal(t, "foobar", identity.Name()) + + err = im.Update("foobar", User{Name: "foobaz"}) + require.Error(t, err) + require.Equal(t, "foobar", identity.Name()) + + identity, err = im.GetVerifier("foobaz") + require.Error(t, err) + require.Nil(t, identity) + + identity, err = im.GetVerifier("fooboz") + require.NoError(t, err) + require.NotNil(t, identity) + require.Equal(t, "fooboz", identity.Name()) + + time.Sleep(1 * time.Second) + + err = im.Update("fooboz", User{Name: "foobaz"}) + require.NoError(t, err) + + user, err = im.Get("foobaz") + require.NoError(t, err) + require.True(t, user.CreatedAt.Equal(user.CreatedAt)) + require.False(t, user.CreatedAt.Equal(user.UpdatedAt)) + require.False(t, time.Time{}.Equal(user.CreatedAt)) +} + +func TestUpdateUserAlias(t *testing.T) { + adapter, err := createAdapter() + require.NoError(t, err) + + im, err := New(Config{ + Adapter: adapter, + Superuser: User{Name: "foobar", Alias: "superalias"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + err = im.Create(User{Name: "fooboz"}) + require.NoError(t, err) + + identity, err := im.GetVerifier("fooboz") + require.NoError(t, err) + require.NotNil(t, identity) + require.Equal(t, "fooboz", identity.Name()) + + _, err = im.GetVerifier("alias") + require.Error(t, err) + + err = im.Update("fooboz", User{Name: "fooboz", Alias: "alias"}) + require.NoError(t, err) + + identity, err = im.GetVerifier("fooboz") + require.NoError(t, err) + require.NotNil(t, identity) + require.Equal(t, "fooboz", identity.Name()) + require.Equal(t, "alias", identity.Alias()) + + err = im.Create(User{Name: "barfoo", Alias: "alias2"}) + require.NoError(t, err) + + err = im.Update("fooboz", User{Name: "fooboz", Alias: "alias2"}) + require.Error(t, err) + + err = im.Update("fooboz", User{Name: "barfoo", Alias: "alias"}) + require.Error(t, err) + + err = im.Update("fooboz", User{Name: "fooboz", Alias: "superalias"}) + require.Error(t, err) + + err = im.Update("fooboz", User{Name: "foobar", Alias: ""}) + require.Error(t, err) +} + +func TestUpdateUserAuth0(t *testing.T) { + adapter, err := createAdapter() + require.NoError(t, err) + + im, err := New(Config{ + Adapter: adapter, + Superuser: User{Name: "foobar"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + err = im.Create(User{ + Name: "foobaz", + Superuser: false, + Auth: UserAuth{ + API: UserAuthAPI{ + Auth0: UserAuthAPIAuth0{ + User: "auth0|123456", + Tenant: Auth0Tenant{ + Domain: "datarhei-demo.eu.auth0.com", + Audience: "https://datarhei-demo.eu.auth0.com/api/v2/", + ClientID: "123456", + }, + }, + }, + }, + }) + require.NoError(t, err) + + identity, err := im.GetVerifierFromAuth0("auth0|123456") + require.NoError(t, err) + require.NotNil(t, identity) + + identity, err = im.GetVerifier("foobaz") + require.NoError(t, err) + require.NotNil(t, identity) + + user, err := im.Get("foobaz") + require.NoError(t, err) + + user.Name = "fooboz" + + err = im.Update("foobaz", user) + require.NoError(t, err) + + identity, err = im.GetVerifierFromAuth0("auth0|123456") + require.NoError(t, err) + require.NotNil(t, identity) + + identity, err = im.GetVerifier("fooboz") + require.NoError(t, err) + require.NotNil(t, identity) +} + +func TestRemoveUser(t *testing.T) { + adapter, err := createAdapter() + require.NoError(t, err) + + im, err := New(Config{ + Adapter: adapter, + Superuser: User{Name: "foobar"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + err = im.Delete("fooboz") + require.Error(t, err) + + err = im.Delete("foobar") + require.Error(t, err) + + err = im.Create(User{ + Name: "foobaz", + Superuser: false, + Auth: UserAuth{ + API: UserAuthAPI{ + Password: "apisecret", + Auth0: UserAuthAPIAuth0{}, + }, + Services: UserAuthServices{ + Basic: []string{"secret"}, + Token: []string{"tokensecret"}, + Session: []string{"sessionsecret"}, + }, + }, + }) + require.NoError(t, err) + + identity, err := im.GetVerifier("foobaz") + require.NoError(t, err) + require.NotNil(t, identity) + + ok, err := identity.VerifyAPIPassword("apisecret") + require.True(t, ok) + require.NoError(t, err) + + ok, err = identity.VerifyServiceBasicAuth("secret") + require.True(t, ok) + require.NoError(t, err) + + ok, err = identity.VerifyServiceToken("tokensecret") + require.True(t, ok) + require.NoError(t, err) + + session := identity.GetServiceSession(nil, time.Hour) + + ok, data, err := identity.VerifyServiceSession(session) + require.True(t, ok) + require.Equal(t, nil, data) + require.NoError(t, err) + + access, refresh, err := im.CreateJWT("foobaz") + require.NoError(t, err) + + ok, err = identity.VerifyJWT(access) + require.True(t, ok) + require.NoError(t, err) + + ok, err = identity.VerifyJWT(refresh) + require.True(t, ok) + require.NoError(t, err) + + err = im.Delete("foobaz") + require.NoError(t, err) + + ok, err = identity.VerifyAPIPassword("apisecret") + require.False(t, ok) + require.Error(t, err) + + ok, err = identity.VerifyServiceBasicAuth("secret") + require.False(t, ok) + require.Error(t, err) + + ok, err = identity.VerifyServiceToken("tokensecret") + require.False(t, ok) + require.Error(t, err) + + ok, data, err = identity.VerifyServiceSession(session) + require.False(t, ok) + require.Equal(t, nil, data) + require.Error(t, err) + + ok, err = identity.VerifyJWT(access) + require.False(t, ok) + require.Error(t, err) + + ok, err = identity.VerifyJWT(refresh) + require.False(t, ok) + require.Error(t, err) + + identity, err = im.GetVerifier("foobaz") + require.Error(t, err) + require.Nil(t, identity) + + access, refresh, err = im.CreateJWT("foobaz") + require.Error(t, err) + require.Empty(t, access) + require.Empty(t, refresh) +} + +func TestRemoveUserAuth0(t *testing.T) { + adapter, err := createAdapter() + require.NoError(t, err) + + im, err := New(Config{ + Adapter: adapter, + Superuser: User{Name: "foobar"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + err = im.Create(User{ + Name: "foobaz", + Superuser: false, + Auth: UserAuth{ + API: UserAuthAPI{ + Auth0: UserAuthAPIAuth0{ + User: "auth0|123456", + Tenant: Auth0Tenant{ + Domain: "datarhei-demo.eu.auth0.com", + Audience: "https://datarhei-demo.eu.auth0.com/api/v2/", + ClientID: "123456", + }, + }, + }, + }, + }) + require.NoError(t, err) + + err = im.Create(User{ + Name: "fooboz", + Superuser: false, + Auth: UserAuth{ + API: UserAuthAPI{ + Auth0: UserAuthAPIAuth0{ + User: "auth0|987654", + Tenant: Auth0Tenant{ + Domain: "datarhei-demo.eu.auth0.com", + Audience: "https://datarhei-demo.eu.auth0.com/api/v2/", + ClientID: "987654", + }, + }, + }, + }, + }) + require.NoError(t, err) + + manager, ok := im.(*identityManager) + require.True(t, ok) + require.NotNil(t, manager) + + require.Equal(t, 1, len(manager.tenants)) + require.Equal(t, map[string]string{"auth0|123456": "foobaz", "auth0|987654": "fooboz"}, manager.auth0UserIdentityMap) + + require.ElementsMatch(t, []string{ + "localjwt", + "auth0 domain=datarhei-demo.eu.auth0.com audience=https://datarhei-demo.eu.auth0.com/api/v2/ clientid=123456", + "auth0 domain=datarhei-demo.eu.auth0.com audience=https://datarhei-demo.eu.auth0.com/api/v2/ clientid=987654", + }, im.Validators()) + + err = im.Delete("foobaz") + require.NoError(t, err) + + require.Equal(t, 1, len(manager.tenants)) + require.Equal(t, map[string]string{"auth0|987654": "fooboz"}, manager.auth0UserIdentityMap) + + require.ElementsMatch(t, []string{ + "localjwt", + "auth0 domain=datarhei-demo.eu.auth0.com audience=https://datarhei-demo.eu.auth0.com/api/v2/ clientid=987654", + }, im.Validators()) + + err = im.Delete("fooboz") + require.NoError(t, err) + + require.Equal(t, 0, len(manager.tenants)) + require.ElementsMatch(t, []string{ + "localjwt", + }, im.Validators()) +} + +func TestAutosave(t *testing.T) { + adptr, err := createAdapter() + require.NoError(t, err) + + dummyfs := adptr.(*fileAdapter).fs + + im, err := New(Config{ + Adapter: adptr, + Superuser: User{Name: "foobar"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + err = im.Save() + require.NoError(t, err) + + _, err = dummyfs.Stat("./users.json") + require.NoError(t, err) + + data, err := dummyfs.ReadFile("./users.json") + require.NoError(t, err) + require.Equal(t, []byte("[]"), data) + + err = im.Create(User{Name: "foobaz"}) + require.NoError(t, err) + + data, err = dummyfs.ReadFile("./users.json") + require.NoError(t, err) + require.NotEqual(t, []byte("[]"), data) + + user, err := im.Get("foobaz") + require.NoError(t, err) + + user.Name = "fooboz" + + err = im.Update("foobaz", user) + require.NoError(t, err) + + data, err = dummyfs.ReadFile("./users.json") + require.NoError(t, err) + require.NotEqual(t, []byte("[]"), data) + + err = im.Delete("fooboz") + require.NoError(t, err) + + data, err = dummyfs.ReadFile("./users.json") + require.NoError(t, err) + require.Equal(t, []byte("[]"), data) +} + +func TestList(t *testing.T) { + adapter, err := createAdapter() + require.NoError(t, err) + + im, err := New(Config{ + Adapter: adapter, + Superuser: User{Name: "foobar"}, + JWTRealm: "test-realm", + JWTSecret: "abc123", + Logger: nil, + }) + require.NoError(t, err) + require.NotNil(t, im) + + err = im.Create(User{Name: "fooboz"}) + require.NoError(t, err) + + users := im.List() + require.Equal(t, 1, len(users)) + + err = im.Create(User{Name: "foobaz", Alias: "alias"}) + require.NoError(t, err) + + users = im.List() + require.Equal(t, 2, len(users)) +} diff --git a/iam/identity/user.go b/iam/identity/user.go new file mode 100644 index 00000000..1e2d5b5e --- /dev/null +++ b/iam/identity/user.go @@ -0,0 +1,261 @@ +package identity + +import ( + "fmt" + "strings" + "time" + + "github.com/datarhei/core/v16/slices" +) + +type User struct { + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + Name string `json:"name"` + Alias string `json:"alias"` + Superuser bool `json:"superuser"` + Auth UserAuth `json:"auth"` +} + +type UserAuth struct { + API UserAuthAPI `json:"api"` + Services UserAuthServices `json:"services"` +} + +type UserAuthAPI struct { + Password string `json:"password"` + Auth0 UserAuthAPIAuth0 `json:"auth0"` +} + +type UserAuthAPIAuth0 struct { + User string `json:"user"` + Tenant Auth0Tenant `json:"tenant"` +} + +type UserAuthServices struct { + Basic []string `json:"basic"` // Passwords for BasicAuth + Token []string `json:"token"` // Tokens/Streamkey for RTMP and SRT + Session []string `json:"session"` // Secrets for session JWT +} + +func (u *User) Validate() error { + if len(u.Name) == 0 { + return fmt.Errorf("a name is required") + } + + if strings.HasPrefix(u.Name, "$") { + return fmt.Errorf("name is not allowed to start with $") + } + + if len(u.Alias) != 0 { + if strings.HasPrefix(u.Alias, "$") { + return fmt.Errorf("alias is not allowed to start with $") + } + } + + if len(u.Auth.API.Auth0.User) != 0 { + t, err := newAuth0Tenant(u.Auth.API.Auth0.Tenant) + if err != nil { + return fmt.Errorf("auth0: %w", err) + } + + t.Cancel() + } + + return nil +} + +func (u *User) marshalIdentity() *identity { + i := &identity{ + user: u.clone(), + } + + return i +} + +func (u *User) clone() User { + user := *u + + user.Auth.Services.Basic = slices.Copy(u.Auth.Services.Basic) + user.Auth.Services.Token = slices.Copy(u.Auth.Services.Token) + user.Auth.Services.Session = slices.Copy(u.Auth.Services.Session) + + return user +} + +type UserList interface { + Add(u User) error + Get(nameOrAlias string) (User, error) + Update(nameOrAlias string, u User) error + Delete(nameorAlias string) + List() []User +} + +type userlist struct { + namesUserMap map[string]string + auth0UserMap map[string]string + user map[string]User +} + +func NewUserList() UserList { + return &userlist{ + namesUserMap: map[string]string{}, + auth0UserMap: map[string]string{}, + user: map[string]User{}, + } +} + +// Add implements UserList. +func (ul *userlist) Add(u User) error { + if err := u.Validate(); err != nil { + return fmt.Errorf("invalid user: %w", err) + } + + if _, ok := ul.namesUserMap[u.Name]; ok { + return fmt.Errorf("the name '%s' is already in use", u.Name) + } + + if len(u.Alias) != 0 { + if _, ok := ul.namesUserMap[u.Alias]; ok { + return fmt.Errorf("the alias '%s' is already in use", u.Alias) + } + } + + if len(u.Auth.API.Auth0.User) != 0 { + if name, ok := ul.auth0UserMap[u.Auth.API.Auth0.User]; ok { + return fmt.Errorf("the Auth0 user has already an identity (%s)", name) + } + } + + u = u.clone() + + ul.namesUserMap[u.Name] = u.Name + if len(u.Alias) != 0 { + ul.namesUserMap[u.Alias] = u.Name + } + if len(u.Auth.API.Auth0.User) != 0 { + ul.auth0UserMap[u.Auth.API.Auth0.User] = u.Name + } + + ul.user[u.Name] = u + + return nil +} + +func (ul *userlist) Get(nameOrAlias string) (User, error) { + name, ok := ul.namesUserMap[nameOrAlias] + if !ok { + return User{}, fmt.Errorf("user not found") + } + + u, ok := ul.user[name] + if !ok { + return User{}, fmt.Errorf("user not found") + } + + return u.clone(), nil +} + +// Delete implements UserList. +func (ul *userlist) Delete(nameOrAlias string) { + name, ok := ul.namesUserMap[nameOrAlias] + if !ok { + return + } + + u, ok := ul.user[name] + if !ok { + delete(ul.namesUserMap, nameOrAlias) + return + } + + delete(ul.namesUserMap, u.Name) + delete(ul.namesUserMap, u.Alias) + delete(ul.auth0UserMap, u.Auth.API.Auth0.User) + delete(ul.user, u.Name) +} + +// List implements UserList. +func (ul *userlist) List() []User { + user := []User{} + + for _, u := range ul.user { + user = append(user, u.clone()) + } + + return user +} + +// Update implements UserList. +func (ul *userlist) Update(nameOrAlias string, u User) error { + if err := u.Validate(); err != nil { + return fmt.Errorf("invalid user: %w", err) + } + + name, ok := ul.namesUserMap[nameOrAlias] + if !ok { + return fmt.Errorf("user with the name or alias '%s' not found", nameOrAlias) + } + + oldUser, ok := ul.user[name] + if !ok { + return fmt.Errorf("user with the name '%s' not found", name) + } + + delete(ul.namesUserMap, oldUser.Name) + delete(ul.namesUserMap, oldUser.Alias) + delete(ul.auth0UserMap, oldUser.Auth.API.Auth0.User) + + if _, ok := ul.namesUserMap[u.Name]; ok { + ul.namesUserMap[oldUser.Name] = oldUser.Name + if len(oldUser.Alias) != 0 { + ul.namesUserMap[oldUser.Alias] = oldUser.Name + } + if len(oldUser.Auth.API.Auth0.User) != 0 { + ul.auth0UserMap[oldUser.Auth.API.Auth0.User] = oldUser.Name + } + return fmt.Errorf("the name '%s' is already in use", u.Name) + } + + if len(u.Alias) != 0 { + if _, ok := ul.namesUserMap[u.Alias]; ok { + ul.namesUserMap[oldUser.Name] = oldUser.Name + if len(oldUser.Alias) != 0 { + ul.namesUserMap[oldUser.Alias] = oldUser.Name + } + if len(oldUser.Auth.API.Auth0.User) != 0 { + ul.auth0UserMap[oldUser.Auth.API.Auth0.User] = oldUser.Name + } + return fmt.Errorf("the alias '%s' is already in use", u.Alias) + } + } + + if len(u.Auth.API.Auth0.User) != 0 { + if _, ok := ul.auth0UserMap[u.Auth.API.Auth0.User]; ok { + ul.namesUserMap[oldUser.Name] = oldUser.Name + if len(oldUser.Alias) != 0 { + ul.namesUserMap[oldUser.Alias] = oldUser.Name + } + if len(oldUser.Auth.API.Auth0.User) != 0 { + ul.auth0UserMap[oldUser.Auth.API.Auth0.User] = oldUser.Name + } + return fmt.Errorf("the Auth0 user has already an identity (%s)", u.Auth.API.Auth0.User) + } + } + + delete(ul.user, oldUser.Name) + + u = u.clone() + + ul.namesUserMap[u.Name] = u.Name + if len(u.Alias) != 0 { + ul.namesUserMap[u.Alias] = u.Name + } + if len(u.Auth.API.Auth0.User) != 0 { + ul.auth0UserMap[u.Auth.API.Auth0.User] = u.Name + } + + ul.user[u.Name] = u + + return nil +} diff --git a/iam/identity/user_test.go b/iam/identity/user_test.go new file mode 100644 index 00000000..c43d5b0e --- /dev/null +++ b/iam/identity/user_test.go @@ -0,0 +1,146 @@ +package identity + +import ( + "testing" + + "github.com/stretchr/testify/require" +) + +func TestUserName(t *testing.T) { + user := User{} + + err := user.Validate() + require.Error(t, err) + + user.Name = "foobar_5" + err = user.Validate() + require.NoError(t, err) + + user.Name = "foobar:5" + err = user.Validate() + require.NoError(t, err) + + user.Name = "$foob:ar" + err = user.Validate() + require.Error(t, err) +} + +func TestUserAlias(t *testing.T) { + user := User{ + Name: "foober", + } + + err := user.Validate() + require.NoError(t, err) + + user.Alias = "foobar" + err = user.Validate() + require.NoError(t, err) + + user.Alias = "$foob:ar" + err = user.Validate() + require.Error(t, err) +} + +func TestUserListAdd(t *testing.T) { + l := NewUserList() + + _, err := l.Get("foobar") + require.Error(t, err) + + err = l.Add(User{Name: "foobar"}) + require.NoError(t, err) + + _, err = l.Get("foobar") + require.NoError(t, err) + + err = l.Add(User{Name: "foobaz", Alias: "foobar"}) + require.Error(t, err) + + err = l.Add(User{Name: "foobaz", Alias: "foobaz"}) + require.NoError(t, err) + + err = l.Add(User{Name: "barfoo", Alias: "foobaz"}) + require.Error(t, err) +} + +func TestUserListDelete(t *testing.T) { + l := NewUserList() + + _, err := l.Get("foobar") + require.Error(t, err) + + err = l.Add(User{Name: "foobar"}) + require.NoError(t, err) + + _, err = l.Get("foobar") + require.NoError(t, err) + + l.Delete("foobar") + require.NoError(t, err) + + _, err = l.Get("foobar") + require.Error(t, err) + + err = l.Add(User{Name: "foobar", Alias: "foobaz"}) + require.NoError(t, err) + + _, err = l.Get("foobaz") + require.NoError(t, err) + + l.Delete("foobaz") + require.NoError(t, err) + + _, err = l.Get("foobaz") + require.Error(t, err) +} + +func TestUserListUpdate(t *testing.T) { + l := NewUserList() + + err := l.Add(User{Name: "foobar"}) + require.NoError(t, err) + + err = l.Update("foobaz", User{Name: "foobar"}) + require.Error(t, err) + + err = l.Update("foobar", User{Name: "foobaz", Alias: "fooboz"}) + require.NoError(t, err) + + _, err = l.Get("foobar") + require.Error(t, err) + + _, err = l.Get("foobaz") + require.NoError(t, err) + + _, err = l.Get("fooboz") + require.NoError(t, err) + + err = l.Add(User{Name: "foobar"}) + require.NoError(t, err) + + err = l.Update("foobaz", User{Name: "foobar"}) + require.Error(t, err) + + err = l.Update("fooboz", User{Name: "fooboz"}) + require.NoError(t, err) + + _, err = l.Get("foobaz") + require.Error(t, err) +} + +func TestUserListList(t *testing.T) { + l := NewUserList() + + err := l.Add(User{Name: "foobar", Alias: "foobaz"}) + require.NoError(t, err) + + users := l.List() + require.Equal(t, 1, len(users)) + + err = l.Add(User{Name: "barfoo", Alias: "bazfoo"}) + require.NoError(t, err) + + users = l.List() + require.Equal(t, 2, len(users)) +}