Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
+13
View File
@@ -0,0 +1,13 @@
# maintain v2 separate mocks dir
dir: "{{.InterfaceDir}}/mocks"
structname: "{{.InterfaceName}}"
filename: "{{.InterfaceName | snakecase }}.go"
pkgname: mocks
template: testify
packages:
github.com/qsfera/server/pkg/keycloak:
config:
dir: pkg/backends/keycloak/mocks
interfaces:
Client: {}
+15
View File
@@ -0,0 +1,15 @@
SHELL := bash
NAME := invitations
ifneq (, $(shell command -v go 2> /dev/null)) # suppress `command not found warnings` for non go targets in CI
include ../../.bingo/Variables.mk
endif
include ../../.make/default.mk
include ../../.make/go.mk
include ../../.make/release.mk
include ../../.make/docs.mk
.PHONY: go-generate
go-generate: $(MOCKERY)
$(MOCKERY)
+45
View File
@@ -0,0 +1,45 @@
# Invitations
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.
* Users invited via the Invitation Manager (via the libre graph API) will have the `userType="Guest"`.
* Users belonging to the organization have the `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`.
## Provisioning Backends
When КуСфера is used via the IDM service for the user management, users are created using the `/graph/v1.0/users` endpoint via the libre graph API. For larger deployments, the Keycloak admin API can be used to provision users. In a future step, the endpoint, credentials and body might be made configurable using templates.
### Keycloak
The default and currently only available backend used to handle invitations is [Keycloak](https://www.keycloak.org/). Keycloak is an open source identity and access management (IAM) system which is also integrated by other КуСфера services as an authentication and authorization backend.
#### Keycloak Realm Configuration
<!--- Note that the link below must be an absolute URL and not a relative file path --->
See the [example configuration json file](https://github.com/qsfera/server/blob/main/services/invitations/md-sources/example-realm.json) of a Keycloak realm the backend will work with. This file includes the `invitations` client, which is relevant for this service.
To use the example json, set the `INVITATIONS_KEYCLOAK_CLIENT_ID` setting to `invitations`, though any other client ID can be configured.
Importing this example into Keycloak will give you a realm that federates with an LDAP server, has the right
clients configured and all mappers correctly set. Be sure to set all the credentials after the import,
as they will be disabled.
The most relevant bits are the mappers for the `QSFERA_ID` and `QSFERA_USER_TYPE` user properties.
## Backend Configuration
After Keycloak has been configured, the invitation service needs to be configured with the following environment variables:
* `INVITATIONS_KEYCLOAK_BASE_PATH`: The URL to access Keycloak.
* `INVITATIONS_KEYCLOAK_CLIENT_ID`: The client ID of the client to use. In the above example, `invitations` is used.
* `INVITATIONS_KEYCLOAK_CLIENT_SECRET`: The client secret used to authenticate. This can be found in the Keycloak UI.
* `INVITATIONS_KEYCLOAK_CLIENT_REALM`: The realm where the client was added. In the example above, `qsfera` is used.
* `INVITATIONS_KEYCLOAK_USER_REALM`: The realm where to add the users. In the example above, `qsfera` is used.
* `INVITATIONS_KEYCLOAK_INSECURE_SKIP_VERIFY`: If set to true, the verification of the Keycloak HTTPS certificate is skipped. This is not recommended in production environments.
## Bridging Provisioning Delay
Consider that when a guest account has to be provisioned in an external user management, there might be a delay between creating the user and the user being available in the local КуСфера system.
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,98 @@
// Package keycloak offers an invitation backend for the invitation service.
package keycloak
import (
"context"
"github.com/google/uuid"
"github.com/qsfera/server/pkg/keycloak"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/invitations/pkg/invitations"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
const (
userType = "Guest"
)
var userRequiredActions = []keycloak.UserAction{
keycloak.UserActionUpdatePassword,
keycloak.UserActionVerifyEmail,
}
// Backend represents the keycloak backend.
type Backend struct {
logger log.Logger
client keycloak.Client
userRealm string
}
// New instantiates a new keycloak.Backend with a default gocloak client.
func New(
logger log.Logger,
baseURL, clientID, clientSecret, clientRealm, userRealm string,
insecureSkipVerify bool,
) *Backend {
logger = log.Logger{
Logger: logger.With().
Str("invitationBackend", "keycloak").
Str("clientID", clientID).
Str("clientRealm", clientRealm).
Str("userRealm", userRealm).
Logger(),
}
client := keycloak.New(baseURL, clientID, clientSecret, clientRealm, insecureSkipVerify)
return NewWithClient(logger, client, userRealm)
}
// NewWithClient creates a new backend with the supplied keycloak client.
func NewWithClient(
logger log.Logger,
client keycloak.Client,
userRealm string,
) *Backend {
return &Backend{
logger: logger,
client: client,
userRealm: userRealm,
}
}
// CreateUser creates a user in the keycloak backend.
func (b Backend) CreateUser(ctx context.Context, invitation *invitations.Invitation) (string, error) {
u := uuid.New()
b.logger.Info().
Str("email", invitation.InvitedUserEmailAddress).
Msg("Creating new user")
user := &libregraph.User{
Mail: &invitation.InvitedUserEmailAddress,
AccountEnabled: boolP(true),
OnPremisesSamAccountName: invitation.InvitedUserEmailAddress,
Id: stringP(u.String()),
UserType: stringP(userType),
}
id, err := b.client.CreateUser(ctx, b.userRealm, user, userRequiredActions)
if err != nil {
b.logger.Error().
Str("userID", 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 sending mail.
func (b Backend) CanSendMail() bool { return true }
// SendMail sends a mail to the user with details on how to redeem the invitation.
func (b Backend) SendMail(ctx context.Context, id string) error {
return b.client.SendActionsMail(ctx, b.userRealm, id, userRequiredActions)
}
func boolP(b bool) *bool { return &b }
func stringP(s string) *string { return &s }
@@ -0,0 +1,142 @@
// 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"
kcpkg "github.com/qsfera/server/pkg/keycloak"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/invitations/pkg/backends/keycloak"
"github.com/qsfera/server/services/invitations/pkg/backends/keycloak/mocks"
"github.com/qsfera/server/services/invitations/pkg/invitations"
"github.com/stretchr/testify/assert"
"github.com/stretchr/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 []any
returns []any
}
tests := []struct {
name string
args args
want string
clientMocks []mockInputs
assertion assert.ErrorAssertionFunc
}{
{
name: "Test without diplay name",
args: args{
invitation: &invitations.Invitation{
InvitedUserEmailAddress: "test@example.org",
},
},
want: "test-id",
clientMocks: []mockInputs{
{
funcName: "CreateUser",
args: []any{
mock.Anything,
userRealm,
mock.Anything, // can't match on the user because it generates a UUID internally.
[]kcpkg.UserAction{
kcpkg.UserActionUpdatePassword,
kcpkg.UserActionVerifyEmail,
},
},
returns: []any{
"test-id",
nil,
},
},
},
assertion: func(t assert.TestingT, err error, args ...any) bool {
return assert.Nil(t, err, args...)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
c := &mocks.Client{}
for _, m := range tt.clientMocks {
c.On(m.funcName, m.args...).Return(m.returns...)
}
b := keycloak.NewWithClient(log.NopLogger(), c, 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 []any
returns []any
}
tests := []struct {
name string
args args
clientMocks []mockInputs
assertion assert.ErrorAssertionFunc
}{
{
name: "Mail successfully sent",
args: args{
id: "test-id",
},
clientMocks: []mockInputs{
{
funcName: "SendActionsMail",
args: []any{
mock.Anything,
userRealm,
"test-id",
[]kcpkg.UserAction{
kcpkg.UserActionUpdatePassword,
kcpkg.UserActionVerifyEmail,
},
},
returns: []any{
nil,
},
},
},
assertion: func(t assert.TestingT, err error, args ...any) bool {
return assert.Nil(t, err, args...)
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
ctx := context.Background()
c := &mocks.Client{}
for _, m := range tt.clientMocks {
c.On(m.funcName, m.args...).Return(m.returns...)
}
b := keycloak.NewWithClient(log.NopLogger(), c, userRealm)
tt.assertion(t, b.SendMail(ctx, tt.args.id))
})
}
}
@@ -0,0 +1,15 @@
package keycloak
import (
"context"
"github.com/Nerzal/gocloak/v13"
)
// GoCloak represents the parts of gocloak.GoCloak that we use, mainly here for mockery.
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)
}
@@ -0,0 +1,335 @@
// Code generated by mockery; DO NOT EDIT.
// github.com/vektra/mockery
// template: testify
package mocks
import (
"context"
"github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/keycloak"
mock "github.com/stretchr/testify/mock"
)
// 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.
// The first argument is typically a *testing.T value.
func NewClient(t interface {
mock.TestingT
Cleanup(func())
}) *Client {
mock := &Client{}
mock.Mock.Test(t)
t.Cleanup(func() { mock.AssertExpectations(t) })
return mock
}
// Client is an autogenerated mock type for the Client type
type Client struct {
mock.Mock
}
type Client_Expecter struct {
mock *mock.Mock
}
func (_m *Client) EXPECT() *Client_Expecter {
return &Client_Expecter{mock: &_m.Mock}
}
// CreateUser provides a mock function for the type Client
func (_mock *Client) CreateUser(ctx context.Context, realm string, user *libregraph.User, userActions []keycloak.UserAction) (string, error) {
ret := _mock.Called(ctx, realm, user, userActions)
if len(ret) == 0 {
panic("no return value specified for CreateUser")
}
var r0 string
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *libregraph.User, []keycloak.UserAction) (string, error)); ok {
return returnFunc(ctx, realm, user, userActions)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, *libregraph.User, []keycloak.UserAction) string); ok {
r0 = returnFunc(ctx, realm, user, userActions)
} else {
r0 = ret.Get(0).(string)
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, *libregraph.User, []keycloak.UserAction) error); ok {
r1 = returnFunc(ctx, realm, user, userActions)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Client_CreateUser_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'CreateUser'
type Client_CreateUser_Call struct {
*mock.Call
}
// CreateUser is a helper method to define mock.On call
// - ctx context.Context
// - realm string
// - user *libregraph.User
// - userActions []keycloak.UserAction
func (_e *Client_Expecter) CreateUser(ctx interface{}, realm interface{}, user interface{}, userActions interface{}) *Client_CreateUser_Call {
return &Client_CreateUser_Call{Call: _e.mock.On("CreateUser", ctx, realm, user, userActions)}
}
func (_c *Client_CreateUser_Call) Run(run func(ctx context.Context, realm string, user *libregraph.User, userActions []keycloak.UserAction)) *Client_CreateUser_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 *libregraph.User
if args[2] != nil {
arg2 = args[2].(*libregraph.User)
}
var arg3 []keycloak.UserAction
if args[3] != nil {
arg3 = args[3].([]keycloak.UserAction)
}
run(
arg0,
arg1,
arg2,
arg3,
)
})
return _c
}
func (_c *Client_CreateUser_Call) Return(s string, err error) *Client_CreateUser_Call {
_c.Call.Return(s, err)
return _c
}
func (_c *Client_CreateUser_Call) RunAndReturn(run func(ctx context.Context, realm string, user *libregraph.User, userActions []keycloak.UserAction) (string, error)) *Client_CreateUser_Call {
_c.Call.Return(run)
return _c
}
// GetPIIReport provides a mock function for the type Client
func (_mock *Client) GetPIIReport(ctx context.Context, realm string, username string) (*keycloak.PIIReport, error) {
ret := _mock.Called(ctx, realm, username)
if len(ret) == 0 {
panic("no return value specified for GetPIIReport")
}
var r0 *keycloak.PIIReport
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*keycloak.PIIReport, error)); ok {
return returnFunc(ctx, realm, username)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *keycloak.PIIReport); ok {
r0 = returnFunc(ctx, realm, username)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*keycloak.PIIReport)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = returnFunc(ctx, realm, username)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Client_GetPIIReport_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetPIIReport'
type Client_GetPIIReport_Call struct {
*mock.Call
}
// GetPIIReport is a helper method to define mock.On call
// - ctx context.Context
// - realm string
// - username string
func (_e *Client_Expecter) GetPIIReport(ctx interface{}, realm interface{}, username interface{}) *Client_GetPIIReport_Call {
return &Client_GetPIIReport_Call{Call: _e.mock.On("GetPIIReport", ctx, realm, username)}
}
func (_c *Client_GetPIIReport_Call) Run(run func(ctx context.Context, realm string, username string)) *Client_GetPIIReport_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *Client_GetPIIReport_Call) Return(pIIReport *keycloak.PIIReport, err error) *Client_GetPIIReport_Call {
_c.Call.Return(pIIReport, err)
return _c
}
func (_c *Client_GetPIIReport_Call) RunAndReturn(run func(ctx context.Context, realm string, username string) (*keycloak.PIIReport, error)) *Client_GetPIIReport_Call {
_c.Call.Return(run)
return _c
}
// GetUserByUsername provides a mock function for the type Client
func (_mock *Client) GetUserByUsername(ctx context.Context, realm string, username string) (*libregraph.User, error) {
ret := _mock.Called(ctx, realm, username)
if len(ret) == 0 {
panic("no return value specified for GetUserByUsername")
}
var r0 *libregraph.User
var r1 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) (*libregraph.User, error)); ok {
return returnFunc(ctx, realm, username)
}
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string) *libregraph.User); ok {
r0 = returnFunc(ctx, realm, username)
} else {
if ret.Get(0) != nil {
r0 = ret.Get(0).(*libregraph.User)
}
}
if returnFunc, ok := ret.Get(1).(func(context.Context, string, string) error); ok {
r1 = returnFunc(ctx, realm, username)
} else {
r1 = ret.Error(1)
}
return r0, r1
}
// Client_GetUserByUsername_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetUserByUsername'
type Client_GetUserByUsername_Call struct {
*mock.Call
}
// GetUserByUsername is a helper method to define mock.On call
// - ctx context.Context
// - realm string
// - username string
func (_e *Client_Expecter) GetUserByUsername(ctx interface{}, realm interface{}, username interface{}) *Client_GetUserByUsername_Call {
return &Client_GetUserByUsername_Call{Call: _e.mock.On("GetUserByUsername", ctx, realm, username)}
}
func (_c *Client_GetUserByUsername_Call) Run(run func(ctx context.Context, realm string, username string)) *Client_GetUserByUsername_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
run(
arg0,
arg1,
arg2,
)
})
return _c
}
func (_c *Client_GetUserByUsername_Call) Return(user *libregraph.User, err error) *Client_GetUserByUsername_Call {
_c.Call.Return(user, err)
return _c
}
func (_c *Client_GetUserByUsername_Call) RunAndReturn(run func(ctx context.Context, realm string, username string) (*libregraph.User, error)) *Client_GetUserByUsername_Call {
_c.Call.Return(run)
return _c
}
// SendActionsMail provides a mock function for the type Client
func (_mock *Client) SendActionsMail(ctx context.Context, realm string, userID string, userActions []keycloak.UserAction) error {
ret := _mock.Called(ctx, realm, userID, userActions)
if len(ret) == 0 {
panic("no return value specified for SendActionsMail")
}
var r0 error
if returnFunc, ok := ret.Get(0).(func(context.Context, string, string, []keycloak.UserAction) error); ok {
r0 = returnFunc(ctx, realm, userID, userActions)
} else {
r0 = ret.Error(0)
}
return r0
}
// Client_SendActionsMail_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'SendActionsMail'
type Client_SendActionsMail_Call struct {
*mock.Call
}
// SendActionsMail is a helper method to define mock.On call
// - ctx context.Context
// - realm string
// - userID string
// - userActions []keycloak.UserAction
func (_e *Client_Expecter) SendActionsMail(ctx interface{}, realm interface{}, userID interface{}, userActions interface{}) *Client_SendActionsMail_Call {
return &Client_SendActionsMail_Call{Call: _e.mock.On("SendActionsMail", ctx, realm, userID, userActions)}
}
func (_c *Client_SendActionsMail_Call) Run(run func(ctx context.Context, realm string, userID string, userActions []keycloak.UserAction)) *Client_SendActionsMail_Call {
_c.Call.Run(func(args mock.Arguments) {
var arg0 context.Context
if args[0] != nil {
arg0 = args[0].(context.Context)
}
var arg1 string
if args[1] != nil {
arg1 = args[1].(string)
}
var arg2 string
if args[2] != nil {
arg2 = args[2].(string)
}
var arg3 []keycloak.UserAction
if args[3] != nil {
arg3 = args[3].([]keycloak.UserAction)
}
run(
arg0,
arg1,
arg2,
arg3,
)
})
return _c
}
func (_c *Client_SendActionsMail_Call) Return(err error) *Client_SendActionsMail_Call {
_c.Call.Return(err)
return _c
}
func (_c *Client_SendActionsMail_Call) RunAndReturn(run func(ctx context.Context, realm string, userID string, userActions []keycloak.UserAction) error) *Client_SendActionsMail_Call {
_c.Call.Return(run)
return _c
}
@@ -0,0 +1,54 @@
package command
import (
"fmt"
"net/http"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/invitations/pkg/config"
"github.com/qsfera/server/services/invitations/pkg/config/parser"
"github.com/spf13/cobra"
)
// Health is the entrypoint for the health command.
func Health(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "health",
Short: "check health status",
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnError(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
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
},
}
}
@@ -0,0 +1,36 @@
package command
import (
"os"
"github.com/qsfera/server/pkg/clihelper"
"github.com/qsfera/server/services/invitations/pkg/config"
"github.com/spf13/cobra"
)
// GetCommands provides all commands for this service
func GetCommands(cfg *config.Config) []*cobra.Command {
return []*cobra.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 qsfera invitations command.
func Execute(cfg *config.Config) error {
app := clihelper.DefaultApp(&cobra.Command{
Use: "invitations",
Short: "Serve invitations API for КуСфера",
})
app.AddCommand(GetCommands(cfg)...)
app.SetArgs(os.Args[1:])
return app.ExecuteContext(cfg.Context)
}
@@ -0,0 +1,107 @@
package command
import (
"context"
"fmt"
"os/signal"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/runner"
"github.com/qsfera/server/pkg/tracing"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/invitations/pkg/config"
"github.com/qsfera/server/services/invitations/pkg/config/parser"
"github.com/qsfera/server/services/invitations/pkg/metrics"
"github.com/qsfera/server/services/invitations/pkg/server/debug"
"github.com/qsfera/server/services/invitations/pkg/server/http"
"github.com/qsfera/server/services/invitations/pkg/service/v0"
"github.com/spf13/cobra"
)
// Server is the entrypoint for the server command.
func Server(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "server",
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnFatal(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
if err != nil {
return err
}
var cancel context.CancelFunc
if cfg.Context == nil {
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
defer cancel()
}
ctx := cfg.Context
metrics := metrics.New(metrics.Logger(logger))
metrics.BuildInfo.WithLabelValues(version.GetString()).Set(1)
gr := runner.NewGroup()
{
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, traceProvider)
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(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server))
}
{
debugServer, 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(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", debugServer))
}
grResults := gr.Run(ctx)
// return the first non-nil error found in the results
for _, grResult := range grResults {
if grResult.RunnerError != nil {
return grResult.RunnerError
}
}
return nil
},
}
}
@@ -0,0 +1,49 @@
package command
import (
"fmt"
"os"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/invitations/pkg/config"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
"github.com/spf13/cobra"
)
// Version prints the service versions of all running instances.
func Version(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "print the version of this binary and the running service instances",
RunE: func(cmd *cobra.Command, args []string) 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 := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off))
table.Header([]string{"Version", "Address", "Id"})
for _, s := range services {
for _, n := range s.Nodes {
table.Append([]string{s.Version, n.Address, n.Id})
}
}
table.Render()
return nil
},
}
}
@@ -0,0 +1,34 @@
package config
import (
"context"
"github.com/qsfera/server/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:"-"`
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;INVITATIONS_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
Debug Debug `yaml:"debug"`
HTTP HTTP `yaml:"http"`
Keycloak Keycloak `yaml:"keycloak"`
TokenManager *TokenManager `yaml:"token_manager"`
Context context.Context `yaml:"-"`
}
// Keycloak configuration
type Keycloak struct {
BasePath string `yaml:"base_path" env:"OC_KEYCLOAK_BASE_PATH;INVITATIONS_KEYCLOAK_BASE_PATH" desc:"The URL to access keycloak." introductionVersion:"1.0.0"`
ClientID string `yaml:"client_id" env:"OC_KEYCLOAK_CLIENT_ID;INVITATIONS_KEYCLOAK_CLIENT_ID" desc:"The client ID to authenticate with keycloak." introductionVersion:"1.0.0"`
ClientSecret string `yaml:"client_secret" env:"OC_KEYCLOAK_CLIENT_SECRET;INVITATIONS_KEYCLOAK_CLIENT_SECRET" desc:"The client secret to use in authentication." introductionVersion:"1.0.0"`
ClientRealm string `yaml:"client_realm" env:"OC_KEYCLOAK_CLIENT_REALM;INVITATIONS_KEYCLOAK_CLIENT_REALM" desc:"The realm the client is defined in." introductionVersion:"1.0.0"`
UserRealm string `yaml:"user_realm" env:"OC_KEYCLOAK_USER_REALM;INVITATIONS_KEYCLOAK_USER_REALM" desc:"The realm users are defined." introductionVersion:"1.0.0"`
InsecureSkipVerify bool `yaml:"insecure_skip_verify" env:"OC_KEYCLOAK_INSECURE_SKIP_VERIFY;INVITATIONS_KEYCLOAK_INSECURE_SKIP_VERIFY" desc:"Disable TLS certificate validation for Keycloak connections. Do not set this in production environments." introductionVersion:"1.0.0"`
}
@@ -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." introductionVersion:"1.0.0"`
Token string `yaml:"token" env:"INVITATIONS_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
Pprof bool `yaml:"pprof" env:"INVITATIONS_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
Zpages bool `yaml:"zpages" env:"INVITATIONS_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,75 @@
package defaults
import (
"strings"
"github.com/qsfera/server/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:9269",
Token: "",
Pprof: false,
Zpages: false,
},
HTTP: config.HTTP{
Addr: "127.0.0.1:9265",
Root: "/graph/v1.0",
Namespace: "qsfera.web",
CORS: config.CORS{
AllowedOrigins: []string{"https://localhost:9200"},
},
},
Service: config.Service{
Name: "invitations",
},
Keycloak: config.Keycloak{
BasePath: "",
ClientID: "",
ClientSecret: "",
ClientRealm: "",
UserRealm: "",
},
}
}
func EnsureDefaults(cfg *config.Config) {
if cfg.LogLevel == "" {
cfg.LogLevel = "error"
}
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{}
}
if (cfg.Commons != nil && cfg.Commons.QsferaURL != "") &&
(cfg.HTTP.CORS.AllowedOrigins == nil ||
len(cfg.HTTP.CORS.AllowedOrigins) == 1 &&
cfg.HTTP.CORS.AllowedOrigins[0] == "https://localhost:9200") {
cfg.HTTP.CORS.AllowedOrigins = []string{cfg.Commons.QsferaURL}
}
}
func Sanitize(cfg *config.Config) {
// sanitize config
if cfg.HTTP.Root != "/" {
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
}
}
@@ -0,0 +1,20 @@
package config
import "github.com/qsfera/server/pkg/shared"
// CORS defines the available cors configuration.
type CORS struct {
AllowedOrigins []string `yaml:"allow_origins" env:"OC_CORS_ALLOW_ORIGINS;INVITATIONS_CORS_ALLOW_ORIGINS" desc:"A 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. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AllowedMethods []string `yaml:"allow_methods" env:"OC_CORS_ALLOW_METHODS;INVITATIONS_CORS_ALLOW_METHODS" desc:"A 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. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AllowedHeaders []string `yaml:"allow_headers" env:"OC_CORS_ALLOW_HEADERS;INVITATIONS_CORS_ALLOW_HEADERS" desc:"A 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. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AllowCredentials bool `yaml:"allow_credentials" env:"OC_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." introductionVersion:"1.0.0"`
}
// 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." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
Root string `yaml:"root" env:"INVITATIONS_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
CORS CORS `yaml:"cors"`
TLS shared.HTTPServiceTLS `yaml:"tls"`
}
@@ -0,0 +1,37 @@
package parser
import (
"errors"
occfg "github.com/qsfera/server/pkg/config"
"github.com/qsfera/server/services/invitations/pkg/config"
"github.com/qsfera/server/services/invitations/pkg/config/defaults"
"github.com/qsfera/server/pkg/config/envdecode"
)
// ParseConfig loads configuration from known paths.
func ParseConfig(cfg *config.Config) error {
err := occfg.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
}
@@ -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:"OC_JWT_SECRET;INVITATIONS_JWT_SECRET" desc:"The secret to mint and validate jwt tokens." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,6 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}
@@ -0,0 +1,70 @@
package invitations
import libregraph "github.com/opencloud-eu/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"`
}
@@ -0,0 +1,81 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
var (
// Namespace defines the namespace for the defines metrics.
Namespace = "qsfera"
// 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
}
@@ -0,0 +1,31 @@
package metrics
import (
"github.com/qsfera/server/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
}
}
@@ -0,0 +1,50 @@
package debug
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/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
}
}
@@ -0,0 +1,37 @@
package debug
import (
"net/http"
"github.com/qsfera/server/pkg/checks"
"github.com/qsfera/server/pkg/handlers"
"github.com/qsfera/server/pkg/service/debug"
"github.com/qsfera/server/pkg/version"
)
// Server initializes the debug service and server.
func Server(opts ...Option) (*http.Server, error) {
options := newOptions(opts...)
checkHandler := handlers.NewCheckHandler(
handlers.NewCheckHandlerConfiguration().
WithLogger(options.Logger).
WithCheck("web reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr)),
)
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(checkHandler),
debug.Ready(checkHandler),
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
}
@@ -0,0 +1,85 @@
package http
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/invitations/pkg/config"
svc "github.com/qsfera/server/services/invitations/pkg/service/v0"
"github.com/spf13/pflag"
)
// 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 []pflag.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(flags ...pflag.Flag) Option {
return func(o *Options) {
o.Flags = append(o.Flags, flags...)
}
}
// 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
}
}
@@ -0,0 +1,107 @@
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/qsfera/server/pkg/account"
"github.com/qsfera/server/pkg/cors"
"github.com/qsfera/server/pkg/middleware"
ohttp "github.com/qsfera/server/pkg/service/http"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/invitations/pkg/invitations"
svc "github.com/qsfera/server/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.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 {
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
}
render.Status(r, http.StatusCreated)
render.JSON(w, r, res)
}
}
@@ -0,0 +1,10 @@
package service
import "errors"
var (
ErrNotFound = errors.New("query target not found")
ErrBadRequest = errors.New("bad request")
ErrMissingEmail = errors.New("missing email address")
ErrBackend = errors.New("backend error")
)
@@ -0,0 +1,38 @@
package service
import (
"context"
"github.com/qsfera/server/services/invitations/pkg/invitations"
"github.com/qsfera/server/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)
}
@@ -0,0 +1,30 @@
package service
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/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)
}
@@ -0,0 +1,40 @@
package service
import (
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/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
}
}
@@ -0,0 +1,95 @@
package service
import (
"context"
"fmt"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/invitations/pkg/backends/keycloak"
"github.com/qsfera/server/services/invitations/pkg/config"
"github.com/qsfera/server/services/invitations/pkg/invitations"
)
// 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)
}
// 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...)
// 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,
backend: backend,
}, nil
}
type svc struct {
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
}
if invitation.InvitedUserEmailAddress == "" {
return nil, ErrMissingEmail
}
id, err := s.backend.CreateUser(ctx, invitation)
if err != nil {
return nil, fmt.Errorf("%w: %s", ErrBackend, 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)
}
}
return invitation, nil
}
@@ -0,0 +1,37 @@
package service
import (
"context"
"github.com/qsfera/server/services/invitations/pkg/invitations"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
)
// NewTracing returns a service that instruments traces.
func NewTracing(next Service, tp trace.TracerProvider) Service {
return tracing{
next: next,
tp: tp,
}
}
type tracing struct {
next Service
tp trace.TracerProvider
}
// Invite implements the Service interface.
func (t tracing) Invite(ctx context.Context, invitation *invitations.Invitation) (*invitations.Invitation, error) {
spanOpts := []trace.SpanStartOption{
trace.WithSpanKind(trace.SpanKindServer),
trace.WithAttributes(
attribute.KeyValue{
Key: "invitation", Value: attribute.StringValue(invitation.InvitedUserEmailAddress),
}),
}
ctx, span := t.tp.Tracer("invitations").Start(ctx, "Invite", spanOpts...)
defer span.End()
return t.next.Invite(ctx, invitation)
}