minio/internal/config/identity/plugin/config.go
Anis Eleuch 95bf4a57b6
logging: Add subsystem to log API (#19002)
Create new code paths for multiple subsystems in the code. This will
make maintaing this easier later.

Also introduce bugLogIf() for errors that should not happen in the first
place.
2024-04-04 05:04:40 -07:00

515 lines
13 KiB
Go

// Copyright (c) 2015-2022 MinIO, Inc.
//
// This file is part of MinIO Object Storage stack
//
// This program is free software: you can redistribute it and/or modify
// it under the terms of the GNU Affero General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// This program is distributed in the hope that it will be useful
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
// GNU Affero General Public License for more details.
//
// You should have received a copy of the GNU Affero General Public License
// along with this program. If not, see <http://www.gnu.org/licenses/>.
package plugin
import (
"bytes"
"context"
"crypto/sha1"
"encoding/base64"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sync"
"time"
"github.com/minio/minio/internal/arn"
"github.com/minio/minio/internal/config"
"github.com/minio/minio/internal/logger"
"github.com/minio/pkg/v2/env"
xnet "github.com/minio/pkg/v2/net"
)
func authNLogIf(ctx context.Context, err error) {
logger.LogIf(ctx, "authN", err)
}
// Authentication Plugin config and env variables
const (
URL = "url"
AuthToken = "auth_token"
RolePolicy = "role_policy"
RoleID = "role_id"
EnvIdentityPluginURL = "MINIO_IDENTITY_PLUGIN_URL"
EnvIdentityPluginAuthToken = "MINIO_IDENTITY_PLUGIN_AUTH_TOKEN"
EnvIdentityPluginRolePolicy = "MINIO_IDENTITY_PLUGIN_ROLE_POLICY"
EnvIdentityPluginRoleID = "MINIO_IDENTITY_PLUGIN_ROLE_ID"
)
var (
// DefaultKVS - default config for AuthN plugin config
DefaultKVS = config.KVS{
config.KV{
Key: URL,
Value: "",
},
config.KV{
Key: AuthToken,
Value: "",
},
config.KV{
Key: RolePolicy,
Value: "",
},
config.KV{
Key: RoleID,
Value: "",
},
}
defaultHelpPostfix = func(key string) string {
return config.DefaultHelpPostfix(DefaultKVS, key)
}
// Help for Identity Plugin
Help = config.HelpKVS{
config.HelpKV{
Key: URL,
Description: `plugin hook endpoint (HTTP(S)) e.g. "http://localhost:8181/path/to/endpoint"` + defaultHelpPostfix(URL),
Type: "url",
},
config.HelpKV{
Key: AuthToken,
Description: "authorization token for plugin hook endpoint" + defaultHelpPostfix(AuthToken),
Optional: true,
Type: "string",
Sensitive: true,
Secret: true,
},
config.HelpKV{
Key: RolePolicy,
Description: "policies to apply for plugin authorized users" + defaultHelpPostfix(RolePolicy),
Type: "string",
},
config.HelpKV{
Key: RoleID,
Description: "unique ID to generate the ARN" + defaultHelpPostfix(RoleID),
Optional: true,
Type: "string",
},
config.HelpKV{
Key: config.Comment,
Description: config.DefaultComment,
Optional: true,
Type: "sentence",
},
}
)
// Allows only Base64 URL encoding characters.
var validRoleIDRegex = regexp.MustCompile(`^[A-Za-z0-9_-]+$`)
// Args for authentication plugin.
type Args struct {
URL *xnet.URL
AuthToken string
Transport http.RoundTripper
CloseRespFn func(r io.ReadCloser)
RolePolicy string
RoleARN arn.ARN
}
// Validate - validate configuration params.
func (a *Args) Validate() error {
req, err := http.NewRequest(http.MethodPost, a.URL.String(), bytes.NewReader([]byte("")))
if err != nil {
return err
}
req.Header.Set("Content-Type", "application/json")
if a.AuthToken != "" {
req.Header.Set("Authorization", a.AuthToken)
}
client := &http.Client{Transport: a.Transport}
resp, err := client.Do(req)
if err != nil {
return err
}
defer a.CloseRespFn(resp.Body)
return nil
}
type serviceRTTMinuteStats struct {
statsTime time.Time
rttMsSum, maxRttMs float64
successRequestCount int64
failedRequestCount int64
}
type metrics struct {
sync.Mutex
LastCheckSuccess time.Time
LastCheckFailure time.Time
lastFullMinute serviceRTTMinuteStats
currentMinute serviceRTTMinuteStats
}
func (h *metrics) setConnSuccess(reqStartTime time.Time) {
h.Lock()
defer h.Unlock()
h.LastCheckSuccess = reqStartTime
}
func (h *metrics) setConnFailure(reqStartTime time.Time) {
h.Lock()
defer h.Unlock()
h.LastCheckFailure = reqStartTime
}
func (h *metrics) updateLastFullMinute(currReqMinute time.Time) {
// Assumes the caller has h.Lock()'ed
h.lastFullMinute = h.currentMinute
h.currentMinute = serviceRTTMinuteStats{
statsTime: currReqMinute,
}
}
func (h *metrics) accumRequestRTT(reqStartTime time.Time, rttMs float64, isSuccess bool) {
h.Lock()
defer h.Unlock()
// Update connectivity times
if isSuccess {
if reqStartTime.After(h.LastCheckSuccess) {
h.LastCheckSuccess = reqStartTime
}
} else {
if reqStartTime.After(h.LastCheckFailure) {
h.LastCheckFailure = reqStartTime
}
}
// Round the request time *down* to whole minute.
reqTimeMinute := reqStartTime.Truncate(time.Minute)
if reqTimeMinute.After(h.currentMinute.statsTime) {
// Drop the last full minute now, since we got a request for a time we
// are not yet tracking.
h.updateLastFullMinute(reqTimeMinute)
}
var entry *serviceRTTMinuteStats
switch {
case reqTimeMinute.Equal(h.currentMinute.statsTime):
entry = &h.currentMinute
case reqTimeMinute.Equal(h.lastFullMinute.statsTime):
entry = &h.lastFullMinute
default:
// This request is too old, it should never happen, ignore it as we
// cannot return an error.
return
}
// Update stats
if isSuccess {
if entry.maxRttMs < rttMs {
entry.maxRttMs = rttMs
}
entry.rttMsSum += rttMs
entry.successRequestCount++
} else {
entry.failedRequestCount++
}
}
// AuthNPlugin - implements pluggable authentication via webhook.
type AuthNPlugin struct {
args Args
client *http.Client
shutdownCtx context.Context
serviceMetrics *metrics
}
// Enabled returns if AuthNPlugin is enabled.
func Enabled(kvs config.KVS) bool {
return kvs.Get(URL) != ""
}
// LookupConfig lookup AuthNPlugin from config, override with any ENVs.
func LookupConfig(kv config.KVS, transport *http.Transport, closeRespFn func(io.ReadCloser), serverRegion string) (Args, error) {
args := Args{}
if err := config.CheckValidKeys(config.IdentityPluginSubSys, kv, DefaultKVS); err != nil {
return args, err
}
pluginURL := env.Get(EnvIdentityPluginURL, kv.Get(URL))
if pluginURL == "" {
return args, nil
}
authToken := env.Get(EnvIdentityPluginAuthToken, kv.Get(AuthToken))
u, err := xnet.ParseHTTPURL(pluginURL)
if err != nil {
return args, err
}
rolePolicy := env.Get(EnvIdentityPluginRolePolicy, kv.Get(RolePolicy))
if rolePolicy == "" {
return args, config.Errorf("A role policy must be specified for Identity Management Plugin")
}
resourceID := "idmp-"
roleID := env.Get(EnvIdentityPluginRoleID, kv.Get(RoleID))
if roleID == "" {
// We use a hash of the plugin URL so that the ARN remains
// constant across restarts.
h := sha1.New()
h.Write([]byte(pluginURL))
bs := h.Sum(nil)
resourceID += base64.RawURLEncoding.EncodeToString(bs)
} else {
// Check that the roleID is restricted to URL safe characters
// (base64 URL encoding chars).
if !validRoleIDRegex.MatchString(roleID) {
return args, config.Errorf("Role ID must match the regexp `^[a-zA-Z0-9_-]+$`")
}
// Use the user provided ID here.
resourceID += roleID
}
roleArn, err := arn.NewIAMRoleARN(resourceID, serverRegion)
if err != nil {
return args, config.Errorf("unable to generate ARN from the plugin config: %v", err)
}
args = Args{
URL: u,
AuthToken: authToken,
Transport: transport,
CloseRespFn: closeRespFn,
RolePolicy: rolePolicy,
RoleARN: roleArn,
}
if err = args.Validate(); err != nil {
return args, err
}
return args, nil
}
// New - initializes Authorization Management Plugin.
func New(shutdownCtx context.Context, args Args) *AuthNPlugin {
if args.URL == nil || args.URL.Scheme == "" && args.AuthToken == "" {
return nil
}
plugin := AuthNPlugin{
args: args,
client: &http.Client{Transport: args.Transport},
shutdownCtx: shutdownCtx,
serviceMetrics: &metrics{
Mutex: sync.Mutex{},
LastCheckSuccess: time.Unix(0, 0),
LastCheckFailure: time.Unix(0, 0),
lastFullMinute: serviceRTTMinuteStats{},
currentMinute: serviceRTTMinuteStats{},
},
}
go plugin.doPeriodicHealthCheck()
return &plugin
}
// AuthNSuccessResponse - represents the response from the authentication plugin
// service.
type AuthNSuccessResponse struct {
User string `json:"user"`
MaxValiditySeconds int `json:"maxValiditySeconds"`
Claims map[string]interface{} `json:"claims"`
}
// AuthNErrorResponse - represents an error response from the authN plugin.
type AuthNErrorResponse struct {
Reason string `json:"reason"`
}
// AuthNResponse - represents a result of the authentication operation.
type AuthNResponse struct {
Success *AuthNSuccessResponse
Failure *AuthNErrorResponse
}
const (
minValidityDurationSeconds int = 900
maxValidityDurationSeconds int = 365 * 24 * 3600
)
// Authenticate authenticates the token with the external hook endpoint and
// returns a parent user, max expiry duration for the authentication and a set
// of claims.
func (o *AuthNPlugin) Authenticate(roleArn arn.ARN, token string) (AuthNResponse, error) {
if o == nil {
return AuthNResponse{}, nil
}
if roleArn != o.args.RoleARN {
return AuthNResponse{}, fmt.Errorf("Invalid role ARN value: %s", roleArn.String())
}
u := url.URL(*o.args.URL)
q := u.Query()
q.Set("token", token)
u.RawQuery = q.Encode()
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, u.String(), nil)
if err != nil {
return AuthNResponse{}, err
}
if o.args.AuthToken != "" {
req.Header.Set("Authorization", o.args.AuthToken)
}
reqStartTime := time.Now()
resp, err := o.client.Do(req)
if err != nil {
o.serviceMetrics.accumRequestRTT(reqStartTime, 0, false)
return AuthNResponse{}, err
}
defer o.args.CloseRespFn(resp.Body)
reqDurNanos := time.Since(reqStartTime).Nanoseconds()
o.serviceMetrics.accumRequestRTT(reqStartTime, float64(reqDurNanos)/1e6, true)
switch resp.StatusCode {
case 200:
var result AuthNSuccessResponse
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
return AuthNResponse{}, err
}
if result.MaxValiditySeconds < minValidityDurationSeconds || result.MaxValiditySeconds > maxValidityDurationSeconds {
return AuthNResponse{}, fmt.Errorf("Plugin returned an invalid validity duration (%d) - should be between %d and %d",
result.MaxValiditySeconds, minValidityDurationSeconds, maxValidityDurationSeconds)
}
return AuthNResponse{
Success: &result,
}, nil
case 403:
var result AuthNErrorResponse
if err = json.NewDecoder(resp.Body).Decode(&result); err != nil {
return AuthNResponse{}, err
}
return AuthNResponse{
Failure: &result,
}, nil
default:
return AuthNResponse{}, fmt.Errorf("Invalid status code %d from auth plugin", resp.StatusCode)
}
}
// GetRoleInfo - returns ARN to policies map.
func (o *AuthNPlugin) GetRoleInfo() map[arn.ARN]string {
return map[arn.ARN]string{
o.args.RoleARN: o.args.RolePolicy,
}
}
// checkConnectivity returns true if we are able to connect to the plugin
// service.
func (o *AuthNPlugin) checkConnectivity(ctx context.Context) bool {
ctx, cancel := context.WithTimeout(ctx, healthCheckTimeout)
defer cancel()
u := url.URL(*o.args.URL)
req, err := http.NewRequestWithContext(ctx, http.MethodHead, u.String(), nil)
if err != nil {
authNLogIf(ctx, err)
return false
}
if o.args.AuthToken != "" {
req.Header.Set("Authorization", o.args.AuthToken)
}
resp, err := o.client.Do(req)
if err != nil {
return false
}
defer o.args.CloseRespFn(resp.Body)
return true
}
var (
healthCheckInterval = 1 * time.Minute
healthCheckTimeout = 5 * time.Second
)
func (o *AuthNPlugin) doPeriodicHealthCheck() {
ticker := time.NewTicker(healthCheckInterval)
defer ticker.Stop()
for {
select {
case <-ticker.C:
now := time.Now()
isConnected := o.checkConnectivity(o.shutdownCtx)
if isConnected {
o.serviceMetrics.setConnSuccess(now)
} else {
o.serviceMetrics.setConnFailure(now)
}
case <-o.shutdownCtx.Done():
return
}
}
}
// Metrics contains metrics about the authentication plugin service.
type Metrics struct {
LastReachableSecs, LastUnreachableSecs float64
// Last whole minute stats
TotalRequests, FailedRequests int64
AvgSuccRTTMs float64
MaxSuccRTTMs float64
}
// Metrics reports metrics related to plugin service reachability and stats for the last whole minute
func (o *AuthNPlugin) Metrics() Metrics {
if o == nil {
// Return empty metrics when not configured.
return Metrics{}
}
o.serviceMetrics.Lock()
defer o.serviceMetrics.Unlock()
l := &o.serviceMetrics.lastFullMinute
var avg float64
if l.successRequestCount > 0 {
avg = l.rttMsSum / float64(l.successRequestCount)
}
now := time.Now().UTC()
return Metrics{
LastReachableSecs: now.Sub(o.serviceMetrics.LastCheckSuccess).Seconds(),
LastUnreachableSecs: now.Sub(o.serviceMetrics.LastCheckFailure).Seconds(),
TotalRequests: l.failedRequestCount + l.successRequestCount,
FailedRequests: l.failedRequestCount,
AvgSuccRTTMs: avg,
MaxSuccRTTMs: l.maxRttMs,
}
}