|
|
|
/*
|
|
|
|
* MinIO Cloud Storage, (C) 2019-2020 MinIO, Inc.
|
|
|
|
*
|
|
|
|
* Licensed under the Apache License, Version 2.0 (the "License");
|
|
|
|
* you may not use this file except in compliance with the License.
|
|
|
|
* You may obtain a copy of the License at
|
|
|
|
*
|
|
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
|
|
*
|
|
|
|
* Unless required by applicable law or agreed to in writing, software
|
|
|
|
* distributed under the License is distributed on an "AS IS" BASIS,
|
|
|
|
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
|
|
|
* See the License for the specific language governing permissions and
|
|
|
|
* limitations under the License.
|
|
|
|
*/
|
|
|
|
|
|
|
|
package cmd
|
|
|
|
|
|
|
|
import (
|
|
|
|
"context"
|
|
|
|
"encoding/json"
|
|
|
|
"io"
|
|
|
|
"io/ioutil"
|
|
|
|
"net/http"
|
|
|
|
|
|
|
|
"github.com/gorilla/mux"
|
|
|
|
"github.com/minio/minio/cmd/logger"
|
|
|
|
"github.com/minio/minio/pkg/auth"
|
|
|
|
iampolicy "github.com/minio/minio/pkg/iam/policy"
|
|
|
|
"github.com/minio/minio/pkg/madmin"
|
|
|
|
)
|
|
|
|
|
|
|
|
func validateAdminUsersReq(ctx context.Context, w http.ResponseWriter, r *http.Request, action iampolicy.AdminAction) (ObjectLayer, auth.Credentials) {
|
|
|
|
var cred auth.Credentials
|
|
|
|
var adminAPIErr APIErrorCode
|
|
|
|
|
|
|
|
// Get current object layer instance.
|
|
|
|
objectAPI := newObjectLayerWithoutSafeModeFn()
|
|
|
|
if objectAPI == nil || globalNotificationSys == nil || globalIAMSys == nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL)
|
|
|
|
return nil, cred
|
|
|
|
}
|
|
|
|
|
|
|
|
// Validate request signature.
|
|
|
|
cred, adminAPIErr = checkAdminRequestAuthType(ctx, r, action, "")
|
|
|
|
if adminAPIErr != ErrNone {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(adminAPIErr), r.URL)
|
|
|
|
return nil, cred
|
|
|
|
}
|
|
|
|
|
|
|
|
return objectAPI, cred
|
|
|
|
}
|
|
|
|
|
|
|
|
// RemoveUser - DELETE /minio/admin/v3/remove-user?accessKey=<access_key>
|
|
|
|
func (a adminAPIHandlers) RemoveUser(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "RemoveUser")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "RemoveUser", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.DeleteUserAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
accessKey := vars["accessKey"]
|
|
|
|
|
|
|
|
ok, err := globalIAMSys.IsTempUser(accessKey)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if ok {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, errIAMActionNotAllowed), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := globalIAMSys.DeleteUser(accessKey); err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Notify all other MinIO peers to delete user.
|
|
|
|
for _, nerr := range globalNotificationSys.DeleteUser(accessKey) {
|
|
|
|
if nerr.Err != nil {
|
|
|
|
logger.GetReqInfo(ctx).SetTags("peerAddress", nerr.Host.String())
|
|
|
|
logger.LogIf(ctx, nerr.Err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// ListUsers - GET /minio/admin/v3/list-users
|
|
|
|
func (a adminAPIHandlers) ListUsers(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "ListUsers")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "ListUsers", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, cred := validateAdminUsersReq(ctx, w, r, iampolicy.ListUsersAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
password := cred.SecretKey
|
|
|
|
|
|
|
|
allCredentials, err := globalIAMSys.ListUsers()
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
data, err := json.Marshal(allCredentials)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
econfigData, err := madmin.EncryptData(password, data)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
writeSuccessResponseJSON(w, econfigData)
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetUserInfo - GET /minio/admin/v3/user-info
|
|
|
|
func (a adminAPIHandlers) GetUserInfo(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "GetUserInfo")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "GetUserInfo", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.GetUserAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
name := vars["accessKey"]
|
|
|
|
|
|
|
|
userInfo, err := globalIAMSys.GetUserInfo(name)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
data, err := json.Marshal(userInfo)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
writeSuccessResponseJSON(w, data)
|
|
|
|
}
|
|
|
|
|
|
|
|
// UpdateGroupMembers - PUT /minio/admin/v3/update-group-members
|
|
|
|
func (a adminAPIHandlers) UpdateGroupMembers(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "UpdateGroupMembers")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "UpdateGroupMembers", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.AddUserToGroupAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
defer r.Body.Close()
|
|
|
|
data, err := ioutil.ReadAll(r.Body)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrInvalidRequest), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var updReq madmin.GroupAddRemove
|
|
|
|
err = json.Unmarshal(data, &updReq)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrInvalidRequest), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if updReq.IsRemove {
|
|
|
|
err = globalIAMSys.RemoveUsersFromGroup(updReq.Group, updReq.Members)
|
|
|
|
} else {
|
|
|
|
err = globalIAMSys.AddUsersToGroup(updReq.Group, updReq.Members)
|
|
|
|
}
|
|
|
|
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Notify all other MinIO peers to load group.
|
|
|
|
for _, nerr := range globalNotificationSys.LoadGroup(updReq.Group) {
|
|
|
|
if nerr.Err != nil {
|
|
|
|
logger.GetReqInfo(ctx).SetTags("peerAddress", nerr.Host.String())
|
|
|
|
logger.LogIf(ctx, nerr.Err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// GetGroup - /minio/admin/v3/group?group=mygroup1
|
|
|
|
func (a adminAPIHandlers) GetGroup(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "GetGroup")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "GetGroup", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.GetGroupAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
group := vars["group"]
|
|
|
|
|
|
|
|
gdesc, err := globalIAMSys.GetGroupDescription(group)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
body, err := json.Marshal(gdesc)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
writeSuccessResponseJSON(w, body)
|
|
|
|
}
|
|
|
|
|
|
|
|
// ListGroups - GET /minio/admin/v3/groups
|
|
|
|
func (a adminAPIHandlers) ListGroups(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "ListGroups")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "ListGroups", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.ListGroupsAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
groups, err := globalIAMSys.ListGroups()
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
body, err := json.Marshal(groups)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
writeSuccessResponseJSON(w, body)
|
|
|
|
}
|
|
|
|
|
|
|
|
// SetGroupStatus - PUT /minio/admin/v3/set-group-status?group=mygroup1&status=enabled
|
|
|
|
func (a adminAPIHandlers) SetGroupStatus(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "SetGroupStatus")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "SetGroupStatus", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.EnableGroupAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
group := vars["group"]
|
|
|
|
status := vars["status"]
|
|
|
|
|
|
|
|
var err error
|
|
|
|
if status == statusEnabled {
|
|
|
|
err = globalIAMSys.SetGroupStatus(group, true)
|
|
|
|
} else if status == statusDisabled {
|
|
|
|
err = globalIAMSys.SetGroupStatus(group, false)
|
|
|
|
} else {
|
|
|
|
err = errInvalidArgument
|
|
|
|
}
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Notify all other MinIO peers to reload user.
|
|
|
|
for _, nerr := range globalNotificationSys.LoadGroup(group) {
|
|
|
|
if nerr.Err != nil {
|
|
|
|
logger.GetReqInfo(ctx).SetTags("peerAddress", nerr.Host.String())
|
|
|
|
logger.LogIf(ctx, nerr.Err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// SetUserStatus - PUT /minio/admin/v3/set-user-status?accessKey=<access_key>&status=[enabled|disabled]
|
|
|
|
func (a adminAPIHandlers) SetUserStatus(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "SetUserStatus")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "SetUserStatus", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.EnableUserAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
accessKey := vars["accessKey"]
|
|
|
|
status := vars["status"]
|
|
|
|
|
|
|
|
// Custom IAM policies not allowed for admin user.
|
|
|
|
if accessKey == globalActiveCred.AccessKey {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrInvalidRequest), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := globalIAMSys.SetUserStatus(accessKey, madmin.AccountStatus(status)); err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Notify all other MinIO peers to reload user.
|
|
|
|
for _, nerr := range globalNotificationSys.LoadUser(accessKey, false) {
|
|
|
|
if nerr.Err != nil {
|
|
|
|
logger.GetReqInfo(ctx).SetTags("peerAddress", nerr.Host.String())
|
|
|
|
logger.LogIf(ctx, nerr.Err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// AddUser - PUT /minio/admin/v3/add-user?accessKey=<access_key>
|
|
|
|
func (a adminAPIHandlers) AddUser(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "AddUser")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "AddUser", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, cred := validateAdminUsersReq(ctx, w, r, iampolicy.CreateUserAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
accessKey := vars["accessKey"]
|
|
|
|
|
|
|
|
// Custom IAM policies not allowed for admin user.
|
|
|
|
if accessKey == globalActiveCred.AccessKey {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAddUserInvalidArgument), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if r.ContentLength > maxEConfigJSONSize || r.ContentLength == -1 {
|
|
|
|
// More than maxConfigSize bytes were available
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminConfigTooLarge), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
password := cred.SecretKey
|
|
|
|
configBytes, err := madmin.DecryptData(password, io.LimitReader(r.Body, r.ContentLength))
|
|
|
|
if err != nil {
|
|
|
|
logger.LogIf(ctx, err)
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminConfigBadJSON), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var uinfo madmin.UserInfo
|
|
|
|
if err = json.Unmarshal(configBytes, &uinfo); err != nil {
|
|
|
|
logger.LogIf(ctx, err)
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminConfigBadJSON), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if err = globalIAMSys.SetUser(accessKey, uinfo); err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Notify all other Minio peers to reload user
|
|
|
|
for _, nerr := range globalNotificationSys.LoadUser(accessKey, false) {
|
|
|
|
if nerr.Err != nil {
|
|
|
|
logger.GetReqInfo(ctx).SetTags("peerAddress", nerr.Host.String())
|
|
|
|
logger.LogIf(ctx, nerr.Err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// AddServiceAccount - PUT /minio/admin/v3/add-service-account
|
|
|
|
func (a adminAPIHandlers) AddServiceAccount(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "AddServiceAccount")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "AddServiceAccount", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
// Get current object layer instance.
|
|
|
|
objectAPI := newObjectLayerWithoutSafeModeFn()
|
|
|
|
if objectAPI == nil || globalNotificationSys == nil || globalIAMSys == nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
cred, _, owner, s3Err := validateAdminSignature(ctx, r, "")
|
|
|
|
if s3Err != ErrNone {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
password := cred.SecretKey
|
|
|
|
reqBytes, err := madmin.DecryptData(password, io.LimitReader(r.Body, r.ContentLength))
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErrWithErr(ErrAdminConfigBadJSON, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var createReq madmin.AddServiceAccountReq
|
|
|
|
if err = json.Unmarshal(reqBytes, &createReq); err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErrWithErr(ErrAdminConfigBadJSON, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Disallow creating service accounts by root user.
|
|
|
|
if owner {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminAccountNotEligible), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
parentUser := cred.AccessKey
|
|
|
|
if cred.ParentUser != "" {
|
|
|
|
parentUser = cred.ParentUser
|
|
|
|
}
|
|
|
|
|
|
|
|
newCred, err := globalIAMSys.NewServiceAccount(ctx, parentUser, createReq.Policy)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Notify all other Minio peers to reload user the service account
|
|
|
|
for _, nerr := range globalNotificationSys.LoadServiceAccount(newCred.AccessKey) {
|
|
|
|
if nerr.Err != nil {
|
|
|
|
logger.GetReqInfo(ctx).SetTags("peerAddress", nerr.Host.String())
|
|
|
|
logger.LogIf(ctx, nerr.Err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
var createResp = madmin.AddServiceAccountResp{
|
|
|
|
Credentials: auth.Credentials{
|
|
|
|
AccessKey: newCred.AccessKey,
|
|
|
|
SecretKey: newCred.SecretKey,
|
|
|
|
},
|
|
|
|
}
|
|
|
|
|
|
|
|
data, err := json.Marshal(createResp)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
encryptedData, err := madmin.EncryptData(password, data)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
writeSuccessResponseJSON(w, encryptedData)
|
|
|
|
}
|
|
|
|
|
|
|
|
// ListServiceAccounts - GET /minio/admin/v3/list-service-accounts
|
|
|
|
func (a adminAPIHandlers) ListServiceAccounts(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "ListServiceAccounts")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "ListServiceAccounts", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
// Get current object layer instance.
|
|
|
|
objectAPI := newObjectLayerWithoutSafeModeFn()
|
|
|
|
if objectAPI == nil || globalNotificationSys == nil || globalIAMSys == nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
cred, _, owner, s3Err := validateAdminSignature(ctx, r, "")
|
|
|
|
if s3Err != ErrNone {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Disallow creating service accounts by root user.
|
|
|
|
if owner {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminAccountNotEligible), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
parentUser := cred.AccessKey
|
|
|
|
if cred.ParentUser != "" {
|
|
|
|
parentUser = cred.ParentUser
|
|
|
|
}
|
|
|
|
|
|
|
|
serviceAccounts, err := globalIAMSys.ListServiceAccounts(ctx, parentUser)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var listResp = madmin.ListServiceAccountsResp{
|
|
|
|
Accounts: serviceAccounts,
|
|
|
|
}
|
|
|
|
|
|
|
|
data, err := json.Marshal(listResp)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
encryptedData, err := madmin.EncryptData(cred.SecretKey, data)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
writeSuccessResponseJSON(w, encryptedData)
|
|
|
|
}
|
|
|
|
|
|
|
|
// DeleteServiceAccount - DELETE /minio/admin/v3/delete-service-account
|
|
|
|
func (a adminAPIHandlers) DeleteServiceAccount(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "DeleteServiceAccount")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "DeleteServiceAccount", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
// Get current object layer instance.
|
|
|
|
objectAPI := newObjectLayerWithoutSafeModeFn()
|
|
|
|
if objectAPI == nil || globalNotificationSys == nil || globalIAMSys == nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
cred, _, owner, s3Err := validateAdminSignature(ctx, r, "")
|
|
|
|
if s3Err != ErrNone {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Disallow creating service accounts by root user.
|
|
|
|
if owner {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminAccountNotEligible), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
serviceAccount := mux.Vars(r)["accessKey"]
|
|
|
|
if serviceAccount == "" {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminInvalidArgument), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
user, err := globalIAMSys.GetServiceAccountParent(ctx, serviceAccount)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
parentUser := cred.AccessKey
|
|
|
|
if cred.ParentUser != "" {
|
|
|
|
parentUser = cred.ParentUser
|
|
|
|
}
|
|
|
|
|
|
|
|
if parentUser != user || user == "" {
|
|
|
|
// The service account belongs to another user but return not
|
|
|
|
// found error to mitigate brute force attacks. or the
|
|
|
|
// serviceAccount doesn't exist.
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrServiceAccountNotFound), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
err = globalIAMSys.DeleteServiceAccount(ctx, serviceAccount)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
writeSuccessNoContent(w)
|
|
|
|
}
|
|
|
|
|
|
|
|
// AccountUsageInfoHandler returns usage
|
|
|
|
func (a adminAPIHandlers) AccountUsageInfoHandler(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "AccountUsageInfo")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "AccountUsageInfo", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
// Get current object layer instance.
|
|
|
|
objectAPI := newObjectLayerWithoutSafeModeFn()
|
|
|
|
if objectAPI == nil || globalNotificationSys == nil || globalIAMSys == nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
cred, claims, owner, s3Err := validateAdminSignature(ctx, r, "")
|
|
|
|
if s3Err != ErrNone {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Set prefix value for "s3:prefix" policy conditionals.
|
|
|
|
r.Header.Set("prefix", "")
|
|
|
|
|
|
|
|
// Set delimiter value for "s3:delimiter" policy conditionals.
|
|
|
|
r.Header.Set("delimiter", SlashSeparator)
|
|
|
|
|
|
|
|
isAllowedAccess := func(bucketName string) (rd, wr bool) {
|
|
|
|
// Use the following trick to filter in place
|
|
|
|
// https://github.com/golang/go/wiki/SliceTricks#filter-in-place
|
|
|
|
if globalIAMSys.IsAllowed(iampolicy.Args{
|
|
|
|
AccountName: cred.AccessKey,
|
|
|
|
Action: iampolicy.ListBucketAction,
|
|
|
|
BucketName: bucketName,
|
|
|
|
ConditionValues: getConditionValues(r, "", cred.AccessKey, claims),
|
|
|
|
IsOwner: owner,
|
|
|
|
ObjectName: "",
|
|
|
|
Claims: claims,
|
|
|
|
}) {
|
|
|
|
rd = true
|
|
|
|
}
|
|
|
|
|
|
|
|
if globalIAMSys.IsAllowed(iampolicy.Args{
|
|
|
|
AccountName: cred.AccessKey,
|
|
|
|
Action: iampolicy.PutObjectAction,
|
|
|
|
BucketName: bucketName,
|
|
|
|
ConditionValues: getConditionValues(r, "", cred.AccessKey, claims),
|
|
|
|
IsOwner: owner,
|
|
|
|
ObjectName: "",
|
|
|
|
Claims: claims,
|
|
|
|
}) {
|
|
|
|
wr = true
|
|
|
|
}
|
|
|
|
|
|
|
|
return rd, wr
|
|
|
|
}
|
|
|
|
|
|
|
|
buckets, err := objectAPI.ListBuckets(ctx)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Load the latest calculated data usage
|
|
|
|
dataUsageInfo, err := loadDataUsageFromBackend(ctx, objectAPI)
|
|
|
|
if err != nil {
|
|
|
|
// log the error, continue with the accounting response
|
|
|
|
logger.LogIf(ctx, err)
|
|
|
|
}
|
|
|
|
|
|
|
|
accountName := cred.AccessKey
|
|
|
|
if cred.ParentUser != "" {
|
|
|
|
accountName = cred.ParentUser
|
|
|
|
}
|
|
|
|
|
|
|
|
acctInfo := madmin.AccountUsageInfo{
|
|
|
|
AccountName: accountName,
|
|
|
|
}
|
|
|
|
|
|
|
|
for _, bucket := range buckets {
|
|
|
|
rd, wr := isAllowedAccess(bucket.Name)
|
|
|
|
if rd || wr {
|
|
|
|
var size uint64
|
|
|
|
// Fetch the data usage of the current bucket
|
|
|
|
if !dataUsageInfo.LastUpdate.IsZero() {
|
|
|
|
size = dataUsageInfo.BucketsUsage[bucket.Name].Size
|
|
|
|
}
|
|
|
|
acctInfo.Buckets = append(acctInfo.Buckets, madmin.BucketUsageInfo{
|
|
|
|
Name: bucket.Name,
|
|
|
|
Created: bucket.Created,
|
|
|
|
Size: size,
|
|
|
|
Access: madmin.AccountAccess{
|
|
|
|
Read: rd,
|
|
|
|
Write: wr,
|
|
|
|
},
|
|
|
|
})
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
usageInfoJSON, err := json.Marshal(acctInfo)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
writeSuccessResponseJSON(w, usageInfoJSON)
|
|
|
|
}
|
|
|
|
|
|
|
|
// InfoCannedPolicyV2 - GET /minio/admin/v2/info-canned-policy?name={policyName}
|
|
|
|
func (a adminAPIHandlers) InfoCannedPolicyV2(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "InfoCannedPolicyV2")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "InfoCannedPolicyV2", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.GetPolicyAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
policy, err := globalIAMSys.InfoPolicy(mux.Vars(r)["name"])
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
data, err := json.Marshal(policy)
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
w.Write(data)
|
|
|
|
w.(http.Flusher).Flush()
|
|
|
|
}
|
|
|
|
|
|
|
|
// InfoCannedPolicy - GET /minio/admin/v3/info-canned-policy?name={policyName}
|
|
|
|
func (a adminAPIHandlers) InfoCannedPolicy(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "InfoCannedPolicy")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "InfoCannedPolicy", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.GetPolicyAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
policy, err := globalIAMSys.InfoPolicy(mux.Vars(r)["name"])
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if err = json.NewEncoder(w).Encode(policy); err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
w.(http.Flusher).Flush()
|
|
|
|
}
|
|
|
|
|
|
|
|
// ListCannedPoliciesV2 - GET /minio/admin/v2/list-canned-policies
|
|
|
|
func (a adminAPIHandlers) ListCannedPoliciesV2(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "ListCannedPoliciesV2")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "ListCannedPoliciesV2", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.ListUserPoliciesAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
policies, err := globalIAMSys.ListPolicies()
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
policyMap := make(map[string][]byte, len(policies))
|
|
|
|
for k, p := range policies {
|
|
|
|
var err error
|
|
|
|
policyMap[k], err = json.Marshal(p)
|
|
|
|
if err != nil {
|
|
|
|
logger.LogIf(ctx, err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
}
|
|
|
|
if err = json.NewEncoder(w).Encode(policyMap); err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
w.(http.Flusher).Flush()
|
|
|
|
}
|
|
|
|
|
|
|
|
// ListCannedPolicies - GET /minio/admin/v3/list-canned-policies
|
|
|
|
func (a adminAPIHandlers) ListCannedPolicies(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "ListCannedPolicies")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "ListCannedPolicies", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.ListUserPoliciesAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
policies, err := globalIAMSys.ListPolicies()
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
var newPolicies = make(map[string]iampolicy.Policy)
|
|
|
|
for name, p := range policies {
|
|
|
|
_, err = json.Marshal(p)
|
|
|
|
if err != nil {
|
|
|
|
logger.LogIf(ctx, err)
|
|
|
|
continue
|
|
|
|
}
|
|
|
|
newPolicies[name] = p
|
|
|
|
}
|
|
|
|
if err = json.NewEncoder(w).Encode(newPolicies); err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
w.(http.Flusher).Flush()
|
|
|
|
}
|
|
|
|
|
|
|
|
// RemoveCannedPolicy - DELETE /minio/admin/v3/remove-canned-policy?name=<policy_name>
|
|
|
|
func (a adminAPIHandlers) RemoveCannedPolicy(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "RemoveCannedPolicy")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "RemoveCannedPolicy", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.DeletePolicyAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
policyName := vars["name"]
|
|
|
|
|
|
|
|
if err := globalIAMSys.DeletePolicy(policyName); err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Notify all other MinIO peers to delete policy
|
|
|
|
for _, nerr := range globalNotificationSys.DeletePolicy(policyName) {
|
|
|
|
if nerr.Err != nil {
|
|
|
|
logger.GetReqInfo(ctx).SetTags("peerAddress", nerr.Host.String())
|
|
|
|
logger.LogIf(ctx, nerr.Err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// AddCannedPolicy - PUT /minio/admin/v3/add-canned-policy?name=<policy_name>
|
|
|
|
func (a adminAPIHandlers) AddCannedPolicy(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "AddCannedPolicy")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "AddCannedPolicy", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.CreatePolicyAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
policyName := vars["name"]
|
|
|
|
|
|
|
|
// Error out if Content-Length is missing.
|
|
|
|
if r.ContentLength <= 0 {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrMissingContentLength), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Error out if Content-Length is beyond allowed size.
|
|
|
|
if r.ContentLength > maxBucketPolicySize {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrEntityTooLarge), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
iamPolicy, err := iampolicy.ParseConfig(io.LimitReader(r.Body, r.ContentLength))
|
|
|
|
if err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Version in policy must not be empty
|
|
|
|
if iamPolicy.Version == "" {
|
|
|
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrMalformedPolicy), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
if err = globalIAMSys.SetPolicy(policyName, *iamPolicy); err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Notify all other MinIO peers to reload policy
|
|
|
|
for _, nerr := range globalNotificationSys.LoadPolicy(policyName) {
|
|
|
|
if nerr.Err != nil {
|
|
|
|
logger.GetReqInfo(ctx).SetTags("peerAddress", nerr.Host.String())
|
|
|
|
logger.LogIf(ctx, nerr.Err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
// SetPolicyForUserOrGroup - PUT /minio/admin/v3/set-policy?policy=xxx&user-or-group=?[&is-group]
|
|
|
|
func (a adminAPIHandlers) SetPolicyForUserOrGroup(w http.ResponseWriter, r *http.Request) {
|
|
|
|
ctx := newContext(r, w, "SetPolicyForUserOrGroup")
|
|
|
|
|
|
|
|
defer logger.AuditLog(w, r, "SetPolicyForUserOrGroup", mustGetClaimsFromToken(r))
|
|
|
|
|
|
|
|
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.AttachPolicyAdminAction)
|
|
|
|
if objectAPI == nil {
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
vars := mux.Vars(r)
|
|
|
|
policyName := vars["policyName"]
|
|
|
|
entityName := vars["userOrGroup"]
|
|
|
|
isGroup := vars["isGroup"] == "true"
|
|
|
|
|
|
|
|
if !isGroup {
|
|
|
|
ok, err := globalIAMSys.IsTempUser(entityName)
|
|
|
|
if err != nil && err != errNoSuchUser {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
if ok {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, errIAMActionNotAllowed), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
if err := globalIAMSys.PolicyDBSet(entityName, policyName, isGroup); err != nil {
|
|
|
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
|
|
|
return
|
|
|
|
}
|
|
|
|
|
|
|
|
// Notify all other MinIO peers to reload policy
|
|
|
|
for _, nerr := range globalNotificationSys.LoadPolicyMapping(entityName, isGroup) {
|
|
|
|
if nerr.Err != nil {
|
|
|
|
logger.GetReqInfo(ctx).SetTags("peerAddress", nerr.Host.String())
|
|
|
|
logger.LogIf(ctx, nerr.Err)
|
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|