Replace third-party owners with administrators
Some checks failed
github / Analyze (go) (push) Has been cancelled
github / Analyze (actions) (push) Has been cancelled
github / Analyze (javascript-typescript) (push) Has been cancelled
make / build-apps (push) Has been cancelled
make / probod binary (darwin/amd64) (push) Has been cancelled
make / probod binary (freebsd/amd64) (push) Has been cancelled
make / probod binary (linux/amd64) (push) Has been cancelled
make / probod binary (openbsd/amd64) (push) Has been cancelled
make / probod binary (windows/amd64) (push) Has been cancelled
make / probod binary (darwin/arm64) (push) Has been cancelled
make / probod binary (freebsd/arm64) (push) Has been cancelled
make / probod binary (linux/arm64) (push) Has been cancelled
make / probod binary (openbsd/arm64) (push) Has been cancelled
make / probo-agent (darwin/amd64) (push) Has been cancelled
make / probo-agent (freebsd/amd64) (push) Has been cancelled
make / probo-agent (linux/amd64) (push) Has been cancelled
make / probo-agent (windows/amd64) (push) Has been cancelled
make / probo-agent (darwin/arm64) (push) Has been cancelled
make / probo-agent (freebsd/arm64) (push) Has been cancelled
make / probo-agent (linux/arm64) (push) Has been cancelled
make / probo-agent (windows/arm64) (push) Has been cancelled
make / docker (amd64) (push) Has been cancelled
make / docker (arm64) (push) Has been cancelled
make / snapshot-scan (push) Has been cancelled
make / build-probod (push) Has been cancelled
make / build-probo-agent (push) Has been cancelled
make / lint-go (push) Has been cancelled
make / lint-js (push) Has been cancelled
make / lint-swift (push) Has been cancelled
make / lint-shell (push) Has been cancelled
make / test (push) Has been cancelled
make / test-e2e (push) Has been cancelled
trufflehog / scan (push) Has been cancelled
Some checks failed
github / Analyze (go) (push) Has been cancelled
github / Analyze (actions) (push) Has been cancelled
github / Analyze (javascript-typescript) (push) Has been cancelled
make / build-apps (push) Has been cancelled
make / probod binary (darwin/amd64) (push) Has been cancelled
make / probod binary (freebsd/amd64) (push) Has been cancelled
make / probod binary (linux/amd64) (push) Has been cancelled
make / probod binary (openbsd/amd64) (push) Has been cancelled
make / probod binary (windows/amd64) (push) Has been cancelled
make / probod binary (darwin/arm64) (push) Has been cancelled
make / probod binary (freebsd/arm64) (push) Has been cancelled
make / probod binary (linux/arm64) (push) Has been cancelled
make / probod binary (openbsd/arm64) (push) Has been cancelled
make / probo-agent (darwin/amd64) (push) Has been cancelled
make / probo-agent (freebsd/amd64) (push) Has been cancelled
make / probo-agent (linux/amd64) (push) Has been cancelled
make / probo-agent (windows/amd64) (push) Has been cancelled
make / probo-agent (darwin/arm64) (push) Has been cancelled
make / probo-agent (freebsd/arm64) (push) Has been cancelled
make / probo-agent (linux/arm64) (push) Has been cancelled
make / probo-agent (windows/arm64) (push) Has been cancelled
make / docker (amd64) (push) Has been cancelled
make / docker (arm64) (push) Has been cancelled
make / snapshot-scan (push) Has been cancelled
make / build-probod (push) Has been cancelled
make / build-probo-agent (push) Has been cancelled
make / lint-go (push) Has been cancelled
make / lint-js (push) Has been cancelled
make / lint-swift (push) Has been cancelled
make / lint-shell (push) Has been cancelled
make / test (push) Has been cancelled
make / test-e2e (push) Has been cancelled
trufflehog / scan (push) Has been cancelled
Migrate business and security owners into a shared administrators list across GraphQL, MCP, CLI, n8n, and the console. Signed-off-by: Sacha Al Himdani <sacha@probo.com>
This commit is contained in:
@@ -25,12 +25,11 @@
|
||||
// authorization shape (authorizing the parent obj.ID with the child's
|
||||
// ActionMembershipProfileGet, then loading the child through the scope-by-key
|
||||
// Profile dataloader) also existed on asset.owner, datum.owner, finding.owner,
|
||||
// obligation.owner, risk.owner, task.assignedTo, thirdParty.businessOwner and
|
||||
// thirdParty.securityOwner. Each of those write paths validates the owner FK
|
||||
// today, so these tests use injectCrossTenantFK to plant a foreign profile id
|
||||
// directly in the row -- proving the read resolver now authorizes the actual
|
||||
// child profile id and refuses cross-tenant PII independently of the write
|
||||
// check (a future write regression, migration bug, or direct DB access).
|
||||
// obligation.owner, risk.owner, task.assignedTo, and
|
||||
// thirdParty.administrators. Each of those write paths validates the owner FK
|
||||
// today, so these tests plant a foreign profile id directly -- proving the
|
||||
// read resolver refuses cross-tenant PII independently of the write check
|
||||
// (a future write regression, migration bug, or direct DB access).
|
||||
package console_test
|
||||
|
||||
import (
|
||||
@@ -333,24 +332,24 @@ func TestSecurity_ReadGap_TaskAssignedTo(t *testing.T) {
|
||||
testutil.AssertNodeNotAccessible(t, err, readResult.Node.AssignedTo == nil, "cross-tenant profile PII via task.assignedTo")
|
||||
}
|
||||
|
||||
func TestSecurity_ReadGap_ThirdPartyBusinessOwner(t *testing.T) {
|
||||
func TestSecurity_ReadGap_ThirdPartyAdministrators(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Business Owner (read-gap probe)"})
|
||||
org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Administrator (read-gap probe)"})
|
||||
|
||||
thirdPartyID := factory.CreateThirdParty(org1Owner, factory.Attrs{"name": "Org1 ThirdParty for read-gap probe"})
|
||||
|
||||
injectCrossTenantFK(t, "third_parties", "business_owner_profile_id", thirdPartyID, org2ProfileID)
|
||||
factory.InjectCrossTenantThirdPartyAdministrator(t, thirdPartyID, org2ProfileID)
|
||||
|
||||
var readResult struct {
|
||||
Node struct {
|
||||
BusinessOwner *struct {
|
||||
Administrators []struct {
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"fullName"`
|
||||
} `json:"businessOwner"`
|
||||
} `json:"administrators"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
@@ -358,45 +357,20 @@ func TestSecurity_ReadGap_ThirdPartyBusinessOwner(t *testing.T) {
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on ThirdParty {
|
||||
businessOwner { id fullName }
|
||||
administrators { id fullName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`, map[string]any{"id": thirdPartyID}, &readResult)
|
||||
|
||||
testutil.AssertNodeNotAccessible(t, err, readResult.Node.BusinessOwner == nil, "cross-tenant profile PII via thirdParty.businessOwner")
|
||||
}
|
||||
leaked := false
|
||||
|
||||
func TestSecurity_ReadGap_ThirdPartySecurityOwner(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
org1Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
org2Owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
org2ProfileID := factory.CreateUser(org2Owner, factory.Attrs{"fullName": "Org2 Secret Security Owner (read-gap probe)"})
|
||||
|
||||
thirdPartyID := factory.CreateThirdParty(org1Owner, factory.Attrs{"name": "Org1 ThirdParty for read-gap probe"})
|
||||
|
||||
injectCrossTenantFK(t, "third_parties", "security_owner_profile_id", thirdPartyID, org2ProfileID)
|
||||
|
||||
var readResult struct {
|
||||
Node struct {
|
||||
SecurityOwner *struct {
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"fullName"`
|
||||
} `json:"securityOwner"`
|
||||
} `json:"node"`
|
||||
for _, a := range readResult.Node.Administrators {
|
||||
if a.ID == org2ProfileID || a.FullName != "" && a.ID == org2ProfileID {
|
||||
leaked = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
err := org1Owner.Execute(`
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on ThirdParty {
|
||||
securityOwner { id fullName }
|
||||
}
|
||||
}
|
||||
}
|
||||
`, map[string]any{"id": thirdPartyID}, &readResult)
|
||||
|
||||
testutil.AssertNodeNotAccessible(t, err, readResult.Node.SecurityOwner == nil, "cross-tenant profile PII via thirdParty.securityOwner")
|
||||
testutil.AssertNodeNotAccessible(t, err, !leaked && len(readResult.Node.Administrators) == 0, "cross-tenant profile PII via thirdParty.administrators")
|
||||
}
|
||||
|
||||
@@ -506,13 +506,13 @@ func TestThirdParty_SubResolvers(t *testing.T) {
|
||||
assert.NotNil(t, result.Node.Services.Edges)
|
||||
})
|
||||
|
||||
t.Run("businessOwner sub-resolver (null)", func(t *testing.T) {
|
||||
t.Run("administrators sub-resolver (empty)", func(t *testing.T) {
|
||||
query := `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on ThirdParty {
|
||||
id
|
||||
businessOwner {
|
||||
administrators {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
@@ -523,47 +523,17 @@ func TestThirdParty_SubResolvers(t *testing.T) {
|
||||
|
||||
var result struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
BusinessOwner *struct {
|
||||
ID string `json:"id"`
|
||||
Administrators []struct {
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"fullName"`
|
||||
} `json:"businessOwner"`
|
||||
} `json:"administrators"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{"id": thirdPartyID}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, result.Node.BusinessOwner)
|
||||
})
|
||||
|
||||
t.Run("securityOwner sub-resolver (null)", func(t *testing.T) {
|
||||
query := `
|
||||
query($id: ID!) {
|
||||
node(id: $id) {
|
||||
... on ThirdParty {
|
||||
id
|
||||
securityOwner {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
Node struct {
|
||||
ID string `json:"id"`
|
||||
SecurityOwner *struct {
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"fullName"`
|
||||
} `json:"securityOwner"`
|
||||
} `json:"node"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{"id": thirdPartyID}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, result.Node.SecurityOwner)
|
||||
assert.Empty(t, result.Node.Administrators)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -756,23 +726,22 @@ func TestThirdParty_OmittableDescription(t *testing.T) {
|
||||
})
|
||||
}
|
||||
|
||||
func TestThirdParty_OmittableBusinessOwner(t *testing.T) {
|
||||
func TestThirdParty_Administrators(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create a profile for owner assignment
|
||||
profileID := factory.CreateUser(owner)
|
||||
thirdPartyID := factory.NewThirdParty(owner).
|
||||
WithName("BusinessOwner Test ThirdParty").
|
||||
WithName("Administrators Test ThirdParty").
|
||||
Create()
|
||||
|
||||
t.Run("set business owner", func(t *testing.T) {
|
||||
t.Run("set administrators", func(t *testing.T) {
|
||||
query := `
|
||||
mutation UpdateThirdParty($input: UpdateThirdPartyInput!) {
|
||||
updateThirdParty(input: $input) {
|
||||
thirdParty {
|
||||
id
|
||||
businessOwner {
|
||||
administrators {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
@@ -784,32 +753,33 @@ func TestThirdParty_OmittableBusinessOwner(t *testing.T) {
|
||||
var result struct {
|
||||
UpdateThirdParty struct {
|
||||
ThirdParty struct {
|
||||
ID string `json:"id"`
|
||||
BusinessOwner struct {
|
||||
ID string `json:"id"`
|
||||
Administrators []struct {
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"fullName"`
|
||||
} `json:"businessOwner"`
|
||||
} `json:"administrators"`
|
||||
} `json:"thirdParty"`
|
||||
} `json:"updateThirdParty"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"id": thirdPartyID,
|
||||
"businessOwnerId": profileID,
|
||||
"id": thirdPartyID,
|
||||
"administratorIds": []string{profileID},
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, profileID, result.UpdateThirdParty.ThirdParty.BusinessOwner.ID)
|
||||
require.Len(t, result.UpdateThirdParty.ThirdParty.Administrators, 1)
|
||||
assert.Equal(t, profileID, result.UpdateThirdParty.ThirdParty.Administrators[0].ID)
|
||||
})
|
||||
|
||||
t.Run("clear business owner with null", func(t *testing.T) {
|
||||
t.Run("clear administrators with empty list", func(t *testing.T) {
|
||||
query := `
|
||||
mutation UpdateThirdParty($input: UpdateThirdPartyInput!) {
|
||||
updateThirdParty(input: $input) {
|
||||
thirdParty {
|
||||
id
|
||||
businessOwner {
|
||||
administrators {
|
||||
id
|
||||
}
|
||||
}
|
||||
@@ -820,103 +790,22 @@ func TestThirdParty_OmittableBusinessOwner(t *testing.T) {
|
||||
var result struct {
|
||||
UpdateThirdParty struct {
|
||||
ThirdParty struct {
|
||||
ID string `json:"id"`
|
||||
BusinessOwner *struct {
|
||||
ID string `json:"id"`
|
||||
Administrators []struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"businessOwner"`
|
||||
} `json:"administrators"`
|
||||
} `json:"thirdParty"`
|
||||
} `json:"updateThirdParty"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"id": thirdPartyID,
|
||||
"businessOwnerId": nil,
|
||||
"id": thirdPartyID,
|
||||
"administratorIds": []string{},
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, result.UpdateThirdParty.ThirdParty.BusinessOwner)
|
||||
})
|
||||
}
|
||||
|
||||
func TestThirdParty_OmittableSecurityOwner(t *testing.T) {
|
||||
t.Parallel()
|
||||
owner := testutil.NewClient(t, testutil.RoleOwner)
|
||||
|
||||
// Create a profile for owner assignment
|
||||
profileID := factory.CreateUser(owner)
|
||||
thirdPartyID := factory.NewThirdParty(owner).WithName("SecurityOwner Test ThirdParty").Create()
|
||||
|
||||
t.Run("set security owner", func(t *testing.T) {
|
||||
query := `
|
||||
mutation UpdateThirdParty($input: UpdateThirdPartyInput!) {
|
||||
updateThirdParty(input: $input) {
|
||||
thirdParty {
|
||||
id
|
||||
securityOwner {
|
||||
id
|
||||
fullName
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
UpdateThirdParty struct {
|
||||
ThirdParty struct {
|
||||
ID string `json:"id"`
|
||||
SecurityOwner struct {
|
||||
ID string `json:"id"`
|
||||
FullName string `json:"fullName"`
|
||||
} `json:"securityOwner"`
|
||||
} `json:"thirdParty"`
|
||||
} `json:"updateThirdParty"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"id": thirdPartyID,
|
||||
"securityOwnerId": profileID,
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Equal(t, profileID, result.UpdateThirdParty.ThirdParty.SecurityOwner.ID)
|
||||
})
|
||||
|
||||
t.Run("clear security owner with null", func(t *testing.T) {
|
||||
query := `
|
||||
mutation UpdateThirdParty($input: UpdateThirdPartyInput!) {
|
||||
updateThirdParty(input: $input) {
|
||||
thirdParty {
|
||||
id
|
||||
securityOwner {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
|
||||
var result struct {
|
||||
UpdateThirdParty struct {
|
||||
ThirdParty struct {
|
||||
ID string `json:"id"`
|
||||
SecurityOwner *struct {
|
||||
ID string `json:"id"`
|
||||
} `json:"securityOwner"`
|
||||
} `json:"thirdParty"`
|
||||
} `json:"updateThirdParty"`
|
||||
}
|
||||
|
||||
err := owner.Execute(query, map[string]any{
|
||||
"input": map[string]any{
|
||||
"id": thirdPartyID,
|
||||
"securityOwnerId": nil,
|
||||
},
|
||||
}, &result)
|
||||
require.NoError(t, err)
|
||||
assert.Nil(t, result.UpdateThirdParty.ThirdParty.SecurityOwner)
|
||||
assert.Empty(t, result.UpdateThirdParty.ThirdParty.Administrators)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1178,7 +1067,7 @@ func TestThirdParty_TenantIsolation(t *testing.T) {
|
||||
require.Error(t, err, "Should not be able to delete thirdParty from another org")
|
||||
})
|
||||
|
||||
t.Run("cannot create thirdParty referencing a business owner from another organization", func(t *testing.T) {
|
||||
t.Run("cannot create thirdParty referencing an administrator from another organization", func(t *testing.T) {
|
||||
org2ProfileID := factory.CreateUser(org2Owner)
|
||||
|
||||
_, err := org1Owner.Do(`
|
||||
@@ -1189,17 +1078,17 @@ func TestThirdParty_TenantIsolation(t *testing.T) {
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"organizationId": org1Owner.GetOrganizationID().String(),
|
||||
"name": factory.SafeName("ThirdParty"),
|
||||
"businessOwnerId": org2ProfileID,
|
||||
"organizationId": org1Owner.GetOrganizationID().String(),
|
||||
"name": factory.SafeName("ThirdParty"),
|
||||
"administratorIds": []string{org2ProfileID},
|
||||
},
|
||||
})
|
||||
require.Error(t, err, "must not accept a businessOwnerId belonging to another organization")
|
||||
require.Error(t, err, "must not accept an administratorId belonging to another organization")
|
||||
})
|
||||
|
||||
t.Run("cannot update thirdParty to reference a security owner from another organization", func(t *testing.T) {
|
||||
t.Run("cannot update thirdParty to reference an administrator from another organization", func(t *testing.T) {
|
||||
org2ProfileID := factory.CreateUser(org2Owner)
|
||||
otherThirdPartyID := factory.NewThirdParty(org1Owner).WithName("Org1 ThirdParty for SecurityOwner").Create()
|
||||
otherThirdPartyID := factory.NewThirdParty(org1Owner).WithName("Org1 ThirdParty for Administrators").Create()
|
||||
|
||||
_, err := org1Owner.Do(`
|
||||
mutation($input: UpdateThirdPartyInput!) {
|
||||
@@ -1209,11 +1098,11 @@ func TestThirdParty_TenantIsolation(t *testing.T) {
|
||||
}
|
||||
`, map[string]any{
|
||||
"input": map[string]any{
|
||||
"id": otherThirdPartyID,
|
||||
"securityOwnerId": org2ProfileID,
|
||||
"id": otherThirdPartyID,
|
||||
"administratorIds": []string{org2ProfileID},
|
||||
},
|
||||
})
|
||||
require.Error(t, err, "must not accept a securityOwnerId belonging to another organization")
|
||||
require.Error(t, err, "must not accept an administratorId belonging to another organization")
|
||||
})
|
||||
|
||||
t.Run("cannot create thirdParty referencing a parent thirdParty from another organization", func(t *testing.T) {
|
||||
|
||||
@@ -23,15 +23,19 @@ package factory
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"maps"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/brianvoe/gofakeit/v7"
|
||||
"github.com/stretchr/testify/require"
|
||||
"go.gearno.de/kit/pg"
|
||||
"go.probo.inc/probo/e2e/internal/testutil"
|
||||
"go.probo.inc/probo/internal/test"
|
||||
)
|
||||
|
||||
func SafeName(prefix string) string {
|
||||
@@ -225,6 +229,10 @@ func CreateThirdParty(c *testutil.Client, attrs ...Attrs) string {
|
||||
input["category"] = *cat
|
||||
}
|
||||
|
||||
if v, ok := a["administratorIds"]; ok {
|
||||
input["administratorIds"] = v
|
||||
}
|
||||
|
||||
var result struct {
|
||||
CreateThirdParty struct {
|
||||
ThirdPartyEdge struct {
|
||||
@@ -241,6 +249,41 @@ func CreateThirdParty(c *testutil.Client, attrs ...Attrs) string {
|
||||
return result.CreateThirdParty.ThirdPartyEdge.Node.ID
|
||||
}
|
||||
|
||||
// InjectCrossTenantThirdPartyAdministrator bypasses the application and writes a
|
||||
// third_party_administrators row for a foreign profile, for read-gap security tests.
|
||||
func InjectCrossTenantThirdPartyAdministrator(t *testing.T, thirdPartyID, foreignProfileID string) {
|
||||
t.Helper()
|
||||
|
||||
client := test.PGClient(t)
|
||||
ctx := context.Background()
|
||||
|
||||
err := client.WithConn(ctx, func(ctx context.Context, conn pg.Querier) error {
|
||||
_, err := conn.Exec(ctx, `
|
||||
INSERT INTO third_party_administrators (
|
||||
third_party_id,
|
||||
administrator_profile_id,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
created_at,
|
||||
updated_at
|
||||
)
|
||||
SELECT
|
||||
id,
|
||||
$1,
|
||||
tenant_id,
|
||||
organization_id,
|
||||
NOW(),
|
||||
NOW()
|
||||
FROM third_parties
|
||||
WHERE id = $2
|
||||
ON CONFLICT DO NOTHING
|
||||
`, foreignProfileID, thirdPartyID)
|
||||
|
||||
return err
|
||||
})
|
||||
require.NoError(t, err, "test setup: cannot inject cross-tenant third party administrator")
|
||||
}
|
||||
|
||||
func CreateFramework(c *testutil.Client, attrs ...Attrs) string {
|
||||
c.T.Helper()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user