diff --git a/changelog/unreleased/revamp-ocm-invitation-workflow.md b/changelog/unreleased/revamp-ocm-invitation-workflow.md new file mode 100644 index 0000000000..e5243d0ca1 --- /dev/null +++ b/changelog/unreleased/revamp-ocm-invitation-workflow.md @@ -0,0 +1,4 @@ +Enhancement: Revamp OCM invitation workflow + +https://github.com/cs3org/reva/pull/3611 +https://github.com/cs3org/reva/issues/3540 \ No newline at end of file diff --git a/cmd/reva/ocm-invite-forward.go b/cmd/reva/ocm-invite-forward.go index f19c0d8458..540e66aee7 100644 --- a/cmd/reva/ocm-invite-forward.go +++ b/cmd/reva/ocm-invite-forward.go @@ -80,7 +80,7 @@ func ocmInviteForwardCommand() *command { if forwardToken.Status.Code != rpc.Code_CODE_OK { return formatError(forwardToken.Status) } - fmt.Println("OK") + fmt.Println(forwardToken) return nil } return cmd diff --git a/cmd/revad/runtime/loader.go b/cmd/revad/runtime/loader.go index 0ee5477c42..c34bafa537 100644 --- a/cmd/revad/runtime/loader.go +++ b/cmd/revad/runtime/loader.go @@ -36,7 +36,7 @@ import ( _ "github.com/cs3org/reva/pkg/datatx/manager/loader" _ "github.com/cs3org/reva/pkg/group/manager/loader" _ "github.com/cs3org/reva/pkg/metrics/driver/loader" - _ "github.com/cs3org/reva/pkg/ocm/invite/manager/loader" + _ "github.com/cs3org/reva/pkg/ocm/invite/repository/loader" _ "github.com/cs3org/reva/pkg/ocm/provider/authorizer/loader" _ "github.com/cs3org/reva/pkg/ocm/share/manager/loader" _ "github.com/cs3org/reva/pkg/permission/manager/loader" diff --git a/go.mod b/go.mod index 73ad503597..ae5ee497ef 100644 --- a/go.mod +++ b/go.mod @@ -16,7 +16,7 @@ require ( github.com/cheggaaa/pb v1.0.29 github.com/coreos/go-oidc v2.2.1+incompatible github.com/cs3org/cato v0.0.0-20200828125504-e418fc54dd5e - github.com/cs3org/go-cs3apis v0.0.0-20221004162747-f20ee4756d90 + github.com/cs3org/go-cs3apis v0.0.0-20230124153659-5dc78d32c9b7 github.com/dgraph-io/ristretto v0.1.1 github.com/eventials/go-tus v0.0.0-20200718001131-45c7ec8f5d59 github.com/gdexlab/go-render v1.0.1 diff --git a/go.sum b/go.sum index 2ba4b3e7d3..ac5ce943f2 100644 --- a/go.sum +++ b/go.sum @@ -283,8 +283,8 @@ github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ3 github.com/creack/pty v1.1.11/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= github.com/cs3org/cato v0.0.0-20200828125504-e418fc54dd5e h1:tqSPWQeueWTKnJVMJffz4pz0o1WuQxJ28+5x5JgaHD8= github.com/cs3org/cato v0.0.0-20200828125504-e418fc54dd5e/go.mod h1:XJEZ3/EQuI3BXTp/6DUzFr850vlxq11I6satRtz0YQ4= -github.com/cs3org/go-cs3apis v0.0.0-20221004162747-f20ee4756d90 h1:zYg2UzwpChLgXktwt7MJEMv46GQPtluifRnynkSw80Y= -github.com/cs3org/go-cs3apis v0.0.0-20221004162747-f20ee4756d90/go.mod h1:UXha4TguuB52H14EMoSsCqDj7k8a/t7g4gVP+bgY5LY= +github.com/cs3org/go-cs3apis v0.0.0-20230124153659-5dc78d32c9b7 h1:QShkOi9aBptnhYN4W0lueiWTlNtc7O69D6GRpYfZodg= +github.com/cs3org/go-cs3apis v0.0.0-20230124153659-5dc78d32c9b7/go.mod h1:UXha4TguuB52H14EMoSsCqDj7k8a/t7g4gVP+bgY5LY= github.com/cyberdelia/templates v0.0.0-20141128023046-ca7fffd4298c/go.mod h1:GyV+0YP4qX0UQ7r2MoYZ+AvYDp12OF5yg4q8rGnyNh4= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= diff --git a/internal/grpc/services/ocminvitemanager/ocminvitemanager.go b/internal/grpc/services/ocminvitemanager/ocminvitemanager.go index eccd61a9ae..648664f4ae 100644 --- a/internal/grpc/services/ocminvitemanager/ocminvitemanager.go +++ b/internal/grpc/services/ocminvitemanager/ocminvitemanager.go @@ -20,13 +20,21 @@ package ocminvitemanager import ( "context" + "time" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" invitepb "github.com/cs3org/go-cs3apis/cs3/ocm/invite/v1beta1" + ocmprovider "github.com/cs3org/go-cs3apis/cs3/ocm/provider/v1beta1" + rpcv1beta1 "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + ctxpkg "github.com/cs3org/reva/pkg/ctx" "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/ocm/client" "github.com/cs3org/reva/pkg/ocm/invite" - "github.com/cs3org/reva/pkg/ocm/invite/manager/registry" + "github.com/cs3org/reva/pkg/ocm/invite/repository/registry" "github.com/cs3org/reva/pkg/rgrpc" "github.com/cs3org/reva/pkg/rgrpc/status" + "github.com/cs3org/reva/pkg/rgrpc/todo/pool" + "github.com/cs3org/reva/pkg/sharedconf" "github.com/mitchellh/mapstructure" "github.com/pkg/errors" "google.golang.org/grpc" @@ -37,26 +45,46 @@ func init() { } type config struct { - Driver string `mapstructure:"driver"` - Drivers map[string]map[string]interface{} `mapstructure:"drivers"` + Driver string `mapstructure:"driver"` + Drivers map[string]map[string]interface{} `mapstructure:"drivers"` + TokenExpiration string `mapstructure:"token_expiration"` + OCMClientTimeout int `mapstructure:"ocm_timeout"` + OCMClientInsecure bool `mapstructure:"ocm_insecure"` + GatewaySVC string `mapstructure:"gateway_svc"` + + tokenExpiration time.Duration } type service struct { - conf *config - im invite.Manager + conf *config + repo invite.Repository + ocmClient *client.OCMClient } -func (c *config) init() { +func (c *config) init() error { if c.Driver == "" { c.Driver = "json" } + if c.TokenExpiration == "" { + c.TokenExpiration = "24h" + } + + p, err := time.ParseDuration(c.TokenExpiration) + if err != nil { + return err + } + c.tokenExpiration = p + + c.GatewaySVC = sharedconf.GetGatewaySVC(c.GatewaySVC) + + return nil } func (s *service) Register(ss *grpc.Server) { invitepb.RegisterInviteAPIServer(ss, s) } -func getInviteManager(c *config) (invite.Manager, error) { +func getInviteRepository(c *config) (invite.Repository, error) { if f, ok := registry.NewFuncs[c.Driver]; ok { return f(c.Drivers[c.Driver]) } @@ -78,16 +106,22 @@ func New(m map[string]interface{}, ss *grpc.Server) (rgrpc.Service, error) { if err != nil { return nil, err } - c.init() + if err := c.init(); err != nil { + return nil, err + } - im, err := getInviteManager(c) + repo, err := getInviteRepository(c) if err != nil { return nil, err } service := &service{ conf: c, - im: im, + repo: repo, + ocmClient: client.New(&client.Config{ + Timeout: time.Duration(c.OCMClientTimeout) * time.Second, + Insecure: c.OCMClientInsecure, + }), } return service, nil } @@ -101,8 +135,10 @@ func (s *service) UnprotectedEndpoints() []string { } func (s *service) GenerateInviteToken(ctx context.Context, req *invitepb.GenerateInviteTokenRequest) (*invitepb.GenerateInviteTokenResponse, error) { - token, err := s.im.GenerateToken(ctx) - if err != nil { + user := ctxpkg.ContextMustGetUser(ctx) + token := CreateToken(s.conf.tokenExpiration, user.GetId(), req.Description) + + if err := s.repo.AddToken(ctx, token); err != nil { return &invitepb.GenerateInviteTokenResponse{ Status: status.NewInternal(ctx, err, "error generating invite token"), }, nil @@ -115,33 +151,155 @@ func (s *service) GenerateInviteToken(ctx context.Context, req *invitepb.Generat } func (s *service) ForwardInvite(ctx context.Context, req *invitepb.ForwardInviteRequest) (*invitepb.ForwardInviteResponse, error) { - err := s.im.ForwardInvite(ctx, req.InviteToken, req.OriginSystemProvider) + user := ctxpkg.ContextMustGetUser(ctx) + + ocmEndpoint, err := getOCMEndpoint(req.GetOriginSystemProvider()) if err != nil { - return &invitepb.ForwardInviteResponse{ - Status: status.NewInternal(ctx, err, "error forwarding invite:"+err.Error()), - }, nil + return nil, err + } + + remoteUser, err := s.ocmClient.InviteAccepted(ctx, ocmEndpoint, &client.InviteAcceptedRequest{ + Token: req.InviteToken.GetToken(), + RecipientProvider: user.GetId().GetIdp(), + UserID: user.GetId().GetOpaqueId(), + Email: user.GetMail(), + Name: user.GetDisplayName(), + }) + if err != nil { + switch { + case errors.Is(err, client.ErrTokenInvalid): + return &invitepb.ForwardInviteResponse{ + Status: status.NewInvalid(ctx, "token not valid"), + }, nil + case errors.Is(err, client.ErrTokenNotFound): + return &invitepb.ForwardInviteResponse{ + Status: status.NewNotFound(ctx, "token not found"), + }, nil + case errors.Is(err, client.ErrUserAlreadyAccepted): + return &invitepb.ForwardInviteResponse{ + Status: status.NewAlreadyExists(ctx, err, err.Error()), + }, nil + case errors.Is(err, client.ErrServiceNotTrusted): + return &invitepb.ForwardInviteResponse{ + Status: status.NewPermissionDenied(ctx, err, err.Error()), + }, nil + default: + return &invitepb.ForwardInviteResponse{ + Status: status.NewInternal(ctx, err, err.Error()), + }, nil + } + } + + // create a link between the user that accepted the share (in ctx) + // and the remote one (the initiator), so at the end of the invitation workflow they + // know each other + + remoteUserID := &userpb.UserId{ + Type: userpb.UserType_USER_TYPE_PRIMARY, + Idp: req.GetOriginSystemProvider().Domain, + OpaqueId: remoteUser.UserID, + } + + if err := s.repo.AddRemoteUser(ctx, user.Id, &userpb.User{ + Id: remoteUserID, + Mail: remoteUser.Email, + DisplayName: remoteUser.Name, + }); err != nil { + if !errors.Is(err, invite.ErrUserAlreadyAccepted) { + // skip error if user was already accepted + return &invitepb.ForwardInviteResponse{ + Status: status.NewInternal(ctx, err, err.Error()), + }, nil + } } return &invitepb.ForwardInviteResponse{ - Status: status.NewOK(ctx), + Status: status.NewOK(ctx), + UserId: remoteUserID, + Email: remoteUser.Email, + DisplayName: remoteUser.Name, }, nil } +func getOCMEndpoint(originProvider *ocmprovider.ProviderInfo) (string, error) { + for _, s := range originProvider.Services { + if s.Endpoint.Type.Name == "OCM" { + return s.Endpoint.Path, nil + } + } + return "", errors.New("ocm endpoint not specified for mesh provider") +} + func (s *service) AcceptInvite(ctx context.Context, req *invitepb.AcceptInviteRequest) (*invitepb.AcceptInviteResponse, error) { - err := s.im.AcceptInvite(ctx, req.InviteToken, req.RemoteUser) + token, err := s.repo.GetToken(ctx, req.InviteToken.Token) if err != nil { + if errors.Is(err, invite.ErrTokenNotFound) { + return &invitepb.AcceptInviteResponse{ + Status: status.NewNotFound(ctx, "token not found"), + }, nil + } return &invitepb.AcceptInviteResponse{ - Status: status.NewInternal(ctx, err, "error accepting invite"), + Status: status.NewInternal(ctx, err, err.Error()), + }, nil + } + + if !isTokenValid(token) { + return &invitepb.AcceptInviteResponse{ + Status: status.NewInvalid(ctx, "token is not valid"), + }, nil + } + + initiator, err := s.getUserInfo(ctx, token.UserId) + if err != nil { + return &invitepb.AcceptInviteResponse{ + Status: status.NewInternal(ctx, err, err.Error()), + }, nil + } + + if err := s.repo.AddRemoteUser(ctx, token.GetUserId(), req.GetRemoteUser()); err != nil { + if errors.Is(err, invite.ErrUserAlreadyAccepted) { + return &invitepb.AcceptInviteResponse{ + Status: status.NewAlreadyExists(ctx, err, err.Error()), + }, nil + } + return &invitepb.AcceptInviteResponse{ + Status: status.NewInternal(ctx, err, err.Error()), }, nil } return &invitepb.AcceptInviteResponse{ - Status: status.NewOK(ctx), + Status: status.NewOK(ctx), + UserId: initiator.GetId(), + Email: initiator.Mail, + DisplayName: initiator.DisplayName, }, nil } +func (s *service) getUserInfo(ctx context.Context, id *userpb.UserId) (*userpb.User, error) { + gw, err := pool.GetGatewayServiceClient(pool.Endpoint(s.conf.GatewaySVC)) + if err != nil { + return nil, err + } + res, err := gw.GetUser(ctx, &userpb.GetUserRequest{ + UserId: id, + }) + if err != nil { + return nil, err + } + if res.Status.Code != rpcv1beta1.Code_CODE_OK { + return nil, errors.New(res.Status.Message) + } + + return res.User, nil +} + +func isTokenValid(token *invitepb.InviteToken) bool { + return time.Now().Unix() < int64(token.Expiration.Seconds) +} + func (s *service) GetAcceptedUser(ctx context.Context, req *invitepb.GetAcceptedUserRequest) (*invitepb.GetAcceptedUserResponse, error) { - remoteUser, err := s.im.GetAcceptedUser(ctx, req.RemoteUserId) + user := ctxpkg.ContextMustGetUser(ctx) + remoteUser, err := s.repo.GetRemoteUser(ctx, user.GetId(), req.GetRemoteUserId()) if err != nil { return &invitepb.GetAcceptedUserResponse{ Status: status.NewInternal(ctx, err, "error fetching remote user details"), @@ -155,7 +313,8 @@ func (s *service) GetAcceptedUser(ctx context.Context, req *invitepb.GetAccepted } func (s *service) FindAcceptedUsers(ctx context.Context, req *invitepb.FindAcceptedUsersRequest) (*invitepb.FindAcceptedUsersResponse, error) { - acceptedUsers, err := s.im.FindAcceptedUsers(ctx, req.Filter) + user := ctxpkg.ContextMustGetUser(ctx) + acceptedUsers, err := s.repo.FindRemoteUsers(ctx, user.GetId(), req.GetFilter()) if err != nil { return &invitepb.FindAcceptedUsersResponse{ Status: status.NewInternal(ctx, err, "error finding remote users: "+err.Error()), diff --git a/pkg/ocm/invite/token/token.go b/internal/grpc/services/ocminvitemanager/token.go similarity index 71% rename from pkg/ocm/invite/token/token.go rename to internal/grpc/services/ocminvitemanager/token.go index 5189a1e4e4..d91243c1d5 100644 --- a/pkg/ocm/invite/token/token.go +++ b/internal/grpc/services/ocminvitemanager/token.go @@ -16,7 +16,7 @@ // granted to it by virtue of its status as an Intergovernmental Organization // or submit itself to any jurisdiction. -package token +package ocminvitemanager import ( "time" @@ -25,32 +25,21 @@ import ( invitepb "github.com/cs3org/go-cs3apis/cs3/ocm/invite/v1beta1" typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" "github.com/google/uuid" - "github.com/pkg/errors" ) -// DefaultExpirationTime is the expiration time to be used when unspecified in the config. -const DefaultExpirationTime = "24h" - // CreateToken creates a InviteToken object for the userID indicated by userID. -func CreateToken(expiration string, userID *userpb.UserId) (*invitepb.InviteToken, error) { - // Parse time of expiration - duration, err := time.ParseDuration(expiration) - if err != nil { - return nil, errors.Wrap(err, "error parsing time of expiration") - } - +func CreateToken(expiration time.Duration, userID *userpb.UserId, description string) *invitepb.InviteToken { tokenID := uuid.New().String() now := time.Now() - expirationTime := now.Add(duration) + expirationTime := now.Add(expiration) - token := invitepb.InviteToken{ + return &invitepb.InviteToken{ Token: tokenID, UserId: userID, Expiration: &typesv1beta1.Timestamp{ Seconds: uint64(expirationTime.Unix()), Nanos: 0, }, + Description: description, } - - return &token, nil } diff --git a/pkg/ocm/invite/token/token_test.go b/internal/grpc/services/ocminvitemanager/token_test.go similarity index 89% rename from pkg/ocm/invite/token/token_test.go rename to internal/grpc/services/ocminvitemanager/token_test.go index 76dbad724d..cbe57e9e67 100644 --- a/pkg/ocm/invite/token/token_test.go +++ b/internal/grpc/services/ocminvitemanager/token_test.go @@ -16,11 +16,12 @@ // granted to it by virtue of its status as an Intergovernmental Organization // or submit itself to any jurisdiction. -package token +package ocminvitemanager import ( "sync" "testing" + "time" userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" ) @@ -40,10 +41,7 @@ func TestCreateToken(t *testing.T) { Opaque: nil, } - token, err := CreateToken("24h", user.GetId()) - if err != nil { - t.Errorf("CreateToken() error = %v", err) - } + token := CreateToken(24*time.Hour, user.GetId(), "") if token == nil { t.Errorf("CreateToken() got = %v", token) } @@ -73,10 +71,8 @@ func TestCreateTokenCollision(t *testing.T) { } for i := 0; i < 1000000; i++ { - token, err := CreateToken("24h", user.GetId()) - if err != nil { - t.Errorf("CreateToken() error = %v", err) - } + token := CreateToken(24*time.Hour, user.GetId(), "") + if token == nil { t.Errorf("CreateToken() token = %v", token) } diff --git a/internal/http/services/loader/loader.go b/internal/http/services/loader/loader.go index e7246c3a69..87a96be029 100644 --- a/internal/http/services/loader/loader.go +++ b/internal/http/services/loader/loader.go @@ -35,6 +35,7 @@ import ( _ "github.com/cs3org/reva/internal/http/services/preferences" _ "github.com/cs3org/reva/internal/http/services/prometheus" _ "github.com/cs3org/reva/internal/http/services/reverseproxy" + _ "github.com/cs3org/reva/internal/http/services/sciencemesh" _ "github.com/cs3org/reva/internal/http/services/siteacc" _ "github.com/cs3org/reva/internal/http/services/sysinfo" _ "github.com/cs3org/reva/internal/http/services/wellknown" diff --git a/internal/http/services/meshdirectory/meshdirectory.go b/internal/http/services/meshdirectory/meshdirectory.go index 3138ef1995..9a1327adc0 100644 --- a/internal/http/services/meshdirectory/meshdirectory.go +++ b/internal/http/services/meshdirectory/meshdirectory.go @@ -25,7 +25,7 @@ import ( gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" providerv1beta1 "github.com/cs3org/go-cs3apis/cs3/ocm/provider/v1beta1" - "github.com/cs3org/reva/internal/http/services/ocmd" + "github.com/cs3org/reva/internal/http/services/reqres" "github.com/cs3org/reva/pkg/rgrpc/todo/pool" "github.com/cs3org/reva/pkg/rhttp/global" "github.com/cs3org/reva/pkg/rhttp/router" @@ -107,27 +107,27 @@ func (s *svc) serveJSON(w http.ResponseWriter, r *http.Request) { gatewayClient, err := s.getClient() if err != nil { - ocmd.WriteError(w, r, ocmd.APIErrorServerError, + reqres.WriteError(w, r, reqres.APIErrorServerError, fmt.Sprintf("error getting grpc client on addr: %v", s.conf.GatewaySvc), err) return } providers, err := gatewayClient.ListAllProviders(ctx, &providerv1beta1.ListAllProvidersRequest{}) if err != nil { - ocmd.WriteError(w, r, ocmd.APIErrorServerError, "error listing all providers", err) + reqres.WriteError(w, r, reqres.APIErrorServerError, "error listing all providers", err) return } jsonResponse, err := json.Marshal(providers.Providers) if err != nil { - ocmd.WriteError(w, r, ocmd.APIErrorServerError, "error marshalling providers data", err) + reqres.WriteError(w, r, reqres.APIErrorServerError, "error marshalling providers data", err) return } // Write response _, err = w.Write(jsonResponse) if err != nil { - ocmd.WriteError(w, r, ocmd.APIErrorServerError, "error writing providers data", err) + reqres.WriteError(w, r, reqres.APIErrorServerError, "error writing providers data", err) return } diff --git a/internal/http/services/ocmd/config.go b/internal/http/services/ocmd/config.go index 99c62358d7..a8666e53f1 100644 --- a/internal/http/services/ocmd/config.go +++ b/internal/http/services/ocmd/config.go @@ -49,7 +49,7 @@ type configHandler struct { c configData } -func (h *configHandler) init(c *Config) { +func (h *configHandler) init(c *config) { h.c = c.Config if h.c.APIVersion == "" { h.c.APIVersion = "1.0-proposal1" @@ -75,15 +75,14 @@ func (h *configHandler) init(c *Config) { }} } -func (h *configHandler) Handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log := appctx.GetLogger(r.Context()) +// Send sends the configuration to the caller. +func (h *configHandler) Send(w http.ResponseWriter, r *http.Request) { + log := appctx.GetLogger(r.Context()) - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - indentedConf, _ := json.MarshalIndent(h.c, "", " ") - if _, err := w.Write(indentedConf); err != nil { - log.Err(err).Msg("Error writing to ResponseWriter") - } - }) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + indentedConf, _ := json.MarshalIndent(h.c, "", " ") + if _, err := w.Write(indentedConf); err != nil { + log.Err(err).Msg("Error writing to ResponseWriter") + } } diff --git a/internal/http/services/ocmd/invites.go b/internal/http/services/ocmd/invites.go index 856f102712..bae981ed2f 100644 --- a/internal/http/services/ocmd/invites.go +++ b/internal/http/services/ocmd/invites.go @@ -22,222 +22,73 @@ import ( "encoding/json" "errors" "fmt" - "html" - "io" "mime" "net/http" + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" invitepb "github.com/cs3org/go-cs3apis/cs3/ocm/invite/v1beta1" ocmprovider "github.com/cs3org/go-cs3apis/cs3/ocm/provider/v1beta1" rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + "github.com/cs3org/reva/internal/http/services/reqres" "github.com/cs3org/reva/pkg/appctx" - ctxpkg "github.com/cs3org/reva/pkg/ctx" "github.com/cs3org/reva/pkg/rgrpc/todo/pool" - "github.com/cs3org/reva/pkg/rhttp/router" "github.com/cs3org/reva/pkg/smtpclient" "github.com/cs3org/reva/pkg/utils" ) type invitesHandler struct { smtpCredentials *smtpclient.SMTPCredentials - gatewayAddr string + gatewayClient gateway.GatewayAPIClient meshDirectoryURL string } -func (h *invitesHandler) init(c *Config) { - h.gatewayAddr = c.GatewaySvc +func (h *invitesHandler) init(c *config) error { + var err error + h.gatewayClient, err = pool.GetGatewayServiceClient(pool.Endpoint(c.GatewaySvc)) + if err != nil { + return err + } if c.SMTPCredentials != nil { h.smtpCredentials = smtpclient.NewSMTPCredentials(c.SMTPCredentials) } h.meshDirectoryURL = c.MeshDirectoryURL + return nil } -func (h *invitesHandler) Handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log := appctx.GetLogger(r.Context()) - var head string - head, r.URL.Path = router.ShiftPath(r.URL.Path) - log.Debug().Str("head", head).Str("tail", r.URL.Path).Msg("http routing") - - switch head { - case "": - h.generateInviteToken(w, r) - case "forward": - h.forwardInvite(w, r) - case "accept": - h.acceptInvite(w, r) - case "find-accepted-users": - h.findAcceptedUsers(w, r) - case "generate": - h.generate(w, r) - default: - w.WriteHeader(http.StatusNotFound) - } - }) +type acceptInviteRequest struct { + Token string `json:"token"` + UserID string `json:"userID"` + RecipientProvider string `json:"recipientProvider"` + Name string `json:"name"` + Email string `json:"email"` } -func (h *invitesHandler) generateInviteToken(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - - gatewayClient, err := pool.GetGatewayServiceClient(pool.Endpoint(h.gatewayAddr)) - if err != nil { - WriteError(w, r, APIErrorServerError, "error getting gateway grpc client", err) - return - } - - token, err := gatewayClient.GenerateInviteToken(ctx, &invitepb.GenerateInviteTokenRequest{}) - if err != nil { - WriteError(w, r, APIErrorServerError, "error generating token", err) - return - } - - if r.FormValue("recipient") != "" && h.smtpCredentials != nil { - usr := ctxpkg.ContextMustGetUser(ctx) - - // TODO: the message body needs to point to the meshdirectory service - subject := fmt.Sprintf("ScienceMesh: %s wants to collaborate with you", usr.DisplayName) - body := "Hi,\n\n" + - usr.DisplayName + " (" + usr.Mail + ") wants to start sharing OCM resources with you. " + - "To accept the invite, please visit the following URL:\n" + - h.meshDirectoryURL + "?token=" + token.InviteToken.Token + "&providerDomain=" + usr.Id.Idp + "\n\n" + - "Alternatively, you can visit your mesh provider and use the following details:\n" + - "Token: " + token.InviteToken.Token + "\n" + - "ProviderDomain: " + usr.Id.Idp + "\n\n" + - "Best,\nThe ScienceMesh team" - - err = h.smtpCredentials.SendMail(r.FormValue("recipient"), subject, body) - if err != nil { - WriteError(w, r, APIErrorServerError, "error sending token by mail", err) - return - } - } - - jsonResponse, err := json.Marshal(token.InviteToken) - if err != nil { - WriteError(w, r, APIErrorServerError, "error marshalling token data", err) - return - } - - // Write response - _, err = w.Write(jsonResponse) - if err != nil { - WriteError(w, r, APIErrorServerError, "error writing token data", err) - return - } - - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) -} - -func (h *invitesHandler) forwardInvite(w http.ResponseWriter, r *http.Request) { +// AcceptInvite informs avout an accepted invitation so that the users +// can initiate the OCM share creation. +func (h *invitesHandler) AcceptInvite(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := appctx.GetLogger(ctx) - contentType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) - var token, providerDomain string - if err == nil && contentType == "application/json" { - defer r.Body.Close() - reqBody, err := io.ReadAll(r.Body) - if err == nil { - reqMap := make(map[string]string) - err = json.Unmarshal(reqBody, &reqMap) - if err == nil { - token, providerDomain = reqMap["token"], reqMap["providerDomain"] - } - } - } else { - token, providerDomain = r.FormValue("token"), r.FormValue("providerDomain") - } - if token == "" || providerDomain == "" { - WriteError(w, r, APIErrorInvalidParameter, "token and providerDomain must not be null", nil) - return - } - - gatewayClient, err := pool.GetGatewayServiceClient(pool.Endpoint(h.gatewayAddr)) - if err != nil { - WriteError(w, r, APIErrorServerError, "error getting gateway grpc client", err) - return - } - - inviteToken := &invitepb.InviteToken{ - Token: token, - } - providerInfo, err := gatewayClient.GetInfoByDomain(ctx, &ocmprovider.GetInfoByDomainRequest{ - Domain: providerDomain, - }) + req, err := getAcceptInviteRequest(r) if err != nil { - WriteError(w, r, APIErrorServerError, "error sending a grpc get invite by domain info request", err) - return - } - if providerInfo.Status.Code != rpc.Code_CODE_OK { - WriteError(w, r, APIErrorServerError, "grpc forward invite request failed", errors.New(providerInfo.Status.Message)) + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "missing parameters in request", err) return } - forwardInviteReq := &invitepb.ForwardInviteRequest{ - InviteToken: inviteToken, - OriginSystemProvider: providerInfo.ProviderInfo, - } - forwardInviteResponse, err := gatewayClient.ForwardInvite(ctx, forwardInviteReq) - if err != nil { - WriteError(w, r, APIErrorServerError, "error sending a grpc forward invite request", err) - return - } - if forwardInviteResponse.Status.Code != rpc.Code_CODE_OK { - WriteError(w, r, APIErrorServerError, "grpc forward invite request failed", errors.New(forwardInviteResponse.Status.Message)) - return - } - - _, err = w.Write([]byte("{\"message\": \"Success\", \"providerDomain\":\"" + html.EscapeString(providerDomain) + "\"}")) - if err != nil { - WriteError(w, r, APIErrorServerError, "error writing token data", err) - return - } - w.WriteHeader(http.StatusOK) - - log.Info().Msgf("Invite forwarded to: %s", providerDomain) -} - -func (h *invitesHandler) acceptInvite(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - log := appctx.GetLogger(ctx) - contentType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) - var token, userID, recipientProvider, name, email string - if err == nil && contentType == "application/json" { - defer r.Body.Close() - reqBody, err := io.ReadAll(r.Body) - if err == nil { - reqMap := make(map[string]string) - err = json.Unmarshal(reqBody, &reqMap) - if err == nil { - token, userID, recipientProvider = reqMap["token"], reqMap["userID"], reqMap["recipientProvider"] - name, email = reqMap["name"], reqMap["email"] - } - } - } else { - token, userID, recipientProvider = r.FormValue("token"), r.FormValue("userID"), r.FormValue("recipientProvider") - name, email = r.FormValue("name"), r.FormValue("email") - } - if token == "" || userID == "" || recipientProvider == "" { - WriteError(w, r, APIErrorInvalidParameter, "missing parameters in request", nil) - return - } - - gatewayClient, err := pool.GetGatewayServiceClient(pool.Endpoint(h.gatewayAddr)) - if err != nil { - WriteError(w, r, APIErrorServerError, "error getting gateway grpc client", err) + if req.Token == "" || req.UserID == "" || req.RecipientProvider == "" { + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "token, userID and recipiendProvider must not be null", nil) return } clientIP, err := utils.GetClientIP(r) if err != nil { - WriteError(w, r, APIErrorServerError, fmt.Sprintf("error retrieving client IP from request: %s", r.RemoteAddr), err) + reqres.WriteError(w, r, reqres.APIErrorServerError, fmt.Sprintf("error retrieving client IP from request: %s", r.RemoteAddr), err) return } providerInfo := ocmprovider.ProviderInfo{ - Domain: recipientProvider, + Domain: req.RecipientProvider, Services: []*ocmprovider.Service{ { Host: clientIP, @@ -245,93 +96,85 @@ func (h *invitesHandler) acceptInvite(w http.ResponseWriter, r *http.Request) { }, } - providerAllowedResp, err := gatewayClient.IsProviderAllowed(ctx, &ocmprovider.IsProviderAllowedRequest{ + providerAllowedResp, err := h.gatewayClient.IsProviderAllowed(ctx, &ocmprovider.IsProviderAllowedRequest{ Provider: &providerInfo, }) if err != nil { - WriteError(w, r, APIErrorServerError, "error sending a grpc is provider allowed request", err) + reqres.WriteError(w, r, reqres.APIErrorServerError, "error sending a grpc is provider allowed request", err) return } if providerAllowedResp.Status.Code != rpc.Code_CODE_OK { - WriteError(w, r, APIErrorUnauthenticated, "provider not authorized", errors.New(providerAllowedResp.Status.Message)) + reqres.WriteError(w, r, reqres.APIErrorUntrustedService, "provider not trusted", errors.New(providerAllowedResp.Status.Message)) return } userObj := &userpb.User{ Id: &userpb.UserId{ - OpaqueId: userID, - Idp: recipientProvider, + OpaqueId: req.UserID, + Idp: req.RecipientProvider, Type: userpb.UserType_USER_TYPE_PRIMARY, }, - Mail: email, - DisplayName: name, + Mail: req.Email, + DisplayName: req.Name, } acceptInviteRequest := &invitepb.AcceptInviteRequest{ InviteToken: &invitepb.InviteToken{ - Token: token, + Token: req.Token, }, RemoteUser: userObj, } - acceptInviteResponse, err := gatewayClient.AcceptInvite(ctx, acceptInviteRequest) + acceptInviteResponse, err := h.gatewayClient.AcceptInvite(ctx, acceptInviteRequest) if err != nil { - WriteError(w, r, APIErrorServerError, "error sending a grpc accept invite request", err) + reqres.WriteError(w, r, reqres.APIErrorServerError, "error sending a grpc accept invite request", err) return } if acceptInviteResponse.Status.Code != rpc.Code_CODE_OK { - WriteError(w, r, APIErrorServerError, "grpc accept invite request failed", errors.New(acceptInviteResponse.Status.Message)) - return - } - - log.Info().Msgf("User: %+v added to accepted users.", userObj) -} - -func (h *invitesHandler) findAcceptedUsers(w http.ResponseWriter, r *http.Request) { - log := appctx.GetLogger(r.Context()) - - ctx := r.Context() - gatewayClient, err := pool.GetGatewayServiceClient(pool.Endpoint(h.gatewayAddr)) - if err != nil { - WriteError(w, r, APIErrorServerError, "error getting gateway grpc client", err) - return + switch acceptInviteResponse.Status.Code { + case rpc.Code_CODE_NOT_FOUND: + reqres.WriteError(w, r, reqres.APIErrorNotFound, "token not found", nil) + return + case rpc.Code_CODE_INVALID_ARGUMENT: + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "token has expired", nil) + return + case rpc.Code_CODE_ALREADY_EXISTS: + reqres.WriteError(w, r, reqres.APIErrorAlreadyExist, "user already known", nil) + return + default: + reqres.WriteError(w, r, reqres.APIErrorServerError, "unexpected error: "+acceptInviteResponse.Status.Message, errors.New(acceptInviteResponse.Status.Message)) + return + } } - response, err := gatewayClient.FindAcceptedUsers(ctx, &invitepb.FindAcceptedUsersRequest{ - Filter: "", - }) - if err != nil { - WriteError(w, r, APIErrorServerError, "error sending a grpc find accepted users request", err) + if err := json.NewEncoder(w).Encode(&user{ + UserID: acceptInviteResponse.UserId.OpaqueId, + Email: acceptInviteResponse.Email, + Name: acceptInviteResponse.DisplayName, + }); err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error encoding response", err) return } - - indentedResponse, _ := json.MarshalIndent(response, "", " ") - w.Header().Set("Content-Type", "application/json") w.WriteHeader(http.StatusOK) - log.Debug().Msg("findAcceptedUsers json response: " + string(indentedResponse)) - if _, err := w.Write(indentedResponse); err != nil { - log.Err(err).Msg("Error writing to ResponseWriter") - } -} - -func (h *invitesHandler) generate(w http.ResponseWriter, r *http.Request) { - log := appctx.GetLogger(r.Context()) + w.Header().Set("Content-Type", "application/json") - ctx := r.Context() - gatewayClient, err := pool.GetGatewayServiceClient(pool.Endpoint(h.gatewayAddr)) - if err != nil { - WriteError(w, r, APIErrorServerError, "error getting gateway grpc client", err) - return - } + log.Info().Str("user", fmt.Sprintf("%s@%s", userObj.Id.OpaqueId, userObj.Id.Idp)).Str("token", req.Token).Msg("added to accepted users") +} - response, err := gatewayClient.GenerateInviteToken(ctx, &invitepb.GenerateInviteTokenRequest{}) - if err != nil { - WriteError(w, r, APIErrorServerError, "error sending a grpc generate invite token request", err) - return - } +type user struct { + UserID string `json:"userID"` + Email string `json:"email"` + Name string `json:"name"` +} - indentedResponse, _ := json.MarshalIndent(response, "", " ") - w.Header().Set("Content-Type", "application/json") - w.WriteHeader(http.StatusOK) - if _, err := w.Write(indentedResponse); err != nil { - log.Err(err).Msg("Error writing to ResponseWriter") +func getAcceptInviteRequest(r *http.Request) (*acceptInviteRequest, error) { + var req acceptInviteRequest + contentType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err == nil && contentType == "application/json" { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return nil, err + } + } else { + req.Token, req.UserID, req.RecipientProvider = r.FormValue("token"), r.FormValue("userID"), r.FormValue("recipientProvider") + req.Name, req.Email = r.FormValue("name"), r.FormValue("email") } + return &req, nil } diff --git a/internal/http/services/ocmd/notifications.go b/internal/http/services/ocmd/notifications.go index ec787f52f3..1e3f591298 100644 --- a/internal/http/services/ocmd/notifications.go +++ b/internal/http/services/ocmd/notifications.go @@ -20,26 +20,15 @@ package ocmd import ( "net/http" - - "github.com/cs3org/reva/pkg/appctx" - "github.com/cs3org/reva/pkg/rhttp/router" ) type notificationsHandler struct { } -func (h *notificationsHandler) init(c *Config) { +func (h *notificationsHandler) init(c *config) { } -func (h *notificationsHandler) Handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log := appctx.GetLogger(r.Context()) - - var head string - head, r.URL.Path = router.ShiftPath(r.URL.Path) - - log.Debug().Str("head", head).Str("tail", r.URL.Path).Msg("http routing") - - w.WriteHeader(http.StatusOK) - }) +// SendNotification is used to let the provider know that a user has removed a share. +func (h *notificationsHandler) SendNotification(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusNotImplemented) } diff --git a/internal/http/services/ocmd/ocmd.go b/internal/http/services/ocmd/ocm.go similarity index 55% rename from internal/http/services/ocmd/ocmd.go rename to internal/http/services/ocmd/ocm.go index c0f94e7656..2974320a0e 100644 --- a/internal/http/services/ocmd/ocmd.go +++ b/internal/http/services/ocmd/ocm.go @@ -23,9 +23,9 @@ import ( "github.com/cs3org/reva/pkg/appctx" "github.com/cs3org/reva/pkg/rhttp/global" - "github.com/cs3org/reva/pkg/rhttp/router" "github.com/cs3org/reva/pkg/sharedconf" "github.com/cs3org/reva/pkg/smtpclient" + "github.com/go-chi/chi/v5" "github.com/mitchellh/mapstructure" "github.com/rs/zerolog" ) @@ -34,8 +34,7 @@ func init() { global.Register("ocmd", New) } -// Config holds the config options that need to be passed down to all ocdav handlers. -type Config struct { +type config struct { SMTPCredentials *smtpclient.SMTPCredentials `mapstructure:"smtp_credentials"` Prefix string `mapstructure:"prefix"` Host string `mapstructure:"host"` @@ -44,7 +43,7 @@ type Config struct { Config configData `mapstructure:"config"` } -func (c *Config) init() { +func (c *config) init() { c.GatewaySvc = sharedconf.GetGatewaySVC(c.GatewaySvc) if c.Prefix == "" { @@ -53,41 +52,53 @@ func (c *Config) init() { } type svc struct { - Conf *Config - SharesHandler *sharesHandler - NotificationsHandler *notificationsHandler - ConfigHandler *configHandler - InvitesHandler *invitesHandler - SendHandler *sendHandler + Conf *config + router chi.Router } -// New returns a new ocmd object. +// New returns a new ocmd object, that implements +// the OCM APIs specified in https://cs3org.github.io/OCM-API/docs.html func New(m map[string]interface{}, log *zerolog.Logger) (global.Service, error) { - conf := &Config{} + conf := &config{} if err := mapstructure.Decode(m, conf); err != nil { return nil, err } conf.init() + r := chi.NewRouter() s := &svc{ - Conf: conf, + Conf: conf, + router: r, + } + + if err := s.routerInit(); err != nil { + return nil, err } - s.SharesHandler = new(sharesHandler) - s.NotificationsHandler = new(notificationsHandler) - s.ConfigHandler = new(configHandler) - s.InvitesHandler = new(invitesHandler) - s.SendHandler = new(sendHandler) - s.SharesHandler.init(s.Conf) - s.NotificationsHandler.init(s.Conf) - log.Debug().Str("initializing ConfigHandler Host", s.Conf.Host) - - s.ConfigHandler.init(s.Conf) - s.InvitesHandler.init(s.Conf) - s.SendHandler.init(s.Conf) return s, nil } +func (s *svc) routerInit() error { + configHandler := new(configHandler) + sharesHandler := new(sharesHandler) + notificationsHandler := new(notificationsHandler) + invitesHandler := new(invitesHandler) + + configHandler.init(s.Conf) + sharesHandler.init(s.Conf) + notificationsHandler.init(s.Conf) + if err := invitesHandler.init(s.Conf); err != nil { + return err + } + + s.router.Get("/ocm-provider", configHandler.Send) // FIXME: where this endpoint is documented? + s.router.Post("/shares", sharesHandler.CreateShare) + s.router.Post("/notifications", notificationsHandler.SendNotification) + s.router.Post("/invite-accepted", invitesHandler.AcceptInvite) + + return nil +} + // Close performs cleanup. func (s *svc) Close() error { return nil @@ -98,37 +109,16 @@ func (s *svc) Prefix() string { } func (s *svc) Unprotected() []string { - return []string{"/invites/accept", "/shares", "/ocm-provider", "/notifications"} + return []string{"/invite-accepted", "/shares", "/ocm-provider", "/notifications"} } func (s *svc) Handler() http.Handler { return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - ctx := r.Context() - log := appctx.GetLogger(ctx) - - var head string - head, r.URL.Path = router.ShiftPath(r.URL.Path) - log.Debug().Str("head", head).Str("tail", r.URL.Path).Msg("http routing") - - switch head { - case "ocm-provider": - s.ConfigHandler.Handler().ServeHTTP(w, r) - return - case "shares": - s.SharesHandler.Handler().ServeHTTP(w, r) - return - case "notifications": - s.NotificationsHandler.Handler().ServeHTTP(w, r) - return - case "invites": - s.InvitesHandler.Handler().ServeHTTP(w, r) - return - case "send": - s.SendHandler.Handler().ServeHTTP(w, r) - return - } - - log.Warn().Msgf("request not handled. Try e.g. 'ocm-provider', 'shares', 'notifications', 'invites', or 'send' instead of '%s'", head) - w.WriteHeader(http.StatusNotFound) + log := appctx.GetLogger(r.Context()) + log.Debug().Str("path", r.URL.Path).Msg("ocs routing") + + // unset raw path, otherwise chi uses it to route and then fails to match percent encoded path segments + r.URL.RawPath = "" + s.router.ServeHTTP(w, r) }) } diff --git a/internal/http/services/ocmd/send.go b/internal/http/services/ocmd/send.go deleted file mode 100644 index 9f56092fc9..0000000000 --- a/internal/http/services/ocmd/send.go +++ /dev/null @@ -1,210 +0,0 @@ -// Copyright 2018-2023 CERN -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// In applying this license, CERN does not waive the privileges and immunities -// granted to it by virtue of its status as an Intergovernmental Organization -// or submit itself to any jurisdiction. - -package ocmd - -import ( - "context" - "encoding/json" - "errors" - "io" - "net/http" - "strconv" - - gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" - userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - ocmprovider "github.com/cs3org/go-cs3apis/cs3/ocm/provider/v1beta1" - rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" - ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1" - provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" - types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" - "github.com/cs3org/reva/pkg/appctx" - ctxpkg "github.com/cs3org/reva/pkg/ctx" - "github.com/cs3org/reva/pkg/rgrpc/todo/pool" - "github.com/cs3org/reva/pkg/utils" - "google.golang.org/grpc/metadata" -) - -type sendHandler struct { - GatewaySvc string -} - -func (h *sendHandler) init(c *Config) { - h.GatewaySvc = c.GatewaySvc -} - -func (h *sendHandler) Handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - log := appctx.GetLogger(r.Context()) - defer r.Body.Close() - reqBody, err := io.ReadAll(r.Body) - if err != nil { - log.Error().Msg("cannot read POST body!") - w.WriteHeader(http.StatusInternalServerError) - return - } - - reqMap := make(map[string]string) - err = json.Unmarshal(reqBody, &reqMap) - if err != nil { - log.Error().Msg("cannot parse POST body!") - w.WriteHeader(http.StatusInternalServerError) - return - } - sourcePath, targetPath, sharedSecret := reqMap["sourcePath"], reqMap["targetPath"], reqMap["sharedSecret"] - recipientUsername, recipientHost := reqMap["recipientUsername"], reqMap["recipientHost"] - loginType, loginUsername, loginPassword := reqMap["loginType"], reqMap["loginUsername"], reqMap["loginPassword"] - - // "sourcePath": "other", - // "targetPath": "sciencemesh\/other", - // "type": "dir", (unused) - // "recipientUsername": "marie", - // "recipientHost": "revanc2.docker", - // "loginType": "basic", - // "loginUsername": "einstein", - // "loginPassword": "Ny4Nv6WLoC1o70kVgrVOZLZ2vRgPjuej" - - gatewayAddr := h.GatewaySvc - gatewayClient, err := pool.GetGatewayServiceClient(pool.Endpoint(gatewayAddr)) - if err != nil { - log.Error().Msg("cannot get grpc client!") - w.WriteHeader(http.StatusInternalServerError) - return - } - loginReq := &gateway.AuthenticateRequest{ - Type: loginType, - ClientId: loginUsername, - ClientSecret: loginPassword, - } - - loginCtx := context.Background() - res, err := gatewayClient.Authenticate(loginCtx, loginReq) - if err != nil { - log.Error().Msg("error logging in") - w.WriteHeader(http.StatusInternalServerError) - return - } - authCtx := context.Background() - - authCtx = ctxpkg.ContextSetToken(authCtx, res.Token) - authCtx = metadata.AppendToOutgoingContext(authCtx, ctxpkg.TokenHeader, res.Token) - - // copied from cmd/reva/public-share-create.go: - ref := &provider.Reference{Path: sourcePath} - - req := &provider.StatRequest{Ref: ref} - res2, err := gatewayClient.Stat(authCtx, req) - if err != nil { - log.Error().Msg("gatewayClient.Stat operation failed; is the storage backend reachable?") - w.WriteHeader(http.StatusInternalServerError) - return - } - - if res2.Status.Code != rpc.Code_CODE_OK { - log.Error().Msgf("sourcePath %s does not exist on the storage backend", sourcePath) - w.WriteHeader(http.StatusInternalServerError) - return - } - - // see cmd/reva/share-creat.go:getSharePerm - readerPermission := &provider.ResourcePermissions{ - GetPath: true, - InitiateFileDownload: true, - ListFileVersions: true, - ListContainer: true, - Stat: true, - } - - grant := &ocm.ShareGrant{ - Permissions: &ocm.SharePermissions{ - Permissions: readerPermission, - }, - Grantee: &provider.Grantee{ - Type: provider.GranteeType_GRANTEE_TYPE_USER, - Id: &provider.Grantee_UserId{ - UserId: &userpb.UserId{ - Idp: recipientHost, - OpaqueId: recipientUsername, - }, - }, - }, - } - recipientProviderInfo, err := gatewayClient.GetInfoByDomain(authCtx, &ocmprovider.GetInfoByDomainRequest{ - Domain: recipientHost, - }) - if err != nil { - WriteError(w, r, APIErrorServerError, "error sending a grpc get invite by domain info request", err) - return - } - if recipientProviderInfo.Status.Code != rpc.Code_CODE_OK { - WriteError(w, r, APIErrorServerError, "grpc forward invite request failed", errors.New(recipientProviderInfo.Status.Message)) - return - } - - shareRequest := &ocm.CreateOCMShareRequest{ - Opaque: &types.Opaque{ - Map: map[string]*types.OpaqueEntry{ - "permissions": { - Decoder: "plain", - Value: []byte(strconv.Itoa(0)), - }, - "name": { - Decoder: "plain", - Value: []byte(targetPath), - }, - "protocol": { - Decoder: "plain", - Value: []byte("webdav"), // TODO: support datatx too - }, - "token": { - Decoder: "plain", - Value: []byte(sharedSecret), - }, - }, - }, - ResourceId: res2.Info.Id, - Grant: grant, - RecipientMeshProvider: recipientProviderInfo.ProviderInfo, - } - - shareRes, err := gatewayClient.CreateOCMShare(authCtx, shareRequest) - if err != nil { - log.Error().Msg("error sending: CreateShare: " + err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - - if shareRes.Status.Code != rpc.Code_CODE_OK { - log.Error().Msg("error returned: CreateShare: " + err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - - responseBody, err := utils.MarshalProtoV1ToJSON(shareRes) - if err != nil { - log.Error().Msg("error encoding response: " + err.Error()) - w.WriteHeader(http.StatusInternalServerError) - return - } - w.WriteHeader(http.StatusOK) - _, err = w.Write(responseBody) - if err != nil { - log.Error().Msg("error writing response body:" + err.Error()) - } - }) -} diff --git a/internal/http/services/ocmd/shares.go b/internal/http/services/ocmd/shares.go index 5950a7f515..a4dbd9699b 100644 --- a/internal/http/services/ocmd/shares.go +++ b/internal/http/services/ocmd/shares.go @@ -36,6 +36,7 @@ import ( rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" "github.com/cs3org/reva/internal/http/services/owncloud/ocs/conversions" + "github.com/cs3org/reva/internal/http/services/reqres" "github.com/cs3org/reva/pkg/appctx" "github.com/cs3org/reva/pkg/rgrpc/todo/pool" "github.com/cs3org/reva/pkg/utils" @@ -45,22 +46,13 @@ type sharesHandler struct { gatewayAddr string } -func (h *sharesHandler) init(c *Config) { +func (h *sharesHandler) init(c *config) { h.gatewayAddr = c.GatewaySvc } -func (h *sharesHandler) Handler() http.Handler { - return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - h.createShare(w, r) - default: - WriteError(w, r, APIErrorInvalidParameter, "Only POST method is allowed", nil) - } - }) -} - -func (h *sharesHandler) createShare(w http.ResponseWriter, r *http.Request) { +// CreateShare sends all the informations to the consumer needed to start +// synchronization between the two services. +func (h *sharesHandler) CreateShare(w http.ResponseWriter, r *http.Request) { ctx := r.Context() log := appctx.GetLogger(ctx) contentType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) @@ -86,7 +78,7 @@ func (h *sharesHandler) createShare(w http.ResponseWriter, r *http.Request) { providerID = reqMap["providerId"].(string) } } else { - WriteError(w, r, APIErrorInvalidParameter, "could not parse json request body", nil) + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "could not parse json request body", nil) } } } else { @@ -95,30 +87,30 @@ func (h *sharesHandler) createShare(w http.ResponseWriter, r *http.Request) { resource, providerID, owner = r.FormValue("name"), r.FormValue("providerId"), r.FormValue("owner") err = json.Unmarshal([]byte(protocolJSON), &protocol) if err != nil { - WriteError(w, r, APIErrorInvalidParameter, "invalid protocol parameters", nil) + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "invalid protocol parameters", nil) } } if resource == "" || providerID == "" || owner == "" { msg := fmt.Sprintf("missing details about resource to be shared (resource='%s', providerID='%s', owner='%s", resource, providerID, owner) - WriteError(w, r, APIErrorInvalidParameter, msg, nil) + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, msg, nil) return } if shareWith == "" || protocol["name"] == "" || meshProvider == "" { msg := fmt.Sprintf("missing request parameters (shareWith='%s', protocol.name='%s', meshProvider='%s'", shareWith, protocol["name"], meshProvider) - WriteError(w, r, APIErrorInvalidParameter, msg, nil) + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, msg, nil) return } gatewayClient, err := pool.GetGatewayServiceClient(pool.Endpoint(h.gatewayAddr)) if err != nil { - WriteError(w, r, APIErrorServerError, "error getting storage grpc client", err) + reqres.WriteError(w, r, reqres.APIErrorServerError, "error getting storage grpc client", err) return } clientIP, err := utils.GetClientIP(r) if err != nil { - WriteError(w, r, APIErrorServerError, fmt.Sprintf("error retrieving client IP from request: %s", r.RemoteAddr), err) + reqres.WriteError(w, r, reqres.APIErrorServerError, fmt.Sprintf("error retrieving client IP from request: %s", r.RemoteAddr), err) return } providerInfo := ocmprovider.ProviderInfo{ @@ -134,11 +126,11 @@ func (h *sharesHandler) createShare(w http.ResponseWriter, r *http.Request) { Provider: &providerInfo, }) if err != nil { - WriteError(w, r, APIErrorServerError, "error sending a grpc is provider allowed request", err) + reqres.WriteError(w, r, reqres.APIErrorServerError, "error sending a grpc is provider allowed request", err) return } if providerAllowedResp.Status.Code != rpc.Code_CODE_OK { - WriteError(w, r, APIErrorUnauthenticated, "provider not authorized", errors.New(providerAllowedResp.Status.Message)) + reqres.WriteError(w, r, reqres.APIErrorUnauthenticated, "provider not authorized", errors.New(providerAllowedResp.Status.Message)) return } @@ -147,11 +139,11 @@ func (h *sharesHandler) createShare(w http.ResponseWriter, r *http.Request) { UserId: &userpb.UserId{OpaqueId: shareWithParts[0]}, SkipFetchingUserGroups: true, }) if err != nil { - WriteError(w, r, APIErrorServerError, "error searching recipient", err) + reqres.WriteError(w, r, reqres.APIErrorServerError, "error searching recipient", err) return } if userRes.Status.Code != rpc.Code_CODE_OK { - WriteError(w, r, APIErrorNotFound, "user not found", errors.New(userRes.Status.Message)) + reqres.WriteError(w, r, reqres.APIErrorNotFound, "user not found", errors.New(userRes.Status.Message)) return } @@ -159,7 +151,7 @@ func (h *sharesHandler) createShare(w http.ResponseWriter, r *http.Request) { var token string options, ok := protocol["options"].(map[string]interface{}) if !ok { - WriteError(w, r, APIErrorInvalidParameter, "protocol: webdav token not provided", nil) + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "protocol: webdav token not provided", nil) return } @@ -167,7 +159,7 @@ func (h *sharesHandler) createShare(w http.ResponseWriter, r *http.Request) { if !ok { token, ok = options["token"].(string) if !ok { - WriteError(w, r, APIErrorInvalidParameter, "protocol: webdav token not provided", nil) + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "protocol: webdav token not provided", nil) return } } @@ -178,7 +170,7 @@ func (h *sharesHandler) createShare(w http.ResponseWriter, r *http.Request) { } else { permissions, err = conversions.NewPermissions(pval) if err != nil { - WriteError(w, r, APIErrorInvalidParameter, err.Error(), nil) + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, err.Error(), nil) return } role = conversions.RoleFromOCSPermissions(permissions) @@ -186,13 +178,13 @@ func (h *sharesHandler) createShare(w http.ResponseWriter, r *http.Request) { val, err := json.Marshal(role.CS3ResourcePermissions()) if err != nil { - WriteError(w, r, APIErrorServerError, "could not encode role", nil) + reqres.WriteError(w, r, reqres.APIErrorServerError, "could not encode role", nil) return } ownerParts := strings.Split(owner, "@") if len(ownerParts) != 2 { - WriteError(w, r, APIErrorInvalidParameter, "owner should be opaqueId@webDAVHost", nil) + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "owner should be opaqueId@webDAVHost", nil) } ownerID := &userpb.UserId{ OpaqueId: ownerParts[0], @@ -222,15 +214,15 @@ func (h *sharesHandler) createShare(w http.ResponseWriter, r *http.Request) { } createShareResponse, err := gatewayClient.CreateOCMCoreShare(ctx, createShareReq) if err != nil { - WriteError(w, r, APIErrorServerError, "error sending a grpc create ocm core share request", err) + reqres.WriteError(w, r, reqres.APIErrorServerError, "error sending a grpc create ocm core share request", err) return } if createShareResponse.Status.Code != rpc.Code_CODE_OK { if createShareResponse.Status.Code == rpc.Code_CODE_NOT_FOUND { - WriteError(w, r, APIErrorNotFound, "not found", nil) + reqres.WriteError(w, r, reqres.APIErrorNotFound, "not found", nil) return } - WriteError(w, r, APIErrorServerError, "grpc create ocm core share request failed", errors.New(createShareResponse.Status.Message)) + reqres.WriteError(w, r, reqres.APIErrorServerError, "grpc create ocm core share request failed", errors.New(createShareResponse.Status.Message)) return } @@ -242,7 +234,7 @@ func (h *sharesHandler) createShare(w http.ResponseWriter, r *http.Request) { }, ) if err != nil { - WriteError(w, r, APIErrorServerError, "error marshalling share data", err) + reqres.WriteError(w, r, reqres.APIErrorServerError, "error marshalling share data", err) return } @@ -251,7 +243,7 @@ func (h *sharesHandler) createShare(w http.ResponseWriter, r *http.Request) { _, err = w.Write(jsonOut) if err != nil { - WriteError(w, r, APIErrorServerError, "error writing shares data", err) + reqres.WriteError(w, r, reqres.APIErrorServerError, "error writing shares data", err) return } diff --git a/internal/http/services/ocmd/reqres.go b/internal/http/services/reqres/reqres.go similarity index 95% rename from internal/http/services/ocmd/reqres.go rename to internal/http/services/reqres/reqres.go index 674f58709a..d7ae131535 100644 --- a/internal/http/services/ocmd/reqres.go +++ b/internal/http/services/reqres/reqres.go @@ -16,7 +16,7 @@ // granted to it by virtue of its status as an Intergovernmental Organization // or submit itself to any jurisdiction. -package ocmd +package reqres import ( "encoding/json" @@ -36,6 +36,7 @@ const ( APIErrorUnimplemented APIErrorCode = "FUNCTION_NOT_IMPLEMENTED" APIErrorInvalidParameter APIErrorCode = "INVALID_PARAMETER" APIErrorProviderError APIErrorCode = "PROVIDER_ERROR" + APIErrorAlreadyExist APIErrorCode = "ALREADY_EXIST" APIErrorServerError APIErrorCode = "SERVER_ERROR" ) @@ -47,6 +48,7 @@ var APIErrorCodeMapping = map[APIErrorCode]int{ APIErrorUnimplemented: http.StatusNotImplemented, APIErrorInvalidParameter: http.StatusBadRequest, APIErrorProviderError: http.StatusBadGateway, + APIErrorAlreadyExist: http.StatusConflict, APIErrorServerError: http.StatusInternalServerError, } diff --git a/internal/http/services/sciencemesh/sciencemesh.go b/internal/http/services/sciencemesh/sciencemesh.go new file mode 100644 index 0000000000..261612b2f1 --- /dev/null +++ b/internal/http/services/sciencemesh/sciencemesh.go @@ -0,0 +1,114 @@ +// Copyright 2018-2023 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package sciencemesh + +import ( + "net/http" + + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/rhttp/global" + "github.com/cs3org/reva/pkg/sharedconf" + "github.com/cs3org/reva/pkg/smtpclient" + "github.com/go-chi/chi/v5" + "github.com/mitchellh/mapstructure" + "github.com/rs/zerolog" +) + +func init() { + global.Register("sciencemesh", New) +} + +// New returns a new sciencemesh service. +func New(m map[string]interface{}, log *zerolog.Logger) (global.Service, error) { + conf := &config{} + if err := mapstructure.Decode(m, conf); err != nil { + return nil, err + } + + conf.init() + + r := chi.NewRouter() + s := &svc{ + conf: conf, + router: r, + } + + if err := s.routerInit(); err != nil { + return nil, err + } + + return s, nil +} + +// Close performs cleanup. +func (s *svc) Close() error { + return nil +} + +type config struct { + Prefix string `mapstructure:"prefix"` + SMTPCredentials *smtpclient.SMTPCredentials `mapstructure:"smtp_credentials"` + GatewaySvc string `mapstructure:"gatewaysvc"` + MeshDirectoryURL string `mapstructure:"mesh_directory_url"` +} + +func (c *config) init() { + if c.Prefix == "" { + c.Prefix = "sciencemesh" + } + + c.GatewaySvc = sharedconf.GetGatewaySVC(c.GatewaySvc) +} + +type svc struct { + conf *config + router chi.Router +} + +func (s *svc) routerInit() error { + tokenHandler := new(tokenHandler) + if err := tokenHandler.init(s.conf); err != nil { + return err + } + + s.router.Get("/generate-invite", tokenHandler.Generate) + s.router.Post("/accept-invite", tokenHandler.AcceptInvite) + s.router.Get("/find-accepted-users", tokenHandler.FindAccepted) + + return nil +} + +func (s *svc) Prefix() string { + return s.conf.Prefix +} + +func (s *svc) Unprotected() []string { + return nil +} + +func (s *svc) Handler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + log := appctx.GetLogger(r.Context()) + log.Debug().Str("path", r.URL.Path).Msg("sciencemesh routing") + + // unset raw path, otherwise chi uses it to route and then fails to match percent encoded path segments + r.URL.RawPath = "" + s.router.ServeHTTP(w, r) + }) +} diff --git a/internal/http/services/sciencemesh/token.go b/internal/http/services/sciencemesh/token.go new file mode 100644 index 0000000000..15fbc51fee --- /dev/null +++ b/internal/http/services/sciencemesh/token.go @@ -0,0 +1,204 @@ +// Copyright 2018-2023 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package sciencemesh + +import ( + "encoding/json" + "errors" + "fmt" + "mime" + "net/http" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + invitepb "github.com/cs3org/go-cs3apis/cs3/ocm/invite/v1beta1" + ocmprovider "github.com/cs3org/go-cs3apis/cs3/ocm/provider/v1beta1" + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + "github.com/cs3org/reva/internal/http/services/reqres" + "github.com/cs3org/reva/pkg/appctx" + ctxpkg "github.com/cs3org/reva/pkg/ctx" + "github.com/cs3org/reva/pkg/rgrpc/todo/pool" + "github.com/cs3org/reva/pkg/smtpclient" +) + +type tokenHandler struct { + gatewayClient gateway.GatewayAPIClient + smtpCredentials *smtpclient.SMTPCredentials + meshDirectoryURL string +} + +func (h *tokenHandler) init(c *config) error { + var err error + h.gatewayClient, err = pool.GetGatewayServiceClient(pool.Endpoint(c.GatewaySvc)) + if err != nil { + return err + } + + if c.SMTPCredentials != nil { + h.smtpCredentials = smtpclient.NewSMTPCredentials(c.SMTPCredentials) + } + + h.meshDirectoryURL = c.MeshDirectoryURL + return nil +} + +// Generate generates an invitation token and if a recipient is specified, +// will send an email containing the link the user will use to accept the +// invitation. +func (h *tokenHandler) Generate(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + token, err := h.gatewayClient.GenerateInviteToken(ctx, &invitepb.GenerateInviteTokenRequest{ + Description: r.URL.Query().Get("description"), + }) + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error generating token", err) + return + } + + if r.FormValue("recipient") != "" && h.smtpCredentials != nil { + usr := ctxpkg.ContextMustGetUser(ctx) + + // TODO: the message body needs to point to the meshdirectory service + subject := fmt.Sprintf("ScienceMesh: %s wants to collaborate with you", usr.DisplayName) + body := "Hi,\n\n" + + usr.DisplayName + " (" + usr.Mail + ") wants to start sharing OCM resources with you. " + + "To accept the invite, please visit the following URL:\n" + + h.meshDirectoryURL + "?token=" + token.InviteToken.Token + "&providerDomain=" + usr.Id.Idp + "\n\n" + + "Alternatively, you can visit your mesh provider and use the following details:\n" + + "Token: " + token.InviteToken.Token + "\n" + + "ProviderDomain: " + usr.Id.Idp + "\n\n" + + "Best,\nThe ScienceMesh team" + + err = h.smtpCredentials.SendMail(r.FormValue("recipient"), subject, body) + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error sending token by mail", err) + return + } + } + + if err := json.NewEncoder(w).Encode(token.InviteToken); err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error marshalling token data", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) +} + +type acceptInviteRequest struct { + Token string `json:"token"` + ProviderDomain string `json:"providerDomain"` +} + +// AcceptInvite accepts an invitation from the user in the remote provider. +func (h *tokenHandler) AcceptInvite(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + log := appctx.GetLogger(ctx) + + req, err := getAcceptInviteRequest(r) + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "missing parameters in request", err) + return + } + + if req.Token == "" || req.ProviderDomain == "" { + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "token and providerDomain must not be null", nil) + return + } + + providerInfo, err := h.gatewayClient.GetInfoByDomain(ctx, &ocmprovider.GetInfoByDomainRequest{ + Domain: req.ProviderDomain, + }) + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error sending a grpc get invite by domain info request", err) + return + } + if providerInfo.Status.Code != rpc.Code_CODE_OK { + reqres.WriteError(w, r, reqres.APIErrorServerError, "grpc forward invite request failed", errors.New(providerInfo.Status.Message)) + return + } + + forwardInviteReq := &invitepb.ForwardInviteRequest{ + InviteToken: &invitepb.InviteToken{ + Token: req.Token, + }, + OriginSystemProvider: providerInfo.ProviderInfo, + } + forwardInviteResponse, err := h.gatewayClient.ForwardInvite(ctx, forwardInviteReq) + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error sending a grpc forward invite request", err) + return + } + if forwardInviteResponse.Status.Code != rpc.Code_CODE_OK { + switch forwardInviteResponse.Status.Code { + case rpc.Code_CODE_NOT_FOUND: + reqres.WriteError(w, r, reqres.APIErrorNotFound, "token not found", nil) + return + case rpc.Code_CODE_INVALID_ARGUMENT: + reqres.WriteError(w, r, reqres.APIErrorInvalidParameter, "token has expired", nil) + return + case rpc.Code_CODE_ALREADY_EXISTS: + reqres.WriteError(w, r, reqres.APIErrorAlreadyExist, "user already known", nil) + return + case rpc.Code_CODE_PERMISSION_DENIED: + reqres.WriteError(w, r, reqres.APIErrorUnauthenticated, "remove service not trusted", nil) + return + default: + reqres.WriteError(w, r, reqres.APIErrorServerError, "unexpected error: "+forwardInviteResponse.Status.Message, errors.New(forwardInviteResponse.Status.Message)) + return + } + } + + w.WriteHeader(http.StatusOK) + + log.Info().Str("token", req.Token).Str("provider", req.ProviderDomain).Msgf("invite forwarded") +} + +func getAcceptInviteRequest(r *http.Request) (*acceptInviteRequest, error) { + var req acceptInviteRequest + contentType, _, err := mime.ParseMediaType(r.Header.Get("Content-Type")) + if err == nil && contentType == "application/json" { + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + return nil, err + } + } else { + req.Token, req.ProviderDomain = r.FormValue("token"), r.FormValue("providerDomain") + } + return &req, nil +} + +// FindAccepted returns the list of all the users that accepted the invitation +// to the authenticated user. +func (h *tokenHandler) FindAccepted(w http.ResponseWriter, r *http.Request) { + ctx := r.Context() + + res, err := h.gatewayClient.FindAcceptedUsers(ctx, &invitepb.FindAcceptedUsersRequest{}) + if err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error sending a grpc find accepted users request", err) + return + } + + if err := json.NewEncoder(w).Encode(res.AcceptedUsers); err != nil { + reqres.WriteError(w, r, reqres.APIErrorServerError, "error marshalling token data", err) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) +} diff --git a/pkg/ocm/client/client.go b/pkg/ocm/client/client.go new file mode 100644 index 0000000000..fa6214a835 --- /dev/null +++ b/pkg/ocm/client/client.go @@ -0,0 +1,149 @@ +// Copyright 2018-2023 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package client + +import ( + "bytes" + "context" + "encoding/json" + "io" + "net/http" + "net/url" + "time" + + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/rhttp" + "github.com/pkg/errors" +) + +// ErrTokenInvalid is the error returned by the invite-accepted +// endpoint when the token is not valid. +var ErrTokenInvalid = errors.New("the invitation token is invalid") + +// ErrServiceNotTrusted is the error returned by the invite-accepted +// endpoint when the service is not trusted to accept invitations. +var ErrServiceNotTrusted = errors.New("service is not trusted to accept invitations") + +// ErrUserAlreadyAccepted is the error returned by the invite-accepted +// endpoint when a user is already know by the remote cloud. +var ErrUserAlreadyAccepted = errors.New("user already accepted an invitation token") + +// ErrTokenNotFound is the error returned by the invite-accepted +// endpoint when the request is done using a not existing token. +var ErrTokenNotFound = errors.New("token not found") + +// OCMClient is the client for an OCM provider. +type OCMClient struct { + client *http.Client +} + +// Config is the configuration to be used for the OCMClient. +type Config struct { + Timeout time.Duration + Insecure bool +} + +// New returns a new OCMClient. +func New(c *Config) *OCMClient { + return &OCMClient{ + client: rhttp.GetHTTPClient( + rhttp.Timeout(c.Timeout), + rhttp.Insecure(c.Insecure), + ), + } +} + +// InviteAcceptedRequest contains the parameters for accepting +// an invitation. +type InviteAcceptedRequest struct { + UserID string `json:"userID"` + Email string `json:"email"` + Name string `json:"name"` + RecipientProvider string `json:"recipientProvider"` + Token string `json:"token"` +} + +// User contains the remote user's information when accepting +// an invitation. +type User struct { + UserID string `json:"userID"` + Email string `json:"email"` + Name string `json:"name"` +} + +func (r *InviteAcceptedRequest) toJSON() (io.Reader, error) { + var b bytes.Buffer + if err := json.NewEncoder(&b).Encode(r); err != nil { + return nil, err + } + return &b, nil +} + +// InviteAccepted informs the sender that the invitation was accepted to start sharing +// https://cs3org.github.io/OCM-API/docs.html?branch=develop&repo=OCM-API&user=cs3org#/paths/~1invite-accepted/post +func (c *OCMClient) InviteAccepted(ctx context.Context, endpoint string, r *InviteAcceptedRequest) (*User, error) { + url, err := url.JoinPath(endpoint, "invite-accepted") + if err != nil { + return nil, err + } + + body, err := r.toJSON() + if err != nil { + return nil, err + } + + req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, body) + if err != nil { + return nil, errors.Wrap(err, "error creating request") + } + req.Header.Set("Content-Type", "application/json") + + resp, err := c.client.Do(req) + if err != nil { + return nil, errors.Wrap(err, "error doing request") + } + defer resp.Body.Close() + + return c.parseInviteAcceptedResponse(resp) +} + +func (c *OCMClient) parseInviteAcceptedResponse(r *http.Response) (*User, error) { + switch r.StatusCode { + case http.StatusOK: + var u User + if err := json.NewDecoder(r.Body).Decode(&u); err != nil { + return nil, errors.Wrap(err, "error decoding response body") + } + return &u, nil + case http.StatusBadRequest: + return nil, ErrTokenInvalid + case http.StatusNotFound: + return nil, ErrTokenNotFound + case http.StatusConflict: + return nil, ErrUserAlreadyAccepted + case http.StatusForbidden: + return nil, ErrServiceNotTrusted + } + + body, err := io.ReadAll(r.Body) + if err != nil { + return nil, errors.Wrap(err, "error decoding response body") + } + return nil, errtypes.InternalError(string(body)) +} diff --git a/pkg/ocm/invite/invite.go b/pkg/ocm/invite/invite.go index e4a4760c79..7dfd9ac2f7 100644 --- a/pkg/ocm/invite/invite.go +++ b/pkg/ocm/invite/invite.go @@ -20,26 +20,33 @@ package invite import ( "context" + "errors" userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" invitepb "github.com/cs3org/go-cs3apis/cs3/ocm/invite/v1beta1" - ocmprovider "github.com/cs3org/go-cs3apis/cs3/ocm/provider/v1beta1" ) -// Manager is the interface that is used to perform operations to invites. -type Manager interface { - // GenerateToken creates a new token for the user with a specified validity. - GenerateToken(ctx context.Context) (*invitepb.InviteToken, error) +// Repository is the interfaces used to store the tokens and the invited users. +type Repository interface { + // AddToken stores the token in the repository. + AddToken(ctx context.Context, token *invitepb.InviteToken) error - // ForwardInvite forwards a received invite to the sync'n'share system provider. - ForwardInvite(ctx context.Context, invite *invitepb.InviteToken, originProvider *ocmprovider.ProviderInfo) error + // GetToken gets the token from the repository. + GetToken(ctx context.Context, token string) (*invitepb.InviteToken, error) - // AcceptInvite completes an invitation acceptance. - AcceptInvite(ctx context.Context, invite *invitepb.InviteToken, remoteUser *userpb.User) error + // AddRemoteUser stores the remote user. + AddRemoteUser(ctx context.Context, initiator *userpb.UserId, remoteUser *userpb.User) error - // GetAcceptedUser retrieves details about a remote user who has accepted an invite to share. - GetAcceptedUser(ctx context.Context, remoteUserID *userpb.UserId) (*userpb.User, error) + // GetRemoteUser retrieves details about a remote user who has accepted an invite to share. + GetRemoteUser(ctx context.Context, initiator *userpb.UserId, remoteUserID *userpb.UserId) (*userpb.User, error) - // FindAcceptedUsers finds remote users who have accepted invites based on their attributes. - FindAcceptedUsers(ctx context.Context, query string) ([]*userpb.User, error) + // FindRemoteUsers finds remote users who have accepted invites based on their attributes. + FindRemoteUsers(ctx context.Context, initiator *userpb.UserId, query string) ([]*userpb.User, error) } + +// ErrTokenNotFound is the error returned when the token does not exist. +var ErrTokenNotFound = errors.New("token not found") + +// ErrUserAlreadyAccepted is the error returned when the user was +// already added to the accepted users list. +var ErrUserAlreadyAccepted = errors.New("user already added to accepted users") diff --git a/pkg/ocm/invite/manager/json/json.go b/pkg/ocm/invite/manager/json/json.go deleted file mode 100644 index 09536bacf4..0000000000 --- a/pkg/ocm/invite/manager/json/json.go +++ /dev/null @@ -1,327 +0,0 @@ -// Copyright 2018-2023 CERN -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// In applying this license, CERN does not waive the privileges and immunities -// granted to it by virtue of its status as an Intergovernmental Organization -// or submit itself to any jurisdiction. - -package json - -import ( - "context" - "encoding/json" - "fmt" - "io" - "net/http" - "net/url" - "os" - "path" - "strings" - "sync" - "time" - - userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - invitepb "github.com/cs3org/go-cs3apis/cs3/ocm/invite/v1beta1" - ocmprovider "github.com/cs3org/go-cs3apis/cs3/ocm/provider/v1beta1" - "github.com/cs3org/reva/pkg/appctx" - ctxpkg "github.com/cs3org/reva/pkg/ctx" - "github.com/cs3org/reva/pkg/errtypes" - "github.com/cs3org/reva/pkg/ocm/invite" - "github.com/cs3org/reva/pkg/ocm/invite/manager/registry" - "github.com/cs3org/reva/pkg/ocm/invite/token" - "github.com/cs3org/reva/pkg/rhttp" - "github.com/mitchellh/mapstructure" - "github.com/pkg/errors" -) - -const acceptInviteEndpoint = "invites/accept" - -type inviteModel struct { - File string - Invites map[string]*invitepb.InviteToken `json:"invites"` - AcceptedUsers map[string][]*userpb.User `json:"accepted_users"` -} - -type manager struct { - config *config - sync.Mutex // concurrent access to the file - model *inviteModel - client *http.Client -} - -type config struct { - File string `mapstructure:"file"` - Expiration string `mapstructure:"expiration"` - InsecureConnections bool `mapstructure:"insecure_connections"` -} - -func init() { - registry.Register("json", New) -} - -func (c *config) init() error { - if c.File == "" { - c.File = "/var/tmp/reva/ocm-invites.json" - } - - if c.Expiration == "" { - c.Expiration = token.DefaultExpirationTime - } - return nil -} - -// New returns a new invite manager object. -func New(m map[string]interface{}) (invite.Manager, error) { - config, err := parseConfig(m) - if err != nil { - err = errors.Wrap(err, "error parsing config for json invite manager") - return nil, err - } - err = config.init() - if err != nil { - err = errors.Wrap(err, "error setting config defaults for json invite manager") - return nil, err - } - - // load or create file - model, err := loadOrCreate(config.File) - if err != nil { - err = errors.Wrap(err, "error loading the file containing the invites") - return nil, err - } - - manager := &manager{ - config: config, - model: model, - client: rhttp.GetHTTPClient( - rhttp.Timeout(5*time.Second), - rhttp.Insecure(config.InsecureConnections), - ), - } - - return manager, nil -} - -func parseConfig(m map[string]interface{}) (*config, error) { - c := &config{} - if err := mapstructure.Decode(m, c); err != nil { - return nil, err - } - return c, nil -} - -func loadOrCreate(file string) (*inviteModel, error) { - _, err := os.Stat(file) - if os.IsNotExist(err) { - if err := os.WriteFile(file, []byte("{}"), 0700); err != nil { - err = errors.Wrap(err, "error creating the invite storage file: "+file) - return nil, err - } - } - - fd, err := os.OpenFile(file, os.O_CREATE, 0644) - if err != nil { - err = errors.Wrap(err, "error opening the invite storage file: "+file) - return nil, err - } - defer fd.Close() - - data, err := io.ReadAll(fd) - if err != nil { - err = errors.Wrap(err, "error reading the data") - return nil, err - } - - model := &inviteModel{} - if err := json.Unmarshal(data, model); err != nil { - err = errors.Wrap(err, "error decoding invite data to json") - return nil, err - } - - if model.Invites == nil { - model.Invites = make(map[string]*invitepb.InviteToken) - } - if model.AcceptedUsers == nil { - model.AcceptedUsers = make(map[string][]*userpb.User) - } - - model.File = file - return model, nil -} - -func (model *inviteModel) Save() error { - data, err := json.Marshal(model) - if err != nil { - err = errors.Wrap(err, "error encoding invite data to json") - return err - } - - if err := os.WriteFile(model.File, data, 0644); err != nil { - err = errors.Wrap(err, "error writing invite data to file: "+model.File) - return err - } - - return nil -} - -func (m *manager) GenerateToken(ctx context.Context) (*invitepb.InviteToken, error) { - contexUser := ctxpkg.ContextMustGetUser(ctx) - inviteToken, err := token.CreateToken(m.config.Expiration, contexUser.GetId()) - if err != nil { - return nil, err - } - - // Store token data - m.Lock() - defer m.Unlock() - - m.model.Invites[inviteToken.GetToken()] = inviteToken - if err := m.model.Save(); err != nil { - err = errors.Wrap(err, "error saving model") - return nil, err - } - - return inviteToken, nil -} - -func (m *manager) ForwardInvite(ctx context.Context, invite *invitepb.InviteToken, originProvider *ocmprovider.ProviderInfo) error { - contextUser := ctxpkg.ContextMustGetUser(ctx) - recipientProvider := contextUser.GetId().GetIdp() - - requestBody := url.Values{ - "token": {invite.GetToken()}, - "userID": {contextUser.GetId().GetOpaqueId()}, - "recipientProvider": {recipientProvider}, - "email": {contextUser.GetMail()}, - "name": {contextUser.GetDisplayName()}, - } - - ocmEndpoint, err := getOCMEndpoint(originProvider) - if err != nil { - return err - } - u, err := url.Parse(ocmEndpoint) - if err != nil { - return err - } - u.Path = path.Join(u.Path, acceptInviteEndpoint) - recipientURL := u.String() - - req, err := http.NewRequest(http.MethodPost, recipientURL, strings.NewReader(requestBody.Encode())) - if err != nil { - return errors.Wrap(err, "json: error framing post request") - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value") - - resp, err := m.client.Do(req) - if err != nil { - err = errors.Wrap(err, "json: error sending post request") - return err - } - - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - respBody, e := io.ReadAll(resp.Body) - if e != nil { - return errors.Wrap(e, "json: error reading request body") - } - return errors.Wrap(fmt.Errorf("%s: %s", resp.Status, string(respBody)), fmt.Sprintf("json: error sending accept post request to %s", recipientURL)) - } - - return nil -} - -func (m *manager) AcceptInvite(ctx context.Context, invite *invitepb.InviteToken, remoteUser *userpb.User) error { - m.Lock() - defer m.Unlock() - - inviteToken, err := m.getTokenIfValid(invite) - if err != nil { - return err - } - - currUser := inviteToken.GetUserId() - - // do not allow the user who created the token to accept it - if remoteUser.Id.Idp == currUser.Idp && remoteUser.Id.OpaqueId == currUser.OpaqueId { - return errors.New("json: token creator and recipient are the same") - } - - for _, acceptedUser := range m.model.AcceptedUsers[currUser.GetOpaqueId()] { - if acceptedUser.Id.GetOpaqueId() == remoteUser.Id.OpaqueId && acceptedUser.Id.GetIdp() == remoteUser.Id.Idp { - return errors.New("json: user already added to accepted users") - } - } - m.model.AcceptedUsers[currUser.GetOpaqueId()] = append(m.model.AcceptedUsers[currUser.GetOpaqueId()], remoteUser) - if err := m.model.Save(); err != nil { - err = errors.Wrap(err, "json: error saving model") - return err - } - return nil -} - -func (m *manager) GetAcceptedUser(ctx context.Context, remoteUserID *userpb.UserId) (*userpb.User, error) { - userKey := ctxpkg.ContextMustGetUser(ctx).GetId().GetOpaqueId() - log := appctx.GetLogger(ctx) - for _, acceptedUser := range m.model.AcceptedUsers[userKey] { - log.Info().Msgf("looking for '%s' at '%s' - considering '%s' at '%s'", - remoteUserID.OpaqueId, - remoteUserID.Idp, - acceptedUser.Id.GetOpaqueId(), - acceptedUser.Id.GetIdp(), - ) - if (acceptedUser.Id.GetOpaqueId() == remoteUserID.OpaqueId) && (remoteUserID.Idp == "" || acceptedUser.Id.GetIdp() == remoteUserID.Idp) { - return acceptedUser, nil - } - } - return nil, errtypes.NotFound(remoteUserID.OpaqueId) -} - -func (m *manager) FindAcceptedUsers(ctx context.Context, query string) ([]*userpb.User, error) { - users := []*userpb.User{} - userKey := ctxpkg.ContextMustGetUser(ctx).GetId().GetOpaqueId() - for _, acceptedUser := range m.model.AcceptedUsers[userKey] { - if query == "" || userContains(acceptedUser, query) { - users = append(users, acceptedUser) - } - } - return users, nil -} - -func userContains(u *userpb.User, query string) bool { - query = strings.ToLower(query) - return strings.Contains(strings.ToLower(u.Username), query) || strings.Contains(strings.ToLower(u.DisplayName), query) || - strings.Contains(strings.ToLower(u.Mail), query) || strings.Contains(strings.ToLower(u.Id.OpaqueId), query) -} - -func (m *manager) getTokenIfValid(token *invitepb.InviteToken) (*invitepb.InviteToken, error) { - inviteToken, ok := m.model.Invites[token.GetToken()] - if !ok { - return nil, errors.New("json: invalid token") - } - - if uint64(time.Now().Unix()) > inviteToken.Expiration.Seconds { - return nil, errors.New("json: token expired") - } - return inviteToken, nil -} - -func getOCMEndpoint(originProvider *ocmprovider.ProviderInfo) (string, error) { - for _, s := range originProvider.Services { - if s.Endpoint.Type.Name == "OCM" { - return s.Endpoint.Path, nil - } - } - return "", errors.New("json: ocm endpoint not specified for mesh provider") -} diff --git a/pkg/ocm/invite/manager/memory/memory.go b/pkg/ocm/invite/manager/memory/memory.go deleted file mode 100644 index baddcdfd4a..0000000000 --- a/pkg/ocm/invite/manager/memory/memory.go +++ /dev/null @@ -1,230 +0,0 @@ -// Copyright 2018-2023 CERN -// -// Licensed under the Apache License, Version 2.0 (the "License"); -// you may not use this file except in compliance with the License. -// You may obtain a copy of the License at -// -// http://www.apache.org/licenses/LICENSE-2.0 -// -// Unless required by applicable law or agreed to in writing, software -// distributed under the License is distributed on an "AS IS" BASIS, -// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. -// See the License for the specific language governing permissions and -// limitations under the License. -// -// In applying this license, CERN does not waive the privileges and immunities -// granted to it by virtue of its status as an Intergovernmental Organization -// or submit itself to any jurisdiction. - -package memory - -import ( - "context" - "net/http" - "net/url" - "path" - "strings" - "sync" - "time" - - userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" - invitepb "github.com/cs3org/go-cs3apis/cs3/ocm/invite/v1beta1" - ocmprovider "github.com/cs3org/go-cs3apis/cs3/ocm/provider/v1beta1" - ctxpkg "github.com/cs3org/reva/pkg/ctx" - "github.com/cs3org/reva/pkg/errtypes" - "github.com/cs3org/reva/pkg/ocm/invite" - "github.com/cs3org/reva/pkg/ocm/invite/manager/registry" - "github.com/cs3org/reva/pkg/ocm/invite/token" - "github.com/cs3org/reva/pkg/rhttp" - "github.com/mitchellh/mapstructure" - "github.com/pkg/errors" -) - -const acceptInviteEndpoint = "invites/accept" - -func init() { - registry.Register("memory", New) -} - -func (c *config) init() { - if c.Expiration == "" { - c.Expiration = token.DefaultExpirationTime - } -} - -// New returns a new invite manager. -func New(m map[string]interface{}) (invite.Manager, error) { - c := &config{} - if err := mapstructure.Decode(m, c); err != nil { - err = errors.Wrap(err, "error creating a new manager") - return nil, err - } - c.init() - - return &manager{ - Invites: sync.Map{}, - AcceptedUsers: sync.Map{}, - Config: c, - Client: rhttp.GetHTTPClient( - rhttp.Timeout(5*time.Second), - rhttp.Insecure(c.InsecureConnections), - ), - }, nil -} - -type manager struct { - Invites sync.Map - AcceptedUsers sync.Map - Client *http.Client - Config *config -} - -type config struct { - Expiration string `mapstructure:"expiration"` - InsecureConnections bool `mapstructure:"insecure_connections"` -} - -func (m *manager) GenerateToken(ctx context.Context) (*invitepb.InviteToken, error) { - ctxUser := ctxpkg.ContextMustGetUser(ctx) - inviteToken, err := token.CreateToken(m.Config.Expiration, ctxUser.GetId()) - if err != nil { - return nil, errors.Wrap(err, "memory: error creating token") - } - - m.Invites.Store(inviteToken.GetToken(), inviteToken) - return inviteToken, nil -} - -func (m *manager) ForwardInvite(ctx context.Context, invite *invitepb.InviteToken, originProvider *ocmprovider.ProviderInfo) error { - contextUser := ctxpkg.ContextMustGetUser(ctx) - requestBody := url.Values{ - "token": {invite.GetToken()}, - "userID": {contextUser.GetId().GetOpaqueId()}, - "recipientProvider": {contextUser.GetId().GetIdp()}, - "email": {contextUser.GetMail()}, - "name": {contextUser.GetDisplayName()}, - } - - ocmEndpoint, err := getOCMEndpoint(originProvider) - if err != nil { - return err - } - u, err := url.Parse(ocmEndpoint) - if err != nil { - return err - } - u.Path = path.Join(u.Path, acceptInviteEndpoint) - recipientURL := u.String() - - req, err := http.NewRequest(http.MethodPost, recipientURL, strings.NewReader(requestBody.Encode())) - if err != nil { - return errors.Wrap(err, "json: error framing post request") - } - req.Header.Set("Content-Type", "application/x-www-form-urlencoded; param=value") - - resp, err := m.Client.Do(req) - if err != nil { - err = errors.Wrap(err, "memory: error sending post request") - return err - } - - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - err = errors.Wrap(errors.New(resp.Status), "memory: error sending accept post request") - return err - } - - return nil -} - -func (m *manager) AcceptInvite(ctx context.Context, invite *invitepb.InviteToken, remoteUser *userpb.User) error { - inviteToken, err := m.getTokenIfValid(invite) - if err != nil { - return err - } - - currUser := inviteToken.GetUserId() - - // do not allow the user who created the token to accept it - if remoteUser.Id.Idp == currUser.Idp && remoteUser.Id.OpaqueId == currUser.OpaqueId { - return errors.New("memory: token creator and recipient are the same") - } - - usersList, ok := m.AcceptedUsers.Load(currUser) - acceptedUsers := usersList.([]*userpb.User) - if ok { - for _, acceptedUser := range acceptedUsers { - if acceptedUser.Id.GetOpaqueId() == remoteUser.Id.OpaqueId && acceptedUser.Id.GetIdp() == remoteUser.Id.Idp { - return errors.New("memory: user already added to accepted users") - } - } - - acceptedUsers = append(acceptedUsers, remoteUser) - m.AcceptedUsers.Store(currUser.GetOpaqueId(), acceptedUsers) - } else { - acceptedUsers := []*userpb.User{remoteUser} - m.AcceptedUsers.Store(currUser.GetOpaqueId(), acceptedUsers) - } - return nil -} - -func (m *manager) GetAcceptedUser(ctx context.Context, remoteUserID *userpb.UserId) (*userpb.User, error) { - currUser := ctxpkg.ContextMustGetUser(ctx).GetId().GetOpaqueId() - usersList, ok := m.AcceptedUsers.Load(currUser) - if !ok { - return nil, errtypes.NotFound(remoteUserID.OpaqueId) - } - - acceptedUsers := usersList.([]*userpb.User) - for _, acceptedUser := range acceptedUsers { - if (acceptedUser.Id.GetOpaqueId() == remoteUserID.OpaqueId) && (remoteUserID.Idp == "" || acceptedUser.Id.GetIdp() == remoteUserID.Idp) { - return acceptedUser, nil - } - } - return nil, errtypes.NotFound(remoteUserID.OpaqueId) -} - -func (m *manager) FindAcceptedUsers(ctx context.Context, query string) ([]*userpb.User, error) { - currUser := ctxpkg.ContextMustGetUser(ctx).GetId().GetOpaqueId() - usersList, ok := m.AcceptedUsers.Load(currUser) - if !ok { - return []*userpb.User{}, nil - } - - users := []*userpb.User{} - acceptedUsers := usersList.([]*userpb.User) - for _, acceptedUser := range acceptedUsers { - if query == "" || userContains(acceptedUser, query) { - users = append(users, acceptedUser) - } - } - return users, nil -} - -func userContains(u *userpb.User, query string) bool { - query = strings.ToLower(query) - return strings.Contains(strings.ToLower(u.Username), query) || strings.Contains(strings.ToLower(u.DisplayName), query) || - strings.Contains(strings.ToLower(u.Mail), query) || strings.Contains(strings.ToLower(u.Id.OpaqueId), query) -} - -func (m *manager) getTokenIfValid(token *invitepb.InviteToken) (*invitepb.InviteToken, error) { - tokenInterface, ok := m.Invites.Load(token.GetToken()) - if !ok { - return nil, errors.New("memory: invalid token") - } - - inviteToken := tokenInterface.(*invitepb.InviteToken) - if uint64(time.Now().Unix()) > inviteToken.Expiration.Seconds { - return nil, errors.New("memory: token expired") - } - return inviteToken, nil -} - -func getOCMEndpoint(originProvider *ocmprovider.ProviderInfo) (string, error) { - for _, s := range originProvider.Services { - if s.Endpoint.Type.Name == "OCM" { - return s.Endpoint.Path, nil - } - } - return "", errors.New("json: ocm endpoint not specified for mesh provider") -} diff --git a/pkg/ocm/invite/repository/json/json.go b/pkg/ocm/invite/repository/json/json.go new file mode 100644 index 0000000000..5b8ec6e37d --- /dev/null +++ b/pkg/ocm/invite/repository/json/json.go @@ -0,0 +1,222 @@ +// Copyright 2018-2023 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package json + +import ( + "context" + "encoding/json" + "io" + "os" + "strings" + "sync" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + invitepb "github.com/cs3org/go-cs3apis/cs3/ocm/invite/v1beta1" + "github.com/cs3org/reva/pkg/appctx" + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/ocm/invite" + "github.com/cs3org/reva/pkg/ocm/invite/repository/registry" + "github.com/mitchellh/mapstructure" + "github.com/pkg/errors" +) + +type inviteModel struct { + File string + Invites map[string]*invitepb.InviteToken `json:"invites"` + AcceptedUsers map[string][]*userpb.User `json:"accepted_users"` +} + +type manager struct { + config *config + sync.RWMutex // concurrent access to the file + model *inviteModel +} + +type config struct { + File string `mapstructure:"file"` +} + +func init() { + registry.Register("json", New) +} + +func (c *config) init() error { + if c.File == "" { + c.File = "/var/tmp/reva/ocm-invites.json" + } + + return nil +} + +// New returns a new invite manager object. +func New(m map[string]interface{}) (invite.Repository, error) { + config, err := parseConfig(m) + if err != nil { + return nil, errors.Wrap(err, "error parsing config for json invite repository") + } + err = config.init() + if err != nil { + return nil, errors.Wrap(err, "error setting config defaults for json invite repository") + } + + // load or create file + model, err := loadOrCreate(config.File) + if err != nil { + return nil, errors.Wrap(err, "error loading the file containing the invites") + } + + manager := &manager{ + config: config, + model: model, + } + + return manager, nil +} + +func parseConfig(m map[string]interface{}) (*config, error) { + c := &config{} + if err := mapstructure.Decode(m, c); err != nil { + return nil, err + } + return c, nil +} + +func loadOrCreate(file string) (*inviteModel, error) { + _, err := os.Stat(file) + if os.IsNotExist(err) { + if err := os.WriteFile(file, []byte("{}"), 0700); err != nil { + return nil, errors.Wrap(err, "error creating the invite storage file: "+file) + } + } + + fd, err := os.OpenFile(file, os.O_CREATE, 0644) + if err != nil { + return nil, errors.Wrap(err, "error opening the invite storage file: "+file) + } + defer fd.Close() + + data, err := io.ReadAll(fd) + if err != nil { + return nil, errors.Wrap(err, "error reading the data") + } + + model := &inviteModel{} + if err := json.Unmarshal(data, model); err != nil { + return nil, errors.Wrap(err, "error decoding invite data to json") + } + + if model.Invites == nil { + model.Invites = make(map[string]*invitepb.InviteToken) + } + if model.AcceptedUsers == nil { + model.AcceptedUsers = make(map[string][]*userpb.User) + } + + model.File = file + return model, nil +} + +func (model *inviteModel) save() error { + data, err := json.Marshal(model) + if err != nil { + return errors.Wrap(err, "error encoding invite data to json") + } + + if err := os.WriteFile(model.File, data, 0644); err != nil { + return errors.Wrap(err, "error writing invite data to file: "+model.File) + } + + return nil +} + +func (m *manager) AddToken(ctx context.Context, token *invitepb.InviteToken) error { + m.Lock() + defer m.Unlock() + + m.model.Invites[token.GetToken()] = token + if err := m.model.save(); err != nil { + return errors.Wrap(err, "json: error saving model") + } + return nil +} + +func (m *manager) GetToken(ctx context.Context, token string) (*invitepb.InviteToken, error) { + m.RLock() + defer m.RUnlock() + + if tkn, ok := m.model.Invites[token]; ok { + return tkn, nil + } + return nil, invite.ErrTokenNotFound +} + +func (m *manager) AddRemoteUser(ctx context.Context, initiator *userpb.UserId, remoteUser *userpb.User) error { + m.Lock() + defer m.Unlock() + + for _, acceptedUser := range m.model.AcceptedUsers[initiator.GetOpaqueId()] { + if acceptedUser.Id.GetOpaqueId() == remoteUser.Id.OpaqueId && acceptedUser.Id.GetIdp() == remoteUser.Id.Idp { + return invite.ErrUserAlreadyAccepted + } + } + + m.model.AcceptedUsers[initiator.GetOpaqueId()] = append(m.model.AcceptedUsers[initiator.GetOpaqueId()], remoteUser) + if err := m.model.save(); err != nil { + return errors.Wrap(err, "json: error saving model") + } + return nil +} + +func (m *manager) GetRemoteUser(ctx context.Context, initiator *userpb.UserId, remoteUserID *userpb.UserId) (*userpb.User, error) { + m.RLock() + defer m.RUnlock() + + log := appctx.GetLogger(ctx) + for _, acceptedUser := range m.model.AcceptedUsers[initiator.GetOpaqueId()] { + log.Info().Msgf("looking for '%s' at '%s' - considering '%s' at '%s'", + remoteUserID.OpaqueId, + remoteUserID.Idp, + acceptedUser.Id.GetOpaqueId(), + acceptedUser.Id.GetIdp(), + ) + if (acceptedUser.Id.GetOpaqueId() == remoteUserID.OpaqueId) && (remoteUserID.Idp == "" || acceptedUser.Id.GetIdp() == remoteUserID.Idp) { + return acceptedUser, nil + } + } + return nil, errtypes.NotFound(remoteUserID.OpaqueId) +} + +func (m *manager) FindRemoteUsers(ctx context.Context, initiator *userpb.UserId, query string) ([]*userpb.User, error) { + m.RLock() + defer m.RUnlock() + + users := []*userpb.User{} + for _, acceptedUser := range m.model.AcceptedUsers[initiator.GetOpaqueId()] { + if query == "" || userContains(acceptedUser, query) { + users = append(users, acceptedUser) + } + } + return users, nil +} + +func userContains(u *userpb.User, query string) bool { + query = strings.ToLower(query) + return strings.Contains(strings.ToLower(u.Username), query) || strings.Contains(strings.ToLower(u.DisplayName), query) || + strings.Contains(strings.ToLower(u.Mail), query) || strings.Contains(strings.ToLower(u.Id.OpaqueId), query) +} diff --git a/pkg/ocm/invite/manager/loader/loader.go b/pkg/ocm/invite/repository/loader/loader.go similarity index 87% rename from pkg/ocm/invite/manager/loader/loader.go rename to pkg/ocm/invite/repository/loader/loader.go index feea812c8f..96e3316074 100644 --- a/pkg/ocm/invite/manager/loader/loader.go +++ b/pkg/ocm/invite/repository/loader/loader.go @@ -20,7 +20,7 @@ package loader import ( // Load core share manager drivers. - _ "github.com/cs3org/reva/pkg/ocm/invite/manager/json" - _ "github.com/cs3org/reva/pkg/ocm/invite/manager/memory" + _ "github.com/cs3org/reva/pkg/ocm/invite/repository/json" + _ "github.com/cs3org/reva/pkg/ocm/invite/repository/memory" // Add your own here. ) diff --git a/pkg/ocm/invite/repository/memory/memory.go b/pkg/ocm/invite/repository/memory/memory.go new file mode 100644 index 0000000000..c86da017b9 --- /dev/null +++ b/pkg/ocm/invite/repository/memory/memory.go @@ -0,0 +1,116 @@ +// Copyright 2018-2023 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package memory + +import ( + "context" + "strings" + "sync" + + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + invitepb "github.com/cs3org/go-cs3apis/cs3/ocm/invite/v1beta1" + "github.com/cs3org/reva/pkg/errtypes" + "github.com/cs3org/reva/pkg/ocm/invite" + "github.com/cs3org/reva/pkg/ocm/invite/repository/registry" +) + +func init() { + registry.Register("memory", New) +} + +// New returns a new invite manager. +func New(m map[string]interface{}) (invite.Repository, error) { + return &manager{ + Invites: sync.Map{}, + AcceptedUsers: sync.Map{}, + }, nil +} + +type manager struct { + Invites sync.Map + AcceptedUsers sync.Map +} + +func (m *manager) AddToken(ctx context.Context, token *invitepb.InviteToken) error { + m.Invites.Store(token.GetToken(), token) + return nil +} + +func (m *manager) GetToken(ctx context.Context, token string) (*invitepb.InviteToken, error) { + if v, ok := m.Invites.Load(token); ok { + return v.(*invitepb.InviteToken), nil + } + return nil, invite.ErrTokenNotFound +} + +func (m *manager) AddRemoteUser(ctx context.Context, initiator *userpb.UserId, remoteUser *userpb.User) error { + usersList, ok := m.AcceptedUsers.Load(initiator) + acceptedUsers := usersList.([]*userpb.User) + if ok { + for _, acceptedUser := range acceptedUsers { + if acceptedUser.Id.GetOpaqueId() == remoteUser.Id.OpaqueId && acceptedUser.Id.GetIdp() == remoteUser.Id.Idp { + return invite.ErrUserAlreadyAccepted + } + } + + acceptedUsers = append(acceptedUsers, remoteUser) + m.AcceptedUsers.Store(initiator.GetOpaqueId(), acceptedUsers) + } else { + acceptedUsers := []*userpb.User{remoteUser} + m.AcceptedUsers.Store(initiator.GetOpaqueId(), acceptedUsers) + } + return nil +} + +func (m *manager) GetRemoteUser(ctx context.Context, initiator *userpb.UserId, remoteUserID *userpb.UserId) (*userpb.User, error) { + usersList, ok := m.AcceptedUsers.Load(initiator) + if !ok { + return nil, errtypes.NotFound(remoteUserID.OpaqueId) + } + + acceptedUsers := usersList.([]*userpb.User) + for _, acceptedUser := range acceptedUsers { + if (acceptedUser.Id.GetOpaqueId() == remoteUserID.OpaqueId) && (remoteUserID.Idp == "" || acceptedUser.Id.GetIdp() == remoteUserID.Idp) { + return acceptedUser, nil + } + } + return nil, errtypes.NotFound(remoteUserID.OpaqueId) +} + +func (m *manager) FindRemoteUsers(ctx context.Context, initiator *userpb.UserId, query string) ([]*userpb.User, error) { + usersList, ok := m.AcceptedUsers.Load(initiator) + if !ok { + return []*userpb.User{}, nil + } + + users := []*userpb.User{} + acceptedUsers := usersList.([]*userpb.User) + for _, acceptedUser := range acceptedUsers { + if query == "" || userContains(acceptedUser, query) { + users = append(users, acceptedUser) + } + } + return users, nil +} + +func userContains(u *userpb.User, query string) bool { + query = strings.ToLower(query) + return strings.Contains(strings.ToLower(u.Username), query) || strings.Contains(strings.ToLower(u.DisplayName), query) || + strings.Contains(strings.ToLower(u.Mail), query) || strings.Contains(strings.ToLower(u.Id.OpaqueId), query) +} diff --git a/pkg/ocm/invite/manager/registry/registry.go b/pkg/ocm/invite/repository/registry/registry.go similarity index 80% rename from pkg/ocm/invite/manager/registry/registry.go rename to pkg/ocm/invite/repository/registry/registry.go index abaaefcc15..a4f49d7583 100644 --- a/pkg/ocm/invite/manager/registry/registry.go +++ b/pkg/ocm/invite/repository/registry/registry.go @@ -20,14 +20,14 @@ package registry import "github.com/cs3org/reva/pkg/ocm/invite" -// NewFunc is the function that invite managers +// NewFunc is the function that invite repositories // should register at init time. -type NewFunc func(map[string]interface{}) (invite.Manager, error) +type NewFunc func(map[string]interface{}) (invite.Repository, error) -// NewFuncs is a map containing all the registered invite managers. +// NewFuncs is a map containing all the registered invite repositories. var NewFuncs = map[string]NewFunc{} -// Register registers a new invite manager new function. +// Register registers a new invite repository new function. // Not safe for concurrent use. Safe for use from package init. func Register(name string, f NewFunc) { NewFuncs[name] = f diff --git a/tests/helpers/helpers.go b/tests/helpers/helpers.go index 0899b0ecf2..6e067a2406 100644 --- a/tests/helpers/helpers.go +++ b/tests/helpers/helpers.go @@ -21,6 +21,7 @@ package helpers import ( "bytes" "context" + "encoding/json" "errors" "io" "os" @@ -54,6 +55,37 @@ func TempDir(name string) (string, error) { return tmpRoot, nil } +// TempFile creates a temporary file returning its path. +// The file is filled with the provider r if not nil. +func TempFile(r io.Reader) (string, error) { + dir, err := TempDir("") + if err != nil { + return "", err + } + f, err := os.CreateTemp(dir, "*") + if err != nil { + return "", err + } + defer f.Close() + + if r != nil { + if _, err := io.Copy(f, r); err != nil { + return "", err + } + } + return f.Name(), nil +} + +// TempJSONFile creates a temporary file returning its path. +// The file is filled with the object encoded in json. +func TempJSONFile(c any) (string, error) { + data, err := json.Marshal(c) + if err != nil { + return "", err + } + return TempFile(bytes.NewBuffer(data)) +} + // Upload can be used to initiate an upload and do the upload to a storage.FS in one step. func Upload(ctx context.Context, fs storage.FS, ref *provider.Reference, content []byte) error { uploadIds, err := fs.InitiateUpload(ctx, ref, 0, map[string]string{}) diff --git a/tests/integration/grpc/fixtures/ocm-providers.demo.json b/tests/integration/grpc/fixtures/ocm-providers.demo.json new file mode 100644 index 0000000000..2a95dc8ad2 --- /dev/null +++ b/tests/integration/grpc/fixtures/ocm-providers.demo.json @@ -0,0 +1,48 @@ +[ + { + "name": "cernbox", + "full_name": "CERNBox", + "organization": "CERN", + "domain": "cernbox.cern.ch", + "homepage": "https://cernbox.web.cern.ch", + "description": "CERNBox provides cloud data storage to all CERN users.", + "services": [ + { + "endpoint": { + "type": { + "name": "OCM", + "description": "CERNBox Open Cloud Mesh API" + }, + "name": "CERNBox - OCM API", + "path": "http://{{cernboxhttp_address}}/ocm/", + "is_monitored": true + }, + "api_version": "0.0.1", + "host": "{{cernboxhttp_address}}" + } + ] + }, + { + "name": "oc-cesnet", + "full_name": "ownCloud@CESNET", + "organization": "CESNET", + "domain": "cesnet.cz", + "homepage": "https://owncloud.cesnet.cz", + "description": "OwnCloud has been designed for individual users.", + "services": [ + { + "endpoint": { + "type": { + "name": "OCM", + "description": "CESNET Open Cloud Mesh API" + }, + "name": "CESNET - OCM API", + "path": "http://{{cesnethttp_address}}/ocm/", + "is_monitored": true + }, + "api_version": "0.0.1", + "host": "{{cesnethttp_address}}" + } + ] + } +] \ No newline at end of file diff --git a/tests/integration/grpc/fixtures/ocm-server-cernbox-grpc.toml b/tests/integration/grpc/fixtures/ocm-server-cernbox-grpc.toml new file mode 100644 index 0000000000..fdf35e50fb --- /dev/null +++ b/tests/integration/grpc/fixtures/ocm-server-cernbox-grpc.toml @@ -0,0 +1,41 @@ +[shared] +gatewaysvc = "{{grpc_address}}" + +[grpc] +address = "{{grpc_address}}" + +[grpc.services.gateway] +authregistrysvc = "{{grpc_address}}" +userprovidersvc = "{{grpc_address}}" +ocminvitemanagersvc = "{{grpc_address}}" +ocmproviderauthorizersvc = "{{grpc_address}}" + +[grpc.services.authregistry] +driver = "static" + +[grpc.services.authregistry.drivers.static.rules] +basic = "{{grpc_address}}" + +[grpc.services.ocminvitemanager] +driver = "json" + +[grpc.services.ocminvitemanager.drivers.json] +file = "{{invite_token_file}}" + +[grpc.services.ocmproviderauthorizer] +driver = "json" + +[grpc.services.ocmproviderauthorizer.drivers.json] +providers = "{{file_providers}}" + +[grpc.services.authprovider] +auth_manager = "json" + +[grpc.services.authprovider.auth_managers.json] +users = "fixtures/ocm-users.demo.json" + +[grpc.services.userprovider] +driver = "json" + +[grpc.services.userprovider.drivers.json] +users = "fixtures/ocm-users.demo.json" diff --git a/tests/integration/grpc/fixtures/ocm-server-cernbox-http.toml b/tests/integration/grpc/fixtures/ocm-server-cernbox-http.toml new file mode 100644 index 0000000000..8fcdd31915 --- /dev/null +++ b/tests/integration/grpc/fixtures/ocm-server-cernbox-http.toml @@ -0,0 +1,17 @@ +[shared] +gatewaysvc = "{{cernboxgw_address}}" + +[http] +address = "{{grpc_address}}" + +[http.services.ocmd] + +[http.services.sciencemesh] + +[http.middlewares.cors] + +[http.middlewares.providerauthorizer] +driver = "json" + +[http.middlewares.providerauthorizer.drivers.json] +providers = "fixtures/ocm-providers.demo.json" \ No newline at end of file diff --git a/tests/integration/grpc/fixtures/ocm-server-cesnet-grpc.toml b/tests/integration/grpc/fixtures/ocm-server-cesnet-grpc.toml new file mode 100644 index 0000000000..fdf35e50fb --- /dev/null +++ b/tests/integration/grpc/fixtures/ocm-server-cesnet-grpc.toml @@ -0,0 +1,41 @@ +[shared] +gatewaysvc = "{{grpc_address}}" + +[grpc] +address = "{{grpc_address}}" + +[grpc.services.gateway] +authregistrysvc = "{{grpc_address}}" +userprovidersvc = "{{grpc_address}}" +ocminvitemanagersvc = "{{grpc_address}}" +ocmproviderauthorizersvc = "{{grpc_address}}" + +[grpc.services.authregistry] +driver = "static" + +[grpc.services.authregistry.drivers.static.rules] +basic = "{{grpc_address}}" + +[grpc.services.ocminvitemanager] +driver = "json" + +[grpc.services.ocminvitemanager.drivers.json] +file = "{{invite_token_file}}" + +[grpc.services.ocmproviderauthorizer] +driver = "json" + +[grpc.services.ocmproviderauthorizer.drivers.json] +providers = "{{file_providers}}" + +[grpc.services.authprovider] +auth_manager = "json" + +[grpc.services.authprovider.auth_managers.json] +users = "fixtures/ocm-users.demo.json" + +[grpc.services.userprovider] +driver = "json" + +[grpc.services.userprovider.drivers.json] +users = "fixtures/ocm-users.demo.json" diff --git a/tests/integration/grpc/fixtures/ocm-server-cesnet-http.toml b/tests/integration/grpc/fixtures/ocm-server-cesnet-http.toml new file mode 100644 index 0000000000..f07be9f27d --- /dev/null +++ b/tests/integration/grpc/fixtures/ocm-server-cesnet-http.toml @@ -0,0 +1,17 @@ +[shared] +gatewaysvc = "{{cesnetgw_address}}" + +[http] +address = "{{grpc_address}}" + +[http.services.ocmd] + +[http.services.sciencemesh] + +[http.middlewares.cors] + +[http.middlewares.providerauthorizer] +driver = "json" + +[http.middlewares.providerauthorizer.drivers.json] +providers = "fixtures/ocm-providers.demo.json" \ No newline at end of file diff --git a/tests/integration/grpc/fixtures/ocm-users.demo.json b/tests/integration/grpc/fixtures/ocm-users.demo.json new file mode 100644 index 0000000000..2b836ff1aa --- /dev/null +++ b/tests/integration/grpc/fixtures/ocm-users.demo.json @@ -0,0 +1,62 @@ +[ + { + "id": { + "opaque_id": "4c510ada-c86b-4815-8820-42cdf82c3d51", + "idp": "cernbox.cern.ch", + "type": 1 + }, + "username": "einstein", + "secret": "relativity", + "mail": "einstein@cern.ch", + "display_name": "Albert Einstein", + "groups": [ + "sailing-lovers", + "violin-haters", + "physics-lovers" + ], + "opaque": { + "map": { + "gid": { + "_comment": "decodes to 987", + "decoder": "plain", + "value": "OTg3" + }, + "uid": { + "_comment": "decodes to 123", + "decoder": "plain", + "value": "MTIz" + } + } + } + }, + { + "id": { + "opaque_id": "f7fbf8c8-139b-4376-b307-cf0a8c2d0d9c", + "idp": "cesnet.cz", + "type": 1 + }, + "username": "marie", + "secret": "radioactivity", + "mail": "marie@cesnet.cz", + "display_name": "Marie Curie", + "groups": [ + "radium-lovers", + "polonium-lovers", + "physics-lovers" + ], + "opaque": { + "map": { + "gid": { + "_comment": "decodes to 987", + "decoder": "plain", + "value": "OTg3" + }, + "uid": { + "_comment": "decodes to 456", + "decoder": "plain", + "value": "NDU2" + } + } + } + } +] \ No newline at end of file diff --git a/tests/integration/grpc/grpc_suite_test.go b/tests/integration/grpc/grpc_suite_test.go index 90459d6fc4..bacb1cca3f 100644 --- a/tests/integration/grpc/grpc_suite_test.go +++ b/tests/integration/grpc/grpc_suite_test.go @@ -75,16 +75,41 @@ type Revad struct { // `storage` and a `users` revad will make `storage_address` and // `users_address` available wit the dynamically assigned ports so that // the services can be made available to each other. -func startRevads(configs map[string]string, variables map[string]string) (map[string]*Revad, error) { +func startRevads(configs map[string]string, files map[string]string, variables map[string]string) (map[string]*Revad, error) { mutex.Lock() defer mutex.Unlock() revads := map[string]*Revad{} addresses := map[string]string{} + filesPath := map[string]string{} for name := range configs { addresses[name] = fmt.Sprintf("localhost:%d", port) port++ } + for name, p := range files { + tmpRoot, err := helpers.TempDir("") + if err != nil { + return nil, errors.Wrapf(err, "cannot create tmpdir for") + } + + rawFile, err := os.ReadFile(path.Join("fixtures", p)) + if err != nil { + return nil, errors.Wrapf(err, "error reading file") + } + cfg := string(rawFile) + for v, value := range variables { + cfg = strings.ReplaceAll(cfg, "{{"+v+"}}", value) + } + for name, address := range addresses { + cfg = strings.ReplaceAll(cfg, "{{"+name+"_address}}", address) + } + newFilePath := path.Join(tmpRoot, p) + err = os.WriteFile(newFilePath, []byte(cfg), 0600) + if err != nil { + return nil, errors.Wrapf(err, "error writing file") + } + filesPath[name] = newFilePath + } for name, config := range configs { ownAddress := addresses[name] @@ -94,14 +119,21 @@ func startRevads(configs map[string]string, variables map[string]string) (map[st if err != nil { return nil, errors.Wrapf(err, "Could not create tmpdir") } - newCfgPath := path.Join(tmpRoot, "config.toml") + newCfgPath := path.Join(tmpRoot, config) + fmt.Println(newCfgPath) rawCfg, err := os.ReadFile(path.Join("fixtures", config)) if err != nil { return nil, errors.Wrapf(err, "Could not read config file") } cfg := string(rawCfg) cfg = strings.ReplaceAll(cfg, "{{root}}", tmpRoot) + for name, path := range filesPath { + cfg = strings.ReplaceAll(cfg, "{{file_"+name+"}}", path) + } cfg = strings.ReplaceAll(cfg, "{{grpc_address}}", ownAddress) + if url, ok := addresses["gateway"]; ok { + cfg = strings.ReplaceAll(cfg, "{{gateway_address}}", url) + } for v, value := range variables { cfg = strings.ReplaceAll(cfg, "{{"+v+"}}", value) } diff --git a/tests/integration/grpc/ocm_invitation_test.go b/tests/integration/grpc/ocm_invitation_test.go new file mode 100644 index 0000000000..3e5d5065da --- /dev/null +++ b/tests/integration/grpc/ocm_invitation_test.go @@ -0,0 +1,467 @@ +// Copyright 2018-2023 CERN +// +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. +// +// In applying this license, CERN does not waive the privileges and immunities +// granted to it by virtue of its status as an Intergovernmental Organization +// or submit itself to any jurisdiction. + +package grpc_test + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "net/http" + "os" + + gatewaypb "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + invitepb "github.com/cs3org/go-cs3apis/cs3/ocm/invite/v1beta1" + ocmproviderpb "github.com/cs3org/go-cs3apis/cs3/ocm/provider/v1beta1" + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/cs3org/reva/pkg/auth/scope" + ctxpkg "github.com/cs3org/reva/pkg/ctx" + "github.com/cs3org/reva/pkg/rgrpc/todo/pool" + "github.com/cs3org/reva/pkg/token" + jwt "github.com/cs3org/reva/pkg/token/manager/jwt" + "github.com/cs3org/reva/pkg/utils" + "github.com/cs3org/reva/tests/helpers" + . "github.com/onsi/ginkgo" + . "github.com/onsi/gomega" + "google.golang.org/grpc/metadata" +) + +func ctxWithAuthToken(tokenManager token.Manager, user *userpb.User) context.Context { + ctx := context.Background() + scope, err := scope.AddOwnerScope(nil) + Expect(err).ToNot(HaveOccurred()) + tkn, err := tokenManager.MintToken(ctx, user, scope) + Expect(err).ToNot(HaveOccurred()) + ctx = ctxpkg.ContextSetToken(ctx, tkn) + ctx = metadata.AppendToOutgoingContext(ctx, ctxpkg.TokenHeader, tkn) + ctx = ctxpkg.ContextSetUser(ctx, user) + return ctx +} + +func ocmUserEqual(u1, u2 *userpb.User) bool { + return utils.UserEqual(u1.Id, u2.Id) && u1.DisplayName == u2.DisplayName && u1.Mail == u2.Mail +} + +var _ = Describe("ocm invitation workflow", func() { + var ( + err error + revads = map[string]*Revad{} + + variables = map[string]string{} + + ctxEinstein context.Context + ctxMarie context.Context + cernboxgw gatewaypb.GatewayAPIClient + cesnetgw gatewaypb.GatewayAPIClient + cernbox = &ocmproviderpb.ProviderInfo{ + Name: "cernbox", + FullName: "CERNBox", + Description: "CERNBox provides cloud data storage to all CERN users.", + Organization: "CERN", + Domain: "cernbox.cern.ch", + Homepage: "https://cernbox.web.cern.ch", + Services: []*ocmproviderpb.Service{ + { + Endpoint: &ocmproviderpb.ServiceEndpoint{ + Type: &ocmproviderpb.ServiceType{ + Name: "OCM", + Description: "CERNBox Open Cloud Mesh API", + }, + Name: "CERNBox - OCM API", + Path: "http://127.0.0.1:19001/ocm/", + IsMonitored: true, + }, + Host: "127.0.0.1:19001", + ApiVersion: "0.0.1", + }, + }, + } + inviteTokenFile string + einstein = &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: "4c510ada-c86b-4815-8820-42cdf82c3d51", + Idp: "cernbox.cern.ch", + Type: userpb.UserType_USER_TYPE_PRIMARY, + }, + Username: "einstein", + Mail: "einstein@cern.ch", + DisplayName: "Albert Einstein", + } + marie = &userpb.User{ + Id: &userpb.UserId{ + OpaqueId: "f7fbf8c8-139b-4376-b307-cf0a8c2d0d9c", + Idp: "cesnet.cz", + Type: userpb.UserType_USER_TYPE_PRIMARY, + }, + Username: "marie", + Mail: "marie@cesnet.cz", + DisplayName: "Marie Curie", + } + ) + + JustBeforeEach(func() { + tokenManager, err := jwt.New(map[string]interface{}{"secret": "changemeplease"}) + Expect(err).ToNot(HaveOccurred()) + ctxEinstein = ctxWithAuthToken(tokenManager, einstein) + ctxMarie = ctxWithAuthToken(tokenManager, marie) + revads, err = startRevads(map[string]string{ + "cernboxgw": "ocm-server-cernbox-grpc.toml", + "cernboxhttp": "ocm-server-cernbox-http.toml", + "cesnetgw": "ocm-server-cesnet-grpc.toml", + "cesnethttp": "ocm-server-cesnet-http.toml", + }, map[string]string{ + "providers": "ocm-providers.demo.json", + }, variables) + Expect(err).ToNot(HaveOccurred()) + cernboxgw, err = pool.GetGatewayServiceClient(pool.Endpoint(revads["cernboxgw"].GrpcAddress)) + Expect(err).ToNot(HaveOccurred()) + cesnetgw, err = pool.GetGatewayServiceClient(pool.Endpoint(revads["cesnetgw"].GrpcAddress)) + Expect(err).ToNot(HaveOccurred()) + cernbox.Services[0].Endpoint.Path = "http://" + revads["cernboxhttp"].GrpcAddress + "/ocm" + }) + + AfterEach(func() { + for _, r := range revads { + Expect(r.Cleanup(CurrentGinkgoTestDescription().Failed)).To(Succeed()) + } + Expect(os.RemoveAll(inviteTokenFile)).To(Succeed()) + }) + + Describe("einstein and marie do not know each other", func() { + BeforeEach(func() { + inviteTokenFile, err = helpers.TempJSONFile(map[string]string{}) + Expect(err).ToNot(HaveOccurred()) + variables = map[string]string{ + "invite_token_file": inviteTokenFile, + } + }) + + Context("einstein generates a token", func() { + It("will complete the workflow ", func() { + invitationTknRes, err := cernboxgw.GenerateInviteToken(ctxEinstein, &invitepb.GenerateInviteTokenRequest{}) + Expect(err).ToNot(HaveOccurred()) + Expect(invitationTknRes.Status.Code).To(Equal(rpc.Code_CODE_OK)) + Expect(invitationTknRes.InviteToken).ToNot(BeNil()) + forwardRes, err := cesnetgw.ForwardInvite(ctxMarie, &invitepb.ForwardInviteRequest{ + OriginSystemProvider: cernbox, + InviteToken: invitationTknRes.InviteToken, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(forwardRes.Status.Code).To(Equal(rpc.Code_CODE_OK)) + + Expect(forwardRes.DisplayName).To(Equal(einstein.DisplayName)) + Expect(forwardRes.Email).To(Equal(einstein.Mail)) + Expect(forwardRes.UserId).To(Equal(einstein.Id)) + + usersRes1, err := cernboxgw.FindAcceptedUsers(ctxEinstein, &invitepb.FindAcceptedUsersRequest{}) + Expect(err).ToNot(HaveOccurred()) + Expect(usersRes1.Status.Code).To(Equal(rpc.Code_CODE_OK)) + Expect(usersRes1.AcceptedUsers).To(HaveLen(1)) + info1 := usersRes1.AcceptedUsers[0] + Expect(ocmUserEqual(info1, marie)).To(BeTrue()) + + usersRes2, err := cesnetgw.FindAcceptedUsers(ctxMarie, &invitepb.FindAcceptedUsersRequest{}) + Expect(err).ToNot(HaveOccurred()) + Expect(usersRes2.Status.Code).To(Equal(rpc.Code_CODE_OK)) + Expect(usersRes2.AcceptedUsers).To(HaveLen(1)) + info2 := usersRes2.AcceptedUsers[0] + Expect(ocmUserEqual(info2, einstein)).To(BeTrue()) + }) + + }) + }) + + Describe("an invitation workflow has been already completed between einstein and marie", func() { + BeforeEach(func() { + inviteTokenFile, err = helpers.TempJSONFile(map[string]map[string][]*userpb.User{ + "accepted_users": { + einstein.Id.OpaqueId: {marie}, + marie.Id.OpaqueId: {einstein}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + variables = map[string]string{ + "invite_token_file": inviteTokenFile, + } + }) + + Context("marie accepts a new invite token generated by einstein", func() { + It("fails with already exists code", func() { + inviteTknRes, err := cernboxgw.GenerateInviteToken(ctxEinstein, &invitepb.GenerateInviteTokenRequest{}) + Expect(err).ToNot(HaveOccurred()) + Expect(inviteTknRes.Status.Code).To(Equal(rpc.Code_CODE_OK)) + + forwardRes, err := cesnetgw.ForwardInvite(ctxMarie, &invitepb.ForwardInviteRequest{ + InviteToken: inviteTknRes.InviteToken, + OriginSystemProvider: cernbox, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(forwardRes.Status.Code).To(Equal(rpc.Code_CODE_ALREADY_EXISTS)) + }) + }) + }) + + Describe("marie accepts an expired token", func() { + expiredToken := &invitepb.InviteToken{ + Token: "token", + UserId: einstein.Id, + Expiration: &typesv1beta1.Timestamp{ + Seconds: 0, + }, + Description: "expired token", + } + BeforeEach(func() { + inviteTokenFile, err = helpers.TempJSONFile(map[string]map[string]*invitepb.InviteToken{ + "invites": { + expiredToken.Token: expiredToken, + }, + }) + Expect(err).ToNot(HaveOccurred()) + variables = map[string]string{ + "invite_token_file": inviteTokenFile, + } + }) + + It("will not complete the invitation workflow", func() { + forwardRes, err := cesnetgw.ForwardInvite(ctxMarie, &invitepb.ForwardInviteRequest{ + InviteToken: expiredToken, + OriginSystemProvider: cernbox, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(forwardRes.Status.Code).To(Equal(rpc.Code_CODE_INVALID_ARGUMENT)) + }) + }) + + Describe("marie accept a not existing token", func() { + BeforeEach(func() { + inviteTokenFile, err = helpers.TempJSONFile(map[string]string{}) + Expect(err).ToNot(HaveOccurred()) + variables = map[string]string{ + "invite_token_file": inviteTokenFile, + } + }) + + It("will not complete the invitation workflow", func() { + forwardRes, err := cesnetgw.ForwardInvite(ctxMarie, &invitepb.ForwardInviteRequest{ + InviteToken: &invitepb.InviteToken{ + Token: "not-existing-token", + }, + OriginSystemProvider: cernbox, + }) + Expect(err).ToNot(HaveOccurred()) + Expect(forwardRes.Status.Code).To(Equal(rpc.Code_CODE_NOT_FOUND)) + }) + }) + + Context("clients use the http endpoints exposed by sciencemesh", func() { + var ( + cesnetURL string + cernboxURL string + tknMarie, tknEinstein string + token string + ) + + JustBeforeEach(func() { + cesnetURL = revads["cesnethttp"].GrpcAddress + cernboxURL = revads["cernboxhttp"].GrpcAddress + + var ok bool + tknMarie, ok = ctxpkg.ContextGetToken(ctxMarie) + Expect(ok).To(BeTrue()) + tknEinstein, ok = ctxpkg.ContextGetToken(ctxEinstein) + Expect(ok).To(BeTrue()) + + tknRes, err := cernboxgw.GenerateInviteToken(ctxEinstein, &invitepb.GenerateInviteTokenRequest{}) + Expect(err).ToNot(HaveOccurred()) + Expect(tknRes.Status.Code).To(Equal(rpc.Code_CODE_OK)) + token = tknRes.InviteToken.Token + }) + + acceptInvite := func(revaToken, domain, provider, token string) int { + d, err := json.Marshal(map[string]string{ + "token": token, + "providerDomain": provider, + }) + Expect(err).ToNot(HaveOccurred()) + req, err := http.NewRequestWithContext(context.TODO(), http.MethodPost, fmt.Sprintf("http://%s/sciencemesh/accept-invite", domain), bytes.NewReader(d)) + Expect(err).ToNot(HaveOccurred()) + req.Header.Set("x-access-token", revaToken) + req.Header.Set("content-type", "application/json") + + res, err := http.DefaultClient.Do(req) + Expect(err).ToNot(HaveOccurred()) + defer res.Body.Close() + + return res.StatusCode + } + + findAccepted := func(revaToken, domain string) ([]*userpb.User, int) { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, fmt.Sprintf("http://%s/sciencemesh/find-accepted-users", domain), nil) + Expect(err).ToNot(HaveOccurred()) + req.Header.Set("x-access-token", revaToken) + + res, err := http.DefaultClient.Do(req) + Expect(err).ToNot(HaveOccurred()) + defer res.Body.Close() + + var users []*userpb.User + _ = json.NewDecoder(res.Body).Decode(&users) + return users, res.StatusCode + } + + generateToken := func(revaToken, domain string) (*invitepb.InviteToken, int) { + req, err := http.NewRequestWithContext(context.TODO(), http.MethodGet, fmt.Sprintf("http://%s/sciencemesh/generate-invite", domain), nil) + Expect(err).ToNot(HaveOccurred()) + req.Header.Set("x-access-token", revaToken) + + res, err := http.DefaultClient.Do(req) + Expect(err).ToNot(HaveOccurred()) + defer res.Body.Close() + + var token invitepb.InviteToken + Expect(json.NewDecoder(res.Body).Decode(&token)).To(Succeed()) + return &token, res.StatusCode + } + + Context("einstein and marie do not know each other", func() { + + Context("marie is not logged-in", func() { + It("fails with permission denied", func() { + code := acceptInvite("", cesnetURL, "cernbox.cern.ch", token) + Expect(code).To(Equal(http.StatusUnauthorized)) + }) + }) + It("complete the invitation workflow", func() { + users, code := findAccepted(tknEinstein, cernboxURL) + Expect(code).To(Equal(http.StatusOK)) + Expect(ocmUsersEqual(users, []*userpb.User{})).To(BeTrue()) + + code = acceptInvite(tknMarie, cesnetURL, "cernbox.cern.ch", token) + Expect(code).To(Equal(http.StatusOK)) + + users, code = findAccepted(tknEinstein, cernboxURL) + Expect(code).To(Equal(http.StatusOK)) + Expect(ocmUsersEqual(users, []*userpb.User{marie})).To(BeTrue()) + }) + }) + + Context("marie already accepted an invitation before", func() { + BeforeEach(func() { + inviteTokenFile, err = helpers.TempJSONFile(map[string]map[string][]*userpb.User{ + "accepted_users": { + einstein.Id.OpaqueId: {marie}, + marie.Id.OpaqueId: {einstein}, + }, + }) + Expect(err).ToNot(HaveOccurred()) + variables = map[string]string{ + "invite_token_file": inviteTokenFile, + } + }) + + It("fails the invitation workflow", func() { + users, code := findAccepted(tknEinstein, cernboxURL) + Expect(code).To(Equal(http.StatusOK)) + Expect(ocmUsersEqual(users, []*userpb.User{marie})).To(BeTrue()) + + code = acceptInvite(tknMarie, cesnetURL, "cernbox.cern.ch", token) + Expect(code).To(Equal(http.StatusConflict)) + + users, code = findAccepted(tknEinstein, cernboxURL) + Expect(code).To(Equal(http.StatusOK)) + Expect(ocmUsersEqual(users, []*userpb.User{marie})).To(BeTrue()) + }) + }) + + Context("marie uses an expired token", func() { + expiredToken := &invitepb.InviteToken{ + Token: "token", + UserId: einstein.Id, + Expiration: &typesv1beta1.Timestamp{ + Seconds: 0, + }, + Description: "expired token", + } + BeforeEach(func() { + inviteTokenFile, err = helpers.TempJSONFile(map[string]map[string]*invitepb.InviteToken{ + "invites": { + expiredToken.Token: expiredToken, + }, + }) + Expect(err).ToNot(HaveOccurred()) + variables = map[string]string{ + "invite_token_file": inviteTokenFile, + } + }) + + It("will not complete the invitation workflow", func() { + users, code := findAccepted(tknEinstein, cernboxURL) + Expect(code).To(Equal(http.StatusOK)) + Expect(ocmUsersEqual(users, []*userpb.User{})).To(BeTrue()) + + code = acceptInvite(tknMarie, cesnetURL, "cernbox.cern.ch", expiredToken.Token) + Expect(code).To(Equal(http.StatusBadRequest)) + + users, code = findAccepted(tknEinstein, cernboxURL) + Expect(code).To(Equal(http.StatusOK)) + Expect(ocmUsersEqual(users, []*userpb.User{})).To(BeTrue()) + }) + }) + + Context("generate the token from http apis", func() { + BeforeEach(func() { + inviteTokenFile, err = helpers.TempJSONFile(map[string]map[string]*invitepb.InviteToken{}) + Expect(err).ToNot(HaveOccurred()) + variables = map[string]string{ + "invite_token_file": inviteTokenFile, + } + }) + It("succeeds", func() { + users, code := findAccepted(tknEinstein, cernboxURL) + Expect(code).To(Equal(http.StatusOK)) + Expect(ocmUsersEqual(users, []*userpb.User{})).To(BeTrue()) + + ocmToken, code := generateToken(tknEinstein, cernboxURL) + Expect(code).To(Equal(http.StatusOK)) + + code = acceptInvite(tknMarie, cesnetURL, "cernbox.cern.ch", ocmToken.Token) + Expect(code).To(Equal(http.StatusOK)) + + users, code = findAccepted(tknEinstein, cernboxURL) + Expect(code).To(Equal(http.StatusOK)) + Expect(ocmUsersEqual(users, []*userpb.User{marie})).To(BeTrue()) + }) + }) + + }) +}) + +func ocmUsersEqual(u1, u2 []*userpb.User) bool { + if len(u1) != len(u2) { + return false + } + for i := range u1 { + if !ocmUserEqual(u1[i], u2[i]) { + return false + } + } + return true +} diff --git a/tests/integration/grpc/storageprovider_test.go b/tests/integration/grpc/storageprovider_test.go index 78195214a2..51b5395948 100644 --- a/tests/integration/grpc/storageprovider_test.go +++ b/tests/integration/grpc/storageprovider_test.go @@ -87,7 +87,7 @@ var _ = Describe("storage providers", func() { ctx = metadata.AppendToOutgoingContext(ctx, ctxpkg.TokenHeader, t) ctx = ctxpkg.ContextSetUser(ctx, user) - revads, err = startRevads(dependencies, variables) + revads, err = startRevads(dependencies, nil, variables) Expect(err).ToNot(HaveOccurred()) serviceClient, err = pool.GetStorageProviderServiceClient(pool.Endpoint(revads["storage"].GrpcAddress)) Expect(err).ToNot(HaveOccurred()) diff --git a/tests/integration/grpc/userprovider_test.go b/tests/integration/grpc/userprovider_test.go index 089553ea9a..6dff965ada 100644 --- a/tests/integration/grpc/userprovider_test.go +++ b/tests/integration/grpc/userprovider_test.go @@ -65,7 +65,7 @@ var _ = Describe("user providers", func() { ctx = metadata.AppendToOutgoingContext(ctx, ctxpkg.TokenHeader, t) ctx = ctxpkg.ContextSetUser(ctx, user) - revads, err = startRevads(dependencies, map[string]string{}) + revads, err = startRevads(dependencies, nil, map[string]string{}) Expect(err).ToNot(HaveOccurred()) serviceClient, err = pool.GetUserProviderServiceClient(pool.Endpoint(revads["users"].GrpcAddress)) Expect(err).ToNot(HaveOccurred())