diff --git a/changelog/unreleased/claim-managed-spaces.md b/changelog/unreleased/claim-managed-spaces.md new file mode 100644 index 00000000000..bfe0f2ead2c --- /dev/null +++ b/changelog/unreleased/claim-managed-spaces.md @@ -0,0 +1,5 @@ +Enhancement: Claim managed spaces + +Allow managing spaces from oidc claims + +https://github.com/owncloud/ocis/pull/11280 diff --git a/ocis-pkg/claimsmapper/claimsmapper.go b/ocis-pkg/claimsmapper/claimsmapper.go new file mode 100644 index 00000000000..279022a9909 --- /dev/null +++ b/ocis-pkg/claimsmapper/claimsmapper.go @@ -0,0 +1,65 @@ +package claimsmapper + +import ( + "regexp" + "strings" +) + +// ClaimsMapper is a configurable mapper to map oidc claims to ocis spaceIDs and roles +type ClaimsMapper struct { + claimRegexp *regexp.Regexp + roleMapping map[string]string +} + +// NewClaimsMapper parses the config to create a new ClaimsMapper. It expects +// - a regexp extracting the spaceID and the (unmapped) role from the claim. +// - a roleMapping string of the form "oidcRole1:manager","oidcRole2:editor","oidcRole3:viewer" +// unused roles can be omitted. Second part of the mapping must be a valid ocis role. +// can be omitted if roles already match ocis roles +// +// Panics if regexp is not compilable +func NewClaimsMapper(reg string, roleMapping []string) ClaimsMapper { + em := ClaimsMapper{ + claimRegexp: regexp.MustCompile(reg), + } + + if len(roleMapping) == 0 { + return em + } + + em.roleMapping = make(map[string]string) + for _, ms := range roleMapping { + s := strings.Split(ms, ":") + if len(s) != 2 { + continue + } + em.roleMapping[s[0]] = s[1] + } + return em +} + +// Exec extracts the spaceID and the role from a entitlement +func (em ClaimsMapper) Exec(e string) (match bool, spaceID string, role string) { + s := em.claimRegexp.FindStringSubmatch(e) + if len(s) != 3 { + return + } + + spaceID = s[1] + if spaceID == "" { + return + } + + role = s[2] + if em.roleMapping == nil { + match = true + return + } + + role = em.roleMapping[role] + if role != "" { + match = true + return + } + return false, "", "" +} diff --git a/ocis-pkg/claimsmapper/claimsmapper_test.go b/ocis-pkg/claimsmapper/claimsmapper_test.go new file mode 100644 index 00000000000..4d096ee6a75 --- /dev/null +++ b/ocis-pkg/claimsmapper/claimsmapper_test.go @@ -0,0 +1,76 @@ +package claimsmapper + +import ( + "testing" + + "github.com/test-go/testify/require" +) + +func TestMapClaims(t *testing.T) { + type innercase struct { + input string + expectedNoMatch bool + expectedSpaceID string + expectedRole string + } + var testCases = []struct { + regexp string + mapping []string + cases []innercase + }{ + { + regexp: "some-string:moreinfo:([a-zA-Z0-9-]+):and-the-role-is-(.*)", + cases: []innercase{ + { + input: "some-string:moreinfo:here-is-my-uuid:and-the-role-is-here", + expectedSpaceID: "here-is-my-uuid", + expectedRole: "here", + }, + { + input: "some-otherthing:moreinfo:here-is-my-uuid:and-the-role-is-not-here", + expectedNoMatch: true, + }, + }, + }, + { + regexp: "spaceid=([a-zA-Z0-9-]+),roleid=(.*)", + mapping: []string{"overseer:manager", "worker:editor", "ghoul:viewer"}, + cases: []innercase{ + { + input: "spaceid=vault36,roleid=overseer", + expectedSpaceID: "vault36", + expectedRole: "manager", + }, + { + input: "spaceid=vault36,roleid=worker", + expectedSpaceID: "vault36", + expectedRole: "editor", + }, + { + input: "spaceid=vault36,roleid=ghoul", + expectedSpaceID: "vault36", + expectedRole: "viewer", + }, + { + input: "spaceid=vault36,roleid=radroach", + expectedNoMatch: true, + }, + { + input: "differentid=vault36,roleid=overseer", + expectedNoMatch: true, + }, + }, + }, + } + + for _, tc := range testCases { + cm := NewClaimsMapper(tc.regexp, tc.mapping) + for _, c := range tc.cases { + match, spaceID, role := cm.Exec(c.input) + require.Equal(t, !c.expectedNoMatch, match, c.input) + require.Equal(t, c.expectedSpaceID, spaceID, c.input) + require.Equal(t, c.expectedRole, role, c.input) + } + + } +} diff --git a/services/proxy/pkg/command/server.go b/services/proxy/pkg/command/server.go index e42b6d0bc87..0fda04aeb07 100644 --- a/services/proxy/pkg/command/server.go +++ b/services/proxy/pkg/command/server.go @@ -378,11 +378,18 @@ func loadMiddlewares(logger log.Logger, cfg *config.Config, middleware.WithRevaGatewaySelector(gatewaySelector), middleware.PoliciesProviderService(policiesProviderClient), ), - // finally, trigger home creation when a user logs in + // trigger home creation when a user logs in middleware.CreateHome( middleware.Logger(logger), middleware.WithRevaGatewaySelector(gatewaySelector), middleware.RoleQuotas(cfg.RoleQuotas), ), + // trigger space assignment when a user logs in + middleware.SpaceManager( + cfg.ClaimSpaceManagement, + middleware.Logger(logger), + middleware.WithRevaGatewaySelector(gatewaySelector), + middleware.ServiceAccount(cfg.ServiceAccount.ServiceAccountID, cfg.ServiceAccount.ServiceAccountSecret), + ), ) } diff --git a/services/proxy/pkg/config/config.go b/services/proxy/pkg/config/config.go index 574cbbe70f0..2d82971c40c 100644 --- a/services/proxy/pkg/config/config.go +++ b/services/proxy/pkg/config/config.go @@ -24,27 +24,28 @@ type Config struct { GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"` GrpcClient client.Client `yaml:"-"` - RoleQuotas map[string]uint64 `yaml:"role_quotas"` - Policies []Policy `yaml:"policies"` - AdditionalPolicies []Policy `yaml:"additional_policies"` - OIDC OIDC `yaml:"oidc"` - ServiceAccount ServiceAccount `yaml:"service_account"` - RoleAssignment RoleAssignment `yaml:"role_assignment"` - PolicySelector *PolicySelector `yaml:"policy_selector"` - PreSignedURL PreSignedURL `yaml:"pre_signed_url"` - AccountBackend string `yaml:"account_backend" env:"PROXY_ACCOUNT_BACKEND_TYPE" desc:"Account backend the PROXY service should use. Currently only 'cs3' is possible here." introductionVersion:"pre5.0"` - UserOIDCClaim string `yaml:"user_oidc_claim" env:"PROXY_USER_OIDC_CLAIM" desc:"The name of an OpenID Connect claim that is used for resolving users with the account backend. The value of the claim must hold a per user unique, stable and non re-assignable identifier. The availability of claims depends on your Identity Provider. There are common claims available for most Identity providers like 'email' or 'preferred_username' but you can also add your own claim." introductionVersion:"pre5.0"` - UserCS3Claim string `yaml:"user_cs3_claim" env:"PROXY_USER_CS3_CLAIM" desc:"The name of a CS3 user attribute (claim) that should be mapped to the 'user_oidc_claim'. Supported values are 'username', 'mail' and 'userid'." introductionVersion:"pre5.0"` - MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;PROXY_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services." introductionVersion:"pre5.0" mask:"password"` - AutoprovisionAccounts bool `yaml:"auto_provision_accounts" env:"PROXY_AUTOPROVISION_ACCOUNTS" desc:"Set this to 'true' to automatically provision users that do not yet exist in the users service on-demand upon first sign-in. To use this a write-enabled libregraph user backend needs to be setup an running." introductionVersion:"pre5.0"` - AutoProvisionClaims AutoProvisionClaims `yaml:"auto_provision_claims"` - EnableBasicAuth bool `yaml:"enable_basic_auth" env:"PROXY_ENABLE_BASIC_AUTH" desc:"Set this to true to enable 'basic authentication' (username/password)." introductionVersion:"pre5.0"` - InsecureBackends bool `yaml:"insecure_backends" env:"PROXY_INSECURE_BACKENDS" desc:"Disable TLS certificate validation for all HTTP backend connections." introductionVersion:"pre5.0"` - BackendHTTPSCACert string `yaml:"backend_https_cacert" env:"PROXY_HTTPS_CACERT" desc:"Path/File for the root CA certificate used to validate the server’s TLS certificate for https enabled backend services." introductionVersion:"pre5.0"` - AuthMiddleware AuthMiddleware `yaml:"auth_middleware"` - PoliciesMiddleware PoliciesMiddleware `yaml:"policies_middleware"` - CSPConfigFileLocation string `yaml:"csp_config_file_location" env:"PROXY_CSP_CONFIG_FILE_LOCATION" desc:"The location of the CSP configuration file." introductionVersion:"6.0.0"` - Events Events `yaml:"events"` + RoleQuotas map[string]uint64 `yaml:"role_quotas"` + Policies []Policy `yaml:"policies"` + AdditionalPolicies []Policy `yaml:"additional_policies"` + OIDC OIDC `yaml:"oidc"` + ServiceAccount ServiceAccount `yaml:"service_account"` + RoleAssignment RoleAssignment `yaml:"role_assignment"` + PolicySelector *PolicySelector `yaml:"policy_selector"` + PreSignedURL PreSignedURL `yaml:"pre_signed_url"` + AccountBackend string `yaml:"account_backend" env:"PROXY_ACCOUNT_BACKEND_TYPE" desc:"Account backend the PROXY service should use. Currently only 'cs3' is possible here." introductionVersion:"pre5.0"` + UserOIDCClaim string `yaml:"user_oidc_claim" env:"PROXY_USER_OIDC_CLAIM" desc:"The name of an OpenID Connect claim that is used for resolving users with the account backend. The value of the claim must hold a per user unique, stable and non re-assignable identifier. The availability of claims depends on your Identity Provider. There are common claims available for most Identity providers like 'email' or 'preferred_username' but you can also add your own claim." introductionVersion:"pre5.0"` + UserCS3Claim string `yaml:"user_cs3_claim" env:"PROXY_USER_CS3_CLAIM" desc:"The name of a CS3 user attribute (claim) that should be mapped to the 'user_oidc_claim'. Supported values are 'username', 'mail' and 'userid'." introductionVersion:"pre5.0"` + MachineAuthAPIKey string `yaml:"machine_auth_api_key" env:"OCIS_MACHINE_AUTH_API_KEY;PROXY_MACHINE_AUTH_API_KEY" desc:"Machine auth API key used to validate internal requests necessary to access resources from other services." introductionVersion:"pre5.0" mask:"password"` + AutoprovisionAccounts bool `yaml:"auto_provision_accounts" env:"PROXY_AUTOPROVISION_ACCOUNTS" desc:"Set this to 'true' to automatically provision users that do not yet exist in the users service on-demand upon first sign-in. To use this a write-enabled libregraph user backend needs to be setup an running." introductionVersion:"pre5.0"` + AutoProvisionClaims AutoProvisionClaims `yaml:"auto_provision_claims"` + EnableBasicAuth bool `yaml:"enable_basic_auth" env:"PROXY_ENABLE_BASIC_AUTH" desc:"Set this to true to enable 'basic authentication' (username/password)." introductionVersion:"pre5.0"` + InsecureBackends bool `yaml:"insecure_backends" env:"PROXY_INSECURE_BACKENDS" desc:"Disable TLS certificate validation for all HTTP backend connections." introductionVersion:"pre5.0"` + BackendHTTPSCACert string `yaml:"backend_https_cacert" env:"PROXY_HTTPS_CACERT" desc:"Path/File for the root CA certificate used to validate the server’s TLS certificate for https enabled backend services." introductionVersion:"pre5.0"` + AuthMiddleware AuthMiddleware `yaml:"auth_middleware"` + PoliciesMiddleware PoliciesMiddleware `yaml:"policies_middleware"` + CSPConfigFileLocation string `yaml:"csp_config_file_location" env:"PROXY_CSP_CONFIG_FILE_LOCATION" desc:"The location of the CSP configuration file." introductionVersion:"6.0.0"` + Events Events `yaml:"events"` + ClaimSpaceManagement ClaimSpaceManagement `yaml:"claim_space_management"` Context context.Context `json:"-" yaml:"-"` } @@ -230,3 +231,11 @@ type Events struct { AuthUsername string `yaml:"username" env:"OCIS_EVENTS_AUTH_USERNAME;PROXY_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the ocis service which receives and delivers events between the services." introductionVersion:"7.0.0"` AuthPassword string `yaml:"password" env:"OCIS_EVENTS_AUTH_PASSWORD;PROXY_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the ocis service which receives and delivers events between the services." introductionVersion:"7.0.0"` } + +// ClaimSpaceManagement holds the configuration for claim managed spaces +type ClaimSpaceManagement struct { + Enabled bool `yaml:"enabled" env:"OCIS_CLAIMMANAGEDSPACES_ENABLED" desc:"Enables space management through OIDC claims" introductionVersion:"%%NEXT%%"` + Claim string `yaml:"claim" env:"OCIS_CLAIMMANAGEDSPACES_CLAIMNAME" desc:"The name of the claim used for space management" introductionVersion:"%%NEXT%%"` + Regexp string `yaml:"regexp" env:"OCIS_CLAIMMANAGEDSPACES_REGEXP" desc:"The regular expression that extracts spaceid and role from a claim" introductionVersion:"%%NEXT%%"` + Mapping []string `yaml:"mapping" env:"OCIS_CLAIMMANAGEDSPACES_MAPPING" desc:"(Optional) Mapping of oidc roles to ocis space roles. Example: 'oidcroleA:viewer,oidcroleB:manager'" introductionVersion:"%%NEXT%%"` +} diff --git a/services/proxy/pkg/middleware/options.go b/services/proxy/pkg/middleware/options.go index 8a213208908..fc45d26589b 100644 --- a/services/proxy/pkg/middleware/options.go +++ b/services/proxy/pkg/middleware/options.go @@ -74,6 +74,9 @@ type Options struct { // SkipUserInfo prevents the oidc middleware from querying the userinfo endpoint and read any claims directly from the access token instead SkipUserInfo bool EventsPublisher events.Publisher + // Service Accounts + ServiceAccountID string + ServiceAccountSecret string } // newOptions initializes the available default options. @@ -254,3 +257,11 @@ func EventsPublisher(ep events.Publisher) Option { o.EventsPublisher = ep } } + +// ServiceAccount sets the service account user +func ServiceAccount(id string, secret string) Option { + return func(o *Options) { + o.ServiceAccountID = id + o.ServiceAccountSecret = secret + } +} diff --git a/services/proxy/pkg/middleware/space_manager.go b/services/proxy/pkg/middleware/space_manager.go new file mode 100644 index 00000000000..7b9f7c0f435 --- /dev/null +++ b/services/proxy/pkg/middleware/space_manager.go @@ -0,0 +1,279 @@ +package middleware + +import ( + "context" + "net/http" + + gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1" + userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1" + rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1" + collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1" + storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1" + types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1" + "github.com/owncloud/ocis/v2/ocis-pkg/claimsmapper" + "github.com/owncloud/ocis/v2/ocis-pkg/log" + "github.com/owncloud/ocis/v2/ocis-pkg/oidc" + "github.com/owncloud/ocis/v2/services/proxy/pkg/config" + "github.com/owncloud/reva/v2/pkg/conversions" + revactx "github.com/owncloud/reva/v2/pkg/ctx" + "github.com/owncloud/reva/v2/pkg/rgrpc/todo/pool" + "github.com/owncloud/reva/v2/pkg/utils" + "google.golang.org/protobuf/types/known/fieldmaskpb" +) + +// SpaceManager return a middleware that manages space memberships +func SpaceManager(cfg config.ClaimSpaceManagement, opts ...Option) func(next http.Handler) http.Handler { + options := newOptions(opts...) + logger := options.Logger + + var cm claimsmapper.ClaimsMapper + if cfg.Enabled { + cm = claimsmapper.NewClaimsMapper(cfg.Regexp, cfg.Mapping) + } + + return func(next http.Handler) http.Handler { + return &claimSpaceManager{ + next: next, + logger: logger, + gws: options.RevaGatewaySelector, + mapper: cm, + serviceAccountID: options.ServiceAccountID, + serviceAccountSecret: options.ServiceAccountSecret, + claimName: cfg.Claim, + enabled: cfg.Enabled, + } + } +} + +type claimSpaceManager struct { + next http.Handler + logger log.Logger + gws pool.Selectable[gateway.GatewayAPIClient] + mapper claimsmapper.ClaimsMapper + serviceAccountID string + serviceAccountSecret string + claimName string + enabled bool +} + +func (csm claimSpaceManager) ServeHTTP(w http.ResponseWriter, req *http.Request) { + defer csm.next.ServeHTTP(w, req) + + if !csm.enabled { + return + } + + userid, spaceAssignments := csm.evaluateContext(req.Context()) + if userid == "" { + // no user in context, we omit this request + return + } + + ctx, gwc, err := csm.getCtx() + if err != nil { + csm.logger.Error().Err(err).Msg("could not get service user context") + return + } + + // get all project spaces + res, err := gwc.ListStorageSpaces(ctx, listStorageSpaceRequest()) + if err != nil { + csm.logger.Error().Err(err).Msg("error doing grpc request") + return + } + if res.GetStatus().GetCode() != rpc.Code_CODE_OK { + csm.logger.Error().Str("message", res.GetStatus().GetMessage()).Msg("unexpected status code doing listspaces request") + return + } + + for _, s := range res.GetStorageSpaces() { + hasAccess, actualPerms, err := getSpaceMemberStatus(s, userid) + if err != nil { + csm.logger.Error().Err(err).Msg("error extracting space member") + continue + } + + desiredRole := conversions.RoleFromName(spaceAssignments[s.GetRoot().GetOpaqueId()]) + shouldHaveAccess := desiredRole.Name != conversions.RoleUnknown + + switch { + case shouldHaveAccess && !hasAccess: + // add user to space + res, err := gwc.CreateShare(ctx, createShareRequest(userid, s, desiredRole.CS3ResourcePermissions())) + if err != nil { + csm.logger.Error().Err(err).Msg("error adding space member") + continue + } + if res.GetStatus().GetCode() != rpc.Code_CODE_OK { + csm.logger.Error().Str("message", res.GetStatus().GetMessage()).Msg("unexpected status code doing createshare request") + continue + } + + case !shouldHaveAccess && hasAccess: + // remove user from space + res, err := gwc.RemoveShare(ctx, removeShareRequest(userid, s)) + if err != nil { + csm.logger.Error().Err(err).Msg("error removing space member") + continue + } + if res.GetStatus().GetCode() != rpc.Code_CODE_OK { + csm.logger.Error().Str("message", res.GetStatus().GetMessage()).Msg("unexpected status code doing removeshare request") + continue + } + + case shouldHaveAccess && hasAccess && !permissionsEqual(actualPerms, desiredRole.CS3ResourcePermissions()): + // update user permissions + res, err := gwc.UpdateShare(ctx, updateShareRequest(userid, s, desiredRole.CS3ResourcePermissions())) + if err != nil { + csm.logger.Error().Err(err).Msg("error updating space member") + continue + } + if res.GetStatus().GetCode() != rpc.Code_CODE_OK { + csm.logger.Error().Str("message", res.GetStatus().GetMessage()).Msg("unexpected status code doing updateshare request") + continue + } + } + } +} + +// returns the service user context and the gateway client +func (csm claimSpaceManager) getCtx() (context.Context, gateway.GatewayAPIClient, error) { + gwc, err := csm.gws.Next() + if err != nil { + csm.logger.Error().Err(err).Msg("could not get gateway client") + return nil, nil, err + } + ctx, err := utils.GetServiceUserContext(csm.serviceAccountID, gwc, csm.serviceAccountSecret) + return ctx, gwc, err +} + +// returns the userid and the space assignments from the context +func (csm claimSpaceManager) evaluateContext(ctx context.Context) (string, map[string]string) { + u, _ := revactx.ContextGetUser(ctx) + return u.GetId().GetOpaqueId(), csm.getSpaceAssignments(ctx) +} + +// returns a map[spaceID]role +func (csm claimSpaceManager) getSpaceAssignments(ctx context.Context) map[string]string { + claims := oidc.FromContext(ctx) + values, ok := claims[csm.claimName].([]any) + if !ok { + csm.logger.Error().Interface("entitlements", claims["entitlements"]).Msg("entitlements claims are not a []string") + } + + assignments := make(map[string]string) + for _, ent := range values { + e, ok := ent.(string) + if !ok { + csm.logger.Error().Interface("entitlement", ent).Msg("entitlement is not a sting") + continue + } + + match, spaceid, role := csm.mapper.Exec(e) + if !match { + continue + } + assignments[spaceid] = role + } + + return assignments +} + +func getSpaceMemberStatus(space *storageprovider.StorageSpace, userid string) (bool, *storageprovider.ResourcePermissions, error) { + var permissionsMap map[string]*storageprovider.ResourcePermissions + if err := utils.ReadJSONFromOpaque(space.GetOpaque(), "grants", &permissionsMap); err != nil { + return false, nil, err + } + + for id, perm := range permissionsMap { + if id == userid { + return true, perm, nil + } + } + return false, nil, nil +} + +func permissionsEqual(p1, p2 *storageprovider.ResourcePermissions) bool { + if !conversions.SufficientCS3Permissions(p1, p2) { + return false + } + if !conversions.SufficientCS3Permissions(p2, p1) { + return false + } + return true +} + +func listStorageSpaceRequest() *storageprovider.ListStorageSpacesRequest { + return &storageprovider.ListStorageSpacesRequest{ + Opaque: utils.AppendPlainToOpaque(nil, "unrestricted", "true"), + Filters: []*storageprovider.ListStorageSpacesRequest_Filter{ + { + Type: storageprovider.ListStorageSpacesRequest_Filter_TYPE_SPACE_TYPE, + Term: &storageprovider.ListStorageSpacesRequest_Filter_SpaceType{ + SpaceType: "project", + }, + }, + }, + } +} + +func createShareRequest(userid string, space *storageprovider.StorageSpace, perms *storageprovider.ResourcePermissions) *collaboration.CreateShareRequest { + return &collaboration.CreateShareRequest{ + ResourceInfo: space.GetRootInfo(), + Grant: &collaboration.ShareGrant{ + Grantee: &storageprovider.Grantee{ + Type: storageprovider.GranteeType_GRANTEE_TYPE_USER, + Id: &storageprovider.Grantee_UserId{UserId: &userpb.UserId{ + OpaqueId: userid, + }}, + }, + Permissions: &collaboration.SharePermissions{ + Permissions: perms, + }, + }, + } +} + +func removeShareRequest(userid string, space *storageprovider.StorageSpace) *collaboration.RemoveShareRequest { + return &collaboration.RemoveShareRequest{ + Ref: &collaboration.ShareReference{ + Spec: &collaboration.ShareReference_Key{ + Key: &collaboration.ShareKey{ + ResourceId: space.GetRoot(), + Grantee: buildGrantee(userid)}, + }, + }, + } +} + +func updateShareRequest(userid string, s *storageprovider.StorageSpace, perms *storageprovider.ResourcePermissions) *collaboration.UpdateShareRequest { + o := &types.Opaque{ + Map: map[string]*types.OpaqueEntry{ + "spacegrant": {}, + }, + } + o = utils.AppendPlainToOpaque(o, "spacetype", "project") + return &collaboration.UpdateShareRequest{ + Share: &collaboration.Share{ + ResourceId: s.GetRoot(), + Grantee: buildGrantee(userid), + Permissions: &collaboration.SharePermissions{Permissions: perms}, + }, + UpdateMask: &fieldmaskpb.FieldMask{ + Paths: []string{"permissions"}, + }, + Opaque: o, + } + +} + +func buildGrantee(userid string) *storageprovider.Grantee { + return &storageprovider.Grantee{ + Type: storageprovider.GranteeType_GRANTEE_TYPE_USER, + Id: &storageprovider.Grantee_UserId{ + UserId: &userpb.UserId{ + OpaqueId: userid, + }, + }, + } +}