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"; + } + ]; +}