mirror of
https://github.com/andwati/zitadel.git
synced 2026-10-18 05:52:51 +00:00
feat: set metadata at the user level in CreateUser request (#11719)
# Which Problems Are Solved Currently, metadata can be set/updated only for human users, but it should be available also for service accounts (machine users). # How the Problems Are Solved This is achieved by: - adding a metadata field at the root level in `CreateUserRequest`, which makes it available for both `human` and `machine` user types - returning an error when both the root level and human-level `metadata` fields are set in the `CreateUserRequest` - setting `human` and `machine` metadata from the root `metadata` field in the server layer - pushing `user.metadata.set` event during machine user creation in the command layer - adding integration tests # Additional Changes N/A # Additional Context - Related to https://github.com/zitadel/zitadel/issues/11369 --------- Co-authored-by: Vitor Bari Buccianti <[email protected]> Co-authored-by: Silvan <[email protected]>
This commit is contained in:
co-authored by
Vitor Bari Buccianti
Silvan
parent
92a628d892
commit
4aacfa842f
@@ -15,14 +15,14 @@ import (
|
||||
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
|
||||
)
|
||||
|
||||
func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUserRequest_Human, orgId string, userName, userId *string) (*connect.Response[user.CreateUserResponse], error) {
|
||||
metadataEntries := make([]*user.SetMetadataEntry, len(humanPb.Metadata))
|
||||
for i, metadataEntry := range humanPb.Metadata {
|
||||
metadataEntries[i] = &user.SetMetadataEntry{
|
||||
Key: metadataEntry.GetKey(),
|
||||
Value: metadataEntry.GetValue(),
|
||||
}
|
||||
func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUserRequest_Human, orgId string, userName, userId *string, metadata []*user.Metadata) (*connect.Response[user.CreateUserResponse], error) {
|
||||
if len(metadata) > 0 && len(humanPb.Metadata) > 0 { //nolint:staticcheck
|
||||
return nil, zerrors.ThrowInvalidArgument(nil, "USERv2-j0z7uy", "Errors.User.Metadata.Conflicting")
|
||||
}
|
||||
if len(metadata) == 0 && len(humanPb.Metadata) > 0 { //nolint:staticcheck
|
||||
metadata = humanPb.Metadata //nolint:staticcheck
|
||||
}
|
||||
metadataEntries := setMetadataEntries(metadata)
|
||||
addHumanPb := &user.AddHumanUserRequest{
|
||||
Username: userName,
|
||||
UserId: userId,
|
||||
@@ -69,6 +69,17 @@ func (s *Server) createUserTypeHuman(ctx context.Context, humanPb *user.CreateUs
|
||||
}), nil
|
||||
}
|
||||
|
||||
func setMetadataEntries(metadata []*user.Metadata) []*user.SetMetadataEntry {
|
||||
metadataEntries := make([]*user.SetMetadataEntry, len(metadata))
|
||||
for i, metadataEntry := range metadata {
|
||||
metadataEntries[i] = &user.SetMetadataEntry{
|
||||
Key: metadataEntry.GetKey(),
|
||||
Value: metadataEntry.GetValue(),
|
||||
}
|
||||
}
|
||||
return metadataEntries
|
||||
}
|
||||
|
||||
func (s *Server) updateUserTypeHuman(ctx context.Context, humanPb *user.UpdateUserRequest_Human, userId string, userName *string) (*connect.Response[user.UpdateUserResponse], error) {
|
||||
cmd, err := updateHumanUserToCommand(userId, userName, humanPb)
|
||||
if err != nil {
|
||||
|
||||
@@ -3111,7 +3111,7 @@ func TestServer_CreateUser(t *testing.T) {
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "with metadata",
|
||||
name: "error - metadata on both root and human",
|
||||
testCase: func(runId string) testCase {
|
||||
username := fmt.Sprintf("donald.duck+%s", runId)
|
||||
email := username + "@example.com"
|
||||
@@ -3135,16 +3135,16 @@ func TestServer_CreateUser(t *testing.T) {
|
||||
},
|
||||
Metadata: []*user.Metadata{
|
||||
{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))},
|
||||
{Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))},
|
||||
{Key: "key3", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value3")))},
|
||||
},
|
||||
},
|
||||
},
|
||||
Metadata: []*user.Metadata{
|
||||
{Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))},
|
||||
{Key: "key3", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value3")))},
|
||||
},
|
||||
},
|
||||
},
|
||||
want: &user.CreateUserResponse{
|
||||
Id: "is generated",
|
||||
},
|
||||
wantErr: true,
|
||||
}
|
||||
},
|
||||
},
|
||||
@@ -3514,154 +3514,271 @@ func TestServer_CreateUser_And_Compare(t *testing.T) {
|
||||
type testCase struct {
|
||||
name string
|
||||
args args
|
||||
assert func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse)
|
||||
assert func(
|
||||
t *testing.T,
|
||||
createResponse *user.CreateUserResponse,
|
||||
getResponse *user.GetUserByIDResponse,
|
||||
getMetadataResponse *user.ListUserMetadataResponse,
|
||||
)
|
||||
}
|
||||
tests := []struct {
|
||||
name string
|
||||
testCase func(runId string) testCase
|
||||
}{{
|
||||
name: "human given username",
|
||||
testCase: func(runId string) testCase {
|
||||
username := fmt.Sprintf("donald.duck+%s", runId)
|
||||
email := username + "@example.com"
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
Username: &username,
|
||||
UserType: &user.CreateUserRequest_Human_{
|
||||
Human: &user.CreateUserRequest_Human{
|
||||
Profile: &user.SetHumanProfile{
|
||||
GivenName: "Donald",
|
||||
FamilyName: "Duck",
|
||||
},
|
||||
Email: &user.SetHumanEmail{
|
||||
Email: email,
|
||||
}{
|
||||
{
|
||||
name: "human given username",
|
||||
testCase: func(runId string) testCase {
|
||||
username := fmt.Sprintf("donald.duck+%s", runId)
|
||||
email := username + "@example.com"
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
Username: &username,
|
||||
UserType: &user.CreateUserRequest_Human_{
|
||||
Human: &user.CreateUserRequest_Human{
|
||||
Profile: &user.SetHumanProfile{
|
||||
GivenName: "Donald",
|
||||
FamilyName: "Duck",
|
||||
},
|
||||
Email: &user.SetHumanEmail{
|
||||
Email: email,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) {
|
||||
assert.Equal(t, username, getResponse.GetUser().GetUsername())
|
||||
},
|
||||
}
|
||||
assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, _ *user.ListUserMetadataResponse) {
|
||||
assert.Equal(t, username, getResponse.GetUser().GetUsername())
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "human username default to email",
|
||||
testCase: func(runId string) testCase {
|
||||
username := fmt.Sprintf("donald.duck+%s", runId)
|
||||
email := username + "@example.com"
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
UserType: &user.CreateUserRequest_Human_{
|
||||
Human: &user.CreateUserRequest_Human{
|
||||
Profile: &user.SetHumanProfile{
|
||||
GivenName: "Donald",
|
||||
FamilyName: "Duck",
|
||||
},
|
||||
Email: &user.SetHumanEmail{
|
||||
Email: email,
|
||||
{
|
||||
name: "human with embedded metadata",
|
||||
testCase: func(runId string) testCase {
|
||||
username := fmt.Sprintf("donald.duck+%s", runId)
|
||||
email := username + "@example.com"
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
Username: &username,
|
||||
UserType: &user.CreateUserRequest_Human_{
|
||||
Human: &user.CreateUserRequest_Human{
|
||||
Profile: &user.SetHumanProfile{
|
||||
GivenName: "Donald",
|
||||
FamilyName: "Duck",
|
||||
},
|
||||
Email: &user.SetHumanEmail{
|
||||
Email: email,
|
||||
},
|
||||
Metadata: []*user.Metadata{
|
||||
{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))},
|
||||
{Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) {
|
||||
assert.Equal(t, email, getResponse.GetUser().GetUsername())
|
||||
},
|
||||
}
|
||||
assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, getMetadataResponse *user.ListUserMetadataResponse) {
|
||||
assert.Equal(t, username, getResponse.GetUser().GetUsername())
|
||||
metadataByKey := getMetadataMap(getMetadataResponse)
|
||||
assert.Len(t, getMetadataResponse.GetMetadata(), 2)
|
||||
assert.Equal(t, []byte(base64.StdEncoding.EncodeToString([]byte("value1"))), metadataByKey["key1"])
|
||||
assert.Equal(t, []byte(base64.StdEncoding.EncodeToString([]byte("value2"))), metadataByKey["key2"])
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "service accountname given",
|
||||
testCase: func(runId string) testCase {
|
||||
username := fmt.Sprintf("donald.duck+%s", runId)
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
Username: &username,
|
||||
UserType: &user.CreateUserRequest_Machine_{
|
||||
Machine: &user.CreateUserRequest_Machine{
|
||||
Name: "donald",
|
||||
{
|
||||
name: "human with metadata",
|
||||
testCase: func(runId string) testCase {
|
||||
username := fmt.Sprintf("donald.duck+%s", runId)
|
||||
email := username + "@example.com"
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
Username: &username,
|
||||
UserType: &user.CreateUserRequest_Human_{
|
||||
Human: &user.CreateUserRequest_Human{
|
||||
Profile: &user.SetHumanProfile{
|
||||
GivenName: "Donald",
|
||||
FamilyName: "Duck",
|
||||
},
|
||||
Email: &user.SetHumanEmail{
|
||||
Email: email,
|
||||
},
|
||||
},
|
||||
},
|
||||
Metadata: []*user.Metadata{
|
||||
{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) {
|
||||
assert.Equal(t, username, getResponse.GetUser().GetUsername())
|
||||
},
|
||||
}
|
||||
assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, getMetadataResponse *user.ListUserMetadataResponse) {
|
||||
assert.Equal(t, username, getResponse.GetUser().GetUsername())
|
||||
metadataByKey := getMetadataMap(getMetadataResponse)
|
||||
assert.Len(t, getMetadataResponse.GetMetadata(), 1)
|
||||
assert.Equal(t, []byte(base64.StdEncoding.EncodeToString([]byte("value1"))), metadataByKey["key1"])
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "service accountname default to generated id",
|
||||
testCase: func(runId string) testCase {
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
UserType: &user.CreateUserRequest_Machine_{
|
||||
Machine: &user.CreateUserRequest_Machine{
|
||||
Name: "donald",
|
||||
{
|
||||
name: "human username default to email",
|
||||
testCase: func(runId string) testCase {
|
||||
username := fmt.Sprintf("donald.duck+%s", runId)
|
||||
email := username + "@example.com"
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
UserType: &user.CreateUserRequest_Human_{
|
||||
Human: &user.CreateUserRequest_Human{
|
||||
Profile: &user.SetHumanProfile{
|
||||
GivenName: "Donald",
|
||||
FamilyName: "Duck",
|
||||
},
|
||||
Email: &user.SetHumanEmail{
|
||||
Email: email,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) {
|
||||
assert.Equal(t, createResponse.GetId(), getResponse.GetUser().GetUsername())
|
||||
},
|
||||
}
|
||||
assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, _ *user.ListUserMetadataResponse) {
|
||||
assert.Equal(t, email, getResponse.GetUser().GetUsername())
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "service accountname default to given id",
|
||||
testCase: func(runId string) testCase {
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
UserId: &runId,
|
||||
UserType: &user.CreateUserRequest_Machine_{
|
||||
Machine: &user.CreateUserRequest_Machine{
|
||||
Name: "donald",
|
||||
{
|
||||
name: "service accountname given",
|
||||
testCase: func(runId string) testCase {
|
||||
username := fmt.Sprintf("donald.duck+%s", runId)
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
Username: &username,
|
||||
UserType: &user.CreateUserRequest_Machine_{
|
||||
Machine: &user.CreateUserRequest_Machine{
|
||||
Name: "donald",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) {
|
||||
assert.Equal(t, runId, getResponse.GetUser().GetUsername())
|
||||
},
|
||||
}
|
||||
assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, _ *user.ListUserMetadataResponse) {
|
||||
assert.Equal(t, username, getResponse.GetUser().GetUsername())
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}, {
|
||||
name: "machine access token type jwt",
|
||||
testCase: func(runId string) testCase {
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
UserId: &runId,
|
||||
UserType: &user.CreateUserRequest_Machine_{
|
||||
Machine: &user.CreateUserRequest_Machine{
|
||||
Name: "donald",
|
||||
AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT,
|
||||
{
|
||||
name: "service account with metadata",
|
||||
testCase: func(runId string) testCase {
|
||||
username := fmt.Sprintf("donald.duck+%s", runId)
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
Username: &username,
|
||||
UserType: &user.CreateUserRequest_Machine_{
|
||||
Machine: &user.CreateUserRequest_Machine{
|
||||
Name: "donald",
|
||||
},
|
||||
},
|
||||
Metadata: []*user.Metadata{
|
||||
{Key: "key1", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value1")))},
|
||||
{Key: "key2", Value: []byte(base64.StdEncoding.EncodeToString([]byte("value2")))},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse) {
|
||||
assert.Equal(t, user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, getResponse.GetUser().GetMachine().GetAccessTokenType())
|
||||
},
|
||||
}
|
||||
assert: func(t *testing.T, _ *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, getMetadataResponse *user.ListUserMetadataResponse) {
|
||||
assert.Equal(t, username, getResponse.GetUser().GetUsername())
|
||||
metadataByKey := getMetadataMap(getMetadataResponse)
|
||||
assert.Len(t, getMetadataResponse.GetMetadata(), 2)
|
||||
assert.Equal(t, []byte(base64.StdEncoding.EncodeToString([]byte("value1"))), metadataByKey["key1"])
|
||||
assert.Equal(t, []byte(base64.StdEncoding.EncodeToString([]byte("value2"))), metadataByKey["key2"])
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}}
|
||||
{
|
||||
name: "service accountname default to generated id",
|
||||
testCase: func(runId string) testCase {
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
UserType: &user.CreateUserRequest_Machine_{
|
||||
Machine: &user.CreateUserRequest_Machine{
|
||||
Name: "donald",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, _ *user.ListUserMetadataResponse) {
|
||||
assert.Equal(t, createResponse.GetId(), getResponse.GetUser().GetUsername())
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "service accountname default to given id",
|
||||
testCase: func(runId string) testCase {
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
UserId: &runId,
|
||||
UserType: &user.CreateUserRequest_Machine_{
|
||||
Machine: &user.CreateUserRequest_Machine{
|
||||
Name: "donald",
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, _ *user.ListUserMetadataResponse) {
|
||||
assert.Equal(t, runId, getResponse.GetUser().GetUsername())
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "machine access token type jwt",
|
||||
testCase: func(runId string) testCase {
|
||||
return testCase{
|
||||
args: args{
|
||||
ctx: OrgCTX,
|
||||
req: &user.CreateUserRequest{
|
||||
OrganizationId: Instance.DefaultOrg.Id,
|
||||
UserId: &runId,
|
||||
UserType: &user.CreateUserRequest_Machine_{
|
||||
Machine: &user.CreateUserRequest_Machine{
|
||||
Name: "donald",
|
||||
AccessTokenType: user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
assert: func(t *testing.T, createResponse *user.CreateUserResponse, getResponse *user.GetUserByIDResponse, _ *user.ListUserMetadataResponse) {
|
||||
assert.Equal(t, user.AccessTokenType_ACCESS_TOKEN_TYPE_JWT, getResponse.GetUser().GetMachine().GetAccessTokenType())
|
||||
},
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
for i, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
now := time.Now()
|
||||
@@ -3674,11 +3791,23 @@ func TestServer_CreateUser_And_Compare(t *testing.T) {
|
||||
UserId: createResponse.GetId(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
test.assert(t, createResponse, getResponse)
|
||||
gotMetadataResponse, err := Client.ListUserMetadata(test.args.ctx, &user.ListUserMetadataRequest{
|
||||
UserId: createResponse.GetId(),
|
||||
})
|
||||
require.NoError(t, err)
|
||||
test.assert(t, createResponse, getResponse, gotMetadataResponse)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func getMetadataMap(getMetadataResponse *user.ListUserMetadataResponse) map[string][]byte {
|
||||
metadataByKey := make(map[string][]byte, len(getMetadataResponse.GetMetadata()))
|
||||
for _, md := range getMetadataResponse.GetMetadata() {
|
||||
metadataByKey[md.Key] = md.Value
|
||||
}
|
||||
return metadataByKey
|
||||
}
|
||||
|
||||
func TestServer_CreateUser_Permission(t *testing.T) {
|
||||
newOrgOwnerEmail := integration.Email()
|
||||
newOrg := Instance.CreateOrganization(IamCTX, integration.OrganizationName(), newOrgOwnerEmail)
|
||||
|
||||
@@ -12,7 +12,14 @@ import (
|
||||
"github.com/zitadel/zitadel/pkg/grpc/user/v2"
|
||||
)
|
||||
|
||||
func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.CreateUserRequest_Machine, orgId, userName, userId string) (*connect.Response[user.CreateUserResponse], error) {
|
||||
func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.CreateUserRequest_Machine, orgId, userName, userId string, reqMetadata []*user.Metadata) (*connect.Response[user.CreateUserResponse], error) {
|
||||
metadata := make([]*command.AddMetadataEntry, len(reqMetadata))
|
||||
for i, metadataEntry := range reqMetadata {
|
||||
metadata[i] = &command.AddMetadataEntry{
|
||||
Key: metadataEntry.GetKey(),
|
||||
Value: metadataEntry.GetValue(),
|
||||
}
|
||||
}
|
||||
cmd := &command.Machine{
|
||||
Username: userName,
|
||||
Name: machinePb.Name,
|
||||
@@ -22,6 +29,7 @@ func (s *Server) createUserTypeMachine(ctx context.Context, machinePb *user.Crea
|
||||
ResourceOwner: orgId,
|
||||
AggregateID: userId,
|
||||
},
|
||||
Metadata: metadata,
|
||||
}
|
||||
details, err := s.command.AddMachine(
|
||||
ctx,
|
||||
|
||||
@@ -414,9 +414,9 @@ func (s *Server) HumanMFAInitSkipped(ctx context.Context, req *connect.Request[u
|
||||
func (s *Server) CreateUser(ctx context.Context, req *connect.Request[user.CreateUserRequest]) (*connect.Response[user.CreateUserResponse], error) {
|
||||
switch userType := req.Msg.GetUserType().(type) {
|
||||
case *user.CreateUserRequest_Human_:
|
||||
return s.createUserTypeHuman(ctx, userType.Human, req.Msg.GetOrganizationId(), req.Msg.Username, req.Msg.UserId)
|
||||
return s.createUserTypeHuman(ctx, userType.Human, req.Msg.GetOrganizationId(), req.Msg.Username, req.Msg.UserId, req.Msg.GetMetadata())
|
||||
case *user.CreateUserRequest_Machine_:
|
||||
return s.createUserTypeMachine(ctx, userType.Machine, req.Msg.GetOrganizationId(), req.Msg.GetUsername(), req.Msg.GetUserId())
|
||||
return s.createUserTypeMachine(ctx, userType.Machine, req.Msg.GetOrganizationId(), req.Msg.GetUsername(), req.Msg.GetUserId(), req.Msg.GetMetadata())
|
||||
default:
|
||||
return nil, zerrors.ThrowInternal(nil, "", "user type is not implemented")
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@ type Machine struct {
|
||||
Description string
|
||||
AccessTokenType domain.OIDCTokenType
|
||||
PermissionCheck PermissionCheck
|
||||
Metadata []*AddMetadataEntry
|
||||
}
|
||||
|
||||
func (m *Machine) IsZero() bool {
|
||||
@@ -40,11 +41,9 @@ func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validati
|
||||
if a.ID == "" {
|
||||
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-p0p2mi", "Errors.User.UserIDMissing")
|
||||
}
|
||||
if machine.Name == "" {
|
||||
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-bs9Ds", "Errors.User.Invalid")
|
||||
}
|
||||
if machine.Username == "" {
|
||||
return nil, zerrors.ThrowInvalidArgument(nil, "COMMAND-bm9Ds", "Errors.User.Invalid")
|
||||
err = machine.Validate()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return func(ctx context.Context, filter preparation.FilterToQueryReducer) ([]eventstore.Command, error) {
|
||||
ctx, span := tracing.NewSpan(ctx)
|
||||
@@ -65,9 +64,18 @@ func AddMachineCommand(a *user.Aggregate, machine *Machine) preparation.Validati
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return []eventstore.Command{
|
||||
cmds := []eventstore.Command{
|
||||
user.NewMachineAddedEvent(ctx, &a.Aggregate, machine.Username, machine.Name, machine.Description, domainPolicy.UserLoginMustBeDomain || orgScopedUsername, machine.AccessTokenType),
|
||||
}, nil
|
||||
}
|
||||
for _, metadataEntry := range machine.Metadata {
|
||||
cmds = append(cmds, user.NewMetadataSetEvent(
|
||||
ctx,
|
||||
&a.Aggregate,
|
||||
metadataEntry.Key,
|
||||
metadataEntry.Value,
|
||||
))
|
||||
}
|
||||
return cmds, nil
|
||||
}, nil
|
||||
}
|
||||
}
|
||||
@@ -214,3 +222,18 @@ func getMachineWriteModel(ctx context.Context, userID, resourceOwner string, fil
|
||||
}
|
||||
return writeModel, err
|
||||
}
|
||||
|
||||
func (m *Machine) Validate() (err error) {
|
||||
if m.Name == "" {
|
||||
return zerrors.ThrowInvalidArgument(nil, "COMMAND-bs9Ds", "Errors.User.Invalid")
|
||||
}
|
||||
if m.Username == "" {
|
||||
return zerrors.ThrowInvalidArgument(nil, "COMMAND-bm9Ds", "Errors.User.Invalid")
|
||||
}
|
||||
for _, metadataEntry := range m.Metadata {
|
||||
if err := metadataEntry.Valid(); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -105,6 +105,64 @@ func TestCommandSide_AddMachine(t *testing.T) {
|
||||
err: zerrors.IsPreconditionFailed,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add machine with invalid metadata key, error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
),
|
||||
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
machine: &Machine{
|
||||
ObjectRoot: models.ObjectRoot{
|
||||
ResourceOwner: "org1",
|
||||
},
|
||||
Description: "description",
|
||||
Name: "name",
|
||||
Username: "username",
|
||||
Metadata: []*AddMetadataEntry{
|
||||
{
|
||||
Key: "",
|
||||
Value: []byte("value1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
res: res{
|
||||
err: zerrors.IsErrorInvalidArgument,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add machine with invalid metadata value, error",
|
||||
fields: fields{
|
||||
eventstore: eventstoreExpect(
|
||||
t,
|
||||
),
|
||||
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
|
||||
},
|
||||
args: args{
|
||||
ctx: context.Background(),
|
||||
machine: &Machine{
|
||||
ObjectRoot: models.ObjectRoot{
|
||||
ResourceOwner: "org1",
|
||||
},
|
||||
Description: "description",
|
||||
Name: "name",
|
||||
Username: "username",
|
||||
Metadata: []*AddMetadataEntry{
|
||||
{
|
||||
Key: "key1",
|
||||
Value: []byte(""),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
res: res{
|
||||
err: zerrors.IsErrorInvalidArgument,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "add machine, ok",
|
||||
fields: fields{
|
||||
@@ -131,6 +189,11 @@ func TestCommandSide_AddMachine(t *testing.T) {
|
||||
true,
|
||||
domain.OIDCTokenTypeBearer,
|
||||
),
|
||||
user.NewMetadataSetEvent(context.Background(),
|
||||
&user.NewAggregate("user1", "org1").Aggregate,
|
||||
"key1",
|
||||
[]byte("value1"),
|
||||
),
|
||||
),
|
||||
),
|
||||
idGenerator: id_mock.NewIDGeneratorExpectIDs(t, "user1"),
|
||||
@@ -144,6 +207,12 @@ func TestCommandSide_AddMachine(t *testing.T) {
|
||||
Description: "description",
|
||||
Name: "name",
|
||||
Username: "username",
|
||||
Metadata: []*AddMetadataEntry{
|
||||
{
|
||||
Key: "key1",
|
||||
Value: []byte("value1"),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
res: res{
|
||||
|
||||
@@ -132,6 +132,8 @@ Errors:
|
||||
CouldNotGenerate: "تعذر توليد السر"
|
||||
PAT:
|
||||
NotFound: "رمز الوصول الشخصي غير موجود"
|
||||
Metadata:
|
||||
Conflicting: "يجب عدم تعيين البيانات الوصفية في كلٍ من حقول [user] و[human] في الوقت نفسه"
|
||||
NotHuman: "يجب أن يكون المستخدم شخصياً"
|
||||
NotMachine: "يجب أن يكون المستخدم تقنياً"
|
||||
WrongType: "غير مسموح لنوع المستخدم هذا"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "Тайната не можа да бъде генерирана"
|
||||
PAT:
|
||||
NotFound: "Личен токен за достъп не е намерен"
|
||||
Metadata:
|
||||
Conflicting: "Метаданните не трябва да бъдат зададени едновременно в полетата [user] и [human]"
|
||||
NotHuman: "Потребителят трябва да е личен"
|
||||
NotMachine: "Потребителят трябва да е техничен"
|
||||
WrongType: "Не е разрешено за този тип потребител"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "Tajemství nelze vygenerovat"
|
||||
PAT:
|
||||
NotFound: "Osobní přístupový token nenalezen"
|
||||
Metadata:
|
||||
Conflicting: "Metadata nesmí být nastavena současně v polích [user] a [human]"
|
||||
NotHuman: "Uživatel musí být fyzická osoba"
|
||||
NotMachine: "Uživatel musí být systémový uživatel / technická entita"
|
||||
WrongType: "Nepovolen pro tento typ uživatele"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "Secret konnte nicht generiert werden"
|
||||
PAT:
|
||||
NotFound: "Persönliches Access Token nicht gefunden"
|
||||
Metadata:
|
||||
Conflicting: "Metadaten dürfen nicht gleichzeitig in den Feldern [user] und [human] gesetzt sein"
|
||||
NotHuman: "Der Benutzer muss eine Person sein"
|
||||
NotMachine: "Der Benutzer muss technisch sein"
|
||||
WrongType: "Für diesen Benutzertyp nicht erlaubt"
|
||||
|
||||
@@ -132,6 +132,8 @@ Errors:
|
||||
CouldNotGenerate: "Secret could not be generated"
|
||||
PAT:
|
||||
NotFound: "Personal Access Token not found"
|
||||
Metadata:
|
||||
Conflicting: "Metadata must not be set on both the user and human fields"
|
||||
NotHuman: "The User must be personal"
|
||||
NotMachine: "The User must be technical"
|
||||
WrongType: "Not allowed for this user type"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "El secreto no pudo generarse"
|
||||
PAT:
|
||||
NotFound: "Token de acceso personal no encontrado"
|
||||
Metadata:
|
||||
Conflicting: "Los metadatos no deben definirse simultáneamente en los campos [user] y [human]"
|
||||
NotHuman: "El usuario debe ser personal"
|
||||
NotMachine: "El usuario debe ser técnico"
|
||||
WrongType: "Tipo de usuario no permitido"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "Secret n'a pas pu être généré"
|
||||
PAT:
|
||||
NotFound: "Token d'accès personnel non trouvé"
|
||||
Metadata:
|
||||
Conflicting: "Les métadonnées ne doivent pas être définies simultanément dans les champs [user] et [human]"
|
||||
NotHuman: "L'utilisateur doit être personnel"
|
||||
NotMachine: "L'utilisateur doit être technique"
|
||||
WrongType: "Non autorisé pour ce type d'utilisateur"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "A titkot nem sikerült generálni"
|
||||
PAT:
|
||||
NotFound: "A személyes hozzáférési token nem található"
|
||||
Metadata:
|
||||
Conflicting: "A metaadatok nem lehetnek egyszerre beállítva a [user] és a [human] mezőkben"
|
||||
NotHuman: "A felhasználónak személyesnek kell lennie"
|
||||
NotMachine: "A felhasználónak technikai jellegűnek kell lennie"
|
||||
WrongType: "Ez a felhasználótípus számára nem engedélyezett"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "Rahasia tidak dapat dibuat"
|
||||
PAT:
|
||||
NotFound: "Token Akses Pribadi tidak ditemukan"
|
||||
Metadata:
|
||||
Conflicting: "Metadata tidak boleh diatur secara bersamaan pada field [user] dan [human]"
|
||||
NotHuman: "Pengguna harus bersifat pribadi"
|
||||
NotMachine: "Pengguna harus teknis"
|
||||
WrongType: "Tidak diizinkan untuk jenis pengguna ini"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "Non è stato possibile generare il Secret"
|
||||
PAT:
|
||||
NotFound: "Personal Access Token non trovato"
|
||||
Metadata:
|
||||
Conflicting: "I metadati non devono essere impostati contemporaneamente nei campi [user] e [human]"
|
||||
NotHuman: "L'utente deve essere personale"
|
||||
NotMachine: "L'utente deve essere tecnico"
|
||||
WrongType: "Non consentito per questo tipo di utente"
|
||||
|
||||
@@ -132,6 +132,8 @@ Errors:
|
||||
CouldNotGenerate: "シークレットの生成に失敗しました"
|
||||
PAT:
|
||||
NotFound: "パーソナルアクセストークンが見つかりません"
|
||||
Metadata:
|
||||
Conflicting: "メタデータは [user] と [human] の両方のフィールドに同時に設定してはなりません"
|
||||
NotHuman: "ユーザーはパーソナルである必要があります"
|
||||
NotMachine: "ユーザーはテクニカルである必要があります"
|
||||
WrongType: "このユーザータイプは許可されていません"
|
||||
|
||||
@@ -132,6 +132,8 @@ Errors:
|
||||
CouldNotGenerate: "시크릿을 생성할 수 없습니다"
|
||||
PAT:
|
||||
NotFound: "개인 액세스 토큰을 찾을 수 없습니다"
|
||||
Metadata:
|
||||
Conflicting: "메타데이터는 [user] 및 [human] 필드에 동시에 설정되어서는 안 됩니다"
|
||||
NotHuman: "사용자는 개인이어야 합니다"
|
||||
NotMachine: "사용자는 기술적이어야 합니다"
|
||||
WrongType: "이 사용자 유형에는 허용되지 않습니다"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "Тајната не може да биде генерирана"
|
||||
PAT:
|
||||
NotFound: "Личниот токен за пристап не е пронајден"
|
||||
Metadata:
|
||||
Conflicting: "Метаподатоците не смеат да бидат поставени истовремено во полињата [user] и [human]"
|
||||
NotHuman: "Корисникот мора да биде личност"
|
||||
NotMachine: "Корисникот мора да биде технички"
|
||||
WrongType: "Не е дозволено за овој тип на корисник"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "Geheim kon niet worden gegenereerd"
|
||||
PAT:
|
||||
NotFound: "Persoonlijk toegangstoken niet gevonden"
|
||||
Metadata:
|
||||
Conflicting: "Metadata mogen niet tegelijkertijd in de velden [user] en [human] worden ingesteld"
|
||||
NotHuman: "De gebruiker moet persoonlijk zijn"
|
||||
NotMachine: "De gebruiker moet technisch zijn"
|
||||
WrongType: "Niet toegestaan voor dit gebruikerstype"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "Sekret nie mógł zostać wygenerowany"
|
||||
PAT:
|
||||
NotFound: "Osobisty token dostępu nie znaleziony"
|
||||
Metadata:
|
||||
Conflicting: "Metadane nie mogą być ustawione jednocześnie w polach [user] i [human]"
|
||||
NotHuman: "Użytkownik musi być osobą"
|
||||
NotMachine: "Użytkownik musi być techniczny"
|
||||
WrongType: "Niedozwolone dla tego typu użytkownika"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "Não foi possível gerar o segredo"
|
||||
PAT:
|
||||
NotFound: "Token de Acesso Pessoal não encontrado"
|
||||
Metadata:
|
||||
Conflicting: "Os metadados não devem ser definidos simultaneamente nos campos [user] e [human]"
|
||||
NotHuman: "O usuário deve ser pessoal"
|
||||
NotMachine: "O usuário deve ser técnico"
|
||||
WrongType: "Não permitido para este tipo de usuário"
|
||||
|
||||
@@ -132,6 +132,8 @@ Errors:
|
||||
CouldNotGenerate: "Secretul nu a putut fi generat"
|
||||
PAT:
|
||||
NotFound: "Token-ul de acces personal nu a fost găsit"
|
||||
Metadata:
|
||||
Conflicting: "Metadatele nu trebuie setate simultan în câmpurile [user] și [human]"
|
||||
NotHuman: "Utilizatorul trebuie să fie personal"
|
||||
NotMachine: "Utilizatorul trebuie să fie tehnic"
|
||||
WrongType: "Nu este permis pentru acest tip de utilizator"
|
||||
|
||||
@@ -132,6 +132,8 @@ Errors:
|
||||
CouldNotGenerate: "Ключ не может быть сгенерирован"
|
||||
PAT:
|
||||
NotFound: "Токен личного доступа не найден"
|
||||
Metadata:
|
||||
Conflicting: "Метаданные не должны быть заданы одновременно в полях [user] и [human]"
|
||||
NotHuman: "Пользователь должен быть персональным"
|
||||
NotMachine: "Пользователь должен быть техническим"
|
||||
WrongType: "Запрещено для данного типа пользователя"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "Hemlig kod kunde inte genereras"
|
||||
PAT:
|
||||
NotFound: "Personlig åtkomst-token hittades inte"
|
||||
Metadata:
|
||||
Conflicting: "Metadata får inte anges samtidigt i fälten [user] och [human]"
|
||||
NotHuman: "Användaren måste vara en person"
|
||||
NotMachine: "Användaren måste vara en maskin"
|
||||
WrongType: "Inte tillåtet för denna användartyp"
|
||||
|
||||
@@ -132,6 +132,8 @@ Errors:
|
||||
CouldNotGenerate: "Gizli anahtar oluşturulamadı"
|
||||
PAT:
|
||||
NotFound: "Kişisel Erişim Token'ı bulunamadı"
|
||||
Metadata:
|
||||
Conflicting: "Meta veriler [user] ve [human] alanlarında aynı anda ayarlanmamalıdır"
|
||||
NotHuman: "Kullanıcı kişisel olmalı"
|
||||
NotMachine: "Kullanıcı teknik olmalı"
|
||||
WrongType: "Bu kullanıcı türü için izin verilmiyor"
|
||||
|
||||
@@ -131,6 +131,8 @@ Errors:
|
||||
CouldNotGenerate: "Не вдалося згенерувати секрет"
|
||||
PAT:
|
||||
NotFound: "Персональний токен доступу не знайдено"
|
||||
Metadata:
|
||||
Conflicting: "Метадані не повинні бути встановлені одночасно в полях [user] і [human]"
|
||||
NotHuman: "Користувач повинен бути особистим"
|
||||
NotMachine: "Користувач повинен бути технічним"
|
||||
WrongType: "Не дозволено для цього типу користувача"
|
||||
|
||||
@@ -132,6 +132,8 @@ Errors:
|
||||
CouldNotGenerate: "无法生成秘密"
|
||||
PAT:
|
||||
NotFound: "未找到个人访问令牌"
|
||||
Metadata:
|
||||
Conflicting: "元数据不得同时在 [user] 和 [human] 字段中设置"
|
||||
NotHuman: "用户必须是个人"
|
||||
NotMachine: "用户必须是技术人员"
|
||||
WrongType: "此用户类型不允许"
|
||||
|
||||
@@ -2078,7 +2078,7 @@ message CreateUserRequest{
|
||||
// An Implementation of RFC 6238 is used, with HMAC-SHA-1 and time-step of 30 seconds.
|
||||
// Currently no other options are supported, and if anything different is used the validation will fail.
|
||||
optional string totp_secret = 8 [
|
||||
(validate.rules).string = {min_len: 1 max_len: 200},
|
||||
(validate.rules).string = {min_len: 1, max_len: 200},
|
||||
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
|
||||
min_length: 1,
|
||||
max_length: 200,
|
||||
@@ -2086,8 +2086,12 @@ message CreateUserRequest{
|
||||
}
|
||||
];
|
||||
|
||||
// Metadata to bet set. The values have to be base64 encoded.
|
||||
// Deprecated: Use the root-level `metadata` field in `CreateUserRequest` instead.
|
||||
// If both metadata fields are set, the validation will fail.
|
||||
//
|
||||
// Metadata to be set. The values have to be base64 encoded.
|
||||
repeated Metadata metadata = 9 [
|
||||
deprecated = true,
|
||||
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
|
||||
example: "[{\"key\": \"test1\", \"value\": \"VGhpcyBpcyBteSBmaXJzdCB2YWx1ZQ==\"}, {\"key\": \"test2\", \"value\": \"VGhpcyBpcyBteSBzZWNvbmQgdmFsdWU=\"}]"
|
||||
}
|
||||
@@ -2177,6 +2181,14 @@ message CreateUserRequest{
|
||||
// You can change it to JWT by specifying the access token type in the machine field.
|
||||
Machine machine = 5;
|
||||
}
|
||||
|
||||
// Metadata to be set. The values have to be base64 encoded.
|
||||
repeated Metadata metadata = 6 [
|
||||
(grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
|
||||
example: "[{\"key\": \"test1\", \"value\": \"VGhpcyBpcyBteSBmaXJzdCB2YWx1ZQ==\"}, {\"key\": \"test2\", \"value\": \"VGhpcyBpcyBteSBzZWNvbmQgdmFsdWU=\"}]"
|
||||
}
|
||||
];
|
||||
|
||||
option (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_schema) = {
|
||||
example: "{\"organizationId\":\"69629026806489455\",\"userId\":\"163840776835432345\",\"username\":\"minnie-mouse\",\"human\":{\"profile\":{\"givenName\":\"Minnie\",\"familyName\":\"Mouse\",\"nickName\":\"Mini\",\"displayName\":\"Minnie Mouse\",\"preferredLanguage\":\"en\",\"gender\":\"GENDER_FEMALE\"},\"email\":{\"email\":\"mini@mouse.com\",\"sendCode\":{\"urlTemplate\":\"https://example.com/email/verify?userID={{.UserID}}&code={{.Code}}&orgID={{.OrgID}}\"}},\"phone\":{\"phone\":\"+41791234567\",\"isVerified\":true},\"password\":{\"password\":\"Secr3tP4ssw0rd!\",\"changeRequired\":true},\"idpLinks\":[{\"idpId\":\"d654e6ba-70a3-48ef-a95d-37c8d8a7901a\",\"userId\":\"6516849804890468048461403518\",\"userName\":\"user@external.com\"}],\"totpSecret\":\"TJOPWSDYILLHXFV4MLKNNJOWFG7VSDCK\"}}";
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user