github.com/minio/minio@v0.0.0-20240328213742-3f72439b8a27/internal/config/identity/plugin/config.go (about)

     1  // Copyright (c) 2015-2022 MinIO, Inc.
     2  //
     3  // This file is part of MinIO Object Storage stack
     4  //
     5  // This program is free software: you can redistribute it and/or modify
     6  // it under the terms of the GNU Affero General Public License as published by
     7  // the Free Software Foundation, either version 3 of the License, or
     8  // (at your option) any later version.
     9  //
    10  // This program is distributed in the hope that it will be useful
    11  // but WITHOUT ANY WARRANTY; without even the implied warranty of
    12  // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
    13  // GNU Affero General Public License for more details.
    14  //
    15  // You should have received a copy of the GNU Affero General Public License
    16  // along with this program.  If not, see <http://www.gnu.org/licenses/>.
    17  
    18  package plugin
    19  
    20  import (
    21  	"bytes"
    22  	"context"
    23  	"crypto/sha1"
    24  	"encoding/base64"
    25  	"encoding/json"
    26  	"fmt"
    27  	"io"
    28  	"net/http"
    29  	"net/url"
    30  	"regexp"
    31  	"sync"
    32  	"time"
    33  
    34  	"github.com/minio/minio/internal/arn"
    35  	"github.com/minio/minio/internal/config"
    36  	"github.com/minio/minio/internal/logger"
    37  	"github.com/minio/pkg/v2/env"
    38  	xnet "github.com/minio/pkg/v2/net"
    39  )
    40  
    41  // Authentication Plugin config and env variables
    42  const (
    43  	URL        = "url"
    44  	AuthToken  = "auth_token"
    45  	RolePolicy = "role_policy"
    46  	RoleID     = "role_id"
    47  
    48  	EnvIdentityPluginURL        = "MINIO_IDENTITY_PLUGIN_URL"
    49  	EnvIdentityPluginAuthToken  = "MINIO_IDENTITY_PLUGIN_AUTH_TOKEN"
    50  	EnvIdentityPluginRolePolicy = "MINIO_IDENTITY_PLUGIN_ROLE_POLICY"
    51  	EnvIdentityPluginRoleID     = "MINIO_IDENTITY_PLUGIN_ROLE_ID"
    52  )
    53  
    54  var (
    55  	// DefaultKVS - default config for AuthN plugin config
    56  	DefaultKVS = config.KVS{
    57  		config.KV{
    58  			Key:   URL,
    59  			Value: "",
    60  		},
    61  		config.KV{
    62  			Key:   AuthToken,
    63  			Value: "",
    64  		},
    65  		config.KV{
    66  			Key:   RolePolicy,
    67  			Value: "",
    68  		},
    69  		config.KV{
    70  			Key:   RoleID,
    71  			Value: "",
    72  		},
    73  	}
    74  
    75  	defaultHelpPostfix = func(key string) string {
    76  		return config.DefaultHelpPostfix(DefaultKVS, key)
    77  	}
    78  
    79  	// Help for Identity Plugin
    80  	Help = config.HelpKVS{
    81  		config.HelpKV{
    82  			Key:         URL,
    83  			Description: `plugin hook endpoint (HTTP(S)) e.g. "http://localhost:8181/path/to/endpoint"` + defaultHelpPostfix(URL),
    84  			Type:        "url",
    85  		},
    86  		config.HelpKV{
    87  			Key:         AuthToken,
    88  			Description: "authorization token for plugin hook endpoint" + defaultHelpPostfix(AuthToken),
    89  			Optional:    true,
    90  			Type:        "string",
    91  			Sensitive:   true,
    92  			Secret:      true,
    93  		},
    94  		config.HelpKV{
    95  			Key:         RolePolicy,
    96  			Description: "policies to apply for plugin authorized users" + defaultHelpPostfix(RolePolicy),
    97  			Type:        "string",
    98  		},
    99  		config.HelpKV{
   100  			Key:         RoleID,
   101  			Description: "unique ID to generate the ARN" + defaultHelpPostfix(RoleID),
   102  			Optional:    true,
   103  			Type:        "string",
   104  		},
   105  		config.HelpKV{
   106  			Key:         config.Comment,
   107  			Description: config.DefaultComment,
   108  			Optional:    true,
   109  			Type:        "sentence",
   110  		},
   111  	}
   112  )
   113  
   114  // Allows only Base64 URL encoding characters.
   115  var validRoleIDRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
   116  
   117  // Args for authentication plugin.
   118  type Args struct {
   119  	URL         *xnet.URL
   120  	AuthToken   string
   121  	Transport   http.RoundTripper
   122  	CloseRespFn func(r io.ReadCloser)
   123  
   124  	RolePolicy string
   125  	RoleARN    arn.ARN
   126  }
   127  
   128  // Validate - validate configuration params.
   129  func (a *Args) Validate() error {
   130  	req, err := http.NewRequest(http.MethodPost, a.URL.String(), bytes.NewReader([]byte("")))
   131  	if err != nil {
   132  		return err
   133  	}
   134  
   135  	req.Header.Set("Content-Type", "application/json")
   136  	if a.AuthToken != "" {
   137  		req.Header.Set("Authorization", a.AuthToken)
   138  	}
   139  
   140  	client := &http.Client{Transport: a.Transport}
   141  	resp, err := client.Do(req)
   142  	if err != nil {
   143  		return err
   144  	}
   145  	defer a.CloseRespFn(resp.Body)
   146  
   147  	return nil
   148  }
   149  
   150  type serviceRTTMinuteStats struct {
   151  	statsTime           time.Time
   152  	rttMsSum, maxRttMs  float64
   153  	successRequestCount int64
   154  	failedRequestCount  int64
   155  }
   156  
   157  type metrics struct {
   158  	sync.Mutex
   159  	LastCheckSuccess time.Time
   160  	LastCheckFailure time.Time
   161  	lastFullMinute   serviceRTTMinuteStats
   162  	currentMinute    serviceRTTMinuteStats
   163  }
   164  
   165  func (h *metrics) setConnSuccess(reqStartTime time.Time) {
   166  	h.Lock()
   167  	defer h.Unlock()
   168  	h.LastCheckSuccess = reqStartTime
   169  }
   170  
   171  func (h *metrics) setConnFailure(reqStartTime time.Time) {
   172  	h.Lock()
   173  	defer h.Unlock()
   174  	h.LastCheckFailure = reqStartTime
   175  }
   176  
   177  func (h *metrics) updateLastFullMinute(currReqMinute time.Time) {
   178  	// Assumes the caller has h.Lock()'ed
   179  	h.lastFullMinute = h.currentMinute
   180  	h.currentMinute = serviceRTTMinuteStats{
   181  		statsTime: currReqMinute,
   182  	}
   183  }
   184  
   185  func (h *metrics) accumRequestRTT(reqStartTime time.Time, rttMs float64, isSuccess bool) {
   186  	h.Lock()
   187  	defer h.Unlock()
   188  
   189  	// Update connectivity times
   190  	if isSuccess {
   191  		if reqStartTime.After(h.LastCheckSuccess) {
   192  			h.LastCheckSuccess = reqStartTime
   193  		}
   194  	} else {
   195  		if reqStartTime.After(h.LastCheckFailure) {
   196  			h.LastCheckFailure = reqStartTime
   197  		}
   198  	}
   199  
   200  	// Round the request time *down* to whole minute.
   201  	reqTimeMinute := reqStartTime.Truncate(time.Minute)
   202  	if reqTimeMinute.After(h.currentMinute.statsTime) {
   203  		// Drop the last full minute now, since we got a request for a time we
   204  		// are not yet tracking.
   205  		h.updateLastFullMinute(reqTimeMinute)
   206  	}
   207  	var entry *serviceRTTMinuteStats
   208  	switch {
   209  	case reqTimeMinute.Equal(h.currentMinute.statsTime):
   210  		entry = &h.currentMinute
   211  	case reqTimeMinute.Equal(h.lastFullMinute.statsTime):
   212  		entry = &h.lastFullMinute
   213  	default:
   214  		// This request is too old, it should never happen, ignore it as we
   215  		// cannot return an error.
   216  		return
   217  	}
   218  
   219  	// Update stats
   220  	if isSuccess {
   221  		if entry.maxRttMs < rttMs {
   222  			entry.maxRttMs = rttMs
   223  		}
   224  		entry.rttMsSum += rttMs
   225  		entry.successRequestCount++
   226  	} else {
   227  		entry.failedRequestCount++
   228  	}
   229  }
   230  
   231  // AuthNPlugin - implements pluggable authentication via webhook.
   232  type AuthNPlugin struct {
   233  	args           Args
   234  	client         *http.Client
   235  	shutdownCtx    context.Context
   236  	serviceMetrics *metrics
   237  }
   238  
   239  // Enabled returns if AuthNPlugin is enabled.
   240  func Enabled(kvs config.KVS) bool {
   241  	return kvs.Get(URL) != ""
   242  }
   243  
   244  // LookupConfig lookup AuthNPlugin from config, override with any ENVs.
   245  func LookupConfig(kv config.KVS, transport *http.Transport, closeRespFn func(io.ReadCloser), serverRegion string) (Args, error) {
   246  	args := Args{}
   247  
   248  	if err := config.CheckValidKeys(config.IdentityPluginSubSys, kv, DefaultKVS); err != nil {
   249  		return args, err
   250  	}
   251  
   252  	pluginURL := env.Get(EnvIdentityPluginURL, kv.Get(URL))
   253  	if pluginURL == "" {
   254  		return args, nil
   255  	}
   256  
   257  	authToken := env.Get(EnvIdentityPluginAuthToken, kv.Get(AuthToken))
   258  
   259  	u, err := xnet.ParseHTTPURL(pluginURL)
   260  	if err != nil {
   261  		return args, err
   262  	}
   263  
   264  	rolePolicy := env.Get(EnvIdentityPluginRolePolicy, kv.Get(RolePolicy))
   265  	if rolePolicy == "" {
   266  		return args, config.Errorf("A role policy must be specified for Identity Management Plugin")
   267  	}
   268  
   269  	resourceID := "idmp-"
   270  	roleID := env.Get(EnvIdentityPluginRoleID, kv.Get(RoleID))
   271  	if roleID == "" {
   272  		// We use a hash of the plugin URL so that the ARN remains
   273  		// constant across restarts.
   274  		h := sha1.New()
   275  		h.Write([]byte(pluginURL))
   276  		bs := h.Sum(nil)
   277  		resourceID += base64.RawURLEncoding.EncodeToString(bs)
   278  	} else {
   279  		// Check that the roleID is restricted to URL safe characters
   280  		// (base64 URL encoding chars).
   281  		if !validRoleIDRegex.MatchString(roleID) {
   282  			return args, config.Errorf("Role ID must match the regexp `^[a-zA-Z0-9_-]+$`")
   283  		}
   284  
   285  		// Use the user provided ID here.
   286  		resourceID += roleID
   287  	}
   288  
   289  	roleArn, err := arn.NewIAMRoleARN(resourceID, serverRegion)
   290  	if err != nil {
   291  		return args, config.Errorf("unable to generate ARN from the plugin config: %v", err)
   292  	}
   293  
   294  	args = Args{
   295  		URL:         u,
   296  		AuthToken:   authToken,
   297  		Transport:   transport,
   298  		CloseRespFn: closeRespFn,
   299  		RolePolicy:  rolePolicy,
   300  		RoleARN:     roleArn,
   301  	}
   302  	if err = args.Validate(); err != nil {
   303  		return args, err
   304  	}
   305  	return args, nil
   306  }
   307  
   308  // New - initializes Authorization Management Plugin.
   309  func New(shutdownCtx context.Context, args Args) *AuthNPlugin {
   310  	if args.URL == nil || args.URL.Scheme == "" && args.AuthToken == "" {
   311  		return nil
   312  	}
   313  	plugin := AuthNPlugin{
   314  		args:        args,
   315  		client:      &http.Client{Transport: args.Transport},
   316  		shutdownCtx: shutdownCtx,
   317  		serviceMetrics: &metrics{
   318  			Mutex:            sync.Mutex{},
   319  			LastCheckSuccess: time.Unix(0, 0),
   320  			LastCheckFailure: time.Unix(0, 0),
   321  			lastFullMinute:   serviceRTTMinuteStats{},
   322  			currentMinute:    serviceRTTMinuteStats{},
   323  		},
   324  	}
   325  	go plugin.doPeriodicHealthCheck()
   326  	return &plugin
   327  }
   328  
   329  // AuthNSuccessResponse - represents the response from the authentication plugin
   330  // service.
   331  type AuthNSuccessResponse struct {
   332  	User               string                 `json:"user"`
   333  	MaxValiditySeconds int                    `json:"maxValiditySeconds"`
   334  	Claims             map[string]interface{} `json:"claims"`
   335  }
   336  
   337  // AuthNErrorResponse - represents an error response from the authN plugin.
   338  type AuthNErrorResponse struct {
   339  	Reason string `json:"reason"`
   340  }
   341  
   342  // AuthNResponse - represents a result of the authentication operation.
   343  type AuthNResponse struct {
   344  	Success *AuthNSuccessResponse
   345  	Failure *AuthNErrorResponse
   346  }
   347  
   348  const (
   349  	minValidityDurationSeconds int = 900
   350  	maxValidityDurationSeconds int = 365 * 24 * 3600
   351  )
   352  
   353  // Authenticate authenticates the token with the external hook endpoint and
   354  // returns a parent user, max expiry duration for the authentication and a set
   355  // of claims.
   356  func (o *AuthNPlugin) Authenticate(roleArn arn.ARN, token string) (AuthNResponse, error) {
   357  	if o == nil {
   358  		return AuthNResponse{}, nil
   359  	}
   360  
   361  	if roleArn != o.args.RoleARN {
   362  		return AuthNResponse{}, fmt.Errorf("Invalid role ARN value: %s", roleArn.String())
   363  	}
   364  
   365  	u := url.URL(*o.args.URL)
   366  	q := u.Query()
   367  	q.Set("token", token)
   368  	u.RawQuery = q.Encode()
   369  
   370  	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
   371  	defer cancel()
   372  	req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), nil)
   373  	if err != nil {
   374  		return AuthNResponse{}, err
   375  	}
   376  
   377  	if o.args.AuthToken != "" {
   378  		req.Header.Set("Authorization", o.args.AuthToken)
   379  	}
   380  
   381  	reqStartTime := time.Now()
   382  	resp, err := o.client.Do(req)
   383  	if err != nil {
   384  		o.serviceMetrics.accumRequestRTT(reqStartTime, 0, false)
   385  		return AuthNResponse{}, err
   386  	}
   387  	defer o.args.CloseRespFn(resp.Body)
   388  	reqDurNanos := time.Since(reqStartTime).Nanoseconds()
   389  	o.serviceMetrics.accumRequestRTT(reqStartTime, float64(reqDurNanos)/1e6, true)
   390  
   391  	switch resp.StatusCode {
   392  	case 200:
   393  		var result AuthNSuccessResponse
   394  		if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
   395  			return AuthNResponse{}, err
   396  		}
   397  
   398  		if result.MaxValiditySeconds < minValidityDurationSeconds || result.MaxValiditySeconds > maxValidityDurationSeconds {
   399  			return AuthNResponse{}, fmt.Errorf("Plugin returned an invalid validity duration (%d) - should be between %d and %d",
   400  				result.MaxValiditySeconds, minValidityDurationSeconds, maxValidityDurationSeconds)
   401  		}
   402  
   403  		return AuthNResponse{
   404  			Success: &result,
   405  		}, nil
   406  
   407  	case 403:
   408  		var result AuthNErrorResponse
   409  		if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
   410  			return AuthNResponse{}, err
   411  		}
   412  		return AuthNResponse{
   413  			Failure: &result,
   414  		}, nil
   415  
   416  	default:
   417  		return AuthNResponse{}, fmt.Errorf("Invalid status code %d from auth plugin", resp.StatusCode)
   418  	}
   419  }
   420  
   421  // GetRoleInfo - returns ARN to policies map.
   422  func (o *AuthNPlugin) GetRoleInfo() map[arn.ARN]string {
   423  	return map[arn.ARN]string{
   424  		o.args.RoleARN: o.args.RolePolicy,
   425  	}
   426  }
   427  
   428  // checkConnectivity returns true if we are able to connect to the plugin
   429  // service.
   430  func (o *AuthNPlugin) checkConnectivity(ctx context.Context) bool {
   431  	ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
   432  	defer cancel()
   433  	u := url.URL(*o.args.URL)
   434  
   435  	req, err := http.NewRequestWithContext(ctx, http.MethodHead, u.String(), nil)
   436  	if err != nil {
   437  		logger.LogIf(ctx, err)
   438  		return false
   439  	}
   440  
   441  	if o.args.AuthToken != "" {
   442  		req.Header.Set("Authorization", o.args.AuthToken)
   443  	}
   444  
   445  	resp, err := o.client.Do(req)
   446  	if err != nil {
   447  		return false
   448  	}
   449  	defer o.args.CloseRespFn(resp.Body)
   450  	return true
   451  }
   452  
   453  var (
   454  	healthCheckInterval = 1 * time.Minute
   455  	healthCheckTimeout  = 5 * time.Second
   456  )
   457  
   458  func (o *AuthNPlugin) doPeriodicHealthCheck() {
   459  	ticker := time.NewTicker(healthCheckInterval)
   460  	defer ticker.Stop()
   461  
   462  	for {
   463  		select {
   464  		case <-ticker.C:
   465  			now := time.Now()
   466  			isConnected := o.checkConnectivity(o.shutdownCtx)
   467  			if isConnected {
   468  				o.serviceMetrics.setConnSuccess(now)
   469  			} else {
   470  				o.serviceMetrics.setConnFailure(now)
   471  			}
   472  		case <-o.shutdownCtx.Done():
   473  			return
   474  		}
   475  	}
   476  }
   477  
   478  // Metrics contains metrics about the authentication plugin service.
   479  type Metrics struct {
   480  	LastReachableSecs, LastUnreachableSecs float64
   481  
   482  	// Last whole minute stats
   483  	TotalRequests, FailedRequests int64
   484  	AvgSuccRTTMs                  float64
   485  	MaxSuccRTTMs                  float64
   486  }
   487  
   488  // Metrics reports metrics related to plugin service reachability and stats for the last whole minute
   489  func (o *AuthNPlugin) Metrics() Metrics {
   490  	if o == nil {
   491  		// Return empty metrics when not configured.
   492  		return Metrics{}
   493  	}
   494  	o.serviceMetrics.Lock()
   495  	defer o.serviceMetrics.Unlock()
   496  	l := &o.serviceMetrics.lastFullMinute
   497  	var avg float64
   498  	if l.successRequestCount > 0 {
   499  		avg = l.rttMsSum / float64(l.successRequestCount)
   500  	}
   501  	now := time.Now().UTC()
   502  	return Metrics{
   503  		LastReachableSecs:   now.Sub(o.serviceMetrics.LastCheckSuccess).Seconds(),
   504  		LastUnreachableSecs: now.Sub(o.serviceMetrics.LastCheckFailure).Seconds(),
   505  		TotalRequests:       l.failedRequestCount + l.successRequestCount,
   506  		FailedRequests:      l.failedRequestCount,
   507  		AvgSuccRTTMs:        avg,
   508  		MaxSuccRTTMs:        l.maxRttMs,
   509  	}
   510  }