Repository navigation
feat: [OCISDEV-1516] allow group shares in the vault #13260
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: master
Are you sure you want to change the base?
Changes from all commits
94ae601
aafdd1f
a6efb4a
043a285
4b8dacc
69a10d4
a21c1ab
bb0dd62
eff6191
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 |
| 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) | ||
| 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 | ||
| } | ||
| 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)) | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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" | ||
|
|
@@ -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 | ||
|
|
@@ -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 { | ||
|
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Why not
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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.
Member
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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 In general, |
||
| return nil, nil | ||
| } | ||
| } | ||
| if s.disableEmails(ctx, u) { | ||
| return nil, nil | ||
| } | ||
|
|
@@ -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 | ||
|
|
@@ -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") | ||
|
|
||
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.