feat(cli) Implement infra to support cross account AssumeRole for DHC on ListHosts (#86)
This commit is contained in:
1 parent
1eed21718b
commit
5607603fbf
4 files changed
+397
-15
No files matched your search
@@ -4,6 +4,7 @@ go 1.26.0
|
||||
|
||||
require (
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.7
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.46
|
||||
github.com/aws/aws-sdk-go-v2/service/ec2 v1.254.0
|
||||
github.com/aws/aws-sdk-go-v2/service/iam v1.47.6
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.101.0
|
||||
@@ -26,7 +27,6 @@ require (
|
||||
github.com/apparentlymart/go-textseg/v15 v15.0.0 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/aws/protocol/eventstream v1.7.10 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/config v1.28.5 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/credentials v1.17.46 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/feature/ec2/imds v1.16.20 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/configsources v1.4.23 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/internal/endpoints/v2 v2.7.23 // indirect
|
||||
|
||||
+134
-13
@@ -5,12 +5,17 @@ package host
|
||||
|
||||
import (
|
||||
"context"
|
||||
goerrors "errors"
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/aws/aws-sdk-go-v2/aws"
|
||||
"github.com/aws/aws-sdk-go-v2/credentials/stscreds"
|
||||
"github.com/aws/aws-sdk-go-v2/service/ec2"
|
||||
"github.com/aws/aws-sdk-go-v2/service/ec2/types"
|
||||
"github.com/aws/aws-sdk-go-v2/service/sts"
|
||||
stsTypes "github.com/aws/aws-sdk-go-v2/service/sts/types"
|
||||
"github.com/aws/smithy-go"
|
||||
"github.com/hashicorp/boundary-plugin-aws/internal/credential"
|
||||
"github.com/hashicorp/boundary-plugin-aws/internal/errors"
|
||||
pb "github.com/hashicorp/boundary/sdk/pbs/plugin"
|
||||
@@ -32,6 +37,16 @@ type HostPlugin struct {
|
||||
testCatalogStateOpts []awsCatalogPersistedStateOption
|
||||
}
|
||||
|
||||
type hostSetQuery struct {
|
||||
Id string
|
||||
Input *ec2.DescribeInstancesInput
|
||||
Output *ec2.DescribeInstancesOutput
|
||||
OutputHosts []*pb.ListHostsResponseHost
|
||||
}
|
||||
|
||||
const defaultSessionName = "boundary-default"
|
||||
const dryRunOperationErrorCode = "DryRunOperation"
|
||||
|
||||
// Ensure that we are implementing HostPluginServiceServer
|
||||
var _ pb.HostPluginServiceServer = (*HostPlugin)(nil)
|
||||
|
||||
@@ -495,13 +510,6 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
|
||||
}
|
||||
|
||||
// Build all the queries in advance.
|
||||
type hostSetQuery struct {
|
||||
Id string
|
||||
Input *ec2.DescribeInstancesInput
|
||||
Output *ec2.DescribeInstancesOutput
|
||||
OutputHosts []*pb.ListHostsResponseHost
|
||||
}
|
||||
|
||||
queries := make([]hostSetQuery, len(sets))
|
||||
for i, set := range sets {
|
||||
// Validate Id since we use it in output
|
||||
@@ -531,20 +539,22 @@ func (p *HostPlugin) ListHosts(ctx context.Context, req *pb.ListHostsRequest) (*
|
||||
if catalogAttributes.DualStack {
|
||||
opts = append(opts, WithDualStack(catalogAttributes.DualStack))
|
||||
}
|
||||
ec2Client, err := catalogState.EC2Client(ctx, opts...)
|
||||
if err != nil {
|
||||
return nil, errors.BadRequestStatusf("error getting EC2 client: %s", err)
|
||||
}
|
||||
|
||||
// Run all queries now and assemble output.
|
||||
var maxLen int
|
||||
for i, query := range queries {
|
||||
output, err := ec2Client.DescribeInstances(ctx, query.Input)
|
||||
output, err := checkHosts(ctx, catalogState, query.Input, opts, catalogAttributes.TargetAccount)
|
||||
if err != nil {
|
||||
return nil, errors.BadRequestStatusf("error running DescribeInstances for host set id %q: %s", query.Id, err)
|
||||
if catalogAttributes.TargetAccount != nil {
|
||||
return nil, errors.BadRequestStatusf("unable to list hosts for host set %q: %s", query.Id, err)
|
||||
}
|
||||
return nil, errors.BadRequestStatusf("error retrieving host results for host set id %q: %s", query.Id, err)
|
||||
}
|
||||
|
||||
queries[i].Output = output
|
||||
if output == nil {
|
||||
continue
|
||||
}
|
||||
|
||||
// Process the output here, we will normalize this into a single
|
||||
// set of hosts afterwards (possibly removing duplicates).
|
||||
@@ -711,6 +721,117 @@ func awsInstanceToHost(instance types.Instance, catalogAttributes *CatalogAttrib
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func checkHosts(ctx context.Context,
|
||||
principalCatalogState *awsCatalogPersistedState,
|
||||
input *ec2.DescribeInstancesInput,
|
||||
opts []ec2Option,
|
||||
target *credential.CredentialAttributes,
|
||||
) (*ec2.DescribeInstancesOutput, error) {
|
||||
if principalCatalogState == nil {
|
||||
return nil, fmt.Errorf("unable to create EC2 client from catalog state")
|
||||
}
|
||||
|
||||
var ec2Client EC2API
|
||||
var err error
|
||||
if target == nil {
|
||||
// use the principal account to execute the DescribeInstances command
|
||||
ec2Client, err = principalCatalogState.EC2Client(ctx, opts...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
} else {
|
||||
// use the target account to execute the DescribeInstances command
|
||||
ec2Client, err = ec2ClientForTarget(ctx, principalCatalogState, target, opts)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("EC2 client setup failed for target account: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
output, err := ec2Client.DescribeInstances(ctx, input)
|
||||
if err != nil {
|
||||
var awsErr smithy.APIError
|
||||
ok := goerrors.As(err, &awsErr)
|
||||
if ok && awsErr.ErrorCode() == dryRunOperationErrorCode {
|
||||
// successful DryRun validation, no response necessary, no error thrown
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
if target != nil {
|
||||
return nil, fmt.Errorf("EC2 DescribeInstances failed for target account: %w", err)
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// ec2ClientForTarget builds an EC2 client to query the target account.
|
||||
// The hop is a continuation of the principal session: STS AssumeRole is called
|
||||
// as the principal to get to the target, then EC2 uses the resulting temporary credentials.
|
||||
// This is not a second worker-level credential chain on the target attributes.
|
||||
func ec2ClientForTarget(
|
||||
ctx context.Context,
|
||||
principalCatalogState *awsCatalogPersistedState,
|
||||
target *credential.CredentialAttributes,
|
||||
opts []ec2Option) (EC2API, error) {
|
||||
// Reuse the principal SDK config (HTTP client, retries, logger, and the
|
||||
// principal identity).
|
||||
principalCfg, err := principalCatalogState.GenerateCredentialChain(ctx)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("principal AWS configuration could not be loaded: %w", err)
|
||||
}
|
||||
if principalCfg == nil {
|
||||
return nil, fmt.Errorf("nil principal AWS configuration while configuring EC2 client for target account")
|
||||
}
|
||||
|
||||
// STS must run as the principal so the target role's trust policy sees that
|
||||
// identity, not the worker's. Session name, external ID, and tags are
|
||||
// forwarded from attrs when set so the AssumeRole matches the target role's configuration.
|
||||
stsClient := sts.NewFromConfig(*principalCfg)
|
||||
provider := stscreds.NewAssumeRoleProvider(stsClient, target.RoleArn, func(o *stscreds.AssumeRoleOptions) {
|
||||
if target.RoleSessionName != "" {
|
||||
o.RoleSessionName = target.RoleSessionName
|
||||
} else {
|
||||
// STS may reject the AssumeRole request if session name is not provided.
|
||||
o.RoleSessionName = defaultSessionName
|
||||
}
|
||||
if target.RoleExternalId != "" {
|
||||
o.ExternalID = &target.RoleExternalId
|
||||
}
|
||||
for k, v := range target.RoleTags {
|
||||
o.Tags = append(o.Tags, stsTypes.Tag{
|
||||
Key: aws.String(k),
|
||||
Value: aws.String(v),
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
// Same worker SDK environment as the principal; only identity and region change.
|
||||
targetCfg := principalCfg.Copy()
|
||||
targetCfg.Credentials = aws.NewCredentialsCache(provider)
|
||||
if target.Region != "" {
|
||||
targetCfg.Region = target.Region
|
||||
}
|
||||
|
||||
parsed, err := getOpts(opts...)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("EC2 client options are invalid: %w", err)
|
||||
}
|
||||
// Dual-stack is the worker's path to AWS endpoints, not per-account. Apply
|
||||
// it on the hopped client the same way EC2Client does for the principal.
|
||||
var ec2Opts []func(*ec2.Options)
|
||||
if parsed.withDualStack {
|
||||
ec2Opts = append(ec2Opts, ec2.WithEndpointResolverV2(&endpointResolver{
|
||||
dualStack: parsed.withDualStack,
|
||||
}))
|
||||
}
|
||||
|
||||
if principalCatalogState.testEC2APIFunc != nil {
|
||||
return principalCatalogState.testEC2APIFunc(targetCfg)
|
||||
}
|
||||
return ec2.NewFromConfig(targetCfg, ec2Opts...), nil
|
||||
}
|
||||
|
||||
// dryRunValidation performs an AWS DescribeInstances call to verify the state's
|
||||
// credentials, the host listing functionality as well as the filters, if any
|
||||
// are passed in. This function can therefore be used for both host catalog and
|
||||
|
||||
@@ -1849,6 +1849,162 @@ func TestPluginOnUpdateSetErr(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func testListHostsSet(id, filter string) *hostsets.HostSet {
|
||||
return &hostsets.HostSet{
|
||||
Id: id,
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstDescribeInstancesFilters: structpb.NewListValue(
|
||||
&structpb.ListValue{
|
||||
Values: []*structpb.Value{
|
||||
structpb.NewStringValue(filter),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testListHostsPersisted() *pb.HostCatalogPersisted {
|
||||
return &pb.HostCatalogPersisted{
|
||||
Secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"),
|
||||
credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func testExpectedDescribeInput(filterKey, filterValue string) *ec2.DescribeInstancesInput {
|
||||
return &ec2.DescribeInstancesInput{
|
||||
DryRun: aws.Bool(false),
|
||||
Filters: []types.Filter{
|
||||
{
|
||||
Name: aws.String(filterKey),
|
||||
Values: []string{filterValue},
|
||||
},
|
||||
{
|
||||
Name: aws.String("instance-state-name"),
|
||||
Values: []string{string(types.InstanceStateNameRunning)},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginListHosts(t *testing.T) {
|
||||
describeOutput := &ec2.DescribeInstancesOutput{
|
||||
Reservations: []types.Reservation{
|
||||
{
|
||||
Instances: []types.Instance{
|
||||
{
|
||||
InstanceId: aws.String("i-shared"),
|
||||
PrivateIpAddress: aws.String("10.0.0.1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
catalogFields map[string]*structpb.Value
|
||||
sets []*hostsets.HostSet
|
||||
expectedRegion string
|
||||
expectedHosts []*pb.ListHostsResponseHost
|
||||
expectedInputs []*ec2.DescribeInstancesInput
|
||||
expectedDescribe int
|
||||
}{
|
||||
{
|
||||
name: "principal account lists hosts",
|
||||
catalogFields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
},
|
||||
sets: []*hostsets.HostSet{
|
||||
testListHostsSet("set-1", "tag-key=foo"),
|
||||
},
|
||||
expectedRegion: "us-west-2",
|
||||
expectedHosts: []*pb.ListHostsResponseHost{
|
||||
{
|
||||
ExternalId: "i-shared",
|
||||
SetIds: []string{"set-1"},
|
||||
},
|
||||
},
|
||||
expectedInputs: []*ec2.DescribeInstancesInput{
|
||||
testExpectedDescribeInput("tag-key", "foo"),
|
||||
},
|
||||
expectedDescribe: 1,
|
||||
},
|
||||
{
|
||||
name: "target account lists hosts without querying principal",
|
||||
catalogFields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
credential.ConstTargetRoleArn: structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
|
||||
credential.ConstTargetRegion: structpb.NewStringValue("eu-west-1"),
|
||||
},
|
||||
sets: []*hostsets.HostSet{
|
||||
testListHostsSet("set-1", "tag-key=foo"),
|
||||
testListHostsSet("set-2", "tag-key=bar"),
|
||||
},
|
||||
expectedRegion: "eu-west-1",
|
||||
expectedHosts: []*pb.ListHostsResponseHost{
|
||||
{
|
||||
ExternalId: "i-shared",
|
||||
SetIds: []string{"set-1", "set-2"},
|
||||
},
|
||||
},
|
||||
expectedInputs: []*ec2.DescribeInstancesInput{
|
||||
testExpectedDescribeInput("tag-key", "foo"),
|
||||
testExpectedDescribeInput("tag-key", "bar"),
|
||||
},
|
||||
expectedDescribe: 2,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
tc := tc
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
require := require.New(t)
|
||||
state := &testMockEC2State{}
|
||||
p := &HostPlugin{
|
||||
testCatalogStateOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(newTestMockEC2(
|
||||
state,
|
||||
testMockEC2WithDescribeInstancesOutput(describeOutput),
|
||||
)),
|
||||
},
|
||||
}
|
||||
|
||||
resp, err := p.ListHosts(context.Background(), &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{Fields: tc.catalogFields},
|
||||
},
|
||||
},
|
||||
Persisted: testListHostsPersisted(),
|
||||
Sets: tc.sets,
|
||||
})
|
||||
require.NoError(err)
|
||||
require.NotNil(resp)
|
||||
require.Len(resp.GetHosts(), len(tc.expectedHosts))
|
||||
for i, expected := range tc.expectedHosts {
|
||||
require.Equal(expected.ExternalId, resp.Hosts[i].ExternalId)
|
||||
require.Equal(expected.SetIds, resp.Hosts[i].SetIds)
|
||||
}
|
||||
|
||||
require.Equal(tc.expectedDescribe, state.DescribeInstancesCallCount)
|
||||
require.Len(state.ClientRegions, tc.expectedDescribe)
|
||||
for _, region := range state.ClientRegions {
|
||||
require.Equal(tc.expectedRegion, region)
|
||||
}
|
||||
require.Equal(tc.expectedInputs, state.DescribeInstancesInputs)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginListHostsErr(t *testing.T) {
|
||||
cases := []struct {
|
||||
name string
|
||||
@@ -2130,7 +2286,101 @@ func TestPluginListHostsErr(t *testing.T) {
|
||||
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
|
||||
)),
|
||||
},
|
||||
expectedErrContains: fmt.Sprintf("error running DescribeInstances for host set id \"foobar\": %s", testDescribeInstancesError),
|
||||
expectedErrContains: fmt.Sprintf("error retrieving host results for host set id \"foobar\": %s", testDescribeInstancesError),
|
||||
expectedErrCode: codes.InvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "target attributes missing target_role_arn",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
credential.ConstTargetRegion: structpb.NewStringValue("eu-west-1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
expectedErrContains: "attributes.target_role_arn: missing required value \"target_role_arn\"",
|
||||
expectedErrCode: codes.InvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "target EC2 client error",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
credential.ConstTargetRoleArn: structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Persisted: &pb.HostCatalogPersisted{
|
||||
Secrets: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstAccessKeyId: structpb.NewStringValue("AKIA_foobar"),
|
||||
credential.ConstSecretAccessKey: structpb.NewStringValue("bazqux"),
|
||||
credential.ConstCredsLastRotatedTime: structpb.NewStringValue("2006-01-02T15:04:05+07:00"),
|
||||
},
|
||||
},
|
||||
},
|
||||
Sets: []*hostsets.HostSet{
|
||||
{
|
||||
Id: "foobar",
|
||||
Attrs: &hostsets.HostSet_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
ConstDescribeInstancesFilters: structpb.NewListValue(
|
||||
&structpb.ListValue{
|
||||
Values: []*structpb.Value{
|
||||
structpb.NewStringValue("tag-key=foo"),
|
||||
},
|
||||
},
|
||||
),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
catalogOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(func(...aws.Config) (EC2API, error) {
|
||||
return nil, errors.New("test EC2 client error")
|
||||
}),
|
||||
},
|
||||
expectedErrContains: "unable to list hosts for host set \"foobar\": EC2 client setup failed for target account: test EC2 client error",
|
||||
expectedErrCode: codes.InvalidArgument,
|
||||
},
|
||||
{
|
||||
name: "target DescribeInstances error",
|
||||
req: &pb.ListHostsRequest{
|
||||
Catalog: &hostcatalogs.HostCatalog{
|
||||
Attrs: &hostcatalogs.HostCatalog_Attributes{
|
||||
Attributes: &structpb.Struct{
|
||||
Fields: map[string]*structpb.Value{
|
||||
credential.ConstRegion: structpb.NewStringValue("us-west-2"),
|
||||
credential.ConstTargetRoleArn: structpb.NewStringValue("arn:aws:iam::222222222222:role/Target"),
|
||||
credential.ConstTargetRegion: structpb.NewStringValue("eu-west-1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
Persisted: testListHostsPersisted(),
|
||||
Sets: []*hostsets.HostSet{
|
||||
testListHostsSet("foobar", "tag-key=foo"),
|
||||
},
|
||||
},
|
||||
catalogOpts: []awsCatalogPersistedStateOption{
|
||||
withTestEC2APIFunc(newTestMockEC2(
|
||||
nil,
|
||||
testMockEC2WithDescribeInstancesError(errors.New(testDescribeInstancesError)),
|
||||
)),
|
||||
},
|
||||
expectedErrContains: "unable to list hosts for host set \"foobar\": EC2 DescribeInstances failed for target account: DescribeInstances error",
|
||||
expectedErrCode: codes.InvalidArgument,
|
||||
},
|
||||
{
|
||||
|
||||
@@ -22,11 +22,17 @@ const (
|
||||
type testMockEC2State struct {
|
||||
DescribeInstancesCalled bool
|
||||
DescribeInstancesInputParams *ec2.DescribeInstancesInput
|
||||
DescribeInstancesCallCount int
|
||||
DescribeInstancesInputs []*ec2.DescribeInstancesInput
|
||||
ClientRegions []string
|
||||
}
|
||||
|
||||
func (s *testMockEC2State) Reset() {
|
||||
s.DescribeInstancesCalled = false
|
||||
s.DescribeInstancesInputParams = nil
|
||||
s.DescribeInstancesCallCount = 0
|
||||
s.DescribeInstancesInputs = nil
|
||||
s.ClientRegions = nil
|
||||
}
|
||||
|
||||
type testMockEC2 struct {
|
||||
@@ -72,7 +78,10 @@ func newTestMockEC2(state *testMockEC2State, opts ...testMockEC2Option) ec2APIFu
|
||||
if cfg.Region != "" {
|
||||
m.Region = cfg.Region
|
||||
}
|
||||
}
|
||||
|
||||
if state != nil {
|
||||
state.ClientRegions = append(state.ClientRegions, m.Region)
|
||||
}
|
||||
|
||||
return m, nil
|
||||
@@ -83,6 +92,8 @@ func (m *testMockEC2) DescribeInstances(ctx context.Context, input *ec2.Describe
|
||||
if m.State != nil {
|
||||
m.State.DescribeInstancesCalled = true
|
||||
m.State.DescribeInstancesInputParams = input
|
||||
m.State.DescribeInstancesCallCount++
|
||||
m.State.DescribeInstancesInputs = append(m.State.DescribeInstancesInputs, input)
|
||||
}
|
||||
|
||||
if m.DescribeInstancesError != nil {
|
||||
|
||||
Reference in new issue
Block a user