Initial QSfera import

This commit is contained in:
Курнат Андрей
2026-06-07 10:20:04 +03:00
commit 2315f25754
16485 changed files with 4826827 additions and 0 deletions
@@ -0,0 +1,54 @@
package command
import (
"fmt"
"net/http"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/parser"
"github.com/spf13/cobra"
)
// Health is the entrypoint for the health command.
func Health(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "health",
Short: "check health status",
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnError(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
resp, err := http.Get(
fmt.Sprintf(
"http://%s/healthz",
cfg.Debug.Addr,
),
)
if err != nil {
logger.Fatal().
Err(err).
Msg("Failed to request health check")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
logger.Fatal().
Int("code", resp.StatusCode).
Msg("Health seems to be in bad state")
}
logger.Debug().
Int("code", resp.StatusCode).
Msg("Health got a good state")
return nil
},
}
}
+36
View File
@@ -0,0 +1,36 @@
package command
import (
"os"
"github.com/qsfera/server/pkg/clihelper"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/spf13/cobra"
)
// GetCommands provides all commands for this service
func GetCommands(cfg *config.Config) []*cobra.Command {
return append([]*cobra.Command{
// start this service
Server(cfg),
// interaction with this service
// infos about this service
Health(cfg),
Version(cfg),
}, UnifiedRoles(cfg)...)
}
// Execute is the entry point for the qsfera graph command.
func Execute(cfg *config.Config) error {
app := clihelper.DefaultApp(&cobra.Command{
Use: "graph",
Short: "Serve Graph API for КуСфера",
})
app.AddCommand(GetCommands(cfg)...)
app.SetArgs(os.Args[1:])
return app.ExecuteContext(cfg.Context)
}
+126
View File
@@ -0,0 +1,126 @@
package command
import (
"context"
"fmt"
"os/signal"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/runner"
"github.com/qsfera/server/pkg/tracing"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/parser"
"github.com/qsfera/server/services/graph/pkg/metrics"
"github.com/qsfera/server/services/graph/pkg/server/debug"
"github.com/qsfera/server/services/graph/pkg/server/http"
"github.com/nats-io/nats.go"
"github.com/nats-io/nats.go/jetstream"
"github.com/pkg/errors"
"github.com/spf13/cobra"
)
// Server is the entrypoint for the server command.
func Server(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "server",
Short: fmt.Sprintf("start the %s service without runtime (unsupervised mode)", cfg.Service.Name),
PreRunE: func(cmd *cobra.Command, args []string) error {
return configlog.ReturnFatal(parser.ParseConfig(cfg))
},
RunE: func(cmd *cobra.Command, args []string) error {
logger := log.Configure(cfg.Service.Name, cfg.Commons, cfg.LogLevel)
traceProvider, err := tracing.GetTraceProvider(cmd.Context(), cfg.Commons.TracesExporter, cfg.Service.Name)
if err != nil {
return err
}
var cancel context.CancelFunc
if cfg.Context == nil {
cfg.Context, cancel = signal.NotifyContext(context.Background(), runner.StopSignals...)
defer cancel()
}
ctx := cfg.Context
mtrcs := metrics.New()
mtrcs.BuildInfo.WithLabelValues(version.GetString()).Set(1)
var kv jetstream.KeyValue
// Allow to run without a NATS store (e.g. for the standalone Education provisioning service)
if len(cfg.Store.Nodes) > 0 {
//Connect to NATS servers
natsOptions := nats.Options{
Servers: cfg.Store.Nodes,
User: cfg.Store.AuthUsername,
Password: cfg.Store.AuthPassword,
}
conn, err := natsOptions.Connect()
if err != nil {
return err
}
js, err := jetstream.New(conn)
if err != nil {
return err
}
kv, err = js.KeyValue(ctx, cfg.Store.Database)
if err != nil {
if !errors.Is(err, jetstream.ErrBucketNotFound) {
return fmt.Errorf("failed to get bucket (%s): %w", cfg.Store.Database, err)
}
kv, err = js.CreateKeyValue(ctx, jetstream.KeyValueConfig{
Bucket: cfg.Store.Database,
})
if err != nil {
return fmt.Errorf("failed to create bucket (%s): %w", cfg.Store.Database, err)
}
}
}
gr := runner.NewGroup()
{
server, err := http.Server(
http.Logger(logger),
http.Context(ctx),
http.Config(cfg),
http.Metrics(mtrcs),
http.TraceProvider(traceProvider),
http.NatsKeyValue(kv),
)
if err != nil {
logger.Error().Err(err).Str("transport", "http").Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGoMicroHttpServerRunner(cfg.Service.Name+".http", server))
}
{
server, err := debug.Server(
debug.Logger(logger),
debug.Context(ctx),
debug.Config(cfg),
)
if err != nil {
logger.Info().Err(err).Str("transport", "debug").Msg("Failed to initialize server")
return err
}
gr.Add(runner.NewGolangHttpServerRunner(cfg.Service.Name+".debug", server))
}
grResults := gr.Run(ctx)
// return the first non-nil error found in the results
for _, grResult := range grResults {
if grResult.RunnerError != nil {
return grResult.RunnerError
}
}
return nil
},
}
}
@@ -0,0 +1,103 @@
package command
import (
"os"
"slices"
"strings"
"github.com/qsfera/server/pkg/config/configlog"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/parser"
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/renderer"
"github.com/olekukonko/tablewriter/tw"
"github.com/spf13/cobra"
)
var (
unifiedRolesNames = map[string]string{
unifiedrole.UnifiedRoleViewerID: "Viewer",
unifiedrole.UnifiedRoleViewerListGrantsID: "ViewerListGrants",
unifiedrole.UnifiedRoleSpaceViewerID: "SpaceViewer",
unifiedrole.UnifiedRoleEditorID: "Editor",
unifiedrole.UnifiedRoleEditorListGrantsID: "EditorListGrants",
unifiedrole.UnifiedRoleSpaceEditorID: "SpaceEditor",
unifiedrole.UnifiedRoleSpaceEditorWithoutVersionsID: "SpaceEditorWithoutVersions",
unifiedrole.UnifiedRoleFileEditorID: "FileEditor",
unifiedrole.UnifiedRoleFileEditorListGrantsID: "FileEditorListGrants",
unifiedrole.UnifiedRoleEditorLiteID: "EditorLite",
unifiedrole.UnifiedRoleManagerID: "SpaceManager",
unifiedrole.UnifiedRoleSecureViewerID: "SecureViewer",
}
)
// UnifiedRoles bundles available commands for unified roles
func UnifiedRoles(cfg *config.Config) []*cobra.Command {
cmds := []*cobra.Command{
listUnifiedRoles(cfg),
}
for _, cmd := range cmds {
cmd.Use = strings.Join([]string{cmd.Use, "unified-roles"}, "-")
cmd.PreRunE = func(cmd *cobra.Command, args []string) error {
return configlog.ReturnError(parser.ParseConfig(cfg))
}
}
return cmds
}
// unifiedRolesStatus lists available unified roles, it contains an indicator to show if the role is enabled or not
func listUnifiedRoles(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "list",
Short: "list available unified roles",
RunE: func(cmd *cobra.Command, args []string) error {
r := tw.Rendition{
Settings: tw.Settings{
Separators: tw.Separators{
BetweenRows: tw.On,
},
},
}
tbl := tablewriter.NewTable(os.Stdout, tablewriter.WithRenderer(renderer.NewBlueprint(r)))
headers := []string{"Name", "UID", "Enabled", "Description", "Condition", "Allowed resource actions"}
tbl.Header(headers)
for _, definition := range unifiedrole.GetRoles(unifiedrole.RoleFilterAll()) {
const enabled = "enabled"
const disabled = "disabled"
rows := [][]string{
{unifiedRolesNames[definition.GetId()], definition.GetId(), disabled, definition.GetDescription()},
}
if slices.Contains(cfg.UnifiedRoles.AvailableRoles, definition.GetId()) {
rows[0][2] = enabled
}
for i, rolePermission := range definition.GetRolePermissions() {
actions := strings.Join(rolePermission.GetAllowedResourceActions(), "\n")
row := []string{rolePermission.GetCondition(), actions}
switch i {
case 0:
rows[0] = append(rows[0], row...)
default:
rows[0][4] = rows[0][4] + "\n" + rolePermission.GetCondition()
}
}
for _, row := range rows {
// balance the row before adding it to the table,
// this prevents the row from having empty columns.
tbl.Append(append(row, make([]string, len(headers)-len(row))...))
}
}
tbl.Render()
return nil
},
}
}
@@ -0,0 +1,50 @@
package command
import (
"fmt"
"os"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/version"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/olekukonko/tablewriter"
"github.com/olekukonko/tablewriter/tw"
"github.com/spf13/cobra"
)
// Version prints the service versions of all running instances.
func Version(cfg *config.Config) *cobra.Command {
return &cobra.Command{
Use: "version",
Short: "print the version of this binary and the running service instances",
RunE: func(cmd *cobra.Command, args []string) error {
fmt.Println("Version: " + version.GetString())
fmt.Printf("Compiled: %s\n", version.Compiled())
fmt.Println("")
reg := registry.GetRegistry()
services, err := reg.GetService(cfg.HTTP.Namespace + "." + cfg.Service.Name)
if err != nil {
fmt.Println(fmt.Errorf("could not get %s services from the registry: %v", cfg.Service.Name, err))
return err
}
if len(services) == 0 {
fmt.Println("No running " + cfg.Service.Name + " service found.")
return nil
}
table := tablewriter.NewTable(os.Stdout, tablewriter.WithHeaderAutoFormat(tw.Off))
table.Header([]string{"Version", "Address", "Id"})
for _, s := range services {
for _, n := range s.Nodes {
table.Append([]string{s.Version, n.Address, n.Id})
}
}
table.Render()
return nil
},
}
}
@@ -0,0 +1,7 @@
package config
// Application defines the available graph application configuration.
type Application struct {
ID string `yaml:"id" env:"GRAPH_APPLICATION_ID" desc:"The КуСфера application ID shown in the graph. All app roles are tied to this ID." introductionVersion:"1.0.0"`
DisplayName string `yaml:"displayname" env:"GRAPH_APPLICATION_DISPLAYNAME" desc:"The КуСфера application name." introductionVersion:"1.0.0"`
}
+15
View File
@@ -0,0 +1,15 @@
package config
import "time"
// Cache defines the available configuration for a cache store
type Cache struct {
Store string `yaml:"store" env:"OC_CACHE_STORE;GRAPH_CACHE_STORE" desc:"The type of the cache store. Supported values are: 'memory', 'redis-sentinel', 'nats-js-kv', 'noop'. See the text description for details." introductionVersion:"1.0.0"`
Nodes []string `yaml:"nodes" env:"OC_CACHE_STORE_NODES;GRAPH_CACHE_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store are configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
Database string `yaml:"database" env:"GRAPH_CACHE_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"1.0.0"`
Table string `yaml:"table" env:"GRAPH_CACHE_STORE_TABLE" desc:"The database table the store should use." introductionVersion:"1.0.0"`
TTL time.Duration `yaml:"ttl" env:"OC_CACHE_TTL;GRAPH_CACHE_TTL" desc:"Time to live for cache records in the graph. Defaults to '336h' (2 weeks). See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
DisablePersistence bool `yaml:"disable_persistence" env:"OC_CACHE_DISABLE_PERSISTENCE;GRAPH_CACHE_DISABLE_PERSISTENCE" desc:"Disables persistence of the cache. Only applies when store type 'nats-js-kv' is configured. Defaults to false." introductionVersion:"1.0.0"`
AuthUsername string `yaml:"username" env:"OC_CACHE_AUTH_USERNAME;GRAPH_CACHE_AUTH_USERNAME" desc:"The username to authenticate with the cache. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_CACHE_AUTH_PASSWORD;GRAPH_CACHE_AUTH_PASSWORD" desc:"The password to authenticate with the cache. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
}
+179
View File
@@ -0,0 +1,179 @@
package config
import (
"context"
"time"
"github.com/qsfera/server/pkg/shared"
)
// Config combines all available configuration parts.
type Config struct {
Commons *shared.Commons `yaml:"-"` // don't use this directly as configuration for a service
Service Service `yaml:"-"`
LogLevel string `yaml:"loglevel" env:"OC_LOG_LEVEL;GRAPH_LOG_LEVEL" desc:"The log level. Valid values are: 'panic', 'fatal', 'error', 'warn', 'info', 'debug', 'trace'." introductionVersion:"1.0.0"`
Cache *Cache `yaml:"cache"`
Debug Debug `yaml:"debug"`
HTTP HTTP `yaml:"http"`
API API `yaml:"api"`
Reva *shared.Reva `yaml:"reva"`
TokenManager *TokenManager `yaml:"token_manager"`
GRPCClientTLS *shared.GRPCClientTLS `yaml:"grpc_client_tls"`
Application Application `yaml:"application"`
Spaces Spaces `yaml:"spaces"`
Identity Identity `yaml:"identity"`
IncludeOCMSharees bool `yaml:"include_ocm_sharees" env:"OC_ENABLE_OCM;GRAPH_INCLUDE_OCM_SHAREES" desc:"Include OCM sharees when listing users." introductionVersion:"1.0.0"`
Events Events `yaml:"events"`
UnifiedRoles UnifiedRoles `yaml:"unified_roles"`
MaxConcurrency int `yaml:"max_concurrency" env:"OC_MAX_CONCURRENCY;GRAPH_MAX_CONCURRENCY" desc:"The maximum number of concurrent requests the service will handle." introductionVersion:"1.0.0"`
Keycloak Keycloak `yaml:"keycloak"`
ServiceAccount ServiceAccount `yaml:"service_account"`
Context context.Context `yaml:"-"`
Metadata Metadata `yaml:"metadata_config"`
UserSoftDeleteRetentionTime time.Duration `yaml:"user_soft_delete_retention_time" env:"GRAPH_USER_SOFT_DELETE_RETENTION_TIME" desc:"The time after which a soft-deleted user is permanently deleted. If set to 0 (default), there is no soft delete retention time and users are deleted immediately after being soft-deleted. If set to a positive value, the user will be kept in the system for that duration before being permanently deleted." introductionVersion:"4.0.0"`
Store Store `yaml:"store"`
}
type Spaces struct {
WebDavBase string `yaml:"webdav_base" env:"OC_URL;GRAPH_SPACES_WEBDAV_BASE" desc:"The public facing URL of WebDAV." introductionVersion:"1.0.0"`
WebDavPath string `yaml:"webdav_path" env:"GRAPH_SPACES_WEBDAV_PATH" desc:"The WebDAV sub-path for spaces." introductionVersion:"1.0.0"`
DefaultQuota string `yaml:"default_quota" env:"GRAPH_SPACES_DEFAULT_QUOTA" desc:"The default quota in bytes." introductionVersion:"1.0.0"`
ExtendedSpacePropertiesCacheTTL int `yaml:"extended_space_properties_cache_ttl" env:"GRAPH_SPACES_EXTENDED_SPACE_PROPERTIES_CACHE_TTL" desc:"Max TTL in seconds for the spaces property cache." introductionVersion:"1.0.0"`
UsersCacheTTL int `yaml:"users_cache_ttl" env:"GRAPH_SPACES_USERS_CACHE_TTL" desc:"Max TTL in seconds for the spaces users cache." introductionVersion:"1.0.0"`
GroupsCacheTTL int `yaml:"groups_cache_ttl" env:"GRAPH_SPACES_GROUPS_CACHE_TTL" desc:"Max TTL in seconds for the spaces groups cache." introductionVersion:"1.0.0"`
StorageUsersAddress string `yaml:"storage_users_address" env:"GRAPH_SPACES_STORAGE_USERS_ADDRESS" desc:"The address of the storage-users service." introductionVersion:"1.0.0"`
DefaultLanguage string `yaml:"default_language" env:"OC_DEFAULT_LANGUAGE" desc:"The default language used by services and the WebUI. If not defined, English will be used as default. See the documentation for more details." introductionVersion:"1.0.0"`
TranslationPath string `yaml:"translation_path" env:"OC_TRANSLATION_PATH;GRAPH_TRANSLATION_PATH" desc:"(optional) Set this to a path with custom translations to overwrite the builtin translations. Note that file and folder naming rules apply, see the documentation for more details." introductionVersion:"1.0.0"`
}
type LDAP struct {
URI string `yaml:"uri" env:"OC_LDAP_URI;GRAPH_LDAP_URI" desc:"URI of the LDAP Server to connect to. Supported URI schemes are 'ldaps://' and 'ldap://'" introductionVersion:"1.0.0"`
CACert string `yaml:"cacert" env:"OC_LDAP_CACERT;GRAPH_LDAP_CACERT" desc:"Path/File name for the root CA certificate (in PEM format) used to validate TLS server certificates of the LDAP service. If not defined, the root directory derives from $OC_BASE_DATA_PATH/idm." introductionVersion:"1.0.0"`
Insecure bool `yaml:"insecure" env:"OC_LDAP_INSECURE;GRAPH_LDAP_INSECURE" desc:"Disable TLS certificate validation for the LDAP connections. Do not set this in production environments." introductionVersion:"1.0.0"`
BindDN string `yaml:"bind_dn" env:"OC_LDAP_BIND_DN;GRAPH_LDAP_BIND_DN" desc:"LDAP DN to use for simple bind authentication with the target LDAP server." introductionVersion:"1.0.0"`
BindPassword string `yaml:"bind_password" env:"OC_LDAP_BIND_PASSWORD;GRAPH_LDAP_BIND_PASSWORD" desc:"Password to use for authenticating the 'bind_dn'." introductionVersion:"1.0.0"`
UseServerUUID bool `yaml:"use_server_uuid" env:"GRAPH_LDAP_SERVER_UUID" desc:"If set to true, rely on the LDAP Server to generate a unique ID for users and groups, like when using 'entryUUID' as the user ID attribute." introductionVersion:"1.0.0"`
UsePasswordModExOp bool `yaml:"use_password_modify_exop" env:"GRAPH_LDAP_SERVER_USE_PASSWORD_MODIFY_EXOP" desc:"Use the 'Password Modify Extended Operation' for updating user passwords." introductionVersion:"1.0.0"`
WriteEnabled bool `yaml:"write_enabled" env:"OC_LDAP_SERVER_WRITE_ENABLED;GRAPH_LDAP_SERVER_WRITE_ENABLED" desc:"Allow creating, modifying and deleting LDAP users via the GRAPH API. This can only be set to 'true' when keeping default settings for the LDAP user and group attribute types (the 'OC_LDAP_USER_SCHEMA_* and 'OC_LDAP_GROUP_SCHEMA_* variables)." introductionVersion:"1.0.0"`
RefintEnabled bool `yaml:"refint_enabled" env:"GRAPH_LDAP_REFINT_ENABLED" desc:"Signals that the server has the refint plugin enabled, which makes some actions not needed." introductionVersion:"1.0.0"`
UserBaseDN string `yaml:"user_base_dn" env:"OC_LDAP_USER_BASE_DN;GRAPH_LDAP_USER_BASE_DN" desc:"Search base DN for looking up LDAP users." introductionVersion:"1.0.0"`
UserSearchScope string `yaml:"user_search_scope" env:"OC_LDAP_USER_SCOPE;GRAPH_LDAP_USER_SCOPE" desc:"LDAP search scope to use when looking up users. Supported scopes are 'base', 'one' and 'sub'." introductionVersion:"1.0.0"`
UserFilter string `yaml:"user_filter" env:"OC_LDAP_USER_FILTER;GRAPH_LDAP_USER_FILTER" desc:"LDAP filter to add to the default filters for user search like '(objectclass=qsferaUser)'." introductionVersion:"1.0.0"`
UserObjectClass string `yaml:"user_objectclass" env:"OC_LDAP_USER_OBJECTCLASS;GRAPH_LDAP_USER_OBJECTCLASS" desc:"The object class to use for users in the default user search filter ('inetOrgPerson')." introductionVersion:"1.0.0"`
UserEmailAttribute string `yaml:"user_mail_attribute" env:"OC_LDAP_USER_SCHEMA_MAIL;GRAPH_LDAP_USER_EMAIL_ATTRIBUTE" desc:"LDAP Attribute to use for the email address of users." introductionVersion:"1.0.0"`
UserDisplayNameAttribute string `yaml:"user_displayname_attribute" env:"OC_LDAP_USER_SCHEMA_DISPLAYNAME;GRAPH_LDAP_USER_DISPLAYNAME_ATTRIBUTE" desc:"LDAP Attribute to use for the display name of users." introductionVersion:"1.0.0"`
UserNameAttribute string `yaml:"user_name_attribute" env:"OC_LDAP_USER_SCHEMA_USERNAME;GRAPH_LDAP_USER_NAME_ATTRIBUTE" desc:"LDAP Attribute to use for username of users." introductionVersion:"1.0.0"`
UserIDAttribute string `yaml:"user_id_attribute" env:"OC_LDAP_USER_SCHEMA_ID;GRAPH_LDAP_USER_UID_ATTRIBUTE" desc:"LDAP Attribute to use as the unique ID for users. This should be a stable globally unique ID like a UUID." introductionVersion:"1.0.0"`
UserIDIsOctetString bool `yaml:"user_id_is_octet_string" env:"OC_LDAP_USER_SCHEMA_ID_IS_OCTETSTRING;GRAPH_LDAP_USER_SCHEMA_ID_IS_OCTETSTRING" desc:"Set this to true if the defined 'ID' attribute for users is of the 'OCTETSTRING' syntax. This is required when using the 'objectGUID' attribute of Active Directory for the user ID's." introductionVersion:"1.0.0"`
UserTypeAttribute string `yaml:"user_type_attribute" env:"OC_LDAP_USER_SCHEMA_USER_TYPE;GRAPH_LDAP_USER_TYPE_ATTRIBUTE" desc:"LDAP Attribute to distinguish between 'Member' and 'Guest' users. Default is 'qsferaUserType'." introductionVersion:"1.0.0"`
UserEnabledAttribute string `yaml:"user_enabled_attribute" env:"OC_LDAP_USER_ENABLED_ATTRIBUTE;GRAPH_USER_ENABLED_ATTRIBUTE" desc:"LDAP Attribute to use as a flag telling if the user is enabled or disabled." introductionVersion:"1.0.0"`
DisableUserMechanism string `yaml:"disable_user_mechanism" env:"OC_LDAP_DISABLE_USER_MECHANISM;GRAPH_DISABLE_USER_MECHANISM" desc:"An option to control the behavior for disabling users. Supported options are 'none', 'attribute' and 'group'. If set to 'group', disabling a user via API will add the user to the configured group for disabled users, if set to 'attribute' this will be done in the ldap user entry, if set to 'none' the disable request is not processed. Default is 'attribute'." introductionVersion:"1.0.0"`
LdapDisabledUsersGroupDN string `yaml:"ldap_disabled_users_group_dn" env:"OC_LDAP_DISABLED_USERS_GROUP_DN;GRAPH_DISABLED_USERS_GROUP_DN" desc:"The distinguished name of the group to which added users will be classified as disabled when 'disable_user_mechanism' is set to 'group'." introductionVersion:"1.0.0"`
GroupBaseDN string `yaml:"group_base_dn" env:"OC_LDAP_GROUP_BASE_DN;GRAPH_LDAP_GROUP_BASE_DN" desc:"Search base DN for looking up LDAP groups." introductionVersion:"1.0.0"`
GroupCreateBaseDN string `yaml:"group_create_base_dn" env:"GRAPH_LDAP_GROUP_CREATE_BASE_DN" desc:"Parent DN under which new groups are created. This DN needs to be subordinate to the 'GRAPH_LDAP_GROUP_BASE_DN'. This setting is only relevant when 'GRAPH_LDAP_SERVER_WRITE_ENABLED' is 'true'. It defaults to the value of 'GRAPH_LDAP_GROUP_BASE_DN'. All groups outside of this subtree are treated as readonly groups and cannot be updated." introductionVersion:"1.0.0"`
GroupSearchScope string `yaml:"group_search_scope" env:"OC_LDAP_GROUP_SCOPE;GRAPH_LDAP_GROUP_SEARCH_SCOPE" desc:"LDAP search scope to use when looking up groups. Supported scopes are 'base', 'one' and 'sub'." introductionVersion:"1.0.0"`
GroupFilter string `yaml:"group_filter" env:"OC_LDAP_GROUP_FILTER;GRAPH_LDAP_GROUP_FILTER" desc:"LDAP filter to add to the default filters for group searches." introductionVersion:"1.0.0"`
GroupObjectClass string `yaml:"group_objectclass" env:"OC_LDAP_GROUP_OBJECTCLASS;GRAPH_LDAP_GROUP_OBJECTCLASS" desc:"The object class to use for groups in the default group search filter ('groupOfNames')." introductionVersion:"1.0.0"`
GroupNameAttribute string `yaml:"group_name_attribute" env:"OC_LDAP_GROUP_SCHEMA_GROUPNAME;GRAPH_LDAP_GROUP_NAME_ATTRIBUTE" desc:"LDAP Attribute to use for the name of groups." introductionVersion:"1.0.0"`
GroupMemberAttribute string `yaml:"group_member_attribute" env:"OC_LDAP_GROUP_SCHEMA_MEMBER;GRAPH_LDAP_GROUP_MEMBER_ATTRIBUTE" desc:"LDAP Attribute that is used for group members." introductionVersion:"1.0.0"`
GroupIDAttribute string `yaml:"group_id_attribute" env:"OC_LDAP_GROUP_SCHEMA_ID;GRAPH_LDAP_GROUP_ID_ATTRIBUTE" desc:"LDAP Attribute to use as the unique id for groups. This should be a stable globally unique ID like a UUID." introductionVersion:"1.0.0"`
GroupIDIsOctetString bool `yaml:"group_id_is_octet_string" env:"OC_LDAP_GROUP_SCHEMA_ID_IS_OCTETSTRING;GRAPH_LDAP_GROUP_SCHEMA_ID_IS_OCTETSTRING" desc:"Set this to true if the defined 'ID' attribute for groups is of the 'OCTETSTRING' syntax. This is required when using the 'objectGUID' attribute of Active Directory for the group ID's." introductionVersion:"1.0.0"`
EducationResourcesEnabled bool `yaml:"education_resources_enabled" env:"GRAPH_LDAP_EDUCATION_RESOURCES_ENABLED" desc:"Enable LDAP support for managing education related resources." introductionVersion:"1.0.0"`
EducationConfig LDAPEducationConfig
}
// LDAPEducationConfig represents the LDAP configuration for education related resources
type LDAPEducationConfig struct {
SchoolBaseDN string `yaml:"school_base_dn" env:"GRAPH_LDAP_SCHOOL_BASE_DN" desc:"Search base DN for looking up LDAP schools." introductionVersion:"1.0.0"`
SchoolSearchScope string `yaml:"school_search_scope" env:"GRAPH_LDAP_SCHOOL_SEARCH_SCOPE" desc:"LDAP search scope to use when looking up schools. Supported scopes are 'base', 'one' and 'sub'." introductionVersion:"1.0.0"`
SchoolFilter string `yaml:"school_filter" env:"GRAPH_LDAP_SCHOOL_FILTER" desc:"LDAP filter to add to the default filters for school searches." introductionVersion:"1.0.0"`
SchoolObjectClass string `yaml:"school_objectclass" env:"GRAPH_LDAP_SCHOOL_OBJECTCLASS" desc:"The object class to use for schools in the default school search filter." introductionVersion:"1.0.0"`
SchoolNameAttribute string `yaml:"school_name_attribute" env:"GRAPH_LDAP_SCHOOL_NAME_ATTRIBUTE" desc:"LDAP Attribute to use for the name of a school." introductionVersion:"1.0.0"`
SchoolNumberAttribute string `yaml:"school_number_attribute" env:"GRAPH_LDAP_SCHOOL_NUMBER_ATTRIBUTE" desc:"LDAP Attribute to use for the number of a school." introductionVersion:"1.0.0"`
SchoolIDAttribute string `yaml:"school_id_attribute" env:"GRAPH_LDAP_SCHOOL_ID_ATTRIBUTE" desc:"LDAP Attribute to use as the unique id for schools. This should be a stable globally unique ID like a UUID." introductionVersion:"1.0.0"`
SchoolTerminationGraceDays int `yaml:"school_termination_min_grace_days" env:"GRAPH_LDAP_SCHOOL_TERMINATION_MIN_GRACE_DAYS" desc:"When setting a 'terminationDate' for a school, require the date to be at least this number of days in the future." introductionVersion:"1.0.0"`
}
type Identity struct {
Backend string `yaml:"backend" env:"GRAPH_IDENTITY_BACKEND" desc:"The user identity backend to use. Supported backend types are 'ldap' and 'cs3'." introductionVersion:"1.0.0"`
LDAP LDAP `yaml:"ldap"`
}
// API represents API configuration parameters.
type API struct {
GroupMembersPatchLimit int `yaml:"group_members_patch_limit" env:"GRAPH_GROUP_MEMBERS_PATCH_LIMIT" desc:"The amount of group members allowed to be added with a single patch request." introductionVersion:"1.0.0"`
UsernameMatch string `yaml:"graph_username_match" env:"GRAPH_USERNAME_MATCH" desc:"Apply restrictions to usernames. Supported values are 'default' and 'none'. When set to 'default', user names must not start with a number and are restricted to ASCII characters. When set to 'none', no restrictions are applied. The default value is 'default'." introductionVersion:"1.0.0"`
AssignDefaultUserRole bool `yaml:"graph_assign_default_user_role" env:"GRAPH_ASSIGN_DEFAULT_USER_ROLE" desc:"Whether to assign newly created users the default role 'User'. Set this to 'false' if you want to assign roles manually, or if the role assignment should happen at first login. Set this to 'true' (the default) to assign the role 'User' when creating a new user." introductionVersion:"1.0.0"`
IdentitySearchMinLength int `yaml:"graph_identity_search_min_length" env:"GRAPH_IDENTITY_SEARCH_MIN_LENGTH" desc:"The minimum length the search term needs to have for unprivileged users when searching for users or groups." introductionVersion:"1.0.0"`
ShowUserEmailInResults bool `yaml:"show_email_in_results" env:"OC_SHOW_USER_EMAIL_IN_RESULTS" desc:"Include user email addresses in responses. If absent or set to false emails will be omitted from results. Please note that admin users can always see all email addresses." introductionVersion:"1.0.0"`
}
// Events combines the configuration options for the event bus.
type Events struct {
Endpoint string `yaml:"endpoint" env:"OC_EVENTS_ENDPOINT;GRAPH_EVENTS_ENDPOINT" desc:"The address of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture. Set to a empty string to disable emitting events." introductionVersion:"1.0.0"`
Cluster string `yaml:"cluster" env:"OC_EVENTS_CLUSTER;GRAPH_EVENTS_CLUSTER" desc:"The clusterID of the event system. The event system is the message queuing service. It is used as message broker for the microservice architecture." introductionVersion:"1.0.0"`
TLSInsecure bool `yaml:"tls_insecure" env:"OC_INSECURE;OC_EVENTS_TLS_INSECURE;GRAPH_EVENTS_TLS_INSECURE" desc:"Whether to verify the server TLS certificates." introductionVersion:"1.0.0"`
TLSRootCACertificate string `yaml:"tls_root_ca_certificate" env:"OC_EVENTS_TLS_ROOT_CA_CERTIFICATE;GRAPH_EVENTS_TLS_ROOT_CA_CERTIFICATE" desc:"The root CA certificate used to validate the server's TLS certificate. If provided GRAPH_EVENTS_TLS_INSECURE will be seen as false." introductionVersion:"1.0.0"`
EnableTLS bool `yaml:"enable_tls" env:"OC_EVENTS_ENABLE_TLS;GRAPH_EVENTS_ENABLE_TLS" desc:"Enable TLS for the connection to the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
AuthUsername string `yaml:"username" env:"OC_EVENTS_AUTH_USERNAME;GRAPH_EVENTS_AUTH_USERNAME" desc:"The username to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_EVENTS_AUTH_PASSWORD;GRAPH_EVENTS_AUTH_PASSWORD" desc:"The password to authenticate with the events broker. The events broker is the КуСфера service which receives and delivers events between the services." introductionVersion:"1.0.0"`
}
// CORS defines the available cors configuration.
type CORS struct {
AllowedOrigins []string `yaml:"allow_origins" env:"OC_CORS_ALLOW_ORIGINS;GRAPH_CORS_ALLOW_ORIGINS" desc:"A list of allowed CORS origins. See following chapter for more details: *Access-Control-Allow-Origin* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Origin. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AllowedMethods []string `yaml:"allow_methods" env:"OC_CORS_ALLOW_METHODS;GRAPH_CORS_ALLOW_METHODS" desc:"A list of allowed CORS methods. See following chapter for more details: *Access-Control-Request-Method* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Method. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AllowedHeaders []string `yaml:"allow_headers" env:"OC_CORS_ALLOW_HEADERS;GRAPH_CORS_ALLOW_HEADERS" desc:"A list of allowed CORS headers. See following chapter for more details: *Access-Control-Request-Headers* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Request-Headers. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
AllowCredentials bool `yaml:"allow_credentials" env:"OC_CORS_ALLOW_CREDENTIALS;GRAPH_CORS_ALLOW_CREDENTIALS" desc:"Allow credentials for CORS.See following chapter for more details: *Access-Control-Allow-Credentials* at https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Access-Control-Allow-Credentials." introductionVersion:"1.0.0"`
}
// Keycloak configuration
type Keycloak struct {
BasePath string `yaml:"base_path" env:"OC_KEYCLOAK_BASE_PATH;GRAPH_KEYCLOAK_BASE_PATH" desc:"The URL to access keycloak." introductionVersion:"1.0.0"`
ClientID string `yaml:"client_id" env:"OC_KEYCLOAK_CLIENT_ID;GRAPH_KEYCLOAK_CLIENT_ID" desc:"The client id to authenticate with keycloak." introductionVersion:"1.0.0"`
ClientSecret string `yaml:"client_secret" env:"OC_KEYCLOAK_CLIENT_SECRET;GRAPH_KEYCLOAK_CLIENT_SECRET" desc:"The client secret to use in authentication." introductionVersion:"1.0.0"`
ClientRealm string `yaml:"client_realm" env:"OC_KEYCLOAK_CLIENT_REALM;GRAPH_KEYCLOAK_CLIENT_REALM" desc:"The realm the client is defined in." introductionVersion:"1.0.0"`
UserRealm string `yaml:"user_realm" env:"OC_KEYCLOAK_USER_REALM;GRAPH_KEYCLOAK_USER_REALM" desc:"The realm users are defined." introductionVersion:"1.0.0"`
InsecureSkipVerify bool `yaml:"insecure_skip_verify" env:"OC_KEYCLOAK_INSECURE_SKIP_VERIFY;GRAPH_KEYCLOAK_INSECURE_SKIP_VERIFY" desc:"Disable TLS certificate validation for Keycloak connections. Do not set this in production environments." introductionVersion:"1.0.0"`
}
// ServiceAccount is the configuration for the used service account
type ServiceAccount struct {
ServiceAccountID string `yaml:"service_account_id" env:"OC_SERVICE_ACCOUNT_ID;GRAPH_SERVICE_ACCOUNT_ID" desc:"The ID of the service account the service should use. See the 'auth-service' service description for more details." introductionVersion:"1.0.0"`
ServiceAccountSecret string `yaml:"service_account_secret" env:"OC_SERVICE_ACCOUNT_SECRET;GRAPH_SERVICE_ACCOUNT_SECRET" desc:"The service account secret." introductionVersion:"1.0.0"`
}
// Metadata configures the metadata store to use
type Metadata struct {
GatewayAddress string `yaml:"gateway_addr" env:"GRAPH_STORAGE_GATEWAY_GRPC_ADDR;STORAGE_GATEWAY_GRPC_ADDR" desc:"GRPC address of the STORAGE-SYSTEM service." introductionVersion:"4.0.0"`
StorageAddress string `yaml:"storage_addr" env:"GRAPH_STORAGE_GRPC_ADDR;STORAGE_GRPC_ADDR" desc:"GRPC address of the STORAGE-SYSTEM service." introductionVersion:"4.0.0"`
SystemUserID string `yaml:"system_user_id" env:"OC_SYSTEM_USER_ID;GRAPH_SYSTEM_USER_ID" desc:"ID of the КуСфера STORAGE-SYSTEM system user. Admins need to set the ID for the STORAGE-SYSTEM system user in this config option which is then used to reference the user. Any reasonable long string is possible, preferably this would be an UUIDv4 format." introductionVersion:"4.0.0"`
SystemUserIDP string `yaml:"system_user_idp" env:"OC_SYSTEM_USER_IDP;GRAPH_SYSTEM_USER_IDP" desc:"IDP of the КуСфера STORAGE-SYSTEM system user." introductionVersion:"4.0.0"`
SystemUserAPIKey string `yaml:"system_user_api_key" env:"OC_SYSTEM_USER_API_KEY" desc:"API key for the STORAGE-SYSTEM system user." introductionVersion:"4.0.0"`
}
// Store configures the store to use
type Store struct {
Nodes []string `yaml:"nodes" env:"OC_PERSISTENT_STORE_NODES;GRAPH_STORE_NODES" desc:"A list of nodes to access the configured store. This has no effect when 'memory' store is configured. Note that the behaviour how nodes are used is dependent on the library of the configured store. See the Environment Variable Types description for more details." introductionVersion:"1.0.0"`
Database string `yaml:"database" env:"GRAPH_STORE_DATABASE" desc:"The database name the configured store should use." introductionVersion:"1.0.0"`
AuthUsername string `yaml:"username" env:"OC_PERSISTENT_STORE_AUTH_USERNAME;GRAPH_STORE_AUTH_USERNAME" desc:"The username to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
AuthPassword string `yaml:"password" env:"OC_PERSISTENT_STORE_AUTH_PASSWORD;GRAPH_STORE_AUTH_PASSWORD" desc:"The password to authenticate with the store. Only applies when store type 'nats-js-kv' is configured." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,9 @@
package config
// Debug defines the available debug configuration.
type Debug struct {
Addr string `yaml:"addr" env:"GRAPH_DEBUG_ADDR" desc:"Bind address of the debug server, where metrics, health, config and debug endpoints will be exposed." introductionVersion:"1.0.0"`
Token string `yaml:"token" env:"GRAPH_DEBUG_TOKEN" desc:"Token to secure the metrics endpoint." introductionVersion:"1.0.0"`
Pprof bool `yaml:"pprof" env:"GRAPH_DEBUG_PPROF" desc:"Enables pprof, which can be used for profiling." introductionVersion:"1.0.0"`
Zpages bool `yaml:"zpages" env:"GRAPH_DEBUG_ZPAGES" desc:"Enables zpages, which can be used for collecting and viewing in-memory traces." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,208 @@
package defaults
import (
"path"
"strings"
"time"
"github.com/qsfera/server/pkg/config/defaults"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/pkg/structs"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
)
var (
// _disabledByDefaultUnifiedRoleRoleIDs contains all roles that are not enabled by default,
// but can be enabled by the user.
_disabledByDefaultUnifiedRoleRoleIDs = []string{
unifiedrole.UnifiedRoleSecureViewerID,
unifiedrole.UnifiedRoleSpaceEditorWithoutVersionsID,
unifiedrole.UnifiedRoleViewerListGrantsID,
unifiedrole.UnifiedRoleEditorListGrantsID,
unifiedrole.UnifiedRoleFileEditorListGrantsID,
unifiedrole.UnifiedRoleDeniedID,
}
)
// FullDefaultConfig returns a fully initialized default configuration
func FullDefaultConfig() *config.Config {
cfg := DefaultConfig()
EnsureDefaults(cfg)
Sanitize(cfg)
return cfg
}
// DefaultConfig returns a basic default configuration
func DefaultConfig() *config.Config {
return &config.Config{
Debug: config.Debug{
Addr: "127.0.0.1:9124",
Token: "",
},
HTTP: config.HTTP{
Addr: "127.0.0.1:9120",
Namespace: "qsfera.web",
Root: "/graph",
CORS: config.CORS{
AllowedOrigins: []string{"*"},
AllowedMethods: []string{"GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS"},
AllowedHeaders: []string{"Authorization", "Origin", "Content-Type", "Accept", "X-Requested-With", "X-Request-Id", "Purge", "Restore"},
AllowCredentials: true,
},
},
Service: config.Service{
Name: "graph",
},
Application: config.Application{
DisplayName: "КуСфера",
},
API: config.API{
GroupMembersPatchLimit: 20,
UsernameMatch: "default",
AssignDefaultUserRole: true,
IdentitySearchMinLength: 3,
},
Reva: shared.DefaultRevaConfig(),
Spaces: config.Spaces{
StorageUsersAddress: "qsfera.api.storage-users",
WebDavBase: "https://localhost:9200",
WebDavPath: "/dav/spaces/",
DefaultQuota: "1000000000",
// 1 minute
ExtendedSpacePropertiesCacheTTL: 60,
// 1 minute
GroupsCacheTTL: 60,
// 1 minute
UsersCacheTTL: 60,
},
Identity: config.Identity{
Backend: "ldap",
LDAP: config.LDAP{
URI: "ldaps://localhost:9235",
Insecure: false,
CACert: path.Join(defaults.BaseDataPath(), "idm", "ldap.crt"),
BindDN: "uid=libregraph,ou=sysusers,o=libregraph-idm",
UseServerUUID: false,
UsePasswordModExOp: true,
WriteEnabled: true,
UserBaseDN: "ou=users,o=libregraph-idm",
UserSearchScope: "sub",
UserFilter: "",
UserObjectClass: "inetOrgPerson",
UserEmailAttribute: "mail",
UserDisplayNameAttribute: "displayName",
UserNameAttribute: "uid",
// FIXME: switch this to some more widely available attribute by default
// ideally this needs to be constant for the lifetime of a users
UserIDAttribute: "qsferaUUID",
UserTypeAttribute: "qsferaUserType",
UserEnabledAttribute: "qsferaUserEnabled",
DisableUserMechanism: "attribute",
LdapDisabledUsersGroupDN: "cn=DisabledUsersGroup,ou=groups,o=libregraph-idm",
GroupBaseDN: "ou=groups,o=libregraph-idm",
GroupSearchScope: "sub",
GroupFilter: "",
GroupObjectClass: "groupOfNames",
GroupNameAttribute: "cn",
GroupMemberAttribute: "member",
GroupIDAttribute: "qsferaUUID",
EducationResourcesEnabled: false,
},
},
Cache: &config.Cache{
Store: "memory",
Nodes: []string{"127.0.0.1:9233"},
Database: "cache-roles",
TTL: time.Hour * 24,
},
Events: config.Events{
Endpoint: "127.0.0.1:9233",
Cluster: "qsfera-cluster",
EnableTLS: false,
},
MaxConcurrency: 20,
UnifiedRoles: config.UnifiedRoles{
AvailableRoles: nil, // will be populated with defaults in EnsureDefaults
},
Metadata: config.Metadata{
GatewayAddress: "qsfera.api.storage-system",
StorageAddress: "qsfera.api.storage-system",
SystemUserIDP: "internal",
},
UserSoftDeleteRetentionTime: 0,
Store: config.Store{
Nodes: []string{"127.0.0.1:9233"},
Database: "graph",
},
}
}
// EnsureDefaults adds default values to the configuration if they are not set yet
func EnsureDefaults(cfg *config.Config) {
if cfg.LogLevel == "" {
cfg.LogLevel = "error"
}
if cfg.Cache == nil && cfg.Commons != nil && cfg.Commons.Cache != nil {
cfg.Cache = &config.Cache{
Store: cfg.Commons.Cache.Store,
Nodes: cfg.Commons.Cache.Nodes,
}
} else if cfg.Cache == nil {
cfg.Cache = &config.Cache{}
}
if cfg.TokenManager == nil && cfg.Commons != nil && cfg.Commons.TokenManager != nil {
cfg.TokenManager = &config.TokenManager{
JWTSecret: cfg.Commons.TokenManager.JWTSecret,
}
} else if cfg.TokenManager == nil {
cfg.TokenManager = &config.TokenManager{}
}
if cfg.GRPCClientTLS == nil && cfg.Commons != nil {
cfg.GRPCClientTLS = structs.CopyOrZeroValue(cfg.Commons.GRPCClientTLS)
}
if cfg.Commons != nil {
cfg.HTTP.TLS = cfg.Commons.HTTPServiceTLS
}
if cfg.Identity.LDAP.GroupCreateBaseDN == "" {
cfg.Identity.LDAP.GroupCreateBaseDN = cfg.Identity.LDAP.GroupBaseDN
}
// set default roles, if no roles are defined, we need to take care and provide all the default roles
if len(cfg.UnifiedRoles.AvailableRoles) == 0 {
for _, definition := range unifiedrole.GetRoles(
// filter out the roles that are disabled by default
unifiedrole.RoleFilterInvert(unifiedrole.RoleFilterIDs(_disabledByDefaultUnifiedRoleRoleIDs...)),
) {
cfg.UnifiedRoles.AvailableRoles = append(cfg.UnifiedRoles.AvailableRoles, definition.GetId())
}
}
if cfg.Metadata.SystemUserAPIKey == "" && cfg.Commons != nil && cfg.Commons.SystemUserAPIKey != "" {
cfg.Metadata.SystemUserAPIKey = cfg.Commons.SystemUserAPIKey
}
if cfg.Metadata.SystemUserID == "" && cfg.Commons != nil && cfg.Commons.SystemUserID != "" {
cfg.Metadata.SystemUserID = cfg.Commons.SystemUserID
}
}
// Sanitize sanitized the configuration
func Sanitize(cfg *config.Config) {
// sanitize config
if cfg.HTTP.Root != "/" {
cfg.HTTP.Root = strings.TrimSuffix(cfg.HTTP.Root, "/")
}
// convert ttl to millisecond
// the config is in seconds, therefore we need multiply it.
cfg.Spaces.ExtendedSpacePropertiesCacheTTL = cfg.Spaces.ExtendedSpacePropertiesCacheTTL * int(time.Second)
cfg.Spaces.GroupsCacheTTL = cfg.Spaces.GroupsCacheTTL * int(time.Second)
cfg.Spaces.UsersCacheTTL = cfg.Spaces.UsersCacheTTL * int(time.Second)
}
+13
View File
@@ -0,0 +1,13 @@
package config
import "github.com/qsfera/server/pkg/shared"
// HTTP defines the available http configuration.
type HTTP struct {
Addr string `yaml:"addr" env:"GRAPH_HTTP_ADDR" desc:"The bind address of the HTTP service." introductionVersion:"1.0.0"`
Namespace string `yaml:"-"`
Root string `yaml:"root" env:"GRAPH_HTTP_ROOT" desc:"Subdirectory that serves as the root for this HTTP service." introductionVersion:"1.0.0"`
TLS shared.HTTPServiceTLS `yaml:"tls"`
APIToken string `yaml:"apitoken" env:"GRAPH_HTTP_API_TOKEN" desc:"An optional API bearer token" introductionVersion:"1.0.0"`
CORS CORS `yaml:"cors"`
}
@@ -0,0 +1,126 @@
package parser
import (
"errors"
"fmt"
"slices"
"github.com/go-ldap/ldap/v3"
occfg "github.com/qsfera/server/pkg/config"
defaults2 "github.com/qsfera/server/pkg/config/defaults"
"github.com/qsfera/server/pkg/config/envdecode"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
)
// ParseConfig loads configuration from known paths.
func ParseConfig(cfg *config.Config) error {
err := occfg.BindSourcesToStructs(cfg.Service.Name, cfg)
if err != nil {
return err
}
defaults.EnsureDefaults(cfg)
// load all env variables relevant to the config in the current context.
if err := envdecode.Decode(cfg); err != nil {
// no environment variable set for this config is an expected "error"
if !errors.Is(err, envdecode.ErrNoTargetFieldsAreSet) {
return err
}
}
defaults.Sanitize(cfg)
return Validate(cfg)
}
func Validate(cfg *config.Config) error {
if cfg.TokenManager.JWTSecret == "" {
return shared.MissingJWTTokenError(cfg.Service.Name)
}
if !slices.Contains([]string{"ldap", "cs3"}, cfg.Identity.Backend) {
return fmt.Errorf("'%s' is not a valid identity backend for the 'graph' service", cfg.Identity.Backend)
}
// ensure that the "cs3" identity backend is used in multi-tenant setups
if cfg.Commons.MultiTenantEnabled && cfg.Identity.Backend != "cs3" {
return fmt.Errorf("Multi-tenant support is enabled. The identity backend must be set to 'cs3' for the 'graph' service.")
}
if cfg.Identity.Backend == "ldap" {
if err := validateLDAPSettings(cfg); err != nil {
return err
}
}
if cfg.Application.ID == "" {
return fmt.Errorf("The application ID has not been configured for %s. "+
"Make sure your %s config contains the proper values "+
"(e.g. by running qsfera init or setting it manually in "+
"the config/corresponding environment variable).",
"graph", defaults2.BaseConfigPath())
}
switch cfg.API.UsernameMatch {
case "default", "none":
default:
return fmt.Errorf("The username match validator is invalid for %s. "+
"Make sure your %s config contains the proper values "+
"(e.g. by running qsfera init or setting it manually in "+
"the config/corresponding environment variable).",
"graph", defaults2.BaseConfigPath())
}
if cfg.ServiceAccount.ServiceAccountID == "" {
return shared.MissingServiceAccountID(cfg.Service.Name)
}
if cfg.ServiceAccount.ServiceAccountSecret == "" {
return shared.MissingServiceAccountSecret(cfg.Service.Name)
}
// validate unified roles
{
var err error
for _, uid := range cfg.UnifiedRoles.AvailableRoles {
// check if the role is known
if len(unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(uid))) == 0 {
// collect all possible errors to return them all at once
err = errors.Join(err, fmt.Errorf("%w: %s", unifiedrole.ErrUnknownRole, uid))
}
}
if err != nil {
return err
}
}
return nil
}
func validateLDAPSettings(cfg *config.Config) error {
if cfg.Identity.LDAP.BindPassword == "" {
return shared.MissingLDAPBindPassword(cfg.Service.Name)
}
// ensure that "GroupBaseDN" is below "GroupBaseDN"
if cfg.Identity.LDAP.WriteEnabled && cfg.Identity.LDAP.GroupCreateBaseDN != cfg.Identity.LDAP.GroupBaseDN {
baseDN, err := ldap.ParseDN(cfg.Identity.LDAP.GroupBaseDN)
if err != nil {
return fmt.Errorf("Unable to parse the LDAP Group Base DN '%s': %w ", cfg.Identity.LDAP.GroupBaseDN, err)
}
createBaseDN, err := ldap.ParseDN(cfg.Identity.LDAP.GroupCreateBaseDN)
if err != nil {
return fmt.Errorf("Unable to parse the LDAP Group Create Base DN '%s': %w ", cfg.Identity.LDAP.GroupCreateBaseDN, err)
}
if !baseDN.AncestorOfFold(createBaseDN) {
return fmt.Errorf("The LDAP Group Create Base DN (%s) must be subordinate to the LDAP Group Base DN (%s)", cfg.Identity.LDAP.GroupCreateBaseDN, cfg.Identity.LDAP.GroupBaseDN)
}
}
return nil
}
@@ -0,0 +1,69 @@
package parser_test
import (
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/graph/pkg/config/parser"
)
var _ = Describe("Validate", func() {
var cfg *config.Config
BeforeEach(func() {
cfg = defaults.DefaultConfig()
cfg.Application.ID = "graph-app-id"
cfg.ServiceAccount.ServiceAccountID = "graph-service-account"
cfg.ServiceAccount.ServiceAccountSecret = "graph-service-password"
cfg.Commons = &shared.Commons{
TokenManager: &shared.TokenManager{
JWTSecret: "jwt-secret",
},
}
defaults.EnsureDefaults(cfg)
})
When("multi-tenant support is disabled", func() {
It("should accept a setup with the 'cs3' identity backend", func() {
cfg.Identity.Backend = "cs3"
err := parser.Validate(cfg)
Expect(err).ToNot(HaveOccurred())
})
It("should accept a setup with the 'ldap' identity backend", func() {
cfg.Identity.Backend = "ldap"
// we need to set a password to pass validation
cfg.Identity.LDAP.BindPassword = "bind-password"
err := parser.Validate(cfg)
Expect(err).ToNot(HaveOccurred())
})
})
When("multi-tenant support is disabled", func() {
BeforeEach(func() {
cfg.Commons.MultiTenantEnabled = true
})
It("should accept a setup with the 'cs3' identity backend", func() {
cfg.Identity.Backend = "cs3"
err := parser.Validate(cfg)
Expect(err).ToNot(HaveOccurred())
})
It("should reject a setup with the 'ldap' identity backend", func() {
cfg.Identity.Backend = "ldap"
cfg.Identity.LDAP.BindPassword = "bind-password"
err := parser.Validate(cfg)
Expect(err).To(HaveOccurred())
Expect(err).To(MatchError(ContainSubstring("The identity backend must be set to 'cs3' for the 'graph' service.")))
})
})
It("rejcts a setup with an invalid identity backend", func() {
cfg.Identity.Backend = "invalid-backend"
err := parser.Validate(cfg)
Expect(err).To(HaveOccurred())
Expect(err).To(MatchError(ContainSubstring("is not a valid identity backend")))
})
})
@@ -0,0 +1,13 @@
package parser_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestParser(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Parser Suite")
}
+6
View File
@@ -0,0 +1,6 @@
package config
// TokenManager is the config for using the reva token manager
type TokenManager struct {
JWTSecret string `yaml:"jwt_secret" env:"OC_JWT_SECRET;GRAPH_JWT_SECRET" desc:"The secret to mint and validate jwt tokens." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,6 @@
package config
// Service defines the available service configuration.
type Service struct {
Name string `yaml:"-"`
}
@@ -0,0 +1,6 @@
package config
// UnifiedRoles contains all settings related to unified roles.
type UnifiedRoles struct {
AvailableRoles []string `yaml:"available_roles" env:"GRAPH_AVAILABLE_ROLES" desc:"A comma separated list of roles that are available for assignment." introductionVersion:"1.0.0"`
}
@@ -0,0 +1,83 @@
package errorcode
import (
"slices"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
// FromCS3Status converts a CS3 status code and an error into a corresponding local Error representation.
//
// It takes a *cs3rpc.Status, an error, and a variadic parameter of type cs3rpc.Code.
// If the error is not nil, it creates an Error object with the error message and a GeneralException code.
// If the error is nil, it evaluates the provided CS3 status code and returns an equivalent graph Error.
// If the CS3 status code does not have a direct equivalent within the app,
// or is ignored, a general purpose Error is returned.
//
// This function is particularly useful when dealing with CS3 responses,
// and a unified error handling within the application is necessary.
func FromCS3Status(status *cs3rpc.Status, inerr error, ignore ...cs3rpc.Code) error {
err := Error{errorCode: GeneralException, msg: "unspecified error has occurred", origin: ErrorOriginCS3}
if inerr != nil {
err.msg = inerr.Error()
return err
}
if status != nil {
err.msg = status.GetMessage()
}
code := status.GetCode()
switch {
case slices.Contains(ignore, status.GetCode()):
fallthrough
case code == cs3rpc.Code_CODE_OK:
return nil
case code == cs3rpc.Code_CODE_NOT_FOUND:
err.errorCode = ItemNotFound
case code == cs3rpc.Code_CODE_PERMISSION_DENIED:
err.errorCode = AccessDenied
case code == cs3rpc.Code_CODE_UNAUTHENTICATED:
err.errorCode = Unauthenticated
case code == cs3rpc.Code_CODE_INVALID_ARGUMENT:
err.errorCode = InvalidRequest
case code == cs3rpc.Code_CODE_ALREADY_EXISTS:
err.errorCode = NameAlreadyExists
case code == cs3rpc.Code_CODE_FAILED_PRECONDITION:
err.errorCode = InvalidRequest
case code == cs3rpc.Code_CODE_OUT_OF_RANGE:
err.errorCode = InvalidRange
case code == cs3rpc.Code_CODE_UNIMPLEMENTED:
err.errorCode = NotSupported
case code == cs3rpc.Code_CODE_UNAVAILABLE:
err.errorCode = ServiceNotAvailable
case code == cs3rpc.Code_CODE_INSUFFICIENT_STORAGE:
err.errorCode = QuotaLimitReached
case code == cs3rpc.Code_CODE_LOCKED:
err.errorCode = ItemIsLocked
}
return err
}
// FromStat transforms a *provider.StatResponse object and an error into an Error.
//
// It takes a stat of type *provider.StatResponse, an error, and a variadic parameter of type cs3rpc.Code.
// It invokes the FromCS3Status function with the StatResponse Status and the ignore codes.
func FromStat(stat *provider.StatResponse, err error, ignore ...cs3rpc.Code) error {
// TODO: look into ResourceInfo to get the postprocessing state and map that to 425 status?
return FromCS3Status(stat.GetStatus(), err, ignore...)
}
// FromUtilsStatusCodeError returns original error if `err` does not match to the statusCodeError type
func FromUtilsStatusCodeError(err error, ignore ...cs3rpc.Code) error {
stat := utils.StatusCodeErrorToCS3Status(err)
if stat == nil {
return FromCS3Status(nil, err, ignore...)
}
return FromCS3Status(stat, nil, ignore...)
}
@@ -0,0 +1,73 @@
package errorcode_test
import (
"errors"
"reflect"
"testing"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
func TestFromCS3Status(t *testing.T) {
var tests = []struct {
status *cs3rpc.Status
err error
ignore []cs3rpc.Code
expected error
}{
{nil, nil, nil, errorcode.New(errorcode.GeneralException, "unspecified error has occurred")},
{nil, errors.New("test error"), nil, errorcode.New(errorcode.GeneralException, "test error")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_OK}, nil, nil, nil},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_NOT_FOUND}, nil, []cs3rpc.Code{cs3rpc.Code_CODE_NOT_FOUND}, nil},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_PERMISSION_DENIED}, nil, []cs3rpc.Code{cs3rpc.Code_CODE_NOT_FOUND, cs3rpc.Code_CODE_PERMISSION_DENIED}, nil},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_NOT_FOUND, Message: "msg"}, nil, nil, errorcode.New(errorcode.ItemNotFound, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_PERMISSION_DENIED, Message: "msg"}, nil, nil, errorcode.New(errorcode.AccessDenied, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_UNAUTHENTICATED, Message: "msg"}, nil, nil, errorcode.New(errorcode.Unauthenticated, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_INVALID_ARGUMENT, Message: "msg"}, nil, nil, errorcode.New(errorcode.InvalidRequest, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_ALREADY_EXISTS, Message: "msg"}, nil, nil, errorcode.New(errorcode.NameAlreadyExists, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_FAILED_PRECONDITION, Message: "msg"}, nil, nil, errorcode.New(errorcode.InvalidRequest, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_UNIMPLEMENTED, Message: "msg"}, nil, nil, errorcode.New(errorcode.NotSupported, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_INVALID, Message: "msg"}, nil, nil, errorcode.New(errorcode.GeneralException, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_CANCELLED, Message: "msg"}, nil, nil, errorcode.New(errorcode.GeneralException, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_UNKNOWN, Message: "msg"}, nil, nil, errorcode.New(errorcode.GeneralException, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_RESOURCE_EXHAUSTED, Message: "msg"}, nil, nil, errorcode.New(errorcode.GeneralException, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_ABORTED, Message: "msg"}, nil, nil, errorcode.New(errorcode.GeneralException, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_OUT_OF_RANGE, Message: "msg"}, nil, nil, errorcode.New(errorcode.InvalidRange, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_INTERNAL, Message: "msg"}, nil, nil, errorcode.New(errorcode.GeneralException, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_UNAVAILABLE, Message: "msg"}, nil, nil, errorcode.New(errorcode.ServiceNotAvailable, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_REDIRECTION, Message: "msg"}, nil, nil, errorcode.New(errorcode.GeneralException, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_INSUFFICIENT_STORAGE, Message: "msg"}, nil, nil, errorcode.New(errorcode.QuotaLimitReached, "msg")},
{&cs3rpc.Status{Code: cs3rpc.Code_CODE_LOCKED, Message: "msg"}, nil, nil, errorcode.New(errorcode.ItemIsLocked, "msg")},
}
for _, test := range tests {
var e errorcode.Error
if errors.As(test.expected, &e) {
test.expected = e.WithOrigin(errorcode.ErrorOriginCS3)
}
if got := errorcode.FromCS3Status(test.status, test.err, test.ignore...); !reflect.DeepEqual(got, test.expected) {
t.Error("Test Failed: {} expected, received: {}", test.expected, got)
}
}
}
func TestFromStat(t *testing.T) {
var tests = []struct {
stat *provider.StatResponse
err error
result error
}{
{nil, errors.New("some error"), errorcode.New(errorcode.GeneralException, "some error").WithOrigin(errorcode.ErrorOriginCS3)},
{&provider.StatResponse{Status: &cs3rpc.Status{Code: cs3rpc.Code_CODE_OK}}, nil, nil},
}
for _, test := range tests {
if output := errorcode.FromStat(test.stat, test.err); !reflect.DeepEqual(output, test.result) {
t.Error("Test Failed: {} expected, received: {}", test.result, output)
}
}
}
@@ -0,0 +1,208 @@
// Package errorcode allows to deal with graph error codes
package errorcode
import (
"context"
"errors"
"net/http"
"time"
"github.com/go-chi/chi/v5/middleware"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
// Error defines a custom error struct, containing and MS Graph error code and a textual error message
type Error struct {
errorCode ErrorCode
msg string
origin ErrorOrigin
}
// ErrorOrigin gives information about where the error originated
type ErrorOrigin int
const (
// ErrorOriginUnknown is the default error source
// and indicates that the error does not have any information about its origin
ErrorOriginUnknown ErrorOrigin = iota
// ErrorOriginCS3 indicates that the error originated from a CS3 service
ErrorOriginCS3
)
// ErrorCode defines code as used in MS Graph - see https://docs.microsoft.com/en-us/graph/errors?context=graph%2Fapi%2F1.0&view=graph-rest-1.0
type ErrorCode int
// List taken from https://github.com/microsoft/microsoft-graph-docs-1/blob/main/concepts/errors.md#code-property
const (
// AccessDenied defines the error if the caller doesn't have permission to perform the action.
AccessDenied ErrorCode = iota
// ActivityLimitReached defines the error if the app or user has been throttled.
ActivityLimitReached
// GeneralException defines the error if an unspecified error has occurred.
GeneralException
// InvalidAuthenticationToken defines the error if the access token is missing
InvalidAuthenticationToken
// InvalidRange defines the error if the specified byte range is invalid or unavailable.
InvalidRange
// InvalidRequest defines the error if the request is malformed or incorrect.
InvalidRequest
// ItemNotFound defines the error if the resource could not be found.
ItemNotFound
// MalwareDetected defines the error if malware was detected in the requested resource.
MalwareDetected
// NameAlreadyExists defines the error if the specified item name already exists.
NameAlreadyExists
// NotAllowed defines the error if the action is not allowed by the system.
NotAllowed
// NotSupported defines the error if the request is not supported by the system.
NotSupported
// ResourceModified defines the error if the resource being updated has changed since the caller last read it, usually an eTag mismatch.
ResourceModified
// ResyncRequired defines the error if the delta token is no longer valid, and the app must reset the sync state.
ResyncRequired
// ServiceNotAvailable defines the error if the service is not available. Try the request again after a delay. There may be a Retry-After header.
ServiceNotAvailable
// SyncStateNotFound defines the error when the sync state generation is not found. The delta token is expired and data must be synchronized again.
SyncStateNotFound
// QuotaLimitReached the user has reached their quota limit.
QuotaLimitReached
// Unauthenticated the caller is not authenticated.
Unauthenticated
// PreconditionFailed the request cannot be made and this error response is sent back
PreconditionFailed
// ItemIsLocked The item is locked by another process. Try again later.
ItemIsLocked
)
var errorCodes = [...]string{
"accessDenied",
"activityLimitReached",
"generalException",
"InvalidAuthenticationToken",
"invalidRange",
"invalidRequest",
"itemNotFound",
"malwareDetected",
"nameAlreadyExists",
"notAllowed",
"notSupported",
"resourceModified",
"resyncRequired",
"serviceNotAvailable",
"syncStateNotFound",
"quotaLimitReached",
"unauthenticated",
"preconditionFailed",
"itemIsLocked",
}
// New constructs a new errorcode.Error
func New(e ErrorCode, msg string) Error {
return Error{
errorCode: e,
msg: msg,
}
}
// Render writes a Graph ErrorCode object to the response writer
func (e ErrorCode) Render(w http.ResponseWriter, r *http.Request, status int, msg string) {
render.Status(r, status)
render.JSON(w, r, e.CreateOdataError(r.Context(), msg))
}
// CreateOdataError creates and populates a Graph ErrorCode object
func (e ErrorCode) CreateOdataError(ctx context.Context, msg string) *libregraph.OdataError {
innererror := map[string]any{
"date": time.Now().UTC().Format(time.RFC3339),
}
innererror["request-id"] = middleware.GetReqID(ctx)
return &libregraph.OdataError{
Error: libregraph.OdataErrorMain{
Code: e.String(),
Message: msg,
Innererror: innererror,
},
}
}
// Render writes a Graph Error object to the response writer
func (e Error) Render(w http.ResponseWriter, r *http.Request) {
var status int
switch e.errorCode {
case AccessDenied:
status = http.StatusForbidden
case NotSupported:
status = http.StatusNotImplemented
case InvalidRange:
status = http.StatusRequestedRangeNotSatisfiable
case InvalidRequest:
status = http.StatusBadRequest
case ItemNotFound:
status = http.StatusNotFound
case NameAlreadyExists:
status = http.StatusConflict
case NotAllowed:
status = http.StatusMethodNotAllowed
case ItemIsLocked:
status = http.StatusLocked
case PreconditionFailed:
status = http.StatusPreconditionFailed
default:
status = http.StatusInternalServerError
}
e.errorCode.Render(w, r, status, e.msg)
}
// String returns the string corresponding to the ErrorCode
func (e ErrorCode) String() string {
return errorCodes[e]
}
// Error returns the concatenation of the error string and optional message
func (e Error) Error() string {
errString := errorCodes[e.errorCode]
if e.msg != "" {
errString += ": " + e.msg
}
return errString
}
func (e Error) GetCode() ErrorCode {
return e.errorCode
}
// GetOrigin returns the source of the error
func (e Error) GetOrigin() ErrorOrigin {
return e.origin
}
// WithOrigin returns a new Error with the provided origin
func (e Error) WithOrigin(o ErrorOrigin) Error {
e.origin = o
return e
}
// RenderError render the Graph Error based on a code or default one
func RenderError(w http.ResponseWriter, r *http.Request, err error) {
e, ok := ToError(err)
if !ok {
GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
e.Render(w, r)
}
// ToError checks if the error is of type Error and returns it,
// the second parameter indicates if the error conversion was successful
func ToError(err error) (Error, bool) {
var e Error
if errors.As(err, &e) {
return e, true
}
return Error{}, false
}
@@ -0,0 +1,45 @@
package errorcode_test
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/stretchr/testify/require"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
type customErr struct{}
func (customErr) Error() string {
return "some error"
}
func TestRenderError(t *testing.T) {
t.Parallel()
t.Run("errorcode.Error value error", func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
err := errorcode.New(errorcode.ItemNotFound, "test error")
errorcode.RenderError(w, r, err)
require.Equal(t, http.StatusNotFound, w.Code)
})
t.Run("errorcode.Error zero value error", func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
var err errorcode.Error
errorcode.RenderError(w, r, err)
require.Equal(t, http.StatusForbidden, w.Code)
})
t.Run("custom error", func(t *testing.T) {
r := httptest.NewRequest("GET", "/", nil)
w := httptest.NewRecorder()
var err customErr
errorcode.RenderError(w, r, err)
require.Equal(t, http.StatusInternalServerError, w.Code)
})
}
@@ -0,0 +1,168 @@
package identity
import (
"context"
"net/url"
"time"
"github.com/CiscoM31/godata"
cs3group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
// Errors used by the interfaces
var (
// ErrReadOnly signals that the backend is set to read only.
ErrReadOnly = errorcode.New(errorcode.NotAllowed, "server is configured read-only")
// ErrNotFound signals that the requested resource was not found.
ErrNotFound = errorcode.New(errorcode.ItemNotFound, "not found")
// ErrUnsupportedFilter signals that the requested filter is not supported by the backend.
ErrUnsupportedFilter = godata.NotImplementedError("unsupported filter")
)
const (
UserTypeMember = "Member"
UserTypeGuest = "Guest"
UserTypeFederated = "Federated"
)
// Backend defines the Interface for an IdentityBackend implementation
type Backend interface {
// CreateUser creates a given user in the identity backend.
CreateUser(ctx context.Context, user libregraph.User) (*libregraph.User, error)
// DeleteUser deletes a given user, identified by username or id, from the backend
DeleteUser(ctx context.Context, nameOrID string) error
// UpdateUser applies changes to given user, identified by username or id
UpdateUser(ctx context.Context, nameOrID string, user libregraph.UserUpdate) (*libregraph.User, error)
GetUser(ctx context.Context, nameOrID string, oreq *godata.GoDataRequest) (*libregraph.User, error)
GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.User, error)
// FilterUsers returns a list of users that match the filter
FilterUsers(ctx context.Context, oreq *godata.GoDataRequest, filter *godata.ParseNode) ([]*libregraph.User, error)
UpdateLastSignInDate(ctx context.Context, userID string, timestamp time.Time) error
// CreateGroup creates the supplied group in the identity backend.
CreateGroup(ctx context.Context, group libregraph.Group) (*libregraph.Group, error)
// DeleteGroup deletes a given group, identified by id
DeleteGroup(ctx context.Context, id string) error
// UpdateGroupName updates the group name
UpdateGroupName(ctx context.Context, groupID string, groupName string) error
GetGroup(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.Group, error)
GetGroups(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.Group, error)
// GetGroupMembers list all members of a group
GetGroupMembers(ctx context.Context, id string, oreq *godata.GoDataRequest) ([]*libregraph.User, error)
// AddMembersToGroup adds new members (reference by a slice of IDs) to supplied group in the identity backend.
AddMembersToGroup(ctx context.Context, groupID string, memberID []string) error
// RemoveMemberFromGroup removes a single member (by ID) from a group
RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error
}
// EducationBackend defines the Interface for an EducationBackend implementation
type EducationBackend interface {
// CreateEducationSchool creates the supplied school in the identity backend.
CreateEducationSchool(ctx context.Context, group libregraph.EducationSchool) (*libregraph.EducationSchool, error)
// DeleteEducationSchool deletes a given school, identified by id
DeleteEducationSchool(ctx context.Context, id string) error
// GetEducationSchool reads a given school by id
GetEducationSchool(ctx context.Context, nameOrID string) (*libregraph.EducationSchool, error)
// GetEducationSchools lists all schools
GetEducationSchools(ctx context.Context) ([]*libregraph.EducationSchool, error)
// FilterEducationSchoolsByAttribute list all schools where an attribute matches a value, e.g. all schools with a given externalId
FilterEducationSchoolsByAttribute(ctx context.Context, attr, value string) ([]*libregraph.EducationSchool, error)
// UpdateEducationSchool updates attributes of a school
UpdateEducationSchool(ctx context.Context, numberOrID string, school libregraph.EducationSchool) (*libregraph.EducationSchool, error)
// GetEducationSchoolUsers lists all members of a school
GetEducationSchoolUsers(ctx context.Context, id string) ([]*libregraph.EducationUser, error)
// AddUsersToEducationSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend.
AddUsersToEducationSchool(ctx context.Context, schoolID string, memberID []string) error
// RemoveUserFromEducationSchool removes a single member (by ID) from a school
RemoveUserFromEducationSchool(ctx context.Context, schoolID string, memberID string) error
// GetEducationSchoolClasses lists all classes in a school
GetEducationSchoolClasses(ctx context.Context, schoolNumberOrID string) ([]*libregraph.EducationClass, error)
// AddClassesToEducationSchool adds new classes (referenced by a slice of IDs) to supplied school in the identity backend.
AddClassesToEducationSchool(ctx context.Context, schoolNumberOrID string, memberIDs []string) error
// RemoveClassFromEducationSchool removes a class from a school.
RemoveClassFromEducationSchool(ctx context.Context, schoolNumberOrID string, memberID string) error
// GetEducationClasses lists all classes
GetEducationClasses(ctx context.Context) ([]*libregraph.EducationClass, error)
// GetEducationClass reads a given class by id
GetEducationClass(ctx context.Context, namedOrID string) (*libregraph.EducationClass, error)
// CreateEducationClass creates the supplied education class in the identity backend.
CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error)
// DeleteEducationClass deletes the supplied education class in the identity backend.
DeleteEducationClass(ctx context.Context, nameOrID string) error
// GetEducationClassMembers returns the EducationUser members for an EducationClass
GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error)
// UpdateEducationClass updates properties of the supplied class in the identity backend.
UpdateEducationClass(ctx context.Context, id string, class libregraph.EducationClass) (*libregraph.EducationClass, error)
// CreateEducationUser creates a given education user in the identity backend.
CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error)
// DeleteEducationUser deletes a given education user, identified by username or id, from the backend
DeleteEducationUser(ctx context.Context, nameOrID string) error
// UpdateEducationUser applies changes to given education user, identified by username or id
UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error)
// GetEducationUser reads an education user by id or name
GetEducationUser(ctx context.Context, nameOrID string) (*libregraph.EducationUser, error)
// GetEducationUsers lists all education users
GetEducationUsers(ctx context.Context) ([]*libregraph.EducationUser, error)
// FilterEducationUsersByAttribute list all education users where and attribute matches a value, e.g. all users with a given externalid
FilterEducationUsersByAttribute(ctx context.Context, attr, value string) ([]*libregraph.EducationUser, error)
// GetEducationClassTeachers returns the EducationUser teachers for an EducationClass
GetEducationClassTeachers(ctx context.Context, classID string) ([]*libregraph.EducationUser, error)
// AddTeacherToEducationClass adds a teacher (by ID) to class in the identity backend.
AddTeacherToEducationClass(ctx context.Context, classID string, teacherID string) error
// RemoveTeacherFromEducationClass removes teacher (by ID) from a class
RemoveTeacherFromEducationClass(ctx context.Context, classID string, teacherID string) error
}
// CreateUserModelFromCS3 converts a cs3 User object into a libregraph.User
func CreateUserModelFromCS3(u *cs3user.User) *libregraph.User {
if u.GetId() == nil {
u.Id = &cs3user.UserId{}
}
userType := CS3UserTypeToGraph(u.GetId().GetType())
user := &libregraph.User{
Identities: []libregraph.ObjectIdentity{{
Issuer: &u.GetId().Idp,
IssuerAssignedId: &u.GetId().OpaqueId,
}},
UserType: &userType,
DisplayName: u.GetDisplayName(),
Mail: &u.Mail,
OnPremisesSamAccountName: u.GetUsername(),
Id: &u.GetId().OpaqueId,
}
if u.GetId().GetType() == cs3user.UserType_USER_TYPE_FEDERATED {
ocmUserId := u.GetId().GetOpaqueId() + "@" + u.GetId().GetIdp()
user.Id = &ocmUserId
}
return user
}
func CS3UserTypeToGraph(cs3type cs3user.UserType) string {
switch cs3type {
case cs3user.UserType_USER_TYPE_PRIMARY:
return UserTypeMember
case cs3user.UserType_USER_TYPE_FEDERATED:
return UserTypeFederated
case cs3user.UserType_USER_TYPE_GUEST:
return UserTypeGuest
}
return "unknown"
}
// CreateGroupModelFromCS3 converts a cs3 Group object into a libregraph.Group
func CreateGroupModelFromCS3(g *cs3group.Group) *libregraph.Group {
if g.GetId() == nil {
g.Id = &cs3group.GroupId{}
}
return &libregraph.Group{
Id: &g.Id.OpaqueId,
DisplayName: &g.GroupName,
}
}
+215
View File
@@ -0,0 +1,215 @@
package cache
import (
"context"
"errors"
"strings"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3Group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
cs3User "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
"github.com/jellydator/ttlcache/v3"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/identity"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
revautils "github.com/opencloud-eu/reva/v2/pkg/utils"
)
// IdentityCache implements a simple ttl based cache for looking up users and groups by ID
type IdentityCache struct {
users *ttlcache.Cache[string, *cs3User.User]
groups *ttlcache.Cache[string, libregraph.Group]
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
}
type identityCacheOptions struct {
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
usersTTL time.Duration
groupsTTL time.Duration
}
// IdentityCacheOption defines a single option function.
type IdentityCacheOption func(o *identityCacheOptions)
// IdentityCacheWithGatewaySelector set the gatewaySelector for the Identity Cache
func IdentityCacheWithGatewaySelector(gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) IdentityCacheOption {
return func(o *identityCacheOptions) {
o.gatewaySelector = gatewaySelector
}
}
// IdentityCacheWithUsersTTL sets the TTL for the users cache
func IdentityCacheWithUsersTTL(ttl time.Duration) IdentityCacheOption {
return func(o *identityCacheOptions) {
o.usersTTL = ttl
}
}
// IdentityCacheWithGroupsTTL sets the TTL for the groups cache
func IdentityCacheWithGroupsTTL(ttl time.Duration) IdentityCacheOption {
return func(o *identityCacheOptions) {
o.groupsTTL = ttl
}
}
func newOptions(opts ...IdentityCacheOption) identityCacheOptions {
opt := identityCacheOptions{}
for _, o := range opts {
o(&opt)
}
return opt
}
// NewIdentityCache instantiates a new IdentityCache and sets the supplied options
func NewIdentityCache(opts ...IdentityCacheOption) IdentityCache {
opt := newOptions(opts...)
var cache IdentityCache
cache.users = ttlcache.New(
ttlcache.WithTTL[string, *cs3user.User](opt.usersTTL),
ttlcache.WithDisableTouchOnHit[string, *cs3user.User](),
)
go cache.users.Start()
cache.groups = ttlcache.New(
ttlcache.WithTTL[string, libregraph.Group](opt.groupsTTL),
ttlcache.WithDisableTouchOnHit[string, libregraph.Group](),
)
go cache.groups.Start()
cache.gatewaySelector = opt.gatewaySelector
return cache
}
// GetUser looks up a user by id, if the user is not cached, yet it will do a lookup via the CS3 API
func (cache IdentityCache) GetUser(ctx context.Context, tenantId, userid string) (libregraph.User, error) {
// can we get the tenant from the context or do we have to pass it?
u, err := cache.GetCS3User(ctx, tenantId, userid)
if err != nil {
return libregraph.User{}, err
}
if tenantId != u.GetId().GetTenantId() {
return libregraph.User{}, identity.ErrNotFound
}
return *identity.CreateUserModelFromCS3(u), nil
}
func (cache IdentityCache) GetCS3User(ctx context.Context, tenantId, userid string) (*cs3User.User, error) {
var user *cs3User.User
if item := cache.users.Get(tenantId + "|" + userid); item == nil {
gatewayClient, err := cache.gatewaySelector.Next()
if err != nil {
return nil, errorcode.New(errorcode.GeneralException, err.Error())
}
cs3UserID := &cs3User.UserId{
OpaqueId: userid,
TenantId: tenantId,
}
user, err = revautils.GetUserNoGroups(ctx, cs3UserID, gatewayClient)
if err != nil {
if revautils.IsErrNotFound(err) {
return nil, identity.ErrNotFound
}
return nil, errorcode.New(errorcode.GeneralException, err.Error())
}
cache.users.Set(tenantId+"|"+userid, user, ttlcache.DefaultTTL)
} else {
user = item.Value()
}
return user, nil
}
// GetAcceptedUser looks up a user by id, if the user is not cached, yet it will do a lookup via the CS3 API
func (cache IdentityCache) GetAcceptedUser(ctx context.Context, userid string) (libregraph.User, error) {
u, err := cache.GetAcceptedCS3User(ctx, userid)
if err != nil {
return libregraph.User{}, err
}
return *identity.CreateUserModelFromCS3(u), nil
}
func getIDAndMeshProvider(user string) (id, provider string, err error) {
last := strings.LastIndex(user, "@")
if last == -1 {
return "", "", errors.New("not in the form <id>@<provider>")
}
if len(user[:last]) == 0 {
return "", "", errors.New("empty id")
}
if len(user[last+1:]) == 0 {
return "", "", errors.New("empty provider")
}
return user[:last], user[last+1:], nil
}
func (cache IdentityCache) GetAcceptedCS3User(ctx context.Context, userid string) (*cs3User.User, error) {
var user *cs3user.User
if item := cache.users.Get(userid); item == nil {
gatewayClient, err := cache.gatewaySelector.Next()
if err != nil {
return nil, errorcode.New(errorcode.GeneralException, err.Error())
}
id, provider, err := getIDAndMeshProvider(userid)
if err != nil {
return nil, errorcode.New(errorcode.InvalidRequest, err.Error())
}
cs3UserID := &cs3User.UserId{
Idp: provider,
OpaqueId: id,
Type: cs3User.UserType_USER_TYPE_FEDERATED,
}
user, err = revautils.GetAcceptedUserWithContext(ctx, cs3UserID, gatewayClient)
if err != nil {
if revautils.IsErrNotFound(err) {
return nil, identity.ErrNotFound
}
return nil, errorcode.New(errorcode.GeneralException, err.Error())
}
cache.users.Set(userid, user, ttlcache.DefaultTTL)
} else {
user = item.Value()
}
return user, nil
}
// GetGroup looks up a group by id, if the group is not cached, yet it will do a lookup via the CS3 API
func (cache IdentityCache) GetGroup(ctx context.Context, groupID string) (libregraph.Group, error) {
var group libregraph.Group
if item := cache.groups.Get(groupID); item == nil {
gatewayClient, err := cache.gatewaySelector.Next()
if err != nil {
return group, errorcode.New(errorcode.GeneralException, err.Error())
}
cs3GroupID := &cs3Group.GroupId{
OpaqueId: groupID,
}
req := cs3Group.GetGroupRequest{
GroupId: cs3GroupID,
SkipFetchingMembers: true,
}
res, err := gatewayClient.GetGroup(ctx, &req)
if err != nil {
return group, errorcode.New(errorcode.GeneralException, err.Error())
}
switch res.Status.Code {
case rpc.Code_CODE_OK:
g := res.GetGroup()
group = *identity.CreateGroupModelFromCS3(g)
cache.groups.Set(groupID, group, ttlcache.DefaultTTL)
case rpc.Code_CODE_NOT_FOUND:
return group, identity.ErrNotFound
default:
return group, errorcode.New(errorcode.GeneralException, res.Status.Message)
}
} else {
group = item.Value()
}
return group, nil
}
@@ -0,0 +1,13 @@
package cache_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestCache(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Cache Suite")
}
+106
View File
@@ -0,0 +1,106 @@
package cache
import (
"context"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3User "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
)
// mockGatewaySelector is a mock implementation of pool.Selectable[gateway.GatewayAPIClient]
type mockGatewaySelector struct {
client gateway.GatewayAPIClient
}
func (m *mockGatewaySelector) Next(opts ...pool.Option) (gateway.GatewayAPIClient, error) {
return m.client, nil
}
var _ = Describe("Cache", func() {
var (
ctx context.Context
idc IdentityCache
mockGwSelector pool.Selectable[gateway.GatewayAPIClient]
)
BeforeEach(func() {
// Create a mock gateway selector (client can be nil for cached tests)
mockGwSelector = &mockGatewaySelector{
client: nil,
}
idc = NewIdentityCache(
IdentityCacheWithGatewaySelector(mockGwSelector),
)
ctx = context.Background()
})
Describe("GetUser", func() {
It("should return no error", func() {
alan := &cs3User.User{
Id: &cs3User.UserId{
OpaqueId: "alan",
TenantId: "",
},
DisplayName: "Alan",
}
// Persist the user to the cache for 1 hour
idc.users.Set(alan.GetId().GetTenantId()+"|"+alan.GetId().GetOpaqueId(), alan, time.Hour)
// getting the cache item in cache.go line 103 does not work
ru, err := idc.GetUser(ctx, "", "alan")
Expect(err).To(BeNil())
Expect(ru).ToNot(BeNil())
Expect(ru.GetId()).To(Equal(alan.GetId().GetOpaqueId()))
Expect(ru.GetDisplayName()).To(Equal(alan.GetDisplayName()))
})
It("should return the correct user if two users with the same uid and different tennant ids exist", func() {
alan1 := &cs3User.User{
Id: &cs3User.UserId{
OpaqueId: "alan",
TenantId: "1234",
},
DisplayName: "Alan1",
}
alan2 := &cs3User.User{
Id: &cs3User.UserId{
OpaqueId: "alan",
TenantId: "5678",
},
DisplayName: "Alan2",
}
// Persist the user to the cache for 1 hour
idc.users.Set(alan1.GetId().GetTenantId()+"|"+alan1.GetId().GetOpaqueId(), alan1, time.Hour)
idc.users.Set(alan2.GetId().GetTenantId()+"|"+alan2.GetId().GetOpaqueId(), alan2, time.Hour)
ru, err := idc.GetUser(ctx, "5678", "alan")
Expect(err).To(BeNil())
Expect(ru.GetDisplayName()).To(Equal(alan2.GetDisplayName()))
ru, err = idc.GetUser(ctx, "1234", "alan")
Expect(err).To(BeNil())
Expect(ru.GetDisplayName()).To(Equal(alan1.GetDisplayName()))
})
It("should not return an error, if the tenant id does match", func() {
alan := &cs3User.User{
Id: &cs3User.UserId{
OpaqueId: "alan",
TenantId: "1234",
},
DisplayName: "Alan",
}
// Persist the user to the cache for 1 hour
cu := idc.users.Set(alan.GetId().GetTenantId()+"|"+alan.GetId().GetOpaqueId(), alan, time.Hour)
// Test if element has been persisted in the cache
Expect(cu.Value().GetId().GetOpaqueId()).To(Equal(alan.GetId().GetOpaqueId()))
ru, err := idc.GetUser(ctx, "1234", "alan")
Expect(err).To(BeNil())
Expect(ru.GetDisplayName()).To(Equal(alan.GetDisplayName()))
})
})
})
+261
View File
@@ -0,0 +1,261 @@
package identity
import (
"context"
"net/url"
"time"
"github.com/CiscoM31/godata"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3group "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
cs3user "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/odata"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
)
var (
errNotImplemented = errorcode.New(errorcode.NotSupported, "not implemented")
)
type CS3 struct {
Config *shared.Reva
Logger *log.Logger
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
}
// CreateUser implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) CreateUser(ctx context.Context, user libregraph.User) (*libregraph.User, error) {
return nil, errNotImplemented
}
// DeleteUser implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) DeleteUser(ctx context.Context, nameOrID string) error {
return errNotImplemented
}
// UpdateUser implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) UpdateUser(ctx context.Context, nameOrID string, user libregraph.UserUpdate) (*libregraph.User, error) {
return nil, errNotImplemented
}
// GetUser implements the Backend Interface.
func (i *CS3) GetUser(ctx context.Context, nameOrId string, _ *godata.GoDataRequest) (*libregraph.User, error) {
logger := i.Logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "cs3").Msg("GetUser")
gatewayClient, err := i.GatewaySelector.Next()
if err != nil {
logger.Error().Str("backend", "cs3").Err(err).Msg("could not get gatewayClient")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
}
// Try to get the user by username first
res, err := gatewayClient.GetUserByClaim(ctx, &cs3user.GetUserByClaimRequest{
Claim: "username", // FIXME add consts to reva
Value: nameOrId,
})
switch {
case err != nil:
logger.Error().Str("backend", "cs3").Err(err).Str("nameOrId", nameOrId).Msg("error sending get user by claim id grpc request: transport error")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
return CreateUserModelFromCS3(res.GetUser()), nil
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
// If the user was not found by username, try to get it by user ID
default:
logger.Debug().Str("backend", "cs3").Err(err).Str("nameOrId", nameOrId).Msg("error sending get user by claim id grpc request")
return nil, errorcode.New(errorcode.GeneralException, res.GetStatus().GetMessage())
}
// If the user was not found by username, try to get it by user ID
res, err = gatewayClient.GetUserByClaim(ctx, &cs3user.GetUserByClaimRequest{
Claim: "userid", // FIXME add consts to reva
Value: nameOrId,
})
switch {
case err != nil:
logger.Error().Str("backend", "cs3").Err(err).Str("nameOrId", nameOrId).Msg("error sending get user by claim id grpc request: transport error")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
case res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK:
if res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND {
return nil, errorcode.New(errorcode.ItemNotFound, res.GetStatus().GetMessage())
}
logger.Debug().Str("backend", "cs3").Err(err).Str("nameOrId", nameOrId).Msg("error sending get user by claim id grpc request")
return nil, errorcode.New(errorcode.GeneralException, res.GetStatus().GetMessage())
}
return CreateUserModelFromCS3(res.GetUser()), nil
}
// GetUsers implements the Backend Interface.
func (i *CS3) GetUsers(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.User, error) {
logger := i.Logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "cs3").Msg("GetUsers")
gatewayClient, err := i.GatewaySelector.Next()
if err != nil {
logger.Error().Str("backend", "cs3").Err(err).Msg("could not get gatewayClient")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
}
search, err := odata.GetSearchValues(oreq.Query)
if err != nil {
return nil, err
}
res, err := gatewayClient.FindUsers(ctx, &cs3user.FindUsersRequest{
// FIXME presence match is currently not implemented, an empty search currently leads to
// Unwilling To Perform": Search Error: error parsing filter: (&(objectclass=posixAccount)(|(cn=*)(displayname=*)(mail=*))), error: Present filter match for cn not implemented
Filters: []*cs3user.Filter{
{
Type: cs3user.Filter_TYPE_QUERY,
Term: &cs3user.Filter_Query{
Query: search,
},
},
},
})
switch {
case err != nil:
logger.Error().Str("backend", "cs3").Err(err).Str("search", search).Msg("error sending find users grpc request: transport error")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
case res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK:
if res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND {
return nil, errorcode.New(errorcode.ItemNotFound, res.GetStatus().GetMessage())
}
logger.Debug().Str("backend", "cs3").Err(err).Str("search", search).Msg("error sending find users grpc request")
return nil, errorcode.New(errorcode.GeneralException, res.GetStatus().GetMessage())
}
users := make([]*libregraph.User, 0, len(res.GetUsers()))
for _, user := range res.GetUsers() {
users = append(users, CreateUserModelFromCS3(user))
}
return users, nil
}
// FilterUsers implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) FilterUsers(_ context.Context, _ *godata.GoDataRequest, _ *godata.ParseNode) ([]*libregraph.User, error) {
return nil, errNotImplemented
}
// UpdateLastSignInDate implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) UpdateLastSignInDate(ctx context.Context, userID string, timestamp time.Time) error {
return errNotImplemented
}
// GetGroups implements the Backend Interface.
func (i *CS3) GetGroups(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.Group, error) {
logger := i.Logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "cs3").Msg("GetGroups")
gatewayClient, err := i.GatewaySelector.Next()
if err != nil {
logger.Error().Str("backend", "cs3").Err(err).Msg("could not get gatewayClient")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
}
search, err := odata.GetSearchValues(oreq.Query)
if err != nil {
return nil, err
}
res, err := gatewayClient.FindGroups(ctx, &cs3group.FindGroupsRequest{
// FIXME presence match is currently not implemented, an empty search currently leads to
// Unwilling To Perform": Search Error: error parsing filter: (&(objectclass=posixAccount)(|(cn=*)(displayname=*)(mail=*))), error: Present filter match for cn not implemented
Filters: []*cs3group.Filter{
{
Type: cs3group.Filter_TYPE_QUERY,
Term: &cs3group.Filter_Query{
Query: search,
},
},
},
})
switch {
case err != nil:
logger.Error().Str("backend", "cs3").Err(err).Str("search", search).Msg("error sending find groups grpc request: transport error")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
case res.Status.Code != cs3rpc.Code_CODE_OK:
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
return nil, errorcode.New(errorcode.ItemNotFound, res.Status.Message)
}
logger.Debug().Str("backend", "cs3").Err(err).Str("search", search).Msg("error sending find groups grpc request")
return nil, errorcode.New(errorcode.GeneralException, res.Status.Message)
}
groups := make([]*libregraph.Group, 0, len(res.GetGroups()))
for _, group := range res.GetGroups() {
groups = append(groups, CreateGroupModelFromCS3(group))
}
return groups, nil
}
// CreateGroup implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) CreateGroup(ctx context.Context, group libregraph.Group) (*libregraph.Group, error) {
return nil, errNotImplemented
}
// GetGroup implements the Backend Interface.
func (i *CS3) GetGroup(ctx context.Context, groupID string, queryParam url.Values) (*libregraph.Group, error) {
logger := i.Logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "cs3").Msg("GetGroup")
gatewayClient, err := i.GatewaySelector.Next()
if err != nil {
logger.Error().Str("backend", "cs3").Err(err).Msg("could not get gatewayClient")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
}
res, err := gatewayClient.GetGroupByClaim(ctx, &cs3group.GetGroupByClaimRequest{
Claim: "group_id", // FIXME add consts to reva
Value: groupID,
})
switch {
case err != nil:
logger.Error().Str("backend", "cs3").Err(err).Str("groupid", groupID).Msg("error sending get group by claim id grpc request: transport error")
return nil, errorcode.New(errorcode.ServiceNotAvailable, err.Error())
case res.Status.Code != cs3rpc.Code_CODE_OK:
if res.Status.Code == cs3rpc.Code_CODE_NOT_FOUND {
return nil, errorcode.New(errorcode.ItemNotFound, res.Status.Message)
}
logger.Debug().Str("backend", "cs3").Err(err).Str("groupid", groupID).Msg("error sending get group by claim id grpc request")
return nil, errorcode.New(errorcode.GeneralException, res.Status.Message)
}
return CreateGroupModelFromCS3(res.GetGroup()), nil
}
// DeleteGroup implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) DeleteGroup(ctx context.Context, id string) error {
return errNotImplemented
}
// UpdateGroupName implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) UpdateGroupName(ctx context.Context, groupID string, groupName string) error {
return errNotImplemented
}
// GetGroupMembers implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) GetGroupMembers(ctx context.Context, groupID string, _ *godata.GoDataRequest) ([]*libregraph.User, error) {
return nil, errNotImplemented
}
// AddMembersToGroup implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) AddMembersToGroup(ctx context.Context, groupID string, memberID []string) error {
return errNotImplemented
}
// RemoveMemberFromGroup implements the Backend Interface. It's currently not supported for the CS3 backend
func (i *CS3) RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error {
return errNotImplemented
}
@@ -0,0 +1,145 @@
package identity
import (
"context"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
// ErrEducationBackend is a dummy EducationBackend, doing nothing
type ErrEducationBackend struct{}
// CreateEducationSchool creates the supplied school in the identity backend.
func (i *ErrEducationBackend) CreateEducationSchool(ctx context.Context, school libregraph.EducationSchool) (*libregraph.EducationSchool, error) {
return nil, errNotImplemented
}
// DeleteEducationSchool deletes a given school, identified by id
func (i *ErrEducationBackend) DeleteEducationSchool(ctx context.Context, id string) error {
return errNotImplemented
}
// GetEducationSchool implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationSchool(ctx context.Context, nameOrID string) (*libregraph.EducationSchool, error) {
return nil, errNotImplemented
}
// GetEducationSchools implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationSchools(ctx context.Context) ([]*libregraph.EducationSchool, error) {
return nil, errNotImplemented
}
// FilterEducationSchoolsByAttribute implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) FilterEducationSchoolsByAttribute(ctx context.Context, attr, value string) ([]*libregraph.EducationSchool, error) {
return nil, errNotImplemented
}
// UpdateEducationSchool implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) UpdateEducationSchool(ctx context.Context, numberOrID string, school libregraph.EducationSchool) (*libregraph.EducationSchool, error) {
return nil, errNotImplemented
}
// GetEducationSchoolUsers implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationSchoolUsers(ctx context.Context, id string) ([]*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// GetEducationSchoolClasses implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationSchoolClasses(ctx context.Context, schoolNumberOrID string) ([]*libregraph.EducationClass, error) {
return nil, errNotImplemented
}
// AddClassesToEducationSchool implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) AddClassesToEducationSchool(ctx context.Context, schoolNumberOrID string, memberIDs []string) error {
return errNotImplemented
}
// RemoveClassFromEducationSchool implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) RemoveClassFromEducationSchool(ctx context.Context, schoolNumberOrID string, memberID string) error {
return errNotImplemented
}
// AddUsersToEducationSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend.
func (i *ErrEducationBackend) AddUsersToEducationSchool(ctx context.Context, schoolID string, memberID []string) error {
return errNotImplemented
}
// RemoveUserFromEducationSchool removes a single member (by ID) from a school
func (i *ErrEducationBackend) RemoveUserFromEducationSchool(ctx context.Context, schoolID string, memberID string) error {
return errNotImplemented
}
// GetEducationClasses implements the EducationBackend interface
func (i *ErrEducationBackend) GetEducationClasses(ctx context.Context) ([]*libregraph.EducationClass, error) {
return nil, errNotImplemented
}
// GetEducationClass implements the EducationBackend interface
func (i *ErrEducationBackend) GetEducationClass(ctx context.Context, namedOrID string) (*libregraph.EducationClass, error) {
return nil, errNotImplemented
}
// CreateEducationClass implements the EducationBackend interface
func (i *ErrEducationBackend) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
return nil, errNotImplemented
}
// DeleteEducationClass implements the EducationBackend interface
func (i *ErrEducationBackend) DeleteEducationClass(ctx context.Context, nameOrID string) error {
return errNotImplemented
}
// GetEducationClassMembers implements the EducationBackend interface
func (i *ErrEducationBackend) GetEducationClassMembers(ctx context.Context, nameOrID string) ([]*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// UpdateEducationClass implements the EducationBackend interface
func (i *ErrEducationBackend) UpdateEducationClass(ctx context.Context, id string, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
return nil, errNotImplemented
}
// CreateEducationUser creates a given education user in the identity backend.
func (i *ErrEducationBackend) CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// DeleteEducationUser deletes a given education user, identified by username or id, from the backend
func (i *ErrEducationBackend) DeleteEducationUser(ctx context.Context, nameOrID string) error {
return errNotImplemented
}
// UpdateEducationUser applies changes to given education user, identified by username or id
func (i *ErrEducationBackend) UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// GetEducationUser implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationUser(ctx context.Context, nameOrID string) (*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// GetEducationUsers implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationUsers(ctx context.Context) ([]*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// FilterEducationUsersByAttribute implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) FilterEducationUsersByAttribute(ctx context.Context, attr, value string) ([]*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// GetEducationClassTeachers implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) GetEducationClassTeachers(ctx context.Context, classID string) ([]*libregraph.EducationUser, error) {
return nil, errNotImplemented
}
// AddTeacherToEducationClass implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) AddTeacherToEducationClass(ctx context.Context, classID string, teacherID string) error {
return errNotImplemented
}
// RemoveTeacherFromEducationClass implements the EducationBackend interface for the ErrEducationBackend backend.
func (i *ErrEducationBackend) RemoveTeacherFromEducationClass(ctx context.Context, classID string, teacherID string) error {
return errNotImplemented
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,464 @@
package identity
import (
"context"
"errors"
"fmt"
"github.com/go-ldap/ldap/v3"
"github.com/libregraph/idm/pkg/ldapdn"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
type educationClassAttributeMap struct {
externalID string
classification string
teachers string
}
func newEducationClassAttributeMap() educationClassAttributeMap {
return educationClassAttributeMap{
externalID: "qsferaEducationExternalId",
classification: "qsferaEducationClassType",
teachers: "qsferaEducationTeacherMember",
}
}
// GetEducationClasses implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationClasses(ctx context.Context) ([]*libregraph.EducationClass, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationClasses")
classFilter := fmt.Sprintf("(&%s(objectClass=%s))", i.groupFilter, i.educationConfig.classObjectClass)
classAttrs := i.getEducationClassAttrTypes(false)
searchRequest := ldap.NewSearchRequest(
i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, 0, 0, false,
classFilter,
classAttrs,
nil,
)
logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("GetEducationClasses")
res, err := i.conn.Search(searchRequest)
if err != nil {
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
}
classes := make([]*libregraph.EducationClass, 0, len(res.Entries))
var c *libregraph.EducationClass
for _, e := range res.Entries {
if c = i.createEducationClassModelFromLDAP(e); c == nil {
continue
}
classes = append(classes, c)
}
return classes, nil
}
// CreateEducationClass implements the EducationBackend interface for the LDAP backend.
// An EducationClass is mapped to an LDAP entry of the "groupOfNames" structural ObjectClass.
// With a few additional Attributes added on top via the "qsferaEducationClass" auxiliary ObjectClass.
func (i *LDAP) CreateEducationClass(ctx context.Context, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("create educationClass")
if !i.writeEnabled {
return nil, errorcode.New(errorcode.NotAllowed, "server is configured read-only")
}
ar, err := i.educationClassToAddRequest(class)
if err != nil {
return nil, err
}
if err := i.conn.Add(ar); err != nil {
var lerr *ldap.Error
logger.Debug().Err(err).Msg("error adding class")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errorcode.New(errorcode.NameAlreadyExists, lerr.Error())
}
}
return nil, err
}
// Read back group from LDAP to get the generated UUID
e, err := i.getEducationClassByDN(ar.DN)
if err != nil {
return nil, err
}
return i.createEducationClassModelFromLDAP(e), nil
}
// GetEducationClass implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationClass(ctx context.Context, id string) (*libregraph.EducationClass, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationClass")
e, err := i.getEducationClassByID(id, false)
if err != nil {
return nil, err
}
var class *libregraph.EducationClass
if class = i.createEducationClassModelFromLDAP(e); class == nil {
return nil, errorcode.New(errorcode.ItemNotFound, "not found")
}
return class, nil
}
// DeleteEducationClass implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) DeleteEducationClass(ctx context.Context, id string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("DeleteEducationClass")
if !i.writeEnabled {
return ErrReadOnly
}
e, err := i.getEducationClassByID(id, false)
if err != nil {
return err
}
dr := ldap.DelRequest{DN: e.DN}
if err = i.conn.Del(&dr); err != nil {
return err
}
// TODO update any users that are member of this school
return nil
}
// UpdateEducationClass implements the EducationBackend interface for the LDAP backend.
// Only the displayName and externalID are supported to change at this point.
func (i *LDAP) UpdateEducationClass(ctx context.Context, id string, class libregraph.EducationClass) (*libregraph.EducationClass, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("UpdateEducationClass")
if !i.writeEnabled {
return nil, ErrReadOnly
}
g, err := i.getLDAPGroupByID(id, false)
if err != nil {
return nil, err
}
var updateNeeded bool
if class.GetId() != "" {
id, err := i.ldapUUIDtoString(g, i.groupAttributeMap.id, i.groupIDisOctetString)
if err != nil {
i.logger.Warn().Str("dn", g.DN).Str(i.userAttributeMap.id, g.GetEqualFoldAttributeValue(i.userAttributeMap.id)).Msg("Invalid class. Cannot convert UUID")
return nil, errorcode.New(errorcode.GeneralException, "error converting uuid")
}
if id != class.GetId() {
return nil, errorcode.New(errorcode.NotAllowed, "changing the GroupID is not allowed")
}
}
if class.GetDescription() != "" {
return nil, errorcode.New(errorcode.NotSupported, "changing the description is currently not supported")
}
if len(class.GetMembers()) != 0 {
return nil, errorcode.New(errorcode.NotSupported, "changing the members is currently not supported")
}
if class.GetClassification() != "" {
return nil, errorcode.New(errorcode.NotSupported, "changing the classification is currently not supported")
}
dn := g.DN
if eID := class.GetExternalId(); eID != "" {
if g.GetEqualFoldAttributeValue(i.educationConfig.classAttributeMap.externalID) != eID {
dn, err = i.updateClassExternalID(ctx, dn, eID)
if err != nil {
return nil, err
}
}
}
mr := ldap.ModifyRequest{DN: dn}
if dName := class.GetDisplayName(); dName != "" {
if g.GetEqualFoldAttributeValue(i.groupAttributeMap.name) != dName {
mr.Replace(i.groupAttributeMap.name, []string{dName})
updateNeeded = true
}
}
if updateNeeded {
if err := i.conn.Modify(&mr); err != nil {
return nil, err
}
}
g, err = i.getEducationClassByDN(dn)
if err != nil {
return nil, err
}
return i.createEducationClassModelFromLDAP(g), nil
}
func (i *LDAP) updateClassExternalID(ctx context.Context, dn, externalID string) (string, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
newDN := fmt.Sprintf("qsferaEducationExternalId=%s", externalID)
mrdn := ldap.NewModifyDNRequest(dn, newDN, true, "")
i.logger.Debug().Str("Backend", "ldap").
Str("dn", mrdn.DN).
Str("newrdn", mrdn.NewRDN).
Msg("updating class external ID")
if err := i.conn.ModifyDN(mrdn); err != nil {
var lerr *ldap.Error
logger.Debug().Err(err).Msg("error updating class external ID")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errorcode.New(errorcode.NameAlreadyExists, lerr.Error())
}
}
return "", err
}
return fmt.Sprintf("%s,%s", newDN, i.groupBaseDN), nil
}
// GetEducationClassMembers implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationClassMembers(ctx context.Context, id string) ([]*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationClassMembers")
e, err := i.getEducationClassByID(id, true)
if err != nil {
return nil, err
}
memberEntries, err := i.expandLDAPAttributeEntries(ctx, e, i.groupAttributeMap.member, "")
result := make([]*libregraph.EducationUser, 0, len(memberEntries))
if err != nil {
return nil, err
}
for _, member := range memberEntries {
if u := i.createEducationUserModelFromLDAP(member); u != nil {
result = append(result, u)
}
}
return result, nil
}
func (i *LDAP) educationClassToAddRequest(class libregraph.EducationClass) (*ldap.AddRequest, error) {
plainGroup := i.educationClassToGroup(class)
ldapAttrs, err := i.groupToLDAPAttrValues(*plainGroup)
if err != nil {
return nil, err
}
ldapAttrs, err = i.educationClassToLDAPAttrValues(class, ldapAttrs)
if err != nil {
return nil, err
}
ar := ldap.NewAddRequest(i.getEducationClassLDAPDN(class), nil)
for attrType, values := range ldapAttrs {
ar.Attribute(attrType, values)
}
return ar, nil
}
func (i *LDAP) educationClassToGroup(class libregraph.EducationClass) *libregraph.Group {
group := libregraph.NewGroup()
group.SetDisplayName(class.DisplayName)
return group
}
func (i *LDAP) educationClassToLDAPAttrValues(class libregraph.EducationClass, attrs ldapAttributeValues) (ldapAttributeValues, error) {
if externalID, ok := class.GetExternalIdOk(); ok {
attrs[i.educationConfig.classAttributeMap.externalID] = []string{*externalID}
}
if classification, ok := class.GetClassificationOk(); ok {
attrs[i.educationConfig.classAttributeMap.classification] = []string{*classification}
}
attrs["objectClass"] = append(attrs["objectClass"], i.educationConfig.classObjectClass)
return attrs, nil
}
func (i *LDAP) getEducationClassAttrTypes(requestMembers bool) []string {
attrs := []string{
i.groupAttributeMap.name,
i.groupAttributeMap.id,
i.educationConfig.classAttributeMap.classification,
i.educationConfig.classAttributeMap.externalID,
i.educationConfig.memberOfSchoolAttribute,
i.educationConfig.classAttributeMap.teachers,
}
if requestMembers {
attrs = append(attrs, i.groupAttributeMap.member)
}
return attrs
}
func (i *LDAP) getEducationClassByDN(dn string) (*ldap.Entry, error) {
filter := fmt.Sprintf("(objectClass=%s)", i.educationConfig.classObjectClass)
if i.groupFilter != "" {
filter = fmt.Sprintf("(&%s(%s))", filter, i.groupFilter)
}
return i.getEntryByDN(dn, i.getEducationClassAttrTypes(false), filter)
}
func (i *LDAP) createEducationClassModelFromLDAP(e *ldap.Entry) *libregraph.EducationClass {
group := i.createGroupModelFromLDAP(e)
return i.groupToEducationClass(*group, e)
}
func (i *LDAP) groupToEducationClass(group libregraph.Group, e *ldap.Entry) *libregraph.EducationClass {
class := libregraph.NewEducationClass(group.GetDisplayName(), "")
class.SetId(group.GetId())
if e != nil {
// Set the education User specific Attributes from the supplied LDAP Entry
if externalID := e.GetEqualFoldAttributeValue(i.educationConfig.classAttributeMap.externalID); externalID != "" {
class.SetExternalId(externalID)
}
if classification := e.GetEqualFoldAttributeValue(i.educationConfig.classAttributeMap.classification); classification != "" {
class.SetClassification(classification)
}
}
return class
}
func (i *LDAP) getEducationClassLDAPDN(class libregraph.EducationClass) string {
attributeTypeAndValue := ldap.AttributeTypeAndValue{
Type: "qsferaEducationExternalId",
Value: class.GetExternalId(),
}
return fmt.Sprintf("%s,%s", attributeTypeAndValue.String(), i.groupBaseDN)
}
func (i *LDAP) getEducationClassByID(nameOrID string, requestMembers bool) (*ldap.Entry, error) {
return i.getEducationObjectByNameOrID(
nameOrID,
i.userAttributeMap.id,
i.educationConfig.classAttributeMap.externalID,
i.groupFilter,
i.educationConfig.classObjectClass,
i.groupBaseDN,
i.getEducationClassAttrTypes(requestMembers),
)
}
// GetEducationClassTeachers returns the EducationUser teachers for an EducationClass
func (i *LDAP) GetEducationClassTeachers(ctx context.Context, classID string) ([]*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
class, err := i.getEducationClassByID(classID, false)
if err != nil {
logger.Debug().Err(err).Msg("could not get class: backend error")
return nil, err
}
teacherEntries, err := i.expandLDAPAttributeEntries(ctx, class, i.educationConfig.classAttributeMap.teachers, "")
result := make([]*libregraph.EducationUser, 0, len(teacherEntries))
if err != nil {
return nil, err
}
for _, teacher := range teacherEntries {
if u := i.createEducationUserModelFromLDAP(teacher); u != nil {
result = append(result, u)
}
}
return result, nil
}
// AddTeacherToEducationClass adds a teacher (by ID) to class in the identity backend.
func (i *LDAP) AddTeacherToEducationClass(ctx context.Context, classID string, teacherID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
class, err := i.getEducationClassByID(classID, false)
if err != nil {
logger.Debug().Err(err).Msg("could not get class: backend error")
return err
}
logger.Debug().Str("classDn", class.DN).Msg("got a class")
teacher, err := i.getEducationUserByNameOrID(teacherID)
if err != nil {
logger.Debug().Err(err).Msg("could not get education user: error fetching education user from backend")
return err
}
logger.Debug().Str("userDn", teacher.DN).Msg("got a user")
mr := ldap.ModifyRequest{DN: class.DN}
// Handle empty teacher list
current := class.GetEqualFoldAttributeValues(i.educationConfig.classAttributeMap.teachers)
if len(current) == 1 && current[0] == "" {
mr.Delete(i.educationConfig.classAttributeMap.teachers, []string{""})
}
// Create a Set of current teachers
currentSet := make(map[string]struct{}, len(current))
for _, currentTeacher := range current {
if currentTeacher == "" {
continue
}
nCurrentTeacher, err := ldapdn.ParseNormalize(currentTeacher)
if err != nil {
// Couldn't parse teacher value as a DN, skipping
logger.Warn().Str("teacherDN", currentTeacher).Err(err).Msg("Couldn't parse DN")
continue
}
currentSet[nCurrentTeacher] = struct{}{}
}
var newTeacherDN []string
nDN, err := ldapdn.ParseNormalize(teacher.DN)
if err != nil {
logger.Error().Str("new teacher", teacher.DN).Err(err).Msg("Couldn't parse DN")
return err
}
if _, present := currentSet[nDN]; !present {
newTeacherDN = append(newTeacherDN, teacher.DN)
} else {
logger.Debug().Str("teacherDN", teacher.DN).Msg("Member already present in group. Skipping")
}
if len(newTeacherDN) > 0 {
mr.Add(i.educationConfig.classAttributeMap.teachers, newTeacherDN)
if err := i.conn.Modify(&mr); err != nil {
return err
}
}
return nil
}
// RemoveTeacherFromEducationClass removes teacher (by ID) from a class
func (i *LDAP) RemoveTeacherFromEducationClass(ctx context.Context, classID string, teacherID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
class, err := i.getEducationClassByID(classID, false)
if err != nil {
logger.Debug().Err(err).Msg("could not get class: backend error")
return err
}
teacher, err := i.getEducationUserByNameOrID(teacherID)
if err != nil {
logger.Debug().Err(err).Msg("could not get education user: error fetching education user from backend")
return err
}
return i.removeEntryByDNAndAttributeFromEntry(class, teacher.DN, i.educationConfig.classAttributeMap.teachers)
}
@@ -0,0 +1,551 @@
package identity
import (
"context"
"errors"
"testing"
"github.com/go-ldap/ldap/v3"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/identity/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
var classEntry = ldap.NewEntry("qsferaEducationExternalId=Math0123",
map[string][]string{
"cn": {"Math"},
"qsferaEducationExternalId": {"Math0123"},
"qsferaEducationClassType": {"course"},
"entryUUID": {"abcd-defg"},
})
var classEntryWithSchool = ldap.NewEntry("qsferaEducationExternalId=Math0123",
map[string][]string{
"cn": {"Math"},
"qsferaEducationExternalId": {"Math0123"},
"qsferaEducationClassType": {"course"},
"entryUUID": {"abcd-defg"},
"qsferaMemberOfSchool": {"abcd-defg"},
})
var classEntryWithMember = ldap.NewEntry("qsferaEducationExternalId=Math0123",
map[string][]string{
"cn": {"Math"},
"qsferaEducationExternalId": {"Math0123"},
"qsferaEducationClassType": {"course"},
"entryUUID": {"abcd-defg"},
"member": {"uid=user"},
})
func TestCreateEducationClass(t *testing.T) {
lm := &mocks.Client{}
lm.On("Add", mock.Anything).
Return(nil)
lm.On("Search", mock.Anything).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
assert.NotEqual(t, "", b.educationConfig.classObjectClass)
class := libregraph.NewEducationClass("Math", "course")
class.SetExternalId("Math0123")
class.SetId("abcd-defg")
resClass, err := b.CreateEducationClass(context.Background(), *class)
lm.AssertNumberOfCalls(t, "Add", 1)
lm.AssertNumberOfCalls(t, "Search", 1)
assert.Nil(t, err)
assert.NotNil(t, resClass)
assert.Equal(t, resClass.GetDisplayName(), class.GetDisplayName())
assert.Equal(t, resClass.GetId(), class.GetId())
assert.Equal(t, resClass.GetExternalId(), class.GetExternalId())
assert.Equal(t, resClass.GetClassification(), class.GetClassification())
}
func TestGetEducationClasses(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", mock.Anything).Return(nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock")))
b, _ := getMockedBackend(lm, lconfig, &logger)
_, err := b.GetEducationClasses(context.Background())
assert.ErrorContains(t, err, "itemNotFound:")
lm = &mocks.Client{}
lm.On("Search", mock.Anything).Return(&ldap.SearchResult{}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
g, err := b.GetEducationClasses(context.Background())
if err != nil {
t.Errorf("Expected success, got '%s'", err.Error())
} else if g == nil || len(g) != 0 {
t.Errorf("Expected zero length user slice")
}
lm = &mocks.Client{}
lm.On("Search", mock.Anything).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
g, err = b.GetEducationClasses(context.Background())
if err != nil {
t.Errorf("Expected GetEducationClasses to succeed. Got %s", err.Error())
} else if *g[0].Id != classEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
t.Errorf("Expected GetEducationClasses to return a valid group")
}
}
func TestGetEducationClass(t *testing.T) {
tests := []struct {
name string
id string
filter string
expectedItemNotFound bool
}{
{
name: "Test search class using id",
id: "abcd-defg",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=abcd-defg)(qsferaEducationExternalId=abcd-defg)))",
expectedItemNotFound: false,
},
{
name: "Test search class using unknown Id",
id: "xxxx-xxxx",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=xxxx-xxxx)(qsferaEducationExternalId=xxxx-xxxx)))",
expectedItemNotFound: true,
},
{
name: "Test search class using external ID",
id: "Math0123",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=Math0123)(qsferaEducationExternalId=Math0123)))",
expectedItemNotFound: false,
},
{
name: "Test search school using unknown externalID",
id: "Unknown3210",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=Unknown3210)(qsferaEducationExternalId=Unknown3210)))",
expectedItemNotFound: true,
},
}
for _, tt := range tests {
lm := &mocks.Client{}
sr := &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: tt.filter,
Attributes: []string{"cn", "entryUUID", "qsferaEducationClassType", "qsferaEducationExternalId", "qsferaMemberOfSchool", "qsferaEducationTeacherMember"},
Controls: []ldap.Control(nil),
}
if tt.expectedItemNotFound {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
} else {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntry}}, nil)
}
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
class, err := b.GetEducationClass(context.Background(), tt.id)
lm.AssertNumberOfCalls(t, "Search", 1)
if tt.expectedItemNotFound {
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
} else {
assert.Nil(t, err)
assert.Equal(t, "Math", class.GetDisplayName())
assert.Equal(t, "abcd-defg", class.GetId())
assert.Equal(t, "Math0123", class.GetExternalId())
}
}
}
func TestDeleteEducationClass(t *testing.T) {
tests := []struct {
name string
id string
filter string
expectedItemNotFound bool
}{
{
name: "Test search class using id",
id: "abcd-defg",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=abcd-defg)(qsferaEducationExternalId=abcd-defg)))",
expectedItemNotFound: false,
},
{
name: "Test search class using unknown Id",
id: "xxxx-xxxx",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=xxxx-xxxx)(qsferaEducationExternalId=xxxx-xxxx)))",
expectedItemNotFound: true,
},
{
name: "Test search class using external ID",
id: "Math0123",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=Math0123)(qsferaEducationExternalId=Math0123)))",
expectedItemNotFound: false,
},
{
name: "Test search school using unknown externalID",
id: "Unknown3210",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=Unknown3210)(qsferaEducationExternalId=Unknown3210)))",
expectedItemNotFound: true,
},
}
for _, tt := range tests {
lm := &mocks.Client{}
sr := &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: tt.filter,
Attributes: []string{"cn", "entryUUID", "qsferaEducationClassType", "qsferaEducationExternalId", "qsferaMemberOfSchool", "qsferaEducationTeacherMember"},
Controls: []ldap.Control(nil),
}
if tt.expectedItemNotFound {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
} else {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntry}}, nil)
}
dr := &ldap.DelRequest{
DN: "qsferaEducationExternalId=Math0123",
}
lm.On("Del", dr).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.DeleteEducationClass(context.Background(), tt.id)
lm.AssertNumberOfCalls(t, "Search", 1)
if tt.expectedItemNotFound {
lm.AssertNumberOfCalls(t, "Del", 0)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
} else {
assert.Nil(t, err)
}
}
}
func TestGetEducationClassMembers(t *testing.T) {
tests := []struct {
name string
id string
filter string
expectedItemNotFound bool
}{
{
name: "Test search class using id",
id: "abcd-defg",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=abcd-defg)(qsferaEducationExternalId=abcd-defg)))",
expectedItemNotFound: false,
},
{
name: "Test search class using unknown Id",
id: "xxxx-xxxx",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=xxxx-xxxx)(qsferaEducationExternalId=xxxx-xxxx)))",
expectedItemNotFound: true,
},
{
name: "Test search class using external ID",
id: "Math0123",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=Math0123)(qsferaEducationExternalId=Math0123)))",
expectedItemNotFound: false,
},
{
name: "Test search school using unknown externalID",
id: "Unknown3210",
filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=Unknown3210)(qsferaEducationExternalId=Unknown3210)))",
expectedItemNotFound: true,
},
}
for _, tt := range tests {
lm := &mocks.Client{}
userSr := &ldap.SearchRequest{
BaseDN: "uid=user",
Scope: 0,
SizeLimit: 1,
Filter: "(objectClass=inetOrgPerson)",
Attributes: ldapUserAttributes,
Controls: []ldap.Control(nil),
}
lm.On("Search", userSr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}}, nil)
sr := &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: tt.filter,
Attributes: []string{"cn", "entryUUID", "qsferaEducationClassType", "qsferaEducationExternalId", "qsferaMemberOfSchool", "qsferaEducationTeacherMember", "member"},
Controls: []ldap.Control(nil),
}
if tt.expectedItemNotFound {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
} else {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntryWithMember}}, nil)
}
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
users, err := b.GetEducationClassMembers(context.Background(), tt.id)
if tt.expectedItemNotFound {
lm.AssertNumberOfCalls(t, "Search", 1)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
} else {
lm.AssertNumberOfCalls(t, "Search", 2)
assert.Nil(t, err)
assert.Equal(t, len(users), 1)
}
}
}
func TestLDAP_UpdateEducationClass(t *testing.T) {
externalIDs := []string{"Math3210"}
changeString := "xxxx-xxxx"
type args struct {
id string
class libregraph.EducationClass
}
type modifyData struct {
arg *ldap.ModifyRequest
ret error
}
type modifyDNData struct {
arg *ldap.ModifyDNRequest
ret error
}
type searchData struct {
res *ldap.SearchResult
err error
}
tests := []struct {
name string
args args
modifyDNData modifyDNData
modifyData modifyData
searchData searchData
assertion func(assert.TestingT, error, ...any) bool
}{
{
name: "Change name",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
DisplayName: "Math-2",
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Nil(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{
DN: "qsferaEducationExternalId=Math0123",
Changes: []ldap.Change{
{
Operation: ldap.ReplaceAttribute,
Modification: ldap.PartialAttribute{
Type: "cn",
Vals: []string{"Math-2"},
},
},
},
},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
{
name: "Change external ID",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
ExternalId: &externalIDs[0],
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Nil(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{
DN: "qsferaEducationExternalId=Math0123",
NewRDN: "qsferaEducationExternalId=Math3210",
DeleteOldRDN: true,
NewSuperior: "",
},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
{
name: "Change both name and external ID",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
DisplayName: "Math-2",
ExternalId: &externalIDs[0],
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Nil(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{
DN: "qsferaEducationExternalId=Math3210,ou=groups,dc=test",
Changes: []ldap.Change{
{
Operation: ldap.ReplaceAttribute,
Modification: ldap.PartialAttribute{
Type: "cn",
Vals: []string{"Math-2"},
},
},
},
},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{
DN: "qsferaEducationExternalId=Math0123",
NewRDN: "qsferaEducationExternalId=Math3210",
DeleteOldRDN: true,
NewSuperior: "",
},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
{
name: "Check error: attempt at changing ID",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
Id: &changeString,
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Error(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
{
name: "Check error: attempt at changing description",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
Description: &changeString,
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Error(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
{
name: "Check error: attempt at changing classification",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
Classification: changeString,
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Error(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
{
name: "Check error: attempt at changing members",
args: args{
id: "abcd-defg",
class: libregraph.EducationClass{
Members: []libregraph.User{*libregraph.NewUser("display name", "username")},
},
},
assertion: func(tt assert.TestingT, err error, i ...any) bool { return assert.Error(tt, err) },
modifyData: modifyData{
arg: &ldap.ModifyRequest{},
},
modifyDNData: modifyDNData{
arg: &ldap.ModifyDNRequest{},
ret: nil,
},
searchData: searchData{
res: &ldap.SearchResult{
Entries: []*ldap.Entry{classEntry},
},
err: nil,
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lm := &mocks.Client{}
b, err := getMockedBackend(lm, eduConfig, &logger)
if err != nil {
panic(err)
}
lm.On("Modify", tt.modifyData.arg).Return(tt.modifyData.ret)
lm.On("ModifyDN", tt.modifyDNData.arg).Return(tt.modifyDNData.ret)
lm.On("Search", mock.Anything).Return(tt.searchData.res, tt.searchData.err)
ctx := context.Background()
_, err = b.UpdateEducationClass(ctx, tt.args.id, tt.args.class)
tt.assertion(t, err)
})
}
}
@@ -0,0 +1,812 @@
package identity
import (
"context"
"errors"
"fmt"
"slices"
"time"
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
type educationConfig struct {
schoolBaseDN string
schoolFilter string
schoolObjectClass string
schoolScope int
// memberOfSchoolAttribute defines the AttributeType on the user/group objects
// which contains the school Id to which the user/group is assigned
memberOfSchoolAttribute string
schoolAttributeMap schoolAttributeMap
userObjectClass string
userAttributeMap educationUserAttributeMap
classObjectClass string
classAttributeMap educationClassAttributeMap
}
type schoolAttributeMap struct {
displayName string
schoolNumber string
externalId string
id string
terminationDate string
}
type schoolUpdateOperation uint8
const (
tooManyValues schoolUpdateOperation = iota
schoolUnchanged
schoolRenamed
schoolPropertiesUpdated
)
var (
errNotSet = errors.New("attribute not set")
errSchoolNameExists = errorcode.New(errorcode.NameAlreadyExists, "A school with that name is already present")
errSchoolNumberExists = errorcode.New(errorcode.NameAlreadyExists, "A school with that number is already present")
)
func defaultEducationConfig() educationConfig {
return educationConfig{
schoolObjectClass: "qsferaEducationSchool",
schoolScope: ldap.ScopeWholeSubtree,
memberOfSchoolAttribute: "qsferaMemberOfSchool",
schoolAttributeMap: newSchoolAttributeMap(),
userObjectClass: "qsferaEducationUser",
userAttributeMap: newEducationUserAttributeMap(),
classObjectClass: "qsferaEducationClass",
classAttributeMap: newEducationClassAttributeMap(),
}
}
func newEducationConfig(config config.LDAP) (educationConfig, error) {
if config.EducationResourcesEnabled {
var err error
eduCfg := defaultEducationConfig()
eduCfg.schoolBaseDN = config.EducationConfig.SchoolBaseDN
if config.EducationConfig.SchoolSearchScope != "" {
if eduCfg.schoolScope, err = stringToScope(config.EducationConfig.SchoolSearchScope); err != nil {
return educationConfig{}, fmt.Errorf("error configuring school search scope: %w", err)
}
}
if config.EducationConfig.SchoolFilter != "" {
eduCfg.schoolFilter = config.EducationConfig.SchoolFilter
}
if config.EducationConfig.SchoolObjectClass != "" {
eduCfg.schoolObjectClass = config.EducationConfig.SchoolObjectClass
}
// Attribute mapping config
if config.EducationConfig.SchoolNameAttribute != "" {
eduCfg.schoolAttributeMap.displayName = config.EducationConfig.SchoolNameAttribute
}
if config.EducationConfig.SchoolNumberAttribute != "" {
eduCfg.schoolAttributeMap.schoolNumber = config.EducationConfig.SchoolNumberAttribute
}
if config.EducationConfig.SchoolIDAttribute != "" {
eduCfg.schoolAttributeMap.id = config.EducationConfig.SchoolIDAttribute
}
return eduCfg, nil
}
return educationConfig{}, nil
}
func newSchoolAttributeMap() schoolAttributeMap {
return schoolAttributeMap{
displayName: "ou",
schoolNumber: "qsferaEducationSchoolNumber",
externalId: "qsferaEducationExternalId",
id: "qsferaUUID",
terminationDate: "qsferaEducationSchoolTerminationTimestamp",
}
}
// CreateEducationSchool creates the supplied school in the identity backend.
func (i *LDAP) CreateEducationSchool(ctx context.Context, school libregraph.EducationSchool) (*libregraph.EducationSchool, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("CreateEducationSchool")
if !i.writeEnabled {
return nil, ErrReadOnly
}
// Check that the school number is not already used
if school.HasSchoolNumber() {
_, err := i.getSchoolByNumber(school.GetSchoolNumber())
switch {
case err == nil:
logger.Debug().Err(errSchoolNumberExists).Str("schoolNumber", school.GetSchoolNumber()).Msg("duplicate school number")
return nil, errSchoolNumberExists
case errors.Is(err, ErrNotFound):
break
default:
logger.Error().Err(err).Str("schoolNumber", school.GetSchoolNumber()).Msg("error looking up school by number")
return nil, errorcode.New(errorcode.GeneralException, "error looking up school by number")
}
}
attributeTypeAndValue := ldap.AttributeTypeAndValue{
Type: i.educationConfig.schoolAttributeMap.displayName,
Value: school.GetDisplayName(),
}
dn := fmt.Sprintf("%s,%s",
attributeTypeAndValue.String(),
i.educationConfig.schoolBaseDN,
)
ar := ldap.NewAddRequest(dn, nil)
ar.Attribute(i.educationConfig.schoolAttributeMap.displayName, []string{school.GetDisplayName()})
if school.HasSchoolNumber() {
ar.Attribute(i.educationConfig.schoolAttributeMap.schoolNumber, []string{school.GetSchoolNumber()})
}
if school.HasExternalId() {
ar.Attribute(i.educationConfig.schoolAttributeMap.externalId, []string{school.GetExternalId()})
}
if !i.useServerUUID {
ar.Attribute(i.educationConfig.schoolAttributeMap.id, []string{uuid.NewString()})
}
objectClasses := []string{"organizationalUnit", i.educationConfig.schoolObjectClass, "top"}
ar.Attribute("objectClass", objectClasses)
if err := i.conn.Add(ar); err != nil {
var lerr *ldap.Error
logger.Debug().Err(err).Msg("error adding school")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errSchoolNameExists
}
}
return nil, err
}
// Read back school from LDAP to get the generated UUID
e, err := i.getSchoolByDN(ar.DN)
if err != nil {
return nil, err
}
return i.createSchoolModelFromLDAP(e), nil
}
// UpdateEducationSchoolOperation contains the logic for which update operation to apply to a school
func (i *LDAP) updateEducationSchoolOperation(
schoolUpdate libregraph.EducationSchool,
currentSchool libregraph.EducationSchool,
) schoolUpdateOperation {
providedDisplayName, displayNameIsSet := schoolUpdate.GetDisplayNameOk()
if displayNameIsSet {
if *providedDisplayName == "" || *providedDisplayName == currentSchool.GetDisplayName() {
// The school name hasn't changed
displayNameIsSet = false
}
}
var propertiesUpdated bool
switch {
case schoolUpdate.HasSchoolNumber():
if schoolUpdate.GetSchoolNumber() != "" && schoolUpdate.GetSchoolNumber() != currentSchool.GetSchoolNumber() {
propertiesUpdated = true
}
case schoolUpdate.HasTerminationDate():
if schoolUpdate.GetTerminationDate() != currentSchool.GetTerminationDate() {
propertiesUpdated = true
}
}
if propertiesUpdated && displayNameIsSet {
return tooManyValues
}
if displayNameIsSet {
return schoolRenamed
}
if propertiesUpdated {
return schoolPropertiesUpdated
}
return schoolUnchanged
}
// updateDisplayName updates the school OU in the identity backend
func (i *LDAP) updateDisplayName(ctx context.Context, dn string, providedDisplayName string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
attributeTypeAndValue := ldap.AttributeTypeAndValue{
Type: i.educationConfig.schoolAttributeMap.displayName,
Value: providedDisplayName,
}
mrdn := ldap.NewModifyDNRequest(dn, attributeTypeAndValue.String(), true, "")
i.logger.Debug().Str("backend", "ldap").
Str("dn", mrdn.DN).
Str("newrdn", mrdn.NewRDN).
Msg("updateDisplayName")
if err := i.conn.ModifyDN(mrdn); err != nil {
var lerr *ldap.Error
logger.Debug().Err(err).Msg("error updating school name")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errSchoolNameExists
}
}
return err
}
return nil
}
// updateSchoolProperties updates the properties (other that displayName) of a school.
// It checks if a school number is already taken, before updating the school number
func (i *LDAP) updateSchoolProperties(ctx context.Context, dn string, currentSchool, updatedSchool libregraph.EducationSchool) error {
logger := i.logger.SubloggerWithRequestID(ctx)
mr := ldap.NewModifyRequest(dn, nil)
if updatedSchoolNumber, ok := updatedSchool.GetSchoolNumberOk(); ok {
if *updatedSchoolNumber != "" && currentSchool.GetSchoolNumber() != *updatedSchoolNumber {
_, err := i.getSchoolByNumber(*updatedSchoolNumber)
if err == nil {
return errSchoolNumberExists
}
mr.Replace(i.educationConfig.schoolAttributeMap.schoolNumber, []string{*updatedSchoolNumber})
}
}
if updatedTerminationDate, ok := updatedSchool.GetTerminationDateOk(); ok {
if updatedTerminationDate == nil && currentSchool.HasTerminationDate() {
// Delete the termination date
mr.Delete(i.educationConfig.schoolAttributeMap.terminationDate, []string{})
}
if updatedTerminationDate != nil && *updatedTerminationDate != currentSchool.GetTerminationDate() {
ldapDateTime := updatedTerminationDate.UTC().Format(ldapDateFormat)
mr.Replace(i.educationConfig.schoolAttributeMap.terminationDate, []string{ldapDateTime})
}
}
if err := i.conn.Modify(mr); err != nil {
logger.Debug().Err(err).Msg("error updating school number")
return err
}
return nil
}
// UpdateEducationSchool updates the supplied school in the identity backend
func (i *LDAP) UpdateEducationSchool(ctx context.Context, numberOrID string, school libregraph.EducationSchool) (*libregraph.EducationSchool, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("UpdateEducationSchool")
if !i.writeEnabled {
return nil, ErrReadOnly
}
e, err := i.getSchoolByNumberOrID(numberOrID)
if err != nil {
return nil, err
}
currentSchool := i.createSchoolModelFromLDAP(e)
switch i.updateEducationSchoolOperation(school, *currentSchool) {
case tooManyValues:
return nil, fmt.Errorf("school name and school number cannot be updated in the same request")
case schoolUnchanged:
logger.Debug().Str("backend", "ldap").Msg("UpdateEducationSchool: Nothing changed")
return currentSchool, nil
case schoolRenamed:
if err := i.updateDisplayName(ctx, e.DN, school.GetDisplayName()); err != nil {
return nil, err
}
case schoolPropertiesUpdated:
if err := i.updateSchoolProperties(ctx, e.DN, *currentSchool, school); err != nil {
return nil, err
}
}
// Read back school from LDAP
e, err = i.getSchoolByNumberOrID(i.getID(e))
if err != nil {
return nil, err
}
return i.createSchoolModelFromLDAP(e), nil
}
// DeleteEducationSchool deletes a given school, identified by id
func (i *LDAP) DeleteEducationSchool(ctx context.Context, id string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("DeleteEducationSchool")
if !i.writeEnabled {
return ErrReadOnly
}
e, err := i.getSchoolByNumberOrID(id)
if err != nil {
return err
}
dr := ldap.DelRequest{DN: e.DN}
if err = i.conn.Del(&dr); err != nil {
return err
}
// TODO update any users that are member of this school
return nil
}
// GetEducationSchool implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationSchool(ctx context.Context, numberOrID string) (*libregraph.EducationSchool, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationSchool")
e, err := i.getSchoolByNumberOrID(numberOrID)
if err != nil {
return nil, err
}
return i.createSchoolModelFromLDAP(e), nil
}
// GetEducationSchools implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationSchools(ctx context.Context) ([]*libregraph.EducationSchool, error) {
filter := fmt.Sprintf("(objectClass=%s)", i.educationConfig.schoolObjectClass)
if i.educationConfig.schoolFilter != "" {
filter = fmt.Sprintf("(&%s%s)", i.educationConfig.schoolFilter, filter)
}
return i.searchEducationSchools(ctx, filter)
}
// FilterEducationSchoolsByAttribute implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) FilterEducationSchoolsByAttribute(ctx context.Context, attr, value string) ([]*libregraph.EducationSchool, error) {
logger := i.logger.SubloggerWithRequestID(ctx).With().Str("func", "FilterEducationSchoolsByAttribute").Logger()
logger.Debug().Str("backend", "ldap").Str("attribute", attr).Str("value", value).Msg("")
var ldapAttr string
switch attr {
case "externalId":
ldapAttr = i.educationConfig.schoolAttributeMap.externalId
default:
return nil, errorcode.New(errorcode.InvalidRequest, fmt.Sprintf("filtering by attribute '%s' is not supported", attr))
}
filter := fmt.Sprintf("(&%s(objectClass=%s)(%s=%s))",
i.educationConfig.schoolFilter,
i.educationConfig.schoolObjectClass,
ldap.EscapeFilter(ldapAttr),
ldap.EscapeFilter(value),
)
return i.searchEducationSchools(ctx, filter)
}
// searchEducationSchools builds and executes an LDAP search for education schools and converts the results to EducationSchool models.
func (i *LDAP) searchEducationSchools(ctx context.Context, filter string) ([]*libregraph.EducationSchool, error) {
searchRequest := ldap.NewSearchRequest(
i.educationConfig.schoolBaseDN,
i.educationConfig.schoolScope,
ldap.NeverDerefAliases, 0, 0, false,
filter,
i.getEducationSchoolAttrTypes(),
nil,
)
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("searchEducationSchools")
res, err := i.conn.Search(searchRequest)
if err != nil {
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
}
schools := make([]*libregraph.EducationSchool, 0, len(res.Entries))
for _, e := range res.Entries {
school := i.createSchoolModelFromLDAP(e)
// Skip invalid LDAP entries
if school == nil {
continue
}
schools = append(schools, school)
}
return schools, nil
}
// GetEducationSchoolUsers implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationSchoolUsers(ctx context.Context, schoolNumberOrID string) ([]*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationSchoolUsers")
entries, err := i.getEducationSchoolEntries(
schoolNumberOrID, i.userFilter, i.educationConfig.userObjectClass, i.userBaseDN, i.userScope, i.getEducationUserAttrTypes(), logger,
)
if err != nil {
return nil, err
}
users := make([]*libregraph.EducationUser, 0, len(entries))
for _, e := range entries {
u := i.createEducationUserModelFromLDAP(e)
// Skip invalid LDAP users
if u == nil {
continue
}
users = append(users, u)
}
return users, nil
}
// AddUsersToEducationSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend.
func (i *LDAP) AddUsersToEducationSchool(ctx context.Context, schoolNumberOrID string, memberIDs []string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("AddUsersToEducationSchool")
schoolEntry, err := i.getSchoolByNumberOrID(schoolNumberOrID)
if err != nil {
return err
}
if schoolEntry == nil {
return ErrNotFound
}
schoolID := schoolEntry.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
userEntries := make([]*ldap.Entry, 0, len(memberIDs))
for _, memberID := range memberIDs {
user, err := i.getEducationUserByNameOrID(memberID)
if err != nil {
i.logger.Warn().Str("userid", memberID).Msg("User does not exist")
return errorcode.New(errorcode.ItemNotFound, fmt.Sprintf("user '%s' not found", memberID))
}
userEntries = append(userEntries, user)
}
for _, userEntry := range userEntries {
if err := i.addEntryToSchool(userEntry, schoolID); err != nil {
return err
}
}
return nil
}
// addEntryToSchool adds the schoolID to the entry's memberOfSchool attribute if not already present.
func (i *LDAP) addEntryToSchool(entry *ldap.Entry, schoolID string) error {
currentSchools := entry.GetEqualFoldAttributeValues(i.educationConfig.memberOfSchoolAttribute)
if slices.Contains(currentSchools, schoolID) {
return nil
}
mr := ldap.ModifyRequest{DN: entry.DN}
mr.Add(i.educationConfig.memberOfSchoolAttribute, []string{schoolID})
return i.conn.Modify(&mr)
}
// RemoveUserFromEducationSchool removes a single member (by ID) from a school
func (i *LDAP) RemoveUserFromEducationSchool(ctx context.Context, schoolNumberOrID string, memberID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("RemoveUserFromEducationSchool")
schoolEntry, err := i.getSchoolByNumberOrID(schoolNumberOrID)
if err != nil {
return err
}
if schoolEntry == nil {
return ErrNotFound
}
schoolID := schoolEntry.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
user, err := i.getEducationUserByNameOrID(memberID)
if err != nil {
i.logger.Warn().Str("userid", memberID).Msg("User does not exist")
return err
}
currentSchools := user.GetEqualFoldAttributeValues(i.educationConfig.memberOfSchoolAttribute)
for _, currentSchool := range currentSchools {
if currentSchool == schoolID {
mr := ldap.ModifyRequest{DN: user.DN}
mr.Delete(i.educationConfig.memberOfSchoolAttribute, []string{schoolID})
if err := i.conn.Modify(&mr); err != nil {
return err
}
break
}
}
return nil
}
// GetEducationSchoolClasses implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationSchoolClasses(ctx context.Context, schoolNumberOrID string) ([]*libregraph.EducationClass, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationSchoolClasses")
entries, err := i.getEducationSchoolEntries(
schoolNumberOrID, i.groupFilter, i.educationConfig.classObjectClass, i.groupBaseDN, i.groupScope, i.getEducationClassAttrTypes(false), logger,
)
if err != nil {
return nil, err
}
classes := make([]*libregraph.EducationClass, 0, len(entries))
for _, e := range entries {
class := i.createEducationClassModelFromLDAP(e)
// Skip invalid LDAP classes
if class == nil {
continue
}
classes = append(classes, class)
}
return classes, nil
}
func (i *LDAP) getEducationSchoolEntries(
schoolNumberOrID, filter, objectClass, baseDN string,
scope int,
attributes []string,
logger log.Logger,
) ([]*ldap.Entry, error) {
schoolEntry, err := i.getSchoolByNumberOrID(schoolNumberOrID)
if err != nil {
return nil, err
}
if schoolEntry == nil {
return nil, ErrNotFound
}
schoolID := schoolEntry.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
schoolID = ldap.EscapeFilter(schoolID)
idFilter := fmt.Sprintf("(%s=%s)", i.educationConfig.memberOfSchoolAttribute, schoolID)
searchFilter := fmt.Sprintf("(&%s(objectClass=%s)%s)", filter, objectClass, idFilter)
searchRequest := ldap.NewSearchRequest(
baseDN,
scope,
ldap.NeverDerefAliases, 0, 0, false,
searchFilter,
attributes,
nil,
)
logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("GetEducationClasses")
res, err := i.conn.Search(searchRequest)
if err != nil {
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
}
return res.Entries, nil
}
// AddClassesToEducationSchool adds new members (reference by a slice of IDs) to supplied school in the identity backend.
func (i *LDAP) AddClassesToEducationSchool(ctx context.Context, schoolNumberOrID string, memberIDs []string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("AddClassesToEducationSchool")
schoolEntry, err := i.getSchoolByNumberOrID(schoolNumberOrID)
if err != nil {
return err
}
if schoolEntry == nil {
return ErrNotFound
}
schoolID := schoolEntry.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
classEntries := make([]*ldap.Entry, 0, len(memberIDs))
for _, memberID := range memberIDs {
class, err := i.getEducationClassByID(memberID, false)
if err != nil {
i.logger.Warn().Str("userid", memberID).Msg("Class does not exist")
return err
}
classEntries = append(classEntries, class)
}
for _, classEntry := range classEntries {
if err := i.addEntryToSchool(classEntry, schoolID); err != nil {
return err
}
}
return nil
}
// RemoveClassFromEducationSchool removes a single member (by ID) from a school
func (i *LDAP) RemoveClassFromEducationSchool(ctx context.Context, schoolNumberOrID string, memberID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("RemoveClassFromEducationSchool")
schoolEntry, err := i.getSchoolByNumberOrID(schoolNumberOrID)
if err != nil {
return err
}
if schoolEntry == nil {
return ErrNotFound
}
schoolID := schoolEntry.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
class, err := i.getEducationClassByID(memberID, false)
if err != nil {
i.logger.Warn().Str("userid", memberID).Msg("Class does not exist")
return err
}
currentSchools := class.GetEqualFoldAttributeValues(i.educationConfig.memberOfSchoolAttribute)
for _, currentSchool := range currentSchools {
if currentSchool == schoolID {
mr := ldap.ModifyRequest{DN: class.DN}
mr.Delete(i.educationConfig.memberOfSchoolAttribute, []string{schoolID})
if err := i.conn.Modify(&mr); err != nil {
return err
}
break
}
}
return nil
}
func (i *LDAP) getSchoolByDN(dn string) (*ldap.Entry, error) {
filter := fmt.Sprintf("(objectClass=%s)", i.educationConfig.schoolObjectClass)
if i.educationConfig.schoolFilter != "" {
filter = fmt.Sprintf("(&%s(%s))", filter, i.educationConfig.schoolFilter)
}
return i.getEntryByDN(dn, i.getEducationSchoolAttrTypes(), filter)
}
func (i *LDAP) getSchoolByNumberOrID(numberOrID string) (*ldap.Entry, error) {
numberOrID = ldap.EscapeFilter(numberOrID)
filter := fmt.Sprintf(
"(|(%s=%s)(%s=%s))",
i.educationConfig.schoolAttributeMap.id,
numberOrID,
i.educationConfig.schoolAttributeMap.schoolNumber,
numberOrID,
)
return i.getSchoolByFilter(filter)
}
func (i *LDAP) getSchoolByNumber(schoolNumber string) (*ldap.Entry, error) {
schoolNumber = ldap.EscapeFilter(schoolNumber)
filter := fmt.Sprintf(
"(%s=%s)",
i.educationConfig.schoolAttributeMap.schoolNumber,
schoolNumber,
)
return i.getSchoolByFilter(filter)
}
func (i *LDAP) getSchoolByFilter(filter string) (*ldap.Entry, error) {
filter = fmt.Sprintf("(&%s(objectClass=%s)%s)",
i.educationConfig.schoolFilter,
i.educationConfig.schoolObjectClass,
filter,
)
searchRequest := ldap.NewSearchRequest(
i.educationConfig.schoolBaseDN,
i.educationConfig.schoolScope,
ldap.NeverDerefAliases, 1, 0, false,
filter,
i.getEducationSchoolAttrTypes(),
nil,
)
i.logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("getSchoolByFilter")
res, err := i.conn.Search(searchRequest)
if err != nil {
var errmsg string
if lerr, ok := err.(*ldap.Error); ok {
if lerr.ResultCode == ldap.LDAPResultSizeLimitExceeded {
errmsg = fmt.Sprintf("too many results searching for school '%s'", filter)
i.logger.Debug().Str("backend", "ldap").Err(lerr).
Str("schoolfilter", filter).Msg("too many results searching for school")
}
}
return nil, errorcode.New(errorcode.ItemNotFound, errmsg)
}
if len(res.Entries) == 0 {
return nil, ErrNotFound
}
return res.Entries[0], nil
}
func (i *LDAP) createSchoolModelFromLDAP(e *ldap.Entry) *libregraph.EducationSchool {
if e == nil {
return nil
}
displayName := i.getDisplayName(e)
id := i.getID(e)
schoolNumber := i.getSchoolNumber(e)
externalId := i.getExternalId(e)
t, err := i.getTerminationDate(e)
if err != nil && !errors.Is(err, errNotSet) {
i.logger.Error().Err(err).Str("dn", e.DN).Msg("Error reading termination date for LDAP entry")
}
if id == "" || displayName == "" {
i.logger.Warn().Str("dn", e.DN).Str("id", id).Str("displayName", displayName).Msg("Invalid School. Missing required attribute")
return nil
}
school := libregraph.NewEducationSchool()
school.SetDisplayName(displayName)
school.SetId(id)
if schoolNumber != "" {
school.SetSchoolNumber(schoolNumber)
}
if externalId != "" {
school.SetExternalId(externalId)
}
if t != nil {
school.SetTerminationDate(*t)
}
return school
}
func (i *LDAP) getSchoolNumber(e *ldap.Entry) string {
schoolNumber := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.schoolNumber)
return schoolNumber
}
func (i *LDAP) getExternalId(e *ldap.Entry) string {
externalId := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.externalId)
return externalId
}
func (i *LDAP) getID(e *ldap.Entry) string {
id := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.id)
return id
}
func (i *LDAP) getDisplayName(e *ldap.Entry) string {
displayName := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.displayName)
return displayName
}
func (i *LDAP) getTerminationDate(e *ldap.Entry) (*time.Time, error) {
dateString := e.GetEqualFoldAttributeValue(i.educationConfig.schoolAttributeMap.terminationDate)
if dateString == "" {
return nil, errNotSet
}
t, err := time.Parse(ldapDateFormat, dateString)
if err != nil {
err = fmt.Errorf("error parsing LDAP date: '%s': %w", dateString, err)
return nil, err
}
return &t, nil
}
func (i *LDAP) getEducationSchoolAttrTypes() []string {
return []string{
i.educationConfig.schoolAttributeMap.displayName,
i.educationConfig.schoolAttributeMap.id,
i.educationConfig.schoolAttributeMap.externalId,
i.educationConfig.schoolAttributeMap.schoolNumber,
i.educationConfig.schoolAttributeMap.terminationDate,
}
}
@@ -0,0 +1,722 @@
package identity
import (
"context"
"errors"
"testing"
"time"
"github.com/go-ldap/ldap/v3"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/identity/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
var eduConfig = config.LDAP{
UserBaseDN: "ou=people,dc=test",
UserObjectClass: "inetOrgPerson",
UserSearchScope: "sub",
UserFilter: "",
UserDisplayNameAttribute: "displayname",
UserIDAttribute: "entryUUID",
UserEmailAttribute: "mail",
UserNameAttribute: "uid",
UserEnabledAttribute: "userEnabledAttribute",
DisableUserMechanism: "attribute",
UserTypeAttribute: "userTypeAttribute",
GroupBaseDN: "ou=groups,dc=test",
GroupObjectClass: "groupOfNames",
GroupSearchScope: "sub",
GroupFilter: "",
GroupNameAttribute: "cn",
GroupMemberAttribute: "member",
GroupIDAttribute: "entryUUID",
WriteEnabled: true,
EducationResourcesEnabled: true,
}
var schoolEntry = ldap.NewEntry("ou=Test School",
map[string][]string{
"ou": {"Test School"},
"qsferaEducationSchoolNumber": {"0123"},
"qsferaUUID": {"abcd-defg"},
"qsferaEducationExternalId": {"abcd-defg"},
})
var schoolEntry1 = ldap.NewEntry("ou=Test School1",
map[string][]string{
"ou": {"Test School1"},
"qsferaEducationSchoolNumber": {"0042"},
"qsferaUUID": {"hijk-defg"},
"qsferaEducationExternalId": {"hijk-defg"},
})
var schoolEntryWithTermination = ldap.NewEntry("ou=Test School",
map[string][]string{
"ou": {"Test School"},
"qsferaEducationSchoolNumber": {"0123"},
"qsferaUUID": {"abcd-defg"},
"qsferaEducationExternalId": {"abcd-defg"},
"qsferaEducationSchoolTerminationTimestamp": {"20420131120000Z"},
})
var (
filterSchoolSearchByIdExisting = "(&(objectClass=qsferaEducationSchool)(|(qsferaUUID=abcd-defg)(qsferaEducationSchoolNumber=abcd-defg)))"
filterSchoolSearchByIdNonexistant = "(&(objectClass=qsferaEducationSchool)(|(qsferaUUID=xxxx-xxxx)(qsferaEducationSchoolNumber=xxxx-xxxx)))"
filterSchoolSearchByNumberExisting = "(&(objectClass=qsferaEducationSchool)(|(qsferaUUID=0123)(qsferaEducationSchoolNumber=0123)))"
filterSchoolSearchByNumberNonexistant = "(&(objectClass=qsferaEducationSchool)(|(qsferaUUID=3210)(qsferaEducationSchoolNumber=3210)))"
schoolLDAPAttributeTypes = []string{"ou", "qsferaUUID", "qsferaEducationExternalId", "qsferaEducationSchoolNumber", "qsferaEducationSchoolTerminationTimestamp"}
)
func TestCreateEducationSchool(t *testing.T) {
tests := []struct {
name string
schoolNumber string
schoolName string
expectedError error
}{
{
name: "Create a Education School succeeds",
schoolNumber: "0123",
schoolName: "Test School",
expectedError: nil,
}, {
name: "Create a Education School with a duplicated Schoolnumber fails with an error",
schoolNumber: "0666",
schoolName: "Test School",
expectedError: errorcode.New(errorcode.NameAlreadyExists, "A school with that number is already present"),
}, {
name: "Create a Education School with a duplicated Name fails with an error",
schoolNumber: "0123",
schoolName: "Existing Test School",
expectedError: errorcode.New(errorcode.NameAlreadyExists, "A school with that name is already present"),
}, {
name: "Create a Education School fails, when there is a backend error",
schoolNumber: "1111",
schoolName: "Test School",
expectedError: errorcode.New(errorcode.GeneralException, "error looking up school by number"),
},
}
for _, tt := range tests {
lm := &mocks.Client{}
ldapSchoolGoodAddRequestMatcher := func(ar *ldap.AddRequest) bool {
if ar.DN != "ou=Test School," {
return false
}
for _, attr := range ar.Attributes {
if attr.Type == "qsferaEducationSchoolTerminationTimestamp" {
return false
}
}
return true
}
lm.On("Add", mock.MatchedBy(ldapSchoolGoodAddRequestMatcher)).Return(nil)
ldapExistingSchoolAddRequestMatcher := func(ar *ldap.AddRequest) bool {
if ar.DN == "ou=Existing Test School," {
return true
}
return false
}
lm.On("Add", mock.MatchedBy(ldapExistingSchoolAddRequestMatcher)).Return(ldap.NewError(ldap.LDAPResultEntryAlreadyExists, errors.New("")))
schoolNumberSearchRequest := &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationSchool)(qsferaEducationSchoolNumber=0123))",
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
lm.On("Search", schoolNumberSearchRequest).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{},
},
nil)
existingSchoolNumberSearchRequest := &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationSchool)(qsferaEducationSchoolNumber=0666))",
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
lm.On("Search", existingSchoolNumberSearchRequest).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{schoolEntry},
},
nil)
schoolNumberSearchRequestError := &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationSchool)(qsferaEducationSchoolNumber=1111))",
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
lm.On("Search", schoolNumberSearchRequestError).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{},
},
ldap.NewError(ldap.LDAPResultOther, errors.New("some error")))
schoolLookupAfterCreate := &ldap.SearchRequest{
BaseDN: "ou=Test School,",
Scope: 0,
SizeLimit: 1,
Filter: "(objectClass=qsferaEducationSchool)",
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
lm.On("Search", schoolLookupAfterCreate).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{schoolEntry},
},
nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
assert.NotEqual(t, "", b.educationConfig.schoolObjectClass)
school := libregraph.NewEducationSchool()
school.SetDisplayName(tt.schoolName)
school.SetSchoolNumber(tt.schoolNumber)
school.SetId("abcd-defg")
resSchool, err := b.CreateEducationSchool(context.Background(), *school)
if tt.expectedError == nil {
assert.Nil(t, err)
lm.AssertNumberOfCalls(t, "Add", 1)
assert.NotNil(t, resSchool)
assert.Equal(t, resSchool.GetDisplayName(), school.GetDisplayName())
assert.Equal(t, resSchool.GetId(), school.GetId())
assert.Equal(t, resSchool.GetSchoolNumber(), school.GetSchoolNumber())
assert.False(t, resSchool.HasTerminationDate())
} else {
assert.Equal(t, err, tt.expectedError)
assert.Nil(t, resSchool)
}
}
}
func TestUpdateEducationSchoolTerminationDate(t *testing.T) {
lm := &mocks.Client{}
ldapSchoolTerminationRequestMatcher := func(mr *ldap.ModifyRequest) bool {
if mr.DN != "ou=Test School" {
return false
}
for _, mod := range mr.Changes {
if mod.Operation == ldap.ReplaceAttribute &&
mod.Modification.Type == "qsferaEducationSchoolTerminationTimestamp" &&
mod.Modification.Vals[0] == "20420131120000Z" {
return true
}
}
return false
}
lm.On("Modify", mock.MatchedBy(ldapSchoolTerminationRequestMatcher)).Return(nil)
lm.On("Search", mock.Anything).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{schoolEntry},
},
nil).
Once()
lm.On("Search", mock.Anything).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{schoolEntryWithTermination},
},
nil).
Once()
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
assert.NotEqual(t, "", b.educationConfig.schoolObjectClass)
school := libregraph.NewEducationSchool()
terminationTime := time.Date(2042, time.January, 31, 12, 0, 0, 0, time.UTC)
school.SetTerminationDate(terminationTime)
resSchool, err := b.UpdateEducationSchool(context.Background(), "abcd-defg", *school)
lm.AssertNumberOfCalls(t, "Search", 2)
assert.Nil(t, err)
assert.NotNil(t, resSchool)
assert.Equal(t, "Test School", resSchool.GetDisplayName())
assert.Equal(t, "abcd-defg", resSchool.GetId())
assert.Equal(t, "0123", resSchool.GetSchoolNumber())
assert.True(t, resSchool.HasTerminationDate())
assert.True(t, terminationTime.Equal(resSchool.GetTerminationDate()))
}
func TestUpdateEducationSchoolOperation(t *testing.T) {
testSchoolName := "A name"
testSchoolNumber := "1234"
tests := []struct {
name string
displayName string
schoolNumber string
expectedOperation schoolUpdateOperation
}{
{
name: "Test using school with both number and name, unchanged",
displayName: testSchoolName,
schoolNumber: testSchoolNumber,
expectedOperation: schoolUnchanged,
},
{
name: "Test using school with both number and name, unchanged",
displayName: "A new name",
schoolNumber: "9876",
expectedOperation: tooManyValues,
},
{
name: "Test with unchanged number",
schoolNumber: testSchoolNumber,
expectedOperation: schoolUnchanged,
},
{
name: "Test with unchanged name",
displayName: testSchoolName,
expectedOperation: schoolUnchanged,
},
{
name: "Test new name",
displayName: "Something new",
expectedOperation: schoolRenamed,
},
{
name: "Test new number",
schoolNumber: "9876",
expectedOperation: schoolPropertiesUpdated,
},
}
for _, tt := range tests {
lm := &mocks.Client{}
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
displayName := "A name"
schoolNumber := "1234"
currentSchool := libregraph.EducationSchool{
DisplayName: &displayName,
SchoolNumber: &schoolNumber,
}
schoolUpdate := libregraph.EducationSchool{
DisplayName: &tt.displayName,
SchoolNumber: &tt.schoolNumber,
}
operation := b.updateEducationSchoolOperation(schoolUpdate, currentSchool)
assert.Equal(t, tt.expectedOperation, operation)
}
}
func TestDeleteEducationSchool(t *testing.T) {
tests := []struct {
name string
numberOrId string
filter string
expectedItemNotFound bool
}{
{
name: "Test delete school using schoolId",
numberOrId: "abcd-defg",
filter: filterSchoolSearchByIdExisting,
expectedItemNotFound: false,
},
{
name: "Test delete school using unknown schoolId",
numberOrId: "xxxx-xxxx",
filter: filterSchoolSearchByIdNonexistant,
expectedItemNotFound: true,
},
{
name: "Test delete school using schoolNumber",
numberOrId: "0123",
filter: filterSchoolSearchByNumberExisting,
expectedItemNotFound: false,
},
{
name: "Test delete school using unknown schoolNumber",
numberOrId: "3210",
filter: filterSchoolSearchByNumberNonexistant,
expectedItemNotFound: true,
},
}
for _, tt := range tests {
lm := &mocks.Client{}
sr := &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: tt.filter,
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
if tt.expectedItemNotFound {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
} else {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
}
dr := &ldap.DelRequest{
DN: "ou=Test School",
}
lm.On("Del", dr).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.DeleteEducationSchool(context.Background(), tt.numberOrId)
lm.AssertNumberOfCalls(t, "Search", 1)
if tt.expectedItemNotFound {
lm.AssertNumberOfCalls(t, "Del", 0)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
} else {
assert.Nil(t, err)
}
}
}
func TestGetEducationSchool(t *testing.T) {
tests := []struct {
name string
numberOrId string
filter string
expectedItemNotFound bool
}{
{
name: "Test search school using schoolId",
numberOrId: "abcd-defg",
filter: filterSchoolSearchByIdExisting,
expectedItemNotFound: false,
},
{
name: "Test search school using unknown schoolId",
numberOrId: "xxxx-xxxx",
filter: filterSchoolSearchByIdNonexistant,
expectedItemNotFound: true,
},
{
name: "Test search school using schoolNumber",
numberOrId: "0123",
filter: filterSchoolSearchByNumberExisting,
expectedItemNotFound: false,
},
{
name: "Test search school using unknown schoolNumber",
numberOrId: "3210",
filter: filterSchoolSearchByNumberNonexistant,
expectedItemNotFound: true,
},
}
for _, tt := range tests {
lm := &mocks.Client{}
sr := &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: tt.filter,
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
if tt.expectedItemNotFound {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
} else {
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
}
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
school, err := b.GetEducationSchool(context.Background(), tt.numberOrId)
lm.AssertNumberOfCalls(t, "Search", 1)
if tt.expectedItemNotFound {
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
} else {
assert.Nil(t, err)
assert.Equal(t, "Test School", school.GetDisplayName())
assert.Equal(t, "abcd-defg", school.GetId())
assert.Equal(t, "0123", school.GetSchoolNumber())
}
}
}
func TestGetEducationSchools(t *testing.T) {
lm := &mocks.Client{}
sr1 := &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 0,
Filter: "(objectClass=qsferaEducationSchool)",
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry, schoolEntry1}}, nil)
// lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
_, err = b.GetEducationSchools(context.Background())
lm.AssertNumberOfCalls(t, "Search", 1)
assert.Nil(t, err)
}
var schoolByIDSearch1 *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: filterSchoolSearchByIdExisting,
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
var schoolByNumberSearch *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "",
Scope: 2,
SizeLimit: 1,
Filter: filterSchoolSearchByNumberExisting,
Attributes: schoolLDAPAttributeTypes,
Controls: []ldap.Control(nil),
}
var userByIDSearch1 *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "ou=people,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationUser)(|(uid=abcd-defg)(entryUUID=abcd-defg)))",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
var userByIDSearch2 *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "ou=people,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationUser)(|(uid=does-not-exist)(entryUUID=does-not-exist)))",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
var userToSchoolModRequest *ldap.ModifyRequest = &ldap.ModifyRequest{
DN: "uid=user,ou=people,dc=test",
Changes: []ldap.Change{
{
Operation: ldap.AddAttribute,
Modification: ldap.PartialAttribute{
Type: "qsferaMemberOfSchool",
Vals: []string{"abcd-defg"},
},
},
},
}
var userFromSchoolModRequest *ldap.ModifyRequest = &ldap.ModifyRequest{
DN: "uid=user,ou=people,dc=test",
Changes: []ldap.Change{
{
Operation: ldap.DeleteAttribute,
Modification: ldap.PartialAttribute{
Type: "qsferaMemberOfSchool",
Vals: []string{"abcd-defg"},
},
},
},
}
var classToSchoolModRequest *ldap.ModifyRequest = &ldap.ModifyRequest{
DN: "qsferaEducationExternalId=Math0123",
Changes: []ldap.Change{
{
Operation: ldap.AddAttribute,
Modification: ldap.PartialAttribute{
Type: "qsferaMemberOfSchool",
Vals: []string{"abcd-defg"},
},
},
},
}
var classFromSchoolModRequest *ldap.ModifyRequest = &ldap.ModifyRequest{
DN: "qsferaEducationExternalId=Math0123",
Changes: []ldap.Change{
{
Operation: ldap.DeleteAttribute,
Modification: ldap.PartialAttribute{
Type: "qsferaMemberOfSchool",
Vals: []string{"abcd-defg"},
},
},
},
}
func TestAddUsersToEducationSchool(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", schoolByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", schoolByNumberSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", userByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntry}}, nil)
lm.On("Search", userByIDSearch2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
lm.On("Modify", userToSchoolModRequest).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.AddUsersToEducationSchool(context.Background(), "abcd-defg", []string{"does-not-exist"})
lm.AssertNumberOfCalls(t, "Search", 2)
assert.NotNil(t, err)
err = b.AddUsersToEducationSchool(context.Background(), "abcd-defg", []string{"abcd-defg", "does-not-exist"})
lm.AssertNumberOfCalls(t, "Search", 5)
assert.NotNil(t, err)
err = b.AddUsersToEducationSchool(context.Background(), "abcd-defg", []string{"abcd-defg"})
lm.AssertNumberOfCalls(t, "Search", 7)
assert.Nil(t, err)
// try to add by school number (instead or id)
err = b.AddUsersToEducationSchool(context.Background(), "0123", []string{"abcd-defg"})
lm.AssertNumberOfCalls(t, "Search", 9)
assert.Nil(t, err)
}
func TestRemoveMemberFromEducationSchool(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", schoolByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", schoolByNumberSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", userByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntryWithSchool}}, nil)
lm.On("Search", userByIDSearch2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
lm.On("Modify", userFromSchoolModRequest).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.RemoveUserFromEducationSchool(context.Background(), "abcd-defg", "does-not-exist")
lm.AssertNumberOfCalls(t, "Search", 2)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
err = b.RemoveUserFromEducationSchool(context.Background(), "abcd-defg", "abcd-defg")
lm.AssertNumberOfCalls(t, "Search", 4)
lm.AssertNumberOfCalls(t, "Modify", 1)
// try to remove by school number (instead or id)
err = b.RemoveUserFromEducationSchool(context.Background(), "0123", "abcd-defg")
lm.AssertNumberOfCalls(t, "Search", 6)
lm.AssertNumberOfCalls(t, "Modify", 2)
assert.Nil(t, err)
}
var usersBySchoolIDSearch *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "ou=people,dc=test",
Scope: 2,
SizeLimit: 0,
Filter: "(&(objectClass=qsferaEducationUser)(qsferaMemberOfSchool=abcd-defg))",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
func TestGetEducationSchoolUsers(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", schoolByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", schoolByNumberSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", usersBySchoolIDSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntryWithSchool}}, nil)
b, _ := getMockedBackend(lm, eduConfig, &logger)
users, err := b.GetEducationSchoolUsers(context.Background(), "abcd-defg")
assert.Nil(t, err)
assert.Equal(t, 1, len(users))
users, err = b.GetEducationSchoolUsers(context.Background(), "0123")
assert.Nil(t, err)
assert.Equal(t, 1, len(users))
}
var classesBySchoolIDSearch *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 0,
Filter: "(&(objectClass=qsferaEducationClass)(qsferaMemberOfSchool=abcd-defg))",
Attributes: []string{"cn", "entryUUID", "qsferaEducationClassType", "qsferaEducationExternalId", "qsferaMemberOfSchool", "qsferaEducationTeacherMember"},
Controls: []ldap.Control(nil),
}
func TestGetEducationSchoolClasses(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", schoolByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", schoolByNumberSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", classesBySchoolIDSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntry}}, nil)
b, _ := getMockedBackend(lm, eduConfig, &logger)
users, err := b.GetEducationSchoolClasses(context.Background(), "abcd-defg")
assert.Nil(t, err)
assert.Equal(t, 1, len(users))
users, err = b.GetEducationSchoolClasses(context.Background(), "0123")
assert.Nil(t, err)
assert.Equal(t, 1, len(users))
}
var classesByUUIDSearchNotFound *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=does-not-exist)(qsferaEducationExternalId=does-not-exist)))",
Attributes: []string{"cn", "entryUUID", "qsferaEducationClassType", "qsferaEducationExternalId", "qsferaMemberOfSchool", "qsferaEducationTeacherMember"},
Controls: []ldap.Control(nil),
}
var classesByUUIDSearchFound *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationClass)(|(entryUUID=abcd-defg)(qsferaEducationExternalId=abcd-defg)))",
Attributes: []string{"cn", "entryUUID", "qsferaEducationClassType", "qsferaEducationExternalId", "qsferaMemberOfSchool", "qsferaEducationTeacherMember"},
Controls: []ldap.Control(nil),
}
func TestAddClassesToEducationSchool(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", classesByUUIDSearchNotFound).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
lm.On("Search", classesByUUIDSearchFound).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntry}}, nil)
lm.On("Search", schoolByNumberSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", schoolByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Modify", classToSchoolModRequest).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.AddClassesToEducationSchool(context.Background(), "abcd-defg", []string{"does-not-exist"})
lm.AssertNumberOfCalls(t, "Search", 2)
assert.NotNil(t, err)
err = b.AddClassesToEducationSchool(context.Background(), "abcd-defg", []string{"abcd-defg", "does-not-exist"})
lm.AssertNumberOfCalls(t, "Search", 5)
assert.NotNil(t, err)
err = b.AddClassesToEducationSchool(context.Background(), "abcd-defg", []string{"abcd-defg"})
lm.AssertNumberOfCalls(t, "Search", 7)
assert.Nil(t, err)
// try to add by school number (instead or id)
err = b.AddClassesToEducationSchool(context.Background(), "0123", []string{"abcd-defg"})
lm.AssertNumberOfCalls(t, "Search", 9)
assert.Nil(t, err)
}
func TestRemoveClassFromEducationSchool(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", schoolByIDSearch1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", schoolByNumberSearch).Return(&ldap.SearchResult{Entries: []*ldap.Entry{schoolEntry}}, nil)
lm.On("Search", classesByUUIDSearchFound).Return(&ldap.SearchResult{Entries: []*ldap.Entry{classEntryWithSchool}}, nil)
lm.On("Search", classesByUUIDSearchNotFound).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
lm.On("Modify", classFromSchoolModRequest).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.RemoveClassFromEducationSchool(context.Background(), "abcd-defg", "does-not-exist")
lm.AssertNumberOfCalls(t, "Search", 2)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
err = b.RemoveClassFromEducationSchool(context.Background(), "abcd-defg", "abcd-defg")
lm.AssertNumberOfCalls(t, "Search", 4)
lm.AssertNumberOfCalls(t, "Modify", 1)
// try to remove by school number (instead or id)
err = b.RemoveClassFromEducationSchool(context.Background(), "0123", "abcd-defg")
lm.AssertNumberOfCalls(t, "Search", 6)
lm.AssertNumberOfCalls(t, "Modify", 2)
assert.Nil(t, err)
}
@@ -0,0 +1,400 @@
package identity
import (
"context"
"errors"
"fmt"
"github.com/go-ldap/ldap/v3"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
type educationUserAttributeMap struct {
primaryRole string
externalID string
}
func newEducationUserAttributeMap() educationUserAttributeMap {
return educationUserAttributeMap{
primaryRole: "userClass",
externalID: "qsferaEducationExternalId",
}
}
// CreateEducationUser creates a given education user in the identity backend.
func (i *LDAP) CreateEducationUser(ctx context.Context, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("CreateEducationUser")
if !i.writeEnabled {
return nil, ErrReadOnly
}
ar, err := i.educationUserToAddRequest(user)
if err != nil {
return nil, err
}
if err = i.conn.Add(ar); err != nil {
var lerr *ldap.Error
logger.Debug().Err(err).Msg("error adding user")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errorcode.New(errorcode.NameAlreadyExists, lerr.Error())
}
}
return nil, err
}
// Read back user from LDAP to get the generated UUID
e, err := i.getEducationUserByDN(ar.DN)
if err != nil {
return nil, err
}
return i.createEducationUserModelFromLDAP(e), nil
}
// DeleteEducationUser deletes a given education user, identified by username or id, from the backend
func (i *LDAP) DeleteEducationUser(ctx context.Context, nameOrID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("DeleteEducationUser")
if !i.writeEnabled {
return ErrReadOnly
}
// TODO, implement a proper lookup for education Users here
e, err := i.getEducationUserByNameOrID(nameOrID)
if err != nil {
return err
}
dr := ldap.DelRequest{DN: e.DN}
if err = i.conn.Del(&dr); err != nil {
return err
}
return nil
}
// UpdateEducationUser applies changes to given education user, identified by username or id
func (i *LDAP) UpdateEducationUser(ctx context.Context, nameOrID string, user libregraph.EducationUser) (*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("UpdateEducationUser")
if !i.writeEnabled {
return nil, ErrReadOnly
}
e, err := i.getEducationUserByNameOrID(nameOrID)
if err != nil {
return nil, err
}
var updateNeeded bool
// Don't allow updates of the ID
if user.GetId() != "" {
id, err := i.ldapUUIDtoString(e, i.userAttributeMap.id, i.userIDisOctetString)
if err != nil {
i.logger.Warn().Str("dn", e.DN).Str(i.userAttributeMap.id, e.GetEqualFoldAttributeValue(i.userAttributeMap.id)).Msg("Invalid User. Cannot convert UUID")
return nil, errorcode.New(errorcode.GeneralException, "error converting uuid")
}
if id != user.GetId() {
return nil, errorcode.New(errorcode.NotAllowed, "changing the UserId is not allowed")
}
}
if user.GetOnPremisesSamAccountName() != "" {
if eu := e.GetEqualFoldAttributeValue(i.userAttributeMap.userName); eu != user.GetOnPremisesSamAccountName() {
e, err = i.changeUserName(ctx, e.DN, eu, user.GetOnPremisesSamAccountName())
if err != nil {
return nil, err
}
e, err = i.getEducationUserByDN(e.DN)
if err != nil {
return nil, err
}
}
}
mr := ldap.ModifyRequest{DN: e.DN}
properties := map[string]string{
i.userAttributeMap.displayName: user.GetDisplayName(),
i.userAttributeMap.mail: user.GetMail(),
i.userAttributeMap.surname: user.GetSurname(),
i.userAttributeMap.givenName: user.GetGivenName(),
i.userAttributeMap.userType: user.GetUserType(),
i.educationConfig.userAttributeMap.primaryRole: user.GetPrimaryRole(),
i.educationConfig.userAttributeMap.externalID: user.GetExternalId(),
}
for attribute, value := range properties {
if value != "" {
if e.GetEqualFoldAttributeValue(attribute) != value {
mr.Replace(attribute, []string{value})
updateNeeded = true
}
}
}
if user.AccountEnabled != nil {
un, err := i.updateAccountEnabledState(logger, user.GetAccountEnabled(), e, &mr)
if err != nil {
return nil, err
}
if un {
updateNeeded = true
}
}
if user.PasswordProfile != nil && user.PasswordProfile.GetPassword() != "" {
if i.usePwModifyExOp {
if err := i.updateUserPassword(ctx, e.DN, user.PasswordProfile.GetPassword()); err != nil {
return nil, err
}
} else {
// password are hashed server side there is no need to check if the new password
// is actually different from the old one.
mr.Replace("userPassword", []string{user.PasswordProfile.GetPassword()})
updateNeeded = true
}
}
if identities, ok := user.GetIdentitiesOk(); ok {
attrValues := make([]string, 0, len(identities))
for _, identity := range identities {
identityStr, err := i.identityToLDAPAttrValue(identity)
if err != nil {
return nil, err
}
attrValues = append(attrValues, identityStr)
}
mr.Replace(i.userAttributeMap.identities, attrValues)
updateNeeded = true
}
if updateNeeded {
if err := i.conn.Modify(&mr); err != nil {
return nil, err
}
}
// Read back user from LDAP to get the generated UUID
e, err = i.getEducationUserByDN(e.DN)
if err != nil {
return nil, err
}
returnUser := i.createEducationUserModelFromLDAP(e)
// To avoid a ldap lookup for group membership, set the enabled flag to same as input value
// since this would have been updated with group membership from the input anyway.
if user.AccountEnabled != nil && i.disableUserMechanism == DisableMechanismGroup {
returnUser.AccountEnabled = user.AccountEnabled
}
return returnUser, nil
}
// GetEducationUser implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationUser(ctx context.Context, nameOrID string) (*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetEducationUser")
e, err := i.getEducationUserByNameOrID(nameOrID)
if err != nil {
return nil, err
}
u := i.createEducationUserModelFromLDAP(e)
if u == nil {
return nil, ErrNotFound
}
return u, nil
}
// GetEducationUsers implements the EducationBackend interface for the LDAP backend.
func (i *LDAP) GetEducationUsers(ctx context.Context) ([]*libregraph.EducationUser, error) {
var filter string
if i.userFilter == "" {
filter = fmt.Sprintf("(objectClass=%s)", i.educationConfig.userObjectClass)
} else {
filter = fmt.Sprintf("(&%s(objectClass=%s))", i.userFilter, i.educationConfig.userObjectClass)
}
return i.searchEducationUsers(ctx, filter)
}
func (i *LDAP) FilterEducationUsersByAttribute(ctx context.Context, attr, value string) ([]*libregraph.EducationUser, error) {
logger := i.logger.SubloggerWithRequestID(ctx).With().Str("func", "FilterEducationUsersByAttribute").Logger()
logger.Debug().Str("backend", "ldap").Str("attribute", attr).Str("value", value).Msg("")
var ldapAttr string
switch attr {
case "displayname":
ldapAttr = i.userAttributeMap.displayName
case "mail":
ldapAttr = i.userAttributeMap.mail
case "userType":
ldapAttr = i.userAttributeMap.userType
case "primaryRole":
ldapAttr = i.educationConfig.userAttributeMap.primaryRole
case "externalId":
ldapAttr = i.educationConfig.userAttributeMap.externalID
default:
return nil, errorcode.New(errorcode.InvalidRequest, fmt.Sprintf("filtering by attribute '%s' is not supported", attr))
}
filter := fmt.Sprintf("(&%s(objectClass=%s)(%s=%s))", i.userFilter, i.educationConfig.userObjectClass, ldap.EscapeFilter(ldapAttr), ldap.EscapeFilter(value))
return i.searchEducationUsers(ctx, filter)
}
// searchEducationUsers builds and executes an LDAP search for education users and converts the results to EducationUser models.
func (i *LDAP) searchEducationUsers(ctx context.Context, filter string) ([]*libregraph.EducationUser, error) {
searchRequest := ldap.NewSearchRequest(
i.userBaseDN,
i.userScope,
ldap.NeverDerefAliases, 0, 0, false,
filter,
i.getEducationUserAttrTypes(),
nil,
)
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("searchEducationUsers")
res, err := i.conn.Search(searchRequest)
if err != nil {
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
}
users := make([]*libregraph.EducationUser, 0, len(res.Entries))
for _, e := range res.Entries {
u := i.createEducationUserModelFromLDAP(e)
// Skip invalid LDAP users
if u == nil {
continue
}
users = append(users, u)
}
return users, nil
}
func (i *LDAP) educationUserToUser(eduUser libregraph.EducationUser) *libregraph.User {
user := libregraph.NewUser(*eduUser.DisplayName, *eduUser.OnPremisesSamAccountName)
user.Surname = eduUser.Surname
user.AccountEnabled = eduUser.AccountEnabled
user.GivenName = eduUser.GivenName
user.Mail = eduUser.Mail
user.UserType = eduUser.UserType
user.Identities = eduUser.Identities
return user
}
func (i *LDAP) userToEducationUser(user libregraph.User, e *ldap.Entry) *libregraph.EducationUser {
eduUser := libregraph.NewEducationUser()
eduUser.Id = user.Id
eduUser.OnPremisesSamAccountName = &user.OnPremisesSamAccountName
eduUser.Surname = user.Surname
eduUser.AccountEnabled = user.AccountEnabled
eduUser.GivenName = user.GivenName
eduUser.DisplayName = &user.DisplayName
eduUser.Mail = user.Mail
eduUser.UserType = user.UserType
if e != nil {
// Set the education User specific Attributes from the supplied LDAP Entry
if primaryRole := e.GetEqualFoldAttributeValue(i.educationConfig.userAttributeMap.primaryRole); primaryRole != "" {
eduUser.SetPrimaryRole(primaryRole)
}
if externalID := e.GetEqualFoldAttributeValue(i.educationConfig.userAttributeMap.externalID); externalID != "" {
eduUser.SetExternalId(externalID)
}
}
return eduUser
}
func (i *LDAP) educationUserToLDAPAttrValues(user libregraph.EducationUser, attrs ldapAttributeValues) (ldapAttributeValues, error) {
if role, ok := user.GetPrimaryRoleOk(); ok {
attrs[i.educationConfig.userAttributeMap.primaryRole] = []string{*role}
}
if externalID, ok := user.GetExternalIdOk(); ok {
attrs[i.educationConfig.userAttributeMap.externalID] = []string{*externalID}
}
attrs["objectClass"] = append(attrs["objectClass"], i.educationConfig.userObjectClass)
return attrs, nil
}
func (i *LDAP) educationUserToAddRequest(user libregraph.EducationUser) (*ldap.AddRequest, error) {
plainUser := i.educationUserToUser(user)
ldapAttrs, err := i.userToLDAPAttrValues(*plainUser)
if err != nil {
return nil, err
}
ldapAttrs, err = i.educationUserToLDAPAttrValues(user, ldapAttrs)
if err != nil {
return nil, err
}
ar := ldap.NewAddRequest(i.getUserLDAPDN(*plainUser), nil)
for attrType, values := range ldapAttrs {
ar.Attribute(attrType, values)
}
return ar, nil
}
func (i *LDAP) createEducationUserModelFromLDAP(e *ldap.Entry) *libregraph.EducationUser {
user := i.createUserModelFromLDAP(e)
return i.userToEducationUser(*user, e)
}
func (i *LDAP) getEducationUserAttrTypes() []string {
return []string{
i.userAttributeMap.displayName,
i.userAttributeMap.id,
i.userAttributeMap.mail,
i.userAttributeMap.userName,
i.userAttributeMap.surname,
i.userAttributeMap.givenName,
i.userAttributeMap.accountEnabled,
i.userAttributeMap.userType,
i.userAttributeMap.identities,
i.educationConfig.userAttributeMap.primaryRole,
i.educationConfig.userAttributeMap.externalID,
i.educationConfig.memberOfSchoolAttribute,
}
}
func (i *LDAP) getEducationUserByDN(dn string) (*ldap.Entry, error) {
filter := fmt.Sprintf("(objectClass=%s)", i.educationConfig.userObjectClass)
if i.userFilter != "" {
filter = fmt.Sprintf("(&%s(%s))", filter, i.userFilter)
}
return i.getEntryByDN(dn, i.getEducationUserAttrTypes(), filter)
}
func (i *LDAP) getEducationUserByNameOrID(nameOrID string) (*ldap.Entry, error) {
return i.getEducationObjectByNameOrID(
nameOrID,
i.userAttributeMap.userName,
i.userAttributeMap.id,
i.userFilter,
i.educationConfig.userObjectClass,
i.userBaseDN,
i.getEducationUserAttrTypes(),
)
}
func (i *LDAP) getEducationObjectByNameOrID(nameOrID, nameAttribute, idAttribute, objectFilter, objectClass, baseDN string, attributes []string) (*ldap.Entry, error) {
nameOrID = ldap.EscapeFilter(nameOrID)
filter := fmt.Sprintf("(|(%s=%s)(%s=%s))", nameAttribute, nameOrID, idAttribute, nameOrID)
return i.getEducationObjectByFilter(filter, baseDN, objectFilter, objectClass, attributes)
}
func (i *LDAP) getEducationObjectByFilter(filter, baseDN, objectFilter, objectClass string, attributes []string) (*ldap.Entry, error) {
filter = fmt.Sprintf("(&%s(objectClass=%s)%s)", objectFilter, objectClass, filter)
return i.searchLDAPEntryByFilter(baseDN, attributes, filter)
}
@@ -0,0 +1,303 @@
package identity
import (
"context"
"testing"
"github.com/go-ldap/ldap/v3"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/identity/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
const peopleBaseDN = "ou=people,dc=test"
var eduUserAttrs = []string{
"displayname",
"entryUUID",
"mail",
"uid",
"sn",
"givenname",
"userEnabledAttribute",
"userTypeAttribute",
"qsferaExternalIdentity",
"userClass",
"qsferaEducationExternalId",
"qsferaMemberOfSchool",
}
var eduUserEntry = ldap.NewEntry("uid=user,ou=people,dc=test",
map[string][]string{
"uid": {"testuser"},
"displayname": {"Test User"},
"mail": {"user@example"},
"entryuuid": {"abcd-defg"},
"userClass": {"student"},
"qsferaExternalIdentity": {
"$ http://idp $ testuser",
"xxx $ http://idpnew $ xxxxx-xxxxx-xxxxx",
},
"userTypeAttribute": {"Member"},
"userEnabledAttribute": {"FALSE"},
})
var renamedEduUserEntry = ldap.NewEntry("uid=newtestuser,ou=people,dc=test",
map[string][]string{
"uid": {"newtestuser"},
"displayname": {"Test User"},
"mail": {"user@example"},
"entryuuid": {"abcd-defg"},
"userClass": {"student"},
"qsferaExternalIdentity": {
"$ http://idp $ testuser",
"xxx $ http://idpnew $ xxxxx-xxxxx-xxxxx",
},
"userTypeAttribute": {"Guest"},
"userEnabledAttribute": {"TRUE"},
})
var eduUserEntryWithSchool = ldap.NewEntry("uid=user,ou=people,dc=test",
map[string][]string{
"uid": {"testuser"},
"displayname": {"Test User"},
"mail": {"user@example"},
"entryuuid": {"abcd-defg"},
"userClass": {"student"},
"qsferaMemberOfSchool": {"abcd-defg"},
"qsferaExternalIdentity": {
"$ http://idp $ testuser",
"xxx $ http://idpnew $ xxxxx-xxxxx-xxxxx",
},
})
var sr1 *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: peopleBaseDN,
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationUser)(|(uid=abcd-defg)(entryUUID=abcd-defg)))",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
var sr2 *ldap.SearchRequest = &ldap.SearchRequest{
BaseDN: peopleBaseDN,
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationUser)(|(uid=xxxx-xxxx)(entryUUID=xxxx-xxxx)))",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
func TestCreateEducationUser(t *testing.T) {
lm := &mocks.Client{}
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
//assert.NotEqual(t, "", b.educationConfig.schoolObjectClass)
lm.On("Add", mock.Anything).Return(nil)
lm.On("Search", mock.Anything).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{
eduUserEntry,
},
},
nil)
user := libregraph.NewEducationUser()
user.SetDisplayName("Test User")
user.SetOnPremisesSamAccountName("testuser")
user.SetMail("testuser@example.org")
user.SetPrimaryRole("student")
user.SetUserType(("Member"))
user.SetAccountEnabled(false)
eduUser, err := b.CreateEducationUser(context.Background(), *user)
lm.AssertNumberOfCalls(t, "Add", 1)
lm.AssertNumberOfCalls(t, "Search", 1)
assert.NotNil(t, eduUser)
assert.Nil(t, err)
assert.Equal(t, eduUser.GetDisplayName(), user.GetDisplayName())
assert.Equal(t, eduUser.GetOnPremisesSamAccountName(), user.GetOnPremisesSamAccountName())
assert.Equal(t, "abcd-defg", eduUser.GetId())
assert.Equal(t, eduUser.GetPrimaryRole(), user.GetPrimaryRole())
assert.Equal(t, eduUser.GetUserType(), user.GetUserType())
assert.Equal(t, eduUser.GetAccountEnabled(), false)
}
func TestDeleteEducationUser(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntry}}, nil)
lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
dr1 := &ldap.DelRequest{
DN: "uid=user,ou=people,dc=test",
}
lm.On("Del", dr1).Return(nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
err = b.DeleteEducationUser(context.Background(), "abcd-defg")
lm.AssertNumberOfCalls(t, "Search", 1)
lm.AssertNumberOfCalls(t, "Del", 1)
assert.Nil(t, err)
err = b.DeleteEducationUser(context.Background(), "xxxx-xxxx")
lm.AssertNumberOfCalls(t, "Search", 2)
lm.AssertNumberOfCalls(t, "Del", 1)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
}
func TestGetEducationUser(t *testing.T) {
lm := &mocks.Client{}
lm.On("Search", sr1).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntry}}, nil)
lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{}}, nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
user, err := b.GetEducationUser(context.Background(), "abcd-defg")
lm.AssertNumberOfCalls(t, "Search", 1)
assert.Nil(t, err)
assert.Equal(t, "Test User", user.GetDisplayName())
assert.Equal(t, "abcd-defg", user.GetId())
_, err = b.GetEducationUser(context.Background(), "xxxx-xxxx")
lm.AssertNumberOfCalls(t, "Search", 2)
assert.NotNil(t, err)
assert.Equal(t, "itemNotFound: not found", err.Error())
}
func TestGetEducationUsers(t *testing.T) {
lm := &mocks.Client{}
sr := &ldap.SearchRequest{
BaseDN: peopleBaseDN,
Scope: 2,
SizeLimit: 0,
Filter: "(objectClass=qsferaEducationUser)",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntry}}, nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
_, err = b.GetEducationUsers(context.Background())
lm.AssertNumberOfCalls(t, "Search", 1)
assert.Nil(t, err)
}
func TestFilterEducationUsersByAttr(t *testing.T) {
lm := &mocks.Client{}
sr := &ldap.SearchRequest{
BaseDN: peopleBaseDN,
Scope: 2,
SizeLimit: 0,
Filter: "(&(objectClass=qsferaEducationUser)(qsferaEducationExternalId=id1234))",
Attributes: eduUserAttrs,
Controls: []ldap.Control(nil),
}
lm.On("Search", sr).Return(&ldap.SearchResult{Entries: []*ldap.Entry{eduUserEntry}}, nil)
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
_, err = b.FilterEducationUsersByAttribute(context.Background(), "externalId", "id1234")
lm.AssertNumberOfCalls(t, "Search", 1)
assert.Nil(t, err)
}
func TestUpdateEducationUser(t *testing.T) {
lm := &mocks.Client{}
b, err := getMockedBackend(lm, eduConfig, &logger)
assert.Nil(t, err)
userSearchReq := &ldap.SearchRequest{
BaseDN: peopleBaseDN,
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=qsferaEducationUser)(|(uid=testuser)(entryUUID=testuser)))",
Attributes: eduUserAttrs,
}
userLookupReq := &ldap.SearchRequest{
BaseDN: "uid=newtestuser,ou=people,dc=test",
Scope: 0,
SizeLimit: 1,
Filter: "(objectClass=inetOrgPerson)",
Attributes: ldapUserAttributes,
}
eduUserLookupReq := &ldap.SearchRequest{
BaseDN: "uid=newtestuser,ou=people,dc=test",
Scope: 0,
SizeLimit: 1,
Filter: "(objectClass=qsferaEducationUser)",
Attributes: eduUserAttrs,
}
groupSearchReq := &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
Filter: "(&(objectClass=groupOfNames)(member=uid=user,ou=people,dc=test))",
Attributes: []string{
"cn",
"entryUUID",
},
}
lm.On("Search", userLookupReq).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{
renamedEduUserEntry,
},
},
nil)
lm.On("Search", eduUserLookupReq).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{
renamedEduUserEntry,
},
},
nil)
lm.On("Search", userSearchReq).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{
eduUserEntry,
},
},
nil)
lm.On("Search", groupSearchReq).
Return(
&ldap.SearchResult{
Entries: []*ldap.Entry{},
},
nil)
modReq := ldap.ModifyRequest{
DN: "uid=newtestuser,ou=people,dc=test",
Changes: []ldap.Change{
{
Operation: ldap.ReplaceAttribute,
Modification: ldap.PartialAttribute{
Type: "mail",
Vals: []string{"new@mail.org"},
},
},
{
Operation: ldap.ReplaceAttribute,
Modification: ldap.PartialAttribute{
Type: "userEnabledAttribute",
Vals: []string{"TRUE"},
},
},
},
}
modDNReq := ldap.ModifyDNRequest{
DN: "uid=user,ou=people,dc=test",
NewRDN: "uid=newtestuser",
DeleteOldRDN: true,
}
lm.On("ModifyDN", &modDNReq).Return(nil)
lm.On("Modify", &modReq).Return(nil)
user := libregraph.NewEducationUser()
user.SetOnPremisesSamAccountName("newtestuser")
user.SetMail("new@mail.org")
user.SetAccountEnabled(true)
eduUser, err := b.UpdateEducationUser(context.Background(), "testuser", *user)
assert.NotNil(t, eduUser)
assert.Nil(t, err)
assert.Equal(t, eduUser.GetOnPremisesSamAccountName(), "newtestuser")
assert.Equal(t, "abcd-defg", eduUser.GetId())
assert.Equal(t, "Guest", eduUser.GetUserType())
assert.Equal(t, eduUser.GetAccountEnabled(), true)
}
@@ -0,0 +1,607 @@
package identity
import (
"context"
"errors"
"fmt"
"net/url"
"slices"
"strings"
"github.com/CiscoM31/godata"
"github.com/go-ldap/ldap/v3"
"github.com/google/uuid"
"github.com/libregraph/idm/pkg/ldapdn"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/odata"
)
type groupAttributeMap struct {
name string
id string
member string
}
// GetGroup implements the Backend Interface for the LDAP Backend
func (i *LDAP) GetGroup(ctx context.Context, nameOrID string, queryParam url.Values) (*libregraph.Group, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetGroup")
e, err := i.getLDAPGroupByNameOrID(nameOrID, true)
if err != nil {
return nil, err
}
sel := strings.Split(queryParam.Get("$select"), ",")
exp := strings.Split(queryParam.Get("$expand"), ",")
var g *libregraph.Group
if g = i.createGroupModelFromLDAP(e); g == nil {
return nil, errorcode.New(errorcode.ItemNotFound, "not found")
}
if slices.Contains(sel, "members") || slices.Contains(exp, "members") {
members, err := i.expandLDAPAttributeEntries(ctx, e, i.groupAttributeMap.member, "")
if err != nil {
return nil, err
}
g.Members = make([]libregraph.User, 0, len(members))
if len(members) > 0 {
for _, ue := range members {
if u := i.createUserModelFromLDAP(ue); u != nil {
g.Members = append(g.Members, *u)
}
}
}
}
return g, nil
}
// GetGroups implements the Backend Interface for the LDAP Backend
func (i *LDAP) GetGroups(ctx context.Context, oreq *godata.GoDataRequest) ([]*libregraph.Group, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetGroups")
search, err := odata.GetSearchValues(oreq.Query)
if err != nil {
return nil, err
}
var expandMembers bool
exp, err := odata.GetExpandValues(oreq.Query)
if err != nil {
return nil, err
}
sel, err := odata.GetSelectValues(oreq.Query)
if err != nil {
return nil, err
}
if slices.Contains(exp, "members") || slices.Contains(sel, "members") {
expandMembers = true
}
var groupFilter string
if search != "" {
search = ldap.EscapeFilter(search)
groupFilter = fmt.Sprintf(
"(%s=*%s*)",
i.groupAttributeMap.name, search,
)
}
groupFilter = fmt.Sprintf("(&%s(objectClass=%s)%s)", i.groupFilter, i.groupObjectClass, groupFilter)
groupAttrs := []string{
i.groupAttributeMap.name,
i.groupAttributeMap.id,
}
if expandMembers {
groupAttrs = append(groupAttrs, i.groupAttributeMap.member)
}
searchRequest := ldap.NewSearchRequest(
i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, 0, 0, false,
groupFilter,
groupAttrs,
nil,
)
logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("GetGroups")
res, err := i.conn.Search(searchRequest)
if err != nil {
return nil, errorcode.New(errorcode.ItemNotFound, err.Error())
}
groups := make([]*libregraph.Group, 0, len(res.Entries))
var g *libregraph.Group
for _, e := range res.Entries {
if g = i.createGroupModelFromLDAP(e); g == nil {
continue
}
if expandMembers {
members, err := i.expandLDAPAttributeEntries(ctx, e, i.groupAttributeMap.member, "")
if err != nil {
return nil, err
}
g.Members = make([]libregraph.User, 0, len(members))
if len(members) > 0 {
for _, ue := range members {
if u := i.createUserModelFromLDAP(ue); u != nil {
g.Members = append(g.Members, *u)
}
}
}
}
groups = append(groups, g)
}
return groups, nil
}
// GetGroupMembers implements the Backend Interface for the LDAP Backend
func (i *LDAP) GetGroupMembers(ctx context.Context, groupID string, req *godata.GoDataRequest) ([]*libregraph.User, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("GetGroupMembers")
exp, err := odata.GetExpandValues(req.Query)
if err != nil {
return nil, err
}
e, err := i.getLDAPGroupByNameOrID(groupID, true)
if err != nil {
return nil, err
}
searchTerm, err := odata.GetSearchValues(req.Query)
if err != nil {
return nil, err
}
memberEntries, err := i.expandLDAPAttributeEntries(ctx, e, i.groupAttributeMap.member, searchTerm)
result := make([]*libregraph.User, 0, len(memberEntries))
if err != nil {
return nil, err
}
for _, member := range memberEntries {
if u := i.createUserModelFromLDAP(member); u != nil {
if slices.Contains(exp, "memberOf") {
userGroups, err := i.getGroupsForUser(member.DN)
if err != nil {
return nil, err
}
u.MemberOf = i.groupsFromLDAPEntries(userGroups)
}
result = append(result, u)
}
}
return result, nil
}
// CreateGroup implements the Backend Interface for the LDAP Backend
// It is currently restricted to managing groups based on the "groupOfNames" ObjectClass.
// As "groupOfNames" requires a "member" Attribute to be present. Empty Groups (groups
// without a member) a represented by adding an empty DN as the single member.
func (i *LDAP) CreateGroup(ctx context.Context, group libregraph.Group) (*libregraph.Group, error) {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("create group")
if !i.writeEnabled && i.groupCreateBaseDN == i.groupBaseDN {
return nil, errorcode.New(errorcode.NotAllowed, "server is configured read-only")
}
ar, err := i.groupToAddRequest(group)
if err != nil {
return nil, err
}
if err := i.conn.Add(ar); err != nil {
var lerr *ldap.Error
logger.Debug().Str("backend", "ldap").Str("dn", group.GetDisplayName()).Err(err).Msg("Failed to create group")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errorcode.New(errorcode.NameAlreadyExists, "group already exists")
}
}
return nil, err
}
// Read back group from LDAP to get the generated UUID
e, err := i.getGroupByDN(ar.DN)
if err != nil {
return nil, err
}
return i.createGroupModelFromLDAP(e), nil
}
// DeleteGroup implements the Backend Interface.
func (i *LDAP) DeleteGroup(ctx context.Context, id string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("DeleteGroup")
if !i.writeEnabled && i.groupCreateBaseDN == i.groupBaseDN {
return errorcode.New(errorcode.NotAllowed, "server is configured read-only")
}
e, err := i.getLDAPGroupByID(id, false)
if err != nil {
return err
}
if i.isLDAPGroupReadOnly(e) {
return errorcode.New(errorcode.NotAllowed, "group is read-only")
}
dr := ldap.DelRequest{DN: e.DN}
if err = i.conn.Del(&dr); err != nil {
return err
}
return nil
}
// UpdateGroupName implements the Backend Interface.
func (i *LDAP) UpdateGroupName(ctx context.Context, groupID string, groupName string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("AddMembersToGroup")
if !i.writeEnabled && i.groupCreateBaseDN == i.groupBaseDN {
return errorcode.New(errorcode.NotAllowed, "server is configured read-only")
}
ge, err := i.getLDAPGroupByID(groupID, true)
if err != nil {
return err
}
if i.isLDAPGroupReadOnly(ge) {
return errorcode.New(errorcode.NotAllowed, "group is read-only")
}
if ge.GetEqualFoldAttributeValue(i.groupAttributeMap.name) == groupName {
return nil
}
attributeTypeAndValue := ldap.AttributeTypeAndValue{
Type: i.groupAttributeMap.name,
Value: groupName,
}
newDNString := attributeTypeAndValue.String()
logger.Debug().Str("originalDN", ge.DN).Str("newDN", newDNString).Msg("Modifying DN")
mrdn := ldap.NewModifyDNRequest(ge.DN, newDNString, true, "")
if err := i.conn.ModifyDN(mrdn); err != nil {
var lerr *ldap.Error
logger.Debug().Str("originalDN", ge.DN).Str("newDN", newDNString).Err(err).Msg("Failed to modify DN")
if errors.As(err, &lerr) {
if lerr.ResultCode == ldap.LDAPResultEntryAlreadyExists {
err = errorcode.New(errorcode.NameAlreadyExists, "Group name already in use")
}
}
return err
}
return nil
}
// AddMembersToGroup implements the Backend Interface for the LDAP backend.
// Currently, it is limited to adding Users as Group members. Adding other groups
// as members is not yet implemented
func (i *LDAP) AddMembersToGroup(ctx context.Context, groupID string, memberIDs []string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("AddMembersToGroup")
if !i.writeEnabled && i.groupCreateBaseDN == i.groupBaseDN {
return errorcode.New(errorcode.NotAllowed, "server is configured read-only")
}
ge, err := i.getLDAPGroupByNameOrID(groupID, true)
if err != nil {
return err
}
if i.isLDAPGroupReadOnly(ge) {
return errorcode.New(errorcode.NotAllowed, "group is read-only")
}
mr := ldap.ModifyRequest{DN: ge.DN}
// Handle empty groups (using the empty member attribute)
current := ge.GetEqualFoldAttributeValues(i.groupAttributeMap.member)
if len(current) == 1 && current[0] == "" {
mr.Delete(i.groupAttributeMap.member, []string{""})
}
// Create a Set of current members for faster lookups
currentSet := make(map[string]struct{}, len(current))
for _, currentMember := range current {
// We can ignore any empty member value here
if currentMember == "" {
continue
}
nCurrentMember, err := ldapdn.ParseNormalize(currentMember)
if err != nil {
// We couldn't parse the member value as a DN. Let's skip it, but log a warning
logger.Warn().Str("memberDN", currentMember).Err(err).Msg("Couldn't parse DN")
continue
}
currentSet[nCurrentMember] = struct{}{}
}
var newMemberDN []string
for _, memberID := range memberIDs {
me, err := i.getLDAPUserByID(memberID)
if err != nil {
return err
}
nDN, err := ldapdn.ParseNormalize(me.DN)
if err != nil {
logger.Error().Str("new member", me.DN).Err(err).Msg("Couldn't parse DN")
return err
}
if _, present := currentSet[nDN]; !present {
newMemberDN = append(newMemberDN, me.DN)
} else {
logger.Debug().Str("memberDN", me.DN).Msg("Member already present in group. Skipping")
}
}
if len(newMemberDN) > 0 {
// Small retry loop. It might be that, when reading the group we found the empty group member ("",
// line 289 above). Our modify operation tries to delete that value. However, another go-routine
// might have done that in parallel. In that case
// (LDAPResultNoSuchAttribute) we need to retry the modification
// without to delete.
for j := 0; j < 2; j++ {
mr.Add(i.groupAttributeMap.member, newMemberDN)
if err := i.conn.Modify(&mr); err != nil {
if lerr, ok := err.(*ldap.Error); ok {
switch lerr.ResultCode {
case ldap.LDAPResultAttributeOrValueExists:
err = fmt.Errorf("duplicate member entries in request")
case ldap.LDAPResultNoSuchAttribute:
if len(mr.Changes) == 2 {
// We tried the special case for adding the first group member, but some
// other request running in parallel did that already. Retry with a "normal"
// modification
logger.Debug().Err(err).
Msg("Failed to add first group member. Retrying once, without deleting the empty member value.")
mr.Changes = make([]ldap.Change, 0, 1)
continue
}
default:
logger.Info().Err(err).Msg("Failed to modify group member entries on PATCH group")
err = fmt.Errorf("unknown error when trying to modify group member entries")
}
}
return err
}
// succeeded
break
}
}
return nil
}
// RemoveMemberFromGroup implements the Backend Interface.
func (i *LDAP) RemoveMemberFromGroup(ctx context.Context, groupID string, memberID string) error {
logger := i.logger.SubloggerWithRequestID(ctx)
logger.Debug().Str("backend", "ldap").Msg("RemoveMemberFromGroup")
if !i.writeEnabled && i.groupCreateBaseDN == i.groupBaseDN {
return errorcode.New(errorcode.NotAllowed, "server is configured read-only")
}
ge, err := i.getLDAPGroupByID(groupID, true)
if err != nil {
logger.Debug().Str("backend", "ldap").Str("groupID", groupID).Msg("Error looking up group")
return err
}
if i.isLDAPGroupReadOnly(ge) {
return errorcode.New(errorcode.NotAllowed, "group is read-only")
}
me, err := i.getLDAPUserByID(memberID)
if err != nil {
logger.Debug().Str("backend", "ldap").Str("memberID", memberID).Msg("Error looking up group member")
return err
}
logger.Debug().Str("backend", "ldap").Str("groupdn", ge.DN).Str("member", me.DN).Msg("remove member")
if err = i.removeEntryByDNAndAttributeFromEntry(ge, me.DN, i.groupAttributeMap.member); err != nil {
logger.Error().Err(err).Str("backend", "ldap").Str("group", groupID).Str("member", memberID).Msg("Failed to remove member from group.")
}
return err
}
func (i *LDAP) groupToAddRequest(group libregraph.Group) (*ldap.AddRequest, error) {
ar := ldap.NewAddRequest(i.getGroupCreateLDAPDN(group), nil)
attrMap, err := i.groupToLDAPAttrValues(group)
if err != nil {
return nil, err
}
for attrType, values := range attrMap {
ar.Attribute(attrType, values)
}
return ar, nil
}
func (i *LDAP) getGroupCreateLDAPDN(group libregraph.Group) string {
attributeTypeAndValue := ldap.AttributeTypeAndValue{
Type: "cn",
Value: group.GetDisplayName(),
}
return fmt.Sprintf("%s,%s", attributeTypeAndValue.String(), i.groupCreateBaseDN)
}
func (i *LDAP) groupToLDAPAttrValues(group libregraph.Group) (map[string][]string, error) {
attrs := map[string][]string{
i.groupAttributeMap.name: {group.GetDisplayName()},
"objectClass": {"groupOfNames", "top"},
// This is a crutch to allow groups without members for LDAP servers
// that apply strict Schema checking. The RFCs define "member/uniqueMember"
// as required attribute for groupOfNames/groupOfUniqueNames. So we
// add an empty string (which is a valid DN) as the initial member.
// It will be replaced once real members are added.
// We might want to use the newer, but not so broadly used "groupOfMembers"
// objectclass (RFC2307bis-02) where "member" is optional.
i.groupAttributeMap.member: {""},
}
if !i.useServerUUID {
attrs["qsferaUUID"] = []string{uuid.NewString()}
attrs["objectClass"] = append(attrs["objectClass"], "qsferaObject")
}
return attrs, nil
}
func (i *LDAP) getLDAPGroupByID(id string, requestMembers bool) (*ldap.Entry, error) {
idString, err := filterEscapeAttribute(i.groupAttributeMap.id, i.groupIDisOctetString, id)
if err != nil {
return nil, fmt.Errorf("invalid group id: %w", err)
}
filter := fmt.Sprintf("(%s=%s)", i.groupAttributeMap.id, idString)
return i.getLDAPGroupByFilter(filter, requestMembers)
}
func (i *LDAP) getLDAPGroupByNameOrID(nameOrID string, requestMembers bool) (*ldap.Entry, error) {
idString, err := filterEscapeAttribute(i.groupAttributeMap.id, i.groupIDisOctetString, nameOrID)
// err != nil just means that this is not an uuid, so we can skip the uuid filter part
// and just filter by name
filter := ""
if err == nil {
filter = fmt.Sprintf("(|(%s=%s)(%s=%s))", i.groupAttributeMap.name, ldap.EscapeFilter(nameOrID), i.groupAttributeMap.id, idString)
} else {
filter = fmt.Sprintf("(%s=%s)", i.userAttributeMap.userName, ldap.EscapeFilter(nameOrID))
}
return i.getLDAPGroupByFilter(filter, requestMembers)
}
func (i *LDAP) getLDAPGroupByFilter(filter string, requestMembers bool) (*ldap.Entry, error) {
e, err := i.getLDAPGroupsByFilter(filter, requestMembers, true)
if err != nil {
return nil, err
}
if len(e) == 0 {
return nil, errorcode.New(errorcode.ItemNotFound, "not found")
}
return e[0], nil
}
// Search for LDAP Groups matching the specified filter, if requestMembers is true the groupMemberShip
// attribute will be part of the result attributes. The LDAP filter is combined with the configured groupFilter
// resulting in a filter like "(&(LDAP.groupFilter)(objectClass=LDAP.groupObjectClass)(<filter_from_args>))"
func (i *LDAP) getLDAPGroupsByFilter(filter string, requestMembers, single bool) ([]*ldap.Entry, error) {
attrs := []string{
i.groupAttributeMap.name,
i.groupAttributeMap.id,
}
if requestMembers {
attrs = append(attrs, i.groupAttributeMap.member)
}
sizelimit := 0
if single {
sizelimit = 1
}
searchRequest := ldap.NewSearchRequest(
i.groupBaseDN, i.groupScope, ldap.NeverDerefAliases, sizelimit, 0, false,
fmt.Sprintf("(&%s(objectClass=%s)%s)", i.groupFilter, i.groupObjectClass, filter),
attrs,
nil,
)
i.logger.Debug().Str("backend", "ldap").
Str("base", searchRequest.BaseDN).
Str("filter", searchRequest.Filter).
Int("scope", searchRequest.Scope).
Int("sizelimit", searchRequest.SizeLimit).
Interface("attributes", searchRequest.Attributes).
Msg("getLDAPGroupsByFilter")
res, err := i.conn.Search(searchRequest)
if err != nil {
var errmsg string
if lerr, ok := err.(*ldap.Error); ok {
if lerr.ResultCode == ldap.LDAPResultSizeLimitExceeded {
errmsg = fmt.Sprintf("too many results searching for group '%s'", filter)
i.logger.Debug().Str("backend", "ldap").Err(lerr).Msg(errmsg)
}
}
return nil, errorcode.New(errorcode.ItemNotFound, errmsg)
}
return res.Entries, nil
}
func (i *LDAP) getGroupByDN(dn string) (*ldap.Entry, error) {
attrs := []string{
i.groupAttributeMap.id,
i.groupAttributeMap.name,
}
filter := fmt.Sprintf("(objectClass=%s)", i.groupObjectClass)
if i.groupFilter != "" {
filter = fmt.Sprintf("(&%s(%s))", filter, i.groupFilter)
}
return i.getEntryByDN(dn, attrs, filter)
}
func (i *LDAP) getGroupsForUser(dn string) ([]*ldap.Entry, error) {
groupFilter := fmt.Sprintf(
"(%s=%s)",
i.groupAttributeMap.member, ldap.EscapeFilter(dn),
)
userGroups, err := i.getLDAPGroupsByFilter(groupFilter, false, false)
if err != nil {
return nil, err
}
return userGroups, nil
}
func (i *LDAP) createGroupModelFromLDAP(e *ldap.Entry) *libregraph.Group {
name := e.GetEqualFoldAttributeValue(i.groupAttributeMap.name)
id, err := i.ldapUUIDtoString(e, i.groupAttributeMap.id, i.groupIDisOctetString)
if err != nil {
i.logger.Warn().Str("dn", e.DN).Str(i.groupAttributeMap.id, e.GetEqualFoldAttributeValue(i.groupAttributeMap.id)).Msg("Invalid User. Cannot convert UUID")
}
groupTypes := []string{}
if i.isLDAPGroupReadOnly(e) {
groupTypes = []string{"ReadOnly"}
}
if id != "" && name != "" {
return &libregraph.Group{
DisplayName: &name,
Id: &id,
GroupTypes: groupTypes,
}
}
i.logger.Warn().Str("dn", e.DN).Msg("Group is missing name or id")
return nil
}
func (i *LDAP) isLDAPGroupReadOnly(e *ldap.Entry) bool {
groupDN, err := ldap.ParseDN(e.DN)
if err != nil {
i.logger.Warn().Err(err).Str("dn", e.DN).Msg("Failed to parse DN")
return false
}
baseDN, err := ldap.ParseDN(i.groupCreateBaseDN)
if err != nil {
i.logger.Warn().Err(err).Str("dn", i.groupCreateBaseDN).Msg("Failed to parse DN")
return false
}
return !baseDN.AncestorOfFold(groupDN)
}
func (i *LDAP) groupsFromLDAPEntries(e []*ldap.Entry) []libregraph.Group {
groups := make([]libregraph.Group, 0, len(e))
for _, g := range e {
if grp := i.createGroupModelFromLDAP(g); grp != nil {
groups = append(groups, *grp)
}
}
return groups
}
@@ -0,0 +1,453 @@
package identity
import (
"context"
"errors"
"net/url"
"testing"
"github.com/CiscoM31/godata"
"github.com/go-ldap/ldap/v3"
"github.com/qsfera/server/services/graph/pkg/identity/mocks"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
)
var groupEntry = ldap.NewEntry("cn=group",
map[string][]string{
"cn": {"group"},
"entryuuid": {"abcd-defg"},
"member": {
"uid=user,ou=people,dc=test",
"uid=invalid,ou=people,dc=test",
},
})
var invalidGroupEntry = ldap.NewEntry("cn=invalid",
map[string][]string{
"cn": {"invalid"},
})
var queryParamExpand = url.Values{
"$expand": []string{"members"},
}
var queryParamSelect = url.Values{
"$select": []string{"members"},
}
var groupLookupSearchRequest = &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
SizeLimit: 1,
Filter: "(&(objectClass=groupOfNames)(|(cn=group)(entryUUID=group)))",
Attributes: []string{"cn", "entryUUID", "member"},
Controls: []ldap.Control(nil),
}
var groupListSearchRequest = &ldap.SearchRequest{
BaseDN: "ou=groups,dc=test",
Scope: 2,
Filter: "(&(objectClass=groupOfNames))",
Attributes: []string{"cn", "entryUUID", "member"},
Controls: []ldap.Control(nil),
}
func TestGetGroup(t *testing.T) {
// Mock a Sizelimit Error
lm := &mocks.Client{}
lm.On("Search", mock.Anything).Return(nil, ldap.NewError(ldap.LDAPResultSizeLimitExceeded, errors.New("mock")))
b, _ := getMockedBackend(lm, lconfig, &logger)
_, err := b.GetGroup(context.Background(), "group", nil)
assert.ErrorContains(t, err, "itemNotFound:")
_, err = b.GetGroup(context.Background(), "group", queryParamExpand)
assert.ErrorContains(t, err, "itemNotFound:")
_, err = b.GetGroup(context.Background(), "group", queryParamSelect)
assert.ErrorContains(t, err, "itemNotFound:")
// Mock an empty Search Result
lm = &mocks.Client{}
lm.On("Search", mock.Anything).Return(&ldap.SearchResult{}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
_, err = b.GetGroup(context.Background(), "group", nil)
assert.ErrorContains(t, err, "itemNotFound:")
_, err = b.GetGroup(context.Background(), "group", queryParamExpand)
assert.ErrorContains(t, err, "itemNotFound:")
_, err = b.GetGroup(context.Background(), "group", queryParamSelect)
assert.ErrorContains(t, err, "itemNotFound:")
// Mock an invalid Search Result
lm = &mocks.Client{}
lm.On("Search", mock.Anything).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{invalidGroupEntry},
}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
_, err = b.GetGroup(context.Background(), "group", nil)
assert.ErrorContains(t, err, "itemNotFound:")
_, err = b.GetGroup(context.Background(), "group", queryParamExpand)
assert.ErrorContains(t, err, "itemNotFound:")
_, err = b.GetGroup(context.Background(), "group", queryParamSelect)
assert.ErrorContains(t, err, "itemNotFound:")
// Mock a valid Search Result
lm = &mocks.Client{}
sr2 := &ldap.SearchRequest{
BaseDN: "uid=user,ou=people,dc=test",
SizeLimit: 1,
Filter: "(objectClass=inetOrgPerson)",
Attributes: ldapUserAttributes,
Controls: []ldap.Control(nil),
}
sr3 := &ldap.SearchRequest{
BaseDN: "uid=invalid,ou=people,dc=test",
SizeLimit: 1,
Filter: "(objectClass=inetOrgPerson)",
Attributes: ldapUserAttributes,
Controls: []ldap.Control(nil),
}
lm.On("Search", groupLookupSearchRequest).Return(&ldap.SearchResult{Entries: []*ldap.Entry{groupEntry}}, nil)
lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}}, nil)
lm.On("Search", sr3).Return(&ldap.SearchResult{Entries: []*ldap.Entry{invalidUserEntry}}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
g, err := b.GetGroup(context.Background(), "group", nil)
if err != nil {
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
} else if *g.Id != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
t.Errorf("Expected GetGroup to return a valid group")
}
g, err = b.GetGroup(context.Background(), "group", queryParamExpand)
switch {
case err != nil:
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
t.Errorf("Expected GetGroup to return a valid group")
case len(g.Members) != 1:
t.Errorf("Expected GetGroup with expand to return one member")
case g.Members[0].GetId() != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id):
t.Errorf("Expected GetGroup with expand to return correct member")
}
g, err = b.GetGroup(context.Background(), "group", queryParamSelect)
switch {
case err != nil:
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
t.Errorf("Expected GetGroup to return a valid group")
case len(g.Members) != 1:
t.Errorf("Expected GetGroup with expand to return one member")
case g.Members[0].GetId() != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id):
t.Errorf("Expected GetGroup with expand to return correct member")
}
}
func TestGetGroupReadOnlyBackend(t *testing.T) {
readOnlyConfig := lconfig
readOnlyConfig.WriteEnabled = false
readOnlyConfig.GroupBaseDN = "ou=groups,dc=test"
readOnlyConfig.GroupCreateBaseDN = "ou=local,ou=group,dc=test"
localGroupEntry := groupEntry
localGroupEntry.DN = "cn=local,ou=local,o=base"
lm := &mocks.Client{}
lm.On("Search", groupLookupSearchRequest).Return(&ldap.SearchResult{Entries: []*ldap.Entry{groupEntry}}, nil)
b, _ := getMockedBackend(lm, readOnlyConfig, &logger)
g, err := b.GetGroup(context.Background(), "group", url.Values{})
switch {
case err != nil:
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
t.Errorf("Expected GetGroup to return a valid group")
}
types := g.GetGroupTypes()
switch {
case len(types) == 0:
t.Errorf("No groupTypes attribute on readonly Group")
case len(types) > 1:
t.Errorf("Expected a single groupTypes value on readonly Group")
case types[0] != "ReadOnly":
t.Errorf("Expected a groupTypes 'ReadOnly' on readonly Group")
}
}
func TestGetGroupReadOnlySubtree(t *testing.T) {
readOnlyTreeConfig := lconfig
readOnlyTreeConfig.GroupCreateBaseDN = "ou=write,ou=groups,dc=test"
var writeGroupEntry = ldap.NewEntry("cn=group,ou=write,ou=groups,dc=test",
map[string][]string{
"cn": {"group"},
"entryuuid": {"abcd-defg"},
"member": {
"uid=user,ou=people,dc=test",
"uid=invalid,ou=people,dc=test",
},
})
lm := &mocks.Client{}
lm.On("Search", groupLookupSearchRequest).Return(&ldap.SearchResult{Entries: []*ldap.Entry{groupEntry}}, nil)
b, _ := getMockedBackend(lm, readOnlyTreeConfig, &logger)
g, err := b.GetGroup(context.Background(), "group", url.Values{})
switch {
case err != nil:
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
t.Errorf("Expected GetGroup to return a valid group")
}
types := g.GetGroupTypes()
switch {
case len(types) == 0:
t.Errorf("No groupTypes attribute on readonly Group")
case len(types) > 1:
t.Errorf("Expected a single groupTypes value on readonly Group")
case types[0] != "ReadOnly":
t.Errorf("Expected a groupTypes 'ReadOnly' on readonly Group")
}
lm = &mocks.Client{}
lm.On("Search", groupLookupSearchRequest).Return(&ldap.SearchResult{Entries: []*ldap.Entry{writeGroupEntry}}, nil)
b, _ = getMockedBackend(lm, readOnlyTreeConfig, &logger)
g, err = b.GetGroup(context.Background(), "group", url.Values{})
switch {
case err != nil:
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
case g.GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
t.Errorf("Expected GetGroup to return a valid group")
}
types = g.GetGroupTypes()
if len(types) != 0 {
t.Errorf("No groupTypes attribute expected on writeable Group")
}
}
func TestGetGroups(t *testing.T) {
lm := &mocks.Client{}
oDataReq, err := godata.ParseRequest(context.Background(), "", url.Values{})
if err != nil {
t.Errorf("Expected success, got '%s'", err.Error())
}
lm.On("Search", mock.Anything).Return(nil, ldap.NewError(ldap.LDAPResultOperationsError, errors.New("mock")))
b, _ := getMockedBackend(lm, lconfig, &logger)
_, err = b.GetGroups(context.Background(), oDataReq)
assert.ErrorContains(t, err, "itemNotFound:")
lm = &mocks.Client{}
lm.On("Search", mock.Anything).Return(&ldap.SearchResult{}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
g, err := b.GetGroups(context.Background(), oDataReq)
if err != nil {
t.Errorf("Expected success, got '%s'", err.Error())
} else if g == nil || len(g) != 0 {
t.Errorf("Expected zero length user slice")
}
lm = &mocks.Client{}
lm.On("Search", mock.Anything).Return(&ldap.SearchResult{
Entries: []*ldap.Entry{groupEntry},
}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
g, err = b.GetGroups(context.Background(), oDataReq)
if err != nil {
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
} else if *g[0].Id != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id) {
t.Errorf("Expected GetGroup to return a valid group")
}
// Mock a valid Search Result with expanded group members
lm = &mocks.Client{}
sr2 := &ldap.SearchRequest{
BaseDN: "uid=user,ou=people,dc=test",
SizeLimit: 1,
Filter: "(objectClass=inetOrgPerson)",
Attributes: ldapUserAttributes,
Controls: []ldap.Control(nil),
}
sr3 := &ldap.SearchRequest{
BaseDN: "uid=invalid,ou=people,dc=test",
SizeLimit: 1,
Filter: "(objectClass=inetOrgPerson)",
Attributes: ldapUserAttributes,
Controls: []ldap.Control(nil),
}
for _, param := range []url.Values{queryParamSelect, queryParamExpand} {
oDataReq, err := godata.ParseRequest(context.Background(), "", param)
if err != nil {
t.Errorf("Expected success, got '%s'", err.Error())
}
lm.On("Search", groupListSearchRequest).Return(&ldap.SearchResult{Entries: []*ldap.Entry{groupEntry}}, nil)
lm.On("Search", sr2).Return(&ldap.SearchResult{Entries: []*ldap.Entry{userEntry}}, nil)
lm.On("Search", sr3).Return(&ldap.SearchResult{Entries: []*ldap.Entry{invalidUserEntry}}, nil)
b, _ = getMockedBackend(lm, lconfig, &logger)
g, err = b.GetGroups(context.Background(), oDataReq)
switch {
case err != nil:
t.Errorf("Expected GetGroup to succeed. Got %s", err.Error())
case g[0].GetId() != groupEntry.GetEqualFoldAttributeValue(b.groupAttributeMap.id):
t.Errorf("Expected GetGroup to return a valid group")
case len(g[0].Members) != 1:
t.Errorf("Expected GetGroup to return group with one member")
case g[0].Members[0].GetId() != userEntry.GetEqualFoldAttributeValue(b.userAttributeMap.id):
t.Errorf("Expected GetGroup to return group with correct member")
}
}
}
func TestGetGroupsSearch(t *testing.T) {
lm := &mocks.Client{}
odataReqDefault, err := godata.ParseRequest(context.Background(), "",
url.Values{
"$search": []string{"\"term\""},
},
)
if err != nil {
t.Errorf("Expected success got '%s'", err.Error())
}
// only match if the filter contains the search term unquoted
lm.On("Search", mock.MatchedBy(
func(req *ldap.SearchRequest) bool {
return req.Filter == "(&(objectClass=groupOfNames)(cn=*term*))"
})).
Return(&ldap.SearchResult{}, nil)
b, _ := getMockedBackend(lm, lconfig, &logger)
g, err := b.GetGroups(context.Background(), odataReqDefault)
if err != nil {
t.Errorf("Expected success, got '%s'", err.Error())
} else if g == nil || len(g) != 0 {
t.Errorf("Expected zero length user slice")
}
}
func TestUpdateGroupName(t *testing.T) {
groupDn := "cn=TheGroup,ou=groups,dc=example,dc=org"
type args struct {
groupId string
groupName string
newName string
}
type mockInputs struct {
funcName string
args []any
returns []any
}
tests := []struct {
name string
args args
assertion assert.ErrorAssertionFunc
ldapMocks []mockInputs
}{
{
name: "Test with no name change",
args: args{
groupId: "some-uuid-string",
newName: "TheGroup",
},
assertion: func(t assert.TestingT, err error, args ...any) bool {
return assert.Nil(t, err, args...)
},
ldapMocks: []mockInputs{
{
funcName: "Search",
args: []any{
ldap.NewSearchRequest(
"ou=groups,dc=test",
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases, 1, 0, false,
"(&(objectClass=groupOfNames)(entryUUID=some-uuid-string))",
[]string{"cn", "entryUUID", "member"},
nil,
),
},
returns: []any{
&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: groupDn,
Attributes: []*ldap.EntryAttribute{
{
Name: "cn",
Values: []string{"TheGroup"},
},
},
},
},
},
nil,
},
},
},
},
{
name: "Test with name change",
args: args{
groupId: "some-uuid-string",
newName: "TheGroupWithShinyNewName",
},
assertion: func(t assert.TestingT, err error, args ...any) bool {
return assert.Nil(t, err, args...)
},
ldapMocks: []mockInputs{
{
funcName: "Search",
args: []any{
ldap.NewSearchRequest(
"ou=groups,dc=test",
ldap.ScopeWholeSubtree,
ldap.NeverDerefAliases, 1, 0, false,
"(&(objectClass=groupOfNames)(entryUUID=some-uuid-string))",
[]string{"cn", "entryUUID", "member"},
nil,
),
},
returns: []any{
&ldap.SearchResult{
Entries: []*ldap.Entry{
{
DN: "cn=TheGroup,ou=groups,dc=example,dc=org",
Attributes: []*ldap.EntryAttribute{
{
Name: "cn",
Values: []string{"TheGroup"},
},
},
},
},
},
nil,
},
},
{
funcName: "ModifyDN",
args: []any{
&ldap.ModifyDNRequest{
DN: groupDn,
NewRDN: "cn=TheGroupWithShinyNewName",
DeleteOldRDN: true,
NewSuperior: "",
Controls: []ldap.Control(nil),
},
},
returns: []any{
nil,
},
},
},
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
lm := &mocks.Client{}
for _, ldapMock := range tt.ldapMocks {
lm.On(ldapMock.funcName, ldapMock.args...).Return(ldapMock.returns...)
}
ldapConfig := lconfig
i, _ := getMockedBackend(lm, ldapConfig, &logger)
err := i.UpdateGroupName(context.Background(), tt.args.groupId, tt.args.newName)
tt.assertion(t, err)
})
}
}
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
File diff suppressed because it is too large Load Diff
+10
View File
@@ -0,0 +1,10 @@
[main]
host = https://www.transifex.com
[o:qsfera-eu:p:qsfera-eu:r:qsfera-graph]
file_filter = locale/<lang>/LC_MESSAGES/graph.po
minimum_perc = 75
resource_name = qsfera-graph
source_file = graph.pot
source_lang = en
type = PO
@@ -0,0 +1,135 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Ivan Fustero, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Ivan Fustero, 2025\n"
"Language-Team: Catalan (https://app.transifex.com/qsfera-eu/teams/204053/ca/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ca\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Pot editar"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Pot editar sense versions"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Pot gestionar"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Pot pujar"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Pot veure"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Pot veure (de forma segura)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "No pot accedir"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Denega tot accés."
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Aquí podeu afegir una descripció per a aquest espai."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Visualitza i descarrega."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr "View only documents, images and PDFs. Watermarks will be applied."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Visualitza, descarrega i edita."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Visualitza, descarrega i mostra totes les persones convidades."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Visualitza, descarrega i puja."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr "Visualitza, descarrega, edita i mostra totes les persones convidades."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Visualitza, descarrega, puja, edita, afegeix i suprimeix."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr ""
"Visualitza, descarrega, puja, edita, afegeix, suprimeix i gestiona els "
"membres."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Visualitza, descarregar, pujar, editar, afegir, esborrar i mostrar totes les"
" persones convidades."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr ""
"Visualitza, baixa, puja, edita, afegeix, suprimeix incloent l'historial."
@@ -0,0 +1,139 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Jörn Friedrich Dreyer <jfd@butonic.de>, 2025
# Jannick Kuhr, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-02 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Jannick Kuhr, 2026\n"
"Language-Team: German (https://app.transifex.com/qsfera-eu/teams/204053/de/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: de\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Kann bearbeiten"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Kann bearbeiten ohne Historie"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Kann verwalten"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Kann hochladen"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Kann anzeigen"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Kann anzeigen (sichere Ansicht)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "Kann nicht zugreifen"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Jeder Zugriff verboten"
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Hier können Sie eine Beschreibung für diesen Space einfügen."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Ansehen und herunterladen."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr ""
"Ansehen von Dokumenten, Bildern und PDFs. Wasserzeichen werden angewendet."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Ansehen, herunterladen und bearbeiten."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Ansehen, herunterladen und anzeigen aller eingeladenen Personen."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Ansehen, herunterladen und hochladen."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr ""
"Ansehen, herunterladen, bearbeiten und anzeigen aller eingeladenen Personen."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Ansehen, herunterladen, hochladen, bearbeiten, hinzufügen, löschen."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr ""
"Ansehen, herunterladen, hochladen, bearbeiten, hinzufügen, löschen, teilen "
"und Mitglieder verwalten."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Ansehen, herunterladen, hochladen, bearbeiten, löschen und anzeigen aller "
"eingeladenen Benutzer."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr ""
"Ansehen, herunterladen, hochladen, bearbeiten, hinzufügen, löschen - "
"inklusive des Verlaufs."
@@ -0,0 +1,138 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Efstathios Iosifidis <eiosifidis@gmail.com>, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-23 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Efstathios Iosifidis <eiosifidis@gmail.com>, 2026\n"
"Language-Team: Greek (https://app.transifex.com/qsfera-eu/teams/204053/el/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: el\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Δυνατότητα επεξεργασίας"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Δυνατότητα επεξεργασίας χωρίς εκδόσεις"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Δυνατότητα διαχείρισης"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Δυνατότητα μεταφόρτωσης"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Δυνατότητα προβολής"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Δυνατότητα προβολής (ασφαλής)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "Δεν υπάρχει πρόσβαση"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Άρνηση κάθε πρόσβασης."
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Εδώ μπορείτε να προσθέσετε μια περιγραφή για αυτόν τον Χώρο."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Προβολή και λήψη."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr ""
"Προβολή μόνο εγγράφων, εικόνων και PDF. Θα εφαρμοστούν υδατογραφήματα."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Προβολή, λήψη και επεξεργασία."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Προβολή, λήψη και εμφάνιση όλων των προσκεκλημένων ατόμων."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Προβολή, λήψη και μεταφόρτωση."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr ""
"Προβολή, λήψη, επεξεργασία και εμφάνιση όλων των προσκεκλημένων ατόμων."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Προβολή, λήψη, μεταφόρτωση, επεξεργασία, προσθήκη και διαγραφή."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr ""
"Προβολή, λήψη, μεταφόρτωση, επεξεργασία, προσθήκη, διαγραφή και διαχείριση "
"μελών."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Προβολή, λήψη, μεταφόρτωση, επεξεργασία, προσθήκη, διαγραφή και εμφάνιση "
"όλων των προσκεκλημένων ατόμων."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr ""
"Προβολή, λήψη, μεταφόρτωση, επεξεργασία, προσθήκη, διαγραφή "
"συμπεριλαμβανομένου του ιστορικού."
@@ -0,0 +1,135 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Elías Martín, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Elías Martín, 2025\n"
"Language-Team: Spanish (https://app.transifex.com/qsfera-eu/teams/204053/es/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: es\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Puede editar"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Se puede editar sin versiones"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Se puede gestionar"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Se puede subir"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Se puede visualizar"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Se puede visualizar (de forma segura)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "No se puede acceder"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Prohibir todo el acceso"
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Aquí puedes agregar una descripción a este Espacio."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Ver y descargar"
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr ""
"Ver solamente documentos, imágenes y archivos PDF. Será aplicada una marca "
"de agua."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Ver. descargar y editar."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Ver, descargar y mostrar a todas las personas invitadas."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Ver, descargar y subir."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr "Ver, descargar, editar y mostrar a todas las perosnas invitadas."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Ver, descargar, subuir, editar, añadir y eliminar."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr "Ver, descargar, subir, editar, añadir, eliminar y gestionar miembros."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Ver, descargar, subir, editar, añadir, eliminar y mostrar a todas las "
"personas invitadas."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr ""
"Ver, descargar, subir, editar, añadir y borrar incluyendo el historial."
@@ -0,0 +1,132 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Jiri Grönroos <jiri.gronroos@iki.fi>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-08 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Jiri Grönroos <jiri.gronroos@iki.fi>, 2025\n"
"Language-Team: Finnish (https://app.transifex.com/qsfera-eu/teams/204053/fi/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fi\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Voi muokata"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Voi muokata ilman versioita"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Voi hallita"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Voi lähettää"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Voi nähdä"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Voi nähdä (turvallinen)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "Ei pääsyä"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Estä kaikki pääsy."
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Täällä voit lisätä kuvauksen avaruudelle."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Näytä ja lataa."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr "Näytä vain asiakirjat, kuvat ja PDF:t. Vesileimat lisätään."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Näytä, lataa ja muokkaa."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Näytä, lataa ja näytä kaikki kutsutut henkilöt."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Näytä, lataa ja lähetä."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr "Näytä, lataa, muokkaa ja näytä kaikki kaikki kutsutut henkilöt."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Näytä, lataa lähetä, muokkaa, lisää ja poista."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr "Näytä, lataa, lähetä, muokkaa, lisää, poista ja hallitse jäseniä."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Näytä, lataa, lähetä, muokkaa, lisää, poista ja näytä kaikki kutsutut "
"henkilöt."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr "Näytä, lataa, lähetä, muokkaa, lisää, poista mukaan lukien historia."
@@ -0,0 +1,141 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# eric_G <junk.eg@free.fr>, 2025
# Benoît Aguesse, 2026
# Jérôme HERBINET, 2026
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Jérôme HERBINET, 2026\n"
"Language-Team: French (https://app.transifex.com/qsfera-eu/teams/204053/fr/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: fr\n"
"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Peut éditer"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Peut éditer sans versions"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Peut gérer"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Peut téléverser"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Peut visualiser"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Peut visualiser (sécurisé)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "Accès impossible"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Refuser tout accès."
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Vous pouvez ajouter une description pour cet espace."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Consulter et télécharger."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr ""
"Visualiser uniquement les documents, les images et les fichiers PDF. Des "
"filigranes seront appliqués."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Consulter, télécharger et modifier."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Consulter, télécharger et montrer toutes les personnes invitées."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Consulter, télécharger et téléverser."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr ""
"Consulter, télécharger, modifier et montrer toutes les personnes invitées."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Consulter, télécharger, téléverser, modifier, ajouter et supprimer."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr ""
"Consulter, télécharger, téléverser, modifier, ajouter, supprimer et gérer "
"les membres."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Consulter, télécharger, téléverser, modifier, ajouter, supprimer et montrer "
"toutes les personnes invitées."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr ""
"Consulter, télécharger, téléverser, modifier, ajouter, supprimer, y compris "
"l'historique."
@@ -0,0 +1,135 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Simone Broglia, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Simone Broglia, 2025\n"
"Language-Team: Italian (https://app.transifex.com/qsfera-eu/teams/204053/it/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: it\n"
"Plural-Forms: nplurals=3; plural=n == 1 ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Può modificare"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Può modificare senza versioni"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Può gestire"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Può caricare"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Può visualizzare"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Può visualizzare (protetto)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "Accesso non consentito"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Blocca tutti gli accessi"
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Qui puoi aggiungere una descrizione per questo Spazio."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Visualizza e scarica."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr ""
"Visualizza solo documenti, immagini e PDF. Verranno applicate filigrane."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Visualizza, scarica e modifica."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Visualizza, scarica e mostra tutte le persone invitate."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Visualizza, scarica e carica."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr "Visualizza, scarica, modifica e mostra tutte le persone invitate."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Visualizza, scarica, carica, modifica, aggiungi ed elimina."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr ""
"Visualizza, scarica, carica, modifica, aggiungi, elimina e gestisci i "
"membri."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Visualizza, scarica, carica, modifica, aggiungi, elimina e mostra tutte le "
"persone invitate."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr "View, download, upload, edit, add, delete including the history."
@@ -0,0 +1,130 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# iikaka88, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-19 00:04+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: iikaka88, 2025\n"
"Language-Team: Japanese (https://app.transifex.com/qsfera-eu/teams/204053/ja/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ja\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "編集可"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "編集 (履歴なし)"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "管理可能"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "アップロード可能"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "閲覧可能"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "セキュア閲覧(閲覧のみ)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "アクセス不可"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "すべてのアクセスを拒否"
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "説明を追加"
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "閲覧とダウンロード"
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr "ドキュメント、画像、PDFの閲覧のみ。透かし(ウォーターマーク)が適用"
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "閲覧、ダウンロード、編集"
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "閲覧、ダウンロード、および全招待ユーザーの表示"
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "閲覧、ダウンロード、アップロード"
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr "閲覧、ダウンロード、編集、および全招待ユーザーの表示"
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "閲覧、ダウンロード、アップロード、編集、追加、削除"
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr "閲覧、ダウンロード、アップロード、編集、追加、削除、およびメンバー管理"
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr "閲覧、ダウンロード、アップロード、編集、追加、削除、および全招待ユーザーの表示"
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr "閲覧、ダウンロード、アップロード、編集、追加、削除(履歴を含む)"
@@ -0,0 +1,130 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# gapho shin, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: gapho shin, 2025\n"
"Language-Team: Korean (https://app.transifex.com/qsfera-eu/teams/204053/ko/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ko\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "편집 가능"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "버전 없이 편집 가능"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "관리 가능"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "업로드 가능"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "보기 가능"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "보기 가능 (보안)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "접근 불가"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "모든 접근 거부"
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "여기에 이 공간에 대한 설명을 추가할 수 있습니다."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "보기 및 다운로드."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr "문서, 이미지 및 PDF만 보기. 워터마크가 적용됩니다."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "보기, 다운로드 및 편집."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "모든 초대받은 사람들을 보고, 다운로드하고, 보여줍니다."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "보기, 다운로드 및 업로드."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr "모든 초대받은 사람들을 보고, 다운로드하고, 편집하고, 보여줍니다."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "보기, 다운로드, 업로드, 편집, 추가 및 삭제."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr "회원 보기, 다운로드, 업로드, 편집, 추가, 삭제 및 관리."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr "모든 초대받은 사람들을 보고, 다운로드하고, 업로드하고, 편집하고, 추가하고, 삭제하고, 보여줍니다."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr "기록을 포함하여 보기, 다운로드, 업로드, 편집, 추가, 삭제합니다."
@@ -0,0 +1,138 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Stephan Paternotte <stephan@paternottes.net>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-06 00:01+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Stephan Paternotte <stephan@paternottes.net>, 2025\n"
"Language-Team: Dutch (https://app.transifex.com/qsfera-eu/teams/204053/nl/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: nl\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Kan bewerken"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Kan bewerken zonder versies"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Kan beheren"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Kan uploaden"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Kan weergeven"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Kan weergeven (beveiligd)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "Heeft geen toegang"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Alle toegang weigeren."
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Hier kun je een beschrijving voor deze ruimte toevoegen."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Weergeven en downloaden."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr ""
"Alleen documenten, afbeeldingen en PDF's weergeven. Met toepassing van "
"watermerken."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Weergeven, downloaden en bewerken."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Weergeven, downloaden en alle genodigde personen zien."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Weergeven, downloaden en uploaden."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr "Weergeven, downloaden, bewerken en alle genodigde personen zien."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Weergeven, downloaden, uploaden, bewerken, toevoegen en verwijderen."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr ""
"Weergeven, downloaden, uploaden, bewerken, toevoegen, verwijderen en leden "
"beheren."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Weergeven, downloaden, uploaden, bewerken, toevoegen, verwijderen en alle "
"genodigde personen zien."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr ""
"Weergeven, downloaden, uploaden, bewerken, toevoegen, verwijderen, incl. "
"geschiedenis."
@@ -0,0 +1,139 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# skrzat <skrzacikus@gmail.com>, 2025
# Maksymilian Styżej, 2025
# Xiaomi Box, 2025
# Radoslaw Posim, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-06 00:01+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Radoslaw Posim, 2025\n"
"Language-Team: Polish (https://app.transifex.com/qsfera-eu/teams/204053/pl/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: pl\n"
"Plural-Forms: nplurals=4; plural=(n==1 ? 0 : (n%10>=2 && n%10<=4) && (n%100<12 || n%100>14) ? 1 : n!=1 && (n%10>=0 && n%10<=1) || (n%10>=5 && n%10<=9) || (n%100>=12 && n%100<=14) ? 2 : 3);\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Może edytować"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr ""
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Może zarządzać"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Może przesyłać pliki"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Może oglądać"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Mogę zobaczyć ( źródło) "
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "Nie ma dostępu"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Odmów dostępu wszystkim."
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Tutaj możesz dodać opis dla tej Przestrzeni"
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Zobacz i pobierz"
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr ""
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Wyświetl , Pobierz i edytuj "
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr ""
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Wyświetlanie, pobieranie i przesyłanie."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr ""
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Wyświetlanie, pobieranie, przesyłanie, edycja, dodawanie i usuwanie."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr ""
"Wyświetlanie, pobieranie, przesyłanie, edycja, dodawanie, usuwanie i "
"zarządzanie członkami."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Wyświetlanie, pobieranie, przesyłanie, edycja, dodawanie, usuwanie i "
"wyświetlanie wszystkich zaproszonych osób."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr ""
"Wyświetlanie, pobieranie, przesyłanie, edycja, dodawanie, usuwanie włącznie "
"z historią."
@@ -0,0 +1,139 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Mário Machado, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-06 00:01+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Mário Machado, 2025\n"
"Language-Team: Portuguese (https://app.transifex.com/qsfera-eu/teams/204053/pt/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: pt\n"
"Plural-Forms: nplurals=3; plural=(n == 0 || n == 1) ? 0 : n != 0 && n % 1000000 == 0 ? 1 : 2;\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Pode editar"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Pode editar sem versões"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Pode gerir"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Pode carregar"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Pode visualizar"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Pode visualizar (seguro)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "Não pode aceder"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Negar todo o acesso."
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Aqui pode adicionar uma descrição para este Espaço."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Visualizar e descarregar."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr ""
"Visualizar apenas documentos, imagens e PDFs. Serão aplicadas marcas de "
"água."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Visualizar, descarregar e editar."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Visualizar, descarregar e mostrar todas as pessoas convidadas."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Visualizar, descarregar e carregar."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr ""
"Visualizar, descarregar, editar e mostrar todas as pessoas convidadas."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Visualizar, descarregar, carregar, editar, adicionar e eliminar."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr ""
"Visualizar, descarregar, carregar, editar, adicionar, eliminar e gerir "
"membros."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Visualizar, descarregar, carregar, editar, adicionar, eliminar e mostrar "
"todas as pessoas convidadas."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr ""
"Visualizar, descarregar, carregar, editar, adicionar, eliminar, incluindo o "
"histórico."
@@ -0,0 +1,140 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Savely Krasovsky, 2025
# Lulufox, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-06 00:01+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Lulufox, 2025\n"
"Language-Team: Russian (https://app.transifex.com/qsfera-eu/teams/204053/ru/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: ru\n"
"Plural-Forms: nplurals=4; plural=(n%10==1 && n%100!=11 ? 0 : n%10>=2 && n%10<=4 && (n%100<12 || n%100>14) ? 1 : n%10==0 || (n%10>=5 && n%10<=9) || (n%100>=11 && n%100<=14)? 2 : 3);\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Может редактировать"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Может редактировать без версионирования"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Может управлять"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Может загружать"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Можно смотреть"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Можно смотреть (защищено)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "Не имеет доступа"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Отказать во всех доступах"
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Здесь вы можете добавить описание для Пространства"
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Просмотреть и скачать"
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr ""
"Просмотреть только документы, изображения и файлы PDF. Будут присутствовать "
"вотермарки."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Просмотреть, скачать и редактировать."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Просмотреть, скачать и показать всех приглашенных людей."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Просмотреть, скачать и загрузить."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr ""
"Просмотреть, скачать, отредактировать и показать всех приглашенных людей."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Просмотреть, скачать, загрузить, редактировать, добавить и удалить."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr ""
"Просмотреть, скачать, загрузить, редактировать, добавить, удалить и "
"управлять командой."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Просмотреть, скачать, загрузить, редактировать, добавить, удалить и показать"
" всех приглашенных людей."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr ""
"Просмотреть, скачать, загрузить, редактировать, добавить, удалить включая "
"историю."
@@ -0,0 +1,137 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Daniel Nylander <po@danielnylander.se>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-20 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Daniel Nylander <po@danielnylander.se>, 2025\n"
"Language-Team: Swedish (https://app.transifex.com/qsfera-eu/teams/204053/sv/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: sv\n"
"Plural-Forms: nplurals=2; plural=(n != 1);\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Kan redigera"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Kan redigera utan versioner"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Kan hantera"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Kan skicka upp"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Kan visa"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Kan visa (säkert)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "Kan inte komma åt"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Neka all åtkomst."
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Här kan du lägga till en beskrivning av denna arbetsyta."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Visa och hämta ner."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr ""
"Visa endast dokument, bilder och PDF. Vattenstämplar kommer att tillämpas."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Visa, hämta ner och redigera."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Visa, hämta ner och visa alla inbjudna personer."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Visa. hämta ner och skicka upp."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr "Visa, hämta ner, redigera och visa alla inbjudna personer."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Visa, hämta ner, skicka upp, redigera, lägga till och ta bort."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr ""
"Visa, hämta ner, skicka upp, redigera, lägga till, ta bort och hantera "
"medlemmar."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Visa, hämta ner, skicka upp, redigera, lägga till, ta bort och visa alla "
"inbjudna personer."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr ""
"Visa, hämta ner, skicka upp, redigera, lägga till, ta bort inklusive "
"historiken."
@@ -0,0 +1,142 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# LinkinWires <darkinsonic13@gmail.com>, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-05-08 00:02+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: LinkinWires <darkinsonic13@gmail.com>, 2025\n"
"Language-Team: Ukrainian (https://app.transifex.com/qsfera-eu/teams/204053/uk/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: uk\n"
"Plural-Forms: nplurals=4; plural=(n % 1 == 0 && n % 10 == 1 && n % 100 != 11 ? 0 : n % 1 == 0 && n % 10 >= 2 && n % 10 <= 4 && (n % 100 < 12 || n % 100 > 14) ? 1 : n % 1 == 0 && (n % 10 ==0 || (n % 10 >=5 && n % 10 <=9) || (n % 100 >=11 && n % 100 <=14 )) ? 2: 3);\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Може редагувати"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Може редагувати (без версій)"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Може керувати"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Може вивантажувати"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Може переглядати"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Може переглядати (обмежено)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "Не має доступу"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Заборонити будь-який доступ."
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "Тут можна додати опис до цього простору."
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Може переглядати та завантажувати файли."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr ""
"Може переглядати лише документи, зображення та PDF-файли. Будуть застосовані"
" водяні знаки."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Може переглядати, завантажувати та редагувати файли."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Може переглядати, завантажувати та показувати усіх запрошених людей."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Може переглядати, завантажувати та вивантажувати файли."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr ""
"Може переглядати, завантажувати, редагувати та показувати усіх запрошених "
"людей."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr ""
"Може переглядати, завантажувати, вивантажувати, редагувати, додавати та "
"видаляти файли."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr ""
"Може переглядати, завантажувати, вивантажувати, редагувати, додавати та "
"видаляти файли, а також може керувати учасниками."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Може переглядати, завантажувати, редагувати, додавати, видаляти та "
"показувати усіх запрошених людей."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr ""
"Може переглядати, завантажувати, вивантажувати, редагувати, додавати та "
"видаляти файли, у тому числі їх історію."
@@ -0,0 +1,132 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# Quan Tran, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-19 00:04+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: Quan Tran, 2025\n"
"Language-Team: Vietnamese (https://app.transifex.com/qsfera-eu/teams/204053/vi/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: vi\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "Có quyền chỉnh sửa"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "Có quyền chỉnh sửa không tạo phiên bản"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "Có quyền quản lý"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "Có quyền đăng tải"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "Có quyền xem"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "Có quyền xem (bảo mật)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "Không có quyền truy cập"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "Cấm mọi quyền truy cập."
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr ""
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "Xem và tải về."
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr "Chỉ được xem tài liệu, hình ảnh và PDF. Sẽ hiển thị hình mờ."
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "Xem, tải về và chỉnh sửa."
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "Xem, tải về và hiển thị danh sách người được mời."
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "Xem, tải về và tải lên."
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr "Xem, tải về, chỉnh sửa và hiển thị danh sách người được mời."
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "Xem, tải về, tải lên, chỉnh sửa, thêm và xóa."
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr "Xem, tải về, tải lên, chỉnh sửa, thêm, xóa và quản lý thành viên."
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr ""
"Xem, tải về, tải lên, chỉnh sửa, thêm, xóa và hiển thị danh sách người được "
"mời."
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr "Xem, tải về, tải lên, chỉnh sửa, thêm và xóa bao gồm lịch sử."
@@ -0,0 +1,130 @@
# SOME DESCRIPTIVE TITLE.
# Copyright (C) YEAR THE PACKAGE'S COPYRIGHT HOLDER
# This file is distributed under the same license as the PACKAGE package.
# FIRST AUTHOR <EMAIL@ADDRESS>, YEAR.
#
# Translators:
# YQS Yang, 2025
#
#, fuzzy
msgid ""
msgstr ""
"Project-Id-Version: \n"
"Report-Msgid-Bugs-To: EMAIL\n"
"POT-Creation-Date: 2026-04-22 00:03+0000\n"
"PO-Revision-Date: 2025-01-27 10:17+0000\n"
"Last-Translator: YQS Yang, 2025\n"
"Language-Team: Chinese (https://app.transifex.com/qsfera-eu/teams/204053/zh/)\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Language: zh\n"
"Plural-Forms: nplurals=1; plural=0;\n"
#. UnifiedRole Editor, Role DisplayName (resolves directly)
#. UnifiedRole EditorListGrants, Role DisplayName (resolves directly)
#. UnifiedRole SpaseEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditor, Role DisplayName (resolves directly)
#. UnifiedRole FileEditorListGrants, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:116 pkg/unifiedrole/roles.go:122
#: pkg/unifiedrole/roles.go:128 pkg/unifiedrole/roles.go:140
#: pkg/unifiedrole/roles.go:146
msgid "Can edit"
msgstr "可编辑"
#. UnifiedRole SpaseEditorWithoutVersions, Role DisplayName (resolves
#. directly)
#: pkg/unifiedrole/roles.go:134
msgid "Can edit without versions"
msgstr "可编辑(无版本控制)"
#. UnifiedRole Manager, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:158
msgid "Can manage"
msgstr "可管理"
#. UnifiedRole EditorLite, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:152
msgid "Can upload"
msgstr "可上传"
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole Viewer, Role DisplayName (resolves directly)
#. UnifiedRole SpaseViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:98 pkg/unifiedrole/roles.go:104
#: pkg/unifiedrole/roles.go:110
msgid "Can view"
msgstr "可查看"
#. UnifiedRole SecureViewer, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:164
msgid "Can view (secure)"
msgstr "可查看(安全)"
#. UnifiedRole FullDenial, Role DisplayName (resolves directly)
#: pkg/unifiedrole/roles.go:170
msgid "Cannot access"
msgstr "无法访问"
#. UnifiedRole FullDenial, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:167
msgid "Deny all access."
msgstr "拒绝所有访问。"
#. default description for new spaces
#: pkg/service/v0/spacetemplates.go:32
msgid "Here you can add a description for this Space."
msgstr "此处可为该空间添加描述。"
#. UnifiedRole Viewer, Role Description (resolves directly)
#. UnifiedRole SpaceViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:95 pkg/unifiedrole/roles.go:107
msgid "View and download."
msgstr "查看和下载。"
#. UnifiedRole SecureViewer, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:161
msgid "View only documents, images and PDFs. Watermarks will be applied."
msgstr "仅可查看文档、图片和PDF,且会添加水印。"
#. UnifiedRole FileEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:137
msgid "View, download and edit."
msgstr "查看、下载和编辑。"
#. UnifiedRole ViewerListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:101
msgid "View, download and show all invited people."
msgstr "查看、下载并显示所有受邀人员。"
#. UnifiedRole EditorLite, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:149
msgid "View, download and upload."
msgstr "查看、下载和上传。"
#. UnifiedRole FileEditorListGrants, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:143
msgid "View, download, edit and show all invited people."
msgstr "查看、下载、编辑并显示所有受邀人员。"
#. UnifiedRole Editor, Role Description (resolves directly)
#. UnifiedRole SpaseEditorWithoutVersions, Role Description (resolves
#. directly)
#: pkg/unifiedrole/roles.go:113 pkg/unifiedrole/roles.go:131
msgid "View, download, upload, edit, add and delete."
msgstr "查看、下载、上传、编辑、添加和删除。"
#. UnifiedRole Manager, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:155
msgid "View, download, upload, edit, add, delete and manage members."
msgstr "查看、下载、上传、编辑、添加、删除和管理成员。"
#. UnifiedRoleListGrants Editor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:119
msgid "View, download, upload, edit, add, delete and show all invited people."
msgstr "查看、下载、上传、编辑、添加、删除并显示所有受邀人员。"
#. UnifiedRole SpaseEditor, Role Description (resolves directly)
#: pkg/unifiedrole/roles.go:125
msgid "View, download, upload, edit, add, delete including the history."
msgstr "查看、下载、上传、编辑、添加和删除(含历史版本)。"
@@ -0,0 +1,32 @@
package l10n
import (
"embed"
"github.com/qsfera/server/pkg/l10n"
)
var (
//go:embed locale
_localeFS embed.FS
)
const (
// subfolder where the translation files are stored
_localeSubPath = "locale"
// domain of the graph service (transifex)
_domain = "graph"
)
// Translate translates a string based on the locale and default locale
func Translate(content, locale, defaultLocale, translationPath string) string {
t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, translationPath, _localeFS, _localeSubPath)
return t.Translate(content, locale)
}
// TranslateEntity returns a function that translates a struct or slice based on the locale
func TranslateEntity(locale, defaultLocale string, entity any, opts ...l10n.TranslateOption) error {
t := l10n.NewTranslatorFromCommonConfig(defaultLocale, _domain, "", _localeFS, _localeSubPath)
return t.TranslateEntity(locale, entity, opts...)
}
@@ -0,0 +1,182 @@
package linktype
import (
"errors"
linkv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/grants"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
)
// NoPermissionMatchError is the message returned by a failed conversion
const NoPermissionMatchError = "no matching permission set found"
// LinkType contains cs3 permissions and a libregraph
// linktype reference
type LinkType struct {
Permissions *provider.ResourcePermissions
linkType libregraph.SharingLinkType
}
// GetPermissions returns the cs3 permissions type
func (l *LinkType) GetPermissions() *provider.ResourcePermissions {
if l != nil {
return l.Permissions
}
return nil
}
// SharingLinkTypeFromCS3Permissions creates a libregraph link type
// It returns a list of libregraph actions when the conversion is not possible
func SharingLinkTypeFromCS3Permissions(permissions *linkv1beta1.PublicSharePermissions) (*libregraph.SharingLinkType, []string) {
if permissions == nil {
return nil, nil
}
linkTypes := GetAvailableLinkTypes()
for _, linkType := range linkTypes {
if grants.PermissionsEqual(linkType.GetPermissions(), permissions.GetPermissions()) {
return &linkType.linkType, nil
}
}
return nil, unifiedrole.CS3ResourcePermissionsToLibregraphActions(permissions.GetPermissions())
}
// CS3ResourcePermissionsFromSharingLink creates a cs3 resource permissions type
// it returns an error when the link type is not allowed or empty
func CS3ResourcePermissionsFromSharingLink(createLink libregraph.DriveItemCreateLink, info provider.ResourceType) (*provider.ResourcePermissions, error) {
switch createLink.GetType() {
case "":
return nil, errors.New("link type is empty")
case libregraph.VIEW:
return NewViewLinkPermissionSet().GetPermissions(), nil
case libregraph.EDIT:
if info == provider.ResourceType_RESOURCE_TYPE_FILE {
return NewFileEditLinkPermissionSet().GetPermissions(), nil
}
return NewFolderEditLinkPermissionSet().GetPermissions(), nil
case libregraph.CREATE_ONLY:
if info == provider.ResourceType_RESOURCE_TYPE_FILE {
return nil, errors.New(NoPermissionMatchError)
}
return NewFolderDropLinkPermissionSet().GetPermissions(), nil
case libregraph.UPLOAD:
if info == provider.ResourceType_RESOURCE_TYPE_FILE {
return nil, errors.New(NoPermissionMatchError)
}
return NewFolderUploadLinkPermissionSet().GetPermissions(), nil
case libregraph.INTERNAL:
return NewInternalLinkPermissionSet().GetPermissions(), nil
default:
return nil, errors.New(NoPermissionMatchError)
}
}
// NewInternalLinkPermissionSet creates cs3 permissions for the internal link type
func NewInternalLinkPermissionSet() *LinkType {
return &LinkType{
Permissions: &provider.ResourcePermissions{},
linkType: libregraph.INTERNAL,
}
}
// NewViewLinkPermissionSet creates cs3 permissions for the view link type
func NewViewLinkPermissionSet() *LinkType {
return &LinkType{
Permissions: &provider.ResourcePermissions{
GetPath: true,
GetQuota: true,
InitiateFileDownload: true,
ListContainer: true,
// why is this needed?
ListRecycle: true,
Stat: true,
},
linkType: libregraph.VIEW,
}
}
// NewFileEditLinkPermissionSet creates cs3 permissions for the file edit link type
func NewFileEditLinkPermissionSet() *LinkType {
return &LinkType{
Permissions: &provider.ResourcePermissions{
GetPath: true,
GetQuota: true,
InitiateFileDownload: true,
InitiateFileUpload: true,
ListContainer: true,
// why is this needed?
ListRecycle: true,
// why is this needed?
RestoreRecycleItem: true,
Stat: true,
},
linkType: libregraph.EDIT,
}
}
// NewFolderEditLinkPermissionSet creates cs3 permissions for the folder edit link type
func NewFolderEditLinkPermissionSet() *LinkType {
return &LinkType{
Permissions: &provider.ResourcePermissions{
CreateContainer: true,
Delete: true,
GetPath: true,
GetQuota: true,
InitiateFileDownload: true,
InitiateFileUpload: true,
ListContainer: true,
// why is this needed?
ListRecycle: true,
Move: true,
// why is this needed?
RestoreRecycleItem: true,
Stat: true,
},
linkType: libregraph.EDIT,
}
}
// NewFolderDropLinkPermissionSet creates cs3 permissions for the folder createOnly link type
func NewFolderDropLinkPermissionSet() *LinkType {
return &LinkType{
Permissions: &provider.ResourcePermissions{
Stat: true,
GetPath: true,
CreateContainer: true,
InitiateFileUpload: true,
},
linkType: libregraph.CREATE_ONLY,
}
}
// NewFolderUploadLinkPermissionSet creates cs3 permissions for the folder upload link type
func NewFolderUploadLinkPermissionSet() *LinkType {
return &LinkType{
Permissions: &provider.ResourcePermissions{
CreateContainer: true,
GetPath: true,
GetQuota: true,
InitiateFileDownload: true,
InitiateFileUpload: true,
ListContainer: true,
ListRecycle: true,
Stat: true,
},
linkType: libregraph.UPLOAD,
}
}
// GetAvailableLinkTypes returns a slice of all available link types
func GetAvailableLinkTypes() []*LinkType {
return []*LinkType{
NewInternalLinkPermissionSet(),
NewViewLinkPermissionSet(),
NewFolderUploadLinkPermissionSet(),
NewFileEditLinkPermissionSet(),
NewFolderEditLinkPermissionSet(),
NewFolderDropLinkPermissionSet(),
}
}
@@ -0,0 +1,13 @@
package linktype_test
import (
"testing"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
)
func TestLinktype(t *testing.T) {
RegisterFailHandler(Fail)
RunSpecs(t, "Linktype Suite")
}
@@ -0,0 +1,142 @@
package linktype_test
import (
linkv1beta1 "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
"github.com/qsfera/server/services/graph/pkg/linktype"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/grants"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
var _ = Describe("LinktypeFromPermission", func() {
var (
internalLinkType, _ = libregraph.NewSharingLinkTypeFromValue("internal")
createOnlyLinkType, _ = libregraph.NewSharingLinkTypeFromValue("createOnly")
viewLinkType, _ = libregraph.NewSharingLinkTypeFromValue("view")
uploadLinkType, _ = libregraph.NewSharingLinkTypeFromValue("upload")
editLinkType, _ = libregraph.NewSharingLinkTypeFromValue("edit")
folderEditPermsHaveChanged = linktype.NewFolderEditLinkPermissionSet().GetPermissions()
)
BeforeEach(func() {
// simulate that permissions have changed after link creation
folderEditPermsHaveChanged.CreateContainer = false
})
DescribeTable("SharingLinkTypeFromCS3Permissions",
func(permissions *linkv1beta1.PublicSharePermissions,
expectedSharingLinkType *libregraph.SharingLinkType,
expectedActions []string) {
sharingLinkType, actions := linktype.SharingLinkTypeFromCS3Permissions(permissions)
Expect(sharingLinkType).To(Equal(expectedSharingLinkType))
Expect(expectedActions).To(Equal(actions))
},
Entry("Internal",
&linkv1beta1.PublicSharePermissions{Permissions: linktype.NewInternalLinkPermissionSet().GetPermissions()},
internalLinkType,
nil,
),
Entry("CreateOnly",
&linkv1beta1.PublicSharePermissions{Permissions: linktype.NewFolderDropLinkPermissionSet().GetPermissions()},
createOnlyLinkType,
nil,
),
Entry("View File",
&linkv1beta1.PublicSharePermissions{Permissions: linktype.NewViewLinkPermissionSet().GetPermissions()},
viewLinkType,
nil,
),
Entry("Upload in Folder",
&linkv1beta1.PublicSharePermissions{Permissions: linktype.NewFolderUploadLinkPermissionSet().GetPermissions()},
uploadLinkType,
nil,
),
Entry("File Edit",
&linkv1beta1.PublicSharePermissions{Permissions: linktype.NewFileEditLinkPermissionSet().GetPermissions()},
editLinkType,
nil,
),
Entry("Folder Edit",
&linkv1beta1.PublicSharePermissions{Permissions: linktype.NewFolderEditLinkPermissionSet().GetPermissions()},
editLinkType,
nil,
),
Entry("Folder Edit- Permissions have changed after creation",
&linkv1beta1.PublicSharePermissions{Permissions: folderEditPermsHaveChanged},
nil,
[]string{
"libre.graph/driveItem/standard/delete",
"libre.graph/driveItem/path/read",
"libre.graph/driveItem/quota/read",
"libre.graph/driveItem/content/read",
"libre.graph/driveItem/upload/create",
"libre.graph/driveItem/children/read",
"libre.graph/driveItem/deleted/read",
"libre.graph/driveItem/path/update",
"libre.graph/driveItem/deleted/update",
"libre.graph/driveItem/basic/read",
},
),
)
DescribeTable("CS3ResourcePermissionsFromSharingLink",
func(createLink libregraph.DriveItemCreateLink,
info provider.ResourceType,
expectedPermissions *provider.ResourcePermissions,
hasError bool) {
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(createLink, info)
if hasError == true {
Expect(err).To(HaveOccurred())
} else {
Expect(err).ToNot(HaveOccurred())
Expect(grants.PermissionsEqual(permissions, expectedPermissions)).To(BeTrue())
}
},
Entry("Internal",
libregraph.DriveItemCreateLink{Type: internalLinkType},
provider.ResourceType_RESOURCE_TYPE_FILE, linktype.NewInternalLinkPermissionSet().GetPermissions(),
false,
),
Entry("Create Only",
libregraph.DriveItemCreateLink{Type: createOnlyLinkType},
provider.ResourceType_RESOURCE_TYPE_CONTAINER, linktype.NewFolderDropLinkPermissionSet().GetPermissions(),
false,
),
Entry("Create Only",
libregraph.DriveItemCreateLink{Type: createOnlyLinkType},
provider.ResourceType_RESOURCE_TYPE_FILE, linktype.NewFolderDropLinkPermissionSet().GetPermissions(),
true,
),
Entry("View File",
libregraph.DriveItemCreateLink{Type: viewLinkType},
provider.ResourceType_RESOURCE_TYPE_FILE, linktype.NewViewLinkPermissionSet().GetPermissions(),
false,
),
Entry("View Folder",
libregraph.DriveItemCreateLink{Type: viewLinkType},
provider.ResourceType_RESOURCE_TYPE_CONTAINER, linktype.NewViewLinkPermissionSet().GetPermissions(),
false,
),
Entry("Upload in Folder",
libregraph.DriveItemCreateLink{Type: uploadLinkType},
provider.ResourceType_RESOURCE_TYPE_CONTAINER, linktype.NewFolderUploadLinkPermissionSet().GetPermissions(),
false,
),
Entry("File Edit",
libregraph.DriveItemCreateLink{Type: editLinkType},
provider.ResourceType_RESOURCE_TYPE_FILE, linktype.NewFileEditLinkPermissionSet().GetPermissions(),
false,
),
Entry("Folder Edit",
libregraph.DriveItemCreateLink{Type: editLinkType},
provider.ResourceType_RESOURCE_TYPE_CONTAINER, linktype.NewFolderEditLinkPermissionSet().GetPermissions(),
false,
),
)
})
@@ -0,0 +1,33 @@
package metrics
import "github.com/prometheus/client_golang/prometheus"
var (
// Namespace defines the namespace for the defines metrics.
Namespace = "qsfera"
// Subsystem defines the subsystem for the defines metrics.
Subsystem = "graph"
)
// Metrics defines the available metrics of this service.
type Metrics struct {
// Counter *prometheus.CounterVec
BuildInfo *prometheus.GaugeVec
}
// New initializes the available metrics.
func New() *Metrics {
m := &Metrics{
BuildInfo: prometheus.NewGaugeVec(prometheus.GaugeOpts{
Namespace: Namespace,
Subsystem: Subsystem,
Name: "build_info",
Help: "Build information",
}, []string{"version"}),
}
_ = prometheus.Register(m.BuildInfo)
// TODO: implement metrics
return m
}
@@ -0,0 +1,95 @@
package middleware
import (
"net/http"
gmmetadata "go-micro.dev/v4/metadata"
"google.golang.org/grpc/metadata"
"github.com/qsfera/server/pkg/account"
"github.com/qsfera/server/pkg/log"
opkgm "github.com/qsfera/server/pkg/middleware"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/opencloud-eu/reva/v2/pkg/auth/scope"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/token/manager/jwt"
)
// authOptions initializes the available default options.
func authOptions(opts ...account.Option) account.Options {
opt := account.Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Auth provides a middleware to authenticate requests using the x-access-token header value
// and write it to the context. If there is no x-access-token the middleware prevents access and renders a json document.
func Auth(opts ...account.Option) func(http.Handler) http.Handler {
// Note: This largely duplicates what pkg/middleware/account.go already does (apart from a slightly different error
// handling). Ideally we should merge both middlewares.
opt := authOptions(opts...)
tokenManager, err := jwt.New(map[string]any{
"secret": opt.JWTSecret,
"expires": int64(24 * 60 * 60),
})
if err != nil {
opt.Logger.Fatal().Err(err).Msgf("Could not initialize token-manager")
}
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
t := r.Header.Get(revactx.TokenHeader)
if t == "" {
errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, "Access token is empty.")
/* msgraph error for GET https://graph.microsoft.com/v1.0/me
{
"error":
{
"code":"InvalidAuthenticationToken",
"message":"Access token is empty.",
"innerError":{
"date":"2021-07-09T14:40:51",
"request-id":"bb12f7db-b4c4-43a9-ba4b-31676aeed019",
"client-request-id":"bb12f7db-b4c4-43a9-ba4b-31676aeed019"
}
}
}
*/
return
}
u, tokenScope, err := tokenManager.DismantleToken(r.Context(), t)
if err != nil {
errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, "invalid token")
return
}
if ok, err := scope.VerifyScope(ctx, tokenScope, r); err != nil || !ok {
opt.Logger.Error().Str(log.RequestIDString, r.Header.Get("X-Request-ID")).Err(err).Msg("verifying scope failed")
errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, "verifying scope failed")
return
}
ctx = revactx.ContextSetToken(ctx, t)
ctx = revactx.ContextSetUser(ctx, u)
ctx = gmmetadata.Set(ctx, opkgm.AccountID, u.GetId().GetOpaqueId())
if m := u.GetOpaque().GetMap(); m != nil {
if roles, ok := m["roles"]; ok {
ctx = gmmetadata.Set(ctx, opkgm.RoleIDs, string(roles.GetValue()))
}
}
ctx = metadata.AppendToOutgoingContext(ctx, revactx.TokenHeader, t)
initiatorID := r.Header.Get(revactx.InitiatorHeader)
if initiatorID != "" {
ctx = revactx.ContextSetInitiator(ctx, initiatorID)
ctx = metadata.AppendToOutgoingContext(ctx, revactx.InitiatorHeader, initiatorID)
}
next.ServeHTTP(w, r.WithContext(ctx))
})
}
}
@@ -0,0 +1,54 @@
package middleware
import (
"net/http"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/pkg/roles"
"github.com/qsfera/server/services/graph/pkg/errorcode"
settings "github.com/qsfera/server/services/settings/pkg/service/v0"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
)
// RequireAdmin middleware is used to require the user in context to be an admin / have account management permissions
func RequireAdmin(rm *roles.Manager, logger log.Logger) func(next http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
l := logger.With().Str("middleware", "requireAdmin").Logger()
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
u, ok := revactx.ContextGetUser(r.Context())
if !ok {
errorcode.AccessDenied.Render(w, r, http.StatusUnauthorized, "Unauthorized")
return
}
if u.Id == nil || u.Id.OpaqueId == "" {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "user is missing an id")
return
}
// get roles from context
roleIDs, ok := roles.ReadRoleIDsFromContext(r.Context())
if !ok {
l.Debug().Str("userid", u.Id.OpaqueId).Msg("No roles in context, contacting settings service")
var err error
roleIDs, err = rm.FindRoleIDsForUser(r.Context(), u.Id.OpaqueId)
if err != nil {
l.Error().Err(err).Str("userid", u.Id.OpaqueId).Msg("Failed to get roles for user")
errorcode.AccessDenied.Render(w, r, http.StatusUnauthorized, "Unauthorized")
return
}
if len(roleIDs) == 0 {
l.Error().Err(err).Str("userid", u.Id.OpaqueId).Msg("No roles assigned to user")
errorcode.AccessDenied.Render(w, r, http.StatusUnauthorized, "Unauthorized")
return
}
}
// check if permission is present in roles of the authenticated account
if rm.FindPermissionByID(r.Context(), roleIDs, settings.AccountManagementPermissionID) != nil {
next.ServeHTTP(w, r)
return
}
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, "Forbidden")
})
}
}
@@ -0,0 +1,45 @@
package middleware
import (
"crypto/sha256"
"crypto/subtle"
"net/http"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
var (
// ErrInvalidToken is returned when the request token is invalid.
ErrInvalidToken = "invalid or missing token"
)
// Token provides a middleware to check access secured by a static token.
func Token(token string) func(http.Handler) http.Handler {
h := sha256.New()
requiredTokenHash := h.Sum(([]byte("Bearer " + token)))
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if token == "" {
next.ServeHTTP(w, r)
return
}
header := r.Header.Get("Authorization")
if header == "" {
errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, ErrInvalidToken)
return
}
h = sha256.New()
providedTokenHash := h.Sum([]byte(header))
if subtle.ConstantTimeCompare(requiredTokenHash, providedTokenHash) == 0 {
errorcode.InvalidAuthenticationToken.Render(w, r, http.StatusUnauthorized, ErrInvalidToken)
return
}
next.ServeHTTP(w, r)
})
}
}
@@ -0,0 +1,49 @@
package middleware
import (
"net/http"
"net/http/httptest"
"testing"
)
type dummyHandler struct{}
func (h dummyHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusOK)
}
func TestToken(t *testing.T) {
dh := dummyHandler{}
handler := Token("test-api-key")(dh)
req, err := http.NewRequest("GET", "/token-protected", nil)
req.Header.Set("Authorization", "Bearer wrong")
if err != nil {
t.Fatal(err)
}
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// Check the status code is what we expect.
if status := rr.Code; status != http.StatusUnauthorized {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusUnauthorized)
}
req, err = http.NewRequest("GET", "/token-protected", nil)
req.Header.Set("Authorization", "Bearer test-api-key")
if err != nil {
t.Fatal(err)
}
rr = httptest.NewRecorder()
handler.ServeHTTP(rr, req)
// Check the status code is what we expect.
if status := rr.Code; status != http.StatusOK {
t.Errorf("handler returned wrong status code: got %v want %v",
status, http.StatusOK)
}
}
+104
View File
@@ -0,0 +1,104 @@
package odata
import (
"strings"
"github.com/CiscoM31/godata"
)
// GetExpandValues extracts the values of the $expand query parameter and
// returns them in a []string, rejecting any $expand value that consists of more
// than just a single path segment.
func GetExpandValues(req *godata.GoDataQuery) ([]string, error) {
if req == nil || req.Expand == nil {
return []string{}, nil
}
var expand []string
for _, item := range req.Expand.ExpandItems {
paths, err := collectExpandPaths(item, "")
if err != nil {
return nil, err
}
expand = append(expand, paths...)
}
return expand, nil
}
// collectExpandPaths recursively collects all valid expand paths from the given item.
func collectExpandPaths(item *godata.ExpandItem, prefix string) ([]string, error) {
if err := validateExpandItem(item); err != nil {
return nil, err
}
// Build the current path
currentPath := prefix
if len(item.Path) > 1 {
return nil, godata.NotImplementedError("multiple segments in $expand not supported")
}
if len(item.Path) == 1 {
if currentPath == "" {
currentPath = item.Path[0].Value
} else {
currentPath += "." + item.Path[0].Value
}
}
// Collect all paths, including nested ones
paths := []string{currentPath}
if item.Expand != nil {
for _, subItem := range item.Expand.ExpandItems {
subPaths, err := collectExpandPaths(subItem, currentPath)
if err != nil {
return nil, err
}
paths = append(paths, subPaths...)
}
}
return paths, nil
}
// validateExpandItem checks if an expand item contains unsupported options.
func validateExpandItem(item *godata.ExpandItem) error {
if item.Filter != nil || item.At != nil || item.Search != nil ||
item.OrderBy != nil || item.Skip != nil || item.Top != nil ||
item.Select != nil || item.Compute != nil || item.Levels != 0 {
return godata.NotImplementedError("options for $expand not supported")
}
return nil
}
// GetSelectValues extracts the values of the $select query parameter and
// returns them in a []string, rejects any $select value that consists of more
// than just a single path segment
func GetSelectValues(req *godata.GoDataQuery) ([]string, error) {
if req == nil || req.Select == nil {
return []string{}, nil
}
sel := make([]string, 0, len(req.Select.SelectItems))
for _, item := range req.Select.SelectItems {
if len(item.Segments) > 1 {
return []string{}, godata.NotImplementedError("multiple segments in $select not supported")
}
sel = append(sel, item.Segments[0].Value)
}
return sel, nil
}
// GetSearchValues extracts the value of the $search query parameter and returns
// it as a string. Rejects any search query that is more than just a simple string
func GetSearchValues(req *godata.GoDataQuery) (string, error) {
if req == nil || req.Search == nil {
return "", nil
}
// Only allow simple search queries for now
if len(req.Search.Tree.Children) != 0 {
return "", godata.NotImplementedError("complex search queries are not supported")
}
searchValue := strings.Trim(req.Search.Tree.Token.Value, "\"")
return searchValue, nil
}
@@ -0,0 +1,160 @@
package odata
import (
"testing"
"github.com/CiscoM31/godata"
"github.com/stretchr/testify/assert"
)
func TestGetExpandValues(t *testing.T) {
t.Run("NilRequest", func(t *testing.T) {
result, err := GetExpandValues(nil)
assert.NoError(t, err)
assert.Empty(t, result)
})
t.Run("EmptyExpand", func(t *testing.T) {
req := &godata.GoDataQuery{}
result, err := GetExpandValues(req)
assert.NoError(t, err)
assert.Empty(t, result)
})
t.Run("SinglePathSegment", func(t *testing.T) {
req := &godata.GoDataQuery{
Expand: &godata.GoDataExpandQuery{
ExpandItems: []*godata.ExpandItem{
{Path: []*godata.Token{{Value: "orders"}}},
},
},
}
result, err := GetExpandValues(req)
assert.NoError(t, err)
assert.Equal(t, []string{"orders"}, result)
})
t.Run("MultiplePathSegments", func(t *testing.T) {
req := &godata.GoDataQuery{
Expand: &godata.GoDataExpandQuery{
ExpandItems: []*godata.ExpandItem{
{Path: []*godata.Token{{Value: "orders"}, {Value: "details"}}},
},
},
}
result, err := GetExpandValues(req)
assert.Error(t, err)
assert.Empty(t, result)
})
t.Run("NestedExpand", func(t *testing.T) {
req := &godata.GoDataQuery{
Expand: &godata.GoDataExpandQuery{
ExpandItems: []*godata.ExpandItem{
{
Path: []*godata.Token{{Value: "items"}},
Expand: &godata.GoDataExpandQuery{
ExpandItems: []*godata.ExpandItem{
{
Path: []*godata.Token{{Value: "subitem"}},
Expand: &godata.GoDataExpandQuery{
ExpandItems: []*godata.ExpandItem{
{Path: []*godata.Token{{Value: "subsubitems"}}},
},
},
},
},
},
},
},
},
}
result, err := GetExpandValues(req)
assert.NoError(t, err)
assert.Subset(t, result, []string{"items", "items.subitem", "items.subitem.subsubitems"}, "must contain all levels of expansion")
})
}
func TestGetSelectValues(t *testing.T) {
t.Run("NilRequest", func(t *testing.T) {
result, err := GetSelectValues(nil)
assert.NoError(t, err)
assert.Empty(t, result)
})
t.Run("EmptySelect", func(t *testing.T) {
req := &godata.GoDataQuery{}
result, err := GetSelectValues(req)
assert.NoError(t, err)
assert.Empty(t, result)
})
t.Run("SinglePathSegment", func(t *testing.T) {
req := &godata.GoDataQuery{
Select: &godata.GoDataSelectQuery{
SelectItems: []*godata.SelectItem{
{Segments: []*godata.Token{{Value: "name"}}},
},
},
}
result, err := GetSelectValues(req)
assert.NoError(t, err)
assert.Equal(t, []string{"name"}, result)
})
t.Run("MultiplePathSegments", func(t *testing.T) {
req := &godata.GoDataQuery{
Select: &godata.GoDataSelectQuery{
SelectItems: []*godata.SelectItem{
{Segments: []*godata.Token{{Value: "name"}, {Value: "first"}}},
},
},
}
result, err := GetSelectValues(req)
assert.Error(t, err)
assert.Empty(t, result)
})
}
func TestGetSearchValues(t *testing.T) {
t.Run("NilRequest", func(t *testing.T) {
result, err := GetSearchValues(nil)
assert.NoError(t, err)
assert.Empty(t, result)
})
t.Run("EmptySearch", func(t *testing.T) {
req := &godata.GoDataQuery{}
result, err := GetSearchValues(req)
assert.NoError(t, err)
assert.Empty(t, result)
})
t.Run("SimpleSearch", func(t *testing.T) {
req := &godata.GoDataQuery{
Search: &godata.GoDataSearchQuery{
Tree: &godata.ParseNode{
Token: &godata.Token{Value: "test"},
},
},
}
result, err := GetSearchValues(req)
assert.NoError(t, err)
assert.Equal(t, "test", result)
})
t.Run("ComplexSearch", func(t *testing.T) {
req := &godata.GoDataQuery{
Search: &godata.GoDataSearchQuery{
Tree: &godata.ParseNode{
Children: []*godata.ParseNode{
{Token: &godata.Token{Value: "test"}},
},
},
},
}
result, err := GetSearchValues(req)
assert.Error(t, err)
assert.Empty(t, result)
})
}
@@ -0,0 +1,50 @@
package debug
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/config"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Context context.Context
Config *config.Config
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
@@ -0,0 +1,50 @@
package debug
import (
"net/http"
"net/url"
"github.com/qsfera/server/pkg/checks"
"github.com/qsfera/server/pkg/handlers"
"github.com/qsfera/server/pkg/service/debug"
"github.com/qsfera/server/pkg/version"
)
// Server initializes the debug service and server.
func Server(opts ...Option) (*http.Server, error) {
options := newOptions(opts...)
healthHandlerConfiguration := handlers.NewCheckHandlerConfiguration().
WithLogger(options.Logger).
WithCheck("web reachability", checks.NewHTTPCheck(options.Config.HTTP.Addr))
readyHandlerConfiguration := healthHandlerConfiguration
// Check for LDAP reachability, when we're using the LDAP backend
if options.Config.Identity.Backend == "ldap" {
u, err := url.Parse(options.Config.Identity.LDAP.URI)
if err != nil {
return nil, err
}
readyHandlerConfiguration = readyHandlerConfiguration.
WithCheck("ldap reachability", checks.NewTCPCheck(u.Host))
}
// only check nats if really needed
if options.Config.Events.Endpoint != "" {
readyHandlerConfiguration = readyHandlerConfiguration.
WithCheck("nats reachability", checks.NewNatsCheck(options.Config.Events.Endpoint))
}
return debug.NewService(
debug.Logger(options.Logger),
debug.Name(options.Config.Service.Name),
debug.Version(version.GetString()),
debug.Address(options.Config.Debug.Addr),
debug.Token(options.Config.Debug.Token),
debug.Pprof(options.Config.Debug.Pprof),
debug.Zpages(options.Config.Debug.Zpages),
debug.Health(handlers.NewCheckHandler(healthHandlerConfiguration)),
debug.Ready(handlers.NewCheckHandler(readyHandlerConfiguration)),
), nil
}
@@ -0,0 +1,95 @@
package http
import (
"context"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/metrics"
"github.com/nats-io/nats.go/jetstream"
"github.com/spf13/pflag"
"go.opentelemetry.io/otel/trace"
)
// Option defines a single option function.
type Option func(o *Options)
// Options defines the available options for this package.
type Options struct {
Logger log.Logger
Context context.Context
Config *config.Config
Metrics *metrics.Metrics
Flags []pflag.Flag
Namespace string
TraceProvider trace.TracerProvider
NatsKeyValue jetstream.KeyValue
}
// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
for _, o := range opts {
o(&opt)
}
return opt
}
// Logger provides a function to set the logger option.
func Logger(val log.Logger) Option {
return func(o *Options) {
o.Logger = val
}
}
// Context provides a function to set the context option.
func Context(val context.Context) Option {
return func(o *Options) {
o.Context = val
}
}
// Config provides a function to set the config option.
func Config(val *config.Config) Option {
return func(o *Options) {
o.Config = val
}
}
// Metrics provides a function to set the metrics option.
func Metrics(val *metrics.Metrics) Option {
return func(o *Options) {
o.Metrics = val
}
}
// Flags provides a function to set the flags option.
func Flags(flags ...pflag.Flag) Option {
return func(o *Options) {
o.Flags = append(o.Flags, flags...)
}
}
// Namespace provides a function to set the Namespace option.
func Namespace(val string) Option {
return func(o *Options) {
o.Namespace = val
}
}
// TraceProvider provides a function to set the TraceProvider option.
func TraceProvider(val trace.TracerProvider) Option {
return func(o *Options) {
o.TraceProvider = val
}
}
// NatsKeyValue provides a function to set the NatsKeyValue option.
func NatsKeyValue(val jetstream.KeyValue) Option {
return func(o *Options) {
o.NatsKeyValue = val
}
}
@@ -0,0 +1,193 @@
package http
import (
"context"
"errors"
"fmt"
stdhttp "net/http"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
chimiddleware "github.com/go-chi/chi/v5/middleware"
"github.com/opencloud-eu/reva/v2/pkg/events/stream"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
revaMetadata "github.com/opencloud-eu/reva/v2/pkg/storage/utils/metadata"
"go-micro.dev/v4"
"go-micro.dev/v4/events"
"github.com/qsfera/server/pkg/account"
"github.com/qsfera/server/pkg/cors"
"github.com/qsfera/server/pkg/generators"
"github.com/qsfera/server/pkg/keycloak"
"github.com/qsfera/server/pkg/middleware"
"github.com/qsfera/server/pkg/registry"
"github.com/qsfera/server/pkg/service/grpc"
"github.com/qsfera/server/pkg/service/http"
"github.com/qsfera/server/pkg/storage/metadata"
"github.com/qsfera/server/pkg/version"
ehsvc "github.com/qsfera/server/protogen/gen/qsfera/services/eventhistory/v0"
searchsvc "github.com/qsfera/server/protogen/gen/qsfera/services/search/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
graphMiddleware "github.com/qsfera/server/services/graph/pkg/middleware"
svc "github.com/qsfera/server/services/graph/pkg/service/v0"
)
// Server initializes the http service and server.
func Server(opts ...Option) (http.Service, error) {
options := newOptions(opts...)
service, err := http.NewService(
http.TLSConfig(options.Config.HTTP.TLS),
http.Logger(options.Logger),
http.Namespace(options.Config.HTTP.Namespace),
http.Name(options.Config.Service.Name),
http.Version(version.GetString()),
http.Address(options.Config.HTTP.Addr),
http.Context(options.Context),
http.Flags(options.Flags...),
http.TraceProvider(options.TraceProvider),
)
if err != nil {
options.Logger.Error().
Err(err).
Msg("Error initializing http service")
return http.Service{}, fmt.Errorf("could not initialize http service: %w", err)
}
var eventsStream events.Stream
if options.Config.Events.Endpoint != "" {
var err error
connName := generators.GenerateConnectionName(options.Config.Service.Name, generators.NTypeBus)
eventsStream, err = stream.NatsFromConfig(connName, false, stream.NatsConfig(options.Config.Events))
if err != nil {
options.Logger.Error().
Err(err).
Msg("Error initializing events publisher")
return http.Service{}, fmt.Errorf("could not initialize events publisher: %w", err)
}
}
middlewares := []func(stdhttp.Handler) stdhttp.Handler{
middleware.TraceContext,
chimiddleware.RequestID,
middleware.Version(
options.Config.Service.Name,
version.GetString(),
),
middleware.Logger(
options.Logger,
),
middleware.Cors(
cors.Logger(options.Logger),
cors.AllowedOrigins(options.Config.HTTP.CORS.AllowedOrigins),
cors.AllowedMethods(options.Config.HTTP.CORS.AllowedMethods),
cors.AllowedHeaders(options.Config.HTTP.CORS.AllowedHeaders),
cors.AllowCredentials(options.Config.HTTP.CORS.AllowCredentials),
),
}
// how do we secure the api?
var requireAdminMiddleware func(stdhttp.Handler) stdhttp.Handler
var roleService svc.RoleService
var valueService settingssvc.ValueService
var gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
grpcClient, err := grpc.NewClient(append(grpc.GetClientOptions(options.Config.GRPCClientTLS), grpc.WithTraceProvider(options.TraceProvider))...)
if err != nil {
return http.Service{}, err
}
if options.Config.HTTP.APIToken == "" {
middlewares = append(middlewares,
graphMiddleware.Auth(
account.Logger(options.Logger),
account.JWTSecret(options.Config.TokenManager.JWTSecret),
))
roleService = settingssvc.NewRoleService("qsfera.api.settings", grpcClient)
valueService = settingssvc.NewValueService("qsfera.api.settings", grpcClient)
gatewaySelector, err = pool.GatewaySelector(
options.Config.Reva.Address,
append(
options.Config.Reva.GetRevaOptions(),
pool.WithRegistry(registry.GetRegistry()),
pool.WithTracerProvider(options.TraceProvider),
)...)
if err != nil {
return http.Service{}, fmt.Errorf("could not initialize gateway selector: %w", err)
}
} else {
middlewares = append(middlewares, graphMiddleware.Token(options.Config.HTTP.APIToken))
// use a dummy admin middleware for the chi router
requireAdminMiddleware = func(next stdhttp.Handler) stdhttp.Handler {
return next
}
// no gateway client needed
}
// Keycloak client is optional, so if it stays nil, it's fine.
var keyCloakClient keycloak.Client
if options.Config.Keycloak.BasePath != "" {
kcc := options.Config.Keycloak
if kcc.ClientID == "" || kcc.ClientSecret == "" || kcc.ClientRealm == "" || kcc.UserRealm == "" {
return http.Service{}, errors.New("keycloak client id, secret, client realm and user realm must be set")
}
keyCloakClient = keycloak.New(kcc.BasePath, kcc.ClientID, kcc.ClientSecret, kcc.ClientRealm, kcc.InsecureSkipVerify)
}
hClient := ehsvc.NewEventHistoryService("qsfera.api.eventhistory", grpcClient)
var userProfilePhotoService svc.UsersUserProfilePhotoProvider
{
photoStorage, err := revaMetadata.NewCS3Storage(
options.Config.Metadata.GatewayAddress,
options.Config.Metadata.StorageAddress,
options.Config.Metadata.SystemUserID,
options.Config.Metadata.SystemUserIDP,
options.Config.Metadata.SystemUserAPIKey,
)
if err != nil {
return http.Service{}, fmt.Errorf("could not initialize reva metadata storage: %w", err)
}
photoStorage, err = metadata.NewLazyStorage(photoStorage)
if err != nil {
return http.Service{}, fmt.Errorf("could not initialize lazy metadata storage: %w", err)
}
if err := photoStorage.Init(context.Background(), "f2bdd61a-da7c-49fc-8203-0558109d1b4f"); err != nil {
return http.Service{}, fmt.Errorf("could not initialize metadata storage: %w", err)
}
userProfilePhotoService, err = svc.NewUsersUserProfilePhotoService(photoStorage)
if err != nil {
return http.Service{}, fmt.Errorf("could not initialize user profile photo service: %w", err)
}
}
var handle svc.Service
handle, err = svc.NewService(
svc.Context(options.Context),
svc.UserProfilePhotoService(userProfilePhotoService),
svc.Logger(options.Logger),
svc.Config(options.Config),
svc.Middleware(middlewares...),
svc.EventsPublisher(eventsStream),
svc.EventsConsumer(eventsStream),
svc.WithRoleService(roleService),
svc.WithValueService(valueService),
svc.WithRequireAdminMiddleware(requireAdminMiddleware),
svc.WithGatewaySelector(gatewaySelector),
svc.WithSearchService(searchsvc.NewSearchProviderService("qsfera.api.search", grpcClient)),
svc.KeycloakClient(keyCloakClient),
svc.EventHistoryClient(hClient),
svc.TraceProvider(options.TraceProvider),
svc.WithNatsKeyValue(options.NatsKeyValue),
)
if err != nil {
return http.Service{}, fmt.Errorf("could not initialize graph service: %w", err)
}
if err := micro.RegisterHandler(service.Server(), handle); err != nil {
return http.Service{}, fmt.Errorf("could not register graph service handler: %w", err)
}
return service, nil
}
@@ -0,0 +1,991 @@
package svc
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"slices"
"strings"
"github.com/CiscoM31/godata"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
grouppb "github.com/cs3org/go-cs3apis/cs3/identity/group/v1beta1"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
ocmprovider "github.com/cs3org/go-cs3apis/cs3/ocm/provider/v1beta1"
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/publicshare"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/share"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/qsfera/server/pkg/l10n"
l10n_pkg "github.com/qsfera/server/services/graph/pkg/l10n"
"github.com/qsfera/server/services/graph/pkg/odata"
"github.com/qsfera/server/pkg/conversions"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/identity"
"github.com/qsfera/server/services/graph/pkg/identity/cache"
"github.com/qsfera/server/services/graph/pkg/unifiedrole"
"github.com/qsfera/server/services/graph/pkg/validate"
)
const (
invalidIdMsg = "invalid driveID or itemID"
parseDriveIDErrMsg = "could not parse driveID"
federatedRolesODataFilter = "@libre.graph.permissions.roles.allowedValues/rolePermissions/any(p:contains(p/condition, '@Subject.UserType==\"Federated\"'))"
noLinksODataFilter = "grantedToV2 ne ''"
)
// DriveItemPermissionsProvider contains the methods related to handling permissions on drive items
type DriveItemPermissionsProvider interface {
Invite(ctx context.Context, resourceId *storageprovider.ResourceId, invite libregraph.DriveItemInvite) (libregraph.Permission, error)
SpaceRootInvite(ctx context.Context, driveID *storageprovider.ResourceId, invite libregraph.DriveItemInvite) (libregraph.Permission, error)
ListPermissions(ctx context.Context, itemID *storageprovider.ResourceId, queryOptions ListPermissionsQueryOptions) (libregraph.CollectionOfPermissionsWithAllowedValues, error)
ListSpaceRootPermissions(ctx context.Context, driveID *storageprovider.ResourceId, queryOptions ListPermissionsQueryOptions) (libregraph.CollectionOfPermissionsWithAllowedValues, error)
DeletePermission(ctx context.Context, itemID *storageprovider.ResourceId, permissionID string) error
DeleteSpaceRootPermission(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string) error
UpdatePermission(ctx context.Context, itemID *storageprovider.ResourceId, permissionID string, newPermission libregraph.Permission) (libregraph.Permission, error)
UpdateSpaceRootPermission(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string, newPermission libregraph.Permission) (libregraph.Permission, error)
CreateLink(ctx context.Context, driveItemID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error)
CreateSpaceRootLink(ctx context.Context, driveID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error)
SetPublicLinkPassword(ctx context.Context, driveItemID *storageprovider.ResourceId, permissionID string, password string) (libregraph.Permission, error)
SetPublicLinkPasswordOnSpaceRoot(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string, password string) (libregraph.Permission, error)
}
// DriveItemPermissionsService contains the production business logic for everything that relates to permissions on drive items.
type DriveItemPermissionsService struct {
BaseGraphService
}
type permissionType int
const (
Unknown permissionType = iota
Public
User
Space
OCM
)
type ListPermissionsQueryOptions struct {
Count bool
NoValues bool
NoLinkPermissions bool
FilterFederatedRoles bool
SelectedAttrs []string
}
// NewDriveItemPermissionsService creates a new DriveItemPermissionsService
func NewDriveItemPermissionsService(logger log.Logger, gatewaySelector pool.Selectable[gateway.GatewayAPIClient], identityCache cache.IdentityCache, config *config.Config) (DriveItemPermissionsService, error) {
publicBaseURL, err := url.Parse(config.Spaces.WebDavBase)
if err != nil {
return DriveItemPermissionsService{}, fmt.Errorf("could not parse graph.spaces.webdav_base: %w", err)
}
return DriveItemPermissionsService{
BaseGraphService: BaseGraphService{
logger: &log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemService").Logger()},
gatewaySelector: gatewaySelector,
identityCache: identityCache,
config: config,
availableRoles: unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(config.UnifiedRoles.AvailableRoles...)),
publicBaseURL: publicBaseURL,
},
}, nil
}
// Invite invites a user to a drive item.
func (s DriveItemPermissionsService) Invite(ctx context.Context, resourceId *storageprovider.ResourceId, invite libregraph.DriveItemInvite) (libregraph.Permission, error) {
tenantId := revactx.ContextMustGetUser(ctx).GetId().GetTenantId()
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return libregraph.Permission{}, err
}
statResponse, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: resourceId}})
if err := errorcode.FromStat(statResponse, err); err != nil {
s.logger.Warn().Err(err).Interface("stat.res", statResponse).Msg("stat failed")
return libregraph.Permission{}, err
}
var condition string
if condition, err = roleConditionForResourceType(statResponse.GetInfo()); err != nil {
return libregraph.Permission{}, err
}
unifiedRolePermissions := []*libregraph.UnifiedRolePermission{{AllowedResourceActions: invite.LibreGraphPermissionsActions}}
for _, roleID := range invite.GetRoles() {
// only allow roles that are enabled in the config
if !slices.Contains(s.config.UnifiedRoles.AvailableRoles, roleID) {
return libregraph.Permission{}, unifiedrole.ErrUnknownRole
}
role, err := unifiedrole.GetRole(unifiedrole.RoleFilterIDs(roleID))
if err != nil {
s.logger.Debug().Err(err).Interface("role", invite.GetRoles()[0]).Msg("unable to convert requested role")
return libregraph.Permission{}, err
}
allowedResourceActions := unifiedrole.GetAllowedResourceActions(role, condition)
if len(allowedResourceActions) == 0 && role.GetId() != unifiedrole.UnifiedRoleDeniedID {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "role not applicable to this resource")
}
unifiedRolePermissions = append(unifiedRolePermissions, conversions.ToPointerSlice(role.GetRolePermissions())...)
}
driveRecipient := invite.GetRecipients()[0]
objectID := driveRecipient.GetObjectId()
cs3ResourcePermissions := unifiedrole.PermissionsToCS3ResourcePermissions(unifiedRolePermissions)
permission := &libregraph.Permission{}
availableRoles := unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(s.config.UnifiedRoles.AvailableRoles...))
if role := unifiedrole.CS3ResourcePermissionsToRole(availableRoles, cs3ResourcePermissions, condition, false); role != nil {
permission.Roles = []string{role.GetId()}
}
if len(permission.GetRoles()) == 0 {
permission.LibreGraphPermissionsActions = unifiedrole.CS3ResourcePermissionsToLibregraphActions(cs3ResourcePermissions)
}
var shareid string
var expiration *types.Timestamp
var cTime *types.Timestamp
switch driveRecipient.GetLibreGraphRecipientType() {
case "group":
group, err := s.identityCache.GetGroup(ctx, objectID)
if err != nil {
s.logger.Debug().Err(err).Interface("groupId", objectID).Msg("failed group lookup")
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, err.Error())
}
permission.GrantedToV2 = &libregraph.SharePointIdentitySet{
Group: &libregraph.Identity{
DisplayName: group.GetDisplayName(),
Id: conversions.ToPointer(group.GetId()),
},
}
createShareRequest := createShareRequestToGroup(group, statResponse.GetInfo(), cs3ResourcePermissions)
if invite.ExpirationDateTime != nil {
createShareRequest.GetGrant().Expiration = utils.TimeToTS(*invite.ExpirationDateTime)
}
createShareResponse, err := gatewayClient.CreateShare(ctx, createShareRequest)
if err := errorcode.FromCS3Status(createShareResponse.GetStatus(), err); err != nil {
s.logger.Debug().Err(err).Msg("share creation failed")
return libregraph.Permission{}, err
}
shareid = createShareResponse.GetShare().GetId().GetOpaqueId()
cTime = createShareResponse.GetShare().GetCtime()
expiration = createShareResponse.GetShare().GetExpiration()
default:
user, err := s.identityCache.GetCS3User(ctx, tenantId, objectID)
if errors.Is(err, identity.ErrNotFound) && s.config.IncludeOCMSharees {
user, err = s.identityCache.GetAcceptedCS3User(ctx, objectID)
if err == nil && IsSpaceRoot(statResponse.GetInfo().GetId()) {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "federated user can not become a space member")
}
}
if err != nil {
s.logger.Debug().Err(err).Interface("userId", objectID).Msg("failed user lookup")
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, err.Error())
}
permission.GrantedToV2 = &libregraph.SharePointIdentitySet{
User: &libregraph.Identity{
DisplayName: user.GetDisplayName(),
Id: conversions.ToPointer(user.GetId().GetOpaqueId()),
LibreGraphUserType: conversions.ToPointer(identity.CS3UserTypeToGraph(user.GetId().GetType())),
},
}
if user.GetId().GetType() == userpb.UserType_USER_TYPE_FEDERATED {
providerInfoResp, err := gatewayClient.GetInfoByDomain(ctx, &ocmprovider.GetInfoByDomainRequest{
Domain: user.GetId().GetIdp(),
})
if err = errorcode.FromCS3Status(providerInfoResp.GetStatus(), err); err != nil {
s.logger.Error().Err(err).Msg("getting provider info failed")
return libregraph.Permission{}, err
}
createShareRequest := createShareRequestToFederatedUser(user, statResponse.GetInfo().GetId(), providerInfoResp.ProviderInfo, cs3ResourcePermissions)
if invite.ExpirationDateTime != nil {
createShareRequest.Expiration = utils.TimeToTS(*invite.ExpirationDateTime)
}
createShareResponse, err := gatewayClient.CreateOCMShare(ctx, createShareRequest)
if err = errorcode.FromCS3Status(createShareResponse.GetStatus(), err); err != nil {
s.logger.Error().Err(err).Msg("share creation failed")
return libregraph.Permission{}, err
}
shareid = createShareResponse.GetShare().GetId().GetOpaqueId()
cTime = createShareResponse.GetShare().GetCtime()
expiration = createShareResponse.GetShare().GetExpiration()
} else {
createShareRequest := createShareRequestToUser(user, statResponse.GetInfo(), cs3ResourcePermissions)
if invite.ExpirationDateTime != nil {
createShareRequest.GetGrant().Expiration = utils.TimeToTS(*invite.ExpirationDateTime)
}
createShareResponse, err := gatewayClient.CreateShare(ctx, createShareRequest)
if err = errorcode.FromCS3Status(createShareResponse.GetStatus(), err); err != nil {
s.logger.Error().Err(err).Msg("share creation failed")
return libregraph.Permission{}, err
}
shareid = createShareResponse.GetShare().GetId().GetOpaqueId()
cTime = createShareResponse.GetShare().GetCtime()
expiration = createShareResponse.GetShare().GetExpiration()
}
}
if shareid != "" {
permission.Id = conversions.ToPointer(shareid)
}
if expiration != nil {
permission.SetExpirationDateTime(utils.TSToTime(expiration))
}
// set cTime
if cTime != nil {
permission.SetCreatedDateTime(cs3TimestampToTime(cTime))
}
if user, ok := revactx.ContextGetUser(ctx); ok {
userIdentity, err := userIdToIdentity(ctx, s.identityCache, tenantId, user.GetId().GetOpaqueId())
if err != nil {
s.logger.Error().Err(err).Msg("identity lookup failed")
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, err.Error())
}
permission.SetInvitation(libregraph.SharingInvitation{
InvitedBy: &libregraph.IdentitySet{
User: &userIdentity,
},
})
}
return *permission, nil
}
func createShareRequestToGroup(group libregraph.Group, info *storageprovider.ResourceInfo, cs3ResourcePermissions *storageprovider.ResourcePermissions) *collaboration.CreateShareRequest {
return &collaboration.CreateShareRequest{
ResourceInfo: info,
Grant: &collaboration.ShareGrant{
Grantee: &storageprovider.Grantee{
Type: storageprovider.GranteeType_GRANTEE_TYPE_GROUP,
Id: &storageprovider.Grantee_GroupId{GroupId: &grouppb.GroupId{
OpaqueId: group.GetId(),
}},
},
Permissions: &collaboration.SharePermissions{
Permissions: cs3ResourcePermissions,
},
},
}
}
func createShareRequestToUser(user *userpb.User, info *storageprovider.ResourceInfo, cs3ResourcePermissions *storageprovider.ResourcePermissions) *collaboration.CreateShareRequest {
return &collaboration.CreateShareRequest{
ResourceInfo: info,
Grant: &collaboration.ShareGrant{
Grantee: &storageprovider.Grantee{
Type: storageprovider.GranteeType_GRANTEE_TYPE_USER,
Id: &storageprovider.Grantee_UserId{
UserId: user.GetId(),
},
},
Permissions: &collaboration.SharePermissions{
Permissions: cs3ResourcePermissions,
},
},
}
}
func createShareRequestToFederatedUser(user *userpb.User, resourceId *storageprovider.ResourceId, providerInfo *ocmprovider.ProviderInfo, cs3ResourcePermissions *storageprovider.ResourcePermissions) *ocm.CreateOCMShareRequest {
return &ocm.CreateOCMShareRequest{
ResourceId: resourceId,
Grantee: &storageprovider.Grantee{
Type: storageprovider.GranteeType_GRANTEE_TYPE_USER,
Id: &storageprovider.Grantee_UserId{
UserId: user.GetId(),
},
},
RecipientMeshProvider: providerInfo,
AccessMethods: []*ocm.AccessMethod{
{
Term: &ocm.AccessMethod_WebdavOptions{
WebdavOptions: &ocm.WebDAVAccessMethod{
Permissions: cs3ResourcePermissions,
},
},
},
},
}
}
// SpaceRootInvite handles invitation request on project spaces
func (s DriveItemPermissionsService) SpaceRootInvite(ctx context.Context, driveID *storageprovider.ResourceId, invite libregraph.DriveItemInvite) (libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return libregraph.Permission{}, err
}
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
if err != nil {
return libregraph.Permission{}, errorcode.FromUtilsStatusCodeError(err)
}
if space.SpaceType != _spaceTypeProject {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
}
rootResourceID := space.GetRoot()
return s.Invite(ctx, rootResourceID, invite)
}
// ListPermissions lists the permissions of a driveItem
func (s DriveItemPermissionsService) ListPermissions(ctx context.Context, itemID *storageprovider.ResourceId, queryOptions ListPermissionsQueryOptions) (libregraph.CollectionOfPermissionsWithAllowedValues, error) {
collectionOfPermissions := libregraph.CollectionOfPermissionsWithAllowedValues{}
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return collectionOfPermissions, err
}
statResponse, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: itemID}})
if err := errorcode.FromStat(statResponse, err); err != nil {
s.logger.Warn().Err(err).Interface("stat.res", statResponse).Msg("stat failed")
return collectionOfPermissions, err
}
var condition string
if condition, err = roleConditionForResourceType(statResponse.GetInfo()); err != nil {
return collectionOfPermissions, err
}
permissionSet := statResponse.GetInfo().GetPermissionSet()
allowedActions := unifiedrole.CS3ResourcePermissionsToLibregraphActions(permissionSet)
collectionOfPermissions = libregraph.CollectionOfPermissionsWithAllowedValues{}
if len(queryOptions.SelectedAttrs) == 0 || slices.Contains(queryOptions.SelectedAttrs, "@libre.graph.permissions.actions.allowedValues") {
collectionOfPermissions.LibreGraphPermissionsActionsAllowedValues = allowedActions
}
if len(queryOptions.SelectedAttrs) == 0 || slices.Contains(queryOptions.SelectedAttrs, "@libre.graph.permissions.roles.allowedValues") {
collectionOfPermissions.LibreGraphPermissionsRolesAllowedValues = conversions.ToValueSlice(
unifiedrole.GetRolesByPermissions(
unifiedrole.GetRoles(unifiedrole.RoleFilterIDs(s.config.UnifiedRoles.AvailableRoles...)),
allowedActions,
condition,
queryOptions.FilterFederatedRoles,
false,
),
)
}
for i, definition := range collectionOfPermissions.LibreGraphPermissionsRolesAllowedValues {
// the openapi spec defines that the rolePermissions should not be part of the response
definition.RolePermissions = nil
collectionOfPermissions.LibreGraphPermissionsRolesAllowedValues[i] = definition
}
if len(queryOptions.SelectedAttrs) > 0 {
// no need to fetch shares, we are only interested allowedActions and/or allowedRoles
return collectionOfPermissions, nil
}
driveItems := make(driveItemsByResourceID, 1)
// we can use the statResponse to build the drive item before fetching the shares
item, err := cs3ResourceToDriveItem(s.logger, s.publicBaseURL, statResponse.GetInfo())
if err != nil {
return collectionOfPermissions, err
}
driveItems[storagespace.FormatResourceID(statResponse.GetInfo().GetId())] = *item
var permissionsCount int
if IsSpaceRoot(statResponse.GetInfo().GetId()) {
driveItems, err = s.listSpaceRootUserShares(ctx, []*collaboration.Filter{
share.ResourceIDFilter(itemID),
}, driveItems)
if err != nil {
return collectionOfPermissions, err
}
} else {
// "normal" driveItem, populate user permissions via share providers
driveItems, err = s.listUserShares(ctx, []*collaboration.Filter{
share.ResourceIDFilter(itemID),
}, driveItems)
if err != nil {
return collectionOfPermissions, err
}
if s.config.IncludeOCMSharees {
driveItems, err = s.listOCMShares(ctx, []*ocm.ListOCMSharesRequest_Filter{
{
Type: ocm.ListOCMSharesRequest_Filter_TYPE_RESOURCE_ID,
Term: &ocm.ListOCMSharesRequest_Filter_ResourceId{ResourceId: itemID},
},
}, driveItems)
if err != nil {
return collectionOfPermissions, err
}
}
}
if !queryOptions.NoLinkPermissions {
// finally get public shares, which are possible for spaceroots and "normal" resources
driveItems, err = s.listPublicShares(ctx, []*link.ListPublicSharesRequest_Filter{
publicshare.ResourceIDFilter(itemID),
}, driveItems)
if err != nil {
return collectionOfPermissions, err
}
}
for _, driveItem := range driveItems {
permissionsCount += len(driveItem.Permissions)
if !queryOptions.NoValues {
collectionOfPermissions.Value = append(collectionOfPermissions.Value, driveItem.Permissions...)
}
}
if queryOptions.Count {
collectionOfPermissions.SetOdataCount(int32(permissionsCount))
}
return collectionOfPermissions, nil
}
// ListSpaceRootPermissions handles ListPermissions request on project spaces
func (s DriveItemPermissionsService) ListSpaceRootPermissions(ctx context.Context, driveID *storageprovider.ResourceId, queryOptions ListPermissionsQueryOptions) (libregraph.CollectionOfPermissionsWithAllowedValues, error) {
collectionOfPermissions := libregraph.CollectionOfPermissionsWithAllowedValues{}
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return collectionOfPermissions, err
}
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
if err != nil {
return collectionOfPermissions, errorcode.FromUtilsStatusCodeError(err)
}
isSupportedSpaceType := slices.Contains([]string{_spaceTypeProject, _spaceTypePersonal, _spaceTypeVirtual}, space.GetSpaceType())
if !isSupportedSpaceType {
return collectionOfPermissions, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
}
rootResourceID := space.GetRoot()
return s.ListPermissions(ctx, rootResourceID, queryOptions) // federated roles are not supported for spaces
}
// DeletePermission deletes a permission from a drive item
func (s DriveItemPermissionsService) DeletePermission(ctx context.Context, itemID *storageprovider.ResourceId, permissionID string) error {
var permissionType permissionType
sharedResourceID, err := s.getLinkPermissionResourceID(ctx, permissionID)
switch {
// Check if the ID is referring to a public share
case err == nil:
permissionType = Public
// If the item id is referring to a space root and this is not a public share
// we have to deal with space permissions
case IsSpaceRoot(itemID):
permissionType = Space
sharedResourceID = itemID
err = nil
// If this is neither a public share nor a space permission, check if this is a
// user share
default:
sharedResourceID, err = s.getUserPermissionResourceID(ctx, permissionID)
if err == nil {
permissionType = User
}
}
if sharedResourceID == nil && s.config.IncludeOCMSharees {
sharedResourceID, err = s.getOCMPermissionResourceID(ctx, permissionID)
if err == nil {
permissionType = OCM
}
}
switch {
case err != nil:
return err
case permissionType == Unknown:
return errorcode.New(errorcode.ItemNotFound, "permission not found")
case sharedResourceID == nil:
return errorcode.New(errorcode.ItemNotFound, "failed to resolve resource id for shared resource")
}
// The resourceID of the shared resource need to match the item ID from the Request Path
// otherwise this is an invalid Request.
if !utils.ResourceIDEqual(sharedResourceID, itemID) {
s.logger.Debug().Msg("resourceID of shared does not match itemID")
return errorcode.New(errorcode.InvalidRequest, "permissionID and itemID do not match")
}
switch permissionType {
case User, Space:
return s.removeUserShare(ctx, permissionID)
case Public:
return s.removePublicShare(ctx, permissionID)
case OCM:
return s.removeOCMPermission(ctx, permissionID)
default:
// This should never be reached
return errorcode.New(errorcode.GeneralException, "failed to delete permission")
}
}
// DeleteSpaceRootPermission deletes a permission on the root item of a project space
func (s DriveItemPermissionsService) DeleteSpaceRootPermission(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string) error {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return err
}
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
if err != nil {
return errorcode.FromUtilsStatusCodeError(err)
}
if space.SpaceType != _spaceTypeProject {
return errorcode.New(errorcode.InvalidRequest, "unsupported space type")
}
rootResourceID := space.GetRoot()
return s.DeletePermission(ctx, rootResourceID, permissionID)
}
// UpdatePermission updates a permission on a drive item
func (s DriveItemPermissionsService) UpdatePermission(ctx context.Context, itemID *storageprovider.ResourceId, permissionID string, newPermission libregraph.Permission) (libregraph.Permission, error) {
oldPermission, sharedResourceID, err := s.getPermissionByID(ctx, permissionID, itemID)
// try to get the permission from ocm if the permission was not found first place
if err != nil && s.config.IncludeOCMSharees {
oldPermission, sharedResourceID, err = s.getOCMPermissionByID(ctx, permissionID, itemID)
}
// if we still can't find the permission, return an error
if err != nil {
return libregraph.Permission{}, err
}
// The resourceID of the shared resource need to match the item ID from the Request Path
// otherwise this is an invalid Request.
if !utils.ResourceIDEqual(sharedResourceID, itemID) {
s.logger.Debug().Msg("resourceID of shared does not match itemID")
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "permissionID and itemID do not match")
}
// This is a public link
if _, ok := oldPermission.GetLinkOk(); ok {
updatedPermission, err := s.updatePublicLinkPermission(ctx, permissionID, itemID, &newPermission)
if err != nil {
return libregraph.Permission{}, err
}
return *updatedPermission, nil
}
// This is a user share
updatedPermission, err := s.updateUserShare(ctx, permissionID, sharedResourceID, &newPermission)
if err == nil && updatedPermission != nil {
return *updatedPermission, nil
}
// This is an ocm share
if s.config.IncludeOCMSharees {
updatePermission, err := s.updateOCMPermission(ctx, permissionID, itemID, &newPermission)
if err == nil {
return *updatePermission, nil
}
}
return libregraph.Permission{}, err
}
// UpdateSpaceRootPermission updates a permission on the root item of a project space
func (s DriveItemPermissionsService) UpdateSpaceRootPermission(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string, newPermission libregraph.Permission) (libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return libregraph.Permission{}, err
}
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
if err != nil {
return libregraph.Permission{}, errorcode.FromUtilsStatusCodeError(err)
}
if space.SpaceType != _spaceTypeProject {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
}
rootResourceID := space.GetRoot()
return s.UpdatePermission(ctx, rootResourceID, permissionID, newPermission)
}
// DriveItemPermissionsApi is the api that registers the http endpoints which expose needed operation to the graph api.
// the business logic is delegated to the permissions service and further down to the cs3 client.
type DriveItemPermissionsApi struct {
logger log.Logger
driveItemPermissionsService DriveItemPermissionsProvider
config *config.Config
}
// NewDriveItemPermissionsApi creates a new DriveItemPermissionsApi
func NewDriveItemPermissionsApi(driveItemPermissionService DriveItemPermissionsProvider, logger log.Logger, c *config.Config) (DriveItemPermissionsApi, error) {
return DriveItemPermissionsApi{
logger: log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemApi").Logger()},
driveItemPermissionsService: driveItemPermissionService,
config: c,
}, nil
}
// Invite handles DriveItemInvite requests
func (api DriveItemPermissionsApi) Invite(w http.ResponseWriter, r *http.Request) {
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(invalidIdMsg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, invalidIdMsg)
return
}
driveItemInvite := &libregraph.DriveItemInvite{}
if err = StrictJSONUnmarshal(r.Body, driveItemInvite); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
return
}
ctx := validate.ContextWithAllowedRoleIDs(r.Context(), api.config.UnifiedRoles.AvailableRoles)
if err = validate.StructCtx(ctx, driveItemInvite); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
permission, err := api.driveItemPermissionsService.Invite(ctx, itemID, *driveItemInvite)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: []any{permission}})
}
// SpaceRootInvite handles DriveItemInvite requests on a space root
func (api DriveItemPermissionsApi) SpaceRootInvite(w http.ResponseWriter, r *http.Request) {
driveID, err := parseIDParam(r, "driveID")
if err != nil {
api.logger.Debug().Err(err).Msg(parseDriveIDErrMsg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, parseDriveIDErrMsg)
return
}
driveItemInvite := &libregraph.DriveItemInvite{}
if err = StrictJSONUnmarshal(r.Body, driveItemInvite); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
return
}
ctx := validate.ContextWithAllowedRoleIDs(r.Context(), api.config.UnifiedRoles.AvailableRoles)
if err = validate.StructCtx(ctx, driveItemInvite); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
permission, err := api.driveItemPermissionsService.SpaceRootInvite(ctx, &driveID, *driveItemInvite)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: []any{permission}})
}
// ListPermissions handles ListPermissions requests
func (api DriveItemPermissionsApi) ListPermissions(w http.ResponseWriter, r *http.Request) {
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(invalidIdMsg)
errorcode.RenderError(w, r, err)
return
}
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
if err != nil {
api.logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("Error parsing ListPermissionRequest: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
var queryOptions ListPermissionsQueryOptions
queryOptions, err = api.getListPermissionsQueryOptions(odataReq)
if err != nil {
api.logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("Error parsing ListPermissionRequest query options")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
ctx := r.Context()
permissions, err := api.driveItemPermissionsService.ListPermissions(ctx, itemID, queryOptions)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
loc := r.Header.Get(l10n.HeaderAcceptLanguage)
w.Header().Add("Content-Language", loc)
if loc != "" && loc != "en" {
err := l10n_pkg.TranslateEntity(loc, "en", permissions,
l10n.TranslateEach("LibreGraphPermissionsRolesAllowedValues",
l10n.TranslateField("Description"),
l10n.TranslateField("DisplayName"),
),
)
if err != nil {
api.logger.Error().Err(err).Msg("tranlation error")
}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, permissions)
}
// ListSpaceRootPermissions handles ListPermissions requests on a space root
func (api DriveItemPermissionsApi) ListSpaceRootPermissions(w http.ResponseWriter, r *http.Request) {
driveID, err := parseIDParam(r, "driveID")
if err != nil {
api.logger.Debug().Err(err).Msg(parseDriveIDErrMsg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, parseDriveIDErrMsg)
return
}
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
if err != nil {
api.logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("Error parsing ListPermissionRequest: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
var queryOptions ListPermissionsQueryOptions
queryOptions, err = api.getListPermissionsQueryOptions(odataReq)
if err != nil {
api.logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("Error parsing ListPermissionRequest query options")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
ctx := r.Context()
permissions, err := api.driveItemPermissionsService.ListSpaceRootPermissions(ctx, &driveID, queryOptions)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
loc := r.Header.Get(l10n.HeaderAcceptLanguage)
w.Header().Add("Content-Language", loc)
if loc != "" && loc != "en" {
err := l10n_pkg.TranslateEntity(loc, "en", permissions,
l10n.TranslateEach("LibreGraphPermissionsRolesAllowedValues",
l10n.TranslateField("Description"),
l10n.TranslateField("DisplayName"),
),
)
if err != nil {
api.logger.Error().Err(err).Msg("tranlation error")
}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, permissions)
}
func (api DriveItemPermissionsApi) getListPermissionsQueryOptions(odataReq *godata.GoDataRequest) (ListPermissionsQueryOptions, error) {
queryOptions := ListPermissionsQueryOptions{}
if odataReq.Query.Filter != nil {
switch odataReq.Query.Filter.RawValue {
case federatedRolesODataFilter:
queryOptions.FilterFederatedRoles = true
case noLinksODataFilter:
queryOptions.NoLinkPermissions = true
default:
return ListPermissionsQueryOptions{}, errorcode.New(errorcode.InvalidRequest, "invalid filter value")
}
}
selectAttrs, err := odata.GetSelectValues(odataReq.Query)
if err != nil {
return ListPermissionsQueryOptions{}, err
}
queryOptions.SelectedAttrs = selectAttrs
if odataReq.Query.Count != nil {
queryOptions.Count = bool(*odataReq.Query.Count)
}
if odataReq.Query.Top != nil {
top := int(*odataReq.Query.Top)
switch {
case top != 0:
return ListPermissionsQueryOptions{}, err
case top == 0 && !queryOptions.Count:
return ListPermissionsQueryOptions{}, err
default:
queryOptions.NoValues = true
}
}
return queryOptions, nil
}
// DeletePermission handles DeletePermission requests
func (api DriveItemPermissionsApi) DeletePermission(w http.ResponseWriter, r *http.Request) {
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(invalidIdMsg)
errorcode.RenderError(w, r, err)
return
}
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
if err != nil {
api.logger.Debug().Err(err).Msg("could not parse permissionID")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
return
}
ctx := r.Context()
err = api.driveItemPermissionsService.DeletePermission(ctx, itemID, permissionID)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteSpaceRootPermission handles DeletePermission requests on a space root
func (api DriveItemPermissionsApi) DeleteSpaceRootPermission(w http.ResponseWriter, r *http.Request) {
driveID, err := parseIDParam(r, "driveID")
if err != nil {
api.logger.Debug().Err(err).Msg(parseDriveIDErrMsg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, parseDriveIDErrMsg)
return
}
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
if err != nil {
api.logger.Debug().Err(err).Msg("could not parse permissionID")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
return
}
ctx := r.Context()
err = api.driveItemPermissionsService.DeleteSpaceRootPermission(ctx, &driveID, permissionID)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// UpdatePermission handles UpdatePermission requests
func (api DriveItemPermissionsApi) UpdatePermission(w http.ResponseWriter, r *http.Request) {
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(invalidIdMsg)
errorcode.RenderError(w, r, err)
return
}
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
if err != nil {
api.logger.Debug().Err(err).Msg("could not parse permissionID")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
return
}
permission := libregraph.Permission{}
if err = StrictJSONUnmarshal(r.Body, &permission); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
return
}
ctx := r.Context()
if err = validate.StructCtx(ctx, permission); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
updatedPermission, err := api.driveItemPermissionsService.UpdatePermission(ctx, itemID, permissionID, permission)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &updatedPermission)
}
// UpdateSpaceRootPermission handles UpdatePermission requests on a space root
func (api DriveItemPermissionsApi) UpdateSpaceRootPermission(w http.ResponseWriter, r *http.Request) {
driveID, err := parseIDParam(r, "driveID")
if err != nil {
api.logger.Debug().Err(err).Msg(parseDriveIDErrMsg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, parseDriveIDErrMsg)
return
}
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
if err != nil {
api.logger.Debug().Err(err).Msg("could not parse permissionID")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
return
}
permission := libregraph.Permission{}
if err = StrictJSONUnmarshal(r.Body, &permission); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
return
}
ctx := r.Context()
if err = validate.StructCtx(ctx, permission); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
updatedPermission, err := api.driveItemPermissionsService.UpdateSpaceRootPermission(ctx, &driveID, permissionID, permission)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &updatedPermission)
}
@@ -0,0 +1,426 @@
package svc
import (
"context"
"net/http"
"net/url"
"strconv"
"time"
rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/linktype"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
func (s DriveItemPermissionsService) CreateLink(ctx context.Context, driveItemID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
s.logger.Error().Err(err).Msg("could not select next gateway client")
return libregraph.Permission{}, errorcode.New(errorcode.GeneralException, err.Error())
}
statResp, err := gatewayClient.Stat(
ctx,
&storageprovider.StatRequest{
Ref: &storageprovider.Reference{
ResourceId: driveItemID,
Path: ".",
},
})
if err != nil {
s.logger.Error().Err(err).Msg("transport error, could not stat resource")
return libregraph.Permission{}, errorcode.New(errorcode.GeneralException, err.Error())
}
if code := statResp.GetStatus().GetCode(); code != rpc.Code_CODE_OK {
s.logger.Debug().Interface("itemID", driveItemID).Msg(statResp.GetStatus().GetMessage())
return libregraph.Permission{}, errorcode.New(cs3StatusToErrCode(code), statResp.GetStatus().GetMessage())
}
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(createLink, statResp.GetInfo().GetType())
if err != nil {
s.logger.Debug().Interface("createLink", createLink).Msg(err.Error())
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "invalid link type")
}
if createLink.GetType() == libregraph.INTERNAL && len(createLink.GetPassword()) > 0 {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "password is redundant for the internal link")
}
req := link.CreatePublicShareRequest{
ResourceInfo: statResp.GetInfo(),
Grant: &link.Grant{
Permissions: &link.PublicSharePermissions{
Permissions: permissions,
},
Password: createLink.GetPassword(),
},
}
expirationDate, isSet := createLink.GetExpirationDateTimeOk()
if isSet {
expireTime := parseAndFillUpTime(expirationDate)
if expireTime == nil {
s.logger.Debug().Interface("createLink", createLink).Send()
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "invalid expiration date")
}
req.GetGrant().Expiration = expireTime
}
// set displayname and password protected as arbitrary metadata
req.ResourceInfo.ArbitraryMetadata = &storageprovider.ArbitraryMetadata{
Metadata: map[string]string{
"name": createLink.GetDisplayName(),
"quicklink": strconv.FormatBool(createLink.GetLibreGraphQuickLink()),
},
}
createResp, err := gatewayClient.CreatePublicShare(ctx, &req)
if err != nil {
s.logger.Error().Err(err).Msg("transport error, could not create link")
return libregraph.Permission{}, errorcode.New(errorcode.GeneralException, err.Error())
}
if statusCode := createResp.GetStatus().GetCode(); statusCode != rpc.Code_CODE_OK {
return libregraph.Permission{}, errorcode.New(cs3StatusToErrCode(statusCode), createResp.Status.Message)
}
link := createResp.GetShare()
perm, err := s.libreGraphPermissionFromCS3PublicShare(link)
if err != nil {
return libregraph.Permission{}, errorcode.New(errorcode.GeneralException, err.Error())
}
return *perm, nil
}
func (s DriveItemPermissionsService) CreateSpaceRootLink(ctx context.Context, driveID *storageprovider.ResourceId, createLink libregraph.DriveItemCreateLink) (libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return libregraph.Permission{}, err
}
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
if err != nil {
return libregraph.Permission{}, errorcode.FromUtilsStatusCodeError(err)
}
if space.SpaceType != _spaceTypeProject {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
}
rootResourceID := space.GetRoot()
return s.CreateLink(ctx, rootResourceID, createLink)
}
func (s DriveItemPermissionsService) SetPublicLinkPassword(ctx context.Context, driveItemId *storageprovider.ResourceId, permissionID string, password string) (libregraph.Permission, error) {
publicShare, err := s.getCS3PublicShareByID(ctx, permissionID)
if err != nil {
return libregraph.Permission{}, err
}
// The resourceID of the shared resource need to match the item ID from the Request Path
// otherwise this is an invalid Request.
if !utils.ResourceIDEqual(publicShare.GetResourceId(), driveItemId) {
s.logger.Debug().Msg("resourceID of shared does not match itemID")
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "permissionID and itemID do not match")
}
permission, err := s.updatePublicLinkPassword(ctx, permissionID, password)
if err != nil {
return libregraph.Permission{}, err
}
return *permission, nil
}
func (s DriveItemPermissionsService) SetPublicLinkPasswordOnSpaceRoot(ctx context.Context, driveID *storageprovider.ResourceId, permissionID string, password string) (libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return libregraph.Permission{}, err
}
space, err := utils.GetSpace(ctx, storagespace.FormatResourceID(driveID), gatewayClient)
if err != nil {
return libregraph.Permission{}, errorcode.FromUtilsStatusCodeError(err)
}
if space.SpaceType != _spaceTypeProject {
return libregraph.Permission{}, errorcode.New(errorcode.InvalidRequest, "unsupported space type")
}
rootResourceID := space.GetRoot()
return s.SetPublicLinkPassword(ctx, rootResourceID, permissionID, password)
}
// CreateLink creates a public link on the cs3 api
func (api DriveItemPermissionsApi) CreateLink(w http.ResponseWriter, r *http.Request) {
logger := api.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling create link")
_, driveItemID, err := GetDriveAndItemIDParam(r, &logger)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
var createLink libregraph.DriveItemCreateLink
if err = StrictJSONUnmarshal(r.Body, &createLink); err != nil {
logger.Error().Err(err).Interface("body", r.Body).Msg("could not create link: invalid body schema definition")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition")
return
}
perm, err := api.driveItemPermissionsService.CreateLink(r.Context(), driveItemID, createLink)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, perm)
}
func (api DriveItemPermissionsApi) CreateSpaceRootLink(w http.ResponseWriter, r *http.Request) {
logger := api.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling create link")
driveID, err := parseIDParam(r, "driveID")
if err != nil {
msg := "could not parse driveID"
api.logger.Debug().Err(err).Msg(msg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, msg)
return
}
var createLink libregraph.DriveItemCreateLink
if err = StrictJSONUnmarshal(r.Body, &createLink); err != nil {
logger.Error().Err(err).Interface("body", r.Body).Msg("could not create link: invalid body schema definition")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid body schema definition")
return
}
perm, err := api.driveItemPermissionsService.CreateSpaceRootLink(r.Context(), &driveID, createLink)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, perm)
}
// SetLinkPassword sets public link password on the cs3 api
func (api DriveItemPermissionsApi) SetLinkPassword(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
_, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
errorcode.RenderError(w, r, err)
return
}
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
if err != nil {
api.logger.Debug().Err(err).Msg("could not parse permissionID")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
return
}
password := &libregraph.SharingLinkPassword{}
if err = StrictJSONUnmarshal(r.Body, password); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
return
}
newPermission, err := api.driveItemPermissionsService.SetPublicLinkPassword(ctx, itemID, permissionID, password.GetPassword())
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, newPermission)
}
func (api DriveItemPermissionsApi) SetSpaceRootLinkPassword(w http.ResponseWriter, r *http.Request) {
driveID, err := parseIDParam(r, "driveID")
if err != nil {
msg := "could not parse driveID"
api.logger.Debug().Err(err).Msg(msg)
errorcode.InvalidRequest.Render(w, r, http.StatusUnprocessableEntity, msg)
return
}
permissionID, err := url.PathUnescape(chi.URLParam(r, "permissionID"))
if err != nil {
api.logger.Debug().Err(err).Msg("could not parse permissionID")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid permissionID")
return
}
password := &libregraph.SharingLinkPassword{}
if err = StrictJSONUnmarshal(r.Body, password); err != nil {
api.logger.Debug().Err(err).Interface("Body", r.Body).Msg("failed unmarshalling request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid request body")
return
}
ctx := r.Context()
newPermission, err := api.driveItemPermissionsService.SetPublicLinkPasswordOnSpaceRoot(ctx, &driveID, permissionID, password.GetPassword())
if err != nil {
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, newPermission)
}
func (s DriveItemPermissionsService) updatePublicLinkPermission(ctx context.Context, permissionID string, itemID *storageprovider.ResourceId, newPermission *libregraph.Permission) (perm *libregraph.Permission, err error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
s.logger.Error().Err(err).Msg("could not select next gateway client")
return nil, errorcode.New(errorcode.GeneralException, err.Error())
}
statResp, err := gatewayClient.Stat(
ctx,
&storageprovider.StatRequest{
Ref: &storageprovider.Reference{
ResourceId: itemID,
Path: ".",
},
})
if err := errorcode.FromCS3Status(statResp.GetStatus(), err); err != nil {
return nil, err
}
if newPermission.HasExpirationDateTime() {
expirationDate := newPermission.GetExpirationDateTime()
update := &link.UpdatePublicShareRequest_Update{
Type: link.UpdatePublicShareRequest_Update_TYPE_EXPIRATION,
Grant: &link.Grant{Expiration: parseAndFillUpTime(&expirationDate)},
}
perm, err = s.updatePublicLink(ctx, permissionID, update)
if err != nil {
return nil, err
}
}
if newPermission.HasLink() && newPermission.Link.HasLibreGraphDisplayName() {
changedLink := newPermission.GetLink()
update := &link.UpdatePublicShareRequest_Update{
Type: link.UpdatePublicShareRequest_Update_TYPE_DISPLAYNAME,
DisplayName: changedLink.GetLibreGraphDisplayName(),
}
perm, err = s.updatePublicLink(ctx, permissionID, update)
if err != nil {
return nil, err
}
}
if newPermission.HasLink() && newPermission.Link.HasType() {
changedLink := newPermission.Link.GetType()
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(
libregraph.DriveItemCreateLink{
Type: &changedLink,
},
statResp.GetInfo().GetType(),
)
if err != nil {
return nil, err
}
update := &link.UpdatePublicShareRequest_Update{
Type: link.UpdatePublicShareRequest_Update_TYPE_PERMISSIONS,
Grant: &link.Grant{
Permissions: &link.PublicSharePermissions{Permissions: permissions},
},
}
perm, err = s.updatePublicLink(ctx, permissionID, update)
if err != nil {
return nil, err
}
// reset the password for the internal link
if changedLink == libregraph.INTERNAL {
perm, err = s.updatePublicLinkPassword(ctx, permissionID, "")
if err != nil {
return nil, err
}
}
}
return perm, err
}
func (s DriveItemPermissionsService) updatePublicLinkPassword(ctx context.Context, permissionID string, password string) (*libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
changeLinkRes, err := gatewayClient.UpdatePublicShare(ctx, &link.UpdatePublicShareRequest{
Update: &link.UpdatePublicShareRequest_Update{
Type: link.UpdatePublicShareRequest_Update_TYPE_PASSWORD,
Grant: &link.Grant{
Password: password,
},
},
Ref: &link.PublicShareReference{
Spec: &link.PublicShareReference_Id{
Id: &link.PublicShareId{
OpaqueId: permissionID,
},
},
},
})
if err := errorcode.FromCS3Status(changeLinkRes.GetStatus(), err); err != nil {
return nil, err
}
permission, err := s.libreGraphPermissionFromCS3PublicShare(changeLinkRes.GetShare())
if err != nil {
return nil, err
}
return permission, nil
}
func (s DriveItemPermissionsService) updatePublicLink(ctx context.Context, permissionID string, update *link.UpdatePublicShareRequest_Update) (*libregraph.Permission, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
changeLinkRes, err := gatewayClient.UpdatePublicShare(ctx, &link.UpdatePublicShareRequest{
Update: update,
Ref: &link.PublicShareReference{
Spec: &link.PublicShareReference_Id{
Id: &link.PublicShareId{
OpaqueId: permissionID,
},
},
},
})
if err := errorcode.FromCS3Status(changeLinkRes.GetStatus(), err); err != nil {
return nil, err
}
permission, err := s.libreGraphPermissionFromCS3PublicShare(changeLinkRes.GetShare())
if err != nil {
return nil, err
}
return permission, nil
}
func parseAndFillUpTime(t *time.Time) *types.Timestamp {
if t == nil || t.IsZero() {
return nil
}
// the link needs to be valid for the whole day
tLink := time.Date(t.Year(), t.Month(), t.Day(), 0, 0, 0, 0, t.Location())
tLink = tLink.Add(23*time.Hour + 59*time.Minute + 59*time.Second)
final := tLink.UnixNano()
return &types.Timestamp{
Seconds: uint64(final / 1000000000),
Nanos: uint32(final % 1000000000),
}
}
@@ -0,0 +1,258 @@
package svc_test
import (
"context"
"errors"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
link "github.com/cs3org/go-cs3apis/cs3/sharing/link/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/identity/cache"
"github.com/qsfera/server/services/graph/pkg/linktype"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/utils"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
)
var _ = Describe("createLinkTests", func() {
var (
svc service.DriveItemPermissionsService
driveItemId *provider.ResourceId
ctx context.Context
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector *mocks.Selectable[gateway.GatewayAPIClient]
currentUser = &userpb.User{
Id: &userpb.UserId{
OpaqueId: "user",
},
}
)
const (
ViewerLinkString = "Viewer Link"
)
BeforeEach(func() {
var err error
logger := log.NewLogger()
gatewayClient = cs3mocks.NewGatewayAPIClient(GinkgoT())
gatewaySelector = mocks.NewSelectable[gateway.GatewayAPIClient](GinkgoT())
gatewaySelector.On("Next").Return(gatewayClient, nil)
cache := cache.NewIdentityCache(cache.IdentityCacheWithGatewaySelector(gatewaySelector))
cfg := defaults.FullDefaultConfig()
svc, err = service.NewDriveItemPermissionsService(logger, gatewaySelector, cache, cfg)
Expect(err).ToNot(HaveOccurred())
driveItemId = &provider.ResourceId{
StorageId: "1",
SpaceId: "2",
OpaqueId: "3",
}
ctx = revactx.ContextSetUser(context.Background(), currentUser)
})
Describe("CreateLink", func() {
var (
driveItemCreateLink libregraph.DriveItemCreateLink
statResponse *provider.StatResponse
createLinkResponse *link.CreatePublicShareResponse
)
BeforeEach(func() {
driveItemCreateLink = libregraph.DriveItemCreateLink{
Type: nil,
ExpirationDateTime: nil,
Password: nil,
DisplayName: nil,
LibreGraphQuickLink: nil,
}
statResponse = &provider.StatResponse{
Status: status.NewOK(ctx),
Info: &provider.ResourceInfo{Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER},
}
createLinkResponse = &link.CreatePublicShareResponse{
Status: status.NewOK(ctx),
}
linkType, err := libregraph.NewSharingLinkTypeFromValue("view")
Expect(err).ToNot(HaveOccurred())
driveItemCreateLink.Type = linkType
driveItemCreateLink.ExpirationDateTime = libregraph.PtrTime(time.Now().Add(time.Hour))
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(driveItemCreateLink, provider.ResourceType_RESOURCE_TYPE_CONTAINER)
Expect(err).ToNot(HaveOccurred())
createLinkResponse.Share = &link.PublicShare{
Id: &link.PublicShareId{OpaqueId: "123"},
Expiration: utils.TimeToTS(*driveItemCreateLink.ExpirationDateTime),
PasswordProtected: false,
DisplayName: ViewerLinkString,
Token: "SomeGOODCoffee",
Permissions: &link.PublicSharePermissions{Permissions: permissions},
}
})
// Public Shares / "links" in graph terms
It("creates a public link as expected (happy path)", func() {
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
gatewayClient.On("CreatePublicShare", mock.Anything, mock.Anything).Return(createLinkResponse, nil)
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
Expect(err).ToNot(HaveOccurred())
Expect(perm.GetId()).To(Equal("123"))
Expect(perm.GetExpirationDateTime().Unix()).To(Equal(driveItemCreateLink.ExpirationDateTime.Unix()))
Expect(perm.GetHasPassword()).To(Equal(false))
Expect(perm.GetLink().LibreGraphDisplayName).To(Equal(libregraph.PtrString(ViewerLinkString)))
link := perm.GetLink()
respLinkType := link.GetType()
expected, err := libregraph.NewSharingLinkTypeFromValue("view")
Expect(err).ToNot(HaveOccurred())
Expect(&respLinkType).To(Equal(expected))
})
It("handles a failing CreateLink", func() {
statResponse.Info = &provider.ResourceInfo{Type: provider.ResourceType_RESOURCE_TYPE_FILE}
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
gatewayClient.On("CreatePublicShare", mock.Anything, mock.Anything).Return(createLinkResponse, nil)
linkType, err := libregraph.NewSharingLinkTypeFromValue("edit")
Expect(err).ToNot(HaveOccurred())
driveItemCreateLink.Type = linkType
permissions, err := linktype.CS3ResourcePermissionsFromSharingLink(driveItemCreateLink, provider.ResourceType_RESOURCE_TYPE_CONTAINER)
Expect(err).ToNot(HaveOccurred())
createLinkResponse.Status = status.NewInternal(ctx, "transport error")
createLinkResponse.Share = &link.PublicShare{
Id: &link.PublicShareId{OpaqueId: "123"},
Permissions: &link.PublicSharePermissions{Permissions: permissions},
}
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
Expect(err).To(MatchError(errorcode.New(errorcode.GeneralException, "transport error")))
Expect(perm).To(BeZero())
})
It("fails when the stat returns access denied", func() {
err := errors.New("no permission to stat the file")
statResponse.Status = status.NewPermissionDenied(ctx, err, err.Error())
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
Expect(err).To(MatchError(errorcode.New(errorcode.AccessDenied, "no permission to stat the file")))
Expect(perm).To(BeZero())
})
It("fails when the stat returns resource is locked", func() {
err := errors.New("the resource is locked")
statResponse.Status = status.NewLocked(ctx, err.Error())
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
Expect(err).To(MatchError(errorcode.New(errorcode.ItemIsLocked, "the resource is locked")))
Expect(perm).To(BeZero())
})
It("succeeds when the link type mapping is not successful", func() {
// we need to send a valid link type
linkType, err := libregraph.NewSharingLinkTypeFromValue("edit")
Expect(err).ToNot(HaveOccurred())
driveItemCreateLink.Type = linkType
permissions := &provider.ResourcePermissions{
CreateContainer: true,
InitiateFileUpload: true,
Move: true,
}
// return different permissions which do not match a link type
createLinkResponse.Share = &link.PublicShare{
Id: &link.PublicShareId{OpaqueId: "123"},
Expiration: utils.TimeToTS(*driveItemCreateLink.ExpirationDateTime),
PasswordProtected: false,
DisplayName: ViewerLinkString,
Token: "SomeGOODCoffee",
Permissions: &link.PublicSharePermissions{Permissions: permissions},
}
gatewayClient.On("Stat", mock.Anything, mock.Anything).Return(statResponse, nil)
gatewayClient.On("CreatePublicShare", mock.Anything, mock.Anything).Return(createLinkResponse, nil)
perm, err := svc.CreateLink(context.Background(), driveItemId, driveItemCreateLink)
Expect(err).ToNot(HaveOccurred())
Expect(perm.GetId()).To(Equal("123"))
Expect(perm.GetExpirationDateTime().Unix()).To(Equal(driveItemCreateLink.ExpirationDateTime.Unix()))
Expect(perm.GetHasPassword()).To(Equal(false))
Expect(perm.GetLink().LibreGraphDisplayName).To(Equal(libregraph.PtrString(ViewerLinkString)))
respLink := perm.GetLink()
// some conversion gymnastics
respLinkType := respLink.GetType()
Expect(err).ToNot(HaveOccurred())
mockLink := libregraph.SharingLink{}
lt, _ := linktype.SharingLinkTypeFromCS3Permissions(&link.PublicSharePermissions{Permissions: permissions})
mockLink.Type = lt
expectedType := mockLink.GetType()
Expect(&respLinkType).To(Equal(&expectedType))
libreGraphActions := perm.LibreGraphPermissionsActions
Expect(libreGraphActions[0]).To(Equal("libre.graph/driveItem/children/create"))
Expect(libreGraphActions[1]).To(Equal("libre.graph/driveItem/upload/create"))
Expect(libreGraphActions[2]).To(Equal("libre.graph/driveItem/path/update"))
})
})
Describe("SetLinPassword", func() {
var (
updatePublicShareMockResponse link.UpdatePublicShareResponse
getPublicShareResponse link.GetPublicShareResponse
)
const TestLinkName = "Test Link"
BeforeEach(func() {
updatePublicShareMockResponse = link.UpdatePublicShareResponse{
Status: status.NewOK(ctx),
Share: &link.PublicShare{DisplayName: TestLinkName},
}
getPublicShareResponse = link.GetPublicShareResponse{
Status: status.NewOK(ctx),
Share: &link.PublicShare{
Id: &link.PublicShareId{
OpaqueId: "permissionid",
},
ResourceId: driveItemId,
Permissions: &link.PublicSharePermissions{
Permissions: linktype.NewViewLinkPermissionSet().GetPermissions(),
},
Token: "token",
},
}
})
It("updates the password on a public share", func() {
gatewayClient.On("GetPublicShare", mock.Anything, mock.Anything).Return(&getPublicShareResponse, nil)
updatePublicShareMockResponse.Share.Permissions = &link.PublicSharePermissions{
Permissions: linktype.NewViewLinkPermissionSet().Permissions,
}
updatePublicShareMockResponse.Share.PasswordProtected = true
gatewayClient.On("UpdatePublicShare",
mock.Anything,
mock.MatchedBy(func(req *link.UpdatePublicShareRequest) bool {
return req.GetRef().GetId().GetOpaqueId() == "permissionid"
}),
).Return(&updatePublicShareMockResponse, nil)
perm, err := svc.SetPublicLinkPassword(context.Background(), driveItemId, "permissionid", "OC123!")
Expect(err).ToNot(HaveOccurred())
linkType := perm.Link.GetType()
Expect(string(linkType)).To(Equal("view"))
Expect(perm.GetHasPassword()).To(BeTrue())
})
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,575 @@
package svc
import (
"context"
"errors"
"net/http"
"path/filepath"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
collaboration "github.com/cs3org/go-cs3apis/cs3/sharing/collaboration/v1beta1"
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"google.golang.org/protobuf/types/known/fieldmaskpb"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
const (
_fieldMaskPathState = "state"
_fieldMaskPathMountPoint = "mount_point"
_fieldMaskPathHidden = "hidden"
)
var (
// ErrNoUpdates is returned when no updates are provided
ErrNoUpdates = errors.New("no updates")
// ErrNoUpdater is returned when no updater is provided
ErrNoUpdater = errors.New("no updater")
// ErrAbsoluteNamePath is returned when the name is an absolute path
ErrAbsoluteNamePath = errors.New("name cannot be an absolute path")
// ErrCode errors
// ErrNotAShareJail is returned when the driveID does not belong to a share jail
ErrNotAShareJail = errorcode.New(errorcode.InvalidRequest, "id does not belong to a share jail")
// ErrInvalidDriveIDOrItemID is returned when the driveID or itemID is invalid
ErrInvalidDriveIDOrItemID = errorcode.New(errorcode.InvalidRequest, "invalid driveID or itemID")
// ErrInvalidRequestBody is returned when the request body is invalid
ErrInvalidRequestBody = errorcode.New(errorcode.InvalidRequest, "invalid request body")
// ErrUnmountShare is returned when unmounting a share fails
ErrUnmountShare = errorcode.New(errorcode.InvalidRequest, "unmounting share failed")
// ErrMountShare is returned when mounting a share fails
ErrMountShare = errorcode.New(errorcode.InvalidRequest, "mounting share failed")
// ErrUpdateShares is returned when updating shares fails
ErrUpdateShares = errorcode.New(errorcode.InvalidRequest, "failed to update share")
// ErrInvalidID is returned when the id is invalid
ErrInvalidID = errorcode.New(errorcode.InvalidRequest, "invalid id")
// ErrDriveItemConversion is returned when converting to drive items fails
ErrDriveItemConversion = errorcode.New(errorcode.InvalidRequest, "converting to drive items failed")
// ErrNoShares is returned when no shares are found
ErrNoShares = errorcode.New(errorcode.ItemNotFound, "no shares found")
// ErrAlreadyMounted is returned when all shares are already mounted
ErrAlreadyMounted = errorcode.New(errorcode.NameAlreadyExists, "shares already mounted")
// ErrAlreadyUnmounted is returned when all shares are already unmounted
ErrAlreadyUnmounted = errorcode.New(errorcode.NameAlreadyExists, "shares already unmounted")
)
type (
// UpdateShareClosure is a closure that injects required updates into the update request
UpdateShareClosure func(share *collaboration.ReceivedShare, request *collaboration.UpdateReceivedShareRequest)
// DrivesDriveItemProvider is the interface that needs to be implemented by the individual space service
DrivesDriveItemProvider interface {
// MountShare mounts a share
MountShare(ctx context.Context, resourceID *storageprovider.ResourceId, name string) ([]*collaboration.ReceivedShare, error)
// MountOCMShare mounts an OCM share
MountOCMShare(ctx context.Context, resourceID *storageprovider.ResourceId /*, name string*/) ([]*ocm.ReceivedShare, error)
// UnmountShare unmounts a share
UnmountShare(ctx context.Context, shareID *collaboration.ShareId) error
// UpdateShares updates multiple shares
UpdateShares(ctx context.Context, shares []*collaboration.ReceivedShare, updater UpdateShareClosure) ([]*collaboration.ReceivedShare, error)
// GetShare returns the share
GetShare(ctx context.Context, shareID *collaboration.ShareId) (*collaboration.ReceivedShare, error)
// GetSharesForResource returns all shares for a given resourceID
GetSharesForResource(ctx context.Context, resourceID *storageprovider.ResourceId, filters []*collaboration.Filter) ([]*collaboration.ReceivedShare, error)
}
)
// DrivesDriveItemService contains the production business logic for everything that relates to drives
type DrivesDriveItemService struct {
logger log.Logger
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
}
// NewDrivesDriveItemService creates a new DrivesDriveItemService
func NewDrivesDriveItemService(logger log.Logger, gatewaySelector pool.Selectable[gateway.GatewayAPIClient]) (DrivesDriveItemService, error) {
return DrivesDriveItemService{
logger: log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemService").Logger()},
gatewaySelector: gatewaySelector,
}, nil
}
func (s DrivesDriveItemService) GetShare(ctx context.Context, shareID *collaboration.ShareId) (*collaboration.ReceivedShare, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
// Now, find out the resourceID of the shared resource
getReceivedShareResponse, err := gatewayClient.GetReceivedShare(ctx,
&collaboration.GetReceivedShareRequest{
Ref: &collaboration.ShareReference{
Spec: &collaboration.ShareReference_Id{
Id: shareID,
},
},
},
)
return getReceivedShareResponse.GetShare(), errorcode.FromCS3Status(getReceivedShareResponse.GetStatus(), err)
}
// GetSharesForResource returns all shares for a given resourceID
func (s DrivesDriveItemService) GetSharesForResource(ctx context.Context, resourceID *storageprovider.ResourceId, filters []*collaboration.Filter) ([]*collaboration.ReceivedShare, error) {
// Find all accepted shares for this resource
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
receivedSharesResponse, err := gatewayClient.ListReceivedShares(ctx, &collaboration.ListReceivedSharesRequest{
Filters: append([]*collaboration.Filter{
{
Type: collaboration.Filter_TYPE_RESOURCE_ID,
Term: &collaboration.Filter_ResourceId{
ResourceId: resourceID,
},
},
}, filters...),
})
switch {
case err != nil:
return nil, err
case len(receivedSharesResponse.GetShares()) == 0:
return nil, ErrNoShares
default:
return receivedSharesResponse.GetShares(), errorcode.FromCS3Status(receivedSharesResponse.GetStatus(), err)
}
}
// UpdateShares updates multiple shares;
// it could happen that some shares are updated and some are not,
// this will return a list of updated shares and a list of errors;
// there is no guarantee that all updates are successful
func (s DrivesDriveItemService) UpdateShares(ctx context.Context, shares []*collaboration.ReceivedShare, updater UpdateShareClosure) ([]*collaboration.ReceivedShare, error) {
errs := make([]error, 0, len(shares))
updatedShares := make([]*collaboration.ReceivedShare, 0, len(shares))
for _, share := range shares {
updatedShare, err := s.UpdateShare(
ctx,
share,
updater,
)
if err != nil {
errs = append(errs, err)
continue
}
updatedShares = append(updatedShares, updatedShare)
}
return updatedShares, errors.Join(errs...)
}
// UpdateShare updates a single share
func (s DrivesDriveItemService) UpdateShare(ctx context.Context, share *collaboration.ReceivedShare, updater UpdateShareClosure) (*collaboration.ReceivedShare, error) {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
updateReceivedShareRequest := &collaboration.UpdateReceivedShareRequest{
Share: &collaboration.ReceivedShare{
Share: &collaboration.Share{
Id: share.GetShare().GetId(),
},
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{}},
}
switch updater {
case nil:
return nil, ErrNoUpdater
default:
updater(share, updateReceivedShareRequest)
}
if len(updateReceivedShareRequest.GetUpdateMask().GetPaths()) == 0 {
return nil, ErrNoUpdates
}
updateReceivedShareResponse, err := gatewayClient.UpdateReceivedShare(ctx, updateReceivedShareRequest)
return updateReceivedShareResponse.GetShare(), errorcode.FromCS3Status(updateReceivedShareResponse.GetStatus(), err)
}
// UnmountShare unmounts a share
func (s DrivesDriveItemService) UnmountShare(ctx context.Context, shareID *collaboration.ShareId) error {
share, err := s.GetShare(ctx, shareID)
if err != nil {
return err
}
shares, err := s.GetSharesForResource(ctx, share.GetShare().GetResourceId(), []*collaboration.Filter{
{
Type: collaboration.Filter_TYPE_STATE,
Term: &collaboration.Filter_State{
State: collaboration.ShareState_SHARE_STATE_ACCEPTED,
},
},
{
Type: collaboration.Filter_TYPE_STATE,
Term: &collaboration.Filter_State{
State: collaboration.ShareState_SHARE_STATE_REJECTED,
},
},
})
if err != nil {
return err
}
availableShares := make([]*collaboration.ReceivedShare, 0, 1)
rejectedShares := make([]*collaboration.ReceivedShare, 0, 1)
for _, v := range shares {
switch v.GetState() {
case collaboration.ShareState_SHARE_STATE_ACCEPTED:
availableShares = append(availableShares, v)
case collaboration.ShareState_SHARE_STATE_REJECTED:
rejectedShares = append(rejectedShares, v)
}
}
if len(availableShares) == 0 {
if len(rejectedShares) > 0 {
return ErrAlreadyUnmounted
}
return ErrNoShares
}
_, err = s.UpdateShares(ctx, availableShares, func(_ *collaboration.ReceivedShare, request *collaboration.UpdateReceivedShareRequest) {
request.Share.State = collaboration.ShareState_SHARE_STATE_REJECTED
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathState)
})
return err
}
// MountShare mounts a share, there is no guarantee that all siblings will be mounted
// in some rare cases it could happen that none of the siblings could be mounted,
// then the error will be returned
func (s DrivesDriveItemService) MountShare(ctx context.Context, resourceID *storageprovider.ResourceId, name string) ([]*collaboration.ReceivedShare, error) {
if filepath.IsAbs(name) {
return nil, ErrAbsoluteNamePath
}
if name != "" {
name = filepath.Clean(name)
}
shares, err := s.GetSharesForResource(ctx, resourceID, nil)
if err != nil {
return nil, err
}
availableShares := make([]*collaboration.ReceivedShare, 0, len(shares))
mountedShares := make([]*collaboration.ReceivedShare, 0, 1)
for _, v := range shares {
switch v.GetState() {
case collaboration.ShareState_SHARE_STATE_ACCEPTED:
mountedShares = append(mountedShares, v)
case collaboration.ShareState_SHARE_STATE_PENDING, collaboration.ShareState_SHARE_STATE_REJECTED:
availableShares = append(availableShares, v)
}
}
if len(availableShares) == 0 {
if len(mountedShares) > 0 {
return nil, ErrAlreadyMounted
}
return nil, ErrNoShares
}
updatedShares, err := s.UpdateShares(ctx, availableShares, func(share *collaboration.ReceivedShare, request *collaboration.UpdateReceivedShareRequest) {
request.Share.State = collaboration.ShareState_SHARE_STATE_ACCEPTED
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathState)
// only update if mountPoint name is not empty and the path has changed
if name != "" {
mountPoint := share.GetMountPoint()
if mountPoint == nil {
mountPoint = &storageprovider.Reference{}
}
if filepath.Clean(mountPoint.GetPath()) != name {
mountPoint.Path = name
request.Share.MountPoint = mountPoint
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathMountPoint)
}
}
})
errs, ok := err.(interface{ Unwrap() []error })
if ok && len(errs.Unwrap()) == len(availableShares) {
// none of the received shares could be accepted.
// this is an error, return it.
return nil, err
}
return updatedShares, nil
}
// DrivesDriveItemApi is the api that registers the http endpoints which expose needed operation to the graph api.
// the business logic is delegated to the space service and further down to the cs3 client.
type DrivesDriveItemApi struct {
logger log.Logger
drivesDriveItemService DrivesDriveItemProvider
baseGraphService BaseGraphProvider
}
// NewDrivesDriveItemApi creates a new DrivesDriveItemApi
func NewDrivesDriveItemApi(drivesDriveItemService DrivesDriveItemProvider, baseGraphService BaseGraphProvider, logger log.Logger) (DrivesDriveItemApi, error) {
return DrivesDriveItemApi{
logger: log.Logger{Logger: logger.With().Str("graph api", "DrivesDriveItemApi").Logger()},
drivesDriveItemService: drivesDriveItemService,
baseGraphService: baseGraphService,
}, nil
}
// DeleteDriveItem deletes a drive item
func (api DrivesDriveItemApi) DeleteDriveItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
driveID, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
ErrInvalidDriveIDOrItemID.Render(w, r)
return
}
if !IsShareJail(driveID) {
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
ErrNotAShareJail.Render(w, r)
return
}
shareID := ExtractShareIdFromResourceId(itemID)
if err := api.drivesDriveItemService.UnmountShare(ctx, shareID); err != nil {
api.logger.Debug().Err(err).Msg(ErrUnmountShare.Error())
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// GetDriveItem get a drive item
func (api DrivesDriveItemApi) GetDriveItem(w http.ResponseWriter, r *http.Request) {
driveID, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
ErrInvalidDriveIDOrItemID.Render(w, r)
return
}
if !IsShareJail(driveID) {
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
ErrNotAShareJail.Render(w, r)
return
}
shareID := ExtractShareIdFromResourceId(itemID)
share, err := api.drivesDriveItemService.GetShare(r.Context(), shareID)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrNoShares.Error())
ErrNoShares.Render(w, r)
return
}
availableShares, err := api.drivesDriveItemService.GetSharesForResource(r.Context(), share.GetShare().GetResourceId(), nil)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrNoShares.Error())
ErrNoShares.Render(w, r)
return
}
driveItems, err := api.baseGraphService.CS3ReceivedSharesToDriveItems(r.Context(), availableShares)
switch {
case err != nil:
break
case len(driveItems) != 1:
err = ErrDriveItemConversion
}
if err != nil {
api.logger.Debug().Err(err).Msg(ErrDriveItemConversion.Error())
ErrDriveItemConversion.Render(w, r)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, driveItems[0])
}
// UpdateDriveItem updates a drive item, currently only the visibility of the share is updated
func (api DrivesDriveItemApi) UpdateDriveItem(w http.ResponseWriter, r *http.Request) {
driveID, itemID, err := GetDriveAndItemIDParam(r, &api.logger)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
ErrInvalidDriveIDOrItemID.Render(w, r)
return
}
if !IsShareJail(driveID) {
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
ErrNotAShareJail.Render(w, r)
return
}
shareID := ExtractShareIdFromResourceId(itemID)
requestDriveItem := libregraph.DriveItem{}
if err := StrictJSONUnmarshal(r.Body, &requestDriveItem); err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidRequestBody.Error())
ErrInvalidRequestBody.Render(w, r)
return
}
share, err := api.drivesDriveItemService.GetShare(r.Context(), shareID)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrNoShares.Error())
ErrNoShares.Render(w, r)
return
}
availableShares, err := api.drivesDriveItemService.GetSharesForResource(r.Context(), share.GetShare().GetResourceId(), nil)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrNoShares.Error())
ErrNoShares.Render(w, r)
return
}
updatedShares, err := api.drivesDriveItemService.UpdateShares(
r.Context(),
availableShares,
func(_ *collaboration.ReceivedShare, request *collaboration.UpdateReceivedShareRequest) {
request.GetShare().Hidden = requestDriveItem.GetUIHidden()
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathHidden)
},
)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrUpdateShares.Error())
ErrUpdateShares.Render(w, r)
return
}
driveItems, err := api.baseGraphService.CS3ReceivedSharesToDriveItems(r.Context(), updatedShares)
switch {
case err != nil:
break
case len(driveItems) != 1:
err = ErrDriveItemConversion
}
if err != nil {
api.logger.Debug().Err(err).Msg(ErrDriveItemConversion.Error())
ErrDriveItemConversion.Render(w, r)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, driveItems[0])
}
// CreateDriveItem creates a drive item
func (api DrivesDriveItemApi) CreateDriveItem(w http.ResponseWriter, r *http.Request) {
ctx := r.Context()
driveID, err := parseIDParam(r, "driveID")
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidDriveIDOrItemID.Error())
ErrInvalidDriveIDOrItemID.Render(w, r)
return
}
if !IsShareJail(&driveID) {
api.logger.Debug().Interface("driveID", driveID).Msg(ErrNotAShareJail.Error())
ErrNotAShareJail.Render(w, r)
return
}
requestDriveItem := libregraph.DriveItem{}
if err := StrictJSONUnmarshal(r.Body, &requestDriveItem); err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidRequestBody.Error())
ErrInvalidRequestBody.Render(w, r)
return
}
remoteItem := requestDriveItem.GetRemoteItem()
resourceId, err := storagespace.ParseID(remoteItem.GetId())
if err != nil {
api.logger.Debug().Err(err).Msg(ErrInvalidID.Error())
ErrInvalidID.Render(w, r)
return
}
var driveItems []libregraph.DriveItem
switch {
case resourceId.GetStorageId() == utils.OCMStorageProviderID:
var mountedOcmShares []*ocm.ReceivedShare
mountedOcmShares, err = api.drivesDriveItemService.MountOCMShare(ctx, &resourceId /*, requestDriveItem.GetName()*/)
if err != nil {
api.logger.Debug().Err(err).Msg(ErrMountShare.Error())
switch e, ok := errorcode.ToError(err); {
case ok && e.GetOrigin() == errorcode.ErrorOriginCS3 && e.GetCode() == errorcode.ItemNotFound:
ErrDriveItemConversion.Render(w, r)
default:
errorcode.RenderError(w, r, err)
}
return
}
driveItems, err = api.baseGraphService.CS3ReceivedOCMSharesToDriveItems(ctx, mountedOcmShares)
default:
var mountedShares []*collaboration.ReceivedShare
// Get all shares that the user has received for this resource. There might be multiple
mountedShares, err = api.drivesDriveItemService.MountShare(ctx, &resourceId, requestDriveItem.GetName())
if err != nil {
api.logger.Debug().Err(err).Msg(ErrMountShare.Error())
switch e, ok := errorcode.ToError(err); {
case ok && e.GetOrigin() == errorcode.ErrorOriginCS3 && e.GetCode() == errorcode.ItemNotFound:
ErrDriveItemConversion.Render(w, r)
default:
errorcode.RenderError(w, r, err)
}
return
}
driveItems, err = api.baseGraphService.CS3ReceivedSharesToDriveItems(ctx, mountedShares)
}
switch {
case err != nil:
break
case len(driveItems) != 1:
err = ErrDriveItemConversion
}
if err != nil {
api.logger.Debug().Err(err).Msg(ErrDriveItemConversion.Error())
ErrDriveItemConversion.Render(w, r)
return
}
render.Status(r, http.StatusCreated)
render.JSON(w, r, driveItems[0])
}
@@ -0,0 +1,173 @@
package svc
import (
"context"
"errors"
ocm "github.com/cs3org/go-cs3apis/cs3/sharing/ocm/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"google.golang.org/protobuf/types/known/fieldmaskpb"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
var (
// ErrUnmountOCMShare is returned when unmounting a share fails
ErrUnmountOCMShare = errorcode.New(errorcode.InvalidRequest, "unmounting ocm share failed")
// ErrMountOCMShare is returned when mounting a share fails
ErrMountOCMShare = errorcode.New(errorcode.InvalidRequest, "mounting ocm share failed")
)
type (
// UpdateOCMShareClosure is a closure that injects required updates into the update request
UpdateOCMShareClosure func(share *ocm.ReceivedShare, request *ocm.UpdateReceivedOCMShareRequest)
)
// GetOCMSharesForResource returns all federated shares for a given resourceID
func (s DrivesDriveItemService) GetOCMSharesForResource(ctx context.Context, resourceID *storageprovider.ResourceId) ([]*ocm.ReceivedShare, error) {
// Find all accepted shares for this resource
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return nil, err
}
receivedOCMSharesResponse, err := gatewayClient.ListReceivedOCMShares(ctx, &ocm.ListReceivedOCMSharesRequest{
/* ocm has no filters, yet
Filters: append([]*collaboration.Filter{
{
Type: collaboration.Filter_TYPE_RESOURCE_ID,
Term: &collaboration.Filter_ResourceId{
ResourceId: resourceID,
},
},
}, filters...),
*/
})
switch {
case err != nil:
return nil, err
case len(receivedOCMSharesResponse.GetShares()) == 0:
return nil, ErrNoShares
default:
return receivedOCMSharesResponse.GetShares(), errorcode.FromCS3Status(receivedOCMSharesResponse.GetStatus(), err)
}
}
// UpdateShares updates multiple shares;
// it could happen that some shares are updated and some are not,
// this will return a list of updated shares and a list of errors;
// there is no guarantee that all updates are successful
func (s DrivesDriveItemService) UpdateOCMShares(ctx context.Context, shares []*ocm.ReceivedShare, updater UpdateOCMShareClosure) ([]*ocm.ReceivedShare, error) {
errs := make([]error, 0, len(shares))
updatedShares := make([]*ocm.ReceivedShare, 0, len(shares))
for _, share := range shares {
err := s.UpdateOCMShare(
ctx,
share,
updater,
)
if err != nil {
errs = append(errs, err)
continue
}
updatedShares = append(updatedShares, share)
}
return updatedShares, errors.Join(errs...)
}
// UpdateOCMShare updates a single share
func (s DrivesDriveItemService) UpdateOCMShare(ctx context.Context, share *ocm.ReceivedShare, updater UpdateOCMShareClosure) error {
gatewayClient, err := s.gatewaySelector.Next()
if err != nil {
return err
}
updateReceivedOCMShareRequest := &ocm.UpdateReceivedOCMShareRequest{
Share: &ocm.ReceivedShare{
Id: share.GetId(),
},
UpdateMask: &fieldmaskpb.FieldMask{Paths: []string{}},
}
switch updater {
case nil:
return ErrNoUpdater
default:
updater(share, updateReceivedOCMShareRequest)
}
if len(updateReceivedOCMShareRequest.GetUpdateMask().GetPaths()) == 0 {
return ErrNoUpdates
}
updateReceivedOCMShareResponse, err := gatewayClient.UpdateReceivedOCMShare(ctx, updateReceivedOCMShareRequest)
return errorcode.FromCS3Status(updateReceivedOCMShareResponse.GetStatus(), err)
}
func (s DrivesDriveItemService) MountOCMShare(ctx context.Context, resourceID *storageprovider.ResourceId /*, name string*/) ([]*ocm.ReceivedShare, error) {
/*
if filepath.IsAbs(name) {
return nil, ErrAbsoluteNamePath
}
if name != "" {
name = filepath.Clean(name)
}
*/
shares, err := s.GetOCMSharesForResource(ctx, resourceID)
if err != nil {
return nil, err
}
availableShares := make([]*ocm.ReceivedShare, 0, len(shares))
mountedShares := make([]*ocm.ReceivedShare, 0, 1)
for _, v := range shares {
switch v.GetState() {
case ocm.ShareState_SHARE_STATE_ACCEPTED:
mountedShares = append(mountedShares, v)
case ocm.ShareState_SHARE_STATE_PENDING, ocm.ShareState_SHARE_STATE_REJECTED:
availableShares = append(availableShares, v)
}
}
if len(availableShares) == 0 {
if len(mountedShares) > 0 {
return nil, ErrAlreadyMounted
}
return nil, ErrNoShares
}
updatedShares, err := s.UpdateOCMShares(ctx, availableShares, func(share *ocm.ReceivedShare, request *ocm.UpdateReceivedOCMShareRequest) {
request.Share.State = ocm.ShareState_SHARE_STATE_ACCEPTED
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathState)
// only update if mountPoint name is not empty and the path has changed
/* ocm shares have no mount point???
if name != "" {
mountPoint := share.GetMountPoint()
if mountPoint == nil {
mountPoint = &storageprovider.Reference{}
}
if filepath.Clean(mountPoint.GetPath()) != name {
mountPoint.Path = name
request.Share.MountPoint = mountPoint
request.UpdateMask.Paths = append(request.UpdateMask.Paths, _fieldMaskPathMountPoint)
}
}
*/
})
errs, ok := err.(interface{ Unwrap() []error })
if ok && len(errs.Unwrap()) == len(availableShares) {
// none of the received ocm shares could be accepted.
// this is an error, return it.
return nil, err
}
return updatedShares, nil
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,159 @@
package svc
import (
"context"
"errors"
"fmt"
"io"
"net/http"
"strings"
"github.com/go-chi/render"
"github.com/opencloud-eu/reva/v2/pkg/storage/utils/metadata"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
type (
// UsersUserProfilePhotoProvider is the interface that defines the methods for the user profile photo service
UsersUserProfilePhotoProvider interface {
// GetPhoto retrieves the requested photo
GetPhoto(ctx context.Context, id string) ([]byte, error)
// UpdatePhoto retrieves the requested photo
UpdatePhoto(ctx context.Context, id string, r io.Reader) error
// DeletePhoto deletes the requested photo
DeletePhoto(ctx context.Context, id string) error
}
)
var (
// ErrNoBytes is returned when no bytes are found
ErrNoBytes = errors.New("no bytes")
// ErrInvalidContentType is returned when the content type is invalid
ErrInvalidContentType = errors.New("invalid content type")
// ErrMissingArgument is returned when a required argument is missing
ErrMissingArgument = errors.New("required argument is missing")
)
// UsersUserProfilePhotoService is the implementation of the UsersUserProfilePhotoProvider interface
type UsersUserProfilePhotoService struct {
storage metadata.Storage
}
// NewUsersUserProfilePhotoService creates a new UsersUserProfilePhotoService
func NewUsersUserProfilePhotoService(storage metadata.Storage) (UsersUserProfilePhotoService, error) {
return UsersUserProfilePhotoService{
storage: storage,
}, nil
}
// GetPhoto retrieves the requested photo
func (s UsersUserProfilePhotoService) GetPhoto(ctx context.Context, id string) ([]byte, error) {
return s.storage.SimpleDownload(ctx, id)
}
// DeletePhoto deletes the requested photo
func (s UsersUserProfilePhotoService) DeletePhoto(ctx context.Context, id string) error {
return s.storage.Delete(ctx, id)
}
// UpdatePhoto updates the requested photo
func (s UsersUserProfilePhotoService) UpdatePhoto(ctx context.Context, id string, r io.Reader) error {
if id == "" {
return fmt.Errorf("%w: %s", ErrMissingArgument, "id")
}
photo, err := io.ReadAll(r)
if err != nil {
return err
}
if len(photo) == 0 {
return ErrNoBytes
}
contentType := http.DetectContentType(photo)
if !strings.HasPrefix(contentType, "image/") {
return fmt.Errorf("%w: %s", ErrInvalidContentType, contentType)
}
return s.storage.SimpleUpload(ctx, id, photo)
}
// UsersUserProfilePhotoApi contains all photo related api endpoints
type UsersUserProfilePhotoApi struct {
logger log.Logger
usersUserProfilePhotoService UsersUserProfilePhotoProvider
}
// NewUsersUserProfilePhotoApi creates a new UsersUserProfilePhotoApi
func NewUsersUserProfilePhotoApi(usersUserProfilePhotoService UsersUserProfilePhotoProvider, logger log.Logger) (UsersUserProfilePhotoApi, error) {
return UsersUserProfilePhotoApi{
logger: log.Logger{Logger: logger.With().Str("graph api", "UsersUserProfilePhotoApi").Logger()},
usersUserProfilePhotoService: usersUserProfilePhotoService,
}, nil
}
// GetProfilePhoto creates a handler which renders the corresponding photo
func (api UsersUserProfilePhotoApi) GetProfilePhoto(h HTTPDataHandler[string]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
v, ok := h(w, r)
if !ok {
return
}
photo, err := api.usersUserProfilePhotoService.GetPhoto(r.Context(), v)
if err != nil {
api.logger.Debug().Err(err)
errorcode.GeneralException.Render(w, r, http.StatusNotFound, "failed to get photo")
return
}
render.Status(r, http.StatusOK)
_, _ = w.Write(photo)
}
}
// UpsertProfilePhoto creates a handler which updates or creates the corresponding photo
func (api UsersUserProfilePhotoApi) UpsertProfilePhoto(h HTTPDataHandler[string]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
v, ok := h(w, r)
if !ok {
return
}
if err := api.usersUserProfilePhotoService.UpdatePhoto(r.Context(), v, r.Body); err != nil {
api.logger.Debug().Err(err)
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "failed to update photo")
return
}
defer func() {
_ = r.Body.Close()
}()
render.Status(r, http.StatusOK)
}
}
// DeleteProfilePhoto creates a handler which deletes the corresponding photo
func (api UsersUserProfilePhotoApi) DeleteProfilePhoto(h HTTPDataHandler[string]) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
v, ok := h(w, r)
if !ok {
return
}
if err := api.usersUserProfilePhotoService.DeletePhoto(r.Context(), v); err != nil {
api.logger.Debug().Err(err)
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "failed to delete photo")
return
}
render.Status(r, http.StatusOK)
}
}
@@ -0,0 +1,141 @@
package svc_test
import (
"bytes"
"context"
"errors"
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/mock"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/mocks"
svc "github.com/qsfera/server/services/graph/pkg/service/v0"
)
func TestNewUsersUserProfilePhotoService(t *testing.T) {
service, err := svc.NewUsersUserProfilePhotoService(mocks.NewStorage(t))
assert.NoError(t, err)
t.Run("UpdatePhoto", func(t *testing.T) {
t.Run("reports an error if id is empty", func(t *testing.T) {
err := service.UpdatePhoto(context.Background(), "", bytes.NewReader([]byte{}))
assert.ErrorIs(t, err, svc.ErrMissingArgument)
})
t.Run("reports an error if the reader does not contain any bytes", func(t *testing.T) {
err := service.UpdatePhoto(context.Background(), "123", bytes.NewReader([]byte{}))
assert.ErrorIs(t, err, svc.ErrNoBytes)
})
t.Run("reports an error if data is not an image", func(t *testing.T) {
err := service.UpdatePhoto(context.Background(), "234", bytes.NewReader([]byte("not an image")))
assert.ErrorIs(t, err, svc.ErrInvalidContentType)
})
})
}
func TestUsersUserProfilePhotoApi(t *testing.T) {
var (
serviceProvider = mocks.NewUsersUserProfilePhotoProvider(t)
dataProvider = func(w http.ResponseWriter, r *http.Request) (string, bool) {
return "123", true
}
)
api, err := svc.NewUsersUserProfilePhotoApi(serviceProvider, log.NopLogger())
assert.NoError(t, err)
t.Run("GetProfilePhoto", func(t *testing.T) {
r := httptest.NewRequest(http.MethodGet, "/", nil)
ep := api.GetProfilePhoto(dataProvider)
t.Run("fails if photo provider errors", func(t *testing.T) {
w := httptest.NewRecorder()
serviceProvider.EXPECT().GetPhoto(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string) ([]byte, error) {
return nil, errors.New("any")
}).Once()
ep.ServeHTTP(w, r)
assert.Equal(t, http.StatusNotFound, w.Code)
})
t.Run("successfully returns the requested photo", func(t *testing.T) {
w := httptest.NewRecorder()
serviceProvider.EXPECT().GetPhoto(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string) ([]byte, error) {
return []byte("photo"), nil
}).Once()
ep.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
assert.Equal(t, "photo", w.Body.String())
})
})
t.Run("DeleteProfilePhoto", func(t *testing.T) {
r := httptest.NewRequest(http.MethodDelete, "/", nil)
ep := api.DeleteProfilePhoto(dataProvider)
t.Run("fails if photo provider errors", func(t *testing.T) {
w := httptest.NewRecorder()
serviceProvider.EXPECT().DeletePhoto(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string) error {
return errors.New("any")
}).Once()
ep.ServeHTTP(w, r)
assert.Equal(t, http.StatusInternalServerError, w.Code)
})
t.Run("successfully deletes the requested photo", func(t *testing.T) {
w := httptest.NewRecorder()
serviceProvider.EXPECT().DeletePhoto(mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string) error {
return nil
}).Once()
ep.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
})
})
t.Run("UpsertProfilePhoto", func(t *testing.T) {
r := httptest.NewRequest(http.MethodPut, "/", strings.NewReader("body"))
ep := api.UpsertProfilePhoto(dataProvider)
t.Run("fails if photo provider errors", func(t *testing.T) {
w := httptest.NewRecorder()
serviceProvider.EXPECT().UpdatePhoto(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string, r io.Reader) error {
return errors.New("any")
}).Once()
ep.ServeHTTP(w, r)
assert.Equal(t, http.StatusInternalServerError, w.Code)
})
t.Run("successfully upserts the photo", func(t *testing.T) {
w := httptest.NewRecorder()
serviceProvider.EXPECT().UpdatePhoto(mock.Anything, mock.Anything, mock.Anything).RunAndReturn(func(ctx context.Context, s string, r io.Reader) error {
return nil
}).Once()
ep.ServeHTTP(w, r)
assert.Equal(t, http.StatusOK, w.Code)
})
})
}
@@ -0,0 +1,77 @@
package svc
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/pkg/errorcode"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
)
// ListApplications implements the Service interface.
func (g Graph) ListApplications(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling list applications")
lbr, err := g.roleService.ListRoles(r.Context(), &settingssvc.ListBundlesRequest{})
if err != nil {
logger.Error().Err(err).Msg("could not list roles: transport error")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
roles := make([]libregraph.AppRole, 0, len(lbr.Bundles))
for _, bundle := range lbr.GetBundles() {
role := libregraph.NewAppRole(bundle.GetId())
role.SetDisplayName(bundle.GetDisplayName())
roles = append(roles, *role)
}
application := libregraph.NewApplication(g.config.Application.ID)
application.SetDisplayName(g.config.Application.DisplayName)
application.SetAppRoles(roles)
applications := []*libregraph.Application{
application,
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: applications})
}
// GetApplication implements the Service interface.
func (g Graph) GetApplication(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling get application")
applicationID := chi.URLParam(r, "applicationID")
if applicationID != g.config.Application.ID {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, fmt.Sprintf("requested id %s does not match expected application id %v", applicationID, g.config.Application.ID))
return
}
lbr, err := g.roleService.ListRoles(r.Context(), &settingssvc.ListBundlesRequest{})
if err != nil {
logger.Error().Err(err).Msg("could not list roles: transport error")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
roles := make([]libregraph.AppRole, 0, len(lbr.Bundles))
for _, bundle := range lbr.GetBundles() {
role := libregraph.NewAppRole(bundle.GetId())
role.SetDisplayName(bundle.GetDisplayName())
roles = append(roles, *role)
}
application := libregraph.NewApplication(applicationID)
application.SetDisplayName(g.config.Application.DisplayName)
application.SetAppRoles(roles)
render.Status(r, http.StatusOK)
render.JSON(w, r, application)
}
@@ -0,0 +1,149 @@
package svc_test
import (
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/shared"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
type applicationList struct {
Value []*libregraph.Application
}
var _ = Describe("Applications", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
roleService *mocks.RoleService
identityBackend *identitymocks.Backend
rr *httptest.ResponseRecorder
)
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
identityBackend = &identitymocks.Backend{}
roleService = &mocks.RoleService{}
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
rr = httptest.NewRecorder()
ctx = context.Background()
cfg = defaults.FullDefaultConfig()
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
cfg.TokenManager.JWTSecret = "loremipsum"
cfg.Commons = &shared.Commons{}
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
cfg.Application.ID = "some-application-ID"
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
service.WithRoleService(roleService),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("ListApplications", func() {
It("lists the configured application with appRoles", func() {
roleService.On("ListRoles", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListBundlesResponse{
Bundles: []*settingsmsg.Bundle{
{
Id: "some-appRole-ID",
Type: settingsmsg.Bundle_TYPE_ROLE,
DisplayName: "A human readable name for a role",
},
},
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/applications", nil)
svc.ListApplications(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
responseList := applicationList{}
err = json.Unmarshal(data, &responseList)
Expect(err).ToNot(HaveOccurred())
Expect(len(responseList.Value)).To(Equal(1))
Expect(responseList.Value[0].Id).To(Equal(cfg.Application.ID))
Expect(len(responseList.Value[0].GetAppRoles())).To(Equal(1))
Expect(responseList.Value[0].GetAppRoles()[0].GetId()).To(Equal("some-appRole-ID"))
Expect(responseList.Value[0].GetAppRoles()[0].GetDisplayName()).To(Equal("A human readable name for a role"))
})
})
Describe("GetApplication", func() {
It("gets the application with appRoles", func() {
roleService.On("ListRoles", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListBundlesResponse{
Bundles: []*settingsmsg.Bundle{
{
Id: "some-appRole-ID",
Type: settingsmsg.Bundle_TYPE_ROLE,
DisplayName: "A human readable name for a role",
},
},
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/applications/some-application-ID", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("applicationID", cfg.Application.ID)
r = r.WithContext(context.WithValue(ctx, chi.RouteCtxKey, rctx))
svc.GetApplication(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
application := libregraph.Application{}
err = json.Unmarshal(data, &application)
Expect(err).ToNot(HaveOccurred())
Expect(application.Id).To(Equal(cfg.Application.ID))
Expect(len(application.GetAppRoles())).To(Equal(1))
Expect(application.GetAppRoles()[0].GetId()).To(Equal("some-appRole-ID"))
Expect(application.GetAppRoles()[0].GetDisplayName()).To(Equal("A human readable name for a role"))
})
})
})
@@ -0,0 +1,166 @@
package svc
import (
"fmt"
"net/http"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/pkg/errorcode"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/utils"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
merrors "go-micro.dev/v4/errors"
)
const principalTypeUser = "User"
// ListAppRoleAssignments implements the Service interface.
func (g Graph) ListAppRoleAssignments(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling list appRoleAssignments")
userID := chi.URLParam(r, "userID")
lrar, err := g.roleService.ListRoleAssignments(r.Context(), &settingssvc.ListRoleAssignmentsRequest{
AccountUuid: userID,
})
if err != nil {
// TODO check the error type and return proper error code
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
values := make([]libregraph.AppRoleAssignment, 0, len(lrar.GetAssignments()))
for _, assignment := range lrar.GetAssignments() {
values = append(values, g.assignmentToAppRoleAssignment(assignment))
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: values})
}
// CreateAppRoleAssignment implements the Service interface.
func (g Graph) CreateAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling create appRoleAssignment")
appRoleAssignment := libregraph.NewAppRoleAssignmentWithDefaults()
err := StrictJSONUnmarshal(r.Body, appRoleAssignment)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error()))
return
}
userID := chi.URLParam(r, "userID")
// We can ignore the error, in the worst case the old role will be empty
oldRoles, _ := g.roleService.ListRoleAssignments(r.Context(), &settingssvc.ListRoleAssignmentsRequest{
AccountUuid: userID,
})
if appRoleAssignment.GetPrincipalId() != userID {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("user id %s does not match principal id %v", userID, appRoleAssignment.GetPrincipalId()))
return
}
if appRoleAssignment.GetResourceId() != g.config.Application.ID {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("resource id %s does not match expected application id %v", userID, g.config.Application.ID))
return
}
artur, err := g.roleService.AssignRoleToUser(r.Context(), &settingssvc.AssignRoleToUserRequest{
AccountUuid: userID,
RoleId: appRoleAssignment.AppRoleId,
})
if err != nil {
if merr, ok := merrors.As(err); ok && merr.Code == http.StatusForbidden {
errorcode.NotAllowed.Render(w, r, http.StatusForbidden, err.Error())
return
}
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
var role string
roles := oldRoles.GetAssignments()
if len(roles) > 0 {
role = roles[0].GetRoleId()
}
e := events.UserFeatureChanged{
UserID: userID,
Features: []events.UserFeature{
{
Name: "roleChanged",
Value: appRoleAssignment.AppRoleId,
OldValue: &role,
},
},
Timestamp: utils.TSNow(),
}
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(r.Context(), e)
render.Status(r, http.StatusCreated)
render.JSON(w, r, g.assignmentToAppRoleAssignment(artur.GetAssignment()))
}
// DeleteAppRoleAssignment implements the Service interface.
func (g Graph) DeleteAppRoleAssignment(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("body", r.Body).Msg("calling delete appRoleAssignment")
userID := chi.URLParam(r, "userID")
// check assignment belongs to the user
lrar, err := g.roleService.ListRoleAssignments(r.Context(), &settingssvc.ListRoleAssignmentsRequest{
AccountUuid: userID,
})
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
appRoleAssignmentID := chi.URLParam(r, "appRoleAssignmentID")
assignmentFound := false
for _, roleAssignment := range lrar.GetAssignments() {
if roleAssignment.Id == appRoleAssignmentID {
assignmentFound = true
}
}
if !assignmentFound {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, fmt.Sprintf("appRoleAssignment %v not found for user %v", appRoleAssignmentID, userID))
return
}
_, err = g.roleService.RemoveRoleFromUser(r.Context(), &settingssvc.RemoveRoleFromUserRequest{
Id: appRoleAssignmentID,
})
if err != nil {
if merr, ok := merrors.As(err); ok && merr.Code == http.StatusForbidden {
errorcode.NotAllowed.Render(w, r, http.StatusForbidden, err.Error())
return
}
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.NoContent(w, r)
}
func (g Graph) assignmentToAppRoleAssignment(assignment *settingsmsg.UserRoleAssignment) libregraph.AppRoleAssignment {
appRoleAssignment := libregraph.NewAppRoleAssignmentWithDefaults()
appRoleAssignment.SetId(assignment.Id)
appRoleAssignment.SetAppRoleId(assignment.RoleId)
appRoleAssignment.SetPrincipalType(principalTypeUser) // currently always assigned to the user
appRoleAssignment.SetResourceId(g.config.Application.ID)
appRoleAssignment.SetResourceDisplayName(g.config.Application.DisplayName)
appRoleAssignment.SetPrincipalId(assignment.AccountUuid)
// appRoleAssignment.SetPrincipalDisplayName() // TODO fetch and cache
return *appRoleAssignment
}
@@ -0,0 +1,218 @@
package svc_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/golang/protobuf/ptypes/empty"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/shared"
settingsmsg "github.com/qsfera/server/protogen/gen/qsfera/messages/settings/v0"
settings "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
type assignmentList struct {
Value []*libregraph.AppRoleAssignment
}
var _ = Describe("AppRoleAssignments", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
roleService *mocks.RoleService
identityBackend *identitymocks.Backend
rr *httptest.ResponseRecorder
currentUser = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "user",
},
}
)
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
identityBackend = &identitymocks.Backend{}
roleService = &mocks.RoleService{}
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
rr = httptest.NewRecorder()
ctx = context.Background()
cfg = defaults.FullDefaultConfig()
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
cfg.TokenManager.JWTSecret = "loremipsum"
cfg.Commons = &shared.Commons{}
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
cfg.Application.ID = "some-application-ID"
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
service.WithRoleService(roleService),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("ListAppRoleAssignments", func() {
It("lists the appRoleAssignments", func() {
user := &libregraph.User{
Id: libregraph.PtrString("user1"),
}
assignments := []*settingsmsg.UserRoleAssignment{
{
Id: "some-appRoleAssignment-ID",
AccountUuid: user.GetId(),
RoleId: "some-appRole-ID",
},
}
roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{Assignments: assignments}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/users/user1/appRoleAssignments", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", user.GetId())
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.ListAppRoleAssignments(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
responseList := assignmentList{}
err = json.Unmarshal(data, &responseList)
Expect(err).ToNot(HaveOccurred())
Expect(len(responseList.Value)).To(Equal(1))
Expect(responseList.Value[0].GetId()).ToNot(BeEmpty())
Expect(responseList.Value[0].GetAppRoleId()).To(Equal("some-appRole-ID"))
Expect(responseList.Value[0].GetPrincipalId()).To(Equal(user.GetId()))
Expect(responseList.Value[0].GetResourceId()).To(Equal(cfg.Application.ID))
})
})
Describe("CreateAppRoleAssignment", func() {
It("creates an appRoleAssignment", func() {
user := &libregraph.User{
Id: libregraph.PtrString("user1"),
}
userRoleAssignment := &settingsmsg.UserRoleAssignment{
Id: "some-appRoleAssignment-ID",
AccountUuid: user.GetId(),
RoleId: "some-appRole-ID",
}
roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{
Assignments: []*settingsmsg.UserRoleAssignment{
userRoleAssignment,
},
}, nil)
roleService.On("AssignRoleToUser", mock.Anything, mock.Anything, mock.Anything).Return(&settings.AssignRoleToUserResponse{Assignment: userRoleAssignment}, nil)
ara := libregraph.NewAppRoleAssignmentWithDefaults()
ara.SetAppRoleId("some-appRole-ID")
ara.SetPrincipalId(user.GetId())
ara.SetResourceId(cfg.Application.ID)
araJson, err := json.Marshal(ara)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/users/user1/appRoleAssignments", bytes.NewBuffer(araJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", user.GetId())
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.CreateAppRoleAssignment(rr, r)
Expect(rr.Code).To(Equal(http.StatusCreated))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
assignment := libregraph.AppRoleAssignment{}
err = json.Unmarshal(data, &assignment)
Expect(err).ToNot(HaveOccurred())
Expect(assignment.GetId()).ToNot(BeEmpty())
Expect(assignment.GetAppRoleId()).To(Equal("some-appRole-ID"))
Expect(assignment.GetPrincipalId()).To(Equal("user1"))
Expect(assignment.GetResourceId()).To(Equal(cfg.Application.ID))
})
})
Describe("DeleteAppRoleAssignment", func() {
It("deletes an appRoleAssignment", func() {
user := &libregraph.User{
Id: libregraph.PtrString("user1"),
}
assignments := []*settingsmsg.UserRoleAssignment{
{
Id: "some-appRoleAssignment-ID",
AccountUuid: user.GetId(),
RoleId: "some-appRole-ID",
},
}
roleService.On("ListRoleAssignments", mock.Anything, mock.Anything, mock.Anything).Return(&settings.ListRoleAssignmentsResponse{Assignments: assignments}, nil)
roleService.On("RemoveRoleFromUser", mock.Anything, mock.Anything, mock.Anything).Return(&empty.Empty{}, nil)
ara := libregraph.NewAppRoleAssignmentWithDefaults()
ara.SetAppRoleId("some-appRole-ID")
ara.SetPrincipalId(user.GetId())
ara.SetResourceId(cfg.Application.ID)
araJson, err := json.Marshal(ara)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/users/user1/appRoleAssignments/some-appRoleAssignment-ID", bytes.NewBuffer(araJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", user.GetId())
rctx.URLParams.Add("appRoleAssignmentID", "some-appRoleAssignment-ID")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteAppRoleAssignment(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
})
})
})
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,42 @@
package svc
import (
"errors"
"fmt"
"net/http"
"net/url"
"github.com/go-chi/chi/v5"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
// HTTPDataHandler returns data from the request, it should exit early and return false in the case of any error
type HTTPDataHandler[T any] func(w http.ResponseWriter, r *http.Request) (T, bool)
var (
// ErrNoUser is returned when no user is found
ErrNoUser = errors.New("no user found")
)
// GetUserIDFromCTX extracts the user from the request
func GetUserIDFromCTX(w http.ResponseWriter, r *http.Request) (string, bool) {
u, ok := revactx.ContextGetUser(r.Context())
if !ok {
errorcode.GeneralException.Render(w, r, http.StatusMethodNotAllowed, ErrNoUser.Error())
}
return u.GetId().GetOpaqueId(), ok
}
func GetSlugValue(key string) HTTPDataHandler[string] {
return func(w http.ResponseWriter, r *http.Request) (string, bool) {
v, err := url.PathUnescape(chi.URLParam(r, key))
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, fmt.Sprintf(`failed to get slug: "%s"`, key))
}
return v, err == nil
}
}
@@ -0,0 +1,757 @@
package svc
import (
"context"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"net/http"
"net/url"
"path"
"reflect"
"strconv"
"strings"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
cs3rpc "github.com/cs3org/go-cs3apis/cs3/rpc/v1beta1"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
types "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"golang.org/x/crypto/sha3"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/storagespace"
"github.com/opencloud-eu/reva/v2/pkg/utils"
"github.com/qsfera/server/pkg/log"
"github.com/qsfera/server/services/graph/pkg/errorcode"
)
// CreateUploadSession create an upload session to allow your app to upload files up to the maximum file size.
// An upload session allows your app to upload ranges of the file in sequential API requests, which allows the
// transfer to be resumed if a connection is dropped while the upload is in progress.
// ```json
//
// {
// "@microsoft.graph.conflictBehavior": "fail (default) | replace | rename",
// "description": "description",
// "fileSize": 1234,
// "name": "filename.txt"
// }
//
// ```
// From https://learn.microsoft.com/en-us/graph/api/driveitem-createuploadsession?view=graph-rest-1.0
func (g Graph) CreateUploadSession(w http.ResponseWriter, r *http.Request) {
g.logger.Info().Msg("Calling CreateUploadSession")
driveID, err := parseIDParam(r, "driveID")
if err != nil {
errorcode.RenderError(w, r, err)
return
}
driveItemID, err := parseIDParam(r, "driveItemID")
if err != nil {
errorcode.RenderError(w, r, err)
return
}
if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
return
}
var cusr createUploadSessionRequest
err = json.NewDecoder(r.Body).Decode(&cusr)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("could not select next gateway client")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select next gateway client, aborting")
return
}
ref := &storageprovider.Reference{
ResourceId: &driveItemID,
}
if cusr.Item.Name != "" {
ref.Path = utils.MakeRelativePath(cusr.Item.Name)
}
req := &storageprovider.InitiateFileUploadRequest{
Ref: ref,
Opaque: utils.AppendPlainToOpaque(nil, "Upload-Length", strconv.FormatUint(uint64(cusr.Item.FileSize), 10)),
}
ctx := r.Context()
res, err := gatewayClient.InitiateFileUpload(ctx, req)
switch {
case err != nil:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
// ok
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED:
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
return
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
return
}
uploadSession := uploadSession{
CS3Protocols: res.GetProtocols(),
}
for _, p := range res.GetProtocols() {
if p.GetProtocol() == "simple" {
uploadSession.UploadURL = p.GetUploadEndpoint() + "/" + p.GetToken()
}
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &uploadSession)
}
type createUploadSessionRequest struct {
DeferCommit bool `json:"deferCommit"`
Item driveItemUploadableProperties `json:"item"`
}
type driveItemUploadableProperties struct {
// ConflictBehavior "@microsoft.graph.conflictBehavior"
//Description string
FileSize int64 `json:"fileSize"`
// fileSystemInfo
Name string `json:"name"`
}
type uploadSession struct {
UploadURL string
//"expirationDateTime": "2015-01-29T09:21:55.523Z",
//"nextExpectedRanges": ["0-"]
CS3Protocols []*gateway.FileUploadProtocol
}
// GetRootDriveChildren implements the Service interface.
func (g Graph) GetRootDriveChildren(w http.ResponseWriter, r *http.Request) {
g.logger.Info().Msg("Calling GetRootDriveChildren")
ctx := r.Context()
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
g.logger.Error().Err(err).Msg("could not select next gateway client")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select next gateway client, aborting")
return
}
currentUser := revactx.ContextMustGetUser(r.Context())
// do we need to list all or only the personal drive
filters := []*storageprovider.ListStorageSpacesRequest_Filter{}
filters = append(filters, listStorageSpacesUserFilter(currentUser.GetId().GetOpaqueId()))
filters = append(filters, listStorageSpacesTypeFilter("personal"))
res, err := gatewayClient.ListStorageSpaces(ctx, &storageprovider.ListStorageSpacesRequest{
Filters: filters,
})
switch {
case err != nil:
g.logger.Error().Err(err).Msg("error making ListStorageSpaces grpc call")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK:
if res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
return
}
g.logger.Error().Err(err).Msg("error sending ListStorageSpaces grpc request")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
return
}
var space *storageprovider.StorageSpace
for _, s := range res.GetStorageSpaces() {
if utils.UserIDEqual(currentUser.GetId(), s.GetOwner().GetId()) {
space = s
}
}
lRes, err := gatewayClient.ListContainer(ctx, &storageprovider.ListContainerRequest{
Ref: &storageprovider.Reference{ResourceId: space.GetRoot()},
})
switch {
case err != nil:
g.logger.Error().Err(err).Msg("error making ListContainer grpc call")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, err.Error())
return
case lRes.GetStatus().GetCode() != cs3rpc.Code_CODE_OK:
if lRes.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, lRes.GetStatus().GetMessage())
return
}
if lRes.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED {
// TODO check if we should return 404 to not disclose existing items
errorcode.AccessDenied.Render(w, r, http.StatusForbidden, lRes.GetStatus().GetMessage())
return
}
g.logger.Error().Err(err).Msg("error sending list container grpc request")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
return
}
files, err := formatDriveItems(g.logger, g.publicBaseURL, lRes.GetInfos())
if err != nil {
g.logger.Error().Err(err).Msg("error encoding response as json")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: files})
}
// GetDriveItem returns a driveItem
func (g Graph) GetDriveItem(w http.ResponseWriter, r *http.Request) {
g.logger.Info().Msg("Calling GetDriveItem")
ctx := r.Context()
driveID, err := parseIDParam(r, "driveID")
if err != nil {
errorcode.RenderError(w, r, err)
return
}
driveItemID, err := parseIDParam(r, "driveItemID")
if err != nil {
errorcode.RenderError(w, r, err)
return
}
if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
return
}
/*
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
// Parse the request with odata parser
odataReq, err := godata.ParseRequest(ctx, sanitizedPath, r.URL.Query())
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
*/
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
res, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: &storageprovider.Reference{ResourceId: &driveItemID}})
switch {
case err != nil:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
// ok
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED:
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
return
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
return
}
driveItem, err := cs3ResourceToDriveItem(g.logger, g.publicBaseURL, res.GetInfo())
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &driveItem)
}
// GetDriveItemChildren lists the children of a driveItem
func (g Graph) GetDriveItemChildren(w http.ResponseWriter, r *http.Request) {
g.logger.Info().Msg("Calling GetDriveItemChildren")
ctx := r.Context()
driveID, err := parseIDParam(r, "driveID")
if err != nil {
errorcode.RenderError(w, r, err)
return
}
driveItemID, err := parseIDParam(r, "driveItemID")
if err != nil {
errorcode.RenderError(w, r, err)
return
}
if driveID.GetStorageId() != driveItemID.GetStorageId() || driveID.GetSpaceId() != driveItemID.GetSpaceId() {
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, "Item does not exist")
return
}
/*
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
// Parse the request with odata parser
odataReq, err := godata.ParseRequest(ctx, sanitizedPath, r.URL.Query())
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
*/
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
res, err := gatewayClient.ListContainer(ctx, &storageprovider.ListContainerRequest{
Ref: &storageprovider.Reference{ResourceId: &driveItemID},
})
switch {
case err != nil:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_OK:
// ok
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_NOT_FOUND:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage())
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_PERMISSION_DENIED:
errorcode.ItemNotFound.Render(w, r, http.StatusNotFound, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
return
case res.GetStatus().GetCode() == cs3rpc.Code_CODE_UNAUTHENTICATED:
errorcode.Unauthenticated.Render(w, r, http.StatusUnauthorized, res.GetStatus().GetMessage()) // do not leak existence? check what graph does
return
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, res.GetStatus().GetMessage())
return
}
files, err := formatDriveItems(g.logger, g.publicBaseURL, res.GetInfos())
if err != nil {
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: files})
}
func (g Graph) getRemoteItem(ctx context.Context, root *storageprovider.ResourceId, baseURL *url.URL) (*libregraph.RemoteItem, error) {
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
return nil, err
}
ref := &storageprovider.Reference{
ResourceId: root,
}
res, err := gatewayClient.Stat(ctx, &storageprovider.StatRequest{Ref: ref})
if err != nil {
return nil, err
}
if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
// Only log this, there could be mountpoints which have no grant
g.logger.Debug().Msg(res.GetStatus().GetMessage())
return nil, errors.New("could not fetch grant resource for the mountpoint")
}
item, err := cs3ResourceToRemoteItem(res.GetInfo())
if err != nil {
return nil, err
}
if baseURL != nil && res.GetInfo() != nil && res.GetInfo().GetSpace() != nil {
// TODO read from StorageSpace ... needs Opaque for now
// TODO how do we build the url?
// for now: read from request
item.Name = libregraph.PtrString(res.GetInfo().GetName())
if res.GetInfo().GetSpace().GetRoot() != nil {
webDavURL := *baseURL
relativePath := res.GetInfo().GetPath()
webDavURL.Path = path.Join(webDavURL.Path, storagespace.FormatResourceID(res.GetInfo().GetSpace().GetRoot()), relativePath)
item.WebDavUrl = libregraph.PtrString(webDavURL.String())
}
}
return item, nil
}
func formatDriveItems(logger *log.Logger, publicBaseURL *url.URL, mds []*storageprovider.ResourceInfo) ([]*libregraph.DriveItem, error) {
responses := make([]*libregraph.DriveItem, 0, len(mds))
for i := range mds {
res, err := cs3ResourceToDriveItem(logger, publicBaseURL, mds[i])
if err != nil {
return nil, err
}
responses = append(responses, res)
}
return responses, nil
}
func cs3TimestampToTime(t *types.Timestamp) time.Time {
return time.Unix(int64(t.GetSeconds()), int64(t.GetNanos()))
}
func cs3ResourceToDriveItem(logger *log.Logger, publicBaseURL *url.URL, res *storageprovider.ResourceInfo) (*libregraph.DriveItem, error) {
size := new(int64)
*size = int64(res.GetSize()) // TODO lurking overflow: make size of libregraph drive item use uint64
driveItem := &libregraph.DriveItem{
Id: libregraph.PtrString(storagespace.FormatResourceID(res.GetId())),
Size: size,
}
webURL := *publicBaseURL
webURL.Path = path.Join(webURL.Path, "f", storagespace.FormatResourceID(res.GetId()))
driveItem.WebUrl = libregraph.PtrString(webURL.String())
if name := path.Base(res.GetPath()); name != "" {
driveItem.Name = &name
}
if res.GetEtag() != "" {
driveItem.ETag = &res.Etag
}
if res.GetMtime() != nil {
lastModified := cs3TimestampToTime(res.GetMtime())
driveItem.LastModifiedDateTime = &lastModified
}
if res.GetParentId() != nil {
parentRef := libregraph.NewItemReference()
parentRef.SetDriveType(res.GetSpace().GetSpaceType())
parentRef.SetDriveId(storagespace.FormatStorageID(res.GetParentId().GetStorageId(), res.GetParentId().GetSpaceId()))
parentRef.SetId(storagespace.FormatResourceID(res.GetParentId()))
parentRef.SetName(path.Base(path.Dir(res.GetPath())))
parentRef.SetPath(path.Dir(res.GetPath()))
driveItem.ParentReference = parentRef
}
switch res.GetType() {
case storageprovider.ResourceType_RESOURCE_TYPE_FILE:
mimeType := res.GetMimeType()
if mimeType == "" {
mimeType = "application/octet-stream"
}
// We cannot use a libregraph.File here because the openapi codegenerator autodetects 'File' as a go type ...
driveItem.File = &libregraph.OpenGraphFile{
MimeType: &mimeType,
}
case storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER:
if IsSpaceRoot(res.GetId()) {
driveItem.SetRoot(map[string]any{})
} else {
driveItem.SetFolder(libregraph.Folder{})
}
}
if res.GetArbitraryMetadata() != nil {
driveItem.Audio = cs3ResourceToDriveItemAudioFacet(logger, res)
driveItem.Image = cs3ResourceToDriveItemImageFacet(logger, res)
driveItem.Location = cs3ResourceToDriveItemLocationFacet(logger, res)
driveItem.Photo = cs3ResourceToDriveItemPhotoFacet(logger, res)
}
return driveItem, nil
}
func cs3ResourceToDriveItemAudioFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Audio {
if !strings.HasPrefix(res.GetMimeType(), "audio/") {
return nil
}
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var audio = &libregraph.Audio{}
if ok := unmarshalStringMap(logger, audio, k, "libre.graph.audio."); ok {
return audio
}
return nil
}
func cs3ResourceToDriveItemImageFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Image {
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var image = &libregraph.Image{}
if ok := unmarshalStringMap(logger, image, k, "libre.graph.image."); ok {
return image
}
return nil
}
func cs3ResourceToDriveItemLocationFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.GeoCoordinates {
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var location = &libregraph.GeoCoordinates{}
if ok := unmarshalStringMap(logger, location, k, "libre.graph.location."); ok {
return location
}
return nil
}
func cs3ResourceToDriveItemPhotoFacet(logger *log.Logger, res *storageprovider.ResourceInfo) *libregraph.Photo {
k := res.GetArbitraryMetadata().GetMetadata()
if k == nil {
return nil
}
var photo = &libregraph.Photo{}
if ok := unmarshalStringMap(logger, photo, k, "libre.graph.photo."); ok {
return photo
}
return nil
}
func getFieldName(structField reflect.StructField) string {
tag := structField.Tag.Get("json")
if tag == "" {
return structField.Name
}
return strings.Split(tag, ",")[0]
}
func unmarshalStringMap(logger *log.Logger, out any, flatMap map[string]string, prefix string) bool {
nonEmpty := false
obj := reflect.ValueOf(out).Elem()
timeKind := reflect.TypeOf(&time.Time{}).Elem().Kind()
for i := 0; i < obj.NumField(); i++ {
field := obj.Field(i)
structField := obj.Type().Field(i)
mapKey := prefix + getFieldName(structField)
if value, ok := flatMap[mapKey]; ok {
if field.Kind() == reflect.Ptr {
newValue := reflect.New(field.Type().Elem())
var tmp any
var err error
switch t := newValue.Type().Elem().Kind(); t {
case reflect.String:
tmp = value
case reflect.Int32:
tmp, err = strconv.ParseInt(value, 10, 32)
case reflect.Int64:
tmp, err = strconv.ParseInt(value, 10, 64)
case reflect.Float32:
tmp, err = strconv.ParseFloat(value, 32)
case reflect.Float64:
tmp, err = strconv.ParseFloat(value, 64)
case reflect.Bool:
tmp, err = strconv.ParseBool(value)
case timeKind:
tmp, err = time.Parse(time.RFC3339, value)
default:
err = errors.New("unsupported type")
logger.Error().Err(err).Str("type", t.String()).Str("mapKey", mapKey).Msg("target field type for value of mapKey is not supported")
}
if err != nil {
logger.Error().Err(err).Str("mapKey", mapKey).Msg("unmarshalling failed")
continue
}
newValue.Elem().Set(reflect.ValueOf(tmp).Convert(field.Type().Elem()))
field.Set(newValue)
nonEmpty = true
}
}
}
return nonEmpty
}
func cs3ResourceToRemoteItem(res *storageprovider.ResourceInfo) (*libregraph.RemoteItem, error) {
size := new(int64)
*size = int64(res.GetSize()) // TODO lurking overflow: make size of libregraph drive item use uint64
remoteItem := &libregraph.RemoteItem{
Id: libregraph.PtrString(storagespace.FormatResourceID(res.GetId())),
Size: size,
}
if res.GetPath() != "" {
remoteItem.Path = libregraph.PtrString(path.Clean(res.GetPath()))
}
if res.GetEtag() != "" {
remoteItem.ETag = &res.Etag
}
if res.GetMtime() != nil {
lastModified := cs3TimestampToTime(res.GetMtime())
remoteItem.LastModifiedDateTime = &lastModified
}
if res.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_FILE && res.GetMimeType() != "" {
// We cannot use a libregraph.File here because the openapi codegenerator autodetects 'File' as a go type ...
remoteItem.File = &libregraph.OpenGraphFile{
MimeType: &res.MimeType,
}
}
if res.GetType() == storageprovider.ResourceType_RESOURCE_TYPE_CONTAINER {
remoteItem.Folder = &libregraph.Folder{}
}
if res.GetSpace() != nil && res.GetSpace().GetRoot() != nil {
remoteItem.RootId = libregraph.PtrString(storagespace.FormatResourceID(res.GetSpace().GetRoot()))
grantSpaceAlias := utils.ReadPlainFromOpaque(res.GetSpace().GetOpaque(), "spaceAlias")
if grantSpaceAlias != "" {
remoteItem.DriveAlias = libregraph.PtrString(grantSpaceAlias)
}
}
return remoteItem, nil
}
func (g Graph) getPathForResource(ctx context.Context, id storageprovider.ResourceId) (string, error) {
gatewayClient, err := g.gatewaySelector.Next()
if err != nil {
return "", err
}
res, err := gatewayClient.GetPath(ctx, &storageprovider.GetPathRequest{ResourceId: &id})
if err != nil {
return "", err
}
if res.GetStatus().GetCode() != cs3rpc.Code_CODE_OK {
return "", fmt.Errorf("could not stat %v: %s", id, res.GetStatus().GetMessage())
}
return res.GetPath(), err
}
// getSpecialDriveItems reads properties from the opaque and transforms them into driveItems
func (g Graph) getSpecialDriveItems(ctx context.Context, baseURL *url.URL, space *storageprovider.StorageSpace) []libregraph.DriveItem {
if space.GetRoot().GetStorageId() == utils.ShareStorageProviderID {
return nil // no point in stating the ShareStorageProvider
}
if space.GetOpaque() == nil {
return nil
}
imageNode := utils.ReadPlainFromOpaque(space.GetOpaque(), SpaceImageSpecialFolderName)
readmeNode := utils.ReadPlainFromOpaque(space.GetOpaque(), ReadmeSpecialFolderName)
cachekey := spaceRootStatKey(space.GetRoot(), imageNode, readmeNode)
// if the root is older or equal to our cache we can reuse the cached extended spaces properties
if entry := g.specialDriveItemsCache.Get(cachekey); entry != nil {
if cached, ok := entry.Value().(specialDriveItemEntry); ok {
if cached.rootMtime != nil && space.GetMtime() != nil {
// beware, LaterTS does not handle equalness. it returns t1 if t1 > t2, else t2, so a >= check looks like this
if utils.LaterTS(space.GetMtime(), cached.rootMtime) == cached.rootMtime {
return cached.specialDriveItems
}
}
}
}
var spaceItems []libregraph.DriveItem
var err error
doCache := true
spaceItems, err = g.fetchSpecialDriveItem(ctx, spaceItems, SpaceImageSpecialFolderName, imageNode, space, baseURL)
if err != nil {
doCache = false
g.logger.Debug().Err(err).Str("ID", imageNode).Msg("Could not get space image")
}
spaceItems, err = g.fetchSpecialDriveItem(ctx, spaceItems, ReadmeSpecialFolderName, readmeNode, space, baseURL)
if err != nil {
doCache = false
g.logger.Debug().Err(err).Str("ID", imageNode).Msg("Could not get space readme")
}
// cache properties
spacePropertiesEntry := specialDriveItemEntry{
specialDriveItems: spaceItems,
rootMtime: space.GetMtime(),
}
if doCache {
g.specialDriveItemsCache.Set(cachekey, spacePropertiesEntry, time.Duration(g.config.Spaces.ExtendedSpacePropertiesCacheTTL))
}
return spaceItems
}
func (g Graph) fetchSpecialDriveItem(ctx context.Context, spaceItems []libregraph.DriveItem, itemName string, itemNode string, space *storageprovider.StorageSpace, baseURL *url.URL) ([]libregraph.DriveItem, error) {
var ref *storageprovider.Reference
if itemNode != "" {
rid, _ := storagespace.ParseID(itemNode)
rid.StorageId = space.GetRoot().GetStorageId()
ref = &storageprovider.Reference{
ResourceId: &rid,
}
spaceItem, err := g.getSpecialDriveItem(ctx, ref, itemName, baseURL, space)
if err != nil {
return spaceItems, err
}
if spaceItem != nil {
spaceItems = append(spaceItems, *spaceItem)
}
}
return spaceItems, nil
}
// generates a space root stat cache key used to detect changes in a space
// takes into account the special nodes because changing metadata does not affect the etag / mtime
func spaceRootStatKey(id *storageprovider.ResourceId, imagenode, readmeNode string) string {
if id == nil {
return ""
}
shakeHash := sha3.NewShake256()
_, _ = shakeHash.Write([]byte(id.GetStorageId()))
_, _ = shakeHash.Write([]byte(id.GetSpaceId()))
_, _ = shakeHash.Write([]byte(id.GetOpaqueId()))
_, _ = shakeHash.Write([]byte(imagenode))
_, _ = shakeHash.Write([]byte(readmeNode))
h := make([]byte, 64)
_, _ = shakeHash.Read(h)
return hex.EncodeToString(h)
}
type specialDriveItemEntry struct {
specialDriveItems []libregraph.DriveItem
rootMtime *types.Timestamp
}
func (g Graph) getSpecialDriveItem(ctx context.Context, ref *storageprovider.Reference, itemName string, baseURL *url.URL, space *storageprovider.StorageSpace) (*libregraph.DriveItem, error) {
var spaceItem *libregraph.DriveItem
if ref.GetResourceId().GetSpaceId() == "" && ref.GetResourceId().GetOpaqueId() == "" {
return nil, nil
}
// FIXME we should send a fieldmask 'path' and return it as the Path property to save an additional call to the storage.
// To do that we need to align the useg of ResourceInfo.Name vs ResourceInfo.Path. By default, only the name should be set
// and Path should always be relative to the space root OR the resource the current user can access ...
spaceItem, err := g.getDriveItem(ctx, ref)
if err != nil {
return nil, err
}
itemPath := ref.GetPath()
if itemPath == "" {
// lookup by id
itemPath, err = g.getPathForResource(ctx, *ref.GetResourceId())
if err != nil {
return nil, err
}
}
spaceItem.SpecialFolder = &libregraph.SpecialFolder{Name: libregraph.PtrString(itemName)}
webdavURL := *baseURL
webdavURL.Path = path.Join(webdavURL.Path, space.GetId().GetOpaqueId(), itemPath)
spaceItem.WebDavUrl = libregraph.PtrString(webdavURL.String())
return spaceItem, nil
}
@@ -0,0 +1,455 @@
package svc_test
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userpb "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
"github.com/opencloud-eu/reva/v2/pkg/utils"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
type itemsList struct {
Value []*libregraph.DriveItem
}
var _ = Describe("Driveitems", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
identityBackend *identitymocks.Backend
rr *httptest.ResponseRecorder
newGroup *libregraph.Group
currentUser = &userpb.User{
Id: &userpb.UserId{
OpaqueId: "user",
},
}
)
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
identityBackend = &identitymocks.Backend{}
newGroup = libregraph.NewGroup()
newGroup.SetMembersodataBind([]string{"/users/user1"})
newGroup.SetId("group1")
rr = httptest.NewRecorder()
ctx = context.Background()
cfg = defaults.FullDefaultConfig()
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
cfg.TokenManager.JWTSecret = "loremipsum"
cfg.Commons = &shared.Commons{}
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("GetRootDriveChildren", func() {
It("handles ListStorageSpaces not found", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewNotFound(ctx, "not found"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusNotFound))
})
It("handles ListStorageSpaces error", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewInternal(ctx, "internal error"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
It("handles ListContainer not found", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
}, nil)
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewNotFound(ctx, "not found"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusNotFound))
})
It("handles ListContainer permission denied", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
}, nil)
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewPermissionDenied(ctx, errors.New("denied"), "denied"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
It("handles ListContainer error", func() {
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
}, nil)
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewInternal(ctx, "internal"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
It("succeeds", func() {
mtime := time.Now()
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{{Owner: currentUser, Root: &provider.ResourceId{}}},
}, nil)
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
},
},
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/me/drive/root/children", nil)
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.GetRootDriveChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := itemsList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
Expect(res.Value[0].GetLastModifiedDateTime().Equal(mtime)).To(BeTrue())
Expect(res.Value[0].GetETag()).To(Equal("etag"))
Expect(res.Value[0].GetId()).To(Equal("storageid$spaceid!opaqueid"))
})
})
Describe("GetDriveItemChildren", func() {
It("handles ListContainer not found", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewNotFound(ctx, "not found"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("driveID", "storageid$spaceid")
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetDriveItemChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusNotFound))
})
It("handles ListContainer permission denied as not found", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewPermissionDenied(ctx, errors.New("denied"), "denied"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("driveID", "storageid$spaceid")
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetDriveItemChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusNotFound))
})
It("handles ListContainer error", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewInternal(ctx, "internal"),
}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("driveID", "storageid$spaceid")
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetDriveItemChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
})
Context("it succeeds", func() {
var (
r *http.Request
mtime = time.Now()
)
BeforeEach(func() {
r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/drives/storageid$spaceid/items/storageid$spaceid!nodeid/children", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("driveID", "storageid$spaceid")
rctx.URLParams.Add("driveItemID", "storageid$spaceid!nodeid")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
})
assertItemsList := func(length int) itemsList {
svc.GetDriveItemChildren(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := itemsList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(length))
Expect(res.Value[0].GetLastModifiedDateTime().Equal(mtime)).To(BeTrue())
Expect(res.Value[0].GetETag()).To(Equal("etag"))
Expect(res.Value[0].GetId()).To(Equal("storageid$spaceid!opaqueid"))
Expect(res.Value[0].GetId()).To(Equal("storageid$spaceid!opaqueid"))
return res
}
It("returns a generic file", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
ArbitraryMetadata: nil,
},
},
}, nil)
res := assertItemsList(1)
Expect(res.Value[0].Audio).To(BeNil())
Expect(res.Value[0].Location).To(BeNil())
})
It("returns the audio facet if metadata is available", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
MimeType: "audio/mpeg",
ArbitraryMetadata: &provider.ArbitraryMetadata{
Metadata: map[string]string{
"libre.graph.audio.album": "Some Album",
"libre.graph.audio.albumArtist": "Some AlbumArtist",
"libre.graph.audio.artist": "Some Artist",
"libre.graph.audio.bitrate": "192",
"libre.graph.audio.composers": "Some Composers",
"libre.graph.audio.copyright": "Some Copyright",
"libre.graph.audio.disc": "2",
"libre.graph.audio.discCount": "5",
"libre.graph.audio.duration": "225000",
"libre.graph.audio.genre": "Some Genre",
"libre.graph.audio.hasDrm": "false",
"libre.graph.audio.isVariableBitrate": "true",
"libre.graph.audio.title": "Some Title",
"libre.graph.audio.track": "6",
"libre.graph.audio.trackCount": "9",
"libre.graph.audio.year": "1994",
},
},
},
},
}, nil)
res := assertItemsList(1)
audio := res.Value[0].Audio
Expect(audio).ToNot(BeNil())
Expect(audio.Album).To(Equal(libregraph.PtrString("Some Album")))
Expect(audio.AlbumArtist).To(Equal(libregraph.PtrString("Some AlbumArtist")))
Expect(audio.Artist).To(Equal(libregraph.PtrString("Some Artist")))
Expect(audio.Bitrate).To(Equal(libregraph.PtrInt64(192)))
Expect(audio.Composers).To(Equal(libregraph.PtrString("Some Composers")))
Expect(audio.Copyright).To(Equal(libregraph.PtrString("Some Copyright")))
Expect(audio.Disc).To(Equal(libregraph.PtrInt32(2)))
Expect(audio.DiscCount).To(Equal(libregraph.PtrInt32(5)))
Expect(audio.Duration).To(Equal(libregraph.PtrInt64(225000)))
Expect(audio.Genre).To(Equal(libregraph.PtrString("Some Genre")))
Expect(audio.HasDrm).To(Equal(libregraph.PtrBool(false)))
Expect(audio.IsVariableBitrate).To(Equal(libregraph.PtrBool(true)))
Expect(audio.Title).To(Equal(libregraph.PtrString("Some Title")))
Expect(audio.Track).To(Equal(libregraph.PtrInt32(6)))
Expect(audio.TrackCount).To(Equal(libregraph.PtrInt32(9)))
Expect(audio.Year).To(Equal(libregraph.PtrInt32(1994)))
})
It("returns the location facet if metadata is available", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
MimeType: "image/jpeg",
ArbitraryMetadata: &provider.ArbitraryMetadata{
Metadata: map[string]string{
"libre.graph.location.altitude": "1047.7",
"libre.graph.location.latitude": "49.48675890884328",
"libre.graph.location.longitude": "11.103870357204285",
},
},
},
},
}, nil)
res := assertItemsList(1)
location := res.Value[0].Location
Expect(location).ToNot(BeNil())
Expect(location.Altitude).To(Equal(libregraph.PtrFloat64(1047.7)))
Expect(location.Latitude).To(Equal(libregraph.PtrFloat64(49.48675890884328)))
Expect(location.Longitude).To(Equal(libregraph.PtrFloat64(11.103870357204285)))
})
It("returns the image facet if metadata is available", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
MimeType: "image/jpeg",
ArbitraryMetadata: &provider.ArbitraryMetadata{
Metadata: map[string]string{
"libre.graph.image.width": "1234",
"libre.graph.image.height": "987",
},
},
},
},
}, nil)
res := assertItemsList(1)
image := res.Value[0].Image
Expect(image).ToNot(BeNil())
Expect(image.Width).To(Equal(libregraph.PtrInt32(1234)))
Expect(image.Height).To(Equal(libregraph.PtrInt32(987)))
})
It("returns the photo facet if metadata is available", func() {
gatewayClient.On("ListContainer", mock.Anything, mock.Anything).Return(&provider.ListContainerResponse{
Status: status.NewOK(ctx),
Infos: []*provider.ResourceInfo{
{
Type: provider.ResourceType_RESOURCE_TYPE_FILE,
Id: &provider.ResourceId{StorageId: "storageid", SpaceId: "spaceid", OpaqueId: "opaqueid"},
Etag: "etag",
Mtime: utils.TimeToTS(mtime),
MimeType: "image/jpeg",
ArbitraryMetadata: &provider.ArbitraryMetadata{
Metadata: map[string]string{
"libre.graph.photo.cameraMake": "Canon",
"libre.graph.photo.cameraModel": "Cannon EOS 5D Mark III",
"libre.graph.photo.exposureDenominator": "100",
"libre.graph.photo.exposureNumerator": "1",
"libre.graph.photo.fNumber": "1.8",
"libre.graph.photo.focalLength": "50",
"libre.graph.photo.iso": "400",
"libre.graph.photo.orientation": "1",
"libre.graph.photo.takenDateTime": "2018-01-01T12:34:56Z",
},
},
},
},
}, nil)
res := assertItemsList(1)
photo := res.Value[0].Photo
Expect(photo).ToNot(BeNil())
Expect(photo.CameraMake).To(Equal(libregraph.PtrString("Canon")))
Expect(photo.CameraModel).To(Equal(libregraph.PtrString("Cannon EOS 5D Mark III")))
Expect(photo.ExposureDenominator).To(Equal(libregraph.PtrFloat64(100)))
Expect(photo.ExposureNumerator).To(Equal(libregraph.PtrFloat64(1)))
Expect(photo.FNumber).To(Equal(libregraph.PtrFloat64(1.8)))
Expect(photo.FocalLength).To(Equal(libregraph.PtrFloat64(50)))
Expect(photo.Iso).To(Equal(libregraph.PtrInt32(400)))
Expect(photo.Orientation).To(Equal(libregraph.PtrInt32(1)))
Expect(photo.TakenDateTime).To(Equal(libregraph.PtrTime(time.Date(2018, 1, 1, 12, 34, 56, 0, time.UTC))))
})
})
})
})
@@ -0,0 +1,44 @@
package svc
import (
"net/url"
"testing"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/qsfera/server/pkg/log"
)
func TestCS3ResourceToDriveItemPopulatesWebUrl(t *testing.T) {
logger := log.NewLogger()
res := &provider.ResourceInfo{
Id: &provider.ResourceId{
StorageId: "storage-1",
SpaceId: "space-1",
OpaqueId: "item-1",
},
Type: provider.ResourceType_RESOURCE_TYPE_CONTAINER,
}
t.Run("public base URL without path", func(t *testing.T) {
base, err := url.Parse("https://example.com")
require.NoError(t, err)
item, err := cs3ResourceToDriveItem(&logger, base, res)
require.NoError(t, err)
require.NotNil(t, item.WebUrl)
assert.Equal(t, "https://example.com/f/storage-1$space-1%21item-1", *item.WebUrl)
})
t.Run("public base URL with path prefix", func(t *testing.T) {
base, err := url.Parse("https://example.com/cloud")
require.NoError(t, err)
item, err := cs3ResourceToDriveItem(&logger, base, res)
require.NoError(t, err)
require.NotNil(t, item.WebUrl)
assert.Equal(t, "https://example.com/cloud/f/storage-1$space-1%21item-1", *item.WebUrl)
})
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,197 @@
package svc
import (
"testing"
"time"
"github.com/CiscoM31/godata"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
type sortTest struct {
Drives []*libregraph.Drive
Query godata.GoDataRequest
DrivesSorted []*libregraph.Drive
}
var time1 = time.Date(2022, 02, 02, 15, 00, 00, 00, time.UTC)
var time2 = time.Date(2022, 02, 03, 15, 00, 00, 00, time.UTC)
var time3, time5, time6 *time.Time
var time4 = time.Date(2022, 02, 05, 15, 00, 00, 00, time.UTC)
var drives = []*libregraph.Drive{
drive("3", "project", "Admin", time3),
drive("1", "project", "Alan", &time1),
drive("2", "project", "Mary", &time2),
drive("4", "project", "Margaret", &time4),
}
var drivesLong = append(drives, []*libregraph.Drive{
drive("5", "project", "bob", time5),
drive("6", "project", "alice", time6),
}...)
var sortTests = []sortTest{
{
Drives: drives,
Query: godata.GoDataRequest{
Query: &godata.GoDataQuery{
OrderBy: &godata.GoDataOrderByQuery{
OrderByItems: []*godata.OrderByItem{
{Field: &godata.Token{Value: "name"}, Order: "asc"},
},
},
},
},
DrivesSorted: []*libregraph.Drive{
drive("3", "project", "Admin", time3),
drive("1", "project", "Alan", &time1),
drive("4", "project", "Margaret", &time4),
drive("2", "project", "Mary", &time2),
},
},
{
Drives: drives,
Query: godata.GoDataRequest{
Query: &godata.GoDataQuery{
OrderBy: &godata.GoDataOrderByQuery{
OrderByItems: []*godata.OrderByItem{
{Field: &godata.Token{Value: "name"}, Order: _sortDescending},
},
},
},
},
DrivesSorted: []*libregraph.Drive{
drive("2", "project", "Mary", &time2),
drive("4", "project", "Margaret", &time4),
drive("1", "project", "Alan", &time1),
drive("3", "project", "Admin", time3),
},
},
{
Drives: drivesLong,
Query: godata.GoDataRequest{
Query: &godata.GoDataQuery{
OrderBy: &godata.GoDataOrderByQuery{
OrderByItems: []*godata.OrderByItem{
{Field: &godata.Token{Value: "lastModifiedDateTime"}, Order: "asc"},
},
},
},
},
DrivesSorted: []*libregraph.Drive{
drive("3", "project", "Admin", time3),
drive("6", "project", "alice", time6),
drive("5", "project", "bob", time5),
drive("1", "project", "Alan", &time1),
drive("2", "project", "Mary", &time2),
drive("4", "project", "Margaret", &time4),
},
},
{
Drives: drivesLong,
Query: godata.GoDataRequest{
Query: &godata.GoDataQuery{
OrderBy: &godata.GoDataOrderByQuery{
OrderByItems: []*godata.OrderByItem{
{Field: &godata.Token{Value: "lastModifiedDateTime"}, Order: _sortDescending},
},
},
},
},
DrivesSorted: []*libregraph.Drive{
drive("4", "project", "Margaret", &time4),
drive("2", "project", "Mary", &time2),
drive("1", "project", "Alan", &time1),
drive("5", "project", "bob", time5),
drive("6", "project", "alice", time6),
drive("3", "project", "Admin", time3),
},
},
}
func drive(ID string, dType string, name string, lastModified *time.Time) *libregraph.Drive {
return &libregraph.Drive{Id: libregraph.PtrString(ID), DriveType: libregraph.PtrString(dType), Name: name, LastModifiedDateTime: lastModified, Quota: &libregraph.Quota{}}
}
// TestSort tests the available orderby queries
func TestSort(t *testing.T) {
for _, test := range sortTests {
sorted, err := sortSpaces(&test.Query, test.Drives)
assert.NoError(t, err)
assert.Equal(t, test.DrivesSorted, sorted)
}
}
// TestSortNameNatural verifies that sorting drives by name uses a natural
// (numeric-aware) order, so "Project 10" sorts after "Project 2".
func TestSortNameNatural(t *testing.T) {
input := []*libregraph.Drive{
drive("a", "project", "Project 10", nil),
drive("b", "project", "Project 2", nil),
drive("c", "project", "Project 1", nil),
drive("d", "project", "Project 10a", nil),
}
want := []string{"Project 1", "Project 2", "Project 10", "Project 10a"}
query := godata.GoDataRequest{
Query: &godata.GoDataQuery{
OrderBy: &godata.GoDataOrderByQuery{
OrderByItems: []*godata.OrderByItem{
{Field: &godata.Token{Value: "name"}, Order: "asc"},
},
},
},
}
sorted, err := sortSpaces(&query, input)
require.NoError(t, err)
got := make([]string, 0, len(sorted))
for _, d := range sorted {
got = append(got, d.GetName())
}
assert.Equal(t, want, got)
// Descending must invert the natural order.
query.Query.OrderBy.OrderByItems[0].Order = _sortDescending
sortedDesc, err := sortSpaces(&query, input)
require.NoError(t, err)
gotDesc := make([]string, 0, len(sortedDesc))
for _, d := range sortedDesc {
gotDesc = append(gotDesc, d.GetName())
}
assert.Equal(t, []string{"Project 10a", "Project 10", "Project 2", "Project 1"}, gotDesc)
}
func TestSpaceNameValidation(t *testing.T) {
// set max length
_maxSpaceNameLength = 10
testCases := []struct {
Alias string
SpaceName string
ExpectedError error
}{
{"Happy Path", "Space", nil},
{"Just not too Long", "abcdefghij", nil},
{"Too Long", "abcdefghijk", ErrNameTooLong},
{"Empty", "", ErrNameEmpty},
{"Contains /", "Space/", ErrForbiddenCharacter},
{`Contains \`, `Space\`, ErrForbiddenCharacter},
{`Contains \\`, `Space\\`, ErrForbiddenCharacter},
{"Contains .", "Space.", ErrForbiddenCharacter},
{"Contains :", "Space:", ErrForbiddenCharacter},
{"Contains ?", "Sp?ace", ErrForbiddenCharacter},
{"Contains *", "Spa*ce", ErrForbiddenCharacter},
{`Contains "`, `"Space"`, ErrForbiddenCharacter},
{`Contains >`, `Sp>ce`, ErrForbiddenCharacter},
{`Contains <`, `S<pce`, ErrForbiddenCharacter},
{`Contains |`, `S|p|e`, ErrForbiddenCharacter},
}
for _, tc := range testCases {
err := validateSpaceName(tc.SpaceName)
require.Equal(t, tc.ExpectedError, err, tc.Alias)
}
// set max length back to protect other tests
_maxSpaceNameLength = 255
}
@@ -0,0 +1,578 @@
package svc
import (
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"github.com/CiscoM31/godata"
"github.com/qsfera/server/services/graph/pkg/errorcode"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
// GetEducationClasses implements the Service interface.
func (g Graph) GetEducationClasses(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling GetEducationClasses")
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg(
"could not get educationClasses: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
classes, err := g.identityEducationBackend.GetEducationClasses(r.Context())
if err != nil {
logger.Debug().Err(err).Msg("could not get classes: backend error")
errorcode.RenderError(w, r, err)
return
}
classes, err = sortClasses(odataReq, classes)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get classes: could not sort classes according to query")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: classes})
}
// PostEducationClass implements the Service interface.
func (g Graph) PostEducationClass(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling post EducationClass")
class := libregraph.NewEducationClassWithDefaults()
err := StrictJSONUnmarshal(r.Body, class)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create education class: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
if _, ok := class.GetDisplayNameOk(); !ok {
logger.Debug().Err(err).Interface("class", class).Msg("could not create class: missing required attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute")
return
}
// Disallow user-supplied IDs. It's supposed to be readonly. We're either
// generating them in the backend ourselves or rely on the Backend's
// storage (e.g. LDAP) to provide a unique ID.
if _, ok := class.GetIdOk(); ok {
logger.Debug().Msg("could not create class: id is a read-only attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "class id is a read-only attribute")
return
}
if class, err = g.identityEducationBackend.CreateEducationClass(r.Context(), *class); err != nil {
logger.Debug().Interface("class", class).Msg("could not create class: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
if class != nil && class.Id != nil {
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(events.EducationClassCreated{Executant: currentUser.Id, EducationClassID: *class.Id})
}
*/
render.Status(r, http.StatusCreated)
render.JSON(w, r, class)
}
// PatchEducationClass implements the Service interface.
func (g Graph) PatchEducationClass(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("calling patch education class")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Str("id", classID).Msg("could not change class: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not change class: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
changes := libregraph.NewEducationClassWithDefaults()
err = StrictJSONUnmarshal(r.Body, changes)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not change class: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
var features []events.GroupFeature
if displayName, ok := changes.GetDisplayNameOk(); ok {
features = append(features, events.GroupFeature{Name: "displayname", Value: *displayName})
}
if externalID, ok := changes.GetExternalIdOk(); ok {
features = append(features, events.GroupFeature{Name: "externalid", Value: *externalID})
}
_, err = g.identityEducationBackend.UpdateEducationClass(r.Context(), classID, *changes)
if err != nil {
logger.Error().
Err(err).
Str("classID", classID).
Msg("could not update class")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
if memberRefs, ok := changes.GetMembersodataBindOk(); ok {
// The spec defines a limit of 20 members maxium per Request
if len(memberRefs) > g.config.API.GroupMembersPatchLimit {
logger.Debug().
Int("number", len(memberRefs)).
Int("limit", g.config.API.GroupMembersPatchLimit).
Msg("could not add group members, exceeded members limit")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
fmt.Sprintf("Request is limited to %d members", g.config.API.GroupMembersPatchLimit))
return
}
memberIDs := make([]string, 0, len(memberRefs))
for _, memberRef := range memberRefs {
memberType, id, err := g.parseMemberRef(memberRef)
if err != nil {
logger.Debug().
Str("memberref", memberRef).
Msg("could not change class: Error parsing member@odata.bind values")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing member@odata.bind values")
return
}
logger.Debug().Str("membertype", memberType).Str("memberid", id).Msg("add class member")
// The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact"
// we restrict this to users for now. Might add Classes as members later
if memberType != memberTypeUsers {
logger.Debug().
Str("type", memberType).
Msg("could not change class: could not add member, only user type is allowed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only user are allowed as class members")
return
}
memberIDs = append(memberIDs, id)
}
err = g.identityBackend.AddMembersToGroup(r.Context(), classID, memberIDs)
}
if err != nil {
logger.Debug().Err(err).Msg("could not change class: backend could not add members")
errorcode.RenderError(w, r, err)
return
}
if len(features) > 0 {
e := events.GroupFeatureChanged{
GroupID: classID,
Features: features,
}
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(r.Context(), e)
}
render.Status(r, http.StatusNoContent) // TODO StatusNoContent when prefer=minimal is used, otherwise OK and the resource in the body
render.NoContent(w, r)
}
// GetEducationClass implements the Service interface.
func (g Graph) GetEducationClass(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("calling get education class")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Str("id", classID).Msg("could not get class: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
}
if classID == "" {
logger.Debug().Msg("could not get class: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
logger.Debug().
Str("id", classID).
Interface("query", r.URL.Query()).
Msg("calling get class on backend")
class, err := g.identityEducationBackend.GetEducationClass(r.Context(), classID)
if err != nil {
logger.Debug().Err(err).Msg("could not get class: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, class)
}
// DeleteEducationClass implements the Service interface.
func (g Graph) DeleteEducationClass(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("calling delete class")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Err(err).Str("id", classID).Msg("could not delete class: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not delete class: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
logger.Debug().Str("id", classID).Msg("calling delete class on backend")
err = g.identityEducationBackend.DeleteEducationClass(r.Context(), classID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete class: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(events.ClassDeleted{Executant: currentUser.Id, ClassID: classID})
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// GetEducationClassMembers implements the Service interface.
func (g Graph) GetEducationClassMembers(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("calling get class members")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Str("id", classID).Msg("could not get class members: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not get class members: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
logger.Debug().Str("id", classID).Msg("calling get class members on backend")
members, err := g.identityEducationBackend.GetEducationClassMembers(r.Context(), classID)
if err != nil {
logger.Debug().Err(err).Msg("could not get class members: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, members)
}
// PostEducationClassMember implements the Service interface.
func (g Graph) PostEducationClassMember(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("Calling post class member")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().
Err(err).
Str("id", classID).
Msg("could not add member to class: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not add class member: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
memberRef := libregraph.NewMemberReference()
err = StrictJSONUnmarshal(r.Body, memberRef)
if err != nil {
logger.Debug().
Err(err).
Interface("body", r.Body).
Msg("could not add class member: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
memberRefURL, ok := memberRef.GetOdataIdOk()
if !ok {
logger.Debug().Msg("could not add class member: @odata.id reference is missing")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
return
}
memberType, id, err := g.parseMemberRef(*memberRefURL)
if err != nil {
logger.Debug().Err(err).Msg("could not add class member: error parsing @odata.id url")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
return
}
// The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact"
// we restrict this to users for now. Might add EducationClass as members later
if memberType != memberTypeUsers {
logger.Debug().Str("type", memberType).Msg("could not add class member: Only users are allowed as class members")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as class members")
return
}
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add member on backend")
err = g.identityBackend.AddMembersToGroup(r.Context(), classID, []string{id})
if err != nil {
logger.Debug().Err(err).Msg("could not add class member: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(events.EducationClassMemberAdded{Executant: currentUser.Id, EducationClassID: classID, UserID: id})
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteEducationClassMember implements the Service interface.
func (g Graph) DeleteEducationClassMember(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
memberID := chi.URLParam(r, "memberID")
logger.Info().Str("classID", classID).Str("memberID", memberID).Msg("calling delete class member")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Err(err).Str("id", classID).Msg("could not delete class member: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not delete class member: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
memberID, err = url.PathUnescape(memberID)
if err != nil {
logger.Debug().Err(err).Str("id", memberID).Msg("could not delete class member: unescaping member id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping member id failed")
return
}
if memberID == "" {
logger.Debug().Msg("could not delete class member: missing member id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id")
return
}
logger.Debug().Str("classID", classID).Str("memberID", memberID).Msg("calling delete member on backend")
err = g.identityBackend.RemoveMemberFromGroup(r.Context(), classID, memberID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete class member: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(events.EducationClassMemberRemoved{Executant: currentUser.Id, EducationClassID: classID, UserID: memberID})
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// GetEducationClassTeachers implements the Service interface.
func (g Graph) GetEducationClassTeachers(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("calling get class teachers")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Str("id", classID).Msg("could not get class teachers: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not get class teachers: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
logger.Debug().Str("id", classID).Msg("calling get class teachers on backend")
teachers, err := g.identityEducationBackend.GetEducationClassTeachers(r.Context(), classID)
if err != nil {
logger.Debug().Err(err).Msg("could not get class teachers: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, teachers)
}
// PostEducationClassTeacher implements the Service interface.
func (g Graph) PostEducationClassTeacher(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
logger.Info().Str("classID", classID).Msg("Calling post class teacher")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().
Err(err).
Str("id", classID).
Msg("could not add teacher to class: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not add class teacher: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
memberRef := libregraph.NewMemberReference()
err = StrictJSONUnmarshal(r.Body, memberRef)
if err != nil {
logger.Debug().
Err(err).
Interface("body", r.Body).
Msg("could not add class teacher: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
memberRefURL, ok := memberRef.GetOdataIdOk()
if !ok {
logger.Debug().Msg("could not add class teacher: @odata.id reference is missing")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
return
}
memberType, id, err := g.parseMemberRef(*memberRefURL)
if err != nil {
logger.Debug().Err(err).Msg("could not add class teacher: error parsing @odata.id url")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
return
}
// The MS Graph spec allows "directoryObject", "user", "class" and "organizational Contact"
// we restrict this to users for now. Might add EducationClass as members later
if memberType != memberTypeUsers {
logger.Debug().Str("type", memberType).Msg("could not add class member: Only users are allowed as class teachers")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as class teachers")
return
}
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add teacher on backend")
err = g.identityEducationBackend.AddTeacherToEducationClass(r.Context(), classID, id)
if err != nil {
logger.Debug().Err(err).Msg("could not add class teacher: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(events.EducationClassTeacherAdded{Executant: currentUser.Id, EducationClassID: classID, UserID: id})
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteEducationClassTeacher implements the Service interface.
func (g Graph) DeleteEducationClassTeacher(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
classID := chi.URLParam(r, "classID")
teacherID := chi.URLParam(r, "teacherID")
logger.Info().Str("classID", classID).Str("teacherID", teacherID).Msg("calling delete class teacher")
classID, err := url.PathUnescape(classID)
if err != nil {
logger.Debug().Err(err).Str("id", classID).Msg("could not delete class teacher: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not delete class teacher: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
teacherID, err = url.PathUnescape(teacherID)
if err != nil {
logger.Debug().Err(err).Str("id", teacherID).Msg("could not delete class teacher: unescaping teacher id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping teacher id failed")
return
}
if teacherID == "" {
logger.Debug().Msg("could not delete class teacher: missing teacher id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing teacher id")
return
}
logger.Debug().Str("classID", classID).Str("teacherID", teacherID).Msg("calling delete teacher on backend")
err = g.identityEducationBackend.RemoveTeacherFromEducationClass(r.Context(), classID, teacherID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete class teacher: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
currentUser := revactx.ContextMustGetUser(r.Context())
g.publishEvent(events.EducationClassTeacherRemoved{Executant: currentUser.Id, EducationClassID: classID, UserID: teacherID})
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
func sortClasses(req *godata.GoDataRequest, classes []*libregraph.EducationClass) ([]*libregraph.EducationClass, error) {
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
return classes, nil
}
var less func(i, j int) bool
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
case displayNameAttr:
less = func(i, j int) bool {
return strings.ToLower(classes[i].GetDisplayName()) < strings.ToLower(classes[j].GetDisplayName())
}
default:
return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
}
if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending {
sort.Slice(classes, reverse(less))
} else {
sort.Slice(classes, less)
}
return classes, nil
}
@@ -0,0 +1,658 @@
package svc_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/graph/pkg/errorcode"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
var _ = Describe("EducationClass", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
identityBackend *identitymocks.Backend
identityEducationBackend *identitymocks.EducationBackend
rr *httptest.ResponseRecorder
newClass *libregraph.EducationClass
currentUser = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "user",
},
}
)
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
identityEducationBackend = &identitymocks.EducationBackend{}
identityBackend = &identitymocks.Backend{}
newClass = libregraph.NewEducationClass("math", "course")
newClass.SetMembersodataBind([]string{"/users/user1"})
newClass.SetId("math")
rr = httptest.NewRecorder()
ctx = context.Background()
cfg = defaults.FullDefaultConfig()
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
cfg.TokenManager.JWTSecret = "loremipsum"
cfg.Commons = &shared.Commons{}
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
service.WithIdentityEducationBackend(identityEducationBackend),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("GetEducationClasses", func() {
It("handles invalid ODATA parameters", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes?§foo=bar", nil)
svc.GetEducationClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles invalid sorting queries", func() {
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{newClass}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes?$orderby=invalid", nil)
svc.GetEducationClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("invalidRequest"))
})
It("handles unknown backend errors", func() {
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return(nil, errors.New("failed"))
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
svc.GetEducationClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("generalException"))
})
It("handles backend errors", func() {
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
svc.GetEducationClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("accessDenied"))
})
It("renders an empty list of classes", func() {
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
svc.GetEducationClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := service.ListResponse{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(res.Value).To(Equal([]any{}))
})
It("renders a list of classes", func() {
identityEducationBackend.On("GetEducationClasses", ctx, mock.Anything).Return([]*libregraph.EducationClass{newClass}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
svc.GetEducationClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := groupList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
Expect(res.Value[0].GetId()).To(Equal("math"))
})
})
Describe("GetEducationClass", func() {
It("handles missing or empty class id", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
svc.GetEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", "")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
svc.GetEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
Context("with an existing class", func() {
BeforeEach(func() {
identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
})
It("gets the class", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/"+*newClass.Id, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
svc.GetEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
})
})
})
Describe("PostEducationClass", func() {
It("handles invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBufferString("{invalid"))
svc.PostEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing display name", func() {
newClass = libregraph.NewEducationClassWithDefaults()
newClass.SetId("disallowed")
newClass.SetMembersodataBind([]string{"/non-users/user"})
newClassJson, err := json.Marshal(newClass)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBuffer(newClassJson))
svc.PostEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("disallows group create ids", func() {
newClass = libregraph.NewEducationClassWithDefaults()
newClass.SetId("disallowed")
newClass.SetDisplayName("New Class")
newClass.SetMembersodataBind([]string{"/non-users/user"})
newClassJson, err := json.Marshal(newClass)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/", bytes.NewBuffer(newClassJson))
svc.PostEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("creates the Class", func() {
newClass = libregraph.NewEducationClassWithDefaults()
newClass.SetDisplayName("New Class")
newClassJson, err := json.Marshal(newClass)
Expect(err).ToNot(HaveOccurred())
identityEducationBackend.On("CreateEducationClass", mock.Anything, mock.Anything).Return(newClass, nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/class/", bytes.NewBuffer(newClassJson))
svc.PostEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusCreated))
})
})
Describe("PatchEducationClass", func() {
It("handles invalid body", func() {
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes/", bytes.NewBufferString("{invalid"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing or empty group id", func() {
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
svc.PatchEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
r = httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", "")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
Context("with an existing group", func() {
BeforeEach(func() {
identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
identityEducationBackend.On("UpdateEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
})
It("fails when the number of users is exceeded - spec says 20 max", func() {
updatedClass := libregraph.NewEducationClassWithDefaults()
updatedClass.SetDisplayName("class updated")
updatedClass.SetMembersodataBind([]string{
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
"19", "20", "21",
})
updatedClassJson, err := json.Marshal(updatedClass)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
resp, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
Expect(string(resp)).To(ContainSubstring("Request is limited to 20"))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("succeeds when the number of users is over 20 but the limit is raised to 21", func() {
updatedClass := libregraph.NewEducationClassWithDefaults()
updatedClass.SetDisplayName("group1 updated")
updatedClass.SetMembersodataBind([]string{
"1", "2", "3", "4", "5", "6", "7", "8", "9", "10", "11", "12", "13", "14", "15", "16", "17", "18",
"19", "20", "21",
})
updatedClassJson, err := json.Marshal(updatedClass)
Expect(err).ToNot(HaveOccurred())
cfg.API.GroupMembersPatchLimit = 21
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityBackend(identityBackend),
service.WithIdentityEducationBackend(identityEducationBackend),
)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
resp, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
Expect(string(resp)).To(ContainSubstring("Error parsing member@odata.bind values"))
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on invalid user refs", func() {
updatedClass := libregraph.NewEducationClassWithDefaults()
updatedClass.SetDisplayName("class updated")
updatedClass.SetMembersodataBind([]string{"invalid"})
updatedClassJson, err := json.Marshal(updatedClass)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails when the adding non-users users", func() {
updatedClass := libregraph.NewEducationClassWithDefaults()
updatedClass.SetDisplayName("group1 updated")
updatedClass.SetMembersodataBind([]string{"/non-users/user1"})
updatedClassJson, err := json.Marshal(updatedClass)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("adds members to the class", func() {
identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
updatedClass := libregraph.NewEducationClassWithDefaults()
updatedClass.SetDisplayName("Class updated")
updatedClass.SetMembersodataBind([]string{"/users/user1"})
updatedClassJson, err := json.Marshal(updatedClass)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", bytes.NewBuffer(updatedClassJson))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
})
})
})
Describe("DeleteEducationClass", func() {
Context("with an existing EducationClass", func() {
BeforeEach(func() {
identityEducationBackend.On("GetEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(newClass, nil)
})
})
It("deletes the EducationClass", func() {
identityEducationBackend.On("DeleteEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/classes", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationClass", 1)
// eventsPublisher.AssertNumberOfCalls(GinkgoT(), "Publish", 1)
})
})
Describe("GetEducationClassMembers", func() {
It("gets the list of members", func() {
user := libregraph.NewEducationUser()
user.SetId("user")
identityEducationBackend.On("GetEducationClassMembers", mock.Anything, mock.Anything, mock.Anything).
Return([]*libregraph.EducationUser{user}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/{classID}/members", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetEducationClassMembers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
var members []*libregraph.EducationUser
err = json.Unmarshal(data, &members)
Expect(err).ToNot(HaveOccurred())
Expect(len(members)).To(Equal(1))
Expect(members[0].GetId()).To(Equal("user"))
})
})
Describe("PostEducationClassMember", func() {
It("fails on invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBufferString("{invalid"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on missing member refs", func() {
member := libregraph.NewMemberReference()
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on invalid member refs", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/invalidtype/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("adds a new member", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/users/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
identityBackend.On("AddMembersToGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityBackend.AssertNumberOfCalls(GinkgoT(), "AddMembersToGroup", 1)
})
})
Describe("DeleteEducationClassMembers", func() {
It("handles missing or empty member id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing or empty member id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("memberID", "/users/user")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("deletes members", func() {
identityBackend.On("RemoveMemberFromGroup", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/members/{memberID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
rctx.URLParams.Add("memberID", "/users/user1")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClassMember(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityBackend.AssertNumberOfCalls(GinkgoT(), "RemoveMemberFromGroup", 1)
})
})
Describe("GetEducationClassTeachers", func() {
It("gets the list of teachers", func() {
user := libregraph.NewEducationUser()
user.SetId("user")
identityEducationBackend.On("GetEducationClassTeachers", mock.Anything, mock.Anything, mock.Anything).
Return([]*libregraph.EducationUser{user}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/classes/{classID}/teachers", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetEducationClassTeachers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
var teachers []*libregraph.EducationUser
err = json.Unmarshal(data, &teachers)
Expect(err).ToNot(HaveOccurred())
Expect(len(teachers)).To(Equal(1))
Expect(teachers[0].GetId()).To(Equal("user"))
})
})
Describe("PostEducationClassTeacher", func() {
It("fails on invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/teachers", bytes.NewBufferString("{invalid"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on missing teacher refs", func() {
member := libregraph.NewMemberReference()
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/teachers", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on invalid teacher refs", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/invalidtype/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/teachers", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("adds a new teacher", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/users/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
identityEducationBackend.On("AddTeacherToEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/classes/{classID}/teachers", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "AddTeacherToEducationClass", 1)
})
})
Describe("DeleteEducationClassTeacher", func() {
It("handles missing or empty teacher id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/teachers/{teacherID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing or empty teacher id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/teachers/{teacherID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("teacherID", "/users/user")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("deletes teacher", func() {
identityEducationBackend.On("RemoveTeacherFromEducationClass", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/classes/{classID}/teachers/{teacherID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("classID", *newClass.Id)
rctx.URLParams.Add("teacherID", "/users/user1")
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationClassTeacher(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveTeacherFromEducationClass", 1)
})
})
})
@@ -0,0 +1,631 @@
package svc
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"time"
"github.com/CiscoM31/godata"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
"github.com/qsfera/server/services/graph/pkg/identity"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
)
// GetEducationSchools implements the Service interface.
func (g Graph) GetEducationSchools(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("query", r.URL.Query()).Msg("calling get schools")
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get schools: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
schools, err := g.getEducationSchoolsFromBackend(r.Context(), odataReq)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get schools")
renderEqualityFilterError(w, r, err)
return
}
schools, err = sortEducationSchools(odataReq, schools)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("cannot get schools: could not sort schools according to query")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: schools})
}
// PostEducationSchool implements the Service interface.
func (g Graph) PostEducationSchool(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Msg("calling post school")
school := libregraph.NewEducationSchool()
err := StrictJSONUnmarshal(r.Body, school)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create school: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
// Disallow user-supplied IDs. It's supposed to be readonly. We're either
// generating them in the backend ourselves or rely on the Backend's
// storage (e.g. LDAP) to provide a unique ID.
if _, ok := school.GetIdOk(); ok {
logger.Debug().Msg("could not create school: id is a read-only attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "school id is a read-only attribute")
return
}
if _, ok := school.GetDisplayNameOk(); !ok {
logger.Debug().Interface("school", school).Msg("could not create school: missing required attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Missing Required Attribute")
return
}
// validate terminationDate attribute, needs to be "far enough" in the future, terminationDate can be nil (means
// termination date is to be deleted
if terminationDate, ok := school.GetTerminationDateOk(); ok && terminationDate != nil {
err = g.validateTerminationDate(*terminationDate)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
}
return
}
if school, err = g.identityEducationBackend.CreateEducationSchool(r.Context(), *school); err != nil {
logger.Debug().Err(err).Interface("school", school).Msg("could not create school: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
if school != nil && school.Id != nil {
e := events.SchoolCreated{SchoolID: *school.Id}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
}
*/
render.Status(r, http.StatusCreated)
render.JSON(w, r, school)
}
// PatchEducationSchool implements the Service interface.
func (g Graph) PatchEducationSchool(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("calling patch school")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Str("id", schoolID).Msg("could not update school: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not update school: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
school := libregraph.NewEducationSchool()
err = StrictJSONUnmarshal(r.Body, school)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not update school: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
// validate terminationDate attribute, needs to be "far enough" in the future, terminationDate can be nil (means
// termination date is to be deleted
if terminationDate, ok := school.GetTerminationDateOk(); ok && terminationDate != nil {
err = g.validateTerminationDate(*terminationDate)
if err != nil {
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
}
if school, err = g.identityEducationBackend.UpdateEducationSchool(r.Context(), schoolID, *school); err != nil {
logger.Debug().Err(err).Interface("school", school).Msg("could not update school: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
e := events.SchoolFeatureChanged{SchoolID: schoolID}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
*/
render.Status(r, http.StatusOK)
render.JSON(w, r, school)
}
// GetEducationSchool implements the Service interface.
func (g Graph) GetEducationSchool(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("calling get school")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Str("id", schoolID).Msg("could not get school: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
}
if schoolID == "" {
logger.Debug().Msg("could not get school: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
logger.Debug().
Str("id", schoolID).
Interface("query", r.URL.Query()).
Msg("calling get school on backend")
school, err := g.identityEducationBackend.GetEducationSchool(r.Context(), schoolID)
if err != nil {
logger.Debug().Err(err).Msg("could not get school: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, school)
}
// DeleteEducationSchool implements the Service interface.
func (g Graph) DeleteEducationSchool(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("calling delete school")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not delete school: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
// Read school and check if termination date is set
school, err := g.identityEducationBackend.GetEducationSchool(r.Context(), schoolID)
if err != nil {
logger.Debug().Err(err).Msg("could not get school: backend error")
errorcode.RenderError(w, r, err)
return
}
termination, ok := school.GetTerminationDateOk()
if !ok {
logger.Debug().Msg("cannot delete school: not termination date set")
errorcode.NotAllowed.Render(w, r, http.StatusMethodNotAllowed, "no termination date set")
return
}
if time.Now().Before(*termination) {
logger.Debug().Time("terminationDate", *termination).Msg("cannot delete school: termination date not reached")
errorcode.NotAllowed.Render(w, r, http.StatusMethodNotAllowed, "can't delete school before termination date")
return
}
logger.Debug().Str("schoolID", schoolID).Msg("Getting users of school")
users, err := g.identityEducationBackend.GetEducationSchoolUsers(r.Context(), schoolID)
if err != nil {
logger.Debug().Err(err).Msg("could not get school users: backend error")
errorcode.RenderError(w, r, err)
return
}
for _, user := range users {
logger.Debug().Str("schoolID", schoolID).Str("userID", *user.Id).Msg("calling delete member on backend")
if err := g.identityEducationBackend.RemoveUserFromEducationSchool(r.Context(), schoolID, *user.Id); err != nil {
if errors.Is(err, identity.ErrNotFound) {
logger.Debug().Str("schoolID", schoolID).Str("userID", *user.Id).Msg("user not found")
continue
}
logger.Debug().Err(err).Msg("could not delete school member: backend error")
errorcode.RenderError(w, r, err)
// TODO Do we need return right hear?
}
}
logger.Debug().Str("id", schoolID).Msg("calling delete school on backend")
err = g.identityEducationBackend.DeleteEducationSchool(r.Context(), schoolID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete school: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
e := events.SchoolDeleted{SchoolID: schoolID}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// GetEducationSchoolUsers implements the Service interface.
func (g Graph) GetEducationSchoolUsers(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("calling get school users")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Str("id", schoolID).Msg("could not get school users: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not get school users: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
logger.Debug().Str("id", schoolID).Msg("calling get school users on backend")
users, err := g.identityEducationBackend.GetEducationSchoolUsers(r.Context(), schoolID)
if err != nil {
logger.Debug().Err(err).Msg("could not get school users: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, users)
}
// PostEducationSchoolUser implements the Service interface.
func (g Graph) PostEducationSchoolUser(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("Calling post school user")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().
Err(err).
Str("id", schoolID).
Msg("could not add user to school: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not add school user: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
memberRef := libregraph.NewMemberReference()
err = StrictJSONUnmarshal(r.Body, memberRef)
if err != nil {
logger.Debug().
Err(err).
Interface("body", r.Body).
Msg("could not add school user: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
memberRefURL, ok := memberRef.GetOdataIdOk()
if !ok {
logger.Debug().Msg("could not add school user: @odata.id reference is missing")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
return
}
memberType, id, err := g.parseMemberRef(*memberRefURL)
if err != nil {
logger.Debug().Err(err).Msg("could not add school user: error parsing @odata.id url")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
return
}
// The MS Graph spec allows "directoryObject", "user", "school" and "organizational Contact"
// we restrict this to users for now. Might add Schools as members later
if memberType != "users" {
logger.Debug().Str("type", memberType).Msg("could not add school user: Only users are allowed as school members")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only users are allowed as school members")
return
}
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add user on backend")
err = g.identityEducationBackend.AddUsersToEducationSchool(r.Context(), schoolID, []string{id})
if err != nil {
logger.Debug().Err(err).Msg("could not add school user: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
e := events.SchoolMemberAdded{SchoolID: schoolID, UserID: id}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteEducationSchoolUser implements the Service interface.
func (g Graph) DeleteEducationSchoolUser(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
userID := chi.URLParam(r, "userID")
logger.Info().Str("schoolID", schoolID).Str("userID", userID).Msg("calling delete school member")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school member: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not delete school member: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
userID, err = url.PathUnescape(userID)
if err != nil {
logger.Debug().Err(err).Str("id", userID).Msg("could not delete school member: unescaping member id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping member id failed")
return
}
if userID == "" {
logger.Debug().Msg("could not delete school member: missing member id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing member id")
return
}
logger.Debug().Str("schoolID", schoolID).Str("userID", userID).Msg("calling delete member on backend")
err = g.identityEducationBackend.RemoveUserFromEducationSchool(r.Context(), schoolID, userID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete school member: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
e := events.SchoolMemberRemoved{SchoolID: schoolID, UserID: userID}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// GetEducationSchoolClasses implements the Service interface.
func (g Graph) GetEducationSchoolClasses(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("calling get school classes")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Str("id", schoolID).Msg("could not get school users: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not get school users: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
logger.Debug().Str("id", schoolID).Msg("calling get school classes on backend")
classes, err := g.identityEducationBackend.GetEducationSchoolClasses(r.Context(), schoolID)
if err != nil {
logger.Debug().Err(err).Msg("could not get school classes: backend error")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, classes)
}
// PostEducationSchoolClass implements the Service interface.
func (g Graph) PostEducationSchoolClass(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
logger.Info().Str("schoolID", schoolID).Msg("Calling post school class")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().
Err(err).
Str("id", schoolID).
Msg("could not add class to school: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not add school class: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
memberRef := libregraph.NewMemberReference()
err = StrictJSONUnmarshal(r.Body, memberRef)
if err != nil {
logger.Debug().
Err(err).
Interface("body", r.Body).
Msg("could not add school class: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
memberRefURL, ok := memberRef.GetOdataIdOk()
if !ok {
logger.Debug().Msg("could not add school class: @odata.id reference is missing")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "@odata.id reference is missing")
return
}
memberType, id, err := g.parseMemberRef(*memberRefURL)
if err != nil {
logger.Debug().Err(err).Msg("could not add school class: error parsing @odata.id url")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Error parsing @odata.id url")
return
}
// The MS Graph spec allows "directoryObject", "user", "school" and "organizational Contact"
// we restrict this to users for now. Might add Schools as members later
if memberType != "classes" {
logger.Debug().Str("type", memberType).Msg("could not add school class: Only classes are allowed as school members")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "Only classes are allowed as school members")
return
}
logger.Debug().Str("memberType", memberType).Str("id", id).Msg("calling add class on backend")
err = g.identityEducationBackend.AddClassesToEducationSchool(r.Context(), schoolID, []string{id})
if err != nil {
logger.Debug().Err(err).Msg("could not add school class: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
e := events.SchoolMemberAdded{SchoolID: schoolID, UserID: id}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// DeleteEducationSchoolClass implements the Service interface.
func (g Graph) DeleteEducationSchoolClass(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
schoolID := chi.URLParam(r, "schoolID")
classID := chi.URLParam(r, "classID")
logger.Info().Str("schoolID", schoolID).Str("classID", classID).Msg("calling delete school class")
schoolID, err := url.PathUnescape(schoolID)
if err != nil {
logger.Debug().Err(err).Str("id", schoolID).Msg("could not delete school class: unescaping school id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping school id failed")
return
}
if schoolID == "" {
logger.Debug().Msg("could not delete school class: missing school id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing school id")
return
}
classID, err = url.PathUnescape(classID)
if err != nil {
logger.Debug().Err(err).Str("id", classID).Msg("could not delete school class: unescaping class id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping class id failed")
return
}
if classID == "" {
logger.Debug().Msg("could not delete school class: missing class id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing class id")
return
}
logger.Debug().Str("schoolID", schoolID).Str("classID", classID).Msg("calling delete class on backend")
err = g.identityEducationBackend.RemoveClassFromEducationSchool(r.Context(), schoolID, classID)
if err != nil {
logger.Debug().Err(err).Msg("could not delete school class: backend error")
errorcode.RenderError(w, r, err)
return
}
/* TODO requires reva changes
e := events.SchoolMemberRemoved{SchoolID: schoolID, UserID: userID}
if currentUser, ok := ctxpkg.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(e)
*/
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
func (g Graph) validateTerminationDate(terminationDate time.Time) error {
if terminationDate.Before(time.Now()) {
return fmt.Errorf("can not set a termination date in the past")
}
graceDays := g.config.Identity.LDAP.EducationConfig.SchoolTerminationGraceDays
if graceDays != 0 {
if terminationDate.Before(time.Now().Add(time.Duration(graceDays) * 24 * time.Hour)) {
return fmt.Errorf("termination needs to be at least %d day(s) in the future", graceDays)
}
}
return nil
}
func sortEducationSchools(req *godata.GoDataRequest, schools []*libregraph.EducationSchool) ([]*libregraph.EducationSchool, error) {
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
return schools, nil
}
var less func(i, j int) bool
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
case displayNameAttr:
less = func(i, j int) bool {
return strings.ToLower(schools[i].GetDisplayName()) < strings.ToLower(schools[j].GetDisplayName())
}
default:
return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
}
if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending {
sort.Slice(schools, reverse(less))
} else {
sort.Slice(schools, less)
}
return schools, nil
}
// getEducationSchoolsFromBackend fetches schools from the backend, applying an OData $filter if present.
func (g Graph) getEducationSchoolsFromBackend(ctx context.Context, odataReq *godata.GoDataRequest) ([]*libregraph.EducationSchool, error) {
if odataReq.Query.Filter != nil {
attr, value, err := g.getEqualityFilter(ctx, odataReq)
if err != nil {
return nil, err
}
return g.identityEducationBackend.FilterEducationSchoolsByAttribute(ctx, attr, value)
}
return g.identityEducationBackend.GetEducationSchools(ctx)
}
@@ -0,0 +1,618 @@
package svc_test
import (
"bytes"
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"net/url"
"time"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
ctxpkg "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/pkg/shared"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
"github.com/qsfera/server/services/graph/pkg/errorcode"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
type schoolList struct {
Value []*libregraph.EducationSchool
}
var _ = Describe("Schools", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
identityEducationBackend *identitymocks.EducationBackend
rr *httptest.ResponseRecorder
newSchool *libregraph.EducationSchool
currentUser = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "user",
},
}
)
BeforeEach(func() {
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
identityEducationBackend = &identitymocks.EducationBackend{}
newSchool = libregraph.NewEducationSchool()
newSchool.SetId("school1")
rr = httptest.NewRecorder()
ctx = context.Background()
cfg = defaults.FullDefaultConfig()
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
cfg.Identity.LDAP.EducationConfig.SchoolTerminationGraceDays = 30
cfg.TokenManager.JWTSecret = "loremipsum"
cfg.Commons = &shared.Commons{}
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.WithIdentityEducationBackend(identityEducationBackend),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("GetEducationSchools", func() {
It("handles invalid ODATA parameters", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?§foo=bar", nil)
svc.GetEducationSchools(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles invalid sorting queries", func() {
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{newSchool}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?$orderby=invalid", nil)
svc.GetEducationSchools(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("invalidRequest"))
})
It("handles unknown backend errors", func() {
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return(nil, errors.New("failed"))
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
svc.GetEducationSchools(rr, r)
Expect(rr.Code).To(Equal(http.StatusInternalServerError))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("generalException"))
})
It("handles backend errors", func() {
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
svc.GetEducationSchools(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
odataerr := libregraph.OdataError{}
err = json.Unmarshal(data, &odataerr)
Expect(err).ToNot(HaveOccurred())
Expect(odataerr.Error.Code).To(Equal("accessDenied"))
})
It("renders an empty list of schools", func() {
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
svc.GetEducationSchools(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := service.ListResponse{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(res.Value).To(Equal([]any{}))
})
It("renders a list of schools", func() {
identityEducationBackend.On("GetEducationSchools", ctx, mock.Anything).Return([]*libregraph.EducationSchool{newSchool}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
svc.GetEducationSchools(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := schoolList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
Expect(res.Value[0].GetId()).To(Equal("school1"))
})
When("used with a filter", func() {
It("fails with an unsupported filter", func() {
svc.GetEducationSchools(rr, httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?$filter="+url.QueryEscape("displayName ne 'test'"), nil))
Expect(rr.Code).To(Equal(http.StatusNotImplemented))
})
It("calls the backend with the externalId filter", func() {
identityEducationBackend.On("FilterEducationSchoolsByAttribute", mock.Anything, "externalId", "ext1234").Return([]*libregraph.EducationSchool{newSchool}, nil)
svc.GetEducationSchools(rr, httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools?$filter="+url.QueryEscape("externalId eq 'ext1234'"), nil))
Expect(rr.Code).To(Equal(http.StatusOK))
})
})
})
Describe("GetEducationSchool", func() {
It("handles missing or empty school id", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
svc.GetEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
r = httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", "")
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
svc.GetEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
Context("with an existing school", func() {
BeforeEach(func() {
identityEducationBackend.On("GetEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(newSchool, nil)
})
It("gets the school", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/"+*newSchool.Id, nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, nil), chi.RouteCtxKey, rctx))
svc.GetEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
})
})
})
Describe("PostEducationSchool", func() {
It("handles invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBufferString("{invalid"))
svc.PostEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing display name", func() {
newSchool = libregraph.NewEducationSchool()
newSchool.SetSchoolNumber("0034")
newSchoolJson, err := json.Marshal(newSchool)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
svc.PostEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("disallows school create ids", func() {
newSchool = libregraph.NewEducationSchool()
newSchool.SetId("disallowed")
newSchool.SetDisplayName("New School")
newSchool.SetSchoolNumber("0034")
newSchoolJson, err := json.Marshal(newSchool)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
svc.PostEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles backend errors", func() {
newSchool = libregraph.NewEducationSchool()
newSchool.SetDisplayName("New School")
newSchool.SetSchoolNumber("0034")
newSchoolJson, err := json.Marshal(newSchool)
Expect(err).ToNot(HaveOccurred())
identityEducationBackend.On("CreateEducationSchool", mock.Anything, mock.Anything).Return(nil, errorcode.New(errorcode.AccessDenied, "access denied"))
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
svc.PostEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
It("creates the school", func() {
newSchool = libregraph.NewEducationSchool()
newSchool.SetDisplayName("New School")
newSchool.SetSchoolNumber("0034")
newSchoolJson, err := json.Marshal(newSchool)
Expect(err).ToNot(HaveOccurred())
identityEducationBackend.On("CreateEducationSchool", mock.Anything, mock.Anything).Return(newSchool, nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/", bytes.NewBuffer(newSchoolJson))
svc.PostEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusCreated))
})
})
Describe("updating a School", func() {
schoolUpdate := libregraph.NewEducationSchool()
schoolUpdate.SetDisplayName("New School Name")
schoolUpdateJson, _ := json.Marshal(schoolUpdate)
schoolUpdatePast := libregraph.NewEducationSchool()
schoolUpdatePast.SetTerminationDate(time.Now().Add(-time.Hour * 1))
schoolUpdatePastJson, _ := json.Marshal(schoolUpdatePast)
schoolUpdateBeforeGrace := libregraph.NewEducationSchool()
schoolUpdateBeforeGrace.SetTerminationDate(time.Now().Add(24 * 10 * time.Hour))
schoolUpdateBeforeGraceJson, _ := json.Marshal(schoolUpdateBeforeGrace)
schoolUpdatePastGrace := libregraph.NewEducationSchool()
schoolUpdatePastGrace.SetTerminationDate(time.Now().Add(24 * 31 * time.Hour))
schoolUpdatePastGraceJson, _ := json.Marshal(schoolUpdatePastGrace)
BeforeEach(func() {
identityEducationBackend.On("UpdateEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(schoolUpdate, nil)
})
DescribeTable("PatchEducationSchool",
func(schoolId string, body io.Reader, statusCode int) {
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/schools/", body)
rctx := chi.NewRouteContext()
if schoolId != "" {
rctx.URLParams.Add("schoolID", schoolId)
}
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationSchool(rr, r)
Expect(rr.Code).To(Equal(statusCode))
},
Entry("handles invalid body", "school-id", bytes.NewBufferString("{invalid"), http.StatusBadRequest),
Entry("handles missing or empty school id", "", bytes.NewBufferString(""), http.StatusBadRequest),
Entry("handles malformed school id", "school%id", bytes.NewBuffer(schoolUpdateJson), http.StatusBadRequest),
Entry("updates the school", "school-id", bytes.NewBuffer(schoolUpdateJson), http.StatusOK),
Entry("fails to set a termination date in the past", "school-id", bytes.NewBuffer(schoolUpdatePastJson), http.StatusBadRequest),
Entry("fails to set a termination date before grace period", "school-id", bytes.NewBuffer(schoolUpdateBeforeGraceJson), http.StatusBadRequest),
Entry("succeeds to set a termination date past the grace period", "school-id", bytes.NewBuffer(schoolUpdatePastGraceJson), http.StatusOK),
)
})
Describe("DeleteEducationSchool", func() {
schoolWithFutureTermination := libregraph.NewEducationSchool()
schoolWithFutureTermination.SetId("schoolWithFutureTermination")
schoolWithFutureTermination.SetTerminationDate(time.Now().Add(time.Hour * 24))
schoolWithPastTermination := libregraph.NewEducationSchool()
schoolWithPastTermination.SetId("schoolWithPastTermination")
schoolWithPastTermination.SetTerminationDate(time.Now().Add(-time.Hour * 24))
Context("with an existing school", func() {
BeforeEach(func() {
identityEducationBackend.On("GetEducationSchool", mock.Anything, "school1").Return(newSchool, nil)
identityEducationBackend.On("GetEducationSchool", mock.Anything, "schoolWithFutureTermination", mock.Anything).Return(schoolWithFutureTermination, nil)
identityEducationBackend.On("GetEducationSchool", mock.Anything, "schoolWithPastTermination", mock.Anything).Return(schoolWithPastTermination, nil)
})
DescribeTable("checks terminnation date",
func(schoolId string, statusCode int) {
identityEducationBackend.On("DeleteEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
identityEducationBackend.On("GetEducationSchoolUsers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.EducationUser{}, nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", schoolId)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchool(rr, r)
Expect(rr.Code).To(Equal(statusCode))
if rr.Code == http.StatusNoContent {
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationSchool", 1)
} else {
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationSchool", 0)
}
},
Entry("fails when school has no termination date", "school1", http.StatusMethodNotAllowed),
Entry("fails when school has a termination date in the future", "schoolWithFutureTermination", http.StatusMethodNotAllowed),
Entry("succeeds when school has a termination date in the past", "schoolWithPastTermination", http.StatusNoContent),
)
It("removes the users from the school", func() {
user1 := libregraph.NewEducationUser()
user1.SetId("user1")
user2 := libregraph.NewEducationUser()
user2.SetId("user2")
identityEducationBackend.On("GetEducationSchoolUsers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.EducationUser{user1, user2}, nil)
identityEducationBackend.On("DeleteEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
identityEducationBackend.On("RemoveUserFromEducationSchool", mock.Anything, mock.Anything, *user1.Id).Return(nil)
identityEducationBackend.On("RemoveUserFromEducationSchool", mock.Anything, mock.Anything, *user2.Id).Return(nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", "schoolWithPastTermination")
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchool(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "DeleteEducationSchool", 1)
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveUserFromEducationSchool", 2)
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "GetEducationSchoolUsers", 1)
})
})
})
Describe("GetEducationSchoolUsers", func() {
It("gets the list of members", func() {
user := libregraph.NewEducationUser()
user.SetOnPremisesSamAccountName("user")
user.SetDisplayName("display name")
user.SetMail("mail")
user.SetId("user")
identityEducationBackend.On("GetEducationSchoolUsers", mock.Anything, mock.Anything, mock.Anything).Return([]*libregraph.EducationUser{user}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/{schoolID}/users", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetEducationSchoolUsers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
var members []*libregraph.User
err = json.Unmarshal(data, &members)
Expect(err).ToNot(HaveOccurred())
Expect(len(members)).To(Equal(1))
Expect(members[0].GetId()).To(Equal("user"))
})
})
Describe("PostEducationSchoolUsers", func() {
It("fails on invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBufferString("{invalid"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on missing member refs", func() {
member := libregraph.NewMemberReference()
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on invalid member refs", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/invalidtype/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("adds a new member", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/users/user")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
identityEducationBackend.On("AddUsersToEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "AddUsersToEducationSchool", 1)
})
})
Describe("DeleteEducationSchoolUsers", func() {
It("handles missing or empty member id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{userID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing or empty member id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{userID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", "/users/user")
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("deletes members", func() {
identityEducationBackend.On("RemoveUserFromEducationSchool", mock.Anything, mock.Anything, mock.Anything).Return(nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/members/{userID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
rctx.URLParams.Add("userID", "/users/user1")
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveUserFromEducationSchool", 1)
})
})
Describe("GetEducationSchoolClasses", func() {
It("gets the list of classes", func() {
class := libregraph.NewEducationClassWithDefaults()
class.SetId("class")
identityEducationBackend.On("GetEducationSchoolClasses", mock.Anything, *newSchool.Id).Return([]*libregraph.EducationClass{class}, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/schools/{schoolID}/classes", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetEducationSchoolClasses(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
var classes []*libregraph.EducationClass
err = json.Unmarshal(data, &classes)
Expect(err).ToNot(HaveOccurred())
Expect(len(classes)).To(Equal(1))
Expect(classes[0].GetId()).To(Equal("class"))
})
})
Describe("PostEducationSchoolClasses", func() {
It("fails on invalid body", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/classes", bytes.NewBufferString("{invalid"))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on missing member refs", func() {
member := libregraph.NewMemberReference()
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/classes", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("fails on invalid class refs", func() {
class := libregraph.NewMemberReference()
class.SetOdataId("/invalidtype/class")
data, err := json.Marshal(class)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/classesa", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("adds a new class", func() {
member := libregraph.NewMemberReference()
member.SetOdataId("/classes/class")
data, err := json.Marshal(member)
Expect(err).ToNot(HaveOccurred())
identityEducationBackend.On("AddClassesToEducationSchool", mock.Anything, *newSchool.Id, []string{"class"}).Return(nil)
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/schools/{schoolID}/members", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PostEducationSchoolClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "AddClassesToEducationSchool", 1)
})
})
Describe("DeleteEducationSchoolClass", func() {
It("handles missing or empty member id", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/classes/{classID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchoolClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("deletes members", func() {
identityEducationBackend.On("RemoveClassFromEducationSchool", mock.Anything, *newSchool.Id, "/classes/class1").Return(nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/schools/{schoolID}/classes/{classID}/$ref", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("schoolID", *newSchool.Id)
rctx.URLParams.Add("classID", "/classes/class1")
r = r.WithContext(context.WithValue(ctxpkg.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationSchoolClass(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
identityEducationBackend.AssertNumberOfCalls(GinkgoT(), "RemoveClassFromEducationSchool", 1)
})
})
})
@@ -0,0 +1,441 @@
package svc
import (
"context"
"errors"
"fmt"
"net/http"
"net/url"
"sort"
"strings"
"github.com/CiscoM31/godata"
storageprovider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
"github.com/go-chi/chi/v5"
"github.com/go-chi/render"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
"github.com/qsfera/server/services/graph/pkg/errorcode"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/events"
"github.com/opencloud-eu/reva/v2/pkg/utils"
)
// GetEducationUsers implements the Service interface.
func (g Graph) GetEducationUsers(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context()).With().Str("func", "GetEducationUsers").Logger()
sanitizedPath := strings.TrimPrefix(r.URL.Path, "/graph/v1.0/")
odataReq, err := godata.ParseRequest(r.Context(), sanitizedPath, r.URL.Query())
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get education users: query error")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
users, err := g.getEducationUsersFromBackend(r.Context(), odataReq)
if err != nil {
logger.Debug().Err(err).Interface("query", r.URL.Query()).Msg("could not get education users")
renderEqualityFilterError(w, r, err)
return
}
users, err = sortEducationUsers(odataReq, users)
if err != nil {
logger.Debug().Interface("query", odataReq).Msg("error while sorting education users according to query")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, err.Error())
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, &ListResponse{Value: users})
}
// PostEducationUser implements the Service interface.
func (g Graph) PostEducationUser(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
logger.Info().Interface("body", r.Body).Msg("calling create education user")
u := libregraph.NewEducationUser()
err := StrictJSONUnmarshal(r.Body, u)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not create education user: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("invalid request body: %v", err.Error()))
return
}
// Disallow user-supplied IDs. It's supposed to be readonly. We're either
// generating them in the backend ourselves or rely on the Backend's
// storage (e.g. LDAP) to provide a unique ID.
if _, ok := u.GetIdOk(); ok {
logger.Debug().Interface("user", u).Msg("could not create education user: id is a read-only attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "education user id is a read-only attribute")
return
}
if _, ok := u.GetDisplayNameOk(); !ok {
logger.Debug().Err(err).Interface("user", u).Msg("could not create education user: missing required Attribute: 'displayName'")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'displayName'")
return
}
for i, identity := range u.GetIdentities() {
if _, ok := identity.GetIssuerOk(); !ok {
logger.Debug().Err(err).Interface("user", u).Msgf("could not create education user: missing Attribute in 'identities' Collection Entry %d: 'issuer'", i)
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("missing Attribute in 'identities' Collection Entry %d: 'issuer'", i))
return
}
if _, ok := identity.GetIssuerAssignedIdOk(); !ok {
logger.Debug().Err(err).Interface("user", u).Msgf("could not create education user: missing Attribute in 'identities' Collection Entry %d: 'issuerAssignedId'", i)
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("missing Attribute in 'identities' Collection Entry %d: 'issuerAssignedId'", i))
return
}
}
if accountName, ok := u.GetOnPremisesSamAccountNameOk(); ok {
if !g.isValidUsername(*accountName) {
logger.Debug().Str("username", *accountName).Msg("could not create education user: username must be at least the local part of an email")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("username %s must be at least the local part of an email", *u.OnPremisesSamAccountName))
return
}
} else {
logger.Debug().Interface("user", u).Msg("could not create education user: missing required Attribute: 'onPremisesSamAccountName'")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing required Attribute: 'onPremisesSamAccountName'")
return
}
if mail, ok := u.GetMailOk(); ok {
if !isValidEmail(*mail) {
logger.Debug().Str("mail", *u.Mail).Msg("could not create education user: invalid email address")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("%v is not a valid email address", *u.Mail))
return
}
}
if u.HasUserType() {
if !isValidUserType(*u.UserType) {
logger.Debug().Interface("user", u).Msg("invalid userType attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid userType attribute, valid options are 'Member' or 'Guest'")
return
}
} else {
u.SetUserType("Member")
}
logger.Debug().Interface("user", u).Msg("calling create education user on backend")
if u, err = g.identityEducationBackend.CreateEducationUser(r.Context(), *u); err != nil {
logger.Debug().Err(err).Msg("could not create education user: backend error")
errorcode.RenderError(w, r, err)
return
}
e := events.UserCreated{UserID: *u.Id}
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(r.Context(), e)
render.Status(r, http.StatusOK)
render.JSON(w, r, u)
}
// GetEducationUser implements the Service interface.
func (g Graph) GetEducationUser(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
userID := chi.URLParam(r, "userID")
logger.Info().Str("userID", userID).Msg("calling get education user")
userID, err := url.PathUnescape(userID)
if err != nil {
logger.Debug().Err(err).Str("id", userID).Msg("could not get education user: unescaping education user id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping education user id failed")
return
}
if userID == "" {
logger.Debug().Msg("could not get user: missing education user id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing education user id")
return
}
logger.Debug().Str("id", userID).Msg("calling get education user from backend")
user, err := g.identityEducationBackend.GetEducationUser(r.Context(), userID)
if err != nil {
logger.Debug().Err(err).Msg("could not get education user: error fetching education user from backend")
errorcode.RenderError(w, r, err)
return
}
render.Status(r, http.StatusOK)
render.JSON(w, r, user)
}
// DeleteEducationUser implements the Service interface.
func (g Graph) DeleteEducationUser(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
userID := chi.URLParam(r, "userID")
logger.Info().Str("userID", userID).Msg("calling delete education user")
userID, err := url.PathUnescape(userID)
if err != nil {
logger.Debug().Err(err).Str("id", userID).Msg("could not delete education user: unescaping education user id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping education user id failed")
return
}
if userID == "" {
logger.Debug().Msg("could not delete education user: missing education user id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing education user id")
return
}
logger.Debug().Str("id", userID).Msg("calling get education user on user backend")
user, err := g.identityEducationBackend.GetEducationUser(r.Context(), userID)
if err != nil {
logger.Debug().Err(err).Str("userID", userID).Msg("failed to get education user from backend")
errorcode.RenderError(w, r, err)
return
}
e := events.UserDeleted{UserID: user.GetId()}
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
if currentUser.GetId().GetOpaqueId() == user.GetId() {
logger.Debug().Msg("could not delete education user: self deletion forbidden")
errorcode.NotAllowed.Render(w, r, http.StatusForbidden, "self deletion forbidden")
return
}
e.Executant = currentUser.GetId()
}
if g.gatewaySelector != nil {
logger.Debug().
Str("user", user.GetId()).
Msg("calling list spaces with user filter to fetch the personal space for deletion")
opaque := utils.AppendPlainToOpaque(nil, "unrestricted", "T")
f := listStorageSpacesUserFilter(user.GetId())
client, err := g.gatewaySelector.Next()
if err != nil {
logger.Error().Err(err).Msg("could not select next gateway client")
errorcode.ServiceNotAvailable.Render(w, r, http.StatusInternalServerError, "could not select next gateway client, aborting")
return
}
lspr, err := client.ListStorageSpaces(r.Context(), &storageprovider.ListStorageSpacesRequest{
Opaque: opaque,
Filters: []*storageprovider.ListStorageSpacesRequest_Filter{f},
})
if err != nil {
// transport error, log as error
logger.Error().Err(err).Msg("could not fetch spaces: transport error")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not fetch spaces for deletion, aborting")
return
}
for _, sp := range lspr.GetStorageSpaces() {
if !(sp.SpaceType == _spaceTypePersonal && sp.Owner.Id.OpaqueId == user.GetId()) {
continue
}
// TODO: check if request contains a homespace and if, check if requesting user has the privilege to
// delete it and make sure it is not deleting its own homespace
// needs modification of the cs3api
// Deleting a space a two step process (1. disabling/trashing, 2. purging)
// Do the "disable/trash" step only if the space is not marked as trashed yet:
if _, ok := sp.Opaque.Map[_spaceStateTrashed]; !ok {
_, err := client.DeleteStorageSpace(r.Context(), &storageprovider.DeleteStorageSpaceRequest{
Id: &storageprovider.StorageSpaceId{
OpaqueId: sp.Id.OpaqueId,
},
})
if err != nil {
logger.Error().Err(err).Msg("could not disable homespace: transport error")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not disable homespace, aborting")
return
}
}
purgeFlag := utils.AppendPlainToOpaque(nil, "purge", "")
_, err := client.DeleteStorageSpace(r.Context(), &storageprovider.DeleteStorageSpaceRequest{
Opaque: purgeFlag,
Id: &storageprovider.StorageSpaceId{
OpaqueId: sp.Id.OpaqueId,
},
})
if err != nil {
// transport error, log as error
logger.Error().Err(err).Msg("could not delete homespace: transport error")
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, "could not delete homespace, aborting")
return
}
break
}
}
logger.Debug().Str("id", user.GetId()).Msg("calling delete education user on backend")
err = g.identityEducationBackend.DeleteEducationUser(r.Context(), user.GetId())
if err != nil {
logger.Debug().Err(err).Msg("could not delete education user: backend error")
errorcode.RenderError(w, r, err)
return
}
g.publishEvent(r.Context(), e)
render.Status(r, http.StatusNoContent)
render.NoContent(w, r)
}
// PatchEducationUser implements the Service Interface. Updates the specified attributes of an
// ExistingUser
func (g Graph) PatchEducationUser(w http.ResponseWriter, r *http.Request) {
logger := g.logger.SubloggerWithRequestID(r.Context())
nameOrID := chi.URLParam(r, "userID")
logger.Info().Str("userID", nameOrID).Msg("calling patch education user")
nameOrID, err := url.PathUnescape(nameOrID)
if err != nil {
logger.Debug().Err(err).Str("id", nameOrID).Msg("could not update education user: unescaping education user id failed")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "unescaping education user id failed")
return
}
if nameOrID == "" {
logger.Debug().Msg("could not update education user: missing education user id")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "missing education user id")
return
}
changes := libregraph.NewEducationUser()
err = StrictJSONUnmarshal(r.Body, changes)
if err != nil {
logger.Debug().Err(err).Interface("body", r.Body).Msg("could not update education user: invalid request body")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
fmt.Sprintf("invalid request body: %s", err.Error()))
return
}
if accountName, ok := changes.GetOnPremisesSamAccountNameOk(); ok {
if !g.isValidUsername(*accountName) {
logger.Debug().Str("username", *accountName).Msg("could not update education user: username must be at least the local part of an email")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, fmt.Sprintf("username %s must be at least the local part of an email", *changes.OnPremisesSamAccountName))
return
}
}
var features []events.UserFeature
if mail, ok := changes.GetMailOk(); ok {
if !isValidEmail(*mail) {
logger.Debug().Str("mail", *mail).Msg("could not update education user: email is not a valid email address")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest,
fmt.Sprintf("'%s' is not a valid email address", *mail))
return
}
features = append(features, events.UserFeature{Name: "email", Value: *mail})
}
if name, ok := changes.GetDisplayNameOk(); ok {
features = append(features, events.UserFeature{Name: "displayname", Value: *name})
}
if changes.HasUserType() {
if !isValidUserType(*changes.UserType) {
logger.Debug().Interface("user", changes).Msg("invalid userType attribute")
errorcode.InvalidRequest.Render(w, r, http.StatusBadRequest, "invalid userType attribute, valid options are 'Member' or 'Guest'")
return
}
}
logger.Debug().Str("nameid", nameOrID).Interface("changes", *changes).Msg("calling update education user on backend")
u, err := g.identityEducationBackend.UpdateEducationUser(r.Context(), nameOrID, *changes)
if err != nil {
logger.Debug().Err(err).Str("id", nameOrID).Msg("could not update education user: backend error")
errorcode.RenderError(w, r, err)
return
}
e := events.UserFeatureChanged{
UserID: nameOrID,
Features: features,
Timestamp: utils.TSNow(),
}
if currentUser, ok := revactx.ContextGetUser(r.Context()); ok {
e.Executant = currentUser.GetId()
}
g.publishEvent(r.Context(), e)
render.Status(r, http.StatusOK) // TODO StatusNoContent when prefer=minimal is used
render.JSON(w, r, u)
}
func sortEducationUsers(req *godata.GoDataRequest, users []*libregraph.EducationUser) ([]*libregraph.EducationUser, error) {
if req.Query.OrderBy == nil || len(req.Query.OrderBy.OrderByItems) != 1 {
return users, nil
}
var less func(i, j int) bool
switch req.Query.OrderBy.OrderByItems[0].Field.Value {
case displayNameAttr:
less = func(i, j int) bool {
return strings.ToLower(users[i].GetDisplayName()) < strings.ToLower(users[j].GetDisplayName())
}
case "mail":
less = func(i, j int) bool {
return strings.ToLower(users[i].GetMail()) < strings.ToLower(users[j].GetMail())
}
case "onPremisesSamAccountName":
less = func(i, j int) bool {
return strings.ToLower(users[i].GetOnPremisesSamAccountName()) < strings.ToLower(users[j].GetOnPremisesSamAccountName())
}
default:
return nil, fmt.Errorf("we do not support <%s> as a order parameter", req.Query.OrderBy.OrderByItems[0].Field.Value)
}
if req.Query.OrderBy.OrderByItems[0].Order == _sortDescending {
sort.Slice(users, reverse(less))
} else {
sort.Slice(users, less)
}
return users, nil
}
// getEducationUsersFromBackend fetches users from the backend, applying an OData $filter if present.
func (g Graph) getEducationUsersFromBackend(ctx context.Context, odataReq *godata.GoDataRequest) ([]*libregraph.EducationUser, error) {
if odataReq.Query.Filter != nil {
attr, value, err := g.getEqualityFilter(ctx, odataReq)
if err != nil {
return nil, err
}
return g.identityEducationBackend.FilterEducationUsersByAttribute(ctx, attr, value)
}
return g.identityEducationBackend.GetEducationUsers(ctx)
}
func (g Graph) getEqualityFilter(ctx context.Context, req *godata.GoDataRequest) (string, string, error) {
logger := g.logger.SubloggerWithRequestID(ctx)
root := req.Query.Filter.Tree
if root.Token.Type != godata.ExpressionTokenLogical {
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
return "", "", unsupportedFilterError()
}
if root.Token.Value != "eq" {
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
return "", "", unsupportedFilterError()
}
if len(root.Children) != 2 {
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
return "", "", unsupportedFilterError()
}
if root.Children[0].Token.Type != godata.ExpressionTokenLiteral || root.Children[1].Token.Type != godata.ExpressionTokenString {
logger.Debug().Str("filter", req.Query.Filter.RawValue).Msg(unsupportedFilter)
return "", "", unsupportedFilterError()
}
// unquote
value := strings.Trim(root.Children[1].Token.Value, "'")
return root.Children[0].Token.Value, value, nil
}
// renderEqualityFilterError writes the appropriate HTTP error response for errors returned by getEqualityFilter.
func renderEqualityFilterError(w http.ResponseWriter, r *http.Request, err error) {
var errcode errorcode.Error
var godataerr *godata.GoDataError
switch {
case errors.As(err, &errcode):
errcode.Render(w, r)
case errors.As(err, &godataerr):
errorcode.GeneralException.Render(w, r, godataerr.ResponseCode, err.Error())
default:
errorcode.GeneralException.Render(w, r, http.StatusInternalServerError, err.Error())
}
}
@@ -0,0 +1,553 @@
package svc_test
import (
"bytes"
"context"
"encoding/json"
"io"
"net/http"
"net/http/httptest"
"net/url"
gateway "github.com/cs3org/go-cs3apis/cs3/gateway/v1beta1"
userv1beta1 "github.com/cs3org/go-cs3apis/cs3/identity/user/v1beta1"
provider "github.com/cs3org/go-cs3apis/cs3/storage/provider/v1beta1"
typesv1beta1 "github.com/cs3org/go-cs3apis/cs3/types/v1beta1"
"github.com/go-chi/chi/v5"
. "github.com/onsi/ginkgo/v2"
. "github.com/onsi/gomega"
libregraph "github.com/opencloud-eu/libre-graph-api-go"
revactx "github.com/opencloud-eu/reva/v2/pkg/ctx"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/status"
"github.com/opencloud-eu/reva/v2/pkg/rgrpc/todo/pool"
cs3mocks "github.com/opencloud-eu/reva/v2/tests/cs3mocks/mocks"
"github.com/stretchr/testify/mock"
"google.golang.org/grpc"
"github.com/qsfera/server/pkg/shared"
settingssvc "github.com/qsfera/server/protogen/gen/qsfera/services/settings/v0"
"github.com/qsfera/server/services/graph/mocks"
"github.com/qsfera/server/services/graph/pkg/config"
"github.com/qsfera/server/services/graph/pkg/config/defaults"
identitymocks "github.com/qsfera/server/services/graph/pkg/identity/mocks"
service "github.com/qsfera/server/services/graph/pkg/service/v0"
)
type educationUserList struct {
Value []*libregraph.EducationUser
}
var _ = Describe("EducationUsers", func() {
var (
svc service.Service
ctx context.Context
cfg *config.Config
gatewayClient *cs3mocks.GatewayAPIClient
gatewaySelector pool.Selectable[gateway.GatewayAPIClient]
eventsPublisher mocks.Publisher
roleService *mocks.RoleService
identityEducationBackend *identitymocks.EducationBackend
rr *httptest.ResponseRecorder
currentUser = &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "user",
},
}
)
BeforeEach(func() {
eventsPublisher.On("Publish", mock.Anything, mock.Anything, mock.Anything).Return(nil)
pool.RemoveSelector("GatewaySelector" + "qsfera.api.gateway")
gatewayClient = &cs3mocks.GatewayAPIClient{}
gatewaySelector = pool.GetSelector[gateway.GatewayAPIClient](
"GatewaySelector",
"qsfera.api.gateway",
func(cc grpc.ClientConnInterface) gateway.GatewayAPIClient {
return gatewayClient
},
)
identityEducationBackend = &identitymocks.EducationBackend{}
roleService = &mocks.RoleService{}
rr = httptest.NewRecorder()
ctx = context.Background()
cfg = defaults.FullDefaultConfig()
cfg.Identity.LDAP.CACert = "" // skip the startup checks, we don't use LDAP at all in this tests
cfg.TokenManager.JWTSecret = "loremipsum"
cfg.Commons = &shared.Commons{}
cfg.GRPCClientTLS = &shared.GRPCClientTLS{}
var err error
svc, err = service.NewService(
service.Config(cfg),
service.WithGatewaySelector(gatewaySelector),
service.EventsPublisher(&eventsPublisher),
service.WithIdentityEducationBackend(identityEducationBackend),
service.WithRoleService(roleService),
)
Expect(err).ToNot(HaveOccurred())
})
Describe("GetEducationUsers", func() {
It("handles invalid requests", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$invalid=true", nil)
svc.GetEducationUsers(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("lists the users", func() {
user := &libregraph.EducationUser{}
user.SetId("user1")
users := []*libregraph.EducationUser{user}
identityEducationBackend.On("GetEducationUsers", mock.Anything, mock.Anything, mock.Anything).Return(users, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users", nil)
svc.GetEducationUsers(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
res := educationUserList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
Expect(len(res.Value)).To(Equal(1))
Expect(res.Value[0].GetId()).To(Equal("user1"))
})
It("sorts", func() {
user := &libregraph.EducationUser{}
user.SetId("user1")
user.SetMail("z@example.com")
user.SetDisplayName("9")
user.SetOnPremisesSamAccountName("9")
user2 := &libregraph.EducationUser{}
user2.SetId("user2")
user2.SetMail("a@example.com")
user2.SetDisplayName("1")
user2.SetOnPremisesSamAccountName("1")
users := []*libregraph.EducationUser{user, user2}
identityEducationBackend.On("GetEducationUsers", mock.Anything, mock.Anything, mock.Anything).Return(users, nil)
getUsers := func(path string) []*libregraph.EducationUser {
r := httptest.NewRequest(http.MethodGet, path, nil)
rec := httptest.NewRecorder()
svc.GetEducationUsers(rec, r)
Expect(rec.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rec.Body)
Expect(err).ToNot(HaveOccurred())
res := educationUserList{}
err = json.Unmarshal(data, &res)
Expect(err).ToNot(HaveOccurred())
return res.Value
}
unsorted := getUsers("/graph/v1.0/education/users")
Expect(len(unsorted)).To(Equal(2))
Expect(unsorted[0].GetId()).To(Equal("user1"))
Expect(unsorted[1].GetId()).To(Equal("user2"))
byMail := getUsers("/graph/v1.0/education/users?$orderby=mail")
Expect(len(byMail)).To(Equal(2))
Expect(byMail[0].GetId()).To(Equal("user2"))
Expect(byMail[1].GetId()).To(Equal("user1"))
byMail = getUsers("/graph/v1.0/education/users?$orderby=mail%20asc")
Expect(len(byMail)).To(Equal(2))
Expect(byMail[0].GetId()).To(Equal("user2"))
Expect(byMail[1].GetId()).To(Equal("user1"))
byMail = getUsers("/graph/v1.0/education/users?$orderby=mail%20desc")
Expect(len(byMail)).To(Equal(2))
Expect(byMail[0].GetId()).To(Equal("user1"))
Expect(byMail[1].GetId()).To(Equal("user2"))
byDisplayName := getUsers("/graph/v1.0/education/users?$orderby=displayName")
Expect(len(byDisplayName)).To(Equal(2))
Expect(byDisplayName[0].GetId()).To(Equal("user2"))
Expect(byDisplayName[1].GetId()).To(Equal("user1"))
byDisplayName = getUsers("/graph/v1.0/education/users?$orderby=displayName%20asc")
Expect(len(byDisplayName)).To(Equal(2))
Expect(byDisplayName[0].GetId()).To(Equal("user2"))
Expect(byDisplayName[1].GetId()).To(Equal("user1"))
byDisplayName = getUsers("/graph/v1.0/education/users?$orderby=displayName%20desc")
Expect(len(byDisplayName)).To(Equal(2))
Expect(byDisplayName[0].GetId()).To(Equal("user1"))
Expect(byDisplayName[1].GetId()).To(Equal("user2"))
byOnPremisesSamAccountName := getUsers("/graph/v1.0/education/users?$orderby=onPremisesSamAccountName")
Expect(len(byOnPremisesSamAccountName)).To(Equal(2))
Expect(byOnPremisesSamAccountName[0].GetId()).To(Equal("user2"))
Expect(byOnPremisesSamAccountName[1].GetId()).To(Equal("user1"))
byOnPremisesSamAccountName = getUsers("/graph/v1.0/education/users?$orderby=onPremisesSamAccountName%20asc")
Expect(len(byOnPremisesSamAccountName)).To(Equal(2))
Expect(byOnPremisesSamAccountName[0].GetId()).To(Equal("user2"))
Expect(byOnPremisesSamAccountName[1].GetId()).To(Equal("user1"))
byOnPremisesSamAccountName = getUsers("/graph/v1.0/education/users?$orderby=onPremisesSamAccountName%20desc")
Expect(len(byOnPremisesSamAccountName)).To(Equal(2))
Expect(byOnPremisesSamAccountName[0].GetId()).To(Equal("user1"))
Expect(byOnPremisesSamAccountName[1].GetId()).To(Equal("user2"))
// Handles invalid sort field
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$orderby=invalid", nil)
svc.GetEducationUsers(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
When("used with a filter", func() {
It("fails with an unsupported filter ", func() {
svc.GetEducationUsers(rr, httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$filter="+url.QueryEscape("displayName ne 'test'"), nil))
Expect(rr.Code).To(Equal(http.StatusNotImplemented))
})
It("calls the backend with the filter", func() {
identityEducationBackend.On("FilterEducationUsersByAttribute", mock.Anything, "externalId", "id1234").Return([]*libregraph.EducationUser{}, nil)
svc.GetEducationUsers(rr, httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users?$filter="+url.QueryEscape("externalId eq 'id1234'"), nil))
Expect(rr.Code).To(Equal(http.StatusOK))
})
})
})
Describe("GetEducationUser", func() {
It("handles missing userids", func() {
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users", nil)
svc.GetEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("gets the user", func() {
user := &libregraph.EducationUser{}
user.SetId("user1")
identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(user, nil)
r := httptest.NewRequest(http.MethodGet, "/graph/v1.0/education/users", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", *user.Id)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.GetEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
responseUser := &libregraph.EducationUser{}
err = json.Unmarshal(data, &responseUser)
Expect(err).ToNot(HaveOccurred())
Expect(responseUser.GetId()).To(Equal("user1"))
})
})
Describe("PostEducationUser", func() {
var (
user *libregraph.EducationUser
assertHandleBadAttributes = func(user *libregraph.EducationUser) {
userJson, err := json.Marshal(user)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson))
svc.PostEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
}
)
BeforeEach(func() {
identity := libregraph.ObjectIdentity{}
identity.SetIssuer("issu.er")
identity.SetIssuerAssignedId("our-user.1")
user = &libregraph.EducationUser{}
user.SetDisplayName("Display Name")
user.SetOnPremisesSamAccountName("user")
user.SetMail("user@example.com")
user.SetAccountEnabled(true)
user.SetIdentities([]libregraph.ObjectIdentity{identity})
user.SetPrimaryRole("student")
})
It("handles invalid bodies", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", nil)
svc.PostEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles missing display names", func() {
user.DisplayName = nil
assertHandleBadAttributes(user)
})
It("handles missing OnPremisesSamAccountName", func() {
user.OnPremisesSamAccountName = nil
assertHandleBadAttributes(user)
user.SetOnPremisesSamAccountName("")
assertHandleBadAttributes(user)
})
It("handles bad Mails", func() {
user.SetMail("not-a-mail-address")
assertHandleBadAttributes(user)
})
It("handles set Ids - they are read-only", func() {
user.SetId("/users/user")
assertHandleBadAttributes(user)
})
It("creates a user", func() {
roleService.On("AssignRoleToUser", mock.Anything, mock.Anything).Return(&settingssvc.AssignRoleToUserResponse{}, nil)
identityEducationBackend.On("CreateEducationUser", mock.Anything, mock.Anything).Return(func(ctx context.Context, user libregraph.EducationUser) *libregraph.EducationUser {
user.SetId("/users/user")
return &user
}, nil)
userJson, err := json.Marshal(user)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson))
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.PostEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
createdUser := libregraph.EducationUser{}
err = json.Unmarshal(data, &createdUser)
Expect(err).ToNot(HaveOccurred())
Expect(createdUser.GetUserType()).To(Equal("Member"))
})
It("creates a guest user", func() {
roleService.On("AssignRoleToUser", mock.Anything, mock.Anything).Return(&settingssvc.AssignRoleToUserResponse{}, nil)
identityEducationBackend.On("CreateEducationUser", mock.Anything, mock.Anything).Return(func(ctx context.Context, user libregraph.EducationUser) *libregraph.EducationUser {
user.SetId("/users/user")
return &user
}, nil)
user.SetUserType("Guest")
userJson, err := json.Marshal(user)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson))
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.PostEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
createdUser := libregraph.EducationUser{}
err = json.Unmarshal(data, &createdUser)
Expect(err).ToNot(HaveOccurred())
Expect(createdUser.GetUserType()).To(Equal("Guest"))
})
It("creates a member user", func() {
roleService.On("AssignRoleToUser", mock.Anything, mock.Anything).Return(&settingssvc.AssignRoleToUserResponse{}, nil)
identityEducationBackend.On("CreateEducationUser", mock.Anything, mock.Anything).Return(func(ctx context.Context, user libregraph.EducationUser) *libregraph.EducationUser {
user.SetId("/users/user")
return &user
}, nil)
user.SetUserType("Member")
userJson, err := json.Marshal(user)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson))
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.PostEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
createdUser := libregraph.EducationUser{}
err = json.Unmarshal(data, &createdUser)
Expect(err).ToNot(HaveOccurred())
Expect(createdUser.GetUserType()).To(Equal("Member"))
})
It("creates a user without email", func() {
roleService.On("AssignRoleToUser", mock.Anything, mock.Anything).Return(&settingssvc.AssignRoleToUserResponse{}, nil)
identityEducationBackend.On("CreateEducationUser", mock.Anything, mock.Anything).Return(func(ctx context.Context, user libregraph.EducationUser) *libregraph.EducationUser {
user.SetId("/users/user")
return &user
}, nil)
user.Mail = nil
userJson, err := json.Marshal(user)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users", bytes.NewBuffer(userJson))
r = r.WithContext(revactx.ContextSetUser(ctx, currentUser))
svc.PostEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err := io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
createdUser := libregraph.EducationUser{}
err = json.Unmarshal(data, &createdUser)
Expect(err).ToNot(HaveOccurred())
Expect(createdUser.GetMail()).To(Equal(""))
})
})
Describe("DeleteEducationUser", func() {
It("handles missing userids", func() {
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/users/{userid}", nil)
svc.DeleteEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("prevents a user from deleting themselves", func() {
lu := libregraph.EducationUser{}
lu.SetId(currentUser.Id.OpaqueId)
identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(&lu, nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/users/{userid}", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", currentUser.Id.OpaqueId)
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusForbidden))
})
It("deletes a user from deleting themselves", func() {
otheruser := &userv1beta1.User{
Id: &userv1beta1.UserId{
OpaqueId: "otheruser",
},
}
lu := libregraph.EducationUser{}
lu.SetId(otheruser.Id.OpaqueId)
identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(&lu, nil)
identityEducationBackend.On("DeleteEducationUser", mock.Anything, mock.Anything).Return(nil)
gatewayClient.On("DeleteStorageSpace", mock.Anything, mock.Anything).Return(&provider.DeleteStorageSpaceResponse{
Status: status.NewOK(ctx),
}, nil)
gatewayClient.On("ListStorageSpaces", mock.Anything, mock.Anything, mock.Anything).Return(&provider.ListStorageSpacesResponse{
Status: status.NewOK(ctx),
StorageSpaces: []*provider.StorageSpace{
{
Opaque: &typesv1beta1.Opaque{},
Id: &provider.StorageSpaceId{OpaqueId: "drive1"},
Root: &provider.ResourceId{SpaceId: "space", OpaqueId: "space"},
SpaceType: "personal",
Owner: otheruser,
},
},
}, nil)
r := httptest.NewRequest(http.MethodDelete, "/graph/v1.0/education/users/{userid}", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", lu.GetId())
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.DeleteEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusNoContent))
gatewayClient.AssertNumberOfCalls(GinkgoT(), "DeleteStorageSpace", 2) // 2 calls for the home space. first trash, then purge
})
})
Describe("PatchEducationUser", func() {
var (
user *libregraph.EducationUser
)
BeforeEach(func() {
user = &libregraph.EducationUser{}
user.SetDisplayName("Display Name")
user.SetOnPremisesSamAccountName("user")
user.SetMail("user@example.com")
user.SetId("/users/user")
user.SetAccountEnabled(true)
identityEducationBackend.On("GetEducationUser", mock.Anything, mock.Anything, mock.Anything).Return(&user, nil)
})
It("handles missing userids", func() {
r := httptest.NewRequest(http.MethodPatch, "/graph/v1.0/education/users/{userid}", nil)
svc.PatchEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles invalid bodies", func() {
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", nil)
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", user.GetId())
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles invalid email", func() {
user.SetMail("invalid")
data, err := json.Marshal(user)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", user.GetId())
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("handles invalid userType", func() {
user.SetUserType("Clown")
data, err := json.Marshal(user)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", user.GetId())
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusBadRequest))
})
It("updates attributes", func() {
identityEducationBackend.On("UpdateEducationUser", mock.Anything, user.GetId(), mock.Anything).Return(user, nil)
user.SetDisplayName("New Display Name")
user.SetAccountEnabled(false)
data, err := json.Marshal(user)
Expect(err).ToNot(HaveOccurred())
r := httptest.NewRequest(http.MethodPost, "/graph/v1.0/education/users?$invalid=true", bytes.NewBuffer(data))
rctx := chi.NewRouteContext()
rctx.URLParams.Add("userID", user.GetId())
r = r.WithContext(context.WithValue(revactx.ContextSetUser(ctx, currentUser), chi.RouteCtxKey, rctx))
svc.PatchEducationUser(rr, r)
Expect(rr.Code).To(Equal(http.StatusOK))
data, err = io.ReadAll(rr.Body)
Expect(err).ToNot(HaveOccurred())
updatedUser := libregraph.EducationUser{}
err = json.Unmarshal(data, &updatedUser)
Expect(err).ToNot(HaveOccurred())
Expect(updatedUser.GetDisplayName()).To(Equal("New Display Name"))
Expect(updatedUser.GetAccountEnabled()).To(Equal(false))
})
})
})
@@ -0,0 +1,5 @@
package svc
var (
CS3ReceivedShareToLibreGraphPermissions = cs3ReceivedShareToLibreGraphPermissions
)

Some files were not shown because too many files have changed in this diff Show More