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 }