From f805cca3633fd262da5d1713ff68ef2d2092f19e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Wed, 1 Mar 2023 13:24:28 +0000 Subject: [PATCH 01/14] initial invitations skeleton MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jörn Friedrich Dreyer --- ocis-pkg/config/config.go | 2 + ocis-pkg/config/defaultconfig.go | 2 + ocis/pkg/command/invitations.go | 30 ++ ocis/pkg/runtime/service/service.go | 2 + services/invitations/.dockerignore | 2 + services/invitations/Makefile | 39 ++ services/invitations/README.md | 7 + services/invitations/cmd/invitations/main.go | 14 + services/invitations/mocks/ldapclient.go | 358 ++++++++++++++++++ services/invitations/pkg/command/health.go | 54 +++ services/invitations/pkg/command/root.go | 59 +++ services/invitations/pkg/command/server.go | 117 ++++++ services/invitations/pkg/command/version.go | 50 +++ services/invitations/pkg/config/config.go | 33 ++ services/invitations/pkg/config/debug.go | 9 + .../pkg/config/defaults/defaultconfig.go | 80 ++++ services/invitations/pkg/config/http.go | 20 + services/invitations/pkg/config/log.go | 9 + .../invitations/pkg/config/parser/parse.go | 37 ++ services/invitations/pkg/config/reva.go | 6 + services/invitations/pkg/config/service.go | 6 + services/invitations/pkg/config/tracing.go | 9 + .../pkg/invitations/invitations.go | 70 ++++ services/invitations/pkg/logging/logging.go | 17 + services/invitations/pkg/metrics/metrics.go | 81 ++++ services/invitations/pkg/metrics/options.go | 31 ++ .../invitations/pkg/server/debug/option.go | 50 +++ .../invitations/pkg/server/debug/server.go | 63 +++ .../invitations/pkg/server/http/option.go | 84 ++++ .../invitations/pkg/server/http/server.go | 110 ++++++ services/invitations/pkg/service/v0/errors.go | 5 + .../invitations/pkg/service/v0/instrument.go | 38 ++ .../invitations/pkg/service/v0/logging.go | 30 ++ services/invitations/pkg/service/v0/option.go | 40 ++ .../invitations/pkg/service/v0/service.go | 55 +++ .../invitations/pkg/service/v0/tracing.go | 31 ++ services/invitations/pkg/tracing/tracing.go | 23 ++ services/invitations/reflex.conf | 2 + .../pkg/config/defaults/defaultconfig.go | 4 + 39 files changed, 1679 insertions(+) create mode 100644 ocis/pkg/command/invitations.go create mode 100644 services/invitations/.dockerignore create mode 100644 services/invitations/Makefile create mode 100644 services/invitations/README.md create mode 100644 services/invitations/cmd/invitations/main.go create mode 100644 services/invitations/mocks/ldapclient.go create mode 100644 services/invitations/pkg/command/health.go create mode 100644 services/invitations/pkg/command/root.go create mode 100644 services/invitations/pkg/command/server.go create mode 100644 services/invitations/pkg/command/version.go create mode 100644 services/invitations/pkg/config/config.go create mode 100644 services/invitations/pkg/config/debug.go create mode 100644 services/invitations/pkg/config/defaults/defaultconfig.go create mode 100644 services/invitations/pkg/config/http.go create mode 100644 services/invitations/pkg/config/log.go create mode 100644 services/invitations/pkg/config/parser/parse.go create mode 100644 services/invitations/pkg/config/reva.go create mode 100644 services/invitations/pkg/config/service.go create mode 100644 services/invitations/pkg/config/tracing.go create mode 100644 services/invitations/pkg/invitations/invitations.go create mode 100644 services/invitations/pkg/logging/logging.go create mode 100644 services/invitations/pkg/metrics/metrics.go create mode 100644 services/invitations/pkg/metrics/options.go create mode 100644 services/invitations/pkg/server/debug/option.go create mode 100644 services/invitations/pkg/server/debug/server.go create mode 100644 services/invitations/pkg/server/http/option.go create mode 100644 services/invitations/pkg/server/http/server.go create mode 100644 services/invitations/pkg/service/v0/errors.go create mode 100644 services/invitations/pkg/service/v0/instrument.go create mode 100644 services/invitations/pkg/service/v0/logging.go create mode 100644 services/invitations/pkg/service/v0/option.go create mode 100644 services/invitations/pkg/service/v0/service.go create mode 100644 services/invitations/pkg/service/v0/tracing.go create mode 100644 services/invitations/pkg/tracing/tracing.go create mode 100644 services/invitations/reflex.conf diff --git a/ocis-pkg/config/config.go b/ocis-pkg/config/config.go index 7e38b1a1fff..7d71a792da2 100644 --- a/ocis-pkg/config/config.go +++ b/ocis-pkg/config/config.go @@ -16,6 +16,7 @@ import ( groups "github.com/owncloud/ocis/v2/services/groups/pkg/config" idm "github.com/owncloud/ocis/v2/services/idm/pkg/config" idp "github.com/owncloud/ocis/v2/services/idp/pkg/config" + invitations "github.com/owncloud/ocis/v2/services/invitations/pkg/config" nats "github.com/owncloud/ocis/v2/services/nats/pkg/config" notifications "github.com/owncloud/ocis/v2/services/notifications/pkg/config" ocdav "github.com/owncloud/ocis/v2/services/ocdav/pkg/config" @@ -87,6 +88,7 @@ type Config struct { Groups *groups.Config `yaml:"groups"` IDM *idm.Config `yaml:"idm"` IDP *idp.Config `yaml:"idp"` + Invitations *invitations.Config `yaml:"invitations"` Nats *nats.Config `yaml:"nats"` Notifications *notifications.Config `yaml:"notifications"` OCDav *ocdav.Config `yaml:"ocdav"` diff --git a/ocis-pkg/config/defaultconfig.go b/ocis-pkg/config/defaultconfig.go index 89579f7d8f1..3afd0896c8a 100644 --- a/ocis-pkg/config/defaultconfig.go +++ b/ocis-pkg/config/defaultconfig.go @@ -15,6 +15,7 @@ import ( groups "github.com/owncloud/ocis/v2/services/groups/pkg/config/defaults" idm "github.com/owncloud/ocis/v2/services/idm/pkg/config/defaults" idp "github.com/owncloud/ocis/v2/services/idp/pkg/config/defaults" + invitations "github.com/owncloud/ocis/v2/services/invitations/pkg/config/defaults" nats "github.com/owncloud/ocis/v2/services/nats/pkg/config/defaults" notifications "github.com/owncloud/ocis/v2/services/notifications/pkg/config/defaults" ocdav "github.com/owncloud/ocis/v2/services/ocdav/pkg/config/defaults" @@ -60,6 +61,7 @@ func DefaultConfig() *Config { Groups: groups.DefaultConfig(), IDM: idm.DefaultConfig(), IDP: idp.DefaultConfig(), + Invitations: invitations.DefaultConfig(), Nats: nats.DefaultConfig(), Notifications: notifications.DefaultConfig(), OCDav: ocdav.DefaultConfig(), diff --git a/ocis/pkg/command/invitations.go b/ocis/pkg/command/invitations.go new file mode 100644 index 00000000000..984d8adf85a --- /dev/null +++ b/ocis/pkg/command/invitations.go @@ -0,0 +1,30 @@ +package command + +import ( + "github.com/owncloud/ocis/v2/ocis-pkg/config" + "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" + "github.com/owncloud/ocis/v2/ocis-pkg/config/parser" + "github.com/owncloud/ocis/v2/ocis/pkg/command/helper" + "github.com/owncloud/ocis/v2/ocis/pkg/register" + "github.com/owncloud/ocis/v2/services/invitations/pkg/command" + "github.com/urfave/cli/v2" +) + +// InvitationsCommand is the entrypoint for the invitations command. +func InvitationsCommand(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: cfg.Invitations.Service.Name, + Usage: helper.SubcommandDescription(cfg.Invitations.Service.Name), + Category: "services", + Before: func(c *cli.Context) error { + configlog.Error(parser.ParseConfig(cfg, true)) + cfg.Invitations.Commons = cfg.Commons + return nil + }, + Subcommands: command.GetCommands(cfg.Invitations), + } +} + +func init() { + register.AddCommand(InvitationsCommand) +} diff --git a/ocis/pkg/runtime/service/service.go b/ocis/pkg/runtime/service/service.go index f3a10eb4582..06cc367ed94 100644 --- a/ocis/pkg/runtime/service/service.go +++ b/ocis/pkg/runtime/service/service.go @@ -29,6 +29,7 @@ import ( groups "github.com/owncloud/ocis/v2/services/groups/pkg/command" idm "github.com/owncloud/ocis/v2/services/idm/pkg/command" idp "github.com/owncloud/ocis/v2/services/idp/pkg/command" + invitations "github.com/owncloud/ocis/v2/services/invitations/pkg/command" nats "github.com/owncloud/ocis/v2/services/nats/pkg/command" notifications "github.com/owncloud/ocis/v2/services/notifications/pkg/command" ocdav "github.com/owncloud/ocis/v2/services/ocdav/pkg/command" @@ -108,6 +109,7 @@ func NewService(options ...Option) (*Service, error) { s.ServicesRegistry[opts.Config.StorageSystem.Service.Name] = storageSystem.NewSutureService s.ServicesRegistry[opts.Config.Graph.Service.Name] = graph.NewSutureService s.ServicesRegistry[opts.Config.IDM.Service.Name] = idm.NewSutureService + s.ServicesRegistry[opts.Config.Invitations.Service.Name] = invitations.NewSutureService s.ServicesRegistry[opts.Config.OCS.Service.Name] = ocs.NewSutureService s.ServicesRegistry[opts.Config.Store.Service.Name] = store.NewSutureService s.ServicesRegistry[opts.Config.Thumbnails.Service.Name] = thumbnails.NewSutureService diff --git a/services/invitations/.dockerignore b/services/invitations/.dockerignore new file mode 100644 index 00000000000..4ec85b5e4f7 --- /dev/null +++ b/services/invitations/.dockerignore @@ -0,0 +1,2 @@ +* +!bin/ diff --git a/services/invitations/Makefile b/services/invitations/Makefile new file mode 100644 index 00000000000..d34a0071b53 --- /dev/null +++ b/services/invitations/Makefile @@ -0,0 +1,39 @@ +SHELL := bash +NAME := invitations + +include ../../.make/recursion.mk + +############ tooling ############ +ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI +include ../../.bingo/Variables.mk +endif + +############ go tooling ############ +include ../../.make/go.mk + +############ release ############ +include ../../.make/release.mk + +############ docs generate ############ +include ../../.make/docs.mk + +.PHONY: docs-generate +docs-generate: config-docs-generate + +############ generate ############ +include ../../.make/generate.mk + +.PHONY: ci-go-generate +ci-go-generate: $(MOCKERY) # CI runs ci-node-generate automatically before this target + $(MOCKERY) --srcpkg github.com/go-ldap/ldap/v3 --case underscore --filename ldapclient.go --name Client + + +.PHONY: ci-node-generate +ci-node-generate: + +############ licenses ############ +.PHONY: ci-node-check-licenses +ci-node-check-licenses: + +.PHONY: ci-node-save-licenses +ci-node-save-licenses: diff --git a/services/invitations/README.md b/services/invitations/README.md new file mode 100644 index 00000000000..86ec3ec028c --- /dev/null +++ b/services/invitations/README.md @@ -0,0 +1,7 @@ +# Invitations Service + +The invitations service provides an [Invitation Manager](https://learn.microsoft.com/en-us/graph/api/invitation-post?view=graph-rest-1.0&tabs=http) that can be used to invide external users aka Guests to an organization. + +On the libre graph API invited users will have `userType="Guest"`, whereas users belonging to the organization have `userType="Member"`. + +The corresponding CS3 API [user types](https://cs3org.github.io/cs3apis/#cs3.identity.user.v1beta1.UserType) used to reperesent this are USER_TYPE_GUEST and USER_TYPE_PRIMARY. \ No newline at end of file diff --git a/services/invitations/cmd/invitations/main.go b/services/invitations/cmd/invitations/main.go new file mode 100644 index 00000000000..3290c0b3065 --- /dev/null +++ b/services/invitations/cmd/invitations/main.go @@ -0,0 +1,14 @@ +package main + +import ( + "os" + + "github.com/owncloud/ocis/v2/services/invitations/pkg/command" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config/defaults" +) + +func main() { + if err := command.Execute(defaults.DefaultConfig()); err != nil { + os.Exit(1) + } +} diff --git a/services/invitations/mocks/ldapclient.go b/services/invitations/mocks/ldapclient.go new file mode 100644 index 00000000000..7893c5dcca6 --- /dev/null +++ b/services/invitations/mocks/ldapclient.go @@ -0,0 +1,358 @@ +// Code generated by mockery v2.14.1. DO NOT EDIT. + +package mocks + +import ( + ldap "github.com/go-ldap/ldap/v3" + mock "github.com/stretchr/testify/mock" + + time "time" + + tls "crypto/tls" +) + +// Client is an autogenerated mock type for the Client type +type Client struct { + mock.Mock +} + +// Add provides a mock function with given fields: _a0 +func (_m *Client) Add(_a0 *ldap.AddRequest) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(*ldap.AddRequest) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Bind provides a mock function with given fields: username, password +func (_m *Client) Bind(username string, password string) error { + ret := _m.Called(username, password) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(username, password) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Close provides a mock function with given fields: +func (_m *Client) Close() { + _m.Called() +} + +// Compare provides a mock function with given fields: dn, attribute, value +func (_m *Client) Compare(dn string, attribute string, value string) (bool, error) { + ret := _m.Called(dn, attribute, value) + + var r0 bool + if rf, ok := ret.Get(0).(func(string, string, string) bool); ok { + r0 = rf(dn, attribute, value) + } else { + r0 = ret.Get(0).(bool) + } + + var r1 error + if rf, ok := ret.Get(1).(func(string, string, string) error); ok { + r1 = rf(dn, attribute, value) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Del provides a mock function with given fields: _a0 +func (_m *Client) Del(_a0 *ldap.DelRequest) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(*ldap.DelRequest) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ExternalBind provides a mock function with given fields: +func (_m *Client) ExternalBind() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// IsClosing provides a mock function with given fields: +func (_m *Client) IsClosing() bool { + ret := _m.Called() + + var r0 bool + if rf, ok := ret.Get(0).(func() bool); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(bool) + } + + return r0 +} + +// Modify provides a mock function with given fields: _a0 +func (_m *Client) Modify(_a0 *ldap.ModifyRequest) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(*ldap.ModifyRequest) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ModifyDN provides a mock function with given fields: _a0 +func (_m *Client) ModifyDN(_a0 *ldap.ModifyDNRequest) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(*ldap.ModifyDNRequest) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// ModifyWithResult provides a mock function with given fields: _a0 +func (_m *Client) ModifyWithResult(_a0 *ldap.ModifyRequest) (*ldap.ModifyResult, error) { + ret := _m.Called(_a0) + + var r0 *ldap.ModifyResult + if rf, ok := ret.Get(0).(func(*ldap.ModifyRequest) *ldap.ModifyResult); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ldap.ModifyResult) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*ldap.ModifyRequest) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// NTLMUnauthenticatedBind provides a mock function with given fields: domain, username +func (_m *Client) NTLMUnauthenticatedBind(domain string, username string) error { + ret := _m.Called(domain, username) + + var r0 error + if rf, ok := ret.Get(0).(func(string, string) error); ok { + r0 = rf(domain, username) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// PasswordModify provides a mock function with given fields: _a0 +func (_m *Client) PasswordModify(_a0 *ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) { + ret := _m.Called(_a0) + + var r0 *ldap.PasswordModifyResult + if rf, ok := ret.Get(0).(func(*ldap.PasswordModifyRequest) *ldap.PasswordModifyResult); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ldap.PasswordModifyResult) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*ldap.PasswordModifyRequest) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Search provides a mock function with given fields: _a0 +func (_m *Client) Search(_a0 *ldap.SearchRequest) (*ldap.SearchResult, error) { + ret := _m.Called(_a0) + + var r0 *ldap.SearchResult + if rf, ok := ret.Get(0).(func(*ldap.SearchRequest) *ldap.SearchResult); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ldap.SearchResult) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*ldap.SearchRequest) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SearchWithPaging provides a mock function with given fields: searchRequest, pagingSize +func (_m *Client) SearchWithPaging(searchRequest *ldap.SearchRequest, pagingSize uint32) (*ldap.SearchResult, error) { + ret := _m.Called(searchRequest, pagingSize) + + var r0 *ldap.SearchResult + if rf, ok := ret.Get(0).(func(*ldap.SearchRequest, uint32) *ldap.SearchResult); ok { + r0 = rf(searchRequest, pagingSize) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ldap.SearchResult) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*ldap.SearchRequest, uint32) error); ok { + r1 = rf(searchRequest, pagingSize) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// SetTimeout provides a mock function with given fields: _a0 +func (_m *Client) SetTimeout(_a0 time.Duration) { + _m.Called(_a0) +} + +// SimpleBind provides a mock function with given fields: _a0 +func (_m *Client) SimpleBind(_a0 *ldap.SimpleBindRequest) (*ldap.SimpleBindResult, error) { + ret := _m.Called(_a0) + + var r0 *ldap.SimpleBindResult + if rf, ok := ret.Get(0).(func(*ldap.SimpleBindRequest) *ldap.SimpleBindResult); ok { + r0 = rf(_a0) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*ldap.SimpleBindResult) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(*ldap.SimpleBindRequest) error); ok { + r1 = rf(_a0) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// Start provides a mock function with given fields: +func (_m *Client) Start() { + _m.Called() +} + +// StartTLS provides a mock function with given fields: _a0 +func (_m *Client) StartTLS(_a0 *tls.Config) error { + ret := _m.Called(_a0) + + var r0 error + if rf, ok := ret.Get(0).(func(*tls.Config) error); ok { + r0 = rf(_a0) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// TLSConnectionState provides a mock function with given fields: +func (_m *Client) TLSConnectionState() (tls.ConnectionState, bool) { + ret := _m.Called() + + var r0 tls.ConnectionState + if rf, ok := ret.Get(0).(func() tls.ConnectionState); ok { + r0 = rf() + } else { + r0 = ret.Get(0).(tls.ConnectionState) + } + + var r1 bool + if rf, ok := ret.Get(1).(func() bool); ok { + r1 = rf() + } else { + r1 = ret.Get(1).(bool) + } + + return r0, r1 +} + +// UnauthenticatedBind provides a mock function with given fields: username +func (_m *Client) UnauthenticatedBind(username string) error { + ret := _m.Called(username) + + var r0 error + if rf, ok := ret.Get(0).(func(string) error); ok { + r0 = rf(username) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// Unbind provides a mock function with given fields: +func (_m *Client) Unbind() error { + ret := _m.Called() + + var r0 error + if rf, ok := ret.Get(0).(func() error); ok { + r0 = rf() + } else { + r0 = ret.Error(0) + } + + return r0 +} + +type mockConstructorTestingTNewClient interface { + mock.TestingT + Cleanup(func()) +} + +// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewClient(t mockConstructorTestingTNewClient) *Client { + mock := &Client{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} diff --git a/services/invitations/pkg/command/health.go b/services/invitations/pkg/command/health.go new file mode 100644 index 00000000000..d9c6de1619b --- /dev/null +++ b/services/invitations/pkg/command/health.go @@ -0,0 +1,54 @@ +package command + +import ( + "fmt" + "net/http" + + "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config/parser" + "github.com/owncloud/ocis/v2/services/invitations/pkg/logging" + "github.com/urfave/cli/v2" +) + +// Health is the entrypoint for the health command. +func Health(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "health", + Usage: "check health status", + Category: "info", + Before: func(c *cli.Context) error { + return configlog.ReturnError(parser.ParseConfig(cfg)) + }, + Action: func(c *cli.Context) error { + logger := logging.Configure(cfg.Service.Name, cfg.Log) + + resp, err := http.Get( + fmt.Sprintf( + "http://%s/healthz", + cfg.Debug.Addr, + ), + ) + + if err != nil { + logger.Fatal(). + Err(err). + Msg("Failed to request health check") + } + + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + logger.Fatal(). + Int("code", resp.StatusCode). + Msg("Health seems to be in bad state") + } + + logger.Debug(). + Int("code", resp.StatusCode). + Msg("Health got a good state") + + return nil + }, + } +} diff --git a/services/invitations/pkg/command/root.go b/services/invitations/pkg/command/root.go new file mode 100644 index 00000000000..dde7035578e --- /dev/null +++ b/services/invitations/pkg/command/root.go @@ -0,0 +1,59 @@ +package command + +import ( + "context" + "os" + + "github.com/owncloud/ocis/v2/ocis-pkg/clihelper" + ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" + "github.com/thejerf/suture/v4" + "github.com/urfave/cli/v2" +) + +// GetCommands provides all commands for this service +func GetCommands(cfg *config.Config) cli.Commands { + return []*cli.Command{ + // start this service + Server(cfg), + + // interaction with this service + + // infos about this service + Health(cfg), + Version(cfg), + } +} + +// Execute is the entry point for the ocis invitations command. +func Execute(cfg *config.Config) error { + app := clihelper.DefaultApp(&cli.App{ + Name: "invitations", + Usage: "Serve invitations API for oCIS", + Commands: GetCommands(cfg), + }) + + return app.Run(os.Args) +} + +// SutureService allows for the webdav command to be embedded and supervised by a suture supervisor tree. +type SutureService struct { + cfg *config.Config +} + +// NewSutureService creates a new webdav.SutureService +func NewSutureService(cfg *ociscfg.Config) suture.Service { + cfg.Invitations.Commons = cfg.Commons + return SutureService{ + cfg: cfg.Invitations, + } +} + +func (s SutureService) Serve(ctx context.Context) error { + s.cfg.Context = ctx + if err := Execute(s.cfg); err != nil { + return err + } + + return nil +} diff --git a/services/invitations/pkg/command/server.go b/services/invitations/pkg/command/server.go new file mode 100644 index 00000000000..75cdea5f51b --- /dev/null +++ b/services/invitations/pkg/command/server.go @@ -0,0 +1,117 @@ +package command + +import ( + "context" + "fmt" + + "github.com/oklog/run" + "github.com/owncloud/ocis/v2/ocis-pkg/config/configlog" + "github.com/owncloud/ocis/v2/ocis-pkg/version" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config/parser" + "github.com/owncloud/ocis/v2/services/invitations/pkg/logging" + "github.com/owncloud/ocis/v2/services/invitations/pkg/metrics" + "github.com/owncloud/ocis/v2/services/invitations/pkg/server/debug" + "github.com/owncloud/ocis/v2/services/invitations/pkg/server/http" + "github.com/owncloud/ocis/v2/services/invitations/pkg/service/v0" + "github.com/owncloud/ocis/v2/services/invitations/pkg/tracing" + "github.com/urfave/cli/v2" +) + +// Server is the entrypoint for the server command. +func Server(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "server", + Usage: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name), + Category: "server", + Before: func(c *cli.Context) error { + return configlog.ReturnFatal(parser.ParseConfig(cfg)) + }, + Action: func(c *cli.Context) error { + logger := logging.Configure(cfg.Service.Name, cfg.Log) + err := tracing.Configure(cfg) + if err != nil { + return err + } + + var ( + gr = run.Group{} + ctx, cancel = func() (context.Context, context.CancelFunc) { + if cfg.Context == nil { + return context.WithCancel(context.Background()) + } + return context.WithCancel(cfg.Context) + }() + metrics = metrics.New(metrics.Logger(logger)) + ) + + defer cancel() + + metrics.BuildInfo.WithLabelValues(version.GetString()).Set(1) + + { + + svc, err := service.New( + service.Logger(logger), + service.Config(cfg), + //service.WithRelationProviders(relationProviders), + ) + if err != nil { + logger.Error().Err(err).Msg("handler init") + return err + } + svc = service.NewInstrument(svc, metrics) + svc = service.NewLogging(svc, logger) // this logs service specific data + svc = service.NewTracing(svc) + + server, err := http.Server( + http.Logger(logger), + http.Context(ctx), + http.Config(cfg), + http.Service(svc), + ) + + if err != nil { + logger.Info(). + Err(err). + Str("transport", "http"). + Msg("Failed to initialize server") + + return err + } + + gr.Add(func() error { + return server.Run() + }, func(err error) { + logger.Error(). + Err(err). + Str("transport", "http"). + Msg("Shutting down server") + + cancel() + }) + } + + { + server, err := debug.Server( + debug.Logger(logger), + debug.Context(ctx), + debug.Config(cfg), + ) + + if err != nil { + logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server") + return err + } + + gr.Add(server.ListenAndServe, func(err error) { + logger.Error().Err(err) + _ = server.Shutdown(ctx) + cancel() + }) + } + + return gr.Run() + }, + } +} diff --git a/services/invitations/pkg/command/version.go b/services/invitations/pkg/command/version.go new file mode 100644 index 00000000000..01a85f800fe --- /dev/null +++ b/services/invitations/pkg/command/version.go @@ -0,0 +1,50 @@ +package command + +import ( + "fmt" + "os" + + "github.com/owncloud/ocis/v2/ocis-pkg/registry" + "github.com/owncloud/ocis/v2/ocis-pkg/version" + + tw "github.com/olekukonko/tablewriter" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" + "github.com/urfave/cli/v2" +) + +// Version prints the service versions of all running instances. +func Version(cfg *config.Config) *cli.Command { + return &cli.Command{ + Name: "version", + Usage: "print the version of this binary and the running service instances", + Category: "info", + Action: func(c *cli.Context) error { + fmt.Println("Version: " + version.GetString()) + fmt.Printf("Compiled: %s\n", version.Compiled()) + fmt.Println("") + + reg := registry.GetRegistry() + services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name) + if err != nil { + fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err)) + return err + } + + if len(services) == 0 { + fmt.Println("No running " + cfg.Service.Name + " service found.") + return nil + } + + table := tw.NewWriter(os.Stdout) + table.SetHeader([]string{"Version", "Address", "Id"}) + table.SetAutoFormatHeaders(false) + for _, s := range services { + for _, n := range s.Nodes { + table.Append([]string{s.Version, n.Address, n.Id}) + } + } + table.Render() + return nil + }, + } +} diff --git a/services/invitations/pkg/config/config.go b/services/invitations/pkg/config/config.go new file mode 100644 index 00000000000..5e9aab9c600 --- /dev/null +++ b/services/invitations/pkg/config/config.go @@ -0,0 +1,33 @@ +package config + +import ( + "context" + + "github.com/owncloud/ocis/v2/ocis-pkg/shared" +) + +// Config combines all available configuration parts. +type Config struct { + Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service + + Service Service `yaml:"-"` + + Tracing *Tracing `yaml:"tracing"` + Log *Log `yaml:"log"` + Debug Debug `yaml:"debug"` + + HTTP HTTP `yaml:"http"` + + TokenManager *TokenManager `yaml:"token_manager"` + + Context context.Context `yaml:"-"` +} + +// Instance to use with a matching rule and titles +type Instance struct { + Claim string `yaml:"claim"` + Regex string `yaml:"regex"` + Href string `yaml:"href"` + Titles map[string]string `yaml:"titles"` + Break bool `yaml:"break"` +} diff --git a/services/invitations/pkg/config/debug.go b/services/invitations/pkg/config/debug.go new file mode 100644 index 00000000000..a49030f8605 --- /dev/null +++ b/services/invitations/pkg/config/debug.go @@ -0,0 +1,9 @@ +package config + +// Debug defines the available debug configuration. +type Debug struct { + Addr string `yaml:"addr" env:"INVITATIONS_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed."` + Token string `yaml:"token" env:"INVITATIONS_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint."` + Pprof bool `yaml:"pprof" env:"INVITATIONS_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling."` + Zpages bool `yaml:"zpages" env:"INVITATIONS_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces."` +} diff --git a/services/invitations/pkg/config/defaults/defaultconfig.go b/services/invitations/pkg/config/defaults/defaultconfig.go new file mode 100644 index 00000000000..5249cef136e --- /dev/null +++ b/services/invitations/pkg/config/defaults/defaultconfig.go @@ -0,0 +1,80 @@ +package defaults + +import ( + "strings" + + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" +) + +func FullDefaultConfig() *config.Config { + cfg := DefaultConfig() + EnsureDefaults(cfg) + Sanitize(cfg) + return cfg +} + +func DefaultConfig() *config.Config { + return &config.Config{ + Debug: config.Debug{ + Addr: "127.0.0.1:0", // :0 to pick any free local port + Token: "", + Pprof: false, + Zpages: false, + }, + HTTP: config.HTTP{ + Addr: "127.0.0.1:0", // :0 to pick any free local port + Root: "/graph/v1.0", + Namespace: "com.owncloud.graph", + CORS: config.CORS{ + AllowedOrigins: []string{"*"}, + }, + }, + Service: config.Service{ + Name: "invitations", + }, + } +} + +func EnsureDefaults(cfg *config.Config) { + // provide with defaults for shared logging, since we need a valid destination address for "envdecode". + if cfg.Log == nil && cfg.Commons != nil && cfg.Commons.Log != nil { + cfg.Log = &config.Log{ + Level: cfg.Commons.Log.Level, + Pretty: cfg.Commons.Log.Pretty, + Color: cfg.Commons.Log.Color, + File: cfg.Commons.Log.File, + } + } else if cfg.Log == nil { + cfg.Log = &config.Log{} + } + // provide with defaults for shared tracing, since we need a valid destination address for "envdecode". + if cfg.Tracing == nil && cfg.Commons != nil && cfg.Commons.Tracing != nil { + cfg.Tracing = &config.Tracing{ + Enabled: cfg.Commons.Tracing.Enabled, + Type: cfg.Commons.Tracing.Type, + Endpoint: cfg.Commons.Tracing.Endpoint, + Collector: cfg.Commons.Tracing.Collector, + } + } else if cfg.Tracing == nil { + cfg.Tracing = &config.Tracing{} + } + + if cfg.Commons != nil { + cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS + } + + if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil { + cfg.TokenManager = &config.TokenManager{ + JWTSecret: cfg.Commons.TokenManager.JWTSecret, + } + } else if cfg.TokenManager == nil { + cfg.TokenManager = &config.TokenManager{} + } +} + +func Sanitize(cfg *config.Config) { + // sanitize config + if cfg.HTTP.Root != "/" { + cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/") + } +} diff --git a/services/invitations/pkg/config/http.go b/services/invitations/pkg/config/http.go new file mode 100644 index 00000000000..342b45fcaf1 --- /dev/null +++ b/services/invitations/pkg/config/http.go @@ -0,0 +1,20 @@ +package config + +import "github.com/owncloud/ocis/v2/ocis-pkg/shared" + +// CORS defines the available cors configuration. +type CORS struct { + AllowedOrigins []string `yaml:"allow_origins" env:"OCIS_CORS_ALLOW_ORIGINS;INVITATIONS_CORS_ALLOW_ORIGINS" desc:"A comma-separated list of allowed CORS origins. See following chapter for more details: *Access-Control-Allow-Origin* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin"` + AllowedMethods []string `yaml:"allow_methods" env:"OCIS_CORS_ALLOW_METHODS;INVITATIONS_CORS_ALLOW_METHODS" desc:"A comma-separated list of allowed CORS methods. See following chapter for more details: *Access-Control-Request-Method* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method"` + AllowedHeaders []string `yaml:"allow_headers" env:"OCIS_CORS_ALLOW_HEADERS;INVITATIONS_CORS_ALLOW_HEADERS" desc:"A comma-separated list of allowed CORS headers. See following chapter for more details: *Access-Control-Request-Headers* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers."` + AllowCredentials bool `yaml:"allow_credentials" env:"OCIS_CORS_ALLOW_CREDENTIALS;INVITATIONS_CORS_ALLOW_CREDENTIALS" desc:"Allow credentials for CORS.See following chapter for more details: *Access-Control-Allow-Credentials* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials."` +} + +// HTTP defines the available http configuration. +type HTTP struct { + Addr string `yaml:"addr" env:"INVITATIONS_HTTP_ADDR" desc:"The bind address of the HTTP service."` + Namespace string `yaml:"-"` + Root string `yaml:"root" env:"INVITATIONS_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service."` + CORS CORS `yaml:"cors"` + TLS shared.HTTPServiceTLS `yaml:"tls"` +} diff --git a/services/invitations/pkg/config/log.go b/services/invitations/pkg/config/log.go new file mode 100644 index 00000000000..76875dfb46c --- /dev/null +++ b/services/invitations/pkg/config/log.go @@ -0,0 +1,9 @@ +package config + +// Log defines the available log configuration. +type Log struct { + Level string `mapstructure:"level" env:"OCIS_LOG_LEVEL;INVITATIONS_LOG_LEVEL" desc:"The log level. Valid values are: \"panic\", \"fatal\", \"error\", \"warn\", \"info\", \"debug\", \"trace\"."` + Pretty bool `mapstructure:"pretty" env:"OCIS_LOG_PRETTY;INVITATIONS_LOG_PRETTY" desc:"Activates pretty log output."` + Color bool `mapstructure:"color" env:"OCIS_LOG_COLOR;INVITATIONS_LOG_COLOR" desc:"Activates colorized log output."` + File string `mapstructure:"file" env:"OCIS_LOG_FILE;INVITATIONS_LOG_FILE" desc:"The path to the log file. Activates logging to this file if set."` +} diff --git a/services/invitations/pkg/config/parser/parse.go b/services/invitations/pkg/config/parser/parse.go new file mode 100644 index 00000000000..f0a3e666730 --- /dev/null +++ b/services/invitations/pkg/config/parser/parse.go @@ -0,0 +1,37 @@ +package parser + +import ( + "errors" + + ociscfg "github.com/owncloud/ocis/v2/ocis-pkg/config" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config/defaults" + + "github.com/owncloud/ocis/v2/ocis-pkg/config/envdecode" +) + +// ParseConfig loads configuration from known paths. +func ParseConfig(cfg *config.Config) error { + _, err := ociscfg.BindSourcesToStructs(cfg.Service.Name, cfg) + if err != nil { + return err + } + + defaults.EnsureDefaults(cfg) + + // load all env variables relevant to the config in the current context. + if err := envdecode.Decode(cfg); err != nil { + // no environment variable set for this config is an expected "error" + if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) { + return err + } + } + + defaults.Sanitize(cfg) + + return Validate(cfg) +} + +func Validate(cfg *config.Config) error { + return nil +} diff --git a/services/invitations/pkg/config/reva.go b/services/invitations/pkg/config/reva.go new file mode 100644 index 00000000000..8f80aabf2bc --- /dev/null +++ b/services/invitations/pkg/config/reva.go @@ -0,0 +1,6 @@ +package config + +// TokenManager is the config for using the reva token manager +type TokenManager struct { + JWTSecret string `yaml:"jwt_secret" env:"OCIS_JWT_SECRET;INVITATIONS_JWT_SECRET" desc:"The secret to mint and validate jwt tokens."` +} diff --git a/services/invitations/pkg/config/service.go b/services/invitations/pkg/config/service.go new file mode 100644 index 00000000000..d1eac383f0b --- /dev/null +++ b/services/invitations/pkg/config/service.go @@ -0,0 +1,6 @@ +package config + +// Service defines the available service configuration. +type Service struct { + Name string `yaml:"-"` +} diff --git a/services/invitations/pkg/config/tracing.go b/services/invitations/pkg/config/tracing.go new file mode 100644 index 00000000000..8b9cafecddb --- /dev/null +++ b/services/invitations/pkg/config/tracing.go @@ -0,0 +1,9 @@ +package config + +// Tracing defines the available tracing configuration. +type Tracing struct { + Enabled bool `yaml:"enabled" env:"OCIS_TRACING_ENABLED;INVITATIONS_TRACING_ENABLED" desc:"Activates tracing."` + Type string `yaml:"type" env:"OCIS_TRACING_TYPE;INVITATIONS_TRACING_TYPE" desc:"The type of tracing. Defaults to \"\", which is the same as \"jaeger\". Allowed tracing types are \"jaeger\" and \"\" as of now."` + Endpoint string `yaml:"endpoint" env:"OCIS_TRACING_ENDPOINT;INVITATIONS_TRACING_ENDPOINT" desc:"The endpoint of the tracing agent."` + Collector string `yaml:"collector" env:"OCIS_TRACING_COLLECTOR;INVITATIONS_TRACING_COLLECTOR" desc:"The HTTP endpoint for sending spans directly to a collector, i.e. http://jaeger-collector:14268/api/traces. Only used if the tracing endpoint is unset."` +} diff --git a/services/invitations/pkg/invitations/invitations.go b/services/invitations/pkg/invitations/invitations.go new file mode 100644 index 00000000000..876aeceb2d7 --- /dev/null +++ b/services/invitations/pkg/invitations/invitations.go @@ -0,0 +1,70 @@ +package invitations + +import libregraph "github.com/owncloud/libre-graph-api-go" + +// Invitation represents an invitation as per https://learn.microsoft.com/en-us/graph/api/resources/invitation?view=graph-rest-1.0 +type Invitation struct { + // The display name of the user being invited. + InvitedUserDisplayName string `json:"invitedUserDisplayName,omitempty"` + + // The email address of the user being invited. Required. + InvitedUserEmailAddress string `json:"invitedUserEmailAddress"` + + // Additional configuration for the message being sent to the + // invited user, including customizing message text, language + // and cc recipient list. + InvitedUserMessageInfo *InvitedUserMessageInfo `json:"invitedUserMessageInfo,omitempty"` + // The userType of the user being invited. By default, this is + // `Guest``. You can invite as `Member`` if you are a company + // administrator. + InvitedUserType string `json:"invitedUserType,omitempty"` + // The URL the user should be redirected to once the invitation + // is redeemed. Required. + InviteRedirectUrl string `json:"inviteRedirectUrl"` + // The URL the user can use to redeem their invitation. Read-only. + InviteRedeemUrl string `json:"inviteRedeemUrl,omitempty"` + // Reset the user's redemption status and reinvite a user while + // retaining their user identifier, group memberships, and app + // assignments. This property allows you to enable a user to + // sign-in using a different email address from the one in the + // previous invitation. + ResetRedemption string `json:"resetRedemption,omitempty"` + // Indicates whether an email should be sent to the user being + // invited. The default is false. + SendInvitationMessage bool `json:"sendInvitationMessage,omitempty"` + // The status of the invitation. Possible values are: + // `PendingAcceptance`, `Completed`, `InProgress`, and `Error`. + Status string `json:"status,omitempty"` + + // Relations + + // The user created as part of the invitation creation. Read-Only + InvitedUser *libregraph.User `json:"invitedUser,omitempty"` +} + +type InvitedUserMessageInfo struct { + // Additional recipients the invitation message should be sent + // to. Currently only 1 additional recipient is supported. + CcRecipients []Recipient `json:"ccRecipients"` + + // Customized message body you want to send if you don't want + // the default message. + CustomizedMessageBody string `json:"customizedMessageBody"` + + // The language you want to send the default message in. If the + // customizedMessageBody is specified, this property is ignored, + // and the message is sent using the customizedMessageBody. The + // language format should be in ISO 639. The default is en-US. + MessageLanguage string `json:"messageLanguage"` +} +type Recipient struct { + // The recipient's email address. + EmailAddress EmailAddress `json:"emailAddress"` +} +type EmailAddress struct { + // The email address of the person or entity. + Aaddress string `json:"address"` + + // The display name of the person or entity. + Name string `json:"name"` +} diff --git a/services/invitations/pkg/logging/logging.go b/services/invitations/pkg/logging/logging.go new file mode 100644 index 00000000000..a450db15ad9 --- /dev/null +++ b/services/invitations/pkg/logging/logging.go @@ -0,0 +1,17 @@ +package logging + +import ( + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" +) + +// LoggerFromConfig initializes a service-specific logger instance. +func Configure(name string, cfg *config.Log) log.Logger { + return log.NewLogger( + log.Name(name), + log.Level(cfg.Level), + log.Pretty(cfg.Pretty), + log.Color(cfg.Color), + log.File(cfg.File), + ) +} diff --git a/services/invitations/pkg/metrics/metrics.go b/services/invitations/pkg/metrics/metrics.go new file mode 100644 index 00000000000..b034799b951 --- /dev/null +++ b/services/invitations/pkg/metrics/metrics.go @@ -0,0 +1,81 @@ +package metrics + +import "github.com/prometheus/client_golang/prometheus" + +var ( + // Namespace defines the namespace for the defines metrics. + Namespace = "ocis" + + // Subsystem defines the subsystem for the defines metrics. + Subsystem = "invitations" +) + +// Metrics defines the available metrics of this service. +type Metrics struct { + BuildInfo *prometheus.GaugeVec + Counter *prometheus.CounterVec + Latency *prometheus.SummaryVec + Duration *prometheus.HistogramVec +} + +// New initializes the available metrics. +func New(opts ...Option) *Metrics { + options := newOptions(opts...) + + m := &Metrics{ + BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "build_info", + Help: "Build information", + }, []string{"version"}), + Counter: prometheus.NewCounterVec(prometheus.CounterOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "invitation_total", + Help: "How many invitation requests processed", + }, []string{}), + Latency: prometheus.NewSummaryVec(prometheus.SummaryOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "invitation_latency_microseconds", + Help: "Invitation request latencies in microseconds", + }, []string{}), + Duration: prometheus.NewHistogramVec(prometheus.HistogramOpts{ + Namespace: Namespace, + Subsystem: Subsystem, + Name: "invitation_duration_seconds", + Help: "Invitation request time in seconds", + }, []string{}), + } + + if err := prometheus.Register(m.BuildInfo); err != nil { + options.Logger.Error(). + Err(err). + Str("metric", "BuildInfo"). + Msg("Failed to register prometheus metric") + } + + if err := prometheus.Register(m.Counter); err != nil { + options.Logger.Error(). + Err(err). + Str("metric", "counter"). + Msg("Failed to register prometheus metric") + } + + if err := prometheus.Register(m.Latency); err != nil { + options.Logger.Error(). + Err(err). + Str("metric", "latency"). + Msg("Failed to register prometheus metric") + } + + if err := prometheus.Register(m.Duration); err != nil { + options.Logger.Error(). + Err(err). + Str("metric", "duration"). + Msg("Failed to register prometheus metric") + } + + return m +} diff --git a/services/invitations/pkg/metrics/options.go b/services/invitations/pkg/metrics/options.go new file mode 100644 index 00000000000..4a1279c8e74 --- /dev/null +++ b/services/invitations/pkg/metrics/options.go @@ -0,0 +1,31 @@ +package metrics + +import ( + "github.com/owncloud/ocis/v2/ocis-pkg/log" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + Logger log.Logger +} + +// newOptions initializes the available default options. +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// Logger provides a function to set the logger option. +func Logger(val log.Logger) Option { + return func(o *Options) { + o.Logger = val + } +} diff --git a/services/invitations/pkg/server/debug/option.go b/services/invitations/pkg/server/debug/option.go new file mode 100644 index 00000000000..6f7626a9def --- /dev/null +++ b/services/invitations/pkg/server/debug/option.go @@ -0,0 +1,50 @@ +package debug + +import ( + "context" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + Logger log.Logger + Context context.Context + Config *config.Config +} + +// newOptions initializes the available default options. +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// Logger provides a function to set the logger option. +func Logger(val log.Logger) Option { + return func(o *Options) { + o.Logger = val + } +} + +// Context provides a function to set the context option. +func Context(val context.Context) Option { + return func(o *Options) { + o.Context = val + } +} + +// Config provides a function to set the config option. +func Config(val *config.Config) Option { + return func(o *Options) { + o.Config = val + } +} diff --git a/services/invitations/pkg/server/debug/server.go b/services/invitations/pkg/server/debug/server.go new file mode 100644 index 00000000000..359df37b011 --- /dev/null +++ b/services/invitations/pkg/server/debug/server.go @@ -0,0 +1,63 @@ +package debug + +import ( + "io" + "net/http" + + "github.com/owncloud/ocis/v2/ocis-pkg/service/debug" + "github.com/owncloud/ocis/v2/ocis-pkg/version" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" +) + +// Server initializes the debug service and server. +func Server(opts ...Option) (*http.Server, error) { + options := newOptions(opts...) + + return debug.NewService( + debug.Logger(options.Logger), + debug.Name(options.Config.Service.Name), + debug.Version(version.GetString()), + debug.Address(options.Config.Debug.Addr), + debug.Token(options.Config.Debug.Token), + debug.Pprof(options.Config.Debug.Pprof), + debug.Zpages(options.Config.Debug.Zpages), + debug.Health(health(options.Config)), + debug.Ready(ready(options.Config)), + debug.CorsAllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins), + debug.CorsAllowedMethods(options.Config.HTTP.CORS.AllowedMethods), + debug.CorsAllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders), + debug.CorsAllowCredentials(options.Config.HTTP.CORS.AllowCredentials), + ), nil +} + +// health implements the health check. +func health(cfg *config.Config) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + + // TODO: check if services are up and running + + _, err := io.WriteString(w, http.StatusText(http.StatusOK)) + // io.WriteString should not fail but if it does we want to know. + if err != nil { + panic(err) + } + } +} + +// ready implements the ready check. +func ready(cfg *config.Config) func(http.ResponseWriter, *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "text/plain") + w.WriteHeader(http.StatusOK) + + // TODO: check if services are up and running + + _, err := io.WriteString(w, http.StatusText(http.StatusOK)) + // io.WriteString should not fail but if it does we want to know. + if err != nil { + panic(err) + } + } +} diff --git a/services/invitations/pkg/server/http/option.go b/services/invitations/pkg/server/http/option.go new file mode 100644 index 00000000000..12b06cfbbc3 --- /dev/null +++ b/services/invitations/pkg/server/http/option.go @@ -0,0 +1,84 @@ +package http + +import ( + "context" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" + svc "github.com/owncloud/ocis/v2/services/invitations/pkg/service/v0" + "github.com/urfave/cli/v2" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + Name string + Namespace string + Logger log.Logger + Context context.Context + Config *config.Config + Flags []cli.Flag + Service svc.Service +} + +// newOptions initializes the available default options. +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// Name provides a name for the service. +func Name(val string) Option { + return func(o *Options) { + o.Name = val + } +} + +// Logger provides a function to set the logger option. +func Logger(val log.Logger) Option { + return func(o *Options) { + o.Logger = val + } +} + +// Context provides a function to set the context option. +func Context(val context.Context) Option { + return func(o *Options) { + o.Context = val + } +} + +// Config provides a function to set the config option. +func Config(val *config.Config) Option { + return func(o *Options) { + o.Config = val + } +} + +// Flags provides a function to set the flags option. +func Flags(val []cli.Flag) Option { + return func(o *Options) { + o.Flags = append(o.Flags, val...) + } +} + +// Namespace provides a function to set the namespace option. +func Namespace(val string) Option { + return func(o *Options) { + o.Namespace = val + } +} + +// Service provides a function to set the service option. +func Service(val svc.Service) Option { + return func(o *Options) { + o.Service = val + } +} diff --git a/services/invitations/pkg/server/http/server.go b/services/invitations/pkg/server/http/server.go new file mode 100644 index 00000000000..ea9f0186e61 --- /dev/null +++ b/services/invitations/pkg/server/http/server.go @@ -0,0 +1,110 @@ +package http + +import ( + "encoding/json" + "fmt" + "net/http" + + "github.com/go-chi/chi/v5" + chimiddleware "github.com/go-chi/chi/v5/middleware" + "github.com/go-chi/render" + "github.com/owncloud/ocis/v2/ocis-pkg/account" + "github.com/owncloud/ocis/v2/ocis-pkg/cors" + "github.com/owncloud/ocis/v2/ocis-pkg/middleware" + ohttp "github.com/owncloud/ocis/v2/ocis-pkg/service/http" + "github.com/owncloud/ocis/v2/ocis-pkg/version" + "github.com/owncloud/ocis/v2/services/graph/pkg/service/v0/errorcode" + "github.com/owncloud/ocis/v2/services/invitations/pkg/invitations" + svc "github.com/owncloud/ocis/v2/services/invitations/pkg/service/v0" + "go-micro.dev/v4" +) + +// Server initializes the http service and server. +func Server(opts ...Option) (ohttp.Service, error) { + options := newOptions(opts...) + service := options.Service + + svc, err := ohttp.NewService( + ohttp.TLSConfig(options.Config.HTTP.TLS), + ohttp.Logger(options.Logger), + ohttp.Namespace(options.Config.HTTP.Namespace), + ohttp.Name(options.Config.Service.Name), + ohttp.Version(version.GetString()), + ohttp.Address(options.Config.HTTP.Addr), + ohttp.Context(options.Context), + ohttp.Flags(options.Flags...), + ) + if err != nil { + options.Logger.Error(). + Err(err). + Msg("Error initializing http service") + return ohttp.Service{}, err + } + + mux := chi.NewMux() + + mux.Use(chimiddleware.RealIP) + mux.Use(chimiddleware.RequestID) + mux.Use(middleware.TraceContext) + mux.Use(middleware.NoCache) + mux.Use( + middleware.Cors( + cors.Logger(options.Logger), + cors.AllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins), + cors.AllowedMethods(options.Config.HTTP.CORS.AllowedMethods), + cors.AllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders), + cors.AllowCredentials(options.Config.HTTP.CORS.AllowCredentials), + )) + mux.Use(middleware.Secure) + + mux.Use(middleware.Version( + options.Name, + version.String, + )) + mux.Use(middleware.ExtractAccountUUID( + account.Logger(options.Logger), + account.JWTSecret(options.Config.TokenManager.JWTSecret), + )) + + // this logs http request related data + mux.Use(middleware.Logger( + options.Logger, + )) + + mux.Route(options.Config.HTTP.Root, func(r chi.Router) { + r.Post("/invitations", InvitationHandler(service)) + }) + + err = micro.RegisterHandler(svc.Server(), mux) + if err != nil { + options.Logger.Fatal().Err(err).Msg("failed to register the handler") + } + + svc.Init() + return svc, nil +} + +func InvitationHandler(service svc.Service) func(w http.ResponseWriter, r *http.Request) { + return func(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + i := &invitations.Invitation{} + err := json.NewDecoder(r.Body).Decode(i) + if err != nil { + //logger.Debug().Err(err).Interface("body", r.Body).Msg("could not invite user: invalid request body") + errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error())) + return + } + + res, err := service.Invite(ctx, i) + if err != nil { + render.Status(r, http.StatusInternalServerError) + render.PlainText(w, r, err.Error()) + return + } + + //w.Header().Set("Content-type", "application/json") + render.Status(r, http.StatusCreated) + render.JSON(w, r, res) + } +} diff --git a/services/invitations/pkg/service/v0/errors.go b/services/invitations/pkg/service/v0/errors.go new file mode 100644 index 00000000000..b71ec4ca73f --- /dev/null +++ b/services/invitations/pkg/service/v0/errors.go @@ -0,0 +1,5 @@ +package service + +import "errors" + +var ErrNotFound = errors.New("query target not found") diff --git a/services/invitations/pkg/service/v0/instrument.go b/services/invitations/pkg/service/v0/instrument.go new file mode 100644 index 00000000000..42c31b5097e --- /dev/null +++ b/services/invitations/pkg/service/v0/instrument.go @@ -0,0 +1,38 @@ +package service + +import ( + "context" + + "github.com/owncloud/ocis/v2/services/invitations/pkg/invitations" + "github.com/owncloud/ocis/v2/services/invitations/pkg/metrics" + "github.com/prometheus/client_golang/prometheus" +) + +// NewInstrument returns a service that instruments metrics. +func NewInstrument(next Service, metrics *metrics.Metrics) Service { + return instrument{ + next: next, + metrics: metrics, + } +} + +type instrument struct { + next Service + metrics *metrics.Metrics +} + +// Invite implements the Service interface. +func (i instrument) Invite(ctx context.Context, invitation *invitations.Invitation) (*invitations.Invitation, error) { + timer := prometheus.NewTimer(prometheus.ObserverFunc(func(v float64) { + us := v * 1000000 + + i.metrics.Latency.WithLabelValues().Observe(us) + i.metrics.Duration.WithLabelValues().Observe(v) + })) + + defer timer.ObserveDuration() + + i.metrics.Counter.WithLabelValues().Inc() + + return i.next.Invite(ctx, invitation) +} diff --git a/services/invitations/pkg/service/v0/logging.go b/services/invitations/pkg/service/v0/logging.go new file mode 100644 index 00000000000..0115d9c80ca --- /dev/null +++ b/services/invitations/pkg/service/v0/logging.go @@ -0,0 +1,30 @@ +package service + +import ( + "context" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/invitations/pkg/invitations" +) + +// NewLogging returns a service that logs messages. +func NewLogging(next Service, logger log.Logger) Service { + return logging{ + next: next, + logger: logger, + } +} + +type logging struct { + next Service + logger log.Logger +} + +// Invite implements the Service interface. +func (l logging) Invite(ctx context.Context, invitation *invitations.Invitation) (*invitations.Invitation, error) { + l.logger.Debug(). + Interface("invitation", invitation). + Msg("Invite") + + return l.next.Invite(ctx, invitation) +} diff --git a/services/invitations/pkg/service/v0/option.go b/services/invitations/pkg/service/v0/option.go new file mode 100644 index 00000000000..35641c0a5fd --- /dev/null +++ b/services/invitations/pkg/service/v0/option.go @@ -0,0 +1,40 @@ +package service + +import ( + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" +) + +// Option defines a single option function. +type Option func(o *Options) + +// Options defines the available options for this package. +type Options struct { + Logger log.Logger + Config *config.Config +} + +// newOptions initializes the available default options. +func newOptions(opts ...Option) Options { + opt := Options{} + + for _, o := range opts { + o(&opt) + } + + return opt +} + +// Logger provides a function to set the logger option. +func Logger(val log.Logger) Option { + return func(o *Options) { + o.Logger = val + } +} + +// Config provides a function to set the config option. +func Config(val *config.Config) Option { + return func(o *Options) { + o.Config = val + } +} diff --git a/services/invitations/pkg/service/v0/service.go b/services/invitations/pkg/service/v0/service.go new file mode 100644 index 00000000000..aa5fd0785ef --- /dev/null +++ b/services/invitations/pkg/service/v0/service.go @@ -0,0 +1,55 @@ +package service + +import ( + "context" + + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" + "github.com/owncloud/ocis/v2/services/invitations/pkg/invitations" +) + +const ( + OwnCloudInstanceRel = "http://invitations.owncloud/rel/server-instance" + OpenIDConnectRel = "http://openid.net/specs/connect/1.0/issuer" +) + +// Service defines the extension handlers. +type Service interface { + // Invite creates a new invitation. Invitation adds an external user to the organization. + // + // When creating a new invitation you have several options available: + // 1. On invitation creation, Microsoft Graph can automatically send an + // invitation email directly to the invited user, or your app can use + // the inviteRedeemUrl returned in the creation response to craft your + // own invitation (through your communication mechanism of choice) to + // the invited user. If you decide to have Microsoft Graph send an + // invitation email automatically, you can control the content and + // language of the email using invitedUserMessageInfo. + // 2. When the user is invited, a user entity (of userType Guest) is + // created and can now be used to control access to resources. The + // invited user has to go through the redemption process to access any + // resources they have been invited to. + Invite(ctx context.Context, invitation *invitations.Invitation) (*invitations.Invitation, error) +} + +// New returns a new instance of Service +func New(opts ...Option) (Service, error) { + options := newOptions(opts...) + + return svc{ + log: options.Logger, + config: options.Config, + }, nil +} + +type svc struct { + config *config.Config + log log.Logger +} + +// Invite implements the service interface +func (s svc) Invite(ctx context.Context, invitation *invitations.Invitation) (*invitations.Invitation, error) { + return &invitations.Invitation{ + InvitedUserDisplayName: "Yay", + }, nil +} diff --git a/services/invitations/pkg/service/v0/tracing.go b/services/invitations/pkg/service/v0/tracing.go new file mode 100644 index 00000000000..2f5ea3b8159 --- /dev/null +++ b/services/invitations/pkg/service/v0/tracing.go @@ -0,0 +1,31 @@ +package service + +import ( + "context" + + "github.com/owncloud/ocis/v2/services/invitations/pkg/invitations" + invitationstracing "github.com/owncloud/ocis/v2/services/invitations/pkg/tracing" + "go.opentelemetry.io/otel/attribute" + "go.opentelemetry.io/otel/trace" +) + +// NewTracing returns a service that instruments traces. +func NewTracing(next Service) Service { + return tracing{ + next: next, + } +} + +type tracing struct { + next Service +} + +// Invite implements the Service interface. +func (t tracing) Invite(ctx context.Context, invitation *invitations.Invitation) (*invitations.Invitation, error) { + ctx, span := invitationstracing.TraceProvider.Tracer("invitations").Start(ctx, "Invite", trace.WithAttributes( + attribute.KeyValue{Key: "invitation", Value: attribute.StringValue(invitation.InvitedUserEmailAddress)}, + )) + defer span.End() + + return t.next.Invite(ctx, invitation) +} diff --git a/services/invitations/pkg/tracing/tracing.go b/services/invitations/pkg/tracing/tracing.go new file mode 100644 index 00000000000..dfae5fc0c17 --- /dev/null +++ b/services/invitations/pkg/tracing/tracing.go @@ -0,0 +1,23 @@ +package tracing + +import ( + pkgtrace "github.com/owncloud/ocis/v2/ocis-pkg/tracing" + "github.com/owncloud/ocis/v2/services/invitations/pkg/config" + "go.opentelemetry.io/otel/trace" +) + +var ( + // TraceProvider is the global trace provider for the proxy service. + TraceProvider = trace.NewNoopTracerProvider() +) + +func Configure(cfg *config.Config) error { + var err error + if cfg.Tracing.Enabled { + if TraceProvider, err = pkgtrace.GetTraceProvider(cfg.Tracing.Endpoint, cfg.Tracing.Collector, cfg.Service.Name, cfg.Tracing.Type); err != nil { + return err + } + } + + return nil +} diff --git a/services/invitations/reflex.conf b/services/invitations/reflex.conf new file mode 100644 index 00000000000..c73e25dffd1 --- /dev/null +++ b/services/invitations/reflex.conf @@ -0,0 +1,2 @@ +# backend +-r '^(cmd|pkg)/.*\.go$' -R '^node_modules/' -s -- sh -c 'make bin/ocis-invitations-debug && bin/ocis-invitations-debug --log-level debug server --debug-pprof --debug-zpages' diff --git a/services/proxy/pkg/config/defaults/defaultconfig.go b/services/proxy/pkg/config/defaults/defaultconfig.go index cde2c9360a1..582dbd0da79 100644 --- a/services/proxy/pkg/config/defaults/defaultconfig.go +++ b/services/proxy/pkg/config/defaults/defaultconfig.go @@ -219,6 +219,10 @@ func DefaultPolicies() []config.Policy { Endpoint: "/app/", // /app or /apps? ocdav only handles /apps Service: "com.owncloud.web.frontend", }, + { + Endpoint: "/graph/v1.0/invitations", + Service: "com.owncloud.graph.invitations", + }, { Endpoint: "/graph/", Service: "com.owncloud.graph.graph", From f8fd49a9a945d85e8c5cc063261f2b3c4343a68e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Wed, 1 Mar 2023 16:54:32 +0100 Subject: [PATCH 02/14] invitation implementation outline MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jörn Friedrich Dreyer --- services/invitations/pkg/service/v0/errors.go | 6 +- .../invitations/pkg/service/v0/service.go | 69 ++++++++++++++++++- 2 files changed, 71 insertions(+), 4 deletions(-) diff --git a/services/invitations/pkg/service/v0/errors.go b/services/invitations/pkg/service/v0/errors.go index b71ec4ca73f..7f34c4edbf2 100644 --- a/services/invitations/pkg/service/v0/errors.go +++ b/services/invitations/pkg/service/v0/errors.go @@ -2,4 +2,8 @@ package service import "errors" -var ErrNotFound = errors.New("query target not found") +var ( + ErrNotFound = errors.New("query target not found") + ErrBadRequest = errors.New("bad request") + ErrMissingEmail = errors.New("missing email address") +) diff --git a/services/invitations/pkg/service/v0/service.go b/services/invitations/pkg/service/v0/service.go index aa5fd0785ef..b72194c88c9 100644 --- a/services/invitations/pkg/service/v0/service.go +++ b/services/invitations/pkg/service/v0/service.go @@ -1,8 +1,13 @@ package service import ( + "bytes" "context" + "crypto/tls" + "encoding/json" + "net/http" + libregraph "github.com/owncloud/libre-graph-api-go" "github.com/owncloud/ocis/v2/ocis-pkg/log" "github.com/owncloud/ocis/v2/services/invitations/pkg/config" "github.com/owncloud/ocis/v2/services/invitations/pkg/invitations" @@ -49,7 +54,65 @@ type svc struct { // Invite implements the service interface func (s svc) Invite(ctx context.Context, invitation *invitations.Invitation) (*invitations.Invitation, error) { - return &invitations.Invitation{ - InvitedUserDisplayName: "Yay", - }, nil + + if invitation == nil { + return nil, ErrBadRequest + } + + if invitation.InvitedUserEmailAddress == "" { + return nil, ErrMissingEmail + } + + user := &libregraph.User{ + Mail: &invitation.InvitedUserEmailAddress, + // TODO we cannot set the user type here + } + + if invitation.InvitedUserDisplayName != "" { + user.DisplayName = &invitation.InvitedUserDisplayName + } + // we don't really need a username as guests have to log in with their email address anyway + // what if later a user is provisioned with a guest accounts email address? + + data, err := json.Marshal(user) + if err != nil { + return nil, err + } + + // send a request to the provisioning endpoint + tr := &http.Transport{ + TLSClientConfig: &tls.Config{InsecureSkipVerify: true /*TODO make configurable*/}, + } + client := &http.Client{Transport: tr} + + req, err := http.NewRequest("POST", "/graph/v1.0/users", bytes.NewReader(data)) + if err != nil { + return nil, err + } + + // TODO either forward current user token or use bearer token? + req.Header.Set("Authorization", "Bearer some-token") + + res, err := client.Do(req) + if err != nil { + return nil, err + } + defer res.Body.Close() + + invitedUser := &libregraph.User{} + err = json.NewDecoder(res.Body).Decode(invitedUser) + if err != nil { + return nil, err + } + + response := &invitations.Invitation{ + InvitedUser: invitedUser, + } + if res.StatusCode == http.StatusCreated { + response.Status = "Completed" + } + + // optionally send an email + + return response, nil } From 6c824519561ab9a2264cc58094ac31eedd08a3e2 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Thu, 2 Mar 2023 15:33:19 +0100 Subject: [PATCH 03/14] Update services/invitations/README.md Co-authored-by: Martin --- services/invitations/README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/services/invitations/README.md b/services/invitations/README.md index 86ec3ec028c..2042e73334f 100644 --- a/services/invitations/README.md +++ b/services/invitations/README.md @@ -1,7 +1,7 @@ # Invitations Service -The invitations service provides an [Invitation Manager](https://learn.microsoft.com/en-us/graph/api/invitation-post?view=graph-rest-1.0&tabs=http) that can be used to invide external users aka Guests to an organization. +The invitations service provides an [Invitation Manager](https://learn.microsoft.com/en-us/graph/api/invitation-post?view=graph-rest-1.0&tabs=http) that can be used to invite external users aka Guests to an organization. -On the libre graph API invited users will have `userType="Guest"`, whereas users belonging to the organization have `userType="Member"`. +Users invited via this Invitation Manager (libre graph API) will have `userType="Guest"`, whereas users belonging to the organization have `userType="Member"`. -The corresponding CS3 API [user types](https://cs3org.github.io/cs3apis/#cs3.identity.user.v1beta1.UserType) used to reperesent this are USER_TYPE_GUEST and USER_TYPE_PRIMARY. \ No newline at end of file +The corresponding CS3 API [user types](https://cs3org.github.io/cs3apis/#cs3.identity.user.v1beta1.UserType) used to reperesent this are: `USER_TYPE_GUEST` and `USER_TYPE_PRIMARY`. \ No newline at end of file From 5d7a19997bcb5e15ef4ba890dad95d0556573536 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Thu, 2 Mar 2023 15:37:38 +0100 Subject: [PATCH 04/14] correctly add new service MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jörn Friedrich Dreyer --- .drone.star | 1 + Makefile | 1 + docs/services/invitations/_index.md | 16 ++++++++++++++++ docs/services/invitations/configuration.md | 15 +++++++++++++++ services/invitations/README.md | 12 +++++++++++- 5 files changed, 44 insertions(+), 1 deletion(-) create mode 100644 docs/services/invitations/_index.md create mode 100644 docs/services/invitations/configuration.md diff --git a/.drone.star b/.drone.star index b6b65700969..a874f32becf 100644 --- a/.drone.star +++ b/.drone.star @@ -68,6 +68,7 @@ config = { "services/groups", "services/idm", "services/idp", + "services/invitations", "services/nats", "services/notifications", "services/ocdav", diff --git a/Makefile b/Makefile index 056c7c57a57..3f3d02c53fe 100644 --- a/Makefile +++ b/Makefile @@ -32,6 +32,7 @@ OCIS_MODULES = \ services/groups \ services/idm \ services/idp \ + services/invitations \ services/nats \ services/notifications \ services/ocdav \ diff --git a/docs/services/invitations/_index.md b/docs/services/invitations/_index.md new file mode 100644 index 00000000000..718690bd07d --- /dev/null +++ b/docs/services/invitations/_index.md @@ -0,0 +1,16 @@ +--- +title: IDP +weight: 20 +geekdocRepo: https://github.com/owncloud/ocis +geekdocEditPath: edit/master/docs/services/invitations +geekdocFilePath: _index.md +geekdocCollapseSection: true +--- + +## Abstract + +This service provides an invitations service to invite guests into the organization. + +## Table of Contents + +{{< toc-tree >}} diff --git a/docs/services/invitations/configuration.md b/docs/services/invitations/configuration.md new file mode 100644 index 00000000000..3cc3dea0d88 --- /dev/null +++ b/docs/services/invitations/configuration.md @@ -0,0 +1,15 @@ +--- +title: Service Configuration +date: 2023-03-02T15:27:00+01:00 +weight: 20 +geekdocRepo: https://github.com/owncloud/ocis +geekdocEditPath: edit/master/docs/services/invitations +geekdocFilePath: configuration.md +geekdocCollapseSection: true +--- + +## Example YAML Config + +{{< include file="services/_includes/invitations-config-example.yaml" language="yaml" >}} + +{{< include file="services/_includes/invitations_configvars.md" >}} \ No newline at end of file diff --git a/services/invitations/README.md b/services/invitations/README.md index 2042e73334f..a40ff2483a2 100644 --- a/services/invitations/README.md +++ b/services/invitations/README.md @@ -4,4 +4,14 @@ The invitations service provides an [Invitation Manager](https://learn.microsoft Users invited via this Invitation Manager (libre graph API) will have `userType="Guest"`, whereas users belonging to the organization have `userType="Member"`. -The corresponding CS3 API [user types](https://cs3org.github.io/cs3apis/#cs3.identity.user.v1beta1.UserType) used to reperesent this are: `USER_TYPE_GUEST` and `USER_TYPE_PRIMARY`. \ No newline at end of file +The corresponding CS3 API [user types](https://cs3org.github.io/cs3apis/#cs3.identity.user.v1beta1.UserType) used to reperesent this are: `USER_TYPE_GUEST` and `USER_TYPE_PRIMARY`. + + +## Provisioning backends + +When oCIS is used for user management the users are created using the `/graph/v1.0/users` endpoint. For larger deployments the keycloak admin API can be used to provision users. We might even make the endpoint, credentials and body configurable using templates. + + +## Bridging provisioning delay + +When a guest account has to be provisioned in an external user management there might be a delay between creating the user and it being available in the local ocis system. In the first iteration the invitations service will only keep track of invites in memory. This list could be persisted in future iterations. From fc5e4ea7d117ceb15a8471f8671b7b514387bc9d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Thu, 2 Mar 2023 17:16:05 +0100 Subject: [PATCH 05/14] generic http backend provisioning requests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Signed-off-by: Jörn Friedrich Dreyer --- services/invitations/pkg/config/config.go | 16 +++--- .../pkg/config/defaults/defaultconfig.go | 11 ++++ .../invitations/pkg/service/v0/service.go | 57 ++++++++++++++++--- 3 files changed, 69 insertions(+), 15 deletions(-) diff --git a/services/invitations/pkg/config/config.go b/services/invitations/pkg/config/config.go index 5e9aab9c600..6606338c16d 100644 --- a/services/invitations/pkg/config/config.go +++ b/services/invitations/pkg/config/config.go @@ -18,16 +18,18 @@ type Config struct { HTTP HTTP `yaml:"http"` + Endpoint Endpoint `yaml:"enpoint"` + TokenManager *TokenManager `yaml:"token_manager"` Context context.Context `yaml:"-"` } -// Instance to use with a matching rule and titles -type Instance struct { - Claim string `yaml:"claim"` - Regex string `yaml:"regex"` - Href string `yaml:"href"` - Titles map[string]string `yaml:"titles"` - Break bool `yaml:"break"` +// Endpoint to use +type Endpoint struct { + URL string `yaml:"url" env:"INVITATIONS_PROVISIONING_URL" desc:"The endpoint provisioning requests are sent to."` + Method string `yaml:"method" env:"INVITATIONS_PROVISIONING_METHOD" desc:"The method to use when making provisioning requests."` + BodyTemplate string `yaml:"body_template" env:"INVITATIONS_PROVISIONING_BODY_TEMPLATE" desc:"The template to use as body of a provisioning request."` + Authorization string `yaml:"authorization" env:"INVITATIONS_PROVISIONING_AUTH" desc:"The authorization to use. Can be 'token' to reuse the access token or 'bearer' to send a static api token."` + Token string `yaml:"authorization" env:"INVITATIONS_PROVISIONING_AUTH" desc:"The bearer token to send in provisioning requests."` } diff --git a/services/invitations/pkg/config/defaults/defaultconfig.go b/services/invitations/pkg/config/defaults/defaultconfig.go index 5249cef136e..527be752ec8 100644 --- a/services/invitations/pkg/config/defaults/defaultconfig.go +++ b/services/invitations/pkg/config/defaults/defaultconfig.go @@ -32,6 +32,17 @@ func DefaultConfig() *config.Config { Service: config.Service{ Name: "invitations", }, + Endpoint: config.Endpoint{ + URL: "{{.OCIS_URL}}/graph/v1.0/users", + Method: "POST", + BodyTemplate: `{ + "inviteRedirectUrl": "{{.redirectUrl}}", + "invitedUserEmailAddress": "{{.mail}}", + "invitedUserDisplayName": "{{.displayName}}", + "sendInvitationMessage": true + }`, + Authorization: "token", // reuse existing token + }, } } diff --git a/services/invitations/pkg/service/v0/service.go b/services/invitations/pkg/service/v0/service.go index b72194c88c9..d774f650613 100644 --- a/services/invitations/pkg/service/v0/service.go +++ b/services/invitations/pkg/service/v0/service.go @@ -5,7 +5,10 @@ import ( "context" "crypto/tls" "encoding/json" + "fmt" "net/http" + "strings" + "text/template" libregraph "github.com/owncloud/libre-graph-api-go" "github.com/owncloud/ocis/v2/ocis-pkg/log" @@ -41,15 +44,24 @@ type Service interface { func New(opts ...Option) (Service, error) { options := newOptions(opts...) + urlTemplate, err := template.New("invitations-provisioning-endpoint-url").Parse(options.Config.Endpoint.URL) + bodyTemplate, err := template.New("invitations-provisioning-endpoint-url").Parse(options.Config.Endpoint.BodyTemplate) + if err != nil { + return nil, err + } return svc{ - log: options.Logger, - config: options.Config, + log: options.Logger, + config: options.Config, + urlTemplate: urlTemplate, + bodyTemplate: bodyTemplate, }, nil } type svc struct { - config *config.Config - log log.Logger + config *config.Config + log log.Logger + urlTemplate *template.Template + bodyTemplate *template.Template } // Invite implements the service interface @@ -74,8 +86,21 @@ func (s svc) Invite(ctx context.Context, invitation *invitations.Invitation) (*i // we don't really need a username as guests have to log in with their email address anyway // what if later a user is provisioned with a guest accounts email address? - data, err := json.Marshal(user) - if err != nil { + templateVars := map[string]string{ + "redirectUrl": invitation.InviteRedirectUrl, + // TODO message and other options + "mail": invitation.InvitedUserEmailAddress, + "displayName": invitation.InvitedUserDisplayName, + "userType": invitation.InvitedUserType, + } + + var urlWriter strings.Builder + if err := s.urlTemplate.Execute(&urlWriter, templateVars); err != nil { + return nil, err + } + + var bodyWriter strings.Builder + if err := s.bodyTemplate.Execute(&bodyWriter, templateVars); err != nil { return nil, err } @@ -85,13 +110,20 @@ func (s svc) Invite(ctx context.Context, invitation *invitations.Invitation) (*i } client := &http.Client{Transport: tr} - req, err := http.NewRequest("POST", "/graph/v1.0/users", bytes.NewReader(data)) + req, err := http.NewRequest(s.config.Endpoint.Method, urlWriter.String(), bytes.NewBufferString(bodyWriter.String())) if err != nil { return nil, err } // TODO either forward current user token or use bearer token? - req.Header.Set("Authorization", "Bearer some-token") + switch s.config.Endpoint.Authorization { + case "token": + // TODO forward current reva access token + case "bearer": + req.Header.Set("Authorization", "Bearer "+s.config.Endpoint.Token) + default: + return nil, fmt.Errorf("unknown authorization: " + s.config.Endpoint.Authorization) + } res, err := client.Do(req) if err != nil { @@ -99,6 +131,15 @@ func (s svc) Invite(ctx context.Context, invitation *invitations.Invitation) (*i } defer res.Body.Close() + // TODO hm ok so we expect the rosponse to be a libregraph user ... so much for a generic endpoint + // we could try parsing into a map[string]interface{} .... hm ... maybe better to be specific about + // the actual backend: libregraph, keycloak, scim or even oc10? + + // Or we remember the mail of the user in memory and try to check if the user is already avilable via + // a local user api ... hm ... graph or cs3 user backend now? + + // in any case this will require an additional endpoint to keep track of the ongoing invitations + invitedUser := &libregraph.User{} err = json.NewDecoder(res.Body).Decode(invitedUser) if err != nil { From f244869e91727ccd12188f0203a653314e516f82 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Franke?= Date: Mon, 20 Mar 2023 12:53:14 +0100 Subject: [PATCH 06/14] Use keycloak for invitations backend. As keycloak already supports everything needed for the required invitation flow, it's ideal to use as the first backend to create users and to send them invitation mails. This PR implements that as the first and (for now) only backend. --- go.mod | 5 + go.sum | 8 + .../pkg/backends/keycloak/backend.go | 149 ++++++++++++++++++ services/invitations/pkg/config/config.go | 18 +-- .../pkg/config/defaults/defaultconfig.go | 16 +- .../invitations/pkg/server/http/server.go | 4 +- services/invitations/pkg/service/v0/errors.go | 1 + .../invitations/pkg/service/v0/service.go | 135 +++++----------- 8 files changed, 218 insertions(+), 118 deletions(-) create mode 100644 services/invitations/pkg/backends/keycloak/backend.go diff --git a/go.mod b/go.mod index ff387132efd..a3763d41461 100644 --- a/go.mod +++ b/go.mod @@ -113,6 +113,7 @@ require ( github.com/Masterminds/sprig v2.22.0+incompatible // indirect github.com/Microsoft/go-winio v0.6.0 // indirect github.com/OneOfOne/xxhash v1.2.8 // indirect + github.com/Nerzal/gocloak/v13 v13.1.0 // indirect github.com/ProtonMail/go-crypto v0.0.0-20220930113650-c6815a8c17ad // indirect github.com/RoaringBitmap/roaring v0.9.4 // indirect github.com/acomagu/bufpipe v1.0.3 // indirect @@ -185,6 +186,8 @@ require ( github.com/go-logfmt/logfmt v0.5.1 // indirect github.com/go-logr/logr v1.2.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect + github.com/go-redis/redis/v8 v8.11.5 // indirect + github.com/go-resty/resty/v2 v2.7.0 // indirect github.com/go-sql-driver/mysql v1.6.0 // indirect github.com/go-task/slim-sprig v0.0.0-20210107165309-348f09dbbbc0 // indirect github.com/gobwas/glob v0.2.3 // indirect @@ -260,6 +263,7 @@ require ( github.com/nats-io/nuid v1.0.1 // indirect github.com/nxadm/tail v1.4.8 // indirect github.com/opencontainers/runtime-spec v1.0.2 // indirect + github.com/opentracing/opentracing-go v1.2.0 // indirect github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c // indirect github.com/patrickmn/go-cache v2.1.0+incompatible // indirect github.com/pmezard/go-difflib v1.0.0 // indirect @@ -276,6 +280,7 @@ require ( github.com/russellhaering/goxmldsig v1.2.0 // indirect github.com/russross/blackfriday/v2 v2.1.0 // indirect github.com/sciencemesh/meshdirectory-web v1.0.4 // indirect + github.com/segmentio/ksuid v1.0.4 // indirect github.com/sergi/go-diff v1.2.0 // indirect github.com/sethvargo/go-password v0.2.0 // indirect github.com/shurcooL/httpfs v0.0.0-20190707220628-8d4bc4ba7749 // indirect diff --git a/go.sum b/go.sum index 19580569b29..da604f53ea4 100644 --- a/go.sum +++ b/go.sum @@ -435,6 +435,8 @@ github.com/Microsoft/go-winio v0.4.16/go.mod h1:XB6nPKklQyQ7GC9LdcBEcBl8PF76WugX github.com/Microsoft/go-winio v0.5.2/go.mod h1:WpS1mjBmmwHBEWmogvA2mj8546UReBk4v8QkMxJ6pZY= github.com/Microsoft/go-winio v0.6.0 h1:slsWYD/zyx7lCXoZVlvQrj0hPTM1HI4+v1sIda2yDvg= github.com/Microsoft/go-winio v0.6.0/go.mod h1:cTAf44im0RAYeL23bpB+fzCyDH2MJiz2BO69KH/soAE= +github.com/Nerzal/gocloak/v13 v13.1.0 h1:ret4pZTIsSQGZHURDMJ4jXnUmHyEoRykBqDTsAKoj8c= +github.com/Nerzal/gocloak/v13 v13.1.0/go.mod h1:rRBtEdh5N0+JlZZEsrfZcB2sRMZWbgSxI2EIv9jpJp4= github.com/OneOfOne/xxhash v1.2.2/go.mod h1:HSdplMjZKSmBqAxg5vPj2TmRDmfkzw+cTzAElWljhcU= github.com/OneOfOne/xxhash v1.2.8 h1:31czK/TI9sNkxIKfaUfGlU47BAxQ0ztGgd9vPyqimf8= github.com/OneOfOne/xxhash v1.2.8/go.mod h1:eZbhyaAYD41SGSSsnmcpxVoRiQ/MPUTjUdIIOT9Um7Q= @@ -823,6 +825,8 @@ github.com/go-playground/validator/v10 v10.4.1/go.mod h1:nlOn6nFhuKACm19sB/8EGNn github.com/go-redis/redis/v8 v8.11.5 h1:AcZZR7igkdvfVmQTPnu9WE37LRrO/YrBH5zWyjDC0oI= github.com/go-redis/redis/v8 v8.11.5/go.mod h1:gREzHqY1hg6oD9ngVRbLStwAWKhA0FEgq8Jd4h5lpwo= github.com/go-resty/resty/v2 v2.1.1-0.20191201195748-d7b97669fe48/go.mod h1:dZGr0i9PLlaaTD4H/hoZIDjQ+r6xq8mgbRzHZf7f2J8= +github.com/go-resty/resty/v2 v2.7.0 h1:me+K9p3uhSmXtrBZ4k9jcEAfJmuC8IivWHwaLZwPrFY= +github.com/go-resty/resty/v2 v2.7.0/go.mod h1:9PWDzw47qPphMRFfhsyk0NnSgvluHcljSMVIq3w7q0I= github.com/go-sql-driver/mysql v1.6.0 h1:BCTh4TKNUYmOmMUcQ3IipzF5prigylS7XXjEkfCHuOE= github.com/go-sql-driver/mysql v1.6.0/go.mod h1:DCzpHaOWr8IXmIStZouvnhqoel9Qv2LBy8hT2VhHyBg= github.com/go-stack/stack v1.8.0/go.mod h1:v0f6uXyyMGvRgIKkXu+yp6POWl0qKG85gN/melR3HDY= @@ -1362,6 +1366,7 @@ github.com/open-policy-agent/opa v0.50.0/go.mod h1:9jKfDk0L5b9rnhH4M0nq10cGHbYOx github.com/opencontainers/runtime-spec v1.0.2 h1:UfAcuLBJB9Coz72x1hgl8O5RVzTdNiaglX6v2DM6FI0= github.com/opencontainers/runtime-spec v1.0.2/go.mod h1:jwyrGlmzljRJv/Fgzds9SsS/C5hL+LL3ko9hs6T5lQ0= github.com/opentracing/opentracing-go v1.1.0/go.mod h1:UkNAQd3GIcIGf0SeVgPpRdFStlNbqXla1AfSYxPUl2o= +github.com/opentracing/opentracing-go v1.2.0 h1:uEJPy/1a5RIPAJ0Ov+OIO8OxWu77jEv+1B0VhjKrZUs= github.com/opentracing/opentracing-go v1.2.0/go.mod h1:GxEUsuufX4nBwe+T+Wl9TAgYrxe9dPLANfrWvHYVTgc= github.com/openzipkin/zipkin-go v0.1.6/go.mod h1:QgAqvLzwWbR/WpD4A3cGpPtJrZXNIiJc5AZX7/PBEpw= github.com/oracle/oci-go-sdk v24.3.0+incompatible/go.mod h1:VQb79nF8Z2cwLkLS35ukwStZIg5F66tcBccjip/j888= @@ -1489,6 +1494,8 @@ github.com/sciencemesh/meshdirectory-web v1.0.4 h1:1YSctF6PAXhoHUYCaeRTj7rHaF7b3 github.com/sciencemesh/meshdirectory-web v1.0.4/go.mod h1:fJSThTS3xf+sTdL0iXQoaQJssLI7tn7DetHMHUl4SRk= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529 h1:nn5Wsu0esKSJiIVhscUtVbo7ada43DJhG55ua/hjS5I= github.com/sean-/seed v0.0.0-20170313163322-e2103e2c3529/go.mod h1:DxrIzT+xaE7yg65j358z/aeFdxmN0P9QXhEzd20vsDc= +github.com/segmentio/ksuid v1.0.4 h1:sBo2BdShXjmcugAMwjugoGUdUV0pcxY5mW4xKRn3v4c= +github.com/segmentio/ksuid v1.0.4/go.mod h1:/XUiZBD3kVx5SmUOl55voK5yeAbBNNIed+2O73XgrPE= github.com/sergi/go-diff v1.0.0/go.mod h1:0CfEIISq7TuYL3j771MWULgwwjU+GofnZX9QAmXWZgo= github.com/sergi/go-diff v1.1.0/go.mod h1:STckp+ISIX8hZLjrqAeVduY0gWCT9IjLuqbuNXdaHfM= github.com/sergi/go-diff v1.2.0 h1:XU+rvMAioB0UC3q1MFrIQy4Vo5/4VsRDQQXHsEya6xQ= @@ -1812,6 +1819,7 @@ golang.org/x/net v0.0.0-20210503060351-7fd8e65b6420/go.mod h1:9nx3DQGgdP8bBQD5qx golang.org/x/net v0.0.0-20210525063256-abc453219eb5/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20210726213435-c6fcb2dbf985/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211015210444-4f30a5c0130f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= +golang.org/x/net v0.0.0-20211029224645-99673261e6eb/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211112202133-69e39bad7dc2/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20211216030914-fe4d6282115f/go.mod h1:9nx3DQGgdP8bBQD5qxJ1jj9UTztislL4KSBs9R2vV5Y= golang.org/x/net v0.0.0-20220127200216-cd36cc0744dd/go.mod h1:CfG3xpIq0wQ8r1q4Su4UZFWDARRcnwPjda9FqA0JpMk= diff --git a/services/invitations/pkg/backends/keycloak/backend.go b/services/invitations/pkg/backends/keycloak/backend.go new file mode 100644 index 00000000000..5b8102bf059 --- /dev/null +++ b/services/invitations/pkg/backends/keycloak/backend.go @@ -0,0 +1,149 @@ +// Package keycloak offers an invitation backend for the invitation service. +// TODO: Maybe move this outside of the invitation service and make it more generic? + +package keycloak + +import ( + "context" + "crypto/tls" + "fmt" + "strings" + + "github.com/Nerzal/gocloak/v13" + "github.com/google/uuid" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/invitations/pkg/invitations" +) + +const ( + idAttr = "OWNCLOUD_ID" + userTypeAttr = "OWNCLOUD_USER_TYPE" + userTypeVal = "Guest" +) + +var userRequiredActions = []string{"UPDATE_PASSWORD", "VERIFY_EMAIL"} + +// Backend represents the keycloak backend. +type Backend struct { + logger log.Logger + client *gocloak.GoCloak + clientID string + clientSecret string + clientRealm string + userRealm string +} + +// New returns a new keycloak.Backend, with all the config options set. +func New( + logger log.Logger, + baseURL, clientID, clientSecret, clientRealm, userRealm string, + insecureSkipVerify bool, +) *Backend { + client := gocloak.NewClient(baseURL) + restyClient := client.RestyClient() + restyClient.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: insecureSkipVerify}) + return &Backend{ + logger: log.Logger{ + logger.With(). + Str("invitationBackend", "keycloak"). + Str("baseURL", baseURL). + Str("clientID", clientID). + Str("clientRealm", clientRealm). + Str("userRealm", userRealm). + Logger(), + }, + client: client, + clientID: clientID, + clientSecret: clientSecret, + clientRealm: clientRealm, + userRealm: userRealm, + } +} + +// CreateUser creates a user in the keycloak backend. +func (b Backend) CreateUser(ctx context.Context, invitation *invitations.Invitation) (string, error) { + token, err := b.getToken(ctx) + if err != nil { + return "", err + } + u := uuid.New() + + firstName, lastName := splitDisplayName(invitation.InvitedUserDisplayName) + b.logger.Info(). + Str(idAttr, u.String()). + Str("email", invitation.InvitedUserEmailAddress). + Msg("Creating new user") + user := gocloak.User{ + FirstName: &firstName, + LastName: &lastName, + Email: &invitation.InvitedUserEmailAddress, + Enabled: gocloak.BoolP(true), + Username: &invitation.InvitedUserEmailAddress, + Attributes: &map[string][]string{ + idAttr: {u.String()}, + userTypeAttr: {userTypeVal}, + }, + RequiredActions: &userRequiredActions, + } + + id, err := b.client.CreateUser(ctx, token.AccessToken, b.userRealm, user) + if err != nil { + b.logger.Error(). + Str(idAttr, u.String()). + Str("email", invitation.InvitedUserEmailAddress). + Err(err). + Msg("Failed to create user") + return "", err + } + + return id, nil +} + +// CanSendMail returns true because keycloak does allow to send mail. +func (b Backend) CanSendMail() bool { return true } + +// SendMail sends a mail to the user with details on how to reedeem the invitation. +func (b Backend) SendMail(ctx context.Context, id string) error { + token, err := b.getToken(ctx) + if err != nil { + return err + } + params := gocloak.ExecuteActionsEmail{ + UserID: &id, + Actions: &userRequiredActions, + } + return b.client.ExecuteActionsEmail(ctx, token.AccessToken, b.userRealm, params) +} + +func (b Backend) getToken(ctx context.Context) (*gocloak.JWT, error) { + b.logger.Debug().Msg("Logging into keycloak") + token, err := b.client.LoginClient(ctx, b.clientID, b.clientSecret, b.clientRealm) + if err != nil { + b.logger.Error().Err(err).Msg("failed to get token") + return nil, fmt.Errorf("failed to get token: %w", err) + } + + rRes, err := b.client.RetrospectToken(ctx, token.AccessToken, b.clientID, b.clientSecret, b.clientRealm) + if err != nil { + b.logger.Error().Err(err).Msg("failed to introspect token") + return nil, fmt.Errorf("failed to retrospect token: %w", err) + } + + if !*rRes.Active { + b.logger.Error().Msg("token not active") + return nil, fmt.Errorf("token is not active") + } + + return token, nil +} + +// Quick and dirty way to split the last name off from the first name(s), imperfect, because +// every culture has a different conception of names. +func splitDisplayName(displayName string) (string, string) { + parts := strings.Split(displayName, " ") + if len(parts) <= 1 { + return parts[0], "" + } + + return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1] +} diff --git a/services/invitations/pkg/config/config.go b/services/invitations/pkg/config/config.go index 6606338c16d..240a4da9b80 100644 --- a/services/invitations/pkg/config/config.go +++ b/services/invitations/pkg/config/config.go @@ -18,18 +18,18 @@ type Config struct { HTTP HTTP `yaml:"http"` - Endpoint Endpoint `yaml:"enpoint"` - + Keycloak Keycloak `yaml:"keycloak"` TokenManager *TokenManager `yaml:"token_manager"` Context context.Context `yaml:"-"` } -// Endpoint to use -type Endpoint struct { - URL string `yaml:"url" env:"INVITATIONS_PROVISIONING_URL" desc:"The endpoint provisioning requests are sent to."` - Method string `yaml:"method" env:"INVITATIONS_PROVISIONING_METHOD" desc:"The method to use when making provisioning requests."` - BodyTemplate string `yaml:"body_template" env:"INVITATIONS_PROVISIONING_BODY_TEMPLATE" desc:"The template to use as body of a provisioning request."` - Authorization string `yaml:"authorization" env:"INVITATIONS_PROVISIONING_AUTH" desc:"The authorization to use. Can be 'token' to reuse the access token or 'bearer' to send a static api token."` - Token string `yaml:"authorization" env:"INVITATIONS_PROVISIONING_AUTH" desc:"The bearer token to send in provisioning requests."` +// Keycloak configuration +type Keycloak struct { + BasePath string `yaml:"base_path" env:"INVITATIONS_KEYCLOAK_BASE_PATH" desc:"The URL to keycloak."` + ClientID string `yaml:"client_id" env:"INVITATIONS_KEYCLOAK_CLIENT_ID" desc:"The client id to authenticate with keycloak."` + ClientSecret string `yaml:"client_secret" env:"INVITATIONS_KEYCLOAK_CLIENT_SECRET" desc:"The client secret to use in authentication."` + ClientRealm string `yaml:"client_realm" env:"INVITATIONS_KEYCLOAK_CLIENT_REALM" desc:"The realm the client is defined in."` + UserRealm string `yaml:"user_realm" env:"INVITATIONS_KEYCLOAK_USER_REALM" desc:"The realm the users are in."` + InsecureSkipVerify bool `yaml:"insecure_skip_verify" env:"INVITATIONS_KEYCLOAK_INSECURE_SKIP_VERIFY" desc:"Skip the check of the TLS certificate."` } diff --git a/services/invitations/pkg/config/defaults/defaultconfig.go b/services/invitations/pkg/config/defaults/defaultconfig.go index 527be752ec8..52d29addcb4 100644 --- a/services/invitations/pkg/config/defaults/defaultconfig.go +++ b/services/invitations/pkg/config/defaults/defaultconfig.go @@ -32,16 +32,12 @@ func DefaultConfig() *config.Config { Service: config.Service{ Name: "invitations", }, - Endpoint: config.Endpoint{ - URL: "{{.OCIS_URL}}/graph/v1.0/users", - Method: "POST", - BodyTemplate: `{ - "inviteRedirectUrl": "{{.redirectUrl}}", - "invitedUserEmailAddress": "{{.mail}}", - "invitedUserDisplayName": "{{.displayName}}", - "sendInvitationMessage": true - }`, - Authorization: "token", // reuse existing token + Keycloak: config.Keycloak{ + BasePath: "https://keycloak.example.org/", + ClientID: "invitations-service", + ClientSecret: "fake-secret", + ClientRealm: "someRealm", + UserRealm: "someRealm", }, } } diff --git a/services/invitations/pkg/server/http/server.go b/services/invitations/pkg/server/http/server.go index ea9f0186e61..d091d52f801 100644 --- a/services/invitations/pkg/server/http/server.go +++ b/services/invitations/pkg/server/http/server.go @@ -91,7 +91,7 @@ func InvitationHandler(service svc.Service) func(w http.ResponseWriter, r *http. i := &invitations.Invitation{} err := json.NewDecoder(r.Body).Decode(i) if err != nil { - //logger.Debug().Err(err).Interface("body", r.Body).Msg("could not invite user: invalid request body") + // logger.Debug().Err(err).Interface("body", r.Body).Msg("could not invite user: invalid request body") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error())) return } @@ -103,7 +103,7 @@ func InvitationHandler(service svc.Service) func(w http.ResponseWriter, r *http. return } - //w.Header().Set("Content-type", "application/json") + // w.Header().Set("Content-type", "application/json") render.Status(r, http.StatusCreated) render.JSON(w, r, res) } diff --git a/services/invitations/pkg/service/v0/errors.go b/services/invitations/pkg/service/v0/errors.go index 7f34c4edbf2..05aece55854 100644 --- a/services/invitations/pkg/service/v0/errors.go +++ b/services/invitations/pkg/service/v0/errors.go @@ -6,4 +6,5 @@ var ( ErrNotFound = errors.New("query target not found") ErrBadRequest = errors.New("bad request") ErrMissingEmail = errors.New("missing email address") + ErrBackend = errors.New("backend error") ) diff --git a/services/invitations/pkg/service/v0/service.go b/services/invitations/pkg/service/v0/service.go index d774f650613..791c8c39d2e 100644 --- a/services/invitations/pkg/service/v0/service.go +++ b/services/invitations/pkg/service/v0/service.go @@ -1,17 +1,11 @@ package service import ( - "bytes" "context" - "crypto/tls" - "encoding/json" "fmt" - "net/http" - "strings" - "text/template" - libregraph "github.com/owncloud/libre-graph-api-go" "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/invitations/pkg/backends/keycloak" "github.com/owncloud/ocis/v2/services/invitations/pkg/config" "github.com/owncloud/ocis/v2/services/invitations/pkg/invitations" ) @@ -40,33 +34,46 @@ type Service interface { Invite(ctx context.Context, invitation *invitations.Invitation) (*invitations.Invitation, error) } +// Backend defines the behaviour of a user backend. +type Backend interface { + // CreateUser creates a user in the backend and returns an identifier string. + CreateUser(ctx context.Context, invitation *invitations.Invitation) (string, error) + // CanSendMail should return true if the backend can send mail + CanSendMail() bool + // SendMail sends a mail to the user with details on how to reedeem the invitation. + SendMail(ctx context.Context, identifier string) error +} + // New returns a new instance of Service func New(opts ...Option) (Service, error) { options := newOptions(opts...) - urlTemplate, err := template.New("invitations-provisioning-endpoint-url").Parse(options.Config.Endpoint.URL) - bodyTemplate, err := template.New("invitations-provisioning-endpoint-url").Parse(options.Config.Endpoint.BodyTemplate) - if err != nil { - return nil, err - } + // Harcode keycloak backend for now, but this should be configurable in the future. + backend := keycloak.New( + options.Logger, + options.Config.Keycloak.BasePath, + options.Config.Keycloak.ClientID, + options.Config.Keycloak.ClientSecret, + options.Config.Keycloak.ClientRealm, + options.Config.Keycloak.UserRealm, + options.Config.Keycloak.InsecureSkipVerify, + ) + return svc{ - log: options.Logger, - config: options.Config, - urlTemplate: urlTemplate, - bodyTemplate: bodyTemplate, + log: options.Logger, + config: options.Config, + backend: backend, }, nil } type svc struct { - config *config.Config - log log.Logger - urlTemplate *template.Template - bodyTemplate *template.Template + config *config.Config + log log.Logger + backend Backend } // Invite implements the service interface func (s svc) Invite(ctx context.Context, invitation *invitations.Invitation) (*invitations.Invitation, error) { - if invitation == nil { return nil, ErrBadRequest } @@ -75,85 +82,19 @@ func (s svc) Invite(ctx context.Context, invitation *invitations.Invitation) (*i return nil, ErrMissingEmail } - user := &libregraph.User{ - Mail: &invitation.InvitedUserEmailAddress, - // TODO we cannot set the user type here - } - - if invitation.InvitedUserDisplayName != "" { - user.DisplayName = &invitation.InvitedUserDisplayName - } - // we don't really need a username as guests have to log in with their email address anyway - // what if later a user is provisioned with a guest accounts email address? - - templateVars := map[string]string{ - "redirectUrl": invitation.InviteRedirectUrl, - // TODO message and other options - "mail": invitation.InvitedUserEmailAddress, - "displayName": invitation.InvitedUserDisplayName, - "userType": invitation.InvitedUserType, - } - - var urlWriter strings.Builder - if err := s.urlTemplate.Execute(&urlWriter, templateVars); err != nil { - return nil, err - } - - var bodyWriter strings.Builder - if err := s.bodyTemplate.Execute(&bodyWriter, templateVars); err != nil { - return nil, err - } - - // send a request to the provisioning endpoint - tr := &http.Transport{ - TLSClientConfig: &tls.Config{InsecureSkipVerify: true /*TODO make configurable*/}, - } - client := &http.Client{Transport: tr} - - req, err := http.NewRequest(s.config.Endpoint.Method, urlWriter.String(), bytes.NewBufferString(bodyWriter.String())) + id, err := s.backend.CreateUser(ctx, invitation) if err != nil { - return nil, err - } - - // TODO either forward current user token or use bearer token? - switch s.config.Endpoint.Authorization { - case "token": - // TODO forward current reva access token - case "bearer": - req.Header.Set("Authorization", "Bearer "+s.config.Endpoint.Token) - default: - return nil, fmt.Errorf("unknown authorization: " + s.config.Endpoint.Authorization) + return nil, fmt.Errorf("%w: %s", ErrBackend, err) } - res, err := client.Do(req) - if err != nil { - return nil, err + // As we only have a single backend, and that backend supports email, we don't have + // any code to handle mailing ourself yet. + if s.backend.CanSendMail() { + err := s.backend.SendMail(ctx, id) + if err != nil { + return nil, fmt.Errorf("%w: %s", ErrBackend, err) + } } - defer res.Body.Close() - - // TODO hm ok so we expect the rosponse to be a libregraph user ... so much for a generic endpoint - // we could try parsing into a map[string]interface{} .... hm ... maybe better to be specific about - // the actual backend: libregraph, keycloak, scim or even oc10? - - // Or we remember the mail of the user in memory and try to check if the user is already avilable via - // a local user api ... hm ... graph or cs3 user backend now? - - // in any case this will require an additional endpoint to keep track of the ongoing invitations - - invitedUser := &libregraph.User{} - err = json.NewDecoder(res.Body).Decode(invitedUser) - if err != nil { - return nil, err - } - - response := &invitations.Invitation{ - InvitedUser: invitedUser, - } - if res.StatusCode == http.StatusCreated { - response.Status = "Completed" - } - - // optionally send an email - return response, nil + return invitation, nil } From 9355ed6812225465af786e153354b794c4325732 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Franke?= Date: Wed, 22 Mar 2023 12:48:24 +0100 Subject: [PATCH 07/14] Add tests for keycloak backend. --- go.mod | 1 + services/invitations/Makefile | 2 +- services/invitations/mocks/ldapclient.go | 358 ------------------ .../pkg/backends/keycloak/backend.go | 20 +- .../pkg/backends/keycloak/backend_test.go | 202 ++++++++++ .../pkg/backends/keycloak/gocloak.go | 17 + services/invitations/pkg/mocks/go_cloak.go | 112 ++++++ 7 files changed, 346 insertions(+), 366 deletions(-) delete mode 100644 services/invitations/mocks/ldapclient.go create mode 100644 services/invitations/pkg/backends/keycloak/backend_test.go create mode 100644 services/invitations/pkg/backends/keycloak/gocloak.go create mode 100644 services/invitations/pkg/mocks/go_cloak.go diff --git a/go.mod b/go.mod index a3763d41461..77a3ff43953 100644 --- a/go.mod +++ b/go.mod @@ -7,6 +7,7 @@ require ( github.com/KimMachineGun/automemlimit v0.2.4 github.com/Masterminds/semver v1.5.0 github.com/MicahParks/keyfunc v1.5.1 + github.com/Nerzal/gocloak/v13 v13.1.0 github.com/armon/go-radix v1.0.0 github.com/bbalet/stopwords v1.0.0 github.com/blevesearch/bleve/v2 v2.3.6 diff --git a/services/invitations/Makefile b/services/invitations/Makefile index d34a0071b53..717e7ff5d23 100644 --- a/services/invitations/Makefile +++ b/services/invitations/Makefile @@ -25,7 +25,7 @@ include ../../.make/generate.mk .PHONY: ci-go-generate ci-go-generate: $(MOCKERY) # CI runs ci-node-generate automatically before this target - $(MOCKERY) --srcpkg github.com/go-ldap/ldap/v3 --case underscore --filename ldapclient.go --name Client + $(MOCKERY) --dir pkg/backends/keycloak --output pkg/mocks --case underscore --name GoCloak .PHONY: ci-node-generate diff --git a/services/invitations/mocks/ldapclient.go b/services/invitations/mocks/ldapclient.go deleted file mode 100644 index 7893c5dcca6..00000000000 --- a/services/invitations/mocks/ldapclient.go +++ /dev/null @@ -1,358 +0,0 @@ -// Code generated by mockery v2.14.1. DO NOT EDIT. - -package mocks - -import ( - ldap "github.com/go-ldap/ldap/v3" - mock "github.com/stretchr/testify/mock" - - time "time" - - tls "crypto/tls" -) - -// Client is an autogenerated mock type for the Client type -type Client struct { - mock.Mock -} - -// Add provides a mock function with given fields: _a0 -func (_m *Client) Add(_a0 *ldap.AddRequest) error { - ret := _m.Called(_a0) - - var r0 error - if rf, ok := ret.Get(0).(func(*ldap.AddRequest) error); ok { - r0 = rf(_a0) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// Bind provides a mock function with given fields: username, password -func (_m *Client) Bind(username string, password string) error { - ret := _m.Called(username, password) - - var r0 error - if rf, ok := ret.Get(0).(func(string, string) error); ok { - r0 = rf(username, password) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// Close provides a mock function with given fields: -func (_m *Client) Close() { - _m.Called() -} - -// Compare provides a mock function with given fields: dn, attribute, value -func (_m *Client) Compare(dn string, attribute string, value string) (bool, error) { - ret := _m.Called(dn, attribute, value) - - var r0 bool - if rf, ok := ret.Get(0).(func(string, string, string) bool); ok { - r0 = rf(dn, attribute, value) - } else { - r0 = ret.Get(0).(bool) - } - - var r1 error - if rf, ok := ret.Get(1).(func(string, string, string) error); ok { - r1 = rf(dn, attribute, value) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// Del provides a mock function with given fields: _a0 -func (_m *Client) Del(_a0 *ldap.DelRequest) error { - ret := _m.Called(_a0) - - var r0 error - if rf, ok := ret.Get(0).(func(*ldap.DelRequest) error); ok { - r0 = rf(_a0) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// ExternalBind provides a mock function with given fields: -func (_m *Client) ExternalBind() error { - ret := _m.Called() - - var r0 error - if rf, ok := ret.Get(0).(func() error); ok { - r0 = rf() - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// IsClosing provides a mock function with given fields: -func (_m *Client) IsClosing() bool { - ret := _m.Called() - - var r0 bool - if rf, ok := ret.Get(0).(func() bool); ok { - r0 = rf() - } else { - r0 = ret.Get(0).(bool) - } - - return r0 -} - -// Modify provides a mock function with given fields: _a0 -func (_m *Client) Modify(_a0 *ldap.ModifyRequest) error { - ret := _m.Called(_a0) - - var r0 error - if rf, ok := ret.Get(0).(func(*ldap.ModifyRequest) error); ok { - r0 = rf(_a0) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// ModifyDN provides a mock function with given fields: _a0 -func (_m *Client) ModifyDN(_a0 *ldap.ModifyDNRequest) error { - ret := _m.Called(_a0) - - var r0 error - if rf, ok := ret.Get(0).(func(*ldap.ModifyDNRequest) error); ok { - r0 = rf(_a0) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// ModifyWithResult provides a mock function with given fields: _a0 -func (_m *Client) ModifyWithResult(_a0 *ldap.ModifyRequest) (*ldap.ModifyResult, error) { - ret := _m.Called(_a0) - - var r0 *ldap.ModifyResult - if rf, ok := ret.Get(0).(func(*ldap.ModifyRequest) *ldap.ModifyResult); ok { - r0 = rf(_a0) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*ldap.ModifyResult) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(*ldap.ModifyRequest) error); ok { - r1 = rf(_a0) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// NTLMUnauthenticatedBind provides a mock function with given fields: domain, username -func (_m *Client) NTLMUnauthenticatedBind(domain string, username string) error { - ret := _m.Called(domain, username) - - var r0 error - if rf, ok := ret.Get(0).(func(string, string) error); ok { - r0 = rf(domain, username) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// PasswordModify provides a mock function with given fields: _a0 -func (_m *Client) PasswordModify(_a0 *ldap.PasswordModifyRequest) (*ldap.PasswordModifyResult, error) { - ret := _m.Called(_a0) - - var r0 *ldap.PasswordModifyResult - if rf, ok := ret.Get(0).(func(*ldap.PasswordModifyRequest) *ldap.PasswordModifyResult); ok { - r0 = rf(_a0) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*ldap.PasswordModifyResult) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(*ldap.PasswordModifyRequest) error); ok { - r1 = rf(_a0) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// Search provides a mock function with given fields: _a0 -func (_m *Client) Search(_a0 *ldap.SearchRequest) (*ldap.SearchResult, error) { - ret := _m.Called(_a0) - - var r0 *ldap.SearchResult - if rf, ok := ret.Get(0).(func(*ldap.SearchRequest) *ldap.SearchResult); ok { - r0 = rf(_a0) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*ldap.SearchResult) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(*ldap.SearchRequest) error); ok { - r1 = rf(_a0) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// SearchWithPaging provides a mock function with given fields: searchRequest, pagingSize -func (_m *Client) SearchWithPaging(searchRequest *ldap.SearchRequest, pagingSize uint32) (*ldap.SearchResult, error) { - ret := _m.Called(searchRequest, pagingSize) - - var r0 *ldap.SearchResult - if rf, ok := ret.Get(0).(func(*ldap.SearchRequest, uint32) *ldap.SearchResult); ok { - r0 = rf(searchRequest, pagingSize) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*ldap.SearchResult) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(*ldap.SearchRequest, uint32) error); ok { - r1 = rf(searchRequest, pagingSize) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// SetTimeout provides a mock function with given fields: _a0 -func (_m *Client) SetTimeout(_a0 time.Duration) { - _m.Called(_a0) -} - -// SimpleBind provides a mock function with given fields: _a0 -func (_m *Client) SimpleBind(_a0 *ldap.SimpleBindRequest) (*ldap.SimpleBindResult, error) { - ret := _m.Called(_a0) - - var r0 *ldap.SimpleBindResult - if rf, ok := ret.Get(0).(func(*ldap.SimpleBindRequest) *ldap.SimpleBindResult); ok { - r0 = rf(_a0) - } else { - if ret.Get(0) != nil { - r0 = ret.Get(0).(*ldap.SimpleBindResult) - } - } - - var r1 error - if rf, ok := ret.Get(1).(func(*ldap.SimpleBindRequest) error); ok { - r1 = rf(_a0) - } else { - r1 = ret.Error(1) - } - - return r0, r1 -} - -// Start provides a mock function with given fields: -func (_m *Client) Start() { - _m.Called() -} - -// StartTLS provides a mock function with given fields: _a0 -func (_m *Client) StartTLS(_a0 *tls.Config) error { - ret := _m.Called(_a0) - - var r0 error - if rf, ok := ret.Get(0).(func(*tls.Config) error); ok { - r0 = rf(_a0) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// TLSConnectionState provides a mock function with given fields: -func (_m *Client) TLSConnectionState() (tls.ConnectionState, bool) { - ret := _m.Called() - - var r0 tls.ConnectionState - if rf, ok := ret.Get(0).(func() tls.ConnectionState); ok { - r0 = rf() - } else { - r0 = ret.Get(0).(tls.ConnectionState) - } - - var r1 bool - if rf, ok := ret.Get(1).(func() bool); ok { - r1 = rf() - } else { - r1 = ret.Get(1).(bool) - } - - return r0, r1 -} - -// UnauthenticatedBind provides a mock function with given fields: username -func (_m *Client) UnauthenticatedBind(username string) error { - ret := _m.Called(username) - - var r0 error - if rf, ok := ret.Get(0).(func(string) error); ok { - r0 = rf(username) - } else { - r0 = ret.Error(0) - } - - return r0 -} - -// Unbind provides a mock function with given fields: -func (_m *Client) Unbind() error { - ret := _m.Called() - - var r0 error - if rf, ok := ret.Get(0).(func() error); ok { - r0 = rf() - } else { - r0 = ret.Error(0) - } - - return r0 -} - -type mockConstructorTestingTNewClient interface { - mock.TestingT - Cleanup(func()) -} - -// NewClient creates a new instance of Client. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. -func NewClient(t mockConstructorTestingTNewClient) *Client { - mock := &Client{} - mock.Mock.Test(t) - - t.Cleanup(func() { mock.AssertExpectations(t) }) - - return mock -} diff --git a/services/invitations/pkg/backends/keycloak/backend.go b/services/invitations/pkg/backends/keycloak/backend.go index 5b8102bf059..e097be9c9b3 100644 --- a/services/invitations/pkg/backends/keycloak/backend.go +++ b/services/invitations/pkg/backends/keycloak/backend.go @@ -1,6 +1,4 @@ // Package keycloak offers an invitation backend for the invitation service. -// TODO: Maybe move this outside of the invitation service and make it more generic? - package keycloak import ( @@ -26,14 +24,14 @@ var userRequiredActions = []string{"UPDATE_PASSWORD", "VERIFY_EMAIL"} // Backend represents the keycloak backend. type Backend struct { logger log.Logger - client *gocloak.GoCloak + client GoCloak clientID string clientSecret string clientRealm string userRealm string } -// New returns a new keycloak.Backend, with all the config options set. +// New instantiates a new keycloak.Backend with a default gocloak client. func New( logger log.Logger, baseURL, clientID, clientSecret, clientRealm, userRealm string, @@ -41,12 +39,20 @@ func New( ) *Backend { client := gocloak.NewClient(baseURL) restyClient := client.RestyClient() - restyClient.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: insecureSkipVerify}) + restyClient.SetTLSClientConfig(&tls.Config{InsecureSkipVerify: insecureSkipVerify}) //nolint:gosec + return NewWithClient(logger, client, clientID, clientSecret, clientRealm, userRealm) +} + +// NewWithClient creates a new backend with the supplied GoCloak client. +func NewWithClient( + logger log.Logger, + client GoCloak, + clientID, clientSecret, clientRealm, userRealm string, +) *Backend { return &Backend{ logger: log.Logger{ - logger.With(). + Logger: logger.With(). Str("invitationBackend", "keycloak"). - Str("baseURL", baseURL). Str("clientID", clientID). Str("clientRealm", clientRealm). Str("userRealm", userRealm). diff --git a/services/invitations/pkg/backends/keycloak/backend_test.go b/services/invitations/pkg/backends/keycloak/backend_test.go new file mode 100644 index 00000000000..6b8ef808058 --- /dev/null +++ b/services/invitations/pkg/backends/keycloak/backend_test.go @@ -0,0 +1,202 @@ +// Package keycloak offers an invitation backend for the invitation service. +// TODO: Maybe move this outside of the invitation service and make it more generic? + +package keycloak_test + +import ( + "context" + "testing" + + "github.com/Nerzal/gocloak/v13" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/services/invitations/pkg/backends/keycloak" + "github.com/owncloud/ocis/v2/services/invitations/pkg/invitations" + "github.com/owncloud/ocis/v2/services/invitations/pkg/mocks" + "github.com/stretchr/testify/assert" + "github.com/test-go/testify/mock" +) + +const ( + clientID = "test-id" + clientSecret = "test-secret" + clientRealm = "client-realm" + userRealm = "user-realm" + jwtToken = "test-token" +) + +func TestBackend_CreateUser(t *testing.T) { + type args struct { + invitation *invitations.Invitation + } + type mockInputs struct { + funcName string + args []interface{} + returns []interface{} + } + tests := []struct { + name string + args args + want string + keycloakMocks []mockInputs + assertion assert.ErrorAssertionFunc + }{ + { + name: "Test without diplay name", + args: args{ + invitation: &invitations.Invitation{ + InvitedUserEmailAddress: "test@example.org", + }, + }, + want: "test-id", + keycloakMocks: []mockInputs{ + { + funcName: "LoginClient", + args: []interface{}{ + mock.Anything, + clientID, + clientSecret, + clientRealm, + }, + returns: []interface{}{ + &gocloak.JWT{ + AccessToken: jwtToken, + }, + nil, + }, + }, + { + funcName: "RetrospectToken", + args: []interface{}{ + mock.Anything, + jwtToken, + clientID, + clientSecret, + clientRealm, + }, + returns: []interface{}{ + &gocloak.IntroSpectTokenResult{ + Active: gocloak.BoolP(true), + }, + nil, + }, + }, + { + funcName: "CreateUser", + args: []interface{}{ + mock.Anything, + jwtToken, + userRealm, + mock.Anything, // can't match on the user because it generates a UUID internally. + // might be worth refactoring the UUID generation to outside of the func + }, + returns: []interface{}{ + "test-id", + nil, + }, + }, + }, + assertion: func(t assert.TestingT, err error, args ...interface{}) bool { + return assert.Nil(t, err, args...) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + c := &mocks.GoCloak{} + for _, m := range tt.keycloakMocks { + c.On(m.funcName, m.args...).Return(m.returns...) + } + b := keycloak.NewWithClient(log.NopLogger(), c, clientID, clientSecret, clientRealm, userRealm) + got, err := b.CreateUser(ctx, tt.args.invitation) + tt.assertion(t, err) + assert.Equal(t, tt.want, got) + }) + } +} + +func TestBackend_SendMail(t *testing.T) { + type args struct { + id string + } + type mockInputs struct { + funcName string + args []interface{} + returns []interface{} + } + tests := []struct { + name string + args args + keycloakMocks []mockInputs + assertion assert.ErrorAssertionFunc + }{ + { + name: "Mail successfully sent", + args: args{ + id: "test-id", + }, + keycloakMocks: []mockInputs{ + { + funcName: "LoginClient", + args: []interface{}{ + mock.Anything, + clientID, + clientSecret, + clientRealm, + }, + returns: []interface{}{ + &gocloak.JWT{ + AccessToken: jwtToken, + }, + nil, + }, + }, + { + funcName: "RetrospectToken", + args: []interface{}{ + mock.Anything, + jwtToken, + clientID, + clientSecret, + clientRealm, + }, + returns: []interface{}{ + &gocloak.IntroSpectTokenResult{ + Active: gocloak.BoolP(true), + }, + nil, + }, + }, + { + funcName: "ExecuteActionsEmail", + args: []interface{}{ + mock.Anything, + jwtToken, + userRealm, + gocloak.ExecuteActionsEmail{ + UserID: gocloak.StringP("test-id"), + Actions: &[]string{"UPDATE_PASSWORD", "VERIFY_EMAIL"}, + }, + }, + returns: []interface{}{ + nil, + }, + }, + }, + assertion: func(t assert.TestingT, err error, args ...interface{}) bool { + return assert.Nil(t, err, args...) + }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := context.Background() + c := &mocks.GoCloak{} + for _, m := range tt.keycloakMocks { + c.On(m.funcName, m.args...).Return(m.returns...) + } + b := keycloak.NewWithClient(log.NopLogger(), c, clientID, clientSecret, clientRealm, userRealm) + tt.assertion(t, b.SendMail(ctx, tt.args.id)) + }) + } +} diff --git a/services/invitations/pkg/backends/keycloak/gocloak.go b/services/invitations/pkg/backends/keycloak/gocloak.go new file mode 100644 index 00000000000..a72b8a6e9d2 --- /dev/null +++ b/services/invitations/pkg/backends/keycloak/gocloak.go @@ -0,0 +1,17 @@ +package keycloak + +import ( + "context" + + "github.com/Nerzal/gocloak/v13" +) + +// GoCloak represents the parts of gocloak.GoCloak that we use, mainly here for mockery. +// +//go:generate mockery --name=GoCloak +type GoCloak interface { + CreateUser(ctx context.Context, token, realm string, user gocloak.User) (string, error) + ExecuteActionsEmail(ctx context.Context, token, realm string, params gocloak.ExecuteActionsEmail) error + LoginClient(ctx context.Context, clientID, clientSecret, realm string) (*gocloak.JWT, error) + RetrospectToken(ctx context.Context, accessToken, clientID, clientSecret, realm string) (*gocloak.IntroSpectTokenResult, error) +} diff --git a/services/invitations/pkg/mocks/go_cloak.go b/services/invitations/pkg/mocks/go_cloak.go new file mode 100644 index 00000000000..6289046719c --- /dev/null +++ b/services/invitations/pkg/mocks/go_cloak.go @@ -0,0 +1,112 @@ +// Code generated by mockery v2.14.1. DO NOT EDIT. + +package mocks + +import ( + context "context" + + gocloak "github.com/Nerzal/gocloak/v13" + + mock "github.com/stretchr/testify/mock" +) + +// GoCloak is an autogenerated mock type for the GoCloak type +type GoCloak struct { + mock.Mock +} + +// CreateUser provides a mock function with given fields: ctx, token, realm, user +func (_m *GoCloak) CreateUser(ctx context.Context, token string, realm string, user gocloak.User) (string, error) { + ret := _m.Called(ctx, token, realm, user) + + var r0 string + if rf, ok := ret.Get(0).(func(context.Context, string, string, gocloak.User) string); ok { + r0 = rf(ctx, token, realm, user) + } else { + r0 = ret.Get(0).(string) + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, string, gocloak.User) error); ok { + r1 = rf(ctx, token, realm, user) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// ExecuteActionsEmail provides a mock function with given fields: ctx, token, realm, params +func (_m *GoCloak) ExecuteActionsEmail(ctx context.Context, token string, realm string, params gocloak.ExecuteActionsEmail) error { + ret := _m.Called(ctx, token, realm, params) + + var r0 error + if rf, ok := ret.Get(0).(func(context.Context, string, string, gocloak.ExecuteActionsEmail) error); ok { + r0 = rf(ctx, token, realm, params) + } else { + r0 = ret.Error(0) + } + + return r0 +} + +// LoginClient provides a mock function with given fields: ctx, clientID, clientSecret, realm +func (_m *GoCloak) LoginClient(ctx context.Context, clientID string, clientSecret string, realm string) (*gocloak.JWT, error) { + ret := _m.Called(ctx, clientID, clientSecret, realm) + + var r0 *gocloak.JWT + if rf, ok := ret.Get(0).(func(context.Context, string, string, string) *gocloak.JWT); ok { + r0 = rf(ctx, clientID, clientSecret, realm) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*gocloak.JWT) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, string, string) error); ok { + r1 = rf(ctx, clientID, clientSecret, realm) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +// RetrospectToken provides a mock function with given fields: ctx, accessToken, clientID, clientSecret, realm +func (_m *GoCloak) RetrospectToken(ctx context.Context, accessToken string, clientID string, clientSecret string, realm string) (*gocloak.IntroSpectTokenResult, error) { + ret := _m.Called(ctx, accessToken, clientID, clientSecret, realm) + + var r0 *gocloak.IntroSpectTokenResult + if rf, ok := ret.Get(0).(func(context.Context, string, string, string, string) *gocloak.IntroSpectTokenResult); ok { + r0 = rf(ctx, accessToken, clientID, clientSecret, realm) + } else { + if ret.Get(0) != nil { + r0 = ret.Get(0).(*gocloak.IntroSpectTokenResult) + } + } + + var r1 error + if rf, ok := ret.Get(1).(func(context.Context, string, string, string, string) error); ok { + r1 = rf(ctx, accessToken, clientID, clientSecret, realm) + } else { + r1 = ret.Error(1) + } + + return r0, r1 +} + +type mockConstructorTestingTNewGoCloak interface { + mock.TestingT + Cleanup(func()) +} + +// NewGoCloak creates a new instance of GoCloak. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. +func NewGoCloak(t mockConstructorTestingTNewGoCloak) *GoCloak { + mock := &GoCloak{} + mock.Mock.Test(t) + + t.Cleanup(func() { mock.AssertExpectations(t) }) + + return mock +} From 361af723a53e63afb27319f3b8901f3b821e54ed Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Franke?= Date: Wed, 22 Mar 2023 12:51:25 +0100 Subject: [PATCH 08/14] Update services/invitations/pkg/config/config.go Co-authored-by: Martin --- services/invitations/pkg/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/invitations/pkg/config/config.go b/services/invitations/pkg/config/config.go index 240a4da9b80..84d63651a05 100644 --- a/services/invitations/pkg/config/config.go +++ b/services/invitations/pkg/config/config.go @@ -26,7 +26,7 @@ type Config struct { // Keycloak configuration type Keycloak struct { - BasePath string `yaml:"base_path" env:"INVITATIONS_KEYCLOAK_BASE_PATH" desc:"The URL to keycloak."` + BasePath string `yaml:"base_path" env:"INVITATIONS_KEYCLOAK_BASE_PATH" desc:"The URL to access keycloak."` ClientID string `yaml:"client_id" env:"INVITATIONS_KEYCLOAK_CLIENT_ID" desc:"The client id to authenticate with keycloak."` ClientSecret string `yaml:"client_secret" env:"INVITATIONS_KEYCLOAK_CLIENT_SECRET" desc:"The client secret to use in authentication."` ClientRealm string `yaml:"client_realm" env:"INVITATIONS_KEYCLOAK_CLIENT_REALM" desc:"The realm the client is defined in."` From 1a7e2fd782b399de67316a485a6870e1b2dbe924 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Franke?= Date: Wed, 22 Mar 2023 12:51:35 +0100 Subject: [PATCH 09/14] Update services/invitations/pkg/config/config.go Co-authored-by: Martin --- services/invitations/pkg/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/invitations/pkg/config/config.go b/services/invitations/pkg/config/config.go index 84d63651a05..a420b6c958b 100644 --- a/services/invitations/pkg/config/config.go +++ b/services/invitations/pkg/config/config.go @@ -31,5 +31,5 @@ type Keycloak struct { ClientSecret string `yaml:"client_secret" env:"INVITATIONS_KEYCLOAK_CLIENT_SECRET" desc:"The client secret to use in authentication."` ClientRealm string `yaml:"client_realm" env:"INVITATIONS_KEYCLOAK_CLIENT_REALM" desc:"The realm the client is defined in."` UserRealm string `yaml:"user_realm" env:"INVITATIONS_KEYCLOAK_USER_REALM" desc:"The realm the users are in."` - InsecureSkipVerify bool `yaml:"insecure_skip_verify" env:"INVITATIONS_KEYCLOAK_INSECURE_SKIP_VERIFY" desc:"Skip the check of the TLS certificate."` + InsecureSkipVerify bool `yaml:"insecure_skip_verify" env:"INVITATIONS_KEYCLOAK_INSECURE_SKIP_VERIFY" desc:"Disable TLS certificate validation for Keycloak connections. Do not set this in production environments."` } From 177cd0bb5725a569b0cfe5275d8d2f66b55a6333 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Franke?= Date: Wed, 22 Mar 2023 12:51:50 +0100 Subject: [PATCH 10/14] Update services/invitations/pkg/config/config.go Co-authored-by: Martin --- services/invitations/pkg/config/config.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/services/invitations/pkg/config/config.go b/services/invitations/pkg/config/config.go index a420b6c958b..ca499aeaed4 100644 --- a/services/invitations/pkg/config/config.go +++ b/services/invitations/pkg/config/config.go @@ -30,6 +30,6 @@ type Keycloak struct { ClientID string `yaml:"client_id" env:"INVITATIONS_KEYCLOAK_CLIENT_ID" desc:"The client id to authenticate with keycloak."` ClientSecret string `yaml:"client_secret" env:"INVITATIONS_KEYCLOAK_CLIENT_SECRET" desc:"The client secret to use in authentication."` ClientRealm string `yaml:"client_realm" env:"INVITATIONS_KEYCLOAK_CLIENT_REALM" desc:"The realm the client is defined in."` - UserRealm string `yaml:"user_realm" env:"INVITATIONS_KEYCLOAK_USER_REALM" desc:"The realm the users are in."` + UserRealm string `yaml:"user_realm" env:"INVITATIONS_KEYCLOAK_USER_REALM" desc:"The realm users are defined."` InsecureSkipVerify bool `yaml:"insecure_skip_verify" env:"INVITATIONS_KEYCLOAK_INSECURE_SKIP_VERIFY" desc:"Disable TLS certificate validation for Keycloak connections. Do not set this in production environments."` } From 10f4af1e59c286445b8b8ab7608e33c2d10b6022 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Dani=C3=ABl=20Franke?= Date: Wed, 22 Mar 2023 13:13:32 +0100 Subject: [PATCH 11/14] Instead of trying to guess names, ignore them. --- .../pkg/backends/keycloak/backend.go | 21 +++---------------- 1 file changed, 3 insertions(+), 18 deletions(-) diff --git a/services/invitations/pkg/backends/keycloak/backend.go b/services/invitations/pkg/backends/keycloak/backend.go index e097be9c9b3..fa42f2c51d9 100644 --- a/services/invitations/pkg/backends/keycloak/backend.go +++ b/services/invitations/pkg/backends/keycloak/backend.go @@ -5,7 +5,6 @@ import ( "context" "crypto/tls" "fmt" - "strings" "github.com/Nerzal/gocloak/v13" "github.com/google/uuid" @@ -74,17 +73,14 @@ func (b Backend) CreateUser(ctx context.Context, invitation *invitations.Invitat } u := uuid.New() - firstName, lastName := splitDisplayName(invitation.InvitedUserDisplayName) b.logger.Info(). Str(idAttr, u.String()). Str("email", invitation.InvitedUserEmailAddress). Msg("Creating new user") user := gocloak.User{ - FirstName: &firstName, - LastName: &lastName, - Email: &invitation.InvitedUserEmailAddress, - Enabled: gocloak.BoolP(true), - Username: &invitation.InvitedUserEmailAddress, + Email: &invitation.InvitedUserEmailAddress, + Enabled: gocloak.BoolP(true), + Username: &invitation.InvitedUserEmailAddress, Attributes: &map[string][]string{ idAttr: {u.String()}, userTypeAttr: {userTypeVal}, @@ -142,14 +138,3 @@ func (b Backend) getToken(ctx context.Context) (*gocloak.JWT, error) { return token, nil } - -// Quick and dirty way to split the last name off from the first name(s), imperfect, because -// every culture has a different conception of names. -func splitDisplayName(displayName string) (string, string) { - parts := strings.Split(displayName, " ") - if len(parts) <= 1 { - return parts[0], "" - } - - return strings.Join(parts[:len(parts)-1], " "), parts[len(parts)-1] -} From f33c524a5dcb6a0d5ce42e1af63004275afebf1b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Fri, 24 Mar 2023 12:07:57 +0100 Subject: [PATCH 12/14] delete unnecessary files --- services/invitations/.dockerignore | 2 -- services/invitations/reflex.conf | 2 -- 2 files changed, 4 deletions(-) delete mode 100644 services/invitations/.dockerignore delete mode 100644 services/invitations/reflex.conf diff --git a/services/invitations/.dockerignore b/services/invitations/.dockerignore deleted file mode 100644 index 4ec85b5e4f7..00000000000 --- a/services/invitations/.dockerignore +++ /dev/null @@ -1,2 +0,0 @@ -* -!bin/ diff --git a/services/invitations/reflex.conf b/services/invitations/reflex.conf deleted file mode 100644 index c73e25dffd1..00000000000 --- a/services/invitations/reflex.conf +++ /dev/null @@ -1,2 +0,0 @@ -# backend --r '^(cmd|pkg)/.*\.go$' -R '^node_modules/' -s -- sh -c 'make bin/ocis-invitations-debug && bin/ocis-invitations-debug --log-level debug server --debug-pprof --debug-zpages' From a1bb47b18030e9873cddd0cffba513bdd146b6f8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Fri, 24 Mar 2023 12:10:58 +0100 Subject: [PATCH 13/14] drop commented code --- services/invitations/pkg/server/http/server.go | 2 -- 1 file changed, 2 deletions(-) diff --git a/services/invitations/pkg/server/http/server.go b/services/invitations/pkg/server/http/server.go index d091d52f801..5c04542f446 100644 --- a/services/invitations/pkg/server/http/server.go +++ b/services/invitations/pkg/server/http/server.go @@ -91,7 +91,6 @@ func InvitationHandler(service svc.Service) func(w http.ResponseWriter, r *http. i := &invitations.Invitation{} err := json.NewDecoder(r.Body).Decode(i) if err != nil { - // logger.Debug().Err(err).Interface("body", r.Body).Msg("could not invite user: invalid request body") errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error())) return } @@ -103,7 +102,6 @@ func InvitationHandler(service svc.Service) func(w http.ResponseWriter, r *http. return } - // w.Header().Set("Content-type", "application/json") render.Status(r, http.StatusCreated) render.JSON(w, r, res) } From 31471df033cd5124602a4d1cc4643a060d766e22 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?J=C3=B6rn=20Friedrich=20Dreyer?= Date: Fri, 24 Mar 2023 12:43:53 +0100 Subject: [PATCH 14/14] fix markdown --- services/invitations/README.md | 2 -- 1 file changed, 2 deletions(-) diff --git a/services/invitations/README.md b/services/invitations/README.md index a40ff2483a2..ea1596a7327 100644 --- a/services/invitations/README.md +++ b/services/invitations/README.md @@ -6,12 +6,10 @@ Users invited via this Invitation Manager (libre graph API) will have `userType= The corresponding CS3 API [user types](https://cs3org.github.io/cs3apis/#cs3.identity.user.v1beta1.UserType) used to reperesent this are: `USER_TYPE_GUEST` and `USER_TYPE_PRIMARY`. - ## Provisioning backends When oCIS is used for user management the users are created using the `/graph/v1.0/users` endpoint. For larger deployments the keycloak admin API can be used to provision users. We might even make the endpoint, credentials and body configurable using templates. - ## Bridging provisioning delay When a guest account has to be provisioned in an external user management there might be a delay between creating the user and it being available in the local ocis system. In the first iteration the invitations service will only keep track of invites in memory. This list could be persisted in future iterations.