diff --git a/build/zitadel/generate-grpc.sh b/build/zitadel/generate-grpc.sh
index 843c8f64ee..ae4d8c041f 100755
--- a/build/zitadel/generate-grpc.sh
+++ b/build/zitadel/generate-grpc.sh
@@ -67,8 +67,10 @@ protoc \
-I=/proto/include \
--grpc-gateway_out ${GOPATH}/src \
--grpc-gateway_opt logtostderr=true \
+ --grpc-gateway_opt allow_delete_body=true \
--openapiv2_out ${OPENAPI_PATH} \
--openapiv2_opt logtostderr=true \
+ --openapiv2_opt allow_delete_body=true \
--authoption_out=${GRPC_PATH}/auth \
--validate_out=lang=go:${GOPATH}/src \
${PROTO_PATH}/auth.proto
@@ -114,6 +116,10 @@ protoc \
-I=/proto/include \
--doc_out=${DOCS_PATH} --doc_opt=${PROTO_PATH}/docs/zitadel-md.tmpl,message.md \
${PROTO_PATH}/message.proto
+protoc \
+ -I=/proto/include \
+ --doc_out=${DOCS_PATH} --doc_opt=${PROTO_PATH}/docs/zitadel-md.tmpl,metadata.md \
+ ${PROTO_PATH}/metadata.proto
protoc \
-I=/proto/include \
--doc_out=${DOCS_PATH} --doc_opt=${PROTO_PATH}/docs/zitadel-md.tmpl,object.md \
@@ -134,9 +140,14 @@ protoc \
-I=/proto/include \
--doc_out=${DOCS_PATH} --doc_opt=${PROTO_PATH}/docs/zitadel-md.tmpl,project.md \
${PROTO_PATH}/project.proto
+ protoc \
+ -I=/proto/include \
+ --doc_out=${DOCS_PATH} --doc_opt=${PROTO_PATH}/docs/zitadel-md.tmpl,text.md \
+ ${PROTO_PATH}/text.proto
protoc \
-I=/proto/include \
--doc_out=${DOCS_PATH} --doc_opt=${PROTO_PATH}/docs/zitadel-md.tmpl,user.md \
${PROTO_PATH}/user.proto
+
echo "done generating grpc"
\ No newline at end of file
diff --git a/docs/docs/apis/proto/auth.md b/docs/docs/apis/proto/auth.md
index d266894b15..5b1ccf2a5b 100644
--- a/docs/docs/apis/proto/auth.md
+++ b/docs/docs/apis/proto/auth.md
@@ -67,6 +67,78 @@ Returns the user sessions of the authorized user of the current useragent
POST: /users/me/sessions/_search
+### SetMyMetadata
+
+> **rpc** SetMyMetadata([SetMyMetadataRequest](#setmymetadatarequest))
+[SetMyMetadataResponse](#setmymetadataresponse)
+
+Sets a user metadata by key to the authorized user
+
+
+
+ POST: /users/me/metadata/{key}
+
+
+### BulkSetMyMetadata
+
+> **rpc** BulkSetMyMetadata([BulkSetMyMetadataRequest](#bulksetmymetadatarequest))
+[BulkSetMyMetadataResponse](#bulksetmymetadataresponse)
+
+Set a list of user metadata to the authorized user
+
+
+
+ POST: /users/me/metadata/_bulk
+
+
+### ListMyMetadata
+
+> **rpc** ListMyMetadata([ListMyMetadataRequest](#listmymetadatarequest))
+[ListMyMetadataResponse](#listmymetadataresponse)
+
+Returns the user metadata of the authorized user
+
+
+
+ POST: /users/me/metadata/_search
+
+
+### GetMyMetadata
+
+> **rpc** GetMyMetadata([GetMyMetadataRequest](#getmymetadatarequest))
+[GetMyMetadataResponse](#getmymetadataresponse)
+
+Returns the user metadata by key of the authorized user
+
+
+
+ GET: /users/me/metadata/{key}
+
+
+### RemoveMyMetadata
+
+> **rpc** RemoveMyMetadata([RemoveMyMetadataRequest](#removemymetadatarequest))
+[RemoveMyMetadataResponse](#removemymetadataresponse)
+
+Removes a user metadata by key to the authorized user
+
+
+
+ DELETE: /users/me/metadata/{key}
+
+
+### BulkRemoveMyMetadata
+
+> **rpc** BulkRemoveMyMetadata([BulkRemoveMyMetadataRequest](#bulkremovemymetadatarequest))
+[BulkRemoveMyMetadataResponse](#bulkremovemymetadataresponse)
+
+Set a list of user metadata to the authorized user
+
+
+
+ DELETE: /users/me/metadata/_bulk
+
+
### ListMyRefreshTokens
> **rpc** ListMyRefreshTokens([ListMyRefreshTokensRequest](#listmyrefreshtokensrequest))
@@ -628,6 +700,62 @@ This is an empty request
+### BulkRemoveMyMetadataRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| keys | repeated string | - | repeated.items.string.min_len: 1
repeated.items.string.max_len: 200
|
+
+
+
+
+### BulkRemoveMyMetadataResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
+### BulkSetMyMetadataRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| metadata | repeated BulkSetMyMetadataRequest.Metadata | - | |
+
+
+
+
+### BulkSetMyMetadataRequest.Metadata
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| key | string | - | string.min_len: 1
string.max_len: 200
|
+| value | bytes | - | bytes.min_len: 1
bytes.max_len: 500000
|
+
+
+
+
+### BulkSetMyMetadataResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
### GetMyEmailRequest
This is an empty request
@@ -646,6 +774,28 @@ This is an empty request
+### GetMyMetadataRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| key | string | - | string.min_len: 1
string.max_len: 200
|
+
+
+
+
+### GetMyMetadataResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| metadata | zitadel.metadata.v1.Metadata | - | |
+
+
+
+
### GetMyPasswordComplexityPolicyRequest
This is an empty request
@@ -811,6 +961,30 @@ This is an empty request
+### ListMyMetadataRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| query | zitadel.v1.ListQuery | - | |
+| queries | repeated zitadel.metadata.v1.MetadataQuery | - | |
+
+
+
+
+### ListMyMetadataResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ListDetails | - | |
+| result | repeated zitadel.metadata.v1.Metadata | - | |
+
+
+
+
### ListMyPasswordlessRequest
This is an empty request
@@ -1063,6 +1237,28 @@ This is an empty request
+### RemoveMyMetadataRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| key | string | - | string.min_len: 1
string.max_len: 200
|
+
+
+
+
+### RemoveMyMetadataResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
### RemoveMyPasswordlessRequest
@@ -1209,6 +1405,29 @@ This is an empty request
+### SetMyMetadataRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| key | string | - | string.min_len: 1
string.max_len: 200
|
+| value | bytes | - | bytes.min_len: 1
bytes.max_len: 500000
|
+
+
+
+
+### SetMyMetadataResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
### SetMyPhoneRequest
diff --git a/docs/docs/apis/proto/management.md b/docs/docs/apis/proto/management.md
index aaf9fee264..395ee0b11d 100644
--- a/docs/docs/apis/proto/management.md
+++ b/docs/docs/apis/proto/management.md
@@ -236,6 +236,78 @@ Changes the username
GET: /users/{user_id}/username
+### SetUserMetadata
+
+> **rpc** SetUserMetadata([SetUserMetadataRequest](#setusermetadatarequest))
+[SetUserMetadataResponse](#setusermetadataresponse)
+
+Sets a user metadata by key
+
+
+
+ POST: /users/{id}/metadata/{key}
+
+
+### BulkSetUserMetadata
+
+> **rpc** BulkSetUserMetadata([BulkSetUserMetadataRequest](#bulksetusermetadatarequest))
+[BulkSetUserMetadataResponse](#bulksetusermetadataresponse)
+
+Set a list of user metadata
+
+
+
+ POST: /users/{id}/metadata/_bulk
+
+
+### ListUserMetadata
+
+> **rpc** ListUserMetadata([ListUserMetadataRequest](#listusermetadatarequest))
+[ListUserMetadataResponse](#listusermetadataresponse)
+
+Returns the user metadata
+
+
+
+ POST: /users/{id}/metadata/_search
+
+
+### GetUserMetadata
+
+> **rpc** GetUserMetadata([GetUserMetadataRequest](#getusermetadatarequest))
+[GetUserMetadataResponse](#getusermetadataresponse)
+
+Returns the user metadata by key
+
+
+
+ GET: /users/{id}/metadata/{key}
+
+
+### RemoveUserMetadata
+
+> **rpc** RemoveUserMetadata([RemoveUserMetadataRequest](#removeusermetadatarequest))
+[RemoveUserMetadataResponse](#removeusermetadataresponse)
+
+Removes a user metadata by key
+
+
+
+ DELETE: /users/{id}/metadata/{key}
+
+
+### BulkRemoveUserMetadata
+
+> **rpc** BulkRemoveUserMetadata([BulkRemoveUserMetadataRequest](#bulkremoveusermetadatarequest))
+[BulkRemoveUserMetadataResponse](#bulkremoveusermetadataresponse)
+
+Set a list of user metadata
+
+
+
+ DELETE: /users/{id}/metadata/_bulk
+
+
### GetHumanProfile
> **rpc** GetHumanProfile([GetHumanProfileRequest](#gethumanprofilerequest))
@@ -3347,6 +3419,64 @@ This is an empty request
+### BulkRemoveUserMetadataRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| id | string | - | string.min_len: 1
string.max_len: 200
|
+| keys | repeated string | - | repeated.items.string.min_len: 1
repeated.items.string.max_len: 200
|
+
+
+
+
+### BulkRemoveUserMetadataResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
+### BulkSetUserMetadataRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| id | string | - | string.min_len: 1
string.max_len: 200
|
+| metadata | repeated BulkSetUserMetadataRequest.Metadata | - | |
+
+
+
+
+### BulkSetUserMetadataRequest.Metadata
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| key | string | - | string.min_len: 1
string.max_len: 200
|
+| value | bytes | - | bytes.min_len: 1
bytes.max_len: 500000
|
+
+
+
+
+### BulkSetUserMetadataResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
### DeactivateAppRequest
@@ -4480,6 +4610,29 @@ This is an empty request
+### GetUserMetadataRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| id | string | - | string.min_len: 1
string.max_len: 200
|
+| key | string | - | string.min_len: 1
string.max_len: 200
|
+
+
+
+
+### GetUserMetadataResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| metadata | zitadel.metadata.v1.Metadata | - | |
+
+
+
+
### HealthzRequest
This is an empty request
@@ -5264,6 +5417,31 @@ This is an empty request
+### ListUserMetadataRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| id | string | - | string.min_len: 1
string.max_len: 200
|
+| query | zitadel.v1.ListQuery | - | |
+| queries | repeated zitadel.metadata.v1.MetadataQuery | - | |
+
+
+
+
+### ListUserMetadataResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ListDetails | - | |
+| result | repeated zitadel.metadata.v1.Metadata | - | |
+
+
+
+
### ListUsersRequest
@@ -6068,6 +6246,29 @@ This is an empty response
+### RemoveUserMetadataRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| id | string | - | string.min_len: 1
string.max_len: 200
|
+| key | string | - | string.min_len: 1
string.max_len: 200
|
+
+
+
+
+### RemoveUserMetadataResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
### RemoveUserRequest
@@ -6756,6 +6957,31 @@ This is an empty request
+### SetUserMetadataRequest
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| id | string | - | string.min_len: 1
string.max_len: 200
|
+| key | string | - | string.min_len: 1
string.max_len: 200
|
+| value | bytes | - | bytes.min_len: 1
bytes.max_len: 500000
|
+
+
+
+
+### SetUserMetadataResponse
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| id | string | - | |
+| details | zitadel.v1.ObjectDetails | - | |
+
+
+
+
### UnlockUserRequest
diff --git a/docs/docs/apis/proto/metadata.md b/docs/docs/apis/proto/metadata.md
new file mode 100644
index 0000000000..d982161621
--- /dev/null
+++ b/docs/docs/apis/proto/metadata.md
@@ -0,0 +1,49 @@
+---
+title: zitadel/metadata.proto
+---
+> This document reflects the state from API 1.0 (available from 20.04.2021)
+
+
+
+
+## Messages
+
+
+### Metadata
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+| key | string | - | |
+| value | bytes | - | |
+
+
+
+
+### MetadataKeyQuery
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| key | string | - | string.max_len: 200
|
+| method | zitadel.v1.TextQueryMethod | - | enum.defined_only: true
|
+
+
+
+
+### MetadataQuery
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| [**oneof**](https://developers.google.com/protocol-buffers/docs/proto3#oneof) query.key_query | MetadataKeyQuery | - | |
+
+
+
+
+
+
diff --git a/docs/docs/apis/proto/text.md b/docs/docs/apis/proto/text.md
new file mode 100644
index 0000000000..a2a0777961
--- /dev/null
+++ b/docs/docs/apis/proto/text.md
@@ -0,0 +1,601 @@
+---
+title: zitadel/text.proto
+---
+> This document reflects the state from API 1.0 (available from 20.04.2021)
+
+
+
+
+## Messages
+
+
+### EmailVerificationDoneScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| next_button_text | string | - | string.max_len: 100
|
+| cancel_button_text | string | - | string.max_len: 100
|
+| login_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### EmailVerificationScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| code_label | string | - | string.max_len: 200
|
+| next_button_text | string | - | string.max_len: 100
|
+| resend_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### ExternalUserNotFoundScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| link_button_text | string | - | string.max_len: 100
|
+| auto_register_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### FooterText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| tos | string | - | string.max_len: 200
|
+| privacy_policy | string | - | string.max_len: 200
|
+| help | string | - | string.max_len: 200
|
+| help_link | string | - | string.max_len: 500
|
+
+
+
+
+### InitMFADoneScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| cancel_button_text | string | - | string.max_len: 100
|
+| next_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### InitMFAOTPScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| description_otp | string | - | string.max_len: 500
|
+| secret_label | string | - | string.max_len: 200
|
+| code_label | string | - | string.max_len: 200
|
+| next_button_text | string | - | string.max_len: 100
|
+| cancel_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### InitMFAPromptScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| otp_option | string | - | string.max_len: 200
|
+| u2f_option | string | - | string.max_len: 200
|
+| skip_button_text | string | - | string.max_len: 100
|
+| next_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### InitMFAU2FScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| token_name_label | string | - | string.max_len: 200
|
+| not_supported | string | - | string.max_len: 500
|
+| register_token_button_text | string | - | string.max_len: 100
|
+| error_retry | string | - | string.max_len: 500
|
+
+
+
+
+### InitPasswordDoneScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| next_button_text | string | - | string.max_len: 100
|
+| cancel_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### InitPasswordScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| code_label | string | - | string.max_len: 200
|
+| new_password_label | string | - | string.max_len: 200
|
+| new_password_confirm_label | string | - | string.max_len: 200
|
+| next_button_text | string | - | string.max_len: 100
|
+| resend_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### InitializeUserDoneScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| cancel_button_text | string | - | string.max_len: 100
|
+| next_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### InitializeUserScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| code_label | string | - | string.max_len: 200
|
+| new_password_label | string | - | string.max_len: 200
|
+| new_password_confirm_label | string | - | string.max_len: 200
|
+| resend_button_text | string | - | string.max_len: 100
|
+| next_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### LinkingUserDoneScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| cancel_button_text | string | - | string.max_len: 100
|
+| next_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### LoginCustomText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+| select_account_text | SelectAccountScreenText | - | |
+| login_text | LoginScreenText | - | |
+| password_text | PasswordScreenText | - | |
+| username_change_text | UsernameChangeScreenText | - | |
+| username_change_done_text | UsernameChangeDoneScreenText | - | |
+| init_password_text | InitPasswordScreenText | - | |
+| init_password_done_text | InitPasswordDoneScreenText | - | |
+| email_verification_text | EmailVerificationScreenText | - | |
+| email_verification_done_text | EmailVerificationDoneScreenText | - | |
+| initialize_user_text | InitializeUserScreenText | - | |
+| initialize_done_text | InitializeUserDoneScreenText | - | |
+| init_mfa_prompt_text | InitMFAPromptScreenText | - | |
+| init_mfa_otp_text | InitMFAOTPScreenText | - | |
+| init_mfa_u2f_text | InitMFAU2FScreenText | - | |
+| init_mfa_done_text | InitMFADoneScreenText | - | |
+| mfa_providers_text | MFAProvidersText | - | |
+| verify_mfa_otp_text | VerifyMFAOTPScreenText | - | |
+| verify_mfa_u2f_text | VerifyMFAU2FScreenText | - | |
+| passwordless_text | PasswordlessScreenText | - | |
+| password_change_text | PasswordChangeScreenText | - | |
+| password_change_done_text | PasswordChangeDoneScreenText | - | |
+| password_reset_done_text | PasswordResetDoneScreenText | - | |
+| registration_option_text | RegistrationOptionScreenText | - | |
+| registration_user_text | RegistrationUserScreenText | - | |
+| registration_org_text | RegistrationOrgScreenText | - | |
+| linking_user_done_text | LinkingUserDoneScreenText | - | |
+| external_user_not_found_text | ExternalUserNotFoundScreenText | - | |
+| success_login_text | SuccessLoginScreenText | - | |
+| logout_text | LogoutDoneScreenText | - | |
+| footer_text | FooterText | - | |
+| passwordless_prompt_text | PasswordlessPromptScreenText | - | |
+| passwordless_registration_text | PasswordlessRegistrationScreenText | - | |
+| passwordless_registration_done_text | PasswordlessRegistrationDoneScreenText | - | |
+
+
+
+
+### LoginScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| title_linking_process | string | - | string.max_len: 200
|
+| description_linking_process | string | - | string.max_len: 500
|
+| user_must_be_member_of_org | string | - | string.max_len: 500
|
+| login_name_label | string | - | string.max_len: 200
|
+| register_button_text | string | - | string.max_len: 100
|
+| next_button_text | string | - | string.max_len: 100
|
+| external_user_description | string | - | string.max_len: 500
|
+| user_name_placeholder | string | - | string.max_len: 200
|
+| login_name_placeholder | string | - | string.max_len: 200
|
+
+
+
+
+### LogoutDoneScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| login_button_text | string | - | string.max_len: 200
|
+
+
+
+
+### MFAProvidersText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| choose_other | string | - | string.max_len: 500
|
+| otp | string | - | string.max_len: 200
|
+| u2f | string | - | string.max_len: 200
|
+
+
+
+
+### MessageCustomText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| details | zitadel.v1.ObjectDetails | - | |
+| title | string | - | |
+| pre_header | string | - | |
+| subject | string | - | |
+| greeting | string | - | |
+| text | string | - | |
+| button_text | string | - | |
+| footer_text | string | - | |
+
+
+
+
+### PasswordChangeDoneScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| next_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### PasswordChangeScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| old_password_label | string | - | string.max_len: 200
|
+| new_password_label | string | - | string.max_len: 200
|
+| new_password_confirm_label | string | - | string.max_len: 200
|
+| cancel_button_text | string | - | string.max_len: 100
|
+| next_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### PasswordResetDoneScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| next_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### PasswordScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| password_label | string | - | string.max_len: 200
|
+| reset_link_text | string | - | string.max_len: 100
|
+| back_button_text | string | - | string.max_len: 100
|
+| next_button_text | string | - | string.max_len: 100
|
+| min_length | string | - | string.max_len: 100
|
+| has_uppercase | string | - | string.max_len: 100
|
+| has_lowercase | string | - | string.max_len: 100
|
+| has_number | string | - | string.max_len: 100
|
+| has_symbol | string | - | string.max_len: 100
|
+| confirmation | string | - | string.max_len: 100
|
+
+
+
+
+### PasswordlessPromptScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| description_init | string | - | string.max_len: 500
|
+| passwordless_button_text | string | - | string.max_len: 100
|
+| next_button_text | string | - | string.max_len: 100
|
+| skip_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### PasswordlessRegistrationDoneScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| next_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### PasswordlessRegistrationScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| token_name_label | string | - | string.max_len: 200
|
+| not_supported | string | - | string.max_len: 500
|
+| register_token_button_text | string | - | string.max_len: 100
|
+| error_retry | string | - | string.max_len: 500
|
+
+
+
+
+### PasswordlessScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| login_with_pw_button_text | string | - | string.max_len: 100
|
+| validate_token_button_text | string | - | string.max_len: 200
|
+| not_supported | string | - | string.max_len: 500
|
+| error_retry | string | - | string.max_len: 500
|
+
+
+
+
+### RegistrationOptionScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| user_name_button_text | string | - | string.max_len: 200
|
+| external_login_description | string | - | string.max_len: 500
|
+
+
+
+
+### RegistrationOrgScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| orgname_label | string | - | string.max_len: 200
|
+| firstname_label | string | - | string.max_len: 200
|
+| lastname_label | string | - | string.max_len: 200
|
+| username_label | string | - | string.max_len: 200
|
+| email_label | string | - | string.max_len: 200
|
+| password_label | string | - | string.max_len: 200
|
+| password_confirm_label | string | - | string.max_len: 200
|
+| tos_and_privacy_label | string | - | string.max_len: 200
|
+| tos_confirm | string | - | string.max_len: 200
|
+| tos_link_text | string | - | string.max_len: 200
|
+| privacy_link_text | string | - | string.max_len: 200
|
+| save_button_text | string | - | string.max_len: 200
|
+| tos_confirm_and | string | - | string.max_len: 200
|
+
+
+
+
+### RegistrationUserScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| description_org_register | string | - | string.max_len: 500
|
+| firstname_label | string | - | string.max_len: 200
|
+| lastname_label | string | - | string.max_len: 200
|
+| email_label | string | - | string.max_len: 200
|
+| username_label | string | - | string.max_len: 200
|
+| language_label | string | - | string.max_len: 200
|
+| gender_label | string | - | string.max_len: 200
|
+| password_label | string | - | string.max_len: 200
|
+| password_confirm_label | string | - | string.max_len: 200
|
+| tos_and_privacy_label | string | - | string.max_len: 200
|
+| tos_confirm | string | - | string.max_len: 200
|
+| tos_link_text | string | - | string.max_len: 200
|
+| privacy_link_text | string | - | string.max_len: 200
|
+| next_button_text | string | - | string.max_len: 200
|
+| back_button_text | string | - | string.max_len: 200
|
+| tos_confirm_and | string | - | string.max_len: 200
|
+
+
+
+
+### SelectAccountScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| title_linking_process | string | - | string.max_len: 200
|
+| description_linking_process | string | - | string.max_len: 500
|
+| other_user | string | - | string.max_len: 500
|
+| session_state_active | string | - | string.max_len: 100
|
+| session_state_inactive | string | - | string.max_len: 100
|
+| user_must_be_member_of_org | string | - | string.max_len: 500
|
+
+
+
+
+### SuccessLoginScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| auto_redirect_description | string | Text to describe that auto redirect should happen after successful login | string.max_len: 500
|
+| redirected_description | string | Text to describe that the window can be closed after redirect | string.max_len: 100
|
+| next_button_text | string | - | string.max_len: 200
|
+
+
+
+
+### UsernameChangeDoneScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| next_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### UsernameChangeScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| username_label | string | - | string.max_len: 200
|
+| cancel_button_text | string | - | string.max_len: 100
|
+| next_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### VerifyMFAOTPScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| code_label | string | - | string.max_len: 200
|
+| next_button_text | string | - | string.max_len: 100
|
+
+
+
+
+### VerifyMFAU2FScreenText
+
+
+
+| Field | Type | Description | Validation |
+| ----- | ---- | ----------- | ----------- |
+| title | string | - | string.max_len: 200
|
+| description | string | - | string.max_len: 500
|
+| validate_token_text | string | - | string.max_len: 500
|
+| not_supported | string | - | string.max_len: 500
|
+| error_retry | string | - | string.max_len: 500
|
+
+
+
+
+
+
diff --git a/internal/api/grpc/auth/metadata_converter.go b/internal/api/grpc/auth/metadata_converter.go
new file mode 100644
index 0000000000..aa0e06ec6d
--- /dev/null
+++ b/internal/api/grpc/auth/metadata_converter.go
@@ -0,0 +1,29 @@
+package auth
+
+import (
+ "github.com/caos/zitadel/internal/api/grpc/metadata"
+ "github.com/caos/zitadel/internal/api/grpc/object"
+ "github.com/caos/zitadel/internal/domain"
+ "github.com/caos/zitadel/pkg/grpc/auth"
+)
+
+func BulkSetMetadataToDomain(req *auth.BulkSetMyMetadataRequest) []*domain.Metadata {
+ metadata := make([]*domain.Metadata, len(req.Metadata))
+ for i, data := range req.Metadata {
+ metadata[i] = &domain.Metadata{
+ Key: data.Key,
+ Value: data.Value,
+ }
+ }
+ return metadata
+}
+
+func ListUserMetadataToDomain(req *auth.ListMyMetadataRequest) *domain.MetadataSearchRequest {
+ offset, limit, asc := object.ListQueryToModel(req.Query)
+ return &domain.MetadataSearchRequest{
+ Offset: offset,
+ Limit: limit,
+ Asc: asc,
+ Queries: metadata.MetadataQueriesToModel(req.Queries),
+ }
+}
diff --git a/internal/api/grpc/auth/user.go b/internal/api/grpc/auth/user.go
index c3a6c0d18d..c831525c27 100644
--- a/internal/api/grpc/auth/user.go
+++ b/internal/api/grpc/auth/user.go
@@ -6,9 +6,12 @@ import (
"github.com/caos/zitadel/internal/api/authz"
"github.com/caos/zitadel/internal/api/grpc/change"
+ "github.com/caos/zitadel/internal/api/grpc/metadata"
"github.com/caos/zitadel/internal/api/grpc/object"
+ obj_grpc "github.com/caos/zitadel/internal/api/grpc/object"
"github.com/caos/zitadel/internal/api/grpc/org"
user_grpc "github.com/caos/zitadel/internal/api/grpc/user"
+ "github.com/caos/zitadel/internal/domain"
"github.com/caos/zitadel/internal/eventstore/v1/models"
grant_model "github.com/caos/zitadel/internal/usergrant/model"
auth_pb "github.com/caos/zitadel/pkg/grpc/auth"
@@ -37,6 +40,79 @@ func (s *Server) ListMyUserChanges(ctx context.Context, req *auth_pb.ListMyUserC
}, nil
}
+func (s *Server) ListMyMetadata(ctx context.Context, req *auth_pb.ListMyMetadataRequest) (*auth_pb.ListMyMetadataResponse, error) {
+ res, err := s.repo.SearchMyMetadata(ctx, ListUserMetadataToDomain(req))
+ if err != nil {
+ return nil, err
+ }
+ return &auth_pb.ListMyMetadataResponse{
+ Result: metadata.MetadataListToPb(res.Result),
+ Details: obj_grpc.ToListDetails(
+ res.TotalResult,
+ res.Sequence,
+ res.Timestamp,
+ ),
+ }, nil
+}
+
+func (s *Server) GetMyMetadata(ctx context.Context, req *auth_pb.GetMyMetadataRequest) (*auth_pb.GetMyMetadataResponse, error) {
+ data, err := s.repo.GetMyMetadataByKey(ctx, req.Key)
+ if err != nil {
+ return nil, err
+ }
+ return &auth_pb.GetMyMetadataResponse{
+ Metadata: metadata.DomainMetadataToPb(data),
+ }, nil
+}
+
+func (s *Server) SetMyMetadata(ctx context.Context, req *auth_pb.SetMyMetadataRequest) (*auth_pb.SetMyMetadataResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ result, err := s.command.SetUserMetadata(ctx, &domain.Metadata{Key: req.Key, Value: req.Value}, ctxData.UserID, ctxData.ResourceOwner)
+ if err != nil {
+ return nil, err
+ }
+ return &auth_pb.SetMyMetadataResponse{
+ Details: obj_grpc.AddToDetailsPb(
+ result.Sequence,
+ result.ChangeDate,
+ result.ResourceOwner,
+ ),
+ }, nil
+}
+
+func (s *Server) BulkSetMyMetadata(ctx context.Context, req *auth_pb.BulkSetMyMetadataRequest) (*auth_pb.BulkSetMyMetadataResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ result, err := s.command.BulkSetUserMetadata(ctx, ctxData.UserID, ctxData.ResourceOwner, BulkSetMetadataToDomain(req)...)
+ if err != nil {
+ return nil, err
+ }
+ return &auth_pb.BulkSetMyMetadataResponse{
+ Details: obj_grpc.DomainToChangeDetailsPb(result),
+ }, nil
+}
+
+func (s *Server) RemoveMyMetadata(ctx context.Context, req *auth_pb.RemoveMyMetadataRequest) (*auth_pb.RemoveMyMetadataResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ result, err := s.command.RemoveUserMetadata(ctx, req.Key, ctxData.UserID, ctxData.ResourceOwner)
+ if err != nil {
+ return nil, err
+ }
+ return &auth_pb.RemoveMyMetadataResponse{
+ Details: obj_grpc.DomainToChangeDetailsPb(result),
+ }, nil
+}
+
+func (s *Server) BulkRemoveMyMetadata(ctx context.Context, req *auth_pb.BulkRemoveMyMetadataRequest) (*auth_pb.BulkRemoveMyMetadataResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ result, err := s.command.BulkRemoveUserMetadata(ctx, ctxData.UserID, ctxData.ResourceOwner, req.Keys...)
+ if err != nil {
+ return nil, err
+ }
+ return &auth_pb.BulkRemoveMyMetadataResponse{
+ Details: obj_grpc.DomainToChangeDetailsPb(result),
+ }, nil
+}
+
func (s *Server) ListMyUserSessions(ctx context.Context, req *auth_pb.ListMyUserSessionsRequest) (*auth_pb.ListMyUserSessionsResponse, error) {
userSessions, err := s.repo.GetMyUserSessions(ctx)
if err != nil {
diff --git a/internal/api/grpc/management/user.go b/internal/api/grpc/management/user.go
index 35aea50da2..fed9c42863 100644
--- a/internal/api/grpc/management/user.go
+++ b/internal/api/grpc/management/user.go
@@ -9,10 +9,12 @@ import (
"github.com/caos/zitadel/internal/api/grpc/authn"
change_grpc "github.com/caos/zitadel/internal/api/grpc/change"
idp_grpc "github.com/caos/zitadel/internal/api/grpc/idp"
+ "github.com/caos/zitadel/internal/api/grpc/metadata"
"github.com/caos/zitadel/internal/api/grpc/object"
obj_grpc "github.com/caos/zitadel/internal/api/grpc/object"
"github.com/caos/zitadel/internal/api/grpc/user"
user_grpc "github.com/caos/zitadel/internal/api/grpc/user"
+ "github.com/caos/zitadel/internal/domain"
grant_model "github.com/caos/zitadel/internal/usergrant/model"
mgmt_pb "github.com/caos/zitadel/pkg/grpc/management"
)
@@ -78,6 +80,79 @@ func (s *Server) IsUserUnique(ctx context.Context, req *mgmt_pb.IsUserUniqueRequ
}, nil
}
+func (s *Server) ListUserMetadata(ctx context.Context, req *mgmt_pb.ListUserMetadataRequest) (*mgmt_pb.ListUserMetadataResponse, error) {
+ res, err := s.user.SearchMetadata(ctx, req.Id, authz.GetCtxData(ctx).OrgID, ListUserMetadataToDomain(req))
+ if err != nil {
+ return nil, err
+ }
+ return &mgmt_pb.ListUserMetadataResponse{
+ Result: metadata.MetadataListToPb(res.Result),
+ Details: obj_grpc.ToListDetails(
+ res.TotalResult,
+ res.Sequence,
+ res.Timestamp,
+ ),
+ }, nil
+}
+
+func (s *Server) GetUserMetadata(ctx context.Context, req *mgmt_pb.GetUserMetadataRequest) (*mgmt_pb.GetUserMetadataResponse, error) {
+ data, err := s.user.GetMetadataByKey(ctx, req.Id, authz.GetCtxData(ctx).OrgID, req.Key)
+ if err != nil {
+ return nil, err
+ }
+ return &mgmt_pb.GetUserMetadataResponse{
+ Metadata: metadata.DomainMetadataToPb(data),
+ }, nil
+}
+
+func (s *Server) SetUserMetadata(ctx context.Context, req *mgmt_pb.SetUserMetadataRequest) (*mgmt_pb.SetUserMetadataResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ result, err := s.command.SetUserMetadata(ctx, &domain.Metadata{Key: req.Key, Value: req.Value}, req.Id, ctxData.ResourceOwner)
+ if err != nil {
+ return nil, err
+ }
+ return &mgmt_pb.SetUserMetadataResponse{
+ Details: obj_grpc.AddToDetailsPb(
+ result.Sequence,
+ result.ChangeDate,
+ result.ResourceOwner,
+ ),
+ }, nil
+}
+
+func (s *Server) BulkSetUserMetadata(ctx context.Context, req *mgmt_pb.BulkSetUserMetadataRequest) (*mgmt_pb.BulkSetUserMetadataResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ result, err := s.command.BulkSetUserMetadata(ctx, req.Id, ctxData.ResourceOwner, BulkSetMetadataToDomain(req)...)
+ if err != nil {
+ return nil, err
+ }
+ return &mgmt_pb.BulkSetUserMetadataResponse{
+ Details: obj_grpc.DomainToChangeDetailsPb(result),
+ }, nil
+}
+
+func (s *Server) RemoveUserMetadata(ctx context.Context, req *mgmt_pb.RemoveUserMetadataRequest) (*mgmt_pb.RemoveUserMetadataResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ result, err := s.command.RemoveUserMetadata(ctx, req.Key, req.Id, ctxData.ResourceOwner)
+ if err != nil {
+ return nil, err
+ }
+ return &mgmt_pb.RemoveUserMetadataResponse{
+ Details: obj_grpc.DomainToChangeDetailsPb(result),
+ }, nil
+}
+
+func (s *Server) BulkRemoveUserMetadata(ctx context.Context, req *mgmt_pb.BulkRemoveUserMetadataRequest) (*mgmt_pb.BulkRemoveUserMetadataResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ result, err := s.command.BulkRemoveUserMetadata(ctx, req.Id, ctxData.ResourceOwner, req.Keys...)
+ if err != nil {
+ return nil, err
+ }
+ return &mgmt_pb.BulkRemoveUserMetadataResponse{
+ Details: obj_grpc.DomainToChangeDetailsPb(result),
+ }, nil
+}
+
func (s *Server) AddHumanUser(ctx context.Context, req *mgmt_pb.AddHumanUserRequest) (*mgmt_pb.AddHumanUserResponse, error) {
human, err := s.command.AddHuman(ctx, authz.GetCtxData(ctx).OrgID, AddHumanUserRequestToDomain(req))
if err != nil {
diff --git a/internal/api/grpc/management/user_converter.go b/internal/api/grpc/management/user_converter.go
index d06f33c594..9e326b2279 100644
--- a/internal/api/grpc/management/user_converter.go
+++ b/internal/api/grpc/management/user_converter.go
@@ -10,6 +10,7 @@ import (
"github.com/caos/zitadel/internal/api/authz"
"github.com/caos/zitadel/internal/api/grpc/authn"
+ "github.com/caos/zitadel/internal/api/grpc/metadata"
"github.com/caos/zitadel/internal/api/grpc/object"
user_grpc "github.com/caos/zitadel/internal/api/grpc/user"
"github.com/caos/zitadel/internal/domain"
@@ -38,6 +39,27 @@ func ListUsersRequestToModel(ctx context.Context, req *mgmt_pb.ListUsersRequest)
}
}
+func BulkSetMetadataToDomain(req *mgmt_pb.BulkSetUserMetadataRequest) []*domain.Metadata {
+ metadata := make([]*domain.Metadata, len(req.Metadata))
+ for i, data := range req.Metadata {
+ metadata[i] = &domain.Metadata{
+ Key: data.Key,
+ Value: data.Value,
+ }
+ }
+ return metadata
+}
+
+func ListUserMetadataToDomain(req *mgmt_pb.ListUserMetadataRequest) *domain.MetadataSearchRequest {
+ offset, limit, asc := object.ListQueryToModel(req.Query)
+ return &domain.MetadataSearchRequest{
+ Offset: offset,
+ Limit: limit,
+ Asc: asc,
+ Queries: metadata.MetadataQueriesToModel(req.Queries),
+ }
+}
+
func AddHumanUserRequestToDomain(req *mgmt_pb.AddHumanUserRequest) *domain.Human {
h := &domain.Human{
Username: req.UserName,
diff --git a/internal/api/grpc/metadata/metadata.go b/internal/api/grpc/metadata/metadata.go
new file mode 100644
index 0000000000..4ca6d466e5
--- /dev/null
+++ b/internal/api/grpc/metadata/metadata.go
@@ -0,0 +1,53 @@
+package metadata
+
+import (
+ "github.com/caos/zitadel/internal/api/grpc/object"
+ "github.com/caos/zitadel/internal/domain"
+ meta_pb "github.com/caos/zitadel/pkg/grpc/metadata"
+)
+
+func MetadataListToPb(dataList []*domain.Metadata) []*meta_pb.Metadata {
+ mds := make([]*meta_pb.Metadata, len(dataList))
+ for i, data := range dataList {
+ mds[i] = DomainMetadataToPb(data)
+ }
+ return mds
+}
+
+func DomainMetadataToPb(data *domain.Metadata) *meta_pb.Metadata {
+ return &meta_pb.Metadata{
+ Key: data.Key,
+ Value: data.Value,
+ Details: object.ToViewDetailsPb(
+ data.Sequence,
+ data.CreationDate,
+ data.ChangeDate,
+ data.ResourceOwner,
+ ),
+ }
+}
+
+func MetadataQueriesToModel(queries []*meta_pb.MetadataQuery) []*domain.MetadataSearchQuery {
+ q := make([]*domain.MetadataSearchQuery, len(queries))
+ for i, query := range queries {
+ q[i] = MetadataQueryToModel(query)
+ }
+ return q
+}
+
+func MetadataQueryToModel(query *meta_pb.MetadataQuery) *domain.MetadataSearchQuery {
+ switch q := query.Query.(type) {
+ case *meta_pb.MetadataQuery_KeyQuery:
+ return MetadataKeyQueryToModel(q.KeyQuery)
+ default:
+ return nil
+ }
+}
+
+func MetadataKeyQueryToModel(q *meta_pb.MetadataKeyQuery) *domain.MetadataSearchQuery {
+ return &domain.MetadataSearchQuery{
+ Key: domain.MetadataSearchKeyKey,
+ Method: object.TextMethodToModel(q.Method),
+ Value: q.Key,
+ }
+}
diff --git a/internal/auth/repository/eventsourcing/eventstore/user.go b/internal/auth/repository/eventsourcing/eventstore/user.go
index 62ca227c48..f155c4f602 100644
--- a/internal/auth/repository/eventsourcing/eventstore/user.go
+++ b/internal/auth/repository/eventsourcing/eventstore/user.go
@@ -14,6 +14,7 @@ import (
"github.com/caos/zitadel/internal/errors"
"github.com/caos/zitadel/internal/eventstore/v1"
"github.com/caos/zitadel/internal/eventstore/v1/models"
+ iam_model "github.com/caos/zitadel/internal/iam/repository/view/model"
key_model "github.com/caos/zitadel/internal/key/model"
key_view_model "github.com/caos/zitadel/internal/key/repository/view/model"
"github.com/caos/zitadel/internal/telemetry/tracing"
@@ -296,3 +297,39 @@ func (r *UserRepo) getUserEvents(ctx context.Context, userID string, sequence ui
}
return r.Eventstore.FilterEvents(ctx, query)
}
+
+func (repo *UserRepo) GetMyMetadataByKey(ctx context.Context, key string) (*domain.Metadata, error) {
+ ctxData := authz.GetCtxData(ctx)
+ data, err := repo.View.MetadataByKeyAndResourceOwner(ctxData.UserID, ctxData.ResourceOwner, key)
+ if err != nil {
+ return nil, err
+ }
+ return iam_model.MetadataViewToDomain(data), nil
+}
+
+func (repo *UserRepo) SearchMyMetadata(ctx context.Context, req *domain.MetadataSearchRequest) (*domain.MetadataSearchResponse, error) {
+ ctxData := authz.GetCtxData(ctx)
+ err := req.EnsureLimit(repo.SearchLimit)
+ if err != nil {
+ return nil, err
+ }
+ sequence, sequenceErr := repo.View.GetLatestUserSequence()
+ logging.Log("EVENT-N9fsd").OnError(sequenceErr).Warn("could not read latest user sequence")
+ req.AppendAggregateIDQuery(ctxData.UserID)
+ req.AppendResourceOwnerQuery(ctxData.ResourceOwner)
+ metadata, count, err := repo.View.SearchMetadata(req)
+ if err != nil {
+ return nil, err
+ }
+ result := &domain.MetadataSearchResponse{
+ Offset: req.Offset,
+ Limit: req.Limit,
+ TotalResult: count,
+ Result: iam_model.MetadataViewsToDomain(metadata),
+ }
+ if sequenceErr == nil {
+ result.Sequence = sequence.CurrentSequence
+ result.Timestamp = sequence.LastSuccessfulSpoolerRun
+ }
+ return result, nil
+}
diff --git a/internal/auth/repository/eventsourcing/handler/handler.go b/internal/auth/repository/eventsourcing/handler/handler.go
index 33b262d872..2aa3b464cb 100644
--- a/internal/auth/repository/eventsourcing/handler/handler.go
+++ b/internal/auth/repository/eventsourcing/handler/handler.go
@@ -72,6 +72,7 @@ func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, es
newRefreshToken(handler{view, bulkLimit, configs.cycleDuration("RefreshToken"), errorCount, es}),
newPrivacyPolicy(handler{view, bulkLimit, configs.cycleDuration("PrivacyPolicy"), errorCount, es}),
newCustomText(handler{view, bulkLimit, configs.cycleDuration("CustomTexts"), errorCount, es}),
+ newMetadata(handler{view, bulkLimit, configs.cycleDuration("Metadata"), errorCount, es}),
}
}
diff --git a/internal/auth/repository/eventsourcing/handler/metadata.go b/internal/auth/repository/eventsourcing/handler/metadata.go
new file mode 100644
index 0000000000..7b6510d200
--- /dev/null
+++ b/internal/auth/repository/eventsourcing/handler/metadata.go
@@ -0,0 +1,124 @@
+package handler
+
+import (
+ "github.com/caos/logging"
+
+ caos_errs "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore/v1"
+ es_models "github.com/caos/zitadel/internal/eventstore/v1/models"
+ "github.com/caos/zitadel/internal/eventstore/v1/query"
+ "github.com/caos/zitadel/internal/eventstore/v1/spooler"
+ iam_model "github.com/caos/zitadel/internal/iam/repository/view/model"
+ usr_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
+)
+
+type Metadata struct {
+ handler
+ subscription *v1.Subscription
+}
+
+func newMetadata(handler handler) *Metadata {
+ h := &Metadata{
+ handler: handler,
+ }
+
+ h.subscribe()
+
+ return h
+}
+
+func (m *Metadata) subscribe() {
+ m.subscription = m.es.Subscribe(m.AggregateTypes()...)
+ go func() {
+ for event := range m.subscription.Events {
+ query.ReduceEvent(m, event)
+ }
+ }()
+}
+
+const (
+ metadataTable = "auth.metadata"
+)
+
+func (m *Metadata) ViewModel() string {
+ return metadataTable
+}
+
+func (m *Metadata) Subscription() *v1.Subscription {
+ return m.subscription
+}
+
+func (_ *Metadata) AggregateTypes() []es_models.AggregateType {
+ return []es_models.AggregateType{usr_model.UserAggregate}
+}
+
+func (p *Metadata) CurrentSequence() (uint64, error) {
+ sequence, err := p.view.GetLatestMetadataSequence()
+ if err != nil {
+ return 0, err
+ }
+ return sequence.CurrentSequence, nil
+}
+
+func (m *Metadata) EventQuery() (*es_models.SearchQuery, error) {
+ sequence, err := m.view.GetLatestMetadataSequence()
+ if err != nil {
+ return nil, err
+ }
+ return es_models.NewSearchQuery().
+ AggregateTypeFilter(m.AggregateTypes()...).
+ LatestSequenceFilter(sequence.CurrentSequence), nil
+}
+
+func (m *Metadata) Reduce(event *es_models.Event) (err error) {
+ switch event.AggregateType {
+ case usr_model.UserAggregate:
+ err = m.processMetadata(event)
+ }
+ return err
+}
+
+func (m *Metadata) processMetadata(event *es_models.Event) (err error) {
+ metadata := new(iam_model.MetadataView)
+ switch event.Type {
+ case usr_model.UserMetadataSet:
+ err = metadata.SetData(event)
+ if err != nil {
+ return err
+ }
+ metadata, err = m.view.MetadataByKey(event.AggregateID, metadata.Key)
+ if err != nil && !caos_errs.IsNotFound(err) {
+ return err
+ }
+ if caos_errs.IsNotFound(err) {
+ err = nil
+ metadata = new(iam_model.MetadataView)
+ metadata.CreationDate = event.CreationDate
+ }
+ err = metadata.AppendEvent(event)
+ case usr_model.UserMetadataRemoved:
+ data := new(iam_model.MetadataView)
+ err = data.SetData(event)
+ if err != nil {
+ return err
+ }
+ return m.view.DeleteMetadata(event.AggregateID, data.Key, event)
+ case usr_model.UserRemoved:
+ return m.view.DeleteMetadataByAggregateID(event.AggregateID, event)
+ default:
+ return m.view.ProcessedMetadataSequence(event)
+ }
+ if err != nil {
+ return err
+ }
+ return m.view.PutMetadata(metadata, event)
+}
+
+func (m *Metadata) OnError(event *es_models.Event, err error) error {
+ logging.LogWithFields("SPOOL-miJJs", "id", event.AggregateID).WithError(err).Warn("something went wrong in custom text handler")
+ return spooler.HandleError(event, err, m.view.GetLatestMetadataFailedEvent, m.view.ProcessedMetadataFailedEvent, m.view.ProcessedMetadataSequence, m.errorCountUntilSkip)
+}
+
+func (m *Metadata) OnSuccess() error {
+ return spooler.HandleSuccess(m.view.UpdateMetadataSpoolerRunTimestamp)
+}
diff --git a/internal/auth/repository/eventsourcing/view/metadata.go b/internal/auth/repository/eventsourcing/view/metadata.go
new file mode 100644
index 0000000000..e9edbb72e2
--- /dev/null
+++ b/internal/auth/repository/eventsourcing/view/metadata.go
@@ -0,0 +1,73 @@
+package view
+
+import (
+ "github.com/caos/zitadel/internal/domain"
+ "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore/v1/models"
+ "github.com/caos/zitadel/internal/iam/repository/view"
+ "github.com/caos/zitadel/internal/iam/repository/view/model"
+ global_view "github.com/caos/zitadel/internal/view/repository"
+)
+
+const (
+ metadataTable = "auth.metadata"
+)
+
+func (v *View) MetadataByKey(aggregateID, key string) (*model.MetadataView, error) {
+ return view.MetadataByKey(v.Db, metadataTable, aggregateID, key)
+}
+
+func (v *View) MetadataListByAggregateID(aggregateID string) ([]*model.MetadataView, error) {
+ return view.GetMetadataList(v.Db, metadataTable, aggregateID)
+}
+
+func (v *View) MetadataByKeyAndResourceOwner(aggregateID, resourceOwner, key string) (*model.MetadataView, error) {
+ return view.MetadataByKeyAndResourceOwner(v.Db, metadataTable, aggregateID, resourceOwner, key)
+}
+
+func (v *View) SearchMetadata(request *domain.MetadataSearchRequest) ([]*model.MetadataView, uint64, error) {
+ return view.SearchMetadata(v.Db, metadataTable, request)
+}
+
+func (v *View) PutMetadata(template *model.MetadataView, event *models.Event) error {
+ err := view.PutMetadata(v.Db, metadataTable, template)
+ if err != nil {
+ return err
+ }
+ return v.ProcessedMetadataSequence(event)
+}
+
+func (v *View) DeleteMetadata(aggregateID, key string, event *models.Event) error {
+ err := view.DeleteMetadata(v.Db, metadataTable, aggregateID, key)
+ if err != nil && !errors.IsNotFound(err) {
+ return err
+ }
+ return v.ProcessedMetadataSequence(event)
+}
+
+func (v *View) DeleteMetadataByAggregateID(aggregateID string, event *models.Event) error {
+ err := view.DeleteMetadataByAggregateID(v.Db, metadataTable, aggregateID)
+ if err != nil && !errors.IsNotFound(err) {
+ return err
+ }
+ return v.ProcessedMetadataSequence(event)
+}
+func (v *View) GetLatestMetadataSequence() (*global_view.CurrentSequence, error) {
+ return v.latestSequence(metadataTable)
+}
+
+func (v *View) ProcessedMetadataSequence(event *models.Event) error {
+ return v.saveCurrentSequence(metadataTable, event)
+}
+
+func (v *View) UpdateMetadataSpoolerRunTimestamp() error {
+ return v.updateSpoolerRunSequence(metadataTable)
+}
+
+func (v *View) GetLatestMetadataFailedEvent(sequence uint64) (*global_view.FailedEvent, error) {
+ return v.latestFailedEvent(metadataTable, sequence)
+}
+
+func (v *View) ProcessedMetadataFailedEvent(failedEvent *global_view.FailedEvent) error {
+ return v.saveFailedEvent(failedEvent)
+}
diff --git a/internal/auth/repository/user.go b/internal/auth/repository/user.go
index 612bacbc0a..768e279110 100644
--- a/internal/auth/repository/user.go
+++ b/internal/auth/repository/user.go
@@ -4,6 +4,7 @@ import (
"context"
"time"
+ "github.com/caos/zitadel/internal/domain"
key_model "github.com/caos/zitadel/internal/key/model"
"github.com/caos/zitadel/internal/user/model"
@@ -42,4 +43,7 @@ type myUserRepo interface {
MyUserChanges(ctx context.Context, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*model.UserChanges, error)
SearchMyUserMemberships(ctx context.Context, request *model.UserMembershipSearchRequest) (*model.UserMembershipSearchResponse, error)
+
+ GetMyMetadataByKey(ctx context.Context, key string) (*domain.Metadata, error)
+ SearchMyMetadata(ctx context.Context, req *domain.MetadataSearchRequest) (*domain.MetadataSearchResponse, error)
}
diff --git a/internal/command/metadata_model.go b/internal/command/metadata_model.go
new file mode 100644
index 0000000000..260d763770
--- /dev/null
+++ b/internal/command/metadata_model.go
@@ -0,0 +1,49 @@
+package command
+
+import (
+ "github.com/caos/zitadel/internal/domain"
+ "github.com/caos/zitadel/internal/eventstore"
+ "github.com/caos/zitadel/internal/repository/metadata"
+)
+
+type MetadataWriteModel struct {
+ eventstore.WriteModel
+
+ Key string
+ Value []byte
+ State domain.MetadataState
+}
+
+func (wm *MetadataWriteModel) Reduce() error {
+ for _, event := range wm.Events {
+ switch e := event.(type) {
+ case *metadata.SetEvent:
+ if wm.Key != e.Key {
+ continue
+ }
+ wm.Value = e.Value
+ wm.State = domain.MetadataStateActive
+ case *metadata.RemovedEvent:
+ wm.State = domain.MetadataStateRemoved
+ }
+ }
+ return wm.WriteModel.Reduce()
+}
+
+type MetadataListWriteModel struct {
+ eventstore.WriteModel
+
+ metadataList map[string][]byte
+}
+
+func (wm *MetadataListWriteModel) Reduce() error {
+ for _, event := range wm.Events {
+ switch e := event.(type) {
+ case *metadata.SetEvent:
+ wm.metadataList[e.Key] = e.Value
+ case *metadata.RemovedEvent:
+ delete(wm.metadataList, e.Key)
+ }
+ }
+ return wm.WriteModel.Reduce()
+}
diff --git a/internal/command/user_converter.go b/internal/command/user_converter.go
index 6cb28deba6..aae254f9f9 100644
--- a/internal/command/user_converter.go
+++ b/internal/command/user_converter.go
@@ -153,3 +153,12 @@ func writeModelToPasswordlessInitCode(initCodeModel *HumanPasswordlessInitCodeWr
State: initCodeModel.State,
}
}
+
+func writeModelToUserMetadata(wm *UserMetadataWriteModel) *domain.Metadata {
+ return &domain.Metadata{
+ ObjectRoot: writeModelToObjectRoot(wm.WriteModel),
+ Key: wm.Key,
+ Value: wm.Value,
+ State: wm.State,
+ }
+}
diff --git a/internal/command/user_metadata.go b/internal/command/user_metadata.go
new file mode 100644
index 0000000000..7f1fc4aaec
--- /dev/null
+++ b/internal/command/user_metadata.go
@@ -0,0 +1,177 @@
+package command
+
+import (
+ "context"
+
+ "github.com/caos/zitadel/internal/domain"
+ caos_errs "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore"
+ "github.com/caos/zitadel/internal/repository/user"
+)
+
+func (c *Commands) SetUserMetadata(ctx context.Context, metadata *domain.Metadata, userID, resourceOwner string) (_ *domain.Metadata, err error) {
+ err = c.checkUserExists(ctx, userID, resourceOwner)
+ if err != nil {
+ return nil, err
+ }
+ setMetadata := NewUserMetadataWriteModel(userID, resourceOwner, metadata.Key)
+ userAgg := UserAggregateFromWriteModel(&setMetadata.WriteModel)
+ event, err := c.setUserMetadata(ctx, userAgg, metadata)
+ if err != nil {
+ return nil, err
+ }
+ pushedEvents, err := c.eventstore.PushEvents(ctx, event)
+ if err != nil {
+ return nil, err
+ }
+
+ err = AppendAndReduce(setMetadata, pushedEvents...)
+ if err != nil {
+ return nil, err
+ }
+ return writeModelToUserMetadata(setMetadata), nil
+}
+
+func (c *Commands) BulkSetUserMetadata(ctx context.Context, userID, resourceOwner string, metadatas ...*domain.Metadata) (_ *domain.ObjectDetails, err error) {
+ if len(metadatas) == 0 {
+ return nil, caos_errs.ThrowPreconditionFailed(nil, "META-9mm2d", "Errors.Metadata.NoData")
+ }
+ err = c.checkUserExists(ctx, userID, resourceOwner)
+ if err != nil {
+ return nil, err
+ }
+
+ events := make([]eventstore.EventPusher, len(metadatas))
+ setMetadata := NewUserMetadataListWriteModel(userID, resourceOwner)
+ userAgg := UserAggregateFromWriteModel(&setMetadata.WriteModel)
+ for i, data := range metadatas {
+ event, err := c.setUserMetadata(ctx, userAgg, data)
+ if err != nil {
+ return nil, err
+ }
+ events[i] = event
+ }
+
+ pushedEvents, err := c.eventstore.PushEvents(ctx, events...)
+ if err != nil {
+ return nil, err
+ }
+
+ err = AppendAndReduce(setMetadata, pushedEvents...)
+ if err != nil {
+ return nil, err
+ }
+ return writeModelToObjectDetails(&setMetadata.WriteModel), nil
+}
+
+func (c *Commands) setUserMetadata(ctx context.Context, userAgg *eventstore.Aggregate, metadata *domain.Metadata) (pusher eventstore.EventPusher, err error) {
+ if !metadata.IsValid() {
+ return nil, caos_errs.ThrowInvalidArgument(nil, "META-2m00f", "Errors.Metadata.Invalid")
+ }
+ return user.NewMetadataSetEvent(
+ ctx,
+ userAgg,
+ metadata.Key,
+ metadata.Value,
+ ), nil
+}
+
+func (c *Commands) RemoveUserMetadata(ctx context.Context, metadataKey, userID, resourceOwner string) (_ *domain.ObjectDetails, err error) {
+ if metadataKey == "" {
+ return nil, caos_errs.ThrowInvalidArgument(nil, "META-2n0fs", "Errors.Metadata.Invalid")
+ }
+ err = c.checkUserExists(ctx, userID, resourceOwner)
+ if err != nil {
+ return nil, err
+ }
+ removeMetadata, err := c.getUserMetadataModelByID(ctx, userID, resourceOwner, metadataKey)
+ if err != nil {
+ return nil, err
+ }
+ if !removeMetadata.State.Exists() {
+ return nil, caos_errs.ThrowNotFound(nil, "META-ncnw3", "Errors.Metadata.NotFound")
+ }
+ userAgg := UserAggregateFromWriteModel(&removeMetadata.WriteModel)
+ event, err := c.removeUserMetadata(ctx, userAgg, metadataKey)
+ if err != nil {
+ return nil, err
+ }
+ pushedEvents, err := c.eventstore.PushEvents(ctx, event)
+ if err != nil {
+ return nil, err
+ }
+
+ err = AppendAndReduce(removeMetadata, pushedEvents...)
+ if err != nil {
+ return nil, err
+ }
+ return writeModelToObjectDetails(&removeMetadata.WriteModel), nil
+}
+
+func (c *Commands) BulkRemoveUserMetadata(ctx context.Context, userID, resourceOwner string, metadataKeys ...string) (_ *domain.ObjectDetails, err error) {
+ if len(metadataKeys) == 0 {
+ return nil, caos_errs.ThrowPreconditionFailed(nil, "META-9mm2d", "Errors.Metadata.NoData")
+ }
+ err = c.checkUserExists(ctx, userID, resourceOwner)
+ if err != nil {
+ return nil, err
+ }
+
+ events := make([]eventstore.EventPusher, len(metadataKeys))
+ removeMetadata, err := c.getUserMetadataListModelByID(ctx, userID, resourceOwner)
+ if err != nil {
+ return nil, err
+ }
+ userAgg := UserAggregateFromWriteModel(&removeMetadata.WriteModel)
+ for i, key := range metadataKeys {
+ if key == "" {
+ return nil, caos_errs.ThrowInvalidArgument(nil, "COMMAND-m29ds", "Errors.Metadata.Invalid")
+ }
+ if _, found := removeMetadata.metadataList[key]; !found {
+ return nil, caos_errs.ThrowNotFound(nil, "META-2nnds", "Errors.Metadata.KeyNotExisting")
+ }
+ event, err := c.removeUserMetadata(ctx, userAgg, key)
+ if err != nil {
+ return nil, err
+ }
+ events[i] = event
+ }
+
+ pushedEvents, err := c.eventstore.PushEvents(ctx, events...)
+ if err != nil {
+ return nil, err
+ }
+
+ err = AppendAndReduce(removeMetadata, pushedEvents...)
+ if err != nil {
+ return nil, err
+ }
+ return writeModelToObjectDetails(&removeMetadata.WriteModel), nil
+}
+
+func (c *Commands) removeUserMetadata(ctx context.Context, userAgg *eventstore.Aggregate, metadataKey string) (pusher eventstore.EventPusher, err error) {
+ pusher = user.NewMetadataRemovedEvent(
+ ctx,
+ userAgg,
+ metadataKey,
+ )
+ return pusher, nil
+}
+
+func (c *Commands) getUserMetadataModelByID(ctx context.Context, userID, resourceOwner, key string) (*UserMetadataWriteModel, error) {
+ userMetadataWriteModel := NewUserMetadataWriteModel(userID, resourceOwner, key)
+ err := c.eventstore.FilterToQueryReducer(ctx, userMetadataWriteModel)
+ if err != nil {
+ return nil, err
+ }
+ return userMetadataWriteModel, nil
+}
+
+func (c *Commands) getUserMetadataListModelByID(ctx context.Context, userID, resourceOwner string) (*UserMetadataListWriteModel, error) {
+ userMetadataWriteModel := NewUserMetadataListWriteModel(userID, resourceOwner)
+ err := c.eventstore.FilterToQueryReducer(ctx, userMetadataWriteModel)
+ if err != nil {
+ return nil, err
+ }
+ return userMetadataWriteModel, nil
+}
diff --git a/internal/command/user_metadata_model.go b/internal/command/user_metadata_model.go
new file mode 100644
index 0000000000..4aed0e6422
--- /dev/null
+++ b/internal/command/user_metadata_model.go
@@ -0,0 +1,92 @@
+package command
+
+import (
+ "github.com/caos/zitadel/internal/eventstore"
+ "github.com/caos/zitadel/internal/repository/user"
+)
+
+type UserMetadataWriteModel struct {
+ MetadataWriteModel
+}
+
+func NewUserMetadataWriteModel(userID, resourceOwner, key string) *UserMetadataWriteModel {
+ return &UserMetadataWriteModel{
+ MetadataWriteModel{
+ WriteModel: eventstore.WriteModel{
+ AggregateID: userID,
+ ResourceOwner: resourceOwner,
+ },
+ Key: key,
+ },
+ }
+}
+
+func (wm *UserMetadataWriteModel) AppendEvents(events ...eventstore.EventReader) {
+ for _, event := range events {
+ switch e := event.(type) {
+ case *user.MetadataSetEvent:
+ wm.MetadataWriteModel.AppendEvents(&e.SetEvent)
+ case *user.MetadataRemovedEvent:
+ wm.MetadataWriteModel.AppendEvents(&e.RemovedEvent)
+ }
+ }
+}
+
+func (wm *UserMetadataWriteModel) Reduce() error {
+ return wm.MetadataWriteModel.Reduce()
+}
+
+func (wm *UserMetadataWriteModel) Query() *eventstore.SearchQueryBuilder {
+ return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
+ ResourceOwner(wm.ResourceOwner).
+ AddQuery().
+ AggregateIDs(wm.MetadataWriteModel.AggregateID).
+ AggregateTypes(user.AggregateType).
+ EventTypes(
+ user.MetadataSetType,
+ user.MetadataRemovedType).
+ Builder()
+}
+
+type UserMetadataListWriteModel struct {
+ MetadataListWriteModel
+}
+
+func NewUserMetadataListWriteModel(userID, resourceOwner string) *UserMetadataListWriteModel {
+ return &UserMetadataListWriteModel{
+ MetadataListWriteModel{
+ WriteModel: eventstore.WriteModel{
+ AggregateID: userID,
+ ResourceOwner: resourceOwner,
+ },
+ metadataList: make(map[string][]byte),
+ },
+ }
+}
+
+func (wm *UserMetadataListWriteModel) AppendEvents(events ...eventstore.EventReader) {
+ for _, event := range events {
+ switch e := event.(type) {
+ case *user.MetadataSetEvent:
+ wm.MetadataListWriteModel.AppendEvents(&e.SetEvent)
+ case *user.MetadataRemovedEvent:
+ wm.MetadataListWriteModel.AppendEvents(&e.RemovedEvent)
+ }
+ }
+}
+
+func (wm *UserMetadataListWriteModel) Reduce() error {
+ return wm.MetadataListWriteModel.Reduce()
+}
+
+func (wm *UserMetadataListWriteModel) Query() *eventstore.SearchQueryBuilder {
+ return eventstore.NewSearchQueryBuilder(eventstore.ColumnsEvent).
+ ResourceOwner(wm.ResourceOwner).
+ AddQuery().
+ AggregateIDs(wm.MetadataListWriteModel.AggregateID).
+ AggregateTypes(user.AggregateType).
+ EventTypes(
+ user.MetadataSetType,
+ user.MetadataRemovedType).
+ Builder()
+}
diff --git a/internal/command/user_metadata_test.go b/internal/command/user_metadata_test.go
new file mode 100644
index 0000000000..9df1d30ad2
--- /dev/null
+++ b/internal/command/user_metadata_test.go
@@ -0,0 +1,739 @@
+package command
+
+import (
+ "context"
+ "testing"
+
+ "github.com/stretchr/testify/assert"
+ "golang.org/x/text/language"
+
+ "github.com/caos/zitadel/internal/domain"
+ caos_errs "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore"
+ "github.com/caos/zitadel/internal/eventstore/repository"
+ "github.com/caos/zitadel/internal/eventstore/v1/models"
+ "github.com/caos/zitadel/internal/repository/user"
+)
+
+func TestCommandSide_SetMetadata(t *testing.T) {
+ type fields struct {
+ eventstore *eventstore.Eventstore
+ }
+ type (
+ args struct {
+ ctx context.Context
+ orgID string
+ userID string
+ metadata *domain.Metadata
+ }
+ )
+ type res struct {
+ want *domain.Metadata
+ err func(error) bool
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+ {
+ name: "user not existing, pre condition error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadata: &domain.Metadata{
+ Key: "key",
+ Value: []byte("value"),
+ },
+ },
+ res: res{
+ err: caos_errs.IsPreconditionFailed,
+ },
+ },
+ {
+ name: "invalid metadata, pre condition error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "",
+ "firstname lastname",
+ language.Und,
+ domain.GenderUnspecified,
+ "email@test.ch",
+ true,
+ ),
+ ),
+ ),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadata: &domain.Metadata{
+ Key: "key",
+ },
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "add metadata, ok",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "",
+ "firstname lastname",
+ language.Und,
+ domain.GenderUnspecified,
+ "email@test.ch",
+ true,
+ ),
+ ),
+ ),
+ expectPush(
+ []*repository.Event{
+ eventFromEventPusher(
+ user.NewMetadataSetEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "key",
+ []byte("value"),
+ ),
+ ),
+ },
+ ),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadata: &domain.Metadata{
+ Key: "key",
+ Value: []byte("value"),
+ },
+ },
+ res: res{
+ want: &domain.Metadata{
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: "user1",
+ ResourceOwner: "org1",
+ },
+ Key: "key",
+ Value: []byte("value"),
+ State: domain.MetadataStateActive,
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := &Commands{
+ eventstore: tt.fields.eventstore,
+ }
+ got, err := r.SetUserMetadata(tt.args.ctx, tt.args.metadata, tt.args.userID, tt.args.orgID)
+ if tt.res.err == nil {
+ assert.NoError(t, err)
+ }
+ if tt.res.err != nil && !tt.res.err(err) {
+ t.Errorf("got wrong err: %v ", err)
+ }
+ if tt.res.err == nil {
+ assert.Equal(t, tt.res.want, got)
+ }
+ })
+ }
+}
+
+func TestCommandSide_BulkSetMetadata(t *testing.T) {
+ type fields struct {
+ eventstore *eventstore.Eventstore
+ }
+ type (
+ args struct {
+ ctx context.Context
+ orgID string
+ userID string
+ metadataList []*domain.Metadata
+ }
+ )
+ type res struct {
+ want *domain.ObjectDetails
+ err func(error) bool
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+ {
+ name: "empty meta data list, pre condition error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ },
+ res: res{
+ err: caos_errs.IsPreconditionFailed,
+ },
+ },
+ {
+ name: "user not existing, pre condition error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadataList: []*domain.Metadata{
+ {Key: "key", Value: []byte("value")},
+ {Key: "key1", Value: []byte("value1")},
+ },
+ },
+ res: res{
+ err: caos_errs.IsPreconditionFailed,
+ },
+ },
+ {
+ name: "invalid metadata, pre condition error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "",
+ "firstname lastname",
+ language.Und,
+ domain.GenderUnspecified,
+ "email@test.ch",
+ true,
+ ),
+ ),
+ ),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadataList: []*domain.Metadata{
+ {Key: "key"},
+ {Key: "key1"},
+ },
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "add metadata, ok",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "",
+ "firstname lastname",
+ language.Und,
+ domain.GenderUnspecified,
+ "email@test.ch",
+ true,
+ ),
+ ),
+ ),
+ expectPush(
+ []*repository.Event{
+ eventFromEventPusher(
+ user.NewMetadataSetEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "key",
+ []byte("value"),
+ ),
+ ),
+ eventFromEventPusher(
+ user.NewMetadataSetEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "key1",
+ []byte("value1"),
+ ),
+ ),
+ },
+ ),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadataList: []*domain.Metadata{
+ {Key: "key", Value: []byte("value")},
+ {Key: "key1", Value: []byte("value1")},
+ },
+ },
+ res: res{
+ want: &domain.ObjectDetails{
+ ResourceOwner: "org1",
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := &Commands{
+ eventstore: tt.fields.eventstore,
+ }
+ got, err := r.BulkSetUserMetadata(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.metadataList...)
+ if tt.res.err == nil {
+ assert.NoError(t, err)
+ }
+ if tt.res.err != nil && !tt.res.err(err) {
+ t.Errorf("got wrong err: %v ", err)
+ }
+ if tt.res.err == nil {
+ assert.Equal(t, tt.res.want, got)
+ }
+ })
+ }
+}
+
+func TestCommandSide_UserRemoveMetadata(t *testing.T) {
+ type fields struct {
+ eventstore *eventstore.Eventstore
+ }
+ type (
+ args struct {
+ ctx context.Context
+ orgID string
+ userID string
+ metadataKey string
+ }
+ )
+ type res struct {
+ want *domain.ObjectDetails
+ err func(error) bool
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+ {
+ name: "user not existing, pre condition error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadataKey: "key",
+ },
+ res: res{
+ err: caos_errs.IsPreconditionFailed,
+ },
+ },
+ {
+ name: "invalid metadata, pre condition error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadataKey: "",
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "meta data not existing, not found error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "",
+ "firstname lastname",
+ language.Und,
+ domain.GenderUnspecified,
+ "email@test.ch",
+ true,
+ ),
+ ),
+ ),
+ expectFilter(),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadataKey: "key",
+ },
+ res: res{
+ err: caos_errs.IsNotFound,
+ },
+ },
+ {
+ name: "remove metadata, ok",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "",
+ "firstname lastname",
+ language.Und,
+ domain.GenderUnspecified,
+ "email@test.ch",
+ true,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ user.NewMetadataSetEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "key",
+ []byte("value"),
+ ),
+ ),
+ ),
+ expectPush(
+ []*repository.Event{
+ eventFromEventPusher(
+ user.NewMetadataRemovedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "key",
+ ),
+ ),
+ },
+ ),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadataKey: "key",
+ },
+ res: res{
+ want: &domain.ObjectDetails{
+ ResourceOwner: "org1",
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := &Commands{
+ eventstore: tt.fields.eventstore,
+ }
+ got, err := r.RemoveUserMetadata(tt.args.ctx, tt.args.metadataKey, tt.args.userID, tt.args.orgID)
+ if tt.res.err == nil {
+ assert.NoError(t, err)
+ }
+ if tt.res.err != nil && !tt.res.err(err) {
+ t.Errorf("got wrong err: %v ", err)
+ }
+ if tt.res.err == nil {
+ assert.Equal(t, tt.res.want, got)
+ }
+ })
+ }
+}
+
+func TestCommandSide_BulkRemoveMetadata(t *testing.T) {
+ type fields struct {
+ eventstore *eventstore.Eventstore
+ }
+ type (
+ args struct {
+ ctx context.Context
+ orgID string
+ userID string
+ metadataList []string
+ }
+ )
+ type res struct {
+ want *domain.ObjectDetails
+ err func(error) bool
+ }
+ tests := []struct {
+ name string
+ fields fields
+ args args
+ res res
+ }{
+ {
+ name: "empty meta data list, pre condition error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ },
+ res: res{
+ err: caos_errs.IsPreconditionFailed,
+ },
+ },
+ {
+ name: "user not existing, pre condition error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadataList: []string{"key", "key1"},
+ },
+ res: res{
+ err: caos_errs.IsPreconditionFailed,
+ },
+ },
+ {
+ name: "remove metadata keys not existing, precondition error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "",
+ "firstname lastname",
+ language.Und,
+ domain.GenderUnspecified,
+ "email@test.ch",
+ true,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ user.NewMetadataSetEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "key",
+ []byte("value"),
+ ),
+ ),
+ ),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadataList: []string{"key", "key1"},
+ },
+ res: res{
+ err: caos_errs.IsNotFound,
+ },
+ },
+ {
+ name: "invalid metadata, pre condition error",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "",
+ "firstname lastname",
+ language.Und,
+ domain.GenderUnspecified,
+ "email@test.ch",
+ true,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ user.NewMetadataSetEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "key",
+ []byte("value"),
+ ),
+ ),
+ eventFromEventPusher(
+ user.NewMetadataSetEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "key1",
+ []byte("value1"),
+ ),
+ ),
+ ),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadataList: []string{""},
+ },
+ res: res{
+ err: caos_errs.IsErrorInvalidArgument,
+ },
+ },
+ {
+ name: "remove metadata, ok",
+ fields: fields{
+ eventstore: eventstoreExpect(
+ t,
+ expectFilter(
+ eventFromEventPusher(
+ user.NewHumanAddedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "username",
+ "firstname",
+ "lastname",
+ "",
+ "firstname lastname",
+ language.Und,
+ domain.GenderUnspecified,
+ "email@test.ch",
+ true,
+ ),
+ ),
+ ),
+ expectFilter(
+ eventFromEventPusher(
+ user.NewMetadataSetEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "key",
+ []byte("value"),
+ ),
+ ),
+ eventFromEventPusher(
+ user.NewMetadataSetEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "key1",
+ []byte("value1"),
+ ),
+ ),
+ ),
+ expectPush(
+ []*repository.Event{
+ eventFromEventPusher(
+ user.NewMetadataRemovedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "key",
+ ),
+ ),
+ eventFromEventPusher(
+ user.NewMetadataRemovedEvent(context.Background(),
+ &user.NewAggregate("user1", "org1").Aggregate,
+ "key1",
+ ),
+ ),
+ },
+ ),
+ ),
+ },
+ args: args{
+ ctx: context.Background(),
+ orgID: "org1",
+ userID: "user1",
+ metadataList: []string{"key", "key1"},
+ },
+ res: res{
+ want: &domain.ObjectDetails{
+ ResourceOwner: "org1",
+ },
+ },
+ },
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ r := &Commands{
+ eventstore: tt.fields.eventstore,
+ }
+ got, err := r.BulkRemoveUserMetadata(tt.args.ctx, tt.args.userID, tt.args.orgID, tt.args.metadataList...)
+ if tt.res.err == nil {
+ assert.NoError(t, err)
+ }
+ if tt.res.err != nil && !tt.res.err(err) {
+ t.Errorf("got wrong err: %v ", err)
+ }
+ if tt.res.err == nil {
+ assert.Equal(t, tt.res.want, got)
+ }
+ })
+ }
+}
diff --git a/internal/domain/metadata.go b/internal/domain/metadata.go
new file mode 100644
index 0000000000..5c5032181d
--- /dev/null
+++ b/internal/domain/metadata.go
@@ -0,0 +1,83 @@
+package domain
+
+import (
+ "time"
+
+ caos_errors "github.com/caos/zitadel/internal/errors"
+ es_models "github.com/caos/zitadel/internal/eventstore/v1/models"
+)
+
+type Metadata struct {
+ es_models.ObjectRoot
+
+ State MetadataState
+ Key string
+ Value []byte
+}
+
+type MetadataState int32
+
+const (
+ MetadataStateUnspecified MetadataState = iota
+ MetadataStateActive
+ MetadataStateRemoved
+)
+
+func (m *Metadata) IsValid() bool {
+ return m.Key != "" && len(m.Value) > 0
+}
+
+func (s MetadataState) Exists() bool {
+ return s != MetadataStateUnspecified && s != MetadataStateRemoved
+}
+
+type MetadataSearchRequest struct {
+ Offset uint64
+ Limit uint64
+ SortingColumn MetadataSearchKey
+ Asc bool
+ Queries []*MetadataSearchQuery
+}
+
+type MetadataSearchKey int32
+
+const (
+ MetadataSearchKeyUnspecified MetadataSearchKey = iota
+ MetadataSearchKeyAggregateID
+ MetadataSearchKeyResourceOwner
+ MetadataSearchKeyKey
+ MetadataSearchKeyValue
+)
+
+type MetadataSearchQuery struct {
+ Key MetadataSearchKey
+ Method SearchMethod
+ Value interface{}
+}
+
+type MetadataSearchResponse struct {
+ Offset uint64
+ Limit uint64
+ TotalResult uint64
+ Result []*Metadata
+ Sequence uint64
+ Timestamp time.Time
+}
+
+func (r *MetadataSearchRequest) EnsureLimit(limit uint64) error {
+ if r.Limit > limit {
+ return caos_errors.ThrowInvalidArgument(nil, "SEARCH-0ds32", "Errors.Limit.ExceedsDefault")
+ }
+ if r.Limit == 0 {
+ r.Limit = limit
+ }
+ return nil
+}
+
+func (r *MetadataSearchRequest) AppendAggregateIDQuery(aggregateID string) {
+ r.Queries = append(r.Queries, &MetadataSearchQuery{Key: MetadataSearchKeyAggregateID, Method: SearchMethodEquals, Value: aggregateID})
+}
+
+func (r *MetadataSearchRequest) AppendResourceOwnerQuery(resourceOwner string) {
+ r.Queries = append(r.Queries, &MetadataSearchQuery{Key: MetadataSearchKeyResourceOwner, Method: SearchMethodEquals, Value: resourceOwner})
+}
diff --git a/internal/iam/repository/view/metadata_view.go b/internal/iam/repository/view/metadata_view.go
new file mode 100644
index 0000000000..5dd2bfbe34
--- /dev/null
+++ b/internal/iam/repository/view/metadata_view.go
@@ -0,0 +1,80 @@
+package view
+
+import (
+ "github.com/jinzhu/gorm"
+
+ "github.com/caos/zitadel/internal/domain"
+ caos_errs "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/iam/repository/view/model"
+ "github.com/caos/zitadel/internal/view/repository"
+)
+
+func GetMetadataList(db *gorm.DB, table string, aggregateID string) ([]*model.MetadataView, error) {
+ metadatas := make([]*model.MetadataView, 0)
+ queries := []*domain.MetadataSearchQuery{
+ {
+ Key: domain.MetadataSearchKeyAggregateID,
+ Value: aggregateID,
+ Method: domain.SearchMethodEquals,
+ },
+ }
+ query := repository.PrepareSearchQuery(table, model.MetadataSearchRequest{Queries: queries})
+ _, err := query(db, &metadatas)
+ if err != nil {
+ return nil, err
+ }
+ return metadatas, nil
+}
+
+func MetadataByKey(db *gorm.DB, table, aggregateID, key string) (*model.MetadataView, error) {
+ metadata := new(model.MetadataView)
+ aggregateIDQuery := &model.MetadataSearchQuery{Key: domain.MetadataSearchKeyAggregateID, Value: aggregateID, Method: domain.SearchMethodEquals}
+ keyQuery := &model.MetadataSearchQuery{Key: domain.MetadataSearchKeyKey, Value: key, Method: domain.SearchMethodEquals}
+ query := repository.PrepareGetByQuery(table, aggregateIDQuery, keyQuery)
+ err := query(db, metadata)
+ if caos_errs.IsNotFound(err) {
+ return nil, caos_errs.ThrowNotFound(nil, "VIEW-29kkd", "Errors.Metadata.NotExisting")
+ }
+ return metadata, err
+}
+
+func MetadataByKeyAndResourceOwner(db *gorm.DB, table, aggregateID, resourceOwner, key string) (*model.MetadataView, error) {
+ metadata := new(model.MetadataView)
+ aggregateIDQuery := &model.MetadataSearchQuery{Key: domain.MetadataSearchKeyAggregateID, Value: aggregateID, Method: domain.SearchMethodEquals}
+ resourceOwnerQuery := &model.MetadataSearchQuery{Key: domain.MetadataSearchKeyResourceOwner, Value: resourceOwner, Method: domain.SearchMethodEquals}
+ keyQuery := &model.MetadataSearchQuery{Key: domain.MetadataSearchKeyKey, Value: key, Method: domain.SearchMethodEquals}
+ query := repository.PrepareGetByQuery(table, aggregateIDQuery, resourceOwnerQuery, keyQuery)
+ err := query(db, metadata)
+ if caos_errs.IsNotFound(err) {
+ return nil, caos_errs.ThrowNotFound(nil, "VIEW-29kkd", "Errors.Metadata.NotExisting")
+ }
+ return metadata, err
+}
+
+func SearchMetadata(db *gorm.DB, table string, req *domain.MetadataSearchRequest) ([]*model.MetadataView, uint64, error) {
+ metadata := make([]*model.MetadataView, 0)
+ query := repository.PrepareSearchQuery(table, model.MetadataSearchRequest{Limit: req.Limit, Offset: req.Offset, Queries: req.Queries})
+ count, err := query(db, &metadata)
+ if err != nil {
+ return nil, 0, err
+ }
+ return metadata, count, nil
+}
+
+func PutMetadata(db *gorm.DB, table string, customText *model.MetadataView) error {
+ save := repository.PrepareSave(table)
+ return save(db, customText)
+}
+
+func DeleteMetadata(db *gorm.DB, table, aggregateID, key string) error {
+ aggregateIDQuery := repository.Key{Key: model.MetadataSearchKey(domain.MetadataSearchKeyAggregateID), Value: aggregateID}
+ keyQuery := repository.Key{Key: model.MetadataSearchKey(domain.MetadataSearchKeyKey), Value: key}
+ delete := repository.PrepareDeleteByKeys(table, aggregateIDQuery, keyQuery)
+ return delete(db)
+}
+
+func DeleteMetadataByAggregateID(db *gorm.DB, table, aggregateID string) error {
+ aggregateIDQuery := repository.Key{Key: model.MetadataSearchKey(domain.MetadataSearchKeyAggregateID), Value: aggregateID}
+ delete := repository.PrepareDeleteByKeys(table, aggregateIDQuery)
+ return delete(db)
+}
diff --git a/internal/iam/repository/view/model/custom_text.go b/internal/iam/repository/view/model/custom_text.go
index fd63822940..d6665d25ce 100644
--- a/internal/iam/repository/view/model/custom_text.go
+++ b/internal/iam/repository/view/model/custom_text.go
@@ -30,10 +30,10 @@ type CustomTextView struct {
CreationDate time.Time `json:"-" gorm:"column:creation_date"`
ChangeDate time.Time `json:"-" gorm:"column:change_date"`
- Template string `json:"Template" gorm:"column:template;primary_key"`
- Language string `json:"Language" gorm:"column:language;primary_key"`
- Key string `json:"Key" gorm:"column:key;primary_key"`
- Text string `json:"Text" gorm:"column:text"`
+ Template string `json:"template" gorm:"column:template;primary_key"`
+ Language string `json:"language" gorm:"column:language;primary_key"`
+ Key string `json:"key" gorm:"column:key;primary_key"`
+ Text string `json:"text" gorm:"column:text"`
Sequence uint64 `json:"-" gorm:"column:sequence"`
}
diff --git a/internal/iam/repository/view/model/metadata.go b/internal/iam/repository/view/model/metadata.go
new file mode 100644
index 0000000000..6530a884ab
--- /dev/null
+++ b/internal/iam/repository/view/model/metadata.go
@@ -0,0 +1,79 @@
+package model
+
+import (
+ "encoding/json"
+ "time"
+
+ "github.com/caos/zitadel/internal/domain"
+ usr_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
+
+ "github.com/caos/logging"
+
+ caos_errs "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore/v1/models"
+)
+
+const (
+ MetadataKeyAggregateID = "aggregate_id"
+ MetadataKeyResourceOwner = "resource_owner"
+ MetadataKeyKey = "key"
+ MetadataKeyValue = "value"
+)
+
+type MetadataView struct {
+ AggregateID string `json:"-" gorm:"column:aggregate_id;primary_key"`
+ ResourceOwner string `json:"-" gorm:"column:resource_owner"`
+ CreationDate time.Time `json:"-" gorm:"column:creation_date"`
+ ChangeDate time.Time `json:"-" gorm:"column:change_date"`
+
+ Key string `json:"key" gorm:"column:key;primary_key"`
+ Value []byte `json:"value" gorm:"column:value"`
+
+ Sequence uint64 `json:"-" gorm:"column:sequence"`
+}
+
+func MetadataViewsToDomain(texts []*MetadataView) []*domain.Metadata {
+ result := make([]*domain.Metadata, len(texts))
+ for i, text := range texts {
+ result[i] = MetadataViewToDomain(text)
+ }
+ return result
+}
+
+func MetadataViewToDomain(data *MetadataView) *domain.Metadata {
+ return &domain.Metadata{
+ ObjectRoot: models.ObjectRoot{
+ AggregateID: data.AggregateID,
+ Sequence: data.Sequence,
+ CreationDate: data.CreationDate,
+ ChangeDate: data.ChangeDate,
+ },
+ Key: data.Key,
+ Value: data.Value,
+ }
+}
+
+func (md *MetadataView) AppendEvent(event *models.Event) (err error) {
+ md.Sequence = event.Sequence
+ switch event.Type {
+ case usr_model.UserMetadataSet:
+ md.setRootData(event)
+ err = md.SetData(event)
+ }
+ return err
+}
+
+func (md *MetadataView) setRootData(event *models.Event) {
+ md.AggregateID = event.AggregateID
+ md.ResourceOwner = event.ResourceOwner
+ md.ChangeDate = event.CreationDate
+ md.Sequence = event.Sequence
+}
+
+func (md *MetadataView) SetData(event *models.Event) error {
+ if err := json.Unmarshal(event.Data, md); err != nil {
+ logging.Log("MODEL-3n9fs").WithError(err).Error("could not unmarshal event data")
+ return caos_errs.ThrowInternal(err, "MODEL-5CVaR", "Could not unmarshal data")
+ }
+ return nil
+}
diff --git a/internal/iam/repository/view/model/metadata_query.go b/internal/iam/repository/view/model/metadata_query.go
new file mode 100644
index 0000000000..79dbffd71f
--- /dev/null
+++ b/internal/iam/repository/view/model/metadata_query.go
@@ -0,0 +1,64 @@
+package model
+
+import (
+ "github.com/caos/zitadel/internal/domain"
+ "github.com/caos/zitadel/internal/view/repository"
+)
+
+type MetadataSearchRequest domain.MetadataSearchRequest
+type MetadataSearchQuery domain.MetadataSearchQuery
+type MetadataSearchKey domain.MetadataSearchKey
+
+func (req MetadataSearchRequest) GetLimit() uint64 {
+ return req.Limit
+}
+
+func (req MetadataSearchRequest) GetOffset() uint64 {
+ return req.Offset
+}
+
+func (req MetadataSearchRequest) GetSortingColumn() repository.ColumnKey {
+ if req.SortingColumn == domain.MetadataSearchKeyUnspecified {
+ return nil
+ }
+ return MetadataSearchKey(req.SortingColumn)
+}
+
+func (req MetadataSearchRequest) GetAsc() bool {
+ return req.Asc
+}
+
+func (req MetadataSearchRequest) GetQueries() []repository.SearchQuery {
+ result := make([]repository.SearchQuery, len(req.Queries))
+ for i, q := range req.Queries {
+ result[i] = MetadataSearchQuery{Key: q.Key, Value: q.Value, Method: q.Method}
+ }
+ return result
+}
+
+func (req MetadataSearchQuery) GetKey() repository.ColumnKey {
+ return MetadataSearchKey(req.Key)
+}
+
+func (req MetadataSearchQuery) GetMethod() domain.SearchMethod {
+ return req.Method
+}
+
+func (req MetadataSearchQuery) GetValue() interface{} {
+ return req.Value
+}
+
+func (key MetadataSearchKey) ToColumnName() string {
+ switch domain.MetadataSearchKey(key) {
+ case domain.MetadataSearchKeyAggregateID:
+ return MetadataKeyAggregateID
+ case domain.MetadataSearchKeyResourceOwner:
+ return MetadataKeyResourceOwner
+ case domain.MetadataSearchKeyKey:
+ return MetadataKeyKey
+ case domain.MetadataSearchKeyValue:
+ return MetadataKeyValue
+ default:
+ return ""
+ }
+}
diff --git a/internal/management/repository/eventsourcing/eventstore/user.go b/internal/management/repository/eventsourcing/eventstore/user.go
index df31d4d555..4a26830f6a 100644
--- a/internal/management/repository/eventsourcing/eventstore/user.go
+++ b/internal/management/repository/eventsourcing/eventstore/user.go
@@ -9,6 +9,7 @@ import (
"github.com/caos/zitadel/internal/domain"
v1 "github.com/caos/zitadel/internal/eventstore/v1"
"github.com/caos/zitadel/internal/eventstore/v1/models"
+ iam_model "github.com/caos/zitadel/internal/iam/repository/view/model"
usr_view "github.com/caos/zitadel/internal/user/repository/view"
"github.com/caos/logging"
@@ -128,6 +129,40 @@ func (repo *UserRepo) IsUserUnique(ctx context.Context, userName, email string)
return repo.View.IsUserUnique(userName, email)
}
+func (repo *UserRepo) GetMetadataByKey(ctx context.Context, userID, resourceOwner, key string) (*domain.Metadata, error) {
+ data, err := repo.View.MetadataByKeyAndResourceOwner(userID, resourceOwner, key)
+ if err != nil {
+ return nil, err
+ }
+ return iam_model.MetadataViewToDomain(data), nil
+}
+
+func (repo *UserRepo) SearchMetadata(ctx context.Context, userID, resourceOwner string, req *domain.MetadataSearchRequest) (*domain.MetadataSearchResponse, error) {
+ err := req.EnsureLimit(repo.SearchLimit)
+ if err != nil {
+ return nil, err
+ }
+ sequence, sequenceErr := repo.View.GetLatestUserSequence()
+ logging.Log("EVENT-m0ds3").OnError(sequenceErr).Warn("could not read latest user sequence")
+ req.AppendAggregateIDQuery(userID)
+ req.AppendResourceOwnerQuery(resourceOwner)
+ metadata, count, err := repo.View.SearchMetadata(req)
+ if err != nil {
+ return nil, err
+ }
+ result := &domain.MetadataSearchResponse{
+ Offset: req.Offset,
+ Limit: req.Limit,
+ TotalResult: count,
+ Result: iam_model.MetadataViewsToDomain(metadata),
+ }
+ if sequenceErr == nil {
+ result.Sequence = sequence.CurrentSequence
+ result.Timestamp = sequence.LastSuccessfulSpoolerRun
+ }
+ return result, nil
+}
+
func (repo *UserRepo) UserMFAs(ctx context.Context, userID string) ([]*usr_model.MultiFactor, error) {
user, err := repo.UserByID(ctx, userID)
if err != nil {
diff --git a/internal/management/repository/eventsourcing/handler/handler.go b/internal/management/repository/eventsourcing/handler/handler.go
index f9cfa299e0..df2b245a16 100644
--- a/internal/management/repository/eventsourcing/handler/handler.go
+++ b/internal/management/repository/eventsourcing/handler/handler.go
@@ -85,6 +85,8 @@ func Register(configs Configs, bulkLimit, errorCount uint64, view *view.View, es
handler{view, bulkLimit, configs.cycleDuration("PrivacyPolicy"), errorCount, es}),
newCustomText(
handler{view, bulkLimit, configs.cycleDuration("CustomText"), errorCount, es}),
+ newMetadata(
+ handler{view, bulkLimit, configs.cycleDuration("Metadata"), errorCount, es}),
}
}
diff --git a/internal/management/repository/eventsourcing/handler/metadata.go b/internal/management/repository/eventsourcing/handler/metadata.go
new file mode 100644
index 0000000000..1d2b7f86b5
--- /dev/null
+++ b/internal/management/repository/eventsourcing/handler/metadata.go
@@ -0,0 +1,124 @@
+package handler
+
+import (
+ "github.com/caos/logging"
+
+ caos_errs "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore/v1"
+ es_models "github.com/caos/zitadel/internal/eventstore/v1/models"
+ "github.com/caos/zitadel/internal/eventstore/v1/query"
+ "github.com/caos/zitadel/internal/eventstore/v1/spooler"
+ iam_model "github.com/caos/zitadel/internal/iam/repository/view/model"
+ usr_model "github.com/caos/zitadel/internal/user/repository/eventsourcing/model"
+)
+
+type Metadata struct {
+ handler
+ subscription *v1.Subscription
+}
+
+func newMetadata(handler handler) *Metadata {
+ h := &Metadata{
+ handler: handler,
+ }
+
+ h.subscribe()
+
+ return h
+}
+
+func (m *Metadata) subscribe() {
+ m.subscription = m.es.Subscribe(m.AggregateTypes()...)
+ go func() {
+ for event := range m.subscription.Events {
+ query.ReduceEvent(m, event)
+ }
+ }()
+}
+
+const (
+ metadataTable = "management.metadata"
+)
+
+func (m *Metadata) ViewModel() string {
+ return metadataTable
+}
+
+func (m *Metadata) Subscription() *v1.Subscription {
+ return m.subscription
+}
+
+func (_ *Metadata) AggregateTypes() []es_models.AggregateType {
+ return []es_models.AggregateType{usr_model.UserAggregate}
+}
+
+func (p *Metadata) CurrentSequence() (uint64, error) {
+ sequence, err := p.view.GetLatestMetadataSequence()
+ if err != nil {
+ return 0, err
+ }
+ return sequence.CurrentSequence, nil
+}
+
+func (m *Metadata) EventQuery() (*es_models.SearchQuery, error) {
+ sequence, err := m.view.GetLatestMetadataSequence()
+ if err != nil {
+ return nil, err
+ }
+ return es_models.NewSearchQuery().
+ AggregateTypeFilter(m.AggregateTypes()...).
+ LatestSequenceFilter(sequence.CurrentSequence), nil
+}
+
+func (m *Metadata) Reduce(event *es_models.Event) (err error) {
+ switch event.AggregateType {
+ case usr_model.UserAggregate:
+ err = m.processMetadata(event)
+ }
+ return err
+}
+
+func (m *Metadata) processMetadata(event *es_models.Event) (err error) {
+ metadata := new(iam_model.MetadataView)
+ switch event.Type {
+ case usr_model.UserMetadataSet:
+ err = metadata.SetData(event)
+ if err != nil {
+ return err
+ }
+ metadata, err = m.view.MetadataByKey(event.AggregateID, metadata.Key)
+ if err != nil && !caos_errs.IsNotFound(err) {
+ return err
+ }
+ if caos_errs.IsNotFound(err) {
+ err = nil
+ metadata = new(iam_model.MetadataView)
+ metadata.CreationDate = event.CreationDate
+ }
+ err = metadata.AppendEvent(event)
+ case usr_model.UserMetadataRemoved:
+ data := new(iam_model.MetadataView)
+ err = data.SetData(event)
+ if err != nil {
+ return err
+ }
+ return m.view.DeleteMetadata(event.AggregateID, data.Key, event)
+ case usr_model.UserRemoved:
+ return m.view.DeleteMetadataByAggregateID(event.AggregateID, event)
+ default:
+ return m.view.ProcessedMetadataSequence(event)
+ }
+ if err != nil {
+ return err
+ }
+ return m.view.PutMetadata(metadata, event)
+}
+
+func (m *Metadata) OnError(event *es_models.Event, err error) error {
+ logging.LogWithFields("SPOOL-3m912", "id", event.AggregateID).WithError(err).Warn("something went wrong in custom text handler")
+ return spooler.HandleError(event, err, m.view.GetLatestMetadataFailedEvent, m.view.ProcessedMetadataFailedEvent, m.view.ProcessedMetadataSequence, m.errorCountUntilSkip)
+}
+
+func (o *Metadata) OnSuccess() error {
+ return spooler.HandleSuccess(o.view.UpdateMetadataSpoolerRunTimestamp)
+}
diff --git a/internal/management/repository/eventsourcing/view/metadata.go b/internal/management/repository/eventsourcing/view/metadata.go
new file mode 100644
index 0000000000..3fd5b9d5f5
--- /dev/null
+++ b/internal/management/repository/eventsourcing/view/metadata.go
@@ -0,0 +1,73 @@
+package view
+
+import (
+ "github.com/caos/zitadel/internal/domain"
+ "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore/v1/models"
+ "github.com/caos/zitadel/internal/iam/repository/view"
+ "github.com/caos/zitadel/internal/iam/repository/view/model"
+ global_view "github.com/caos/zitadel/internal/view/repository"
+)
+
+const (
+ metadataTable = "management.metadata"
+)
+
+func (v *View) MetadataByKey(aggregateID, key string) (*model.MetadataView, error) {
+ return view.MetadataByKey(v.Db, metadataTable, aggregateID, key)
+}
+
+func (v *View) MetadataByKeyAndResourceOwner(aggregateID, resourceOwner, key string) (*model.MetadataView, error) {
+ return view.MetadataByKeyAndResourceOwner(v.Db, metadataTable, aggregateID, resourceOwner, key)
+}
+
+func (v *View) MetadataListByAggregateID(aggregateID string) ([]*model.MetadataView, error) {
+ return view.GetMetadataList(v.Db, metadataTable, aggregateID)
+}
+
+func (v *View) SearchMetadata(request *domain.MetadataSearchRequest) ([]*model.MetadataView, uint64, error) {
+ return view.SearchMetadata(v.Db, metadataTable, request)
+}
+
+func (v *View) PutMetadata(template *model.MetadataView, event *models.Event) error {
+ err := view.PutMetadata(v.Db, metadataTable, template)
+ if err != nil {
+ return err
+ }
+ return v.ProcessedMetadataSequence(event)
+}
+
+func (v *View) DeleteMetadata(aggregateID, key string, event *models.Event) error {
+ err := view.DeleteMetadata(v.Db, metadataTable, aggregateID, key)
+ if err != nil && !errors.IsNotFound(err) {
+ return err
+ }
+ return v.ProcessedMetadataSequence(event)
+}
+
+func (v *View) DeleteMetadataByAggregateID(aggregateID string, event *models.Event) error {
+ err := view.DeleteMetadataByAggregateID(v.Db, metadataTable, aggregateID)
+ if err != nil && !errors.IsNotFound(err) {
+ return err
+ }
+ return v.ProcessedMetadataSequence(event)
+}
+func (v *View) GetLatestMetadataSequence() (*global_view.CurrentSequence, error) {
+ return v.latestSequence(metadataTable)
+}
+
+func (v *View) ProcessedMetadataSequence(event *models.Event) error {
+ return v.saveCurrentSequence(metadataTable, event)
+}
+
+func (v *View) UpdateMetadataSpoolerRunTimestamp() error {
+ return v.updateSpoolerRunSequence(metadataTable)
+}
+
+func (v *View) GetLatestMetadataFailedEvent(sequence uint64) (*global_view.FailedEvent, error) {
+ return v.latestFailedEvent(metadataTable, sequence)
+}
+
+func (v *View) ProcessedMetadataFailedEvent(failedEvent *global_view.FailedEvent) error {
+ return v.saveFailedEvent(failedEvent)
+}
diff --git a/internal/management/repository/user.go b/internal/management/repository/user.go
index b6bf9e832a..23edab42c1 100644
--- a/internal/management/repository/user.go
+++ b/internal/management/repository/user.go
@@ -4,6 +4,7 @@ import (
"context"
"time"
+ "github.com/caos/zitadel/internal/domain"
key_model "github.com/caos/zitadel/internal/key/model"
"github.com/caos/zitadel/internal/user/model"
)
@@ -16,6 +17,9 @@ type UserRepository interface {
GetUserByLoginNameGlobal(ctx context.Context, email string) (*model.UserView, error)
IsUserUnique(ctx context.Context, userName, email string) (bool, error)
+ GetMetadataByKey(ctx context.Context, userID, resourceOwner, key string) (*domain.Metadata, error)
+ SearchMetadata(ctx context.Context, userID, resourceOwner string, req *domain.MetadataSearchRequest) (*domain.MetadataSearchResponse, error)
+
UserChanges(ctx context.Context, id string, lastSequence uint64, limit uint64, sortAscending bool, retention time.Duration) (*model.UserChanges, error)
ProfileByID(ctx context.Context, userID string) (*model.Profile, error)
diff --git a/internal/repository/metadata/metadata.go b/internal/repository/metadata/metadata.go
new file mode 100644
index 0000000000..3cb8c8d3a9
--- /dev/null
+++ b/internal/repository/metadata/metadata.go
@@ -0,0 +1,92 @@
+package metadata
+
+import (
+ "encoding/json"
+
+ "github.com/caos/zitadel/internal/errors"
+ "github.com/caos/zitadel/internal/eventstore"
+ "github.com/caos/zitadel/internal/eventstore/repository"
+)
+
+const (
+ SetEventType = "metadata.set"
+ RemovedEventType = "metadata.removed"
+)
+
+type SetEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ Key string `json:"key"`
+ Value []byte `json:"value"`
+}
+
+func (e *SetEvent) Data() interface{} {
+ return e
+}
+
+func (e *SetEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func NewSetEvent(
+ base *eventstore.BaseEvent,
+ key string,
+ value []byte,
+) *SetEvent {
+ return &SetEvent{
+ BaseEvent: *base,
+ Key: key,
+ Value: value,
+ }
+}
+
+func SetEventMapper(event *repository.Event) (eventstore.EventReader, error) {
+ e := &SetEvent{
+ BaseEvent: *eventstore.BaseEventFromRepo(event),
+ }
+
+ err := json.Unmarshal(event.Data, e)
+ if err != nil {
+ return nil, errors.ThrowInternal(err, "META-3n9fs", "unable to unmarshal metadata set")
+ }
+
+ return e, nil
+}
+
+type RemovedEvent struct {
+ eventstore.BaseEvent `json:"-"`
+
+ Key string `json:"key"`
+}
+
+func (e *RemovedEvent) Data() interface{} {
+ return e
+}
+
+func (e *RemovedEvent) UniqueConstraints() []*eventstore.EventUniqueConstraint {
+ return nil
+}
+
+func NewRemovedEvent(
+ base *eventstore.BaseEvent,
+ key string,
+) *RemovedEvent {
+
+ return &RemovedEvent{
+ BaseEvent: *base,
+ Key: key,
+ }
+}
+
+func RemovedEventMapper(event *repository.Event) (eventstore.EventReader, error) {
+ e := &RemovedEvent{
+ BaseEvent: *eventstore.BaseEventFromRepo(event),
+ }
+
+ err := json.Unmarshal(event.Data, e)
+ if err != nil {
+ return nil, errors.ThrowInternal(err, "META-2m99f", "unable to unmarshal metadata removed")
+ }
+
+ return e, nil
+}
diff --git a/internal/repository/user/eventstore.go b/internal/repository/user/eventstore.go
index 61b8b42287..fe82878095 100644
--- a/internal/repository/user/eventstore.go
+++ b/internal/repository/user/eventstore.go
@@ -45,6 +45,8 @@ func RegisterEventMappers(es *eventstore.Eventstore) {
RegisterFilterEventMapper(UserDomainClaimedType, DomainClaimedEventMapper).
RegisterFilterEventMapper(UserDomainClaimedSentType, DomainClaimedSentEventMapper).
RegisterFilterEventMapper(UserUserNameChangedType, UsernameChangedEventMapper).
+ RegisterFilterEventMapper(MetadataSetType, MetadataSetEventMapper).
+ RegisterFilterEventMapper(MetadataRemovedType, MetadataRemovedEventMapper).
RegisterFilterEventMapper(HumanAddedType, HumanAddedEventMapper).
RegisterFilterEventMapper(HumanRegisteredType, HumanRegisteredEventMapper).
RegisterFilterEventMapper(HumanInitialCodeAddedType, HumanInitialCodeAddedEventMapper).
diff --git a/internal/repository/user/metadata.go b/internal/repository/user/metadata.go
new file mode 100644
index 0000000000..337dbe03e3
--- /dev/null
+++ b/internal/repository/user/metadata.go
@@ -0,0 +1,63 @@
+package user
+
+import (
+ "context"
+
+ "github.com/caos/zitadel/internal/eventstore"
+ "github.com/caos/zitadel/internal/eventstore/repository"
+ "github.com/caos/zitadel/internal/repository/metadata"
+)
+
+const (
+ MetadataSetType = userEventTypePrefix + metadata.SetEventType
+ MetadataRemovedType = userEventTypePrefix + metadata.RemovedEventType
+)
+
+type MetadataSetEvent struct {
+ metadata.SetEvent
+}
+
+func NewMetadataSetEvent(ctx context.Context, aggregate *eventstore.Aggregate, key string, value []byte) *MetadataSetEvent {
+ return &MetadataSetEvent{
+ SetEvent: *metadata.NewSetEvent(
+ eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ MetadataSetType),
+ key,
+ value),
+ }
+}
+
+func MetadataSetEventMapper(event *repository.Event) (eventstore.EventReader, error) {
+ e, err := metadata.SetEventMapper(event)
+ if err != nil {
+ return nil, err
+ }
+
+ return &MetadataSetEvent{SetEvent: *e.(*metadata.SetEvent)}, nil
+}
+
+type MetadataRemovedEvent struct {
+ metadata.RemovedEvent
+}
+
+func NewMetadataRemovedEvent(ctx context.Context, aggregate *eventstore.Aggregate, key string) *MetadataRemovedEvent {
+ return &MetadataRemovedEvent{
+ RemovedEvent: *metadata.NewRemovedEvent(
+ eventstore.NewBaseEventForPush(
+ ctx,
+ aggregate,
+ MetadataRemovedType),
+ key),
+ }
+}
+
+func MetadataRemovedEventMapper(event *repository.Event) (eventstore.EventReader, error) {
+ e, err := metadata.RemovedEventMapper(event)
+ if err != nil {
+ return nil, err
+ }
+
+ return &MetadataRemovedEvent{RemovedEvent: *e.(*metadata.RemovedEvent)}, nil
+}
diff --git a/internal/static/i18n/de.yaml b/internal/static/i18n/de.yaml
index 9ff8ff4f8d..b322680a30 100644
--- a/internal/static/i18n/de.yaml
+++ b/internal/static/i18n/de.yaml
@@ -359,6 +359,11 @@ Errors:
ReadError: Übersetzungsdatei konnte nicht gelesen werden
MergeError: Übersetzungsdatei konnte nicht mit benutzerdefinierten Übersetzungen zusammengeführt werden
NotFound: Übersetzungsdatei existiert nicht
+ MetaData:
+ NotFound: Meta Daten konnten nicht gefunden werden
+ NoData: Meta Daten Liste ist leer
+ Invalid: Meta Daten sind ungültig
+ KeyNotExisting: Ein oder mehrere Keys existiert nicht
EventTypes:
user:
added: Benutzer hinzugefügt
diff --git a/internal/static/i18n/en.yaml b/internal/static/i18n/en.yaml
index 839ad9ad8d..20f853de95 100644
--- a/internal/static/i18n/en.yaml
+++ b/internal/static/i18n/en.yaml
@@ -359,6 +359,11 @@ Errors:
ReadError: Error in reading translation file
MergeError: Translation file could not be merged with custom translations
NotFound: Translation file doesn't exist
+ MetaData:
+ NotFound: Metadata not found
+ NoData: Metadata list is empty
+ Invalid: Metadata is invalid
+ KeyNotExisting: One or more keys do not exist
EventTypes:
user:
added: User added
diff --git a/internal/user/repository/eventsourcing/model/types.go b/internal/user/repository/eventsourcing/model/types.go
index 0c4df1700b..bb171f351b 100644
--- a/internal/user/repository/eventsourcing/model/types.go
+++ b/internal/user/repository/eventsourcing/model/types.go
@@ -69,6 +69,9 @@ const (
DomainClaimed models.EventType = "user.domain.claimed"
DomainClaimedSent models.EventType = "user.domain.claimed.sent"
+
+ UserMetadataSet models.EventType = "user.metadata.set"
+ UserMetadataRemoved models.EventType = "user.metadata.removed"
)
// the following consts are for user(v2).human
diff --git a/migrations/cockroach/V1.58__metadata.sql b/migrations/cockroach/V1.58__metadata.sql
new file mode 100644
index 0000000000..beed6852ce
--- /dev/null
+++ b/migrations/cockroach/V1.58__metadata.sql
@@ -0,0 +1,27 @@
+CREATE TABLE auth.metadata (
+ aggregate_id TEXT,
+
+ key TEXT,
+ value BYTES,
+
+ resource_owner TEXT,
+ creation_date TIMESTAMPTZ,
+ change_date TIMESTAMPTZ,
+ sequence BIGINT,
+
+ PRIMARY KEY (aggregate_id, key)
+);
+
+CREATE TABLE management.metadata (
+ aggregate_id TEXT,
+
+ key TEXT,
+ value BYTES,
+
+ resource_owner TEXT,
+ creation_date TIMESTAMPTZ,
+ change_date TIMESTAMPTZ,
+ sequence BIGINT,
+
+ PRIMARY KEY (aggregate_id, key)
+);
diff --git a/proto/zitadel/auth.proto b/proto/zitadel/auth.proto
index 725d8d56ad..e40a3edfb0 100644
--- a/proto/zitadel/auth.proto
+++ b/proto/zitadel/auth.proto
@@ -7,6 +7,7 @@ import "zitadel/object.proto";
import "zitadel/options.proto";
import "zitadel/policy.proto";
import "zitadel/idp.proto";
+import "zitadel/metadata.proto";
import "validate/validate.proto";
import "google/api/annotations.proto";
import "google/protobuf/duration.proto";
@@ -76,6 +77,7 @@ service AuthService {
rpc ListMyUserChanges(ListMyUserChangesRequest) returns (ListMyUserChangesResponse) {
option (google.api.http) = {
post: "/users/me/changes/_search"
+ body: "*"
};
option (zitadel.v1.auth_option) = {
@@ -87,6 +89,77 @@ service AuthService {
rpc ListMyUserSessions(ListMyUserSessionsRequest) returns (ListMyUserSessionsResponse) {
option (google.api.http) = {
post: "/users/me/sessions/_search"
+ body: "*"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "authenticated"
+ };
+ }
+
+ // Sets a user metadata by key to the authorized user
+ rpc SetMyMetadata(SetMyMetadataRequest) returns (SetMyMetadataResponse) {
+ option (google.api.http) = {
+ post: "/users/me/metadata/{key}"
+ body: "*"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "authenticated"
+ };
+ }
+
+ // Set a list of user metadata to the authorized user
+ rpc BulkSetMyMetadata(BulkSetMyMetadataRequest) returns (BulkSetMyMetadataResponse) {
+ option (google.api.http) = {
+ post: "/users/me/metadata/_bulk"
+ body: "*"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "authenticated"
+ };
+ }
+
+ // Returns the user metadata of the authorized user
+ rpc ListMyMetadata(ListMyMetadataRequest) returns (ListMyMetadataResponse) {
+ option (google.api.http) = {
+ post: "/users/me/metadata/_search"
+ body: "*"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "authenticated"
+ };
+ }
+
+ // Returns the user metadata by key of the authorized user
+ rpc GetMyMetadata(GetMyMetadataRequest) returns (GetMyMetadataResponse) {
+ option (google.api.http) = {
+ get: "/users/me/metadata/{key}"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "authenticated"
+ };
+ }
+
+ // Removes a user metadata by key to the authorized user
+ rpc RemoveMyMetadata(RemoveMyMetadataRequest) returns (RemoveMyMetadataResponse) {
+ option (google.api.http) = {
+ delete: "/users/me/metadata/{key}"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "authenticated"
+ };
+ }
+
+ // Set a list of user metadata to the authorized user
+ rpc BulkRemoveMyMetadata(BulkRemoveMyMetadataRequest) returns (BulkRemoveMyMetadataResponse) {
+ option (google.api.http) = {
+ delete: "/users/me/metadata/_bulk"
+ body: "*"
};
option (zitadel.v1.auth_option) = {
@@ -98,6 +171,7 @@ service AuthService {
rpc ListMyRefreshTokens(ListMyRefreshTokensRequest) returns (ListMyRefreshTokensResponse) {
option (google.api.http) = {
post: "/users/me/tokens/refresh/_search"
+ body: "*"
};
option (zitadel.v1.auth_option) = {
@@ -120,6 +194,7 @@ service AuthService {
rpc RevokeAllMyRefreshTokens(RevokeAllMyRefreshTokensRequest) returns (RevokeAllMyRefreshTokensResponse) {
option (google.api.http) = {
post: "/users/me/tokens/refresh/_revoke_all"
+ body: "*"
};
option (zitadel.v1.auth_option) = {
@@ -592,6 +667,61 @@ message ListMyUserSessionsResponse {
repeated zitadel.user.v1.Session result = 1;
}
+message ListMyMetadataRequest {
+ zitadel.v1.ListQuery query = 1;
+ repeated zitadel.metadata.v1.MetadataQuery queries = 2;
+}
+
+message ListMyMetadataResponse {
+ zitadel.v1.ListDetails details = 1;
+ repeated zitadel.metadata.v1.Metadata result = 2;
+}
+
+message GetMyMetadataRequest {
+ string key = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
+}
+
+message GetMyMetadataResponse {
+ zitadel.metadata.v1.Metadata metadata = 1;
+}
+
+message SetMyMetadataRequest {
+ string key = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
+ bytes value = 2 [(validate.rules).bytes = {min_len: 1, max_len: 500000}];
+}
+
+message SetMyMetadataResponse {
+ zitadel.v1.ObjectDetails details = 1;
+}
+
+message BulkSetMyMetadataRequest {
+ message Metadata {
+ string key = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
+ bytes value = 2 [(validate.rules).bytes = {min_len: 1, max_len: 500000}];
+ }
+ repeated Metadata metadata = 1;
+}
+
+message BulkSetMyMetadataResponse {
+ zitadel.v1.ObjectDetails details = 1;
+}
+
+message RemoveMyMetadataRequest {
+ string key = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
+}
+
+message RemoveMyMetadataResponse {
+ zitadel.v1.ObjectDetails details = 1;
+}
+
+message BulkRemoveMyMetadataRequest {
+ repeated string keys = 1 [(validate.rules).repeated.items.string = {min_len: 1, max_len: 200}];
+}
+
+message BulkRemoveMyMetadataResponse {
+ zitadel.v1.ObjectDetails details = 1;
+}
+
//This is an empty request
message ListMyRefreshTokensRequest {}
diff --git a/proto/zitadel/management.proto b/proto/zitadel/management.proto
index b57275fa99..036497f705 100644
--- a/proto/zitadel/management.proto
+++ b/proto/zitadel/management.proto
@@ -14,6 +14,7 @@ import "zitadel/message.proto";
import "zitadel/change.proto";
import "zitadel/auth_n_key.proto";
import "zitadel/features.proto";
+import "zitadel/metadata.proto";
import "google/api/annotations.proto";
import "google/protobuf/timestamp.proto";
@@ -285,6 +286,82 @@ service ManagementService {
};
}
+ // Sets a user metadata by key
+ rpc SetUserMetadata(SetUserMetadataRequest) returns (SetUserMetadataResponse) {
+ option (google.api.http) = {
+ post: "/users/{id}/metadata/{key}"
+ body: "*"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "user.write"
+ feature: "metadata.user"
+ };
+ }
+
+ // Set a list of user metadata
+ rpc BulkSetUserMetadata(BulkSetUserMetadataRequest) returns (BulkSetUserMetadataResponse) {
+ option (google.api.http) = {
+ post: "/users/{id}/metadata/_bulk"
+ body: "*"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "user.write"
+ feature: "metadata.user"
+ };
+ }
+
+ // Returns the user metadata
+ rpc ListUserMetadata(ListUserMetadataRequest) returns (ListUserMetadataResponse) {
+ option (google.api.http) = {
+ post: "/users/{id}/metadata/_search"
+ body: "*"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "user.read"
+ feature: "metadata.user"
+ };
+ }
+
+ // Returns the user metadata by key
+ rpc GetUserMetadata(GetUserMetadataRequest) returns (GetUserMetadataResponse) {
+ option (google.api.http) = {
+ get: "/users/{id}/metadata/{key}"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "user.read"
+ feature: "metadata.user"
+ };
+ }
+
+ // Removes a user metadata by key
+ rpc RemoveUserMetadata(RemoveUserMetadataRequest) returns (RemoveUserMetadataResponse) {
+ option (google.api.http) = {
+ delete: "/users/{id}/metadata/{key}"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "user.write"
+ feature: "metadata.user"
+ };
+ }
+
+ // Set a list of user metadata
+ rpc BulkRemoveUserMetadata(BulkRemoveUserMetadataRequest) returns (BulkRemoveUserMetadataResponse) {
+ option (google.api.http) = {
+ delete: "/users/{id}/metadata/_bulk"
+ body: "*"
+ };
+
+ option (zitadel.v1.auth_option) = {
+ permission: "user.write"
+ feature: "metadata.user"
+ };
+ }
+
// Returns the profile of the human
rpc GetHumanProfile(GetHumanProfileRequest) returns (GetHumanProfileResponse) {
option (google.api.http) = {
@@ -2827,6 +2904,68 @@ message UpdateUserNameResponse {
zitadel.v1.ObjectDetails details = 1;
}
+message ListUserMetadataRequest {
+ string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
+ zitadel.v1.ListQuery query = 2;
+ repeated zitadel.metadata.v1.MetadataQuery queries = 3;
+}
+
+message ListUserMetadataResponse {
+ zitadel.v1.ListDetails details = 1;
+ repeated zitadel.metadata.v1.Metadata result = 2;
+}
+
+message GetUserMetadataRequest {
+ string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
+ string key = 2 [(validate.rules).string = {min_len: 1, max_len: 200}];
+}
+
+message GetUserMetadataResponse {
+ zitadel.metadata.v1.Metadata metadata = 1;
+}
+
+message SetUserMetadataRequest {
+ string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
+ string key = 2 [(validate.rules).string = {min_len: 1, max_len: 200}];
+ bytes value = 3 [(validate.rules).bytes = {min_len: 1, max_len: 500000}];
+}
+
+message SetUserMetadataResponse {
+ string id = 1;
+ zitadel.v1.ObjectDetails details = 2;
+}
+
+message BulkSetUserMetadataRequest {
+ string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
+ message Metadata {
+ string key = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
+ bytes value = 2 [(validate.rules).bytes = {min_len: 1, max_len: 500000}];
+ }
+ repeated Metadata metadata = 2;
+}
+
+message BulkSetUserMetadataResponse {
+ zitadel.v1.ObjectDetails details = 1;
+}
+
+message RemoveUserMetadataRequest {
+ string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
+ string key = 2 [(validate.rules).string = {min_len: 1, max_len: 200}];
+}
+
+message RemoveUserMetadataResponse {
+ zitadel.v1.ObjectDetails details = 1;
+}
+
+message BulkRemoveUserMetadataRequest {
+ string id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
+ repeated string keys = 2 [(validate.rules).repeated.items.string = {min_len: 1, max_len: 200}];
+}
+
+message BulkRemoveUserMetadataResponse {
+ zitadel.v1.ObjectDetails details = 1;
+}
+
message GetHumanProfileRequest {
string user_id = 1 [(validate.rules).string = {min_len: 1, max_len: 200}];
}
diff --git a/proto/zitadel/metadata.proto b/proto/zitadel/metadata.proto
new file mode 100644
index 0000000000..f3459220c6
--- /dev/null
+++ b/proto/zitadel/metadata.proto
@@ -0,0 +1,45 @@
+syntax = "proto3";
+
+import "zitadel/object.proto";
+import "protoc-gen-openapiv2/options/annotations.proto";
+import "validate/validate.proto";
+
+package zitadel.metadata.v1;
+
+option go_package ="github.com/caos/zitadel/pkg/grpc/metadata";
+
+message Metadata {
+ zitadel.v1.ObjectDetails details = 1;
+ string key = 2 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "metadata key"
+ }
+ ];
+ bytes value = 3 [
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "metadata value"
+ }
+ ];
+}
+
+message MetadataQuery {
+ oneof query {
+ option (validate.required) = true;
+ MetadataKeyQuery key_query = 1;
+ }
+}
+
+message MetadataKeyQuery {
+ string key = 1 [
+ (validate.rules).string = {max_len: 200},
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ example: "\"key\""
+ }
+ ];
+ zitadel.v1.TextQueryMethod method = 2 [
+ (validate.rules).enum.defined_only = true,
+ (grpc.gateway.protoc_gen_openapiv2.options.openapiv2_field) = {
+ description: "defines which text equality method is used";
+ }
+ ];
+}