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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
20 changes: 13 additions & 7 deletions services/graph/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -25,13 +25,14 @@ type Config struct {
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"`
Application Application `yaml:"application"`
Spaces Spaces `yaml:"spaces"`
Identity Identity `yaml:"identity"`
OIDCProfilePicture OIDCProfilePicture `yaml:"oidc_profile_picture"`
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"`
Expand Down Expand Up @@ -116,6 +117,11 @@ type Identity struct {
LDAP LDAP `yaml:"ldap"`
}

type OIDCProfilePicture struct {
OIDCIssuer string `yaml:"oidc_issuer" env:"OC_URL;OC_OIDC_ISSUER;GRAPH_OIDC_ISSUER" desc:"URL of the OIDC issuer used to derive the default profile-picture URL allowlist when no explicit allowlist is configured." introductionVersion:"6.3.0"`
URLAllowlist []string `yaml:"url_allowlist" env:"GRAPH_OIDC_PROFILE_PICTURE_URL_ALLOWLIST" desc:"A comma separated allowlist of URL patterns accepted for profile-picture sync events. Patterns can be full URLs with glob support in the host (for example 'https://*.example.com') or '*' to allow all URLs. If empty, only the OIDC issuer host is allowed by default." introductionVersion:"6.3.0"`
}

// 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"`
Expand Down
8 changes: 8 additions & 0 deletions services/graph/pkg/config/defaults/defaultconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ func DefaultConfig() *config.Config {
EducationResourcesEnabled: false,
},
},
OIDCProfilePicture: config.OIDCProfilePicture{
OIDCIssuer: "https://localhost:9200",
URLAllowlist: []string{},
},
Cache: &config.Cache{
Store: "memory",
Nodes: []string{"127.0.0.1:9233"},
Expand Down Expand Up @@ -191,6 +195,10 @@ func EnsureDefaults(cfg *config.Config) {
cfg.Metadata.SystemUserID = cfg.Commons.SystemUserID
}

if cfg.OIDCProfilePicture.OIDCIssuer == "" && cfg.Commons != nil && cfg.Commons.OpenCloudURL != "" {
cfg.OIDCProfilePicture.OIDCIssuer = cfg.Commons.OpenCloudURL
}

}

// Sanitize sanitized the configuration
Expand Down
2 changes: 2 additions & 0 deletions services/graph/pkg/service/v0/graph.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,13 +62,15 @@ type Graph struct {
permissionsService Permissions
valueService settingssvc.ValueService
specialDriveItemsCache *ttlcache.Cache[string, any]
userProfilePhotoService UsersUserProfilePhotoProvider
eventsPublisher events.Publisher
eventsConsumer events.Consumer
searchService searchsvc.SearchProviderService
keycloakClient keycloak.Client
historyClient ehsvc.EventHistoryService
traceProvider trace.TracerProvider
natskv jetstream.KeyValue
profilePictureHTTPClient HTTPClient
}

// ServeHTTP implements the Service interface.
Expand Down
8 changes: 8 additions & 0 deletions services/graph/pkg/service/v0/option.go
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ type Options struct {
Context context.Context
Logger log.Logger
Config *config.Config
ProfilePictureHTTPClient HTTPClient
Middleware []func(http.Handler) http.Handler
RequireAdminMiddleware func(http.Handler) http.Handler
GatewaySelector pool.Selectable[gateway.GatewayAPIClient]
Expand All @@ -47,6 +48,13 @@ type Options struct {
NatsKeyValue jetstream.KeyValue
}

// ProfilePictureHTTPClient provides a function to set the HTTP client used for downloading OIDC profile pictures.
func ProfilePictureHTTPClient(val HTTPClient) Option {
return func(o *Options) {
o.ProfilePictureHTTPClient = val
}
}

// newOptions initializes the available default options.
func newOptions(opts ...Option) Options {
opt := Options{}
Expand Down
140 changes: 138 additions & 2 deletions services/graph/pkg/service/v0/service.go
Original file line number Diff line number Diff line change
@@ -1,19 +1,25 @@
package svc

import (
"bytes"
"context"
"crypto/tls"
"crypto/x509"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"os"
"path"
"strconv"
"strings"
"time"

"github.com/go-chi/chi/v5"
"github.com/go-chi/chi/v5/middleware"
ldapv3 "github.com/go-ldap/ldap/v3"
"github.com/gobwas/glob"
"github.com/jellydator/ttlcache/v3"
"github.com/opencloud-eu/opencloud/services/graph/pkg/identity/cache"
"github.com/riandyrn/otelchi"
Expand All @@ -39,8 +45,9 @@ import (

const (
// HeaderPurge defines the header name for the purge header.
HeaderPurge = "Purge"
displayNameAttr = "displayName"
HeaderPurge = "Purge"
displayNameAttr = "displayName"
maxProfilePhotoBytes = 10 << 20
)

// Service defines the service handlers.
Expand Down Expand Up @@ -191,6 +198,7 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
BaseGraphService: baseGraphService,
mux: m,
specialDriveItemsCache: spacePropertiesCache,
userProfilePhotoService: options.UserProfilePhotoService,
eventsPublisher: options.EventsPublisher,
eventsConsumer: options.EventsConsumer,
searchService: options.SearchService,
Expand All @@ -200,6 +208,10 @@ func NewService(opts ...Option) (Graph, error) { //nolint:maintidx
traceProvider: options.TraceProvider,
valueService: options.ValueService,
natskv: options.NatsKeyValue,
profilePictureHTTPClient: options.ProfilePictureHTTPClient,
}
if svc.profilePictureHTTPClient == nil {
svc.profilePictureHTTPClient = http.DefaultClient
}

if err := setIdentityBackends(options, &svc); err != nil {
Expand Down Expand Up @@ -578,6 +590,11 @@ func (g *Graph) StartListenForLogonEvents(ctx context.Context, l log.Logger) err
if err := g.identityBackend.UpdateLastSignInDate(ctx, ev.Executant.OpaqueId, utils.TSToTime(ev.Timestamp)); err != nil {
l.Error().Err(err).Str("userid", ev.Executant.OpaqueId).Msg("Error updating last sign in date")
}
if ev.PictureURL != "" {
if err := g.syncProfilePictureFromURL(ctx, ev.Executant.GetOpaqueId(), ev.PictureURL); err != nil {
l.Warn().Err(err).Str("userid", ev.Executant.GetOpaqueId()).Msg("Failed to sync profile picture from OIDC claim")
}
}
}
case <-ctx.Done():
l.Info().Msg("context cancelled")
Expand All @@ -588,6 +605,125 @@ func (g *Graph) StartListenForLogonEvents(ctx context.Context, l log.Logger) err
return nil
}

func (g *Graph) syncProfilePictureFromURL(ctx context.Context, userID, rawURL string) error {
if g.userProfilePhotoService == nil {
return errors.New("profile photo service not configured")
}
if userID == "" {
return errors.New("missing user id for profile picture sync")
}
if !g.isProfilePictureURLAllowed(rawURL) {
return fmt.Errorf("profile picture URL not allowed: %s", rawURL)
}

data, err := g.fetchProfilePicture(ctx, rawURL)
if err != nil {
return err
}

return g.userProfilePhotoService.UpdatePhoto(ctx, userID, bytes.NewReader(data))
}

func (g *Graph) fetchProfilePicture(ctx context.Context, rawURL string) ([]byte, error) {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
request.Header.Set("Accept", "image/*")

resp, err := g.profilePictureHTTPClient.Do(request)
if err != nil {
return nil, err
}
defer resp.Body.Close()

if resp.StatusCode < http.StatusOK || resp.StatusCode >= http.StatusMultipleChoices {
return nil, fmt.Errorf("profile picture request returned %s", resp.Status)
}

limited := io.LimitReader(resp.Body, int64(maxProfilePhotoBytes)+1)
data, err := io.ReadAll(limited)
if err != nil {
return nil, err
}
if len(data) == 0 {
return nil, errors.New("profile picture response was empty")
}
if len(data) > maxProfilePhotoBytes {
return nil, fmt.Errorf("profile picture exceeds %d bytes", maxProfilePhotoBytes)
}
contentType := http.DetectContentType(data)
if !strings.HasPrefix(contentType, "image/") {
return nil, fmt.Errorf("unsupported profile picture content type: %s", contentType)
}

return data, nil
}

func (g *Graph) isProfilePictureURLAllowed(rawURL string) bool {
parsedURL, err := url.Parse(rawURL)
if err != nil || parsedURL.Host == "" {
return false
}
if parsedURL.Scheme != "http" && parsedURL.Scheme != "https" {
return false
}

for _, pattern := range g.profilePictureAllowlistPatterns() {
if pattern == "*" {
return true
}
if urlPatternMatches(pattern, parsedURL) {
return true
}
}
return false
}

func (g *Graph) profilePictureAllowlistPatterns() []string {
if len(g.config.OIDCProfilePicture.URLAllowlist) > 0 {
return g.config.OIDCProfilePicture.URLAllowlist
}
issuerURL, err := url.Parse(g.config.OIDCProfilePicture.OIDCIssuer)
if err != nil || issuerURL.Host == "" {
return nil
}
return []string{fmt.Sprintf("%s://%s", issuerURL.Scheme, issuerURL.Host)}
}

func urlPatternMatches(pattern string, target *url.URL) bool {
if target == nil {
return false
}
parsedPattern, err := url.Parse(pattern)
if err == nil && parsedPattern.Host != "" {
if parsedPattern.Scheme != "" && !strings.EqualFold(parsedPattern.Scheme, target.Scheme) {
return false
}
hostMatcher, err := glob.Compile(strings.ToLower(parsedPattern.Host))
if err != nil {
return false
}
if !hostMatcher.Match(strings.ToLower(target.Host)) {
return false
}
if parsedPattern.Path == "" || parsedPattern.Path == "/" {
return true
}
if strings.HasSuffix(parsedPattern.Path, "*") {
prefix := strings.TrimSuffix(parsedPattern.Path, "*")
return strings.HasPrefix(target.Path, prefix)
}
return path.Clean(parsedPattern.Path) == path.Clean(target.Path)
}

hostMatcher, err := glob.Compile(strings.ToLower(pattern))
if err != nil {
return false
}
return hostMatcher.Match(strings.ToLower(target.Host))
}

// parseHeaderPurge parses the 'Purge' header.
// '1', 't', 'T', 'TRUE', 'true', 'True' are parsed as true
// all other values are false.
Expand Down
1 change: 1 addition & 0 deletions services/proxy/pkg/command/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -367,6 +367,7 @@ func loadMiddlewares(logger log.Logger, cfg *config.Config,
middleware.UserOIDCClaim(cfg.UserOIDCClaim),
middleware.UserCS3Claim(cfg.UserCS3Claim),
middleware.TenantOIDCClaim(cfg.TenantOIDCClaim),
middleware.AutoProvisionClaims(cfg.AutoProvisionClaims),
middleware.TenantIDMappingEnabled(cfg.TenantIDMappingEnabled),
middleware.ServiceAccount(cfg.ServiceAccount),
middleware.WithRevaGatewaySelector(gatewaySelector),
Expand Down
1 change: 1 addition & 0 deletions services/proxy/pkg/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -165,6 +165,7 @@ type AutoProvisionClaims struct {
Username string `yaml:"username" env:"PROXY_AUTOPROVISION_CLAIM_USERNAME" desc:"The name of the OIDC claim that holds the username." introductionVersion:"1.0.0"`
Email string `yaml:"email" env:"PROXY_AUTOPROVISION_CLAIM_EMAIL" desc:"The name of the OIDC claim that holds the email." introductionVersion:"1.0.0"`
DisplayName string `yaml:"display_name" env:"PROXY_AUTOPROVISION_CLAIM_DISPLAYNAME" desc:"The name of the OIDC claim that holds the display name." introductionVersion:"1.0.0"`
ProfilePicture string `yaml:"profile_picture" env:"PROXY_AUTOPROVISION_CLAIM_PROFILE_PICTURE" desc:"The name of the OIDC claim that holds the profile picture URL. When set, the profile picture will be synced on login."`
Comment thread
Guibi1 marked this conversation as resolved.
Comment thread
Guibi1 marked this conversation as resolved.
Groups string `yaml:"groups" env:"PROXY_AUTOPROVISION_CLAIM_GROUPS" desc:"The name of the OIDC claim that holds the groups." introductionVersion:"1.0.0"`
Comment thread
Guibi1 marked this conversation as resolved.
}

Expand Down
1 change: 1 addition & 0 deletions services/proxy/pkg/config/defaults/defaultconfig.go
Original file line number Diff line number Diff line change
Expand Up @@ -90,6 +90,7 @@ func DefaultConfig() *config.Config {
Username: "preferred_username",
Email: "email",
DisplayName: "name",
ProfilePicture: "",
Groups: "groups",
},
EnableBasicAuth: false,
Expand Down
20 changes: 18 additions & 2 deletions services/proxy/pkg/middleware/account_resolver.go
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ func AccountResolver(optionSetters ...Option) func(next http.Handler) http.Handl
userOIDCClaim: options.UserOIDCClaim,
userCS3Claim: options.UserCS3Claim,
tenantOIDCClaim: options.TenantOIDCClaim,
autoProvisionClaims: options.AutoProvisionClaims,
tenantIDMappingEnabled: options.TenantIDMappingEnabled,
gatewaySelector: options.RevaGatewaySelector,
serviceAccount: options.ServiceAccount,
Expand Down Expand Up @@ -82,6 +83,7 @@ type accountResolver struct {
userOIDCClaim string
userCS3Claim string
tenantOIDCClaim string
autoProvisionClaims config.AutoProvisionClaims
// lastGroupSyncCache is used to keep track of when the last sync of group
// memberships was done for a specific user. This is used to trigger a sync
// with every single request.
Expand Down Expand Up @@ -126,6 +128,18 @@ func readStringClaim(path string, claims map[string]any) (string, error) {
return value, fmt.Errorf("claim path '%s' not set or empty", path)
}

func (m accountResolver) readProfilePictureURL(claims map[string]any) string {
if m.autoProvisionClaims.ProfilePicture == "" {
return ""
}
pictureURL, err := readStringClaim(m.autoProvisionClaims.ProfilePicture, claims)
if err != nil {
m.logger.Debug().Err(err).Str("claim", m.autoProvisionClaims.ProfilePicture).Msg("profile picture claim missing")
return ""
}
return pictureURL
}

// TODO do not use the context to store values: https://medium.com/@cep21/how-to-correctly-use-context-context-in-go-1-7-8f2c0fafdf39
func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) {
ctx, span := m.tracer.Start(req.Context(), fmt.Sprintf("%s %s", req.Method, req.URL.Path), trace.WithSpanKind(trace.SpanKindServer))
Expand Down Expand Up @@ -229,9 +243,11 @@ func (m accountResolver) ServeHTTP(w http.ResponseWriter, req *http.Request) {

// If this is a new session, publish user login event
if newSession := oidc.NewSessionFlagFromContext(ctx); newSession && m.eventsPublisher != nil {
pictureURL := m.readProfilePictureURL(claims)
event := events.UserSignedIn{
Executant: user.Id,
Timestamp: utils.TimeToTS(time.Now()),
Executant: user.Id,
PictureURL: pictureURL,
Timestamp: utils.TimeToTS(time.Now()),
}
if err := events.Publish(req.Context(), m.eventsPublisher, event); err != nil {
m.logger.Error().Err(err).Msg("could not publish user signin event.")
Expand Down
13 changes: 11 additions & 2 deletions services/proxy/pkg/middleware/options.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ type Options struct {
// TenantOIDCClaim is a JMESPath expression to extract the tenant ID from the OIDC claims.
// When set, the extracted value is verified against the tenant ID on the resolved user.
TenantOIDCClaim string
// AutoProvisionClaims to read the user info from the oidc claims
AutoProvisionClaims config.AutoProvisionClaims
// AutoprovisionAccounts when an accountResolver does not exist.
AutoprovisionAccounts bool
// EnableBasicAuth to allow basic auth
Expand Down Expand Up @@ -80,8 +82,8 @@ type Options struct {
// tenant ID in the OIDC claims via the gateway's TenantAPI before comparing it to the user's stored tenant ID.
TenantIDMappingEnabled bool
// ServiceAccount holds credentials used to authenticate internal service calls (e.g. TenantAPI lookups).
ServiceAccount config.ServiceAccount
EventsPublisher events.Publisher
ServiceAccount config.ServiceAccount
EventsPublisher events.Publisher
}

// newOptions initializes the available default options.
Expand Down Expand Up @@ -186,6 +188,13 @@ func TenantOIDCClaim(val string) Option {
}
}

// AutoProvisionClaims provides a function to set the AutoProvisionClaims config
func AutoProvisionClaims(cfg config.AutoProvisionClaims) Option {
return func(o *Options) {
o.AutoProvisionClaims = cfg
}
}

// AutoprovisionAccounts provides a function to set the AutoprovisionAccounts config
func AutoprovisionAccounts(val bool) Option {
return func(o *Options) {
Expand Down
Loading