Initial QSfera import
This commit is contained in:
@@ -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
|
||||
},
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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
@@ -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")
|
||||
}
|
||||
@@ -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()))
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -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
@@ -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)
|
||||
}
|
||||
|
||||
}
|
||||
@@ -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
Reference in New Issue
Block a user