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] 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 7e38b1a1ff..7d71a792da 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 89579f7d8f..3afd0896c8 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 0000000000..984d8adf85 --- /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 f3a10eb458..06cc367ed9 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 0000000000..4ec85b5e4f --- /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 0000000000..d34a0071b5 --- /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 0000000000..86ec3ec028 --- /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 0000000000..3290c0b306 --- /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 0000000000..7893c5dcca --- /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 0000000000..d9c6de1619 --- /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 0000000000..dde7035578 --- /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 0000000000..75cdea5f51 --- /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 0000000000..01a85f800f --- /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 0000000000..5e9aab9c60 --- /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 0000000000..a49030f860 --- /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 0000000000..5249cef136 --- /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 0000000000..342b45fcaf --- /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 0000000000..76875dfb46 --- /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 0000000000..f0a3e66673 --- /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 0000000000..8f80aabf2b --- /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 0000000000..d1eac383f0 --- /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 0000000000..8b9cafecdd --- /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 0000000000..876aeceb2d --- /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 0000000000..a450db15ad --- /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 0000000000..b034799b95 --- /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 0000000000..4a1279c8e7 --- /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 0000000000..6f7626a9de --- /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 0000000000..359df37b01 --- /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 0000000000..12b06cfbbc --- /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 0000000000..ea9f0186e6 --- /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 0000000000..b71ec4ca73 --- /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 0000000000..42c31b5097 --- /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 0000000000..0115d9c80c --- /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 0000000000..35641c0a5f --- /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 0000000000..aa5fd0785e --- /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 0000000000..2f5ea3b815 --- /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 0000000000..dfae5fc0c1 --- /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 0000000000..c73e25dffd --- /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 cde2c9360a..582dbd0da7 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",