Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions changelog/unreleased/enhancement-vault-group-share.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
Enhancement: Allow group shares in the vault

Users can now invite a group to a resource in the vault (Safe). Before this
change, the server accepted only users as vault share recipients. The share
dialog in Web now also searches for groups in vault mode. The
notifications and userlog services now send a vault notification only to the
group members who have the permission to use the vault. The other group
members get no email and no in-app notification.

https://github.com/owncloud/ocis/pull/13260
https://github.com/owncloud/reva/pull/787
2 changes: 1 addition & 1 deletion go.mod
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ require (
github.com/open-policy-agent/opa v1.21.1
github.com/orcaman/concurrent-map v1.0.0
github.com/owncloud/libre-graph-api-go v1.0.5-0.20260216101009-eeac018af245
github.com/owncloud/reva/v2 v2.0.0-20261109105443-35b75d9cfbfb
github.com/owncloud/reva/v2 v2.0.0-20261112085604-540968fc9590
github.com/pkg/errors v0.9.1
github.com/pkg/xattr v0.4.12
github.com/prometheus/client_golang v1.25.0
Expand Down
4 changes: 2 additions & 2 deletions go.sum
Original file line number Diff line number Diff line change
Expand Up @@ -734,8 +734,8 @@ github.com/orcaman/concurrent-map v1.0.0 h1:I/2A2XPCb4IuQWcQhBhSwGfiuybl/J0ev9HD
github.com/orcaman/concurrent-map v1.0.0/go.mod h1:Lu3tH6HLW3feq74c2GC+jIMS/K2CFcDWnWD9XkenwhI=
github.com/owncloud/libre-graph-api-go v1.0.5-0.20260216101009-eeac018af245 h1:JRidLTAKhnvyLMRtVtSF4lhBa0NSAOs6fof+d6JnKII=
github.com/owncloud/libre-graph-api-go v1.0.5-0.20260216101009-eeac018af245/go.mod h1:z61VMGAJRtR1nbgXWiNoCkxUXP1B3Je9rMuJbnGd+Og=
github.com/owncloud/reva/v2 v2.0.0-20261109105443-35b75d9cfbfb h1:cZ7iXzqdWHvwQdMu+DeOeCkgtPZggz59VimUqvyuICg=
github.com/owncloud/reva/v2 v2.0.0-20261109105443-35b75d9cfbfb/go.mod h1:aPTQk4pFJ2kmLnkiyBtRrg1Q4O/ItWXrr05XeLvZz8Y=
github.com/owncloud/reva/v2 v2.0.0-20261112085604-540968fc9590 h1:LutU3kwsLUssK5e3F1zuZ9XweLDWapQqtyYDHU/toxY=
github.com/owncloud/reva/v2 v2.0.0-20261112085604-540968fc9590/go.mod h1:aPTQk4pFJ2kmLnkiyBtRrg1Q4O/ItWXrr05XeLvZz8Y=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c h1:rp5dCmg/yLR3mgFuSOe4oEnDDmGLROTvMragMUXpTQw=
github.com/oxtoacart/bpool v0.0.0-20190530202638-03653db5a59c/go.mod h1:X07ZCGwUbLaax7L0S3Tw4hpejzu63ZrrQiUe6W0hcy0=
github.com/patrickmn/go-cache v2.1.0+incompatible h1:HRMgzkcYKYpi3C8ajMPV8OFXaaRUnok+kx1WdO15EQc=
Expand Down
103 changes: 103 additions & 0 deletions ocis-pkg/vault/vault.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Package vault provides helpers for resources in the vault storage provider.
package vault

import (
"context"
"fmt"

gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
permissions "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/owncloud/reva/v2/pkg/permission"
"github.com/owncloud/reva/v2/pkg/storagespace"
"github.com/owncloud/reva/v2/pkg/utils"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
)

// maxConcurrentChecks bounds how many VaultMode checks run at the same time, so that a large
// group does not send all its checks to the gateway at once.
const maxConcurrentChecks = 10

const tracerName = "github.com/owncloud/ocis/v2/ocis-pkg/vault"

// IsVaultStorage reports whether the storage ID belongs to the vault storage provider.
func IsVaultStorage(storageID string) bool {
return storageID == utils.VaultStorageProviderID
}

// IsVaultSpace reports whether the space ID belongs to the vault storage provider.
// It returns true for a space ID that it cannot parse, so that callers fail closed.
func IsVaultSpace(spaceID string) bool {
rid, err := storagespace.ParseID(spaceID)
if err != nil {
return true
}
return IsVaultStorage(rid.GetStorageId())
}

// HasVaultMode reports whether the user holds the VaultMode permission.
// A non-nil error means undetermined, so callers must treat the user as not allowed.
func HasVaultMode(ctx context.Context, gwc gateway.GatewayAPIClient, userID *user.UserId) (bool, error) {
res, err := gwc.CheckPermission(ctx, &permissions.CheckPermissionRequest{
SubjectRef: &permissions.SubjectReference{
Spec: &permissions.SubjectReference_UserId{UserId: userID},
},
Permission: permission.VaultMode,
})
if err != nil {
return false, err
}
switch code := res.GetStatus().GetCode(); code {
case rpc.Code_CODE_OK:
return true, nil
case rpc.Code_CODE_PERMISSION_DENIED:
return false, nil
default:
return false, fmt.Errorf("vault permission check returned status %q: %s", code, res.GetStatus().GetMessage())
}
}

// FilterVaultModeUsers returns the users from userIDs who hold the VaultMode permission, in the
// same order. A group can contain users without the permission, because the gateway does not
// check the group members. The checks run in parallel, with a bound. When a check fails, the
// function calls onError for that user and leaves the user out, so that the caller fails closed.
func FilterVaultModeUsers(ctx context.Context, gwc gateway.GatewayAPIClient, userIDs []*user.UserId, onError func(*user.UserId, error)) []*user.UserId {
ctx, span := trace.SpanFromContext(ctx).TracerProvider().Tracer(tracerName).Start(ctx, "FilterVaultModeUsers",
trace.WithAttributes(attribute.Int("vault.users.checked", len(userIDs))))
defer span.End()

allowed := make([]bool, len(userIDs))
errs := make([]error, len(userIDs))
var eg errgroup.Group
eg.SetLimit(maxConcurrentChecks)
for i, id := range userIDs {
eg.Go(func() error {
allowed[i], errs[i] = HasVaultMode(ctx, gwc, id)

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I assume this is fine because each goroutine will write in a different memory address, but I'd rather have some confirmation.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it is safe. The code makes both slices at full length before the loop starts, and they never grow. Each goroutine writes only its own index, and eg.Wait() makes the writes visible before the reads. Go 1.22+ gives each loop pass its own i and id.

return nil
})
}
_ = eg.Wait()

filtered := make([]*user.UserId, 0, len(userIDs))
failed := 0
for i, id := range userIDs {
if errs[i] != nil {
failed++
if onError != nil {
onError(id, errs[i])
}
continue
}
if allowed[i] {
filtered = append(filtered, id)
}
}
span.SetAttributes(
attribute.Int("vault.users.allowed", len(filtered)),
attribute.Int("vault.users.failed", failed),
)
return filtered
}
110 changes: 110 additions & 0 deletions ocis-pkg/vault/vault_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,110 @@
package vault

import (
"context"
"errors"
"fmt"
"slices"
"testing"

user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
permissions "github.com/cs3org/go-cs3apis/cs3/permissions/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/owncloud/reva/v2/pkg/permission"
"github.com/owncloud/reva/v2/pkg/utils"
cs3mocks "github.com/owncloud/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
)

func TestIsVaultSpace(t *testing.T) {
tests := []struct {
name string
spaceID string
want bool
}{
{"vault space", utils.VaultStorageProviderID + "$space-id", true},
{"regular space", "storage-id$space-id", false},
{"unparsable space id fails closed", "", true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := IsVaultSpace(tt.spaceID); got != tt.want {
t.Errorf("IsVaultSpace(%q) = %v, want %v", tt.spaceID, got, tt.want)
}
})
}
}

func TestHasVaultMode(t *testing.T) {
tests := []struct {
name string
res *permissions.CheckPermissionResponse
err error
want bool
wantErr bool
}{
{"ok grants", &permissions.CheckPermissionResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}}, nil, true, false},
{"permission denied denies", &permissions.CheckPermissionResponse{Status: &rpc.Status{Code: rpc.Code_CODE_PERMISSION_DENIED}}, nil, false, false},
{"internal status fails closed", &permissions.CheckPermissionResponse{Status: &rpc.Status{Code: rpc.Code_CODE_INTERNAL}}, nil, false, true},
{"transport error fails closed", nil, errors.New("unavailable"), false, true},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
gwc := &cs3mocks.GatewayAPIClient{}
gwc.On("CheckPermission", mock.Anything, mock.MatchedBy(func(req *permissions.CheckPermissionRequest) bool {
return req.GetPermission() == permission.VaultMode && req.GetSubjectRef().GetUserId().GetOpaqueId() == "alice"
})).Return(tt.res, tt.err)

got, err := HasVaultMode(context.Background(), gwc, &user.UserId{OpaqueId: "alice"})
if (err != nil) != tt.wantErr {
t.Fatalf("HasVaultMode() error = %v, wantErr %v", err, tt.wantErr)
}
if got != tt.want {
t.Errorf("HasVaultMode() = %v, want %v", got, tt.want)
}
gwc.AssertExpectations(t)
})
}
}

func TestFilterVaultModeUsers(t *testing.T) {
// alice and every "ok-" user hold the permission, bob does not, and the check for carol fails
gwc := &cs3mocks.GatewayAPIClient{}
gwc.On("CheckPermission", mock.Anything, mock.Anything).Return(func(_ context.Context, r *permissions.CheckPermissionRequest, _ ...grpc.CallOption) (*permissions.CheckPermissionResponse, error) {
switch id := r.GetSubjectRef().GetUserId().GetOpaqueId(); {
case id == "carol":
return nil, errors.New("unavailable")
case id == "bob":
return &permissions.CheckPermissionResponse{Status: &rpc.Status{Code: rpc.Code_CODE_PERMISSION_DENIED}}, nil
default:
return &permissions.CheckPermissionResponse{Status: &rpc.Status{Code: rpc.Code_CODE_OK}}, nil
}
})

ids := []*user.UserId{{OpaqueId: "alice"}, {OpaqueId: "bob"}, {OpaqueId: "carol"}}
for i := range 50 {
ids = append(ids, &user.UserId{OpaqueId: fmt.Sprintf("ok-%02d", i)})
}

var failed []string
got := FilterVaultModeUsers(context.Background(), gwc, ids, func(id *user.UserId, err error) {
failed = append(failed, id.GetOpaqueId())
})

want := []string{"alice"}
for i := range 50 {
want = append(want, fmt.Sprintf("ok-%02d", i))
}
gotIDs := make([]string, 0, len(got))
for _, id := range got {
gotIDs = append(gotIDs, id.GetOpaqueId())
}
if !slices.Equal(gotIDs, want) {
t.Errorf("FilterVaultModeUsers() = %v, want %v", gotIDs, want)
}
if !slices.Equal(failed, []string{"carol"}) {
t.Errorf("onError called for %v, want [carol]", failed)
}
gwc.AssertNumberOfCalls(t, "CheckPermission", len(ids))
}
36 changes: 32 additions & 4 deletions services/notifications/pkg/service/service.go
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@ import (
"github.com/owncloud/ocis/v2/ocis-pkg/l10n"
"github.com/owncloud/ocis/v2/ocis-pkg/log"
"github.com/owncloud/ocis/v2/ocis-pkg/middleware"
"github.com/owncloud/ocis/v2/ocis-pkg/vault"
settingssvc "github.com/owncloud/ocis/v2/protogen/gen/ocis/services/settings/v0"
"github.com/owncloud/ocis/v2/services/notifications/pkg/channels"
"github.com/owncloud/ocis/v2/services/notifications/pkg/email"
Expand Down Expand Up @@ -196,8 +197,10 @@ func (s eventsNotifier) send(ctx context.Context, emails []*channels.Message) {
}
}

func (s eventsNotifier) ensureGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId) []*user.User {
granteeList, err := s.getGranteeList(ctx, executant, u, g)
// ensureGranteeList resolves the grantee to the users who get the notification.
// For a vault resource it keeps only the users who hold the VaultMode permission.
func (s eventsNotifier) ensureGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId, inVault bool) []*user.User {
granteeList, err := s.getGranteeList(ctx, executant, u, g, inVault)
if err != nil {
s.logger.Error().Err(err).Str("event", "ensureGranteeList").Msg("Could not get grantee list")
return nil
Expand All @@ -207,9 +210,18 @@ func (s eventsNotifier) ensureGranteeList(ctx context.Context, executant, u *use
return granteeList
}

func (s eventsNotifier) getGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId) ([]*user.User, error) {
func (s eventsNotifier) getGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId, inVault bool) ([]*user.User, error) {
switch {
case u != nil:
if inVault {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
if len(s.filterVaultModeUsers(ctx, gatewayClient, []*user.UserId{u})) == 0 {

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why not return s.filterVaultModeUsers(ctx, gatewayClient, []*user.UserId{u})), nil?
I'd prefer to return an empty list rather than nil on a successful case.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe I misunderstood, but we shouldn't return if len==1 and we should go further.
It doesn't matter whether the function returns nil or an empty list because later this will all be reduced to nil anyway

func (s eventsNotifier) ensureGranteeList(ctx context.Context, executant, u *user.UserId, g *group.GroupId, inVault bool) []*user.User {
	granteeList, err := s.getGranteeList(ctx, executant, u, g, inVault)
	if err != nil {
		s.logger.Error().Err(err).Str("event", "ensureGranteeList").Msg("Could not get grantee list")
		return nil
	} else if len(granteeList) == 0 {
		return nil
	}
	return granteeList
}

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, you're right. I made the wrong suggestion. We need to keep going if the filterVaultModeUser returns some results.

In general, getGranteeList should either return a (maybe empty) list with a nil error, or a "nil" list with an error.
In this particular case, I think it's fine mainly because this function is private (the direct usage is limited), and returning nil should avoid allocating an empty list that won't be really used.
👍

return nil, nil
}
}
if s.disableEmails(ctx, u) {
return nil, nil
}
Expand All @@ -236,12 +248,21 @@ func (s eventsNotifier) getGranteeList(ctx context.Context, executant, u *user.U
return nil, errors.New("could not get group")
}

userList := make([]*user.User, 0, len(res.GetGroup().GetMembers()))
members := make([]*user.UserId, 0, len(res.GetGroup().GetMembers()))
for _, userID := range res.GetGroup().GetMembers() {
// don't add the executant
if userID.GetOpaqueId() == executant.GetOpaqueId() {
continue
}
members = append(members, userID)
}
// check the vault permission first, so that members without it cost no further calls
if inVault {
members = s.filterVaultModeUsers(ctx, gatewayClient, members)
}

userList := make([]*user.User, 0, len(members))
for _, userID := range members {
// don't add users who opted out
if s.disableEmails(ctx, userID) {
continue
Expand All @@ -262,6 +283,13 @@ func (s eventsNotifier) getGranteeList(ctx context.Context, executant, u *user.U
}
}

// filterVaultModeUsers keeps only the users who hold the VaultMode permission.
func (s eventsNotifier) filterVaultModeUsers(ctx context.Context, gatewayClient gateway.GatewayAPIClient, userIDs []*user.UserId) []*user.UserId {
return vault.FilterVaultModeUsers(ctx, gatewayClient, userIDs, func(id *user.UserId, err error) {
s.logger.Error().Err(err).Str("userId", id.GetOpaqueId()).Msg("could not check the vault permission, user skipped")
})
}

func (s eventsNotifier) getUser(ctx context.Context, u *user.UserId) (*user.User, error) {
if u == nil {
return nil, errors.New("need at least one non-nil grantee")
Expand Down
Loading
Loading