mirror of
https://github.com/minio/minio.git
synced 2025-07-08 08:32:18 -04:00
fix: allow accountInfo, addUser and getUserInfo implicit (#10978)
- accountInfo API that returns information about user, access to buckets and the size per bucket - addUser - user is allowed to change their secretKey - getUserInfo - returns user info if the incoming is the same user requesting their information
This commit is contained in:
parent
350c5ff8f8
commit
e6fa410778
@ -129,13 +129,40 @@ func (a adminAPIHandlers) GetUserInfo(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
defer logger.AuditLog(w, r, "GetUserInfo", mustGetClaimsFromToken(r))
|
defer logger.AuditLog(w, r, "GetUserInfo", mustGetClaimsFromToken(r))
|
||||||
|
|
||||||
objectAPI, _ := validateAdminUsersReq(ctx, w, r, iampolicy.GetUserAdminAction)
|
vars := mux.Vars(r)
|
||||||
if objectAPI == nil {
|
name := vars["accessKey"]
|
||||||
|
|
||||||
|
// Get current object layer instance.
|
||||||
|
objectAPI := newObjectLayerFn()
|
||||||
|
if objectAPI == nil || globalNotificationSys == nil {
|
||||||
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrServerNotInitialized), r.URL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
vars := mux.Vars(r)
|
cred, claims, owner, s3Err := validateAdminSignature(ctx, r, "")
|
||||||
name := vars["accessKey"]
|
if s3Err != ErrNone {
|
||||||
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(s3Err), r.URL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
accessKey := cred.AccessKey
|
||||||
|
if cred.ParentUser != "" {
|
||||||
|
accessKey = cred.ParentUser
|
||||||
|
}
|
||||||
|
|
||||||
|
implicitPerm := name == accessKey
|
||||||
|
if !implicitPerm {
|
||||||
|
if !globalIAMSys.IsAllowed(iampolicy.Args{
|
||||||
|
AccountName: accessKey,
|
||||||
|
Action: iampolicy.GetUserAdminAction,
|
||||||
|
ConditionValues: getConditionValues(r, "", accessKey, claims),
|
||||||
|
IsOwner: owner,
|
||||||
|
Claims: claims,
|
||||||
|
}) {
|
||||||
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
userInfo, err := globalIAMSys.GetUserInfo(name)
|
userInfo, err := globalIAMSys.GetUserInfo(name)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -304,7 +331,7 @@ func (a adminAPIHandlers) SetUserStatus(w http.ResponseWriter, r *http.Request)
|
|||||||
accessKey := vars["accessKey"]
|
accessKey := vars["accessKey"]
|
||||||
status := vars["status"]
|
status := vars["status"]
|
||||||
|
|
||||||
// Custom IAM policies not allowed for admin user.
|
// This API is not allowed to lookup accessKey user status
|
||||||
if accessKey == globalActiveCred.AccessKey {
|
if accessKey == globalActiveCred.AccessKey {
|
||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrInvalidRequest), r.URL)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrInvalidRequest), r.URL)
|
||||||
return
|
return
|
||||||
@ -330,20 +357,47 @@ func (a adminAPIHandlers) AddUser(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
defer logger.AuditLog(w, r, "AddUser", mustGetClaimsFromToken(r))
|
defer logger.AuditLog(w, r, "AddUser", mustGetClaimsFromToken(r))
|
||||||
|
|
||||||
objectAPI, cred := validateAdminUsersReq(ctx, w, r, iampolicy.CreateUserAdminAction)
|
|
||||||
if objectAPI == nil {
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
vars := mux.Vars(r)
|
vars := mux.Vars(r)
|
||||||
accessKey := vars["accessKey"]
|
accessKey := vars["accessKey"]
|
||||||
|
|
||||||
// Custom IAM policies not allowed for admin user.
|
// Get current object layer instance.
|
||||||
if accessKey == globalActiveCred.AccessKey {
|
objectAPI := newObjectLayerFn()
|
||||||
|
if objectAPI == nil || globalNotificationSys == 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
|
||||||
|
}
|
||||||
|
|
||||||
|
if cred.IsTemp() || cred.IsServiceAccount() {
|
||||||
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccountNotEligible), r.URL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Not allowed to add a user with same access key as root credential
|
||||||
|
if owner {
|
||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAddUserInvalidArgument), r.URL)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAddUserInvalidArgument), r.URL)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
implicitPerm := accessKey == cred.AccessKey
|
||||||
|
if !implicitPerm {
|
||||||
|
if !globalIAMSys.IsAllowed(iampolicy.Args{
|
||||||
|
AccountName: accessKey,
|
||||||
|
Action: iampolicy.CreateUserAdminAction,
|
||||||
|
ConditionValues: getConditionValues(r, "", accessKey, claims),
|
||||||
|
IsOwner: owner,
|
||||||
|
Claims: claims,
|
||||||
|
}) {
|
||||||
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAccessDenied), r.URL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if r.ContentLength > maxEConfigJSONSize || r.ContentLength == -1 {
|
if r.ContentLength > maxEConfigJSONSize || r.ContentLength == -1 {
|
||||||
// More than maxConfigSize bytes were available
|
// More than maxConfigSize bytes were available
|
||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminConfigTooLarge), r.URL)
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminConfigTooLarge), r.URL)
|
||||||
@ -398,6 +452,12 @@ func (a adminAPIHandlers) AddServiceAccount(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Disallow creating service accounts by root user.
|
||||||
|
if owner {
|
||||||
|
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminAccountNotEligible), r.URL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
password := cred.SecretKey
|
password := cred.SecretKey
|
||||||
reqBytes, err := madmin.DecryptData(password, io.LimitReader(r.Body, r.ContentLength))
|
reqBytes, err := madmin.DecryptData(password, io.LimitReader(r.Body, r.ContentLength))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
@ -411,12 +471,6 @@ func (a adminAPIHandlers) AddServiceAccount(w http.ResponseWriter, r *http.Reque
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Disallow creating service accounts by root user.
|
|
||||||
if owner {
|
|
||||||
writeErrorResponseJSON(ctx, w, errorCodes.ToAPIErr(ErrAdminAccountNotEligible), r.URL)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
parentUser := cred.AccessKey
|
parentUser := cred.AccessKey
|
||||||
if cred.ParentUser != "" {
|
if cred.ParentUser != "" {
|
||||||
parentUser = cred.ParentUser
|
parentUser = cred.ParentUser
|
||||||
@ -572,11 +626,11 @@ func (a adminAPIHandlers) DeleteServiceAccount(w http.ResponseWriter, r *http.Re
|
|||||||
writeSuccessNoContent(w)
|
writeSuccessNoContent(w)
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountUsageInfoHandler returns usage
|
// AccountInfoHandler returns usage
|
||||||
func (a adminAPIHandlers) AccountUsageInfoHandler(w http.ResponseWriter, r *http.Request) {
|
func (a adminAPIHandlers) AccountInfoHandler(w http.ResponseWriter, r *http.Request) {
|
||||||
ctx := newContext(r, w, "AccountUsageInfo")
|
ctx := newContext(r, w, "AccountInfo")
|
||||||
|
|
||||||
defer logger.AuditLog(w, r, "AccountUsageInfo", mustGetClaimsFromToken(r))
|
defer logger.AuditLog(w, r, "AccountInfo", mustGetClaimsFromToken(r))
|
||||||
|
|
||||||
// Get current object layer instance.
|
// Get current object layer instance.
|
||||||
objectAPI := newObjectLayerFn()
|
objectAPI := newObjectLayerFn()
|
||||||
@ -645,8 +699,16 @@ func (a adminAPIHandlers) AccountUsageInfoHandler(w http.ResponseWriter, r *http
|
|||||||
accountName = cred.ParentUser
|
accountName = cred.ParentUser
|
||||||
}
|
}
|
||||||
|
|
||||||
acctInfo := madmin.AccountUsageInfo{
|
policies, err := globalIAMSys.PolicyDBGet(accountName, false)
|
||||||
|
if err != nil {
|
||||||
|
logger.LogIf(ctx, err)
|
||||||
|
writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
acctInfo := madmin.AccountInfo{
|
||||||
AccountName: accountName,
|
AccountName: accountName,
|
||||||
|
Policy: globalIAMSys.GetCombinedPolicy(policies...),
|
||||||
}
|
}
|
||||||
|
|
||||||
for _, bucket := range buckets {
|
for _, bucket := range buckets {
|
||||||
|
@ -116,7 +116,7 @@ func registerAdminRouter(router *mux.Router, enableConfigOps, enableIAMOps bool)
|
|||||||
|
|
||||||
// Add user IAM
|
// Add user IAM
|
||||||
|
|
||||||
adminRouter.Methods(http.MethodGet).Path(adminVersion + "/accountusageinfo").HandlerFunc(httpTraceAll(adminAPI.AccountUsageInfoHandler))
|
adminRouter.Methods(http.MethodGet).Path(adminVersion + "/accountinfo").HandlerFunc(httpTraceAll(adminAPI.AccountInfoHandler))
|
||||||
|
|
||||||
adminRouter.Methods(http.MethodPut).Path(adminVersion+"/add-user").HandlerFunc(httpTraceHdrs(adminAPI.AddUser)).Queries("accessKey", "{accessKey:.*}")
|
adminRouter.Methods(http.MethodPut).Path(adminVersion+"/add-user").HandlerFunc(httpTraceHdrs(adminAPI.AddUser)).Queries("accessKey", "{accessKey:.*}")
|
||||||
|
|
||||||
|
@ -363,6 +363,7 @@ const (
|
|||||||
ErrInvalidDecompressedSize
|
ErrInvalidDecompressedSize
|
||||||
ErrAddUserInvalidArgument
|
ErrAddUserInvalidArgument
|
||||||
ErrAdminAccountNotEligible
|
ErrAdminAccountNotEligible
|
||||||
|
ErrAccountNotEligible
|
||||||
ErrServiceAccountNotFound
|
ErrServiceAccountNotFound
|
||||||
ErrPostPolicyConditionInvalidFormat
|
ErrPostPolicyConditionInvalidFormat
|
||||||
)
|
)
|
||||||
@ -1726,12 +1727,17 @@ var errorCodes = errorCodeMap{
|
|||||||
ErrAddUserInvalidArgument: {
|
ErrAddUserInvalidArgument: {
|
||||||
Code: "XMinioInvalidIAMCredentials",
|
Code: "XMinioInvalidIAMCredentials",
|
||||||
Description: "User is not allowed to be same as admin access key",
|
Description: "User is not allowed to be same as admin access key",
|
||||||
HTTPStatusCode: http.StatusConflict,
|
HTTPStatusCode: http.StatusForbidden,
|
||||||
},
|
},
|
||||||
ErrAdminAccountNotEligible: {
|
ErrAdminAccountNotEligible: {
|
||||||
Code: "XMinioInvalidIAMCredentials",
|
Code: "XMinioInvalidIAMCredentials",
|
||||||
Description: "The administrator key is not eligible for this operation",
|
Description: "The administrator key is not eligible for this operation",
|
||||||
HTTPStatusCode: http.StatusConflict,
|
HTTPStatusCode: http.StatusForbidden,
|
||||||
|
},
|
||||||
|
ErrAccountNotEligible: {
|
||||||
|
Code: "XMinioInvalidIAMCredentials",
|
||||||
|
Description: "The account key is not eligible for this operation",
|
||||||
|
HTTPStatusCode: http.StatusForbidden,
|
||||||
},
|
},
|
||||||
ErrServiceAccountNotFound: {
|
ErrServiceAccountNotFound: {
|
||||||
Code: "XMinioInvalidIAMCredentials",
|
Code: "XMinioInvalidIAMCredentials",
|
||||||
|
47
cmd/iam.go
47
cmd/iam.go
@ -1827,6 +1827,33 @@ func (sys *IAMSys) IsAllowedSTS(args iampolicy.Args) bool {
|
|||||||
return combinedPolicy.IsAllowed(args)
|
return combinedPolicy.IsAllowed(args)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetCombinedPolicy returns a combined policy combining all policies
|
||||||
|
func (sys *IAMSys) GetCombinedPolicy(policies ...string) iampolicy.Policy {
|
||||||
|
// Policies were found, evaluate all of them.
|
||||||
|
sys.store.rlock()
|
||||||
|
defer sys.store.runlock()
|
||||||
|
|
||||||
|
var availablePolicies []iampolicy.Policy
|
||||||
|
for _, pname := range policies {
|
||||||
|
p, found := sys.iamPolicyDocsMap[pname]
|
||||||
|
if found {
|
||||||
|
availablePolicies = append(availablePolicies, p)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(availablePolicies) == 0 {
|
||||||
|
return iampolicy.Policy{}
|
||||||
|
}
|
||||||
|
|
||||||
|
combinedPolicy := availablePolicies[0]
|
||||||
|
for i := 1; i < len(availablePolicies); i++ {
|
||||||
|
combinedPolicy.Statements = append(combinedPolicy.Statements,
|
||||||
|
availablePolicies[i].Statements...)
|
||||||
|
}
|
||||||
|
|
||||||
|
return combinedPolicy
|
||||||
|
}
|
||||||
|
|
||||||
// IsAllowed - checks given policy args is allowed to continue the Rest API.
|
// IsAllowed - checks given policy args is allowed to continue the Rest API.
|
||||||
func (sys *IAMSys) IsAllowed(args iampolicy.Args) bool {
|
func (sys *IAMSys) IsAllowed(args iampolicy.Args) bool {
|
||||||
// If opa is configured, use OPA always.
|
// If opa is configured, use OPA always.
|
||||||
@ -1873,25 +1900,7 @@ func (sys *IAMSys) IsAllowed(args iampolicy.Args) bool {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Policies were found, evaluate all of them.
|
// Policies were found, evaluate all of them.
|
||||||
sys.store.rlock()
|
return sys.GetCombinedPolicy(policies...).IsAllowed(args)
|
||||||
defer sys.store.runlock()
|
|
||||||
|
|
||||||
var availablePolicies []iampolicy.Policy
|
|
||||||
for _, pname := range policies {
|
|
||||||
p, found := sys.iamPolicyDocsMap[pname]
|
|
||||||
if found {
|
|
||||||
availablePolicies = append(availablePolicies, p)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(availablePolicies) == 0 {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
combinedPolicy := availablePolicies[0]
|
|
||||||
for i := 1; i < len(availablePolicies); i++ {
|
|
||||||
combinedPolicy.Statements = append(combinedPolicy.Statements,
|
|
||||||
availablePolicies[i].Statements...)
|
|
||||||
}
|
|
||||||
return combinedPolicy.IsAllowed(args)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default canned policies only if not already overridden by users.
|
// Set default canned policies only if not already overridden by users.
|
||||||
|
@ -47,7 +47,7 @@ func main() {
|
|||||||
|:------------------------------------|:-----------------------------------------|:-------------------|:--------------------------|
|
|:------------------------------------|:-----------------------------------------|:-------------------|:--------------------------|
|
||||||
| [`ServiceTrace`](#ServiceTrace) | [`ServerInfo`](#ServerInfo) | [`Heal`](#Heal) | [`GetConfig`](#GetConfig) |
|
| [`ServiceTrace`](#ServiceTrace) | [`ServerInfo`](#ServerInfo) | [`Heal`](#Heal) | [`GetConfig`](#GetConfig) |
|
||||||
| [`ServiceStop`](#ServiceStop) | [`StorageInfo`](#StorageInfo) | | [`SetConfig`](#SetConfig) |
|
| [`ServiceStop`](#ServiceStop) | [`StorageInfo`](#StorageInfo) | | [`SetConfig`](#SetConfig) |
|
||||||
| [`ServiceRestart`](#ServiceRestart) | [`AccountUsageInfo`](#AccountUsageInfo) | | |
|
| [`ServiceRestart`](#ServiceRestart) | [`AccountInfo`](#AccountInfo) | | |
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
@ -251,16 +251,16 @@ __Example__
|
|||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
<a name="AccountUsageInfo"></a>
|
<a name="AccountInfo"></a>
|
||||||
|
|
||||||
### AccountUsageInfo(ctx context.Context) (AccountUsageInfo, error)
|
### AccountInfo(ctx context.Context) (AccountInfo, error)
|
||||||
|
|
||||||
Fetches accounting usage information for the current authenticated user
|
Fetches accounting usage information for the current authenticated user
|
||||||
|
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
|--------------------------------|----------------------|-------------------------|
|
|--------------------------------|----------------------|-------------------------|
|
||||||
| `AccountUsageInfo.AccountName` | _string_ | Account name. |
|
| `AccountInfo.AccountName` | _string_ | Account name. |
|
||||||
| `AccountUsageInfo.Buckets` | _[]BucketUsageInfo_ | Bucket usage info. |
|
| `AccountInfo.Buckets` | _[]BucketUsageInfo_ | Bucket usage info. |
|
||||||
|
|
||||||
|
|
||||||
| Param | Type | Description |
|
| Param | Type | Description |
|
||||||
@ -281,12 +281,12 @@ __Example__
|
|||||||
|
|
||||||
```go
|
```go
|
||||||
|
|
||||||
accountUsageInfo, err := madmClnt.AccountUsageInfo(context.Background())
|
accountInfo, err := madmClnt.AccountInfo(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println(accountUsageInfo)
|
log.Println(accountInfo)
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
@ -37,10 +37,10 @@ func main() {
|
|||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
accountUsageInfo, err := madmClnt.AccountUsageInfo(context.Background())
|
accountInfo, err := madmClnt.AccountInfo(context.Background())
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Fatalln(err)
|
log.Fatalln(err)
|
||||||
}
|
}
|
||||||
|
|
||||||
log.Println(accountUsageInfo)
|
log.Println(accountInfo)
|
||||||
}
|
}
|
@ -44,37 +44,38 @@ type BucketUsageInfo struct {
|
|||||||
Access AccountAccess `json:"access"`
|
Access AccountAccess `json:"access"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountUsageInfo represents the account usage info of an
|
// AccountInfo represents the account usage info of an
|
||||||
// account across buckets.
|
// account across buckets.
|
||||||
type AccountUsageInfo struct {
|
type AccountInfo struct {
|
||||||
AccountName string
|
AccountName string
|
||||||
|
Policy iampolicy.Policy
|
||||||
Buckets []BucketUsageInfo
|
Buckets []BucketUsageInfo
|
||||||
}
|
}
|
||||||
|
|
||||||
// AccountUsageInfo returns the usage info for the authenticating account.
|
// AccountInfo returns the usage info for the authenticating account.
|
||||||
func (adm *AdminClient) AccountUsageInfo(ctx context.Context) (AccountUsageInfo, error) {
|
func (adm *AdminClient) AccountInfo(ctx context.Context) (AccountInfo, error) {
|
||||||
resp, err := adm.executeMethod(ctx, http.MethodGet, requestData{relPath: adminAPIPrefix + "/accountusageinfo"})
|
resp, err := adm.executeMethod(ctx, http.MethodGet, requestData{relPath: adminAPIPrefix + "/accountinfo"})
|
||||||
defer closeResponse(resp)
|
defer closeResponse(resp)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AccountUsageInfo{}, err
|
return AccountInfo{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check response http status code
|
// Check response http status code
|
||||||
if resp.StatusCode != http.StatusOK {
|
if resp.StatusCode != http.StatusOK {
|
||||||
return AccountUsageInfo{}, httpRespToErrorResponse(resp)
|
return AccountInfo{}, httpRespToErrorResponse(resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unmarshal the server's json response
|
// Unmarshal the server's json response
|
||||||
var accountInfo AccountUsageInfo
|
var accountInfo AccountInfo
|
||||||
|
|
||||||
respBytes, err := ioutil.ReadAll(resp.Body)
|
respBytes, err := ioutil.ReadAll(resp.Body)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AccountUsageInfo{}, err
|
return AccountInfo{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
err = json.Unmarshal(respBytes, &accountInfo)
|
err = json.Unmarshal(respBytes, &accountInfo)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return AccountUsageInfo{}, err
|
return AccountInfo{}, err
|
||||||
}
|
}
|
||||||
|
|
||||||
return accountInfo, nil
|
return accountInfo, nil
|
||||||
|
Loading…
x
Reference in New Issue
Block a user