diff --git a/.envrc b/.envrc index c96c4e427df..6edee4dad4b 100644 --- a/.envrc +++ b/.envrc @@ -50,4 +50,4 @@ export INTEGRATION_DYNAMIC_BACKENDS_POOLSIZE=3 # Keep these in sync with deploy/dockerephmeral/init.sh export AWS_REGION="eu-west-1" export AWS_ACCESS_KEY_ID="dummykey" -export AWS_SECRET_ACCESS_KEY="dummysecret" \ No newline at end of file +export AWS_SECRET_ACCESS_KEY="dummysecret" diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bedcc42ccf..62637fa9742 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,293 @@ +# [2023-10-23] (Chart Release 4.39.0) + +## Release notes + + +* New field for Supported protocols in Galley's MLS feature config + + Galley will refuse to start if the list `supportedProtocols` does not contain + the value of the field `defaultProtocol`. Galley will also refuse to start if + MLS migration is enabled and MLS is not part of `supportedProtocols`. + + The default value for `supportedProtocols` is: + ``` + [proteus, mls] + ``` (#3374) + + +## API changes + + +* The JSON schema of `NonConnectedBackends` has changed to have its single field now called `non_connected_backends`. (#3518) + +* Remove de-federation (to avoid a scalability issue). (#3582) + +* Replace the placeholder self conversation id with the qualified conversation id for welcome events. (#3335) + +* Add new endpoint `DELETE /mls/key-packages/self/:client` (#3295) + +* Introduce an endpoint for deleting a subconversation (#2956, #3119, #3123) + +* Remove MLS endpoints from API v4 and finalise it (#3545) + +* Add new endpoint `GET /conversations/one2one/:domain/:uid` to fetch the MLS 1-1 conversation with another user (#3345) + +* Introduce a subconversation GET endpoint (#2869, #2995) + +* Add `GET /conversations/:domain/:cid/subconversations/:id/groupinfo` endpoint to fetch the group info object for a subconversation (#2932) + +* Introduce v5 development version (#3527) + +* It is now possible to use `PUT /conversation/:domain/:id/protocol` to transition from Mixed to MLS (#3334) + +* Report a failure to add remote users to an MLS conversation (#3304) + +* The key package API has gained a `ciphersuite` query parameter, which should be the hexadecimal value of an MLS ciphersuite, defaulting to `0x0001`. The `ciphersuite` parameter is used by the claim and count endpoints. For uploads, the API is unchanged, and the ciphersuite is taken directly from the uploaded key package. (#3454) + +* Add MLS migration feature config (#3299) + +* Switch to MLS draft 20. The following endpoints are affected by the change: + + - All endpoints with `message/mls` content type now expect and return draft-20 MLS structures. + - `POST /conversations` does not require `creator_client` anymore. + - `POST /mls/commit-bundles` now expects a "stream" of MLS messages, i.e. a sequence of TLS-serialised messages, one after the other, in any order. Its protobuf interface has been removed. + - `POST /mls/welcome` has been removed. Welcome messages can now only be sent as part of a commit bundle. + - `POST /mls/message` does not accept commit messages anymore. All commit messages must be sent as part of a commit bundle. (#3172) + +* Key packages and leaf nodes with x509 credentials are now supported (#3532) + + +## Features + + +* Add reason field to conversation.member-leave (#3640) + +* Support deleting a remote subconversation (#2964) + +* Introduce support for resetting a subconversation (#2956) + +* Introduce a "mixed" conversation protocol type. A conversation of "mixed" protocol functions as a Proteus converation as well as a MLS conversations. It's intended to be used for migrating conversations from Proteus to MLS. (#3258) + +* Added support for post-quantum ciphersuite 0xf031. Correspondingly, MLS groups with a non-default ciphersuite are now supported. The first commit in a group determines the group ciphersuite. (#3454) + +* Remove conversation size limit for MLS conversations (#3468) + +* Added support for MSL 1-1 conversations (#3360) + +* MLS application messages for older epochs are now rejected (#3438) + +* The public key in an x509 credential is now checked against that of the client (#3542) + +* Add federated endpoints to get subconversations (#2952) + +* Add Helm chart (`rabbitmq-external`) to interface RabbitMQ instances outside of the Kubernetes cluster. (#3626) + +* Removing or kicking a user from a conversation also removes the user's clients from any subconversation. (#2942) + +* Add support for subconversations in `POST /mls/commit-bundles` (#2932) + +* Implement endpoint for leaving a subconversation (#2969, #3080, #3085, #3107) + + +## Bug fixes and other updates + + +* Fix nix derivations for rust packages (#3628) + +* Ensure benchmarking dependencies are provided by nix development environment (#3628) + +* Disable a guest user from creating a group conversation (#3622) + +* Adding users to a conversation now enforces that all federation domains that will be in the conversation are federated with each other. (#3514) + +* Fix ES migration script. (#3558) + +* Fixed add user to conversation when one of the other participating backends is offline (#3585) + +* Create a new http2 connection in every federator client request instead of using a shared connection. (#3602) + +* list-clients returns with partial success even if one of the remote backends is unreachable (#3611) + +* Defederation notifications, federation.delete and federation.connectionRemoved, now deduplicate the user list so that we don't send them more notifications than required. (#3515) + +* Fix memory and TCP connection leak in brig, galley, caroghold and background-worker. (#3663) + +* Fix bug where notifications for MLS messages were not showing up in all notification streams of clients (#3610) + +* Map the MLS self-conversation creator's key package reference in Brig (#3055) + +* This fixes a bug where a remote member is removed from a conversation while their backend is unreachable, and the backend does not receive the removal notification once it is reachable again. (#3537) + +* Welcome messages are not sent anymore to the creator of an MLS group on the first commit (#3392) + + +## Documentation + + +* Fix: support api versions other than v0 in swagger docs. (#3619) + +* Updating the route documentation from Swagger 2 to OpenAPI 3. (#3570) + +* Elaborate on internal user creation in prod (#3596) + +* Adding a testing config entry to the PR guidelines. (#3624) + + +## Internal changes + + +* remove leaving clients immediately from subconversations (#3096) + +* Servantify internal end-points: brig/teams (#3634) + +* add conversation type to group ID serialisation (#3344) + +* Do not cache federation remote configs on non-brig services (#3612) + +* JSON derived schemas have been changed to no longer pre-process record fields to drop prefixes that were required to disambiguate fields. + Prefix processing still exists to drop leading underscores from field names, as we are using prefixed field names with `makeLenses`. + Code has been updated to use `OverloadedRecordDot` with the changed field names. (#3518) + +* Updating the route documentation library from swagger2 to openapi3. + + This also introduced a breaking change in how we track what federation calls each route makes. + The openapi3 library doesn't support extension fields, and as such tags are being used instead in a similar way. (#3570) + +* - Extending the information returned in errors for Federator. Paths and response bodies, if available, are included in error logs. + - Prometheus metrics for outgoing and incoming federation requests added. They can be enabled by setting `metrics.serviceMonitor.enabled`, like in other charts. (#3556) + +* CLI tool to consume messages from a RabbitMQ queue (#3589, #3655) + +* Removed user and client threshold fields from mls migration feature. (#3364) + +* Include timestamp in s3 upload path for test logs (#3621) + +* Migrating the following routes to the Servant API form. + + POST /provider/services + GET /provider/services + GET /provider/services/:sid + PUT /provider/services/:sid + PUT /provider/services/:sid/connection + DELETE /provider/services/:sid + GET /providers/:pid/services + GET /providers/:pid/services/:sid + GET /services + GET /services/tags + GET /teams/:tid/services/whitelisted + POST /teams/:tid/services/whitelist (#3554) + +* Provider API has been migrated to servant (#3547) + +* background-worker: Get list of domains from RabbitMQ instead of brig for pushing backend notifications (#3588) + +* Avoid including MLS application messages in the sender client's event stream. (#3379) + +* Avoid empty pushes when chunking pushes in galley (#PR_NOT_FOUND) + +* Introduce a Galley DB table for subconversations (#2869) + +* Support mapping MLS group IDs to subconversations (#2869) + +* change version and conversation type to 16 bit in group ID serialisation (#3353) + +* Brig does not perform key package ref mapping anymore. Claimed key packages are simply removed from the `mls_key_packages` table. The `mls_key_package_refs` table is now unused, and will be removed in the future. (#3172) + +* Add intermediate "mixed" protocol for migrating from Proteus to MLS (#3292) + +* - Do not perform client checks for add and remove proposals in mixed conversations + - Restrict protocol updates to team conversations + - Disallow MLS application messages in mixed conversations + - Send remove proposals when users leave mixed conversations (#3303) + +* New cron job to save data usable to watch the progress of the Proteus to MLS migration in S3 bucket. + + **IMPORTANT:** This cron job is _not_ meant for general use! It can leak data about one team to other teams. (#3579) + +* Subconversations are now created on their first commit (#3355) + +* Propagate messages in MLS subconversations (#2937) + +* Move some MLS tests to new integration suite (#3286) + +* Check validity of notification IDs in the notification API (#3550) + +* stern: Optimize RAM usage of /i/users/meta-info (#3522) + +* Additional integration test for federated connections (#3538) + +* The bot API is now migrated to servant (#3540) + +* `rusty-jwt-tools` is upgraded to version 0.5.0 (#3572) + +* Refactored schema version tracking from manually managed to automatic. (#3643) + +* Avoid unnecessary error logs on service shutdown (#3592) + +* Introduce an effect for subconversations (#2869) + +* Via the update path update the key package of the committer in epoch 0 of a subconversation (#2975) + +* Add more tests for joining a subconversation (#2974) + +* Added `/tools/db/repair-brig-clients-table` to clean up after the fix in #3504 (#3507) + +* Distinguish between update and upsert cassandra commands (follow-up to #3504) (#3513) + +* Truncate `galley.mls_group_member_client` table and drop `galley.member_client` table. + + The data in `mls_group_member_client` could contain nulls from client testing in prod. So, its OK to truncate it. + The `member_client` table is unused. (#3648) + +* All integration tests can generate XML reports. + + To generate the report in brig-integration, galley-integration, + cargohold-integration, gundeck-integration, stern-integration and the new + integration suite pass `--xml=` to generate the XML file. + + For spar-integration and federator-integration pass `-f junit` and set + `JUNIT_OUTPUT_DIRECTORY` and `JUNIT_SUITE_NAME` environment variables. The XML + report will be generated at `$JUNIT_OUTPUT_DIRECTORY/junit.xml`. + + (#3568, #3633) + + +## Federation changes + + +* Add subconversation ID to onMLSMessageSent request payload. (#3270) + +* Derive group ID from qualified conversation ID and, if applicable, + subconversation ID. + + Retire mapping from group IDs to conversation IDs. (group_id_conv_id) + + Remove federation endpoints + - on-new-remote-conversation, + - on-new-remote-subconversation, and + - on-delete-mls-conversation + which were used to synchronise the group to conversation mapping. (#3309) + +* Reorganise the federation API such that queueing notification endpoints are separate from synchronous endpoints. Also simplify queueing federation notification endpoints. (#3647) + +* Introduce an endpoint for resetting a remote subconversation (#2964) + +* Split federation endpoint into on-new-remote-conversation and on-new-remote-subconversation + Call on-new-remote-subconversation when a new subconversation is created + Call on-new-remote-subconversation for all existing subconversations when a new backend gets involved + Call on-new-remote-subconversation when a subconversation is reset (#2997) + +* federator: Allow setting TCP connection timeout for HTTP2 requests + + The helm chart defaults it to 5s which should be best for most installations. (#3595) + +* Constrain which federation endpoints can be used via the queueing federation client (#3629) + +* There is a breaking change in the "on-mls-message-sent" federation endpoint due to queueing. Now that there is retrying because of queueing, the endpoint can no longer respond with a list of unreachable users. (#3629) + +* Remote MLS messages get queued via RabbitMQ (#PR_NOT_FOUND) + + # [2023-08-16] (Chart Release 4.38.0) ## Bug fixes and other updates diff --git a/Makefile b/Makefile index 91a31e617e3..6fad98a6218 100644 --- a/Makefile +++ b/Makefile @@ -13,7 +13,7 @@ CHARTS_INTEGRATION := wire-server databases-ephemeral redis-cluster rabbitmq # (e.g. move charts/brig to charts/wire-server/brig) # this list could be generated from the folder names under ./charts/ like so: # CHARTS_RELEASE := $(shell find charts/ -maxdepth 1 -type d | xargs -n 1 basename | grep -v charts) -CHARTS_RELEASE := wire-server redis-ephemeral redis-cluster rabbitmq databases-ephemeral \ +CHARTS_RELEASE := wire-server redis-ephemeral redis-cluster rabbitmq rabbitmq-external databases-ephemeral \ fake-aws fake-aws-s3 fake-aws-sqs aws-ingress fluent-bit kibana backoffice \ calling-test demo-smtp elasticsearch-curator elasticsearch-external \ elasticsearch-ephemeral minio-external cassandra-external \ @@ -35,7 +35,7 @@ EXE_SCHEMA := ./dist/$(package)-schema # Additionally, if stack is being used with nix, environment variables do not # make it into the shell where hspec is run, to tackle that this variable is # also exported in stack-deps.nix. -export HSPEC_OPTIONS = --fail-on-focused +export HSPEC_OPTIONS ?= --fail-on-focused default: install @@ -67,7 +67,7 @@ clean-hint: @echo -e "\n\n\n>>> PSA: if you get errors that are hard to explain," @echo -e ">>> try 'git submodule update --init --recursive' and 'make full-clean' and run your command again." @echo -e ">>> see https://github.com/wireapp/wire-server/blob/develop/docs/developer/building.md#linker-errors-while-compiling" - @echo -e ">>> to never have to remember submodules again, try `git config --global submodule.recurse true`" + @echo -e ">>> to never have to remember submodules again, try 'git config --global submodule.recurse true'" @echo -e "\n\n\n" .PHONY: cabal.project.local diff --git a/README.md b/README.md index 159a7431297..ada3b2d2cc5 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Wire™ -[![Wire logo](https://github.com/wireapp/wire/blob/master/assets/header-small.png?raw=true)](https://wire.com/jobs/) +[![Wire logo](https://github.com/wireapp/wire/blob/master/assets/header-small.png?raw=true)](https://wire.bamboohr.com/careers) This repository is part of the source code of Wire. You can find more information at [wire.com](https://wire.com) or by contacting opensource@wire.com. diff --git a/cabal.project b/cabal.project index a6334c5db40..2ee9da138e7 100644 --- a/cabal.project +++ b/cabal.project @@ -1,3 +1,6 @@ +repository hackage.haskell.org + url: https://hackage.haskell.org/ +index-state: 2023-10-03T15:17:00Z packages: integration , libs/bilge/ @@ -40,16 +43,18 @@ packages: , services/spar/ , tools/db/assets/ , tools/db/auto-whitelist/ - , tools/db/billing-team-member-backfill/ , tools/db/find-undead/ , tools/db/inconsistencies/ , tools/db/migrate-sso-feature-flag/ , tools/db/move-team/ , tools/db/repair-handles/ + , tools/db/repair-brig-clients-table/ , tools/db/service-backfill/ , tools/fedcalls/ + , tools/rabbitmq-consumer , tools/rex/ , tools/stern/ + , tools/mlsstats/ tests: True benchmarks: True @@ -62,8 +67,6 @@ package background-worker ghc-options: -Werror package bilge ghc-options: -Werror -package billing-team-member-backfill - ghc-options: -Werror package brig ghc-options: -Werror package brig-types @@ -98,6 +101,8 @@ package hscim ghc-options: -Werror package http2-manager ghc-options: -Werror +package integration + ghc-options: -Werror package imports ghc-options: -Werror package jwt-tools @@ -114,6 +119,10 @@ package polysemy-wire-zoo ghc-options: -Werror package proxy ghc-options: -Werror +package mlsstats + ghc-options: -Werror +package rabbitmq-consumer + ghc-options: -Werror package repair-handles ghc-options: -Werror package rex diff --git a/cassandra-schema.cql b/cassandra-schema.cql index 6845c124f68..d300556af70 100644 --- a/cassandra-schema.cql +++ b/cassandra-schema.cql @@ -222,9 +222,10 @@ CREATE TABLE brig_test.user_cookies ( CREATE TABLE brig_test.mls_key_packages ( user uuid, client text, + cipher_suite int, ref blob, data blob, - PRIMARY KEY ((user, client), ref) + PRIMARY KEY ((user, client, cipher_suite), ref) ) WITH CLUSTERING ORDER BY (ref ASC) AND bloom_filter_fp_chance = 0.1 AND caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'} @@ -1184,8 +1185,13 @@ CREATE TABLE galley_test.team_features ( mls_e2eid_lock_status int, mls_e2eid_status int, mls_e2eid_ver_exp timestamp, + mls_migration_finalise_regardless_after timestamp, + mls_migration_lock_status int, + mls_migration_start_time timestamp, + mls_migration_status int, mls_protocol_toggle_users set, mls_status int, + mls_supported_protocols set, outlook_cal_integration_lock_status int, outlook_cal_integration_status int, search_visibility_inbound_status int, @@ -1399,7 +1405,8 @@ CREATE TABLE galley_test.legalhold_pending_prekeys ( CREATE TABLE galley_test.group_id_conv_id ( group_id blob PRIMARY KEY, conv_id uuid, - domain text + domain text, + subconv_id text ) WITH bloom_filter_fp_chance = 0.01 AND caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'} AND comment = '' @@ -1509,6 +1516,8 @@ CREATE TABLE galley_test.mls_group_member_client ( user uuid, client text, key_package_ref blob, + leaf_node_index int, + removal_pending boolean, PRIMARY KEY (group_id, user_domain, user, client) ) WITH CLUSTERING ORDER BY (user_domain ASC, user ASC, client ASC) AND bloom_filter_fp_chance = 0.01 @@ -1596,6 +1605,30 @@ CREATE TABLE galley_test.mls_commit_locks ( AND read_repair_chance = 0.0 AND speculative_retry = '99PERCENTILE'; +CREATE TABLE galley_test.subconversation ( + conv_id uuid, + subconv_id text, + cipher_suite int, + epoch bigint, + group_id blob, + public_group_state blob, + PRIMARY KEY (conv_id, subconv_id) +) WITH CLUSTERING ORDER BY (subconv_id ASC) + AND bloom_filter_fp_chance = 0.01 + AND caching = {'keys': 'ALL', 'rows_per_partition': 'NONE'} + AND comment = '' + AND compaction = {'class': 'org.apache.cassandra.db.compaction.SizeTieredCompactionStrategy', 'max_threshold': '32', 'min_threshold': '4'} + AND compression = {'chunk_length_in_kb': '64', 'class': 'org.apache.cassandra.io.compress.LZ4Compressor'} + AND crc_check_chance = 1.0 + AND dclocal_read_repair_chance = 0.1 + AND default_time_to_live = 0 + AND gc_grace_seconds = 864000 + AND max_index_interval = 2048 + AND memtable_flush_period_in_ms = 0 + AND min_index_interval = 128 + AND read_repair_chance = 0.0 + AND speculative_retry = '99PERCENTILE'; + CREATE TABLE galley_test.team ( team uuid PRIMARY KEY, binding boolean, diff --git a/charts/background-worker/templates/configmap.yaml b/charts/background-worker/templates/configmap.yaml index 7e3612252d8..1a03ad0d5e4 100644 --- a/charts/background-worker/templates/configmap.yaml +++ b/charts/background-worker/templates/configmap.yaml @@ -21,14 +21,6 @@ data: host: federator port: 8080 - galley: - host: galley - port: 8080 - - brig: - host: brig - port: 8080 - rabbitmq: {{toYaml .rabbitmq | indent 6 }} backendNotificationPusher: diff --git a/charts/background-worker/values.yaml b/charts/background-worker/values.yaml index 6fcdb5e05be..a7a552a4536 100644 --- a/charts/background-worker/values.yaml +++ b/charts/background-worker/values.yaml @@ -24,7 +24,9 @@ config: vHost: / adminPort: 15672 backendNotificationPusher: - remotesRefreshInterval: 60 # seconds + pushBackoffMinWait: 10000 # in microseconds, so 10ms + pushBackoffMaxWait: 300000000 # microseconds, so 300s + remotesRefreshInterval: 300000000 # microseconds, so 300s serviceAccount: # When setting this to 'false', either make sure that a service account named diff --git a/charts/backoffice/templates/tests/secret.yaml b/charts/backoffice/templates/tests/secret.yaml new file mode 100644 index 00000000000..0104b5c9963 --- /dev/null +++ b/charts/backoffice/templates/tests/secret.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: stern-integration + labels: + app: stern-integration + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + {{- with .Values.tests.secrets }} + uploadXmlAwsAccessKeyId: {{ .uploadXmlAwsAccessKeyId | b64enc | quote }} + uploadXmlAwsSecretAccessKey: {{ .uploadXmlAwsSecretAccessKey | b64enc | quote }} + {{- end }} diff --git a/charts/backoffice/templates/tests/stern-integration.yaml b/charts/backoffice/templates/tests/stern-integration.yaml index bcdaa8bc630..cbe0da5f117 100644 --- a/charts/backoffice/templates/tests/stern-integration.yaml +++ b/charts/backoffice/templates/tests/stern-integration.yaml @@ -19,6 +19,33 @@ spec: securityContext: {{- toYaml .Values.podSecurityContext | nindent 6 }} {{- end }} + command: + - /bin/bash + - -c + - | + set -euo pipefail + + if stern-integration --xml "$TEST_XML"; then + exit_code=$? + else + exit_code=$? + fi + + {{- if .Values.tests.config.uploadXml }} + # In case a different S3 compliant storage is used to upload test result. + if ! [[ -z "${UPLOAD_XML_AWS_ACCESS_KEY_ID+x}" ]]; then + export AWS_ACCESS_KEY_ID="$UPLOAD_XML_AWS_ACCESS_KEY_ID" + export AWS_SECRET_ACCESS_KEY="$UPLOAD_XML_AWS_SECRET_ACCESS_KEY" + fi + + # The `|| echo ..` ensures that the test isn't seen as failed even if the upload fails. + ts=$(date --utc '+%Y%m%d%H%M%S%N') + uploadUrl="$UPLOAD_XML_S3_BASE_URL/stern-integration/${ts}.xml" + echo "Uploading xml result to: $uploadUrl" + aws s3 cp "$TEST_XML" "$uploadUrl" || echo "failed to upload result" + {{- end }} + + exit $exit_code volumeMounts: - name: "stern-integration" mountPath: "/etc/wire/integration" @@ -26,4 +53,23 @@ spec: requests: memory: "128Mi" cpu: "1" + env: + - name: TEST_XML + value: /tmp/result.xml + {{- if .Values.tests.config.uploadXml }} + - name: UPLOAD_XML_S3_BASE_URL + value: {{ .Values.tests.config.uploadXml.baseUrl }} + {{- if .Values.tests.secrets.uploadXmlAwsAccessKeyId }} + - name: UPLOAD_XML_AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: stern-integration + key: uploadXmlAwsAccessKeyId + - name: UPLOAD_XML_AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: stern-integration + key: uploadXmlAwsSecretAccessKey + {{- end }} + {{- end }} restartPolicy: Never diff --git a/charts/backoffice/values.yaml b/charts/backoffice/values.yaml index 50fbf711a4b..a7b9bc0a700 100644 --- a/charts/backoffice/values.yaml +++ b/charts/backoffice/values.yaml @@ -27,3 +27,12 @@ podSecurityContext: runAsNonRoot: true seccompProfile: type: RuntimeDefault + +tests: + config: {} +# config: +# uploadXml: +# baseUrl: s3://bucket/path/ +# secrets: +# uploadXmlAwsAccessKeyId: +# uploadXmlAwsSecretAccessKey: diff --git a/charts/brig/templates/tests/brig-integration.yaml b/charts/brig/templates/tests/brig-integration.yaml index 17632a48184..1599c3860b7 100644 --- a/charts/brig/templates/tests/brig-integration.yaml +++ b/charts/brig/templates/tests/brig-integration.yaml @@ -42,8 +42,8 @@ spec: configMap: name: "turn" - name: "brig-integration-secrets" - configMap: - name: "brig-integration-secrets" + secret: + secretName: "brig-integration" containers: - name: integration image: "{{ .Values.image.repository }}-integration:{{ .Values.image.tag }}" @@ -58,7 +58,33 @@ spec: # same file-system. # The other test, "user.auth.cookies.limit", is skipped as it is flaky. # This is tracked in https://github.com/zinfra/backend-issues/issues/1150. - command: [ "brig-integration", "--pattern", "!/turn/ && !/user.auth.cookies.limit/" ] + command: + - /bin/bash + - -c + - | + set -euo pipefail + + if brig-integration --xml "$TEST_XML" --pattern "!/turn/ && !/user.auth.cookies.limit/"; then + exit_code=$? + else + exit_code=$? + fi + + {{- if .Values.tests.config.uploadXml }} + # In case a different S3 compliant storage is used to upload test result. + if ! [[ -z "${UPLOAD_XML_AWS_ACCESS_KEY_ID+x}" ]]; then + export AWS_ACCESS_KEY_ID="$UPLOAD_XML_AWS_ACCESS_KEY_ID" + export AWS_SECRET_ACCESS_KEY="$UPLOAD_XML_AWS_SECRET_ACCESS_KEY" + fi + + # The `|| echo ..` ensures that the test isn't seen as failed even if the upload fails. + ts=$(date --utc '+%Y%m%d%H%M%S%N') + uploadUrl="$UPLOAD_XML_S3_BASE_URL/brig-integration/${ts}.xml" + echo "Uploading xml result to: $uploadUrl" + aws s3 cp "$TEST_XML" "$uploadUrl" || echo "failed to upload result" + {{- end }} + + exit $exit_code volumeMounts: - name: "brig-integration" mountPath: "/etc/wire/integration" @@ -90,6 +116,24 @@ spec: - name: RABBITMQ_PASSWORD value: "guest" {{- end }} + - name: TEST_XML + value: /tmp/result.xml + {{- if .Values.tests.config.uploadXml }} + - name: UPLOAD_XML_S3_BASE_URL + value: {{ .Values.tests.config.uploadXml.baseUrl }} + {{- if .Values.tests.secrets.uploadXmlAwsAccessKeyId }} + - name: UPLOAD_XML_AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: brig-integration + key: uploadXmlAwsAccessKeyId + - name: UPLOAD_XML_AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: brig-integration + key: uploadXmlAwsSecretAccessKey + {{- end }} + {{- end }} resources: requests: memory: "512Mi" diff --git a/charts/brig/templates/tests/secret.yaml b/charts/brig/templates/tests/secret.yaml index 69ce7e671e0..5c5459e609a 100644 --- a/charts/brig/templates/tests/secret.yaml +++ b/charts/brig/templates/tests/secret.yaml @@ -1,69 +1,24 @@ apiVersion: v1 -kind: ConfigMap +kind: Secret metadata: - name: brig-integration-secrets + name: brig-integration + labels: + app: brig-integration + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" annotations: "helm.sh/hook": post-install "helm.sh/hook-delete-policy": before-hook-creation +type: Opaque data: - # These "secrets" are only used in tests and are therefore safe to be stored unencrypted - provider-privatekey.pem: | - -----BEGIN RSA PRIVATE KEY----- - MIIEpAIBAAKCAQEAu+Kg/PHHU3atXrUbKnw0G06FliXcNt3lMwl2os5twEDcPPFw - /feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPHWvUBdiLfGrZqJO223DB6D8K2Su/o - dmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKVVPOaOzgtAB21XKRiQ4ermqgi3/nj - r03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiSbUKr/BeArYRcjzr/h5m1In6fG/if - 9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg87X883H+LA/d6X5CTiPv1VMxXdBUi - GPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7lanQIDAQABAoIBAQC0doVy7zgpLsBv - Sz0AnbPe1pjxEwRlntRbJSfSULySALqJvs5s4adSVGUBHX3z/LousAP1SRpCppuU - 8wrLBFgjQVlaAzyQB84EEl+lNtrG8Jrvd2es9R/4sJDkqy50+yuPN5wnzWPFIjhg - 3jP5CHDu29y0LMzsY5yjkzDe9B0bueXEZVU+guRjhpwHHKOFeAr9J9bugFUwgeAr - jF0TztzFAb0fsUNPiQAho1J5PyjSVgItaPfAPv/p30ROG+rz+Rd5NSSvBC5F+yOo - azb84zzwCg/knAfIz7SOMRrmBh2qhGZFZ8gXdq65UaYv+cpT/qo28mpAT2vOkyeD - aPZp0ysBAoGBAOQROoDipe/5BTHBcXYuUE1qa4RIj3wgql5I8igXr4K6ppYBmaOg - DL2rrnqD86chv0P4l/XOomKFwYhVGXtqRkeYnk6mQXwNVkgqcGbY5PSNyMg5+ekq - jSOOPHGzzTWKzYuUDUpB/Lf6jbTv8fq2GYW3ZYiqQ/xiugOvglZrTE7NAoGBANLl - irjByfxAWGhzCrDx0x5MBpsetadI9wUA8u1BDdymsRg73FDn3z7NipVUAMDXMGVj - lqbCRlHESO2yP4GaPEA4FM+MbTZSuhAYV+SY07mEPLHF64/nJas83Zp91r5rhaqJ - L9rWCl3KJ5OUnr3YizCnHIW72FxjwtpjxHJLupsRAoGAGIbhy8qUHeKh9F/hW9xP - NoQjW+6Rv7+jktA1eqpRbbW1BJzXcQldVWiJMxPNuEOg1iZ98SlvvTi1P3wnaWZc - eIapP7wRfs3QYaJuxCC/Pq2g0ieqALFazGAXkALOJtvujvw1Ea9XBlIjuzmyxEuh - Iwg+Gxx0g0f6yTquwax4YGECgYEAnpAK3qKFNO1ECzQDo8oNy0ep59MNDPtlDhQK - katJus5xdCD9oq7TQKrVOTTxZAvmzTQ1PqfuqueDVYOhD9Zg2n/P1cRlEGTek99Z - pfvppB/yak6+r3FA9yBKFS/r1zuMQg3nNweav62QV/tz5pT7AdeDMGFtaPlwtTYx - qyWY5aECgYBPySbPccNj+xxQzxcti2y/UXjC04RgOA/Hm1D0exa0vBqS9uxlOdG8 - F47rKenpBrslvdfTVsCDB1xyP2ebWVzp6EqMycw6OLPxgo3fBfZ4pi6P+rByh0Cc - Lhfh+ET0CPnKCxtop3lUrn4ZvqchS0j3J+M0pDuqoWF5hfKxFhkEIw== - -----END RSA PRIVATE KEY----- - provider-publickey.pem: | - -----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu+Kg/PHHU3atXrUbKnw0 - G06FliXcNt3lMwl2os5twEDcPPFw/feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPH - WvUBdiLfGrZqJO223DB6D8K2Su/odmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKV - VPOaOzgtAB21XKRiQ4ermqgi3/njr03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiS - bUKr/BeArYRcjzr/h5m1In6fG/if9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg8 - 7X883H+LA/d6X5CTiPv1VMxXdBUiGPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7la - nQIDAQAB - -----END PUBLIC KEY----- - provider-publiccert.pem: | - -----BEGIN CERTIFICATE----- - MIIDdjCCAl4CCQCm0AiwERR/qjANBgkqhkiG9w0BAQsFADB9MQswCQYDVQQGEwJE - RTEPMA0GA1UECAwGQmVybGluMQ8wDQYDVQQHDAZCZXJsaW4xGDAWBgNVBAoMD1dp - cmUgU3dpc3MgR21iSDERMA8GA1UEAwwId2lyZS5jb20xHzAdBgkqhkiG9w0BCQEW - EGJhY2tlbmRAd2lyZS5jb20wHhcNMTYwODA0MTMxNDQyWhcNMzYwNzMwMTMxNDQy - WjB9MQswCQYDVQQGEwJERTEPMA0GA1UECAwGQmVybGluMQ8wDQYDVQQHDAZCZXJs - aW4xGDAWBgNVBAoMD1dpcmUgU3dpc3MgR21iSDERMA8GA1UEAwwId2lyZS5jb20x - HzAdBgkqhkiG9w0BCQEWEGJhY2tlbmRAd2lyZS5jb20wggEiMA0GCSqGSIb3DQEB - AQUAA4IBDwAwggEKAoIBAQC74qD88cdTdq1etRsqfDQbToWWJdw23eUzCXaizm3A - QNw88XD994aIArKbGn7smpkOux5LkP1Mcatb45BEg8da9QF2It8atmok7bbcMHoP - wrZK7+h2aeNknbPbeuFegQCtOmW74OD0r5zYtV5dMpVU85o7OC0AHbVcpGJDh6ua - qCLf+eOvTetfKr+o2S413q01yD4cB8bF8a+8JJgF+JJtQqv8F4CthFyPOv+HmbUi - fp8b+J/0YQjqbx3EdP0ltjnfCKSyjDLpqMK6qyQgWDztfzzcf4sD93pfkJOI+/VU - zFd0FSIY+4L0hP/oI1DX8sW3Q/ftrHnz4sZiVoWjuVqdAgMBAAEwDQYJKoZIhvcN - AQELBQADggEBAEuwlHElIGR56KVC1dJiw238mDGjMfQzSP76Wi4zWS6/zZwJUuog - BkC+vacfju8UAMvL+vdqkjOVUHor84/2wuq0qn91AjOITD7tRAZB+XLXxsikKv/v - OXE3A/lCiNi882NegPyXAfFPp/71CIiTQZps1eQkAvhD5t5WiFYPESxDlvEJrHFY - XP4+pp8fL8YPS7iZNIq+z+P8yVIw+B/Hs0ht7wFIYN0xACbU8m9+Rs08JMoT16c+ - hZMuK3BWD3fzkQVfW0yMwz6fWRXB483ZmekGkgndOTDoJQMdJXZxHpI3t2FcxQYj - T45GXxRd18neXtuYa/OoAw9UQFDN5XfXN0g= - -----END CERTIFICATE----- + {{- with .Values.tests.secrets }} + provider-privatekey.pem: {{ .providerPrivateKey | b64enc | quote }} + provider-publickey.pem: {{ .providerPublicKey | b64enc | quote }} + provider-publiccert.pem: {{ .providerPublicCert | b64enc | quote }} + {{- if .uploadXmlAwsAccessKeyId }} + uploadXmlAwsAccessKeyId: {{ .uploadXmlAwsAccessKeyId | b64enc | quote }} + uploadXmlAwsSecretAccessKey: {{ .uploadXmlAwsSecretAccessKey | b64enc | quote }} + {{- end }} + {{- end }} + diff --git a/charts/brig/values.yaml b/charts/brig/values.yaml index 7f756955226..818b4a55578 100644 --- a/charts/brig/values.yaml +++ b/charts/brig/values.yaml @@ -143,3 +143,73 @@ podSecurityContext: runAsNonRoot: true seccompProfile: type: RuntimeDefault +tests: + config: {} + # uploadXml: + # baseUrl: s3://bucket/path/ + + secrets: + # uploadXmlAwsAccessKeyId: + # uploadXmlAwsSecretAccessKey: + + # These "secrets" are only used in tests and are therefore safe to be stored unencrypted + providerPrivateKey: | + -----BEGIN RSA PRIVATE KEY----- + MIIEpAIBAAKCAQEAu+Kg/PHHU3atXrUbKnw0G06FliXcNt3lMwl2os5twEDcPPFw + /feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPHWvUBdiLfGrZqJO223DB6D8K2Su/o + dmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKVVPOaOzgtAB21XKRiQ4ermqgi3/nj + r03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiSbUKr/BeArYRcjzr/h5m1In6fG/if + 9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg87X883H+LA/d6X5CTiPv1VMxXdBUi + GPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7lanQIDAQABAoIBAQC0doVy7zgpLsBv + Sz0AnbPe1pjxEwRlntRbJSfSULySALqJvs5s4adSVGUBHX3z/LousAP1SRpCppuU + 8wrLBFgjQVlaAzyQB84EEl+lNtrG8Jrvd2es9R/4sJDkqy50+yuPN5wnzWPFIjhg + 3jP5CHDu29y0LMzsY5yjkzDe9B0bueXEZVU+guRjhpwHHKOFeAr9J9bugFUwgeAr + jF0TztzFAb0fsUNPiQAho1J5PyjSVgItaPfAPv/p30ROG+rz+Rd5NSSvBC5F+yOo + azb84zzwCg/knAfIz7SOMRrmBh2qhGZFZ8gXdq65UaYv+cpT/qo28mpAT2vOkyeD + aPZp0ysBAoGBAOQROoDipe/5BTHBcXYuUE1qa4RIj3wgql5I8igXr4K6ppYBmaOg + DL2rrnqD86chv0P4l/XOomKFwYhVGXtqRkeYnk6mQXwNVkgqcGbY5PSNyMg5+ekq + jSOOPHGzzTWKzYuUDUpB/Lf6jbTv8fq2GYW3ZYiqQ/xiugOvglZrTE7NAoGBANLl + irjByfxAWGhzCrDx0x5MBpsetadI9wUA8u1BDdymsRg73FDn3z7NipVUAMDXMGVj + lqbCRlHESO2yP4GaPEA4FM+MbTZSuhAYV+SY07mEPLHF64/nJas83Zp91r5rhaqJ + L9rWCl3KJ5OUnr3YizCnHIW72FxjwtpjxHJLupsRAoGAGIbhy8qUHeKh9F/hW9xP + NoQjW+6Rv7+jktA1eqpRbbW1BJzXcQldVWiJMxPNuEOg1iZ98SlvvTi1P3wnaWZc + eIapP7wRfs3QYaJuxCC/Pq2g0ieqALFazGAXkALOJtvujvw1Ea9XBlIjuzmyxEuh + Iwg+Gxx0g0f6yTquwax4YGECgYEAnpAK3qKFNO1ECzQDo8oNy0ep59MNDPtlDhQK + katJus5xdCD9oq7TQKrVOTTxZAvmzTQ1PqfuqueDVYOhD9Zg2n/P1cRlEGTek99Z + pfvppB/yak6+r3FA9yBKFS/r1zuMQg3nNweav62QV/tz5pT7AdeDMGFtaPlwtTYx + qyWY5aECgYBPySbPccNj+xxQzxcti2y/UXjC04RgOA/Hm1D0exa0vBqS9uxlOdG8 + F47rKenpBrslvdfTVsCDB1xyP2ebWVzp6EqMycw6OLPxgo3fBfZ4pi6P+rByh0Cc + Lhfh+ET0CPnKCxtop3lUrn4ZvqchS0j3J+M0pDuqoWF5hfKxFhkEIw== + -----END RSA PRIVATE KEY----- + providerPublicKey: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu+Kg/PHHU3atXrUbKnw0 + G06FliXcNt3lMwl2os5twEDcPPFw/feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPH + WvUBdiLfGrZqJO223DB6D8K2Su/odmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKV + VPOaOzgtAB21XKRiQ4ermqgi3/njr03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiS + bUKr/BeArYRcjzr/h5m1In6fG/if9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg8 + 7X883H+LA/d6X5CTiPv1VMxXdBUiGPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7la + nQIDAQAB + -----END PUBLIC KEY----- + providerPublicCert: | + -----BEGIN CERTIFICATE----- + MIIDdjCCAl4CCQCm0AiwERR/qjANBgkqhkiG9w0BAQsFADB9MQswCQYDVQQGEwJE + RTEPMA0GA1UECAwGQmVybGluMQ8wDQYDVQQHDAZCZXJsaW4xGDAWBgNVBAoMD1dp + cmUgU3dpc3MgR21iSDERMA8GA1UEAwwId2lyZS5jb20xHzAdBgkqhkiG9w0BCQEW + EGJhY2tlbmRAd2lyZS5jb20wHhcNMTYwODA0MTMxNDQyWhcNMzYwNzMwMTMxNDQy + WjB9MQswCQYDVQQGEwJERTEPMA0GA1UECAwGQmVybGluMQ8wDQYDVQQHDAZCZXJs + aW4xGDAWBgNVBAoMD1dpcmUgU3dpc3MgR21iSDERMA8GA1UEAwwId2lyZS5jb20x + HzAdBgkqhkiG9w0BCQEWEGJhY2tlbmRAd2lyZS5jb20wggEiMA0GCSqGSIb3DQEB + AQUAA4IBDwAwggEKAoIBAQC74qD88cdTdq1etRsqfDQbToWWJdw23eUzCXaizm3A + QNw88XD994aIArKbGn7smpkOux5LkP1Mcatb45BEg8da9QF2It8atmok7bbcMHoP + wrZK7+h2aeNknbPbeuFegQCtOmW74OD0r5zYtV5dMpVU85o7OC0AHbVcpGJDh6ua + qCLf+eOvTetfKr+o2S413q01yD4cB8bF8a+8JJgF+JJtQqv8F4CthFyPOv+HmbUi + fp8b+J/0YQjqbx3EdP0ltjnfCKSyjDLpqMK6qyQgWDztfzzcf4sD93pfkJOI+/VU + zFd0FSIY+4L0hP/oI1DX8sW3Q/ftrHnz4sZiVoWjuVqdAgMBAAEwDQYJKoZIhvcN + AQELBQADggEBAEuwlHElIGR56KVC1dJiw238mDGjMfQzSP76Wi4zWS6/zZwJUuog + BkC+vacfju8UAMvL+vdqkjOVUHor84/2wuq0qn91AjOITD7tRAZB+XLXxsikKv/v + OXE3A/lCiNi882NegPyXAfFPp/71CIiTQZps1eQkAvhD5t5WiFYPESxDlvEJrHFY + XP4+pp8fL8YPS7iZNIq+z+P8yVIw+B/Hs0ht7wFIYN0xACbU8m9+Rs08JMoT16c+ + hZMuK3BWD3fzkQVfW0yMwz6fWRXB483ZmekGkgndOTDoJQMdJXZxHpI3t2FcxQYj + T45GXxRd18neXtuYa/OoAw9UQFDN5XfXN0g= + -----END CERTIFICATE----- diff --git a/charts/cargohold/templates/tests/cargohold-integration.yaml b/charts/cargohold/templates/tests/cargohold-integration.yaml index 26e77778067..170e4e02595 100644 --- a/charts/cargohold/templates/tests/cargohold-integration.yaml +++ b/charts/cargohold/templates/tests/cargohold-integration.yaml @@ -21,6 +21,33 @@ spec: securityContext: {{- toYaml .Values.podSecurityContext | nindent 6 }} {{- end }} + command: + - /bin/bash + - -c + - | + set -euo pipefail + + if cargohold-integration --xml "$TEST_XML"; then + exit_code=$? + else + exit_code=$? + fi + + {{- if .Values.tests.config.uploadXml }} + # In case a different S3 compliant storage is used to upload test result. + if ! [[ -z "${UPLOAD_XML_AWS_ACCESS_KEY_ID+x}" ]]; then + export AWS_ACCESS_KEY_ID="$UPLOAD_XML_AWS_ACCESS_KEY_ID" + export AWS_SECRET_ACCESS_KEY="$UPLOAD_XML_AWS_SECRET_ACCESS_KEY" + fi + + # The `|| echo ..` ensures that the test isn't seen as failed even if the upload fails. + ts=$(date --utc '+%Y%m%d%H%M%S%N') + uploadUrl="$UPLOAD_XML_S3_BASE_URL/cargohold-integration/${ts}.xml" + echo "Uploading xml result to: $uploadUrl" + aws s3 cp "$TEST_XML" "$uploadUrl" || echo "failed to upload result" + {{- end }} + + exit $exit_code volumeMounts: - name: "cargohold-integration" mountPath: "/etc/wire/integration" @@ -40,4 +67,22 @@ spec: key: awsSecretKey - name: AWS_REGION value: "{{ .Values.config.aws.region }}" + - name: TEST_XML + value: /tmp/result.xml + {{- if .Values.tests.config.uploadXml }} + - name: UPLOAD_XML_S3_BASE_URL + value: {{ .Values.tests.config.uploadXml.baseUrl }} + {{- if .Values.tests.secrets.uploadXmlAwsAccessKeyId }} + - name: UPLOAD_XML_AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: cargohold-integration + key: uploadXmlAwsAccessKeyId + - name: UPLOAD_XML_AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: cargohold-integration + key: uploadXmlAwsSecretAccessKey + {{- end }} + {{- end }} restartPolicy: Never diff --git a/charts/cargohold/templates/tests/secret.yaml b/charts/cargohold/templates/tests/secret.yaml new file mode 100644 index 00000000000..a8bd1f0ae63 --- /dev/null +++ b/charts/cargohold/templates/tests/secret.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: cargohold-integration + labels: + app: cargohold-integration + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + {{- with .Values.tests.secrets }} + uploadXmlAwsAccessKeyId: {{ .uploadXmlAwsAccessKeyId | b64enc | quote }} + uploadXmlAwsSecretAccessKey: {{ .uploadXmlAwsSecretAccessKey | b64enc | quote }} + {{- end }} diff --git a/charts/cargohold/values.yaml b/charts/cargohold/values.yaml index 300e8b1472d..8ef51a263db 100644 --- a/charts/cargohold/values.yaml +++ b/charts/cargohold/values.yaml @@ -52,3 +52,11 @@ podSecurityContext: runAsNonRoot: true seccompProfile: type: RuntimeDefault +tests: + config: {} +# config: +# uploadXml: +# baseUrl: s3://bucket/path/ +# secrets: +# uploadXmlAwsAccessKeyId: +# uploadXmlAwsSecretAccessKey: diff --git a/charts/fake-aws-s3/requirements.yaml b/charts/fake-aws-s3/requirements.yaml index da4723909d2..f62c11a7b74 100644 --- a/charts/fake-aws-s3/requirements.yaml +++ b/charts/fake-aws-s3/requirements.yaml @@ -1,4 +1,4 @@ dependencies: - name: minio - version: 3.2.0 + version: 5.0.13 repository: https://charts.min.io/ diff --git a/charts/fake-aws-s3/values.yaml b/charts/fake-aws-s3/values.yaml index a736eb82cb0..bf36bdf4a93 100644 --- a/charts/fake-aws-s3/values.yaml +++ b/charts/fake-aws-s3/values.yaml @@ -1,9 +1,5 @@ -# See defaults in https://github.com/helm/charts/tree/master/stable/minio +# See defaults in https://github.com/minio/minio/blob/RELEASE.2023-07-07T07-13-57Z/helm/minio/values.yaml minio: - mcImage: - repository: quay.io/minio/mc - tag: RELEASE.2021-10-07T04-19-58Z - pullPolicy: IfNotPresent fullnameOverride: fake-aws-s3 service: port: "9000" diff --git a/charts/federator/templates/configmap.yaml b/charts/federator/templates/configmap.yaml index 44de6271071..eb9700b9727 100644 --- a/charts/federator/templates/configmap.yaml +++ b/charts/federator/templates/configmap.yaml @@ -51,5 +51,6 @@ data: clientCertificate: "/etc/wire/federator/secrets/tls.crt" clientPrivateKey: "/etc/wire/federator/secrets/tls.key" useSystemCAStore: {{ .useSystemCAStore }} + tcpConnectionTimeout: {{ .tcpConnectionTimeout }} {{- end }} {{- end }} diff --git a/charts/federator/templates/servicemonitor.yaml b/charts/federator/templates/servicemonitor.yaml new file mode 100644 index 00000000000..9738af2420f --- /dev/null +++ b/charts/federator/templates/servicemonitor.yaml @@ -0,0 +1,19 @@ +{{- if .Values.metrics.serviceMonitor.enabled }} +apiVersion: monitoring.coreos.com/v1 +kind: ServiceMonitor +metadata: + name: federator + labels: + app: federator + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + endpoints: + - port: internal + path: /i/metrics + selector: + matchLabels: + app: federator + release: {{ .Release.Name }} +{{- end }} diff --git a/charts/federator/templates/tests/federator-integration.yaml b/charts/federator/templates/tests/federator-integration.yaml index c4edb616f04..f30d7873798 100644 --- a/charts/federator/templates/tests/federator-integration.yaml +++ b/charts/federator/templates/tests/federator-integration.yaml @@ -23,7 +23,34 @@ spec: name: "federator-ca" containers: - name: integration - command: ["federator-integration"] + command: + - /bin/bash + - -c + - | + set -euo pipefail + + if federator-integration -f junit; then + exit_code=$? + else + exit_code=$? + fi + + {{- if .Values.tests.config.uploadXml }} + # In case a different S3 compliant storage is used to upload test result. + if ! [[ -z "${UPLOAD_XML_AWS_ACCESS_KEY_ID+x}" ]]; then + export AWS_ACCESS_KEY_ID="$UPLOAD_XML_AWS_ACCESS_KEY_ID" + export AWS_SECRET_ACCESS_KEY="$UPLOAD_XML_AWS_SECRET_ACCESS_KEY" + fi + TEST_XML="$JUNIT_OUTPUT_DIRECTORY/junit.xml" + + # The `|| echo ..` ensures that the test isn't seen as failed even if the upload fails. + ts=$(date --utc '+%Y%m%d%H%M%S%N') + uploadUrl="$UPLOAD_XML_S3_BASE_URL/federator-integration/${ts}.xml" + echo "Uploading xml result to: $uploadUrl" + aws s3 cp "$TEST_XML" "$uploadUrl" || echo "failed to upload result" + {{- end }} + + exit $exit_code image: "{{ .Values.image.repository }}-integration:{{ .Values.image.tag }}" {{- if eq (include "includeSecurityContext" .) "true" }} securityContext: @@ -38,4 +65,25 @@ spec: mountPath: "/etc/wire/federator/secrets" - name: "federator-ca" mountPath: "/etc/wire/federator/ca" + env: + - name: JUNIT_OUTPUT_DIRECTORY + value: /tmp/ + - name: JUNIT_SUITE_NAME + value: federator + {{- if .Values.tests.config.uploadXml }} + - name: UPLOAD_XML_S3_BASE_URL + value: {{ .Values.tests.config.uploadXml.baseUrl }} + {{- if .Values.tests.secrets.uploadXmlAwsAccessKeyId }} + - name: UPLOAD_XML_AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: federator-integration + key: uploadXmlAwsAccessKeyId + - name: UPLOAD_XML_AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: federator-integration + key: uploadXmlAwsSecretAccessKey + {{- end }} + {{- end }} restartPolicy: Never diff --git a/charts/federator/templates/tests/secret.yaml b/charts/federator/templates/tests/secret.yaml new file mode 100644 index 00000000000..44edadffdae --- /dev/null +++ b/charts/federator/templates/tests/secret.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: federator-integration + labels: + app: federator-integration + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + {{- with .Values.tests.secrets }} + uploadXmlAwsAccessKeyId: {{ .uploadXmlAwsAccessKeyId | b64enc | quote }} + uploadXmlAwsSecretAccessKey: {{ .uploadXmlAwsSecretAccessKey | b64enc | quote }} + {{- end }} diff --git a/charts/federator/values.yaml b/charts/federator/values.yaml index 406bb3b17c6..531388b897c 100644 --- a/charts/federator/values.yaml +++ b/charts/federator/values.yaml @@ -8,6 +8,10 @@ service: internalFederatorPort: 8080 externalFederatorPort: 8081 +metrics: + serviceMonitor: + enabled: false + tls: # if enabled, federator will get its client certificate and private key from # the secret used by the federator ingress @@ -41,6 +45,8 @@ config: # A client certificate and corresponding private key can be specified # similarly to a custom CA store. useSystemCAStore: true + # In microseconds, default is 5s. + tcpConnectionTimeout: 5000000 podSecurityContext: allowPrivilegeEscalation: false @@ -50,3 +56,12 @@ podSecurityContext: runAsNonRoot: true seccompProfile: type: RuntimeDefault + +tests: + config: {} +# config: +# uploadXml: +# baseUrl: s3://bucket/path/ +# secrets: +# uploadXmlAwsAccessKeyId: +# uploadXmlAwsSecretAccessKey: diff --git a/charts/galley/templates/configmap.yaml b/charts/galley/templates/configmap.yaml index 10c22fafeb6..690bfd993c3 100644 --- a/charts/galley/templates/configmap.yaml +++ b/charts/galley/templates/configmap.yaml @@ -59,7 +59,16 @@ data: {{- if .settings.exposeInvitationURLsTeamAllowlist }} exposeInvitationURLsTeamAllowlist: {{ .settings.exposeInvitationURLsTeamAllowlist }} {{- end }} + {{- if .settings.conversationCodeURI }} conversationCodeURI: {{ .settings.conversationCodeURI | quote }} + {{- else if .settings.multiIngress }} + multiIngress: {{- toYaml .settings.multiIngress | nindent 8 }} + {{- else }} + {{ fail "Either settings.conversationCodeURI or settings.multiIngress have to be set"}} + {{- end }} + {{- if (and .settings.conversationCodeURI .settings.multiIngress) }} + {{ fail "settings.conversationCodeURI and settings.multiIngress are mutually exclusive" }} + {{- end }} federationDomain: {{ .settings.federationDomain }} {{- if $.Values.secrets.mlsPrivateKeys }} mlsPrivateKeyPaths: @@ -122,5 +131,9 @@ data: mlsE2EId: {{- toYaml .settings.featureFlags.mlsE2EId | nindent 10 }} {{- end }} + {{- if .settings.featureFlags.mlsMigration }} + mlsMigration: + {{- toYaml .settings.featureFlags.mlsMigration | nindent 10 }} + {{- end }} {{- end }} {{- end }} diff --git a/charts/galley/templates/tests/galley-integration.yaml b/charts/galley/templates/tests/galley-integration.yaml index 9aebc3e7bd6..e1870228379 100644 --- a/charts/galley/templates/tests/galley-integration.yaml +++ b/charts/galley/templates/tests/galley-integration.yaml @@ -35,8 +35,8 @@ spec: configMap: name: "galley" - name: "galley-integration-secrets" - configMap: - name: "galley-integration-secrets" + secret: + secretName: "galley-integration" - name: "galley-secrets" secret: secretName: "galley" @@ -47,6 +47,33 @@ spec: securityContext: {{- toYaml .Values.podSecurityContext | nindent 6 }} {{- end }} + command: + - /bin/bash + - -c + - | + set -euo pipefail + + if galley-integration --xml "$TEST_XML"; then + exit_code=$? + else + exit_code=$? + fi + + {{- if .Values.tests.config.uploadXml }} + # In case a different S3 compliant storage is used to upload test result. + if ! [[ -z "${UPLOAD_XML_AWS_ACCESS_KEY_ID+x}" ]]; then + export AWS_ACCESS_KEY_ID="$UPLOAD_XML_AWS_ACCESS_KEY_ID" + export AWS_SECRET_ACCESS_KEY="$UPLOAD_XML_AWS_SECRET_ACCESS_KEY" + fi + + # The `|| echo ..` ensures that the test isn't seen as failed even if the upload fails. + ts=$(date --utc '+%Y%m%d%H%M%S%N') + uploadUrl="$UPLOAD_XML_S3_BASE_URL/galley-integration/${ts}.xml" + echo "Uploading xml result to: $uploadUrl" + aws s3 cp "$TEST_XML" "$uploadUrl" || echo "failed to upload result" + {{- end }} + + exit $exit_code volumeMounts: - name: "galley-integration" mountPath: "/etc/wire/integration" @@ -71,6 +98,24 @@ spec: - name: RABBITMQ_PASSWORD value: "guest" {{- end }} + - name: TEST_XML + value: /tmp/result.xml + {{- if .Values.tests.config.uploadXml }} + - name: UPLOAD_XML_S3_BASE_URL + value: {{ .Values.tests.config.uploadXml.baseUrl }} + {{- if .Values.tests.secrets.uploadXmlAwsAccessKeyId }} + - name: UPLOAD_XML_AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: galley-integration + key: uploadXmlAwsAccessKeyId + - name: UPLOAD_XML_AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: galley-integration + key: uploadXmlAwsSecretAccessKey + {{- end }} + {{- end }} resources: requests: memory: "512Mi" diff --git a/charts/galley/templates/tests/secret.yaml b/charts/galley/templates/tests/secret.yaml index d58a49c3601..d41373a7f21 100644 --- a/charts/galley/templates/tests/secret.yaml +++ b/charts/galley/templates/tests/secret.yaml @@ -1,69 +1,23 @@ apiVersion: v1 -kind: ConfigMap +kind: Secret metadata: - name: galley-integration-secrets + name: galley-integration + labels: + app: galley-integration + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" annotations: "helm.sh/hook": post-install "helm.sh/hook-delete-policy": before-hook-creation +type: Opaque data: - # These "secrets" are only used in tests and are therefore safe to be stored unencrypted - provider-privatekey.pem: | - -----BEGIN RSA PRIVATE KEY----- - MIIEpAIBAAKCAQEAu+Kg/PHHU3atXrUbKnw0G06FliXcNt3lMwl2os5twEDcPPFw - /feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPHWvUBdiLfGrZqJO223DB6D8K2Su/o - dmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKVVPOaOzgtAB21XKRiQ4ermqgi3/nj - r03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiSbUKr/BeArYRcjzr/h5m1In6fG/if - 9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg87X883H+LA/d6X5CTiPv1VMxXdBUi - GPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7lanQIDAQABAoIBAQC0doVy7zgpLsBv - Sz0AnbPe1pjxEwRlntRbJSfSULySALqJvs5s4adSVGUBHX3z/LousAP1SRpCppuU - 8wrLBFgjQVlaAzyQB84EEl+lNtrG8Jrvd2es9R/4sJDkqy50+yuPN5wnzWPFIjhg - 3jP5CHDu29y0LMzsY5yjkzDe9B0bueXEZVU+guRjhpwHHKOFeAr9J9bugFUwgeAr - jF0TztzFAb0fsUNPiQAho1J5PyjSVgItaPfAPv/p30ROG+rz+Rd5NSSvBC5F+yOo - azb84zzwCg/knAfIz7SOMRrmBh2qhGZFZ8gXdq65UaYv+cpT/qo28mpAT2vOkyeD - aPZp0ysBAoGBAOQROoDipe/5BTHBcXYuUE1qa4RIj3wgql5I8igXr4K6ppYBmaOg - DL2rrnqD86chv0P4l/XOomKFwYhVGXtqRkeYnk6mQXwNVkgqcGbY5PSNyMg5+ekq - jSOOPHGzzTWKzYuUDUpB/Lf6jbTv8fq2GYW3ZYiqQ/xiugOvglZrTE7NAoGBANLl - irjByfxAWGhzCrDx0x5MBpsetadI9wUA8u1BDdymsRg73FDn3z7NipVUAMDXMGVj - lqbCRlHESO2yP4GaPEA4FM+MbTZSuhAYV+SY07mEPLHF64/nJas83Zp91r5rhaqJ - L9rWCl3KJ5OUnr3YizCnHIW72FxjwtpjxHJLupsRAoGAGIbhy8qUHeKh9F/hW9xP - NoQjW+6Rv7+jktA1eqpRbbW1BJzXcQldVWiJMxPNuEOg1iZ98SlvvTi1P3wnaWZc - eIapP7wRfs3QYaJuxCC/Pq2g0ieqALFazGAXkALOJtvujvw1Ea9XBlIjuzmyxEuh - Iwg+Gxx0g0f6yTquwax4YGECgYEAnpAK3qKFNO1ECzQDo8oNy0ep59MNDPtlDhQK - katJus5xdCD9oq7TQKrVOTTxZAvmzTQ1PqfuqueDVYOhD9Zg2n/P1cRlEGTek99Z - pfvppB/yak6+r3FA9yBKFS/r1zuMQg3nNweav62QV/tz5pT7AdeDMGFtaPlwtTYx - qyWY5aECgYBPySbPccNj+xxQzxcti2y/UXjC04RgOA/Hm1D0exa0vBqS9uxlOdG8 - F47rKenpBrslvdfTVsCDB1xyP2ebWVzp6EqMycw6OLPxgo3fBfZ4pi6P+rByh0Cc - Lhfh+ET0CPnKCxtop3lUrn4ZvqchS0j3J+M0pDuqoWF5hfKxFhkEIw== - -----END RSA PRIVATE KEY----- - provider-publickey.pem: | - -----BEGIN PUBLIC KEY----- - MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu+Kg/PHHU3atXrUbKnw0 - G06FliXcNt3lMwl2os5twEDcPPFw/feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPH - WvUBdiLfGrZqJO223DB6D8K2Su/odmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKV - VPOaOzgtAB21XKRiQ4ermqgi3/njr03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiS - bUKr/BeArYRcjzr/h5m1In6fG/if9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg8 - 7X883H+LA/d6X5CTiPv1VMxXdBUiGPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7la - nQIDAQAB - -----END PUBLIC KEY----- - provider-publiccert.pem: | - -----BEGIN CERTIFICATE----- - MIIDdjCCAl4CCQCm0AiwERR/qjANBgkqhkiG9w0BAQsFADB9MQswCQYDVQQGEwJE - RTEPMA0GA1UECAwGQmVybGluMQ8wDQYDVQQHDAZCZXJsaW4xGDAWBgNVBAoMD1dp - cmUgU3dpc3MgR21iSDERMA8GA1UEAwwId2lyZS5jb20xHzAdBgkqhkiG9w0BCQEW - EGJhY2tlbmRAd2lyZS5jb20wHhcNMTYwODA0MTMxNDQyWhcNMzYwNzMwMTMxNDQy - WjB9MQswCQYDVQQGEwJERTEPMA0GA1UECAwGQmVybGluMQ8wDQYDVQQHDAZCZXJs - aW4xGDAWBgNVBAoMD1dpcmUgU3dpc3MgR21iSDERMA8GA1UEAwwId2lyZS5jb20x - HzAdBgkqhkiG9w0BCQEWEGJhY2tlbmRAd2lyZS5jb20wggEiMA0GCSqGSIb3DQEB - AQUAA4IBDwAwggEKAoIBAQC74qD88cdTdq1etRsqfDQbToWWJdw23eUzCXaizm3A - QNw88XD994aIArKbGn7smpkOux5LkP1Mcatb45BEg8da9QF2It8atmok7bbcMHoP - wrZK7+h2aeNknbPbeuFegQCtOmW74OD0r5zYtV5dMpVU85o7OC0AHbVcpGJDh6ua - qCLf+eOvTetfKr+o2S413q01yD4cB8bF8a+8JJgF+JJtQqv8F4CthFyPOv+HmbUi - fp8b+J/0YQjqbx3EdP0ltjnfCKSyjDLpqMK6qyQgWDztfzzcf4sD93pfkJOI+/VU - zFd0FSIY+4L0hP/oI1DX8sW3Q/ftrHnz4sZiVoWjuVqdAgMBAAEwDQYJKoZIhvcN - AQELBQADggEBAEuwlHElIGR56KVC1dJiw238mDGjMfQzSP76Wi4zWS6/zZwJUuog - BkC+vacfju8UAMvL+vdqkjOVUHor84/2wuq0qn91AjOITD7tRAZB+XLXxsikKv/v - OXE3A/lCiNi882NegPyXAfFPp/71CIiTQZps1eQkAvhD5t5WiFYPESxDlvEJrHFY - XP4+pp8fL8YPS7iZNIq+z+P8yVIw+B/Hs0ht7wFIYN0xACbU8m9+Rs08JMoT16c+ - hZMuK3BWD3fzkQVfW0yMwz6fWRXB483ZmekGkgndOTDoJQMdJXZxHpI3t2FcxQYj - T45GXxRd18neXtuYa/OoAw9UQFDN5XfXN0g= - -----END CERTIFICATE----- + {{- with .Values.tests.secrets }} + provider-privatekey.pem: {{ .providerPrivateKey | b64enc | quote }} + provider-publickey.pem: {{ .providerPublicKey | b64enc | quote }} + provider-publiccert.pem: {{ .providerPublicCert | b64enc | quote }} + {{- if .uploadXmlAwsAccessKeyId }} + uploadXmlAwsAccessKeyId: {{ .uploadXmlAwsAccessKeyId | b64enc | quote }} + uploadXmlAwsSecretAccessKey: {{ .uploadXmlAwsSecretAccessKey | b64enc | quote }} + {{- end }} + {{- end }} diff --git a/charts/galley/values.yaml b/charts/galley/values.yaml index 875864142ff..8bd2d28c37f 100644 --- a/charts/galley/values.yaml +++ b/charts/galley/values.yaml @@ -34,6 +34,20 @@ config: exposeInvitationURLsTeamAllowlist: [] maxConvSize: 500 intraListing: true + # Either `conversationCodeURI` or `multiIngress` must be set + # + # `conversationCodeURI` is the URI prefix for conversation invitation links + # It should be of form https://{ACCOUNT_PAGES}/conversation-join/ + conversationCodeURI: null + # + # `multiIngress` is a `Z-Host` depended setting of conversationCodeURI. + # Use this only if you want to expose the instance on mutliple ingresses. + # If set it must a map from `Z-Host` to URI prefix + # Example: + # multiIngress: + # example.com: https://accounts.example.com/conversation-join/ + # example.net: https://accounts.example.net/conversation-join/ + multiIngress: null # Disable one ore more API versions. Please make sure the configuration value is the same in all these charts: # brig, cannon, cargohold, galley, gundeck, proxy, spar. # disabledAPIVersions: [ v3 ] @@ -68,6 +82,7 @@ config: defaultProtocol: proteus allowedCipherSuites: [1] defaultCipherSuite: 1 + supportedProtocols: [proteus, mls] # must contain defaultProtocol searchVisibilityInbound: defaults: status: disabled @@ -97,6 +112,15 @@ config: verificationExpiration: 86400 acmeDiscoveryUrl: null lockStatus: unlocked + mlsMigration: + defaults: + status: disabled + config: + startTime: null # "2029-05-16T10:11:12.123Z" + finaliseRegardlessAfter: null # "2029-10-17T00:00:00.000Z" + usersThreshold: 100 + clientsThreshold: 100 + lockStatus: locked aws: region: "eu-west-1" @@ -119,3 +143,73 @@ podSecurityContext: runAsNonRoot: true seccompProfile: type: RuntimeDefault +tests: + config: {} + # uploadXml: + # baseUrl: s3://bucket/path/ + + secrets: + # uploadXmlAwsAccessKeyId: + # uploadXmlAwsSecretAccessKey: + + # These "secrets" are only used in tests and are therefore safe to be stored unencrypted + providerPrivateKey: | + -----BEGIN RSA PRIVATE KEY----- + MIIEpAIBAAKCAQEAu+Kg/PHHU3atXrUbKnw0G06FliXcNt3lMwl2os5twEDcPPFw + /feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPHWvUBdiLfGrZqJO223DB6D8K2Su/o + dmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKVVPOaOzgtAB21XKRiQ4ermqgi3/nj + r03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiSbUKr/BeArYRcjzr/h5m1In6fG/if + 9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg87X883H+LA/d6X5CTiPv1VMxXdBUi + GPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7lanQIDAQABAoIBAQC0doVy7zgpLsBv + Sz0AnbPe1pjxEwRlntRbJSfSULySALqJvs5s4adSVGUBHX3z/LousAP1SRpCppuU + 8wrLBFgjQVlaAzyQB84EEl+lNtrG8Jrvd2es9R/4sJDkqy50+yuPN5wnzWPFIjhg + 3jP5CHDu29y0LMzsY5yjkzDe9B0bueXEZVU+guRjhpwHHKOFeAr9J9bugFUwgeAr + jF0TztzFAb0fsUNPiQAho1J5PyjSVgItaPfAPv/p30ROG+rz+Rd5NSSvBC5F+yOo + azb84zzwCg/knAfIz7SOMRrmBh2qhGZFZ8gXdq65UaYv+cpT/qo28mpAT2vOkyeD + aPZp0ysBAoGBAOQROoDipe/5BTHBcXYuUE1qa4RIj3wgql5I8igXr4K6ppYBmaOg + DL2rrnqD86chv0P4l/XOomKFwYhVGXtqRkeYnk6mQXwNVkgqcGbY5PSNyMg5+ekq + jSOOPHGzzTWKzYuUDUpB/Lf6jbTv8fq2GYW3ZYiqQ/xiugOvglZrTE7NAoGBANLl + irjByfxAWGhzCrDx0x5MBpsetadI9wUA8u1BDdymsRg73FDn3z7NipVUAMDXMGVj + lqbCRlHESO2yP4GaPEA4FM+MbTZSuhAYV+SY07mEPLHF64/nJas83Zp91r5rhaqJ + L9rWCl3KJ5OUnr3YizCnHIW72FxjwtpjxHJLupsRAoGAGIbhy8qUHeKh9F/hW9xP + NoQjW+6Rv7+jktA1eqpRbbW1BJzXcQldVWiJMxPNuEOg1iZ98SlvvTi1P3wnaWZc + eIapP7wRfs3QYaJuxCC/Pq2g0ieqALFazGAXkALOJtvujvw1Ea9XBlIjuzmyxEuh + Iwg+Gxx0g0f6yTquwax4YGECgYEAnpAK3qKFNO1ECzQDo8oNy0ep59MNDPtlDhQK + katJus5xdCD9oq7TQKrVOTTxZAvmzTQ1PqfuqueDVYOhD9Zg2n/P1cRlEGTek99Z + pfvppB/yak6+r3FA9yBKFS/r1zuMQg3nNweav62QV/tz5pT7AdeDMGFtaPlwtTYx + qyWY5aECgYBPySbPccNj+xxQzxcti2y/UXjC04RgOA/Hm1D0exa0vBqS9uxlOdG8 + F47rKenpBrslvdfTVsCDB1xyP2ebWVzp6EqMycw6OLPxgo3fBfZ4pi6P+rByh0Cc + Lhfh+ET0CPnKCxtop3lUrn4ZvqchS0j3J+M0pDuqoWF5hfKxFhkEIw== + -----END RSA PRIVATE KEY----- + providerPublicKey: | + -----BEGIN PUBLIC KEY----- + MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu+Kg/PHHU3atXrUbKnw0 + G06FliXcNt3lMwl2os5twEDcPPFw/feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPH + WvUBdiLfGrZqJO223DB6D8K2Su/odmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKV + VPOaOzgtAB21XKRiQ4ermqgi3/njr03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiS + bUKr/BeArYRcjzr/h5m1In6fG/if9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg8 + 7X883H+LA/d6X5CTiPv1VMxXdBUiGPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7la + nQIDAQAB + -----END PUBLIC KEY----- + providerPublicCert: | + -----BEGIN CERTIFICATE----- + MIIDdjCCAl4CCQCm0AiwERR/qjANBgkqhkiG9w0BAQsFADB9MQswCQYDVQQGEwJE + RTEPMA0GA1UECAwGQmVybGluMQ8wDQYDVQQHDAZCZXJsaW4xGDAWBgNVBAoMD1dp + cmUgU3dpc3MgR21iSDERMA8GA1UEAwwId2lyZS5jb20xHzAdBgkqhkiG9w0BCQEW + EGJhY2tlbmRAd2lyZS5jb20wHhcNMTYwODA0MTMxNDQyWhcNMzYwNzMwMTMxNDQy + WjB9MQswCQYDVQQGEwJERTEPMA0GA1UECAwGQmVybGluMQ8wDQYDVQQHDAZCZXJs + aW4xGDAWBgNVBAoMD1dpcmUgU3dpc3MgR21iSDERMA8GA1UEAwwId2lyZS5jb20x + HzAdBgkqhkiG9w0BCQEWEGJhY2tlbmRAd2lyZS5jb20wggEiMA0GCSqGSIb3DQEB + AQUAA4IBDwAwggEKAoIBAQC74qD88cdTdq1etRsqfDQbToWWJdw23eUzCXaizm3A + QNw88XD994aIArKbGn7smpkOux5LkP1Mcatb45BEg8da9QF2It8atmok7bbcMHoP + wrZK7+h2aeNknbPbeuFegQCtOmW74OD0r5zYtV5dMpVU85o7OC0AHbVcpGJDh6ua + qCLf+eOvTetfKr+o2S413q01yD4cB8bF8a+8JJgF+JJtQqv8F4CthFyPOv+HmbUi + fp8b+J/0YQjqbx3EdP0ltjnfCKSyjDLpqMK6qyQgWDztfzzcf4sD93pfkJOI+/VU + zFd0FSIY+4L0hP/oI1DX8sW3Q/ftrHnz4sZiVoWjuVqdAgMBAAEwDQYJKoZIhvcN + AQELBQADggEBAEuwlHElIGR56KVC1dJiw238mDGjMfQzSP76Wi4zWS6/zZwJUuog + BkC+vacfju8UAMvL+vdqkjOVUHor84/2wuq0qn91AjOITD7tRAZB+XLXxsikKv/v + OXE3A/lCiNi882NegPyXAfFPp/71CIiTQZps1eQkAvhD5t5WiFYPESxDlvEJrHFY + XP4+pp8fL8YPS7iZNIq+z+P8yVIw+B/Hs0ht7wFIYN0xACbU8m9+Rs08JMoT16c+ + hZMuK3BWD3fzkQVfW0yMwz6fWRXB483ZmekGkgndOTDoJQMdJXZxHpI3t2FcxQYj + T45GXxRd18neXtuYa/OoAw9UQFDN5XfXN0g= + -----END CERTIFICATE----- diff --git a/charts/gundeck/templates/tests/gundeck-integration.yaml b/charts/gundeck/templates/tests/gundeck-integration.yaml index 39fe64a2fc8..7f92351be5a 100644 --- a/charts/gundeck/templates/tests/gundeck-integration.yaml +++ b/charts/gundeck/templates/tests/gundeck-integration.yaml @@ -17,6 +17,33 @@ spec: - name: integration # TODO: When deployed to staging (or real AWS env), _all_ tests should be run command: ["gundeck-integration", "--pattern", "!/RealAWS/"] + command: + - /bin/bash + - -c + - | + set -euo pipefail + + if gundeck-integration --xml "$TEST_XML" --pattern "!/RealAWS/"; then + exit_code=$? + else + exit_code=$? + fi + + {{- if .Values.tests.config.uploadXml }} + # In case a different S3 compliant storage is used to upload test result. + if ! [[ -z "${UPLOAD_XML_AWS_ACCESS_KEY_ID+x}" ]]; then + export AWS_ACCESS_KEY_ID="$UPLOAD_XML_AWS_ACCESS_KEY_ID" + export AWS_SECRET_ACCESS_KEY="$UPLOAD_XML_AWS_SECRET_ACCESS_KEY" + fi + + # The `|| echo ..` ensures that the test isn't seen as failed even if the upload fails. + ts=$(date --utc '+%Y%m%d%H%M%S%N') + uploadUrl="$UPLOAD_XML_S3_BASE_URL/gundeck-integration/${ts}.xml" + echo "Uploading xml result to: $uploadUrl" + aws s3 cp "$TEST_XML" "$uploadUrl" || echo "failed to upload result" + {{- end }} + + exit $exit_code image: "{{ .Values.image.repository }}-integration:{{ .Values.image.tag }}" {{- if eq (include "includeSecurityContext" .) "true" }} securityContext: @@ -35,4 +62,22 @@ spec: value: "dummy" - name: AWS_REGION value: "eu-west-1" + - name: TEST_XML + value: /tmp/result.xml + {{- if .Values.tests.config.uploadXml }} + - name: UPLOAD_XML_S3_BASE_URL + value: {{ .Values.tests.config.uploadXml.baseUrl }} + {{- if .Values.tests.secrets.uploadXmlAwsAccessKeyId }} + - name: UPLOAD_XML_AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: gundeck-integration + key: uploadXmlAwsAccessKeyId + - name: UPLOAD_XML_AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: gundeck-integration + key: uploadXmlAwsSecretAccessKey + {{- end }} + {{- end }} restartPolicy: Never diff --git a/charts/gundeck/templates/tests/secret.yaml b/charts/gundeck/templates/tests/secret.yaml new file mode 100644 index 00000000000..1af8959e4c3 --- /dev/null +++ b/charts/gundeck/templates/tests/secret.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Secret +metadata: + name: gundeck-integration + labels: + app: gundeck-integration + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + {{- with .Values.tests.secrets }} + uploadXmlAwsAccessKeyId: {{ .uploadXmlAwsAccessKeyId | b64enc | quote }} + uploadXmlAwsSecretAccessKey: {{ .uploadXmlAwsSecretAccessKey | b64enc | quote }} + {{- end }} + diff --git a/charts/gundeck/values.yaml b/charts/gundeck/values.yaml index 0ec2ab9efad..28416361448 100644 --- a/charts/gundeck/values.yaml +++ b/charts/gundeck/values.yaml @@ -68,3 +68,11 @@ podSecurityContext: runAsNonRoot: true seccompProfile: type: RuntimeDefault +tests: + config: {} +# config: +# uploadXml: +# baseUrl: s3://bucket/path/ +# secrets: +# uploadXmlAwsAccessKeyId: +# uploadXmlAwsSecretAccessKey: diff --git a/charts/integration/templates/configmap.yaml b/charts/integration/templates/configmap.yaml index 990eec362bc..99a247203ae 100644 --- a/charts/integration/templates/configmap.yaml +++ b/charts/integration/templates/configmap.yaml @@ -57,6 +57,10 @@ data: originDomain: federation-test-helper.{{ .Release.Namespace }}.svc.cluster.local + rabbitmq: + host: rabbitmq + adminPort: 15672 + backendTwo: brig: @@ -115,3 +119,5 @@ data: domain: {{ $dynamicBackend.federatorExternalHostPrefix }}.{{ $.Release.Namespace }}.svc.cluster.local federatorExternalPort: {{ $dynamicBackend.federatorExternalPort }} {{- end }} + cassandra: +{{ toYaml .Values.config.cassandra | indent 6}} diff --git a/charts/integration/templates/integration-integration.yaml b/charts/integration/templates/integration-integration.yaml index 044d28b7410..044b63b3b9a 100644 --- a/charts/integration/templates/integration-integration.yaml +++ b/charts/integration/templates/integration-integration.yaml @@ -111,7 +111,7 @@ spec: - | set -euo pipefail # FUTUREWORK: Do all of this in the integration test binary - integration-dynamic-backends-db-schemas.sh --host {{ .Values.config.cassandra.host }} --port 9042 --replication-factor {{ .Values.config.cassandra.replicationFactor }} + integration-dynamic-backends-db-schemas.sh --host {{ .Values.config.cassandra.host }} --port {{ .Values.config.cassandra.port }} --replication-factor {{ .Values.config.cassandra.replicationFactor }} integration-dynamic-backends-brig-index.sh --elasticsearch-server http://{{ .Values.config.elasticsearch.host }}:9200 integration-dynamic-backends-sqs.sh {{ .Values.config.sqsEndpointUrl }} integration-dynamic-backends-ses.sh {{ .Values.config.sesEndpointUrl }} @@ -131,7 +131,31 @@ spec: securityContext: {{- toYaml .Values.podSecurityContext | nindent 6 }} {{- end }} - command: [ "integration", "--config", "/etc/wire/integration/integration.yaml" ] + command: + - /bin/bash + - -c + - | + set -euo pipefail + + if integration --config /etc/wire/integration/integration.yaml; then + exit_code=$? + else + exit_code=$? + fi + + {{- if .Values.config.uploadXml }} + # In case a different S3 compliant storage is used to upload test result. + if ! [[ -z "${UPLOAD_XML_AWS_ACCESS_KEY_ID+x}" ]]; then + export AWS_ACCESS_KEY_ID="$UPLOAD_XML_AWS_ACCESS_KEY_ID" + export AWS_SECRET_ACCESS_KEY="$UPLOAD_XML_AWS_SECRET_ACCESS_KEY" + fi + + # The `|| echo ..` ensures that the test isn't seen as failed even if the upload fails. + ts=$(date --utc '+%Y%m%d%H%M%S%N') + aws s3 cp "$TEST_XML" "$UPLOAD_XML_S3_BASE_URL/integration/${ts}.xml" || echo "failed to upload result" + {{- end }} + + exit $exit_code resources: requests: memory: "512Mi" @@ -207,3 +231,21 @@ spec: secretKeyRef: name: brig key: rabbitmqPassword + - name: TEST_XML + value: /tmp/result.xml + {{- if .Values.config.uploadXml }} + - name: UPLOAD_XML_S3_BASE_URL + value: {{ .Values.config.uploadXml.baseUrl }} + {{- if .Values.secrets.uploadXmlAwsAccessKeyId }} + - name: UPLOAD_XML_AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: integration + key: uploadXmlAwsAccessKeyId + - name: UPLOAD_XML_AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: integration + key: uploadXmlAwsSecretAccessKey + {{- end }} + {{- end }} diff --git a/charts/integration/templates/secret.yaml b/charts/integration/templates/secret.yaml new file mode 100644 index 00000000000..52c3199b5f0 --- /dev/null +++ b/charts/integration/templates/secret.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: integration + labels: + app: integration + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + {{- with .Values.secrets }} + uploadXmlAwsAccessKeyId: {{ .uploadXmlAwsAccessKeyId | b64enc | quote }} + uploadXmlAwsSecretAccessKey: {{ .uploadXmlAwsSecretAccessKey | b64enc | quote }} + {{- end }} diff --git a/charts/integration/values.yaml b/charts/integration/values.yaml index 96302f56baf..25de2d456e7 100644 --- a/charts/integration/values.yaml +++ b/charts/integration/values.yaml @@ -26,6 +26,7 @@ config: cassandra: host: cassandra-ephemeral + port: 9042 replicationFactor: 1 elasticsearch: @@ -41,3 +42,5 @@ tls: ingress: class: nginx + +secrets: {} diff --git a/charts/mlsstats/.helmignore b/charts/mlsstats/.helmignore new file mode 100644 index 00000000000..f0c13194444 --- /dev/null +++ b/charts/mlsstats/.helmignore @@ -0,0 +1,21 @@ +# Patterns to ignore when building packages. +# This supports shell glob matching, relative path matching, and +# negation (prefixed with !). Only one pattern per line. +.DS_Store +# Common VCS dirs +.git/ +.gitignore +.bzr/ +.bzrignore +.hg/ +.hgignore +.svn/ +# Common backup files +*.swp +*.bak +*.tmp +*~ +# Various IDEs +.project +.idea/ +*.tmproj diff --git a/charts/mlsstats/Chart.yaml b/charts/mlsstats/Chart.yaml new file mode 100644 index 00000000000..24d97f8f2e4 --- /dev/null +++ b/charts/mlsstats/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: mlsstats - Push Proteus to MLS statistics to S3 +name: mlsstats +version: 0.0.1 diff --git a/charts/mlsstats/README.md b/charts/mlsstats/README.md new file mode 100644 index 00000000000..b9c8c118340 --- /dev/null +++ b/charts/mlsstats/README.md @@ -0,0 +1,7 @@ +# mlsstats + +The kubernetes cronjob resource will spawn a new `mlsstats-XXXXXX` pod every day. Logs for the pod can be gathered with `kubectl log`. + +## Important note + +This cron job is _not_ meant for general use! It can leak data about one team to other teams. diff --git a/charts/mlsstats/templates/_helpers.tpl b/charts/mlsstats/templates/_helpers.tpl new file mode 100644 index 00000000000..c288d2067da --- /dev/null +++ b/charts/mlsstats/templates/_helpers.tpl @@ -0,0 +1,13 @@ +{{/* Allow KubeVersion to be overridden. */}} +{{- define "kubeVersion" -}} + {{- default .Capabilities.KubeVersion.Version .Values.kubeVersionOverride -}} +{{- end -}} + +{{/* Get Batch API Version */}} +{{- define "batch.apiVersion" -}} + {{- if and (.Capabilities.APIVersions.Has "batch/v1") (semverCompare ">= 1.21-0" (include "kubeVersion" .)) -}} + {{- print "batch/v1" -}} + {{- else -}} + {{- print "batch/v1beta1" -}} + {{- end -}} +{{- end -}} diff --git a/charts/mlsstats/templates/cronjob.yaml b/charts/mlsstats/templates/cronjob.yaml new file mode 100644 index 00000000000..5247b15cd38 --- /dev/null +++ b/charts/mlsstats/templates/cronjob.yaml @@ -0,0 +1,60 @@ +apiVersion: {{ include "batch.apiVersion" . }} +kind: CronJob +metadata: + name: {{ .Release.Name }} + labels: + app: mlsstats + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + concurrencyPolicy: Forbid + schedule: {{ .Values.schedule | quote }} + jobTemplate: + metadata: + labels: + app: mlsstats + release: {{ .Release.Name }} + annotations: + # An annotation of the configmap checksum ensures changes to the configmap cause a redeployment upon `helm upgrade` + checksum/secret: {{ include (print .Template.BasePath "/secret.yaml") . | sha256sum }} + spec: + backoffLimit: 0 + template: + spec: + restartPolicy: Never + containers: + - name: mlsstats + image: "{{ .Values.image.repository }}:{{ .Values.image.tag }}" + imagePullPolicy: {{ default "" .Values.imagePullPolicy | quote }} + args: [ "mlsstats" + , "--brig-cassandra-host", {{ .Values.config.cassandra.brig.host | quote }} + , "--brig-cassandra-port", {{ .Values.config.cassandra.brig.port | quote }} + , "--brig-cassandra-keyspace", {{ .Values.config.cassandra.brig.keyspace | quote }} + , "--galley-cassandra-host", {{ .Values.config.cassandra.galley.host | quote }} + , "--galley-cassandra-port", {{ .Values.config.cassandra.galley.port | quote }} + , "--galley-cassandra-keyspace", {{ .Values.config.cassandra.galley.keyspace | quote }} + , "--cassandra-pagesize", {{ .Values.config.cassandra.pagesize | quote }} + , "--s3-endpoint", {{ .Values.config.s3.endpoint | quote -}} + , "--s3-region", {{ .Values.config.s3.region | quote -}} + , "--s3-addressing-style", {{ .Values.config.s3.addressingStyle | quote }} + , "--s3-bucket-name", {{ .Values.config.s3.bucket.name | quote }} + , "--s3-bucket-dir", {{ .Values.config.s3.bucket.directory | quote }} + ] + resources: + env: + {{- if hasKey .Values.secrets "awsKeyId" }} + - name: AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: mlsstats + key: awsKeyId + - name: AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: mlsstats + key: awsSecretKey + {{- end }} + - name: AWS_REGION + value: "{{ .Values.config.s3.region }}" +{{ toYaml .Values.resources | indent 16 }} diff --git a/charts/mlsstats/templates/secret.yaml b/charts/mlsstats/templates/secret.yaml new file mode 100644 index 00000000000..3f93be5120c --- /dev/null +++ b/charts/mlsstats/templates/secret.yaml @@ -0,0 +1,20 @@ +apiVersion: v1 +kind: Secret +metadata: + name: {{ .Release.Name }} + labels: + app: mlsstats + chart: "{{ .Chart.Name }}-{{ .Chart.Version }}" + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + {{/* for_helm_linting is necessary only since the 'with' block below does not throw an error upon an empty .Values.secrets */}} + for_helm_linting: {{ required "No .secrets found in configuration. Did you forget to helm -f path/to/secrets.yaml ?" .Values.secrets | quote | b64enc | quote }} + + {{- with .Values.secrets }} + {{- if .awsKeyId }} + awsKeyId: {{ .awsKeyId | b64enc | quote }} + awsSecretKey: {{ .awsSecretKey | b64enc | quote }} + {{- end }} + {{- end }} diff --git a/charts/mlsstats/values.yaml b/charts/mlsstats/values.yaml new file mode 100644 index 00000000000..399bc1ec996 --- /dev/null +++ b/charts/mlsstats/values.yaml @@ -0,0 +1,30 @@ +image: + repository: quay.io/wire/mlsstats + tag: 0.1 +resources: + requests: + memory: "256Mi" + cpu: "100m" + limits: + memory: "256Mi" + cpu: "100m" +schedule: "23 3 * * *" +config: + cassandra: + brig: + host: cassandra + port: 9042 + keyspace: brig + galley: + host: cassandra + port: 9042 + keyspace: galley + pagesize: 1024 + s3: + endpoint: https://s3.eu-west-1.amazonaws.com/ + region: eu-west-1 + addressingStyle: auto + bucket: + name: mlsstats + directory: "/" +secrets: {} diff --git a/charts/nginz/values.yaml b/charts/nginz/values.yaml index 6edf14c2598..83f89007609 100644 --- a/charts/nginz/values.yaml +++ b/charts/nginz/values.yaml @@ -433,6 +433,9 @@ nginx_conf: - all max_body_size: 40m body_buffer_size: 256k + - path: /conversations/([^/]*)/([^/]*)/protocol + envs: + - all - path: /broadcast envs: - all @@ -542,6 +545,8 @@ nginx_conf: - path: /mls/commit-bundles envs: - all + max_body_size: 70m + body_buffer_size: 256k - path: /mls/public-keys envs: - all diff --git a/charts/outlook-addin/Chart.yaml b/charts/outlook-addin/Chart.yaml new file mode 100644 index 00000000000..6367ea14dfa --- /dev/null +++ b/charts/outlook-addin/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +name: outlook-addin +version: 4.38.0 +description: Helm chart for outlook addin for Wire diff --git a/charts/outlook-addin/README.md b/charts/outlook-addin/README.md new file mode 100644 index 00000000000..18c6e40048e --- /dev/null +++ b/charts/outlook-addin/README.md @@ -0,0 +1,191 @@ +# How to install Outlook AddIn for Wire-Server + +WIP: Some of these configurations are subject to change down the line. This documentation will be updated accordingly as they happen. + +This document assumes you already have an instance of wire-server running. If you don't, follow this [documentation](https://github.com/wireapp/wire-server-deploy/blob/master/offline/docs.md) + +## Set up OAuth with wire-server + +To use OAuth, first you will need to enable it by editing `values/wire-server/values.yaml` as follows: + +``` +brig: + # ... + config: + # ... + optSettings: + # ... + setOAuthEnabled: true +``` + +Then you will need to generate a key using "OKP" (Octet Key Pair) and the "Ed25519" curve with OpenSSL that will be used as JWK (JSON Web Key) in the wire-server helm chart. This key will be used to sign and verify [OAuth](https://docs.wire.com/developer/reference/oauth.html#setting-up-public-and-private-keys) access tokens. + +``` +openssl genpkey -algorithm Ed25519 -out private_key.pem +``` + +You can find a `generate_jwk.py` in this chart which you can use to generate the JWK in JSON format that can be used in your wire-server helm chart. Use it in `brig` and `nginz` namespaces in `values/wire-server/secrets.yaml` like shown below. + +``` +brig: + secrets: + oauthJwkKeyPair: | + { + "kty": "OKP", + "crv": "Ed25519", + "x": "...", + "d": "...", + "kid": "..." + } +``` + +``` +# values.yaml or secrets.yaml +nginz: + secrets: + oAuth: + publicKeys: | + { + "kty": "OKP", + "crv": "Ed25519", + "x": "...", + "kid": "..." + } +``` + +Now redeploy wire-server chart: + +``` +d helm upgrade --install wire-server charts/wire-server --values values/wire-server/values.yaml --values/wire-server/secrets.yaml +``` + +## Outlook integration feature flag + +By default, outlook addin as a feature is disabled for all teams. To change this make the following changes in your configuration in `galley` namespace: + +``` +galley: + config: + # ... + settings: + # ... + featureFlags: + # ... + outlookCalIntegration: + defaults: + status: enabled + lockStatus: unlocked +``` + +Redeploy wire-server for these changes to take effect. + +NOTE: As of the time of writing `outlookCalIntegration` is not a typo! (at least not in this documentation) + +If you have an existing team in your wire-server that did not have this feature flag enabled prior to this. You will need to enable that feature flag through [Backoffice API](https://github.com/wireapp/wire-server/tree/05778a2b14ac5aaffca937d6e2cdd9b7b5f3106d/charts/backoffice). + +NOTE: As of the time of writing Backoffice API endpoint for enabling this feature flag is not working as intended so please follow this manual on how to do it with curl on the machine wire-server is running on. + +### How to manually enable outlookCalIntegration feature flag for a team + +You will need your `teamId` (you can find it in TeamSettings under Customization tab). +List all your pods in your Kubernetes cluster with: + +``` +d kubectl get pods -owide +``` + +Copy the name of one of your galley pods and run: + +``` +d kubectl exec -it galley_pod_name /bin/bash +``` + +In the new terminal type: + +``` +curl -v -XPATCH 'http://localhost:8080/i/teams/your_teamID/features/outlookCalIntegration' -H 'content-type: application/json;charset=utf-8' -d '{"status": "enabled", "lockStatus": "unlocked"}' +``` + +Do this for all the teams you want to enable the feature for. + +## Create new client service for OAuth in Brig + +List all your pods in your Kubernetes cluster with: + +``` +d kubectl get pods -owide +``` + +Copy the name of one of your brig pods and run: + +``` +d kubectl exec -it brig_pod_name /bin/bash +``` + +In the new terminal type: + +``` +curl -s -X POST localhost:8080/i/oauth/clients \ + -H "Content-Type: application/json" \ + -d '{ + "application_name":"Wire Microsoft Outlook Calendar Add-in", + "redirect_url":"https://outlook.example.com/callback.html" + }' +``` + +You will get back a response in JSON format that should look like: + +``` +{"client_id":"b2b3...","client_secret":"9ee60..."} +``` + +Write down your client_id as it will be needed later. + +## Deploying Wire Outlook AddIn + +Create a new `values.yaml` file in `values/outlook-addin` directory (create the directory too if missing). +Append the following configuration (change the example.com with your domain). + +``` +host: "outlook.example.com" # this entry has to be without https://!!! +wireApiBaseUrl: "https://nginz-https.example.com" +wireAuthorizationEndpoint: "https://webapp.example.com/auth" +clientId: "" +``` + +As of the time of writing nginz used by wire-server is not set up to whitelist outlook subdomain for CORS requests. So please edit `charts/wire-server/charts/nginz/values.yaml` and find under `nginx_conf`: + +``` + allowlisted_origins: + - webapp + - teams + - account + - outlook # add outlook entry so your addin doesnt get CORS blocked +``` + +### Certificates + +If you are using cert-manager just make the following configuration in values.yaml: + +``` +tls: + issuerRef: + name: letsencrypt-http01 # letsencrypt-http01 is a default config in wire-server, change if needed in your instance +``` + +Now deploy outlook addin chart with: + +``` +d helm upgrade --install outlook-addin charts/outlook-addin --values values/outlook-addin/values.yaml +``` + +If you are using your own provided certificates, deploy the addin with this command: + +``` +d helm upgrade --install outlook-addin charts/outlook-addin --values values/outlook-addin/values.yaml --set-file tls.crt=/path/to/tls.crt --set-file tls.key=/path/to/tls.key +``` + +## Install Wire AddIn in Microsoft Outlook + +After deploying `outlook-addin` you will be able to find `manifest.xml` file on https://outlook.example.com/manifest.xml which you can use to install the addin in your outlook. You can find instructions and screenshots how to do it [here](https://github.com/tlebon/outlook-addin/blob/staging/README.md#how-to-install-the-add-in-in-ms-outlook). +NOTE: Links in the outlined documents are hardcoded for a testing/prod environment, any reference to zinfra.io or wire.com in it should be treated as example.com. diff --git a/charts/outlook-addin/generate_jwk.py b/charts/outlook-addin/generate_jwk.py new file mode 100644 index 00000000000..64ff106fe52 --- /dev/null +++ b/charts/outlook-addin/generate_jwk.py @@ -0,0 +1,23 @@ +import json +from jwcrypto import jwk +import base64 + +def pem_to_jwk(pem_key, is_private=True): + key = jwk.JWK.from_pem(pem_key) + if is_private: + key_dict = key.export(as_dict=True, private_key=True) + else: + key_dict = key.export(as_dict=True, private_key=False) + return key_dict + +def convert_to_pem(base64_key): + pem_key = base64.b64decode(base64_key) + return pem_key + +with open("private_key.pem", "rb") as f: + private_key_pem = f.read() + private_key_b64 = base64.b64encode(private_key_pem).decode('utf-8') + private_jwk = pem_to_jwk(convert_to_pem(private_key_b64), is_private=True) + +print("Private JWK:") +print(json.dumps(private_jwk, indent=2)) diff --git a/charts/outlook-addin/templates/_helpers.tpl b/charts/outlook-addin/templates/_helpers.tpl new file mode 100644 index 00000000000..c2f40c04c95 --- /dev/null +++ b/charts/outlook-addin/templates/_helpers.tpl @@ -0,0 +1,82 @@ +{{/* +Expand the name of the chart. +*/}} +{{- define "outlook.name" -}} +{{- default .Chart.Name .Values.nameOverride | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Create a default fully qualified app name. +We truncate at 63 chars because some Kubernetes name fields are limited to this (by the DNS naming spec). +If release name contains chart name it will be used as a full name. +*/}} +{{- define "outlook.fullname" -}} +{{- if .Values.fullnameOverride }} +{{- .Values.fullnameOverride | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- $name := default .Chart.Name .Values.nameOverride }} +{{- if contains $name .Release.Name }} +{{- .Release.Name | trunc 63 | trimSuffix "-" }} +{{- else }} +{{- printf "%s-%s" .Release.Name $name | trunc 63 | trimSuffix "-" }} +{{- end }} +{{- end }} +{{- end }} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "outlook.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" }} +{{- end }} + +{{/* +Common labels +*/}} +{{- define "outlook.labels" -}} +helm.sh/chart: {{ include "outlook.chart" . }} +{{ include "outlook.selectorLabels" . }} +{{- if .Chart.AppVersion }} +app.kubernetes.io/version: {{ .Chart.AppVersion | quote }} +{{- end }} +app.kubernetes.io/managed-by: {{ .Release.Service }} +{{- end }} + +{{/* +Selector labels +*/}} +{{- define "outlook.selectorLabels" -}} +app.kubernetes.io/name: {{ include "outlook.name" . }} +app.kubernetes.io/instance: {{ .Release.Name }} +{{- end }} + +{{/* Allow KubeVersion to be overridden. */}} +{{- define "kubeVersion" -}} + {{- default .Capabilities.KubeVersion.Version .Values.kubeVersionOverride -}} +{{- end -}} + +{{/* Get Ingress API Version */}} +{{- define "ingress.apiVersion" -}} + {{- if and (.Capabilities.APIVersions.Has "networking.k8s.io/v1") (semverCompare ">= 1.19-0" (include "kubeVersion" .)) -}} + {{- print "networking.k8s.io/v1" -}} + {{- else if .Capabilities.APIVersions.Has "networking.k8s.io/v1beta1" -}} + {{- print "networking.k8s.io/v1beta1" -}} + {{- else -}} + {{- print "extensions/v1beta1" -}} + {{- end -}} +{{- end -}} + +{{/* Check Ingress stability */}} +{{- define "ingress.isStable" -}} + {{- eq (include "ingress.apiVersion" .) "networking.k8s.io/v1" -}} +{{- end -}} + +{{/* Check Ingress supports pathType */}} +{{/* pathType was added to networking.k8s.io/v1beta1 in Kubernetes 1.18 */}} +{{- define "ingress.supportsPathType" -}} + {{- or (eq (include "ingress.isStable" .) "true") (and (eq (include "ingress.apiVersion" .) "networking.k8s.io/v1beta1") (semverCompare ">= 1.18-0" (include "kubeVersion" .))) -}} +{{- end -}} + +{{- define "ingress.FieldNotAnnotation" -}} + {{- (semverCompare ">= 1.27-0" (include "kubeVersion" .)) -}} +{{- end -}} diff --git a/charts/outlook-addin/templates/deployment.yaml b/charts/outlook-addin/templates/deployment.yaml new file mode 100644 index 00000000000..a9679ab816b --- /dev/null +++ b/charts/outlook-addin/templates/deployment.yaml @@ -0,0 +1,40 @@ +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: {{ include "outlook.fullname" . }} + labels: + {{- include "outlook.labels" . | nindent 4 }} +spec: + replicas: 3 + selector: + matchLabels: + app: {{ include "outlook.fullname" . }} + template: + metadata: + labels: + app: {{ include "outlook.fullname" . }} + spec: + containers: + - name: {{ include "outlook.fullname" . }} + image: {{ .Values.containerImage }} + ports: + - name: http + containerPort: 80 + env: + - name: BASE_URL + value: "https://{{ .Values.host }}" + - name: CLIENT_ID + value: "{{ .Values.clientId }}" + - name: WIRE_API_BASE_URL + value: "{{ .Values.wireApiBaseUrl }}" + - name: WIRE_AUTHORIZATION_ENDPOINT + value: "{{ .Values.wireAuthorizationEndpoint }}" + livenessProbe: + httpGet: + path: / + port: http + readinessProbe: + httpGet: + path: / + port: http diff --git a/charts/outlook-addin/templates/ingress.yaml b/charts/outlook-addin/templates/ingress.yaml new file mode 100644 index 00000000000..f006d3dc0e6 --- /dev/null +++ b/charts/outlook-addin/templates/ingress.yaml @@ -0,0 +1,34 @@ +{{- $apiIsStable := eq (include "ingress.isStable" .) "true" -}} +{{- $ingressFieldNotAnnotation := eq (include "ingress.FieldNotAnnotation" .) "true" -}} +{{- $ingressSupportsPathType := eq (include "ingress.supportsPathType" .) "true" -}} +apiVersion: {{ include "ingress.apiVersion" . }} +kind: Ingress +metadata: + name: "{{ include "outlook.fullname" . }}" + labels: + {{- include "outlook.labels" . | nindent 4 }} + annotations: + {{- if not $ingressFieldNotAnnotation }} + kubernetes.io/ingress.class: "{{ .Values.config.ingressClass }}" + {{- end }} + nginx.ingress.kubernetes.io/enable-cors: "true" + nginx.ingress.kubernetes.io/cors-allow-origin: "{{ required "Must specify allowOrigin" .Values.allowOrigin }}" +spec: + {{- if $ingressFieldNotAnnotation }} + ingressClassName: "{{ .Values.config.ingressClass }}" + {{- end }} + tls: + - hosts: + - "{{ .Values.host }}" + secretName: "{{ include "outlook.fullname" . }}" + rules: + - host: "{{ .Values.host }}" + http: + paths: + - path: / + pathType: Prefix + backend: + service: + name: {{ include "outlook.fullname" . }} + port: + number: 8080 diff --git a/charts/outlook-addin/templates/secret-or-certificate.yaml b/charts/outlook-addin/templates/secret-or-certificate.yaml new file mode 100644 index 00000000000..5199985ed9d --- /dev/null +++ b/charts/outlook-addin/templates/secret-or-certificate.yaml @@ -0,0 +1,34 @@ +{{- if .Values.tls.issuerRef -}} +{{- if or .Values.tls.key .Values.tls.crt }} +{{- fail "ingress.issuer and ingress.{crt,key} are mutually exclusive" -}} +{{- end -}} +apiVersion: cert-manager.io/v1 +kind: Certificate +metadata: + name: "{{ include "outlook.fullname" . }}" + labels: + {{- include "outlook.labels" . | nindent 4 }} +spec: + dnsNames: + - {{ .Values.host }} + secretName: "{{ include "outlook.fullname" . }}" + issuerRef: + {{- toYaml .Values.tls.issuerRef | nindent 4 }} + privateKey: + rotationPolicy: Always + algorithm: ECDSA + size: 384 +{{- else if and .Values.tls.key .Values.tls.crt -}} +apiVersion: v1 +kind: Secret +metadata: + name: "{{ include "outlook.fullname" . }}" + labels: + {{- include "outlook.labels" . | nindent 4 }} +type: kubernetes.io/tls +data: + tls.key: {{ required "tls.key is required" .Values.tls.key | b64enc }} + tls.crt: {{ required "tls.crt is required" .Values.tls.crt | b64enc }} +{{- else -}} +{{- fail "must specify tls.key and tls.crt , or tls.issuerRef" -}} +{{- end -}} \ No newline at end of file diff --git a/charts/outlook-addin/templates/service.yaml b/charts/outlook-addin/templates/service.yaml new file mode 100644 index 00000000000..254430f197e --- /dev/null +++ b/charts/outlook-addin/templates/service.yaml @@ -0,0 +1,17 @@ +--- +apiVersion: v1 +kind: Service +metadata: + name: {{ include "outlook.fullname" . }} + labels: + {{- include "outlook.labels" . | nindent 4 }} +spec: + selector: + app: {{ include "outlook.fullname" . }} + ports: + - name: http + port: 8080 + targetPort: http + type: ClusterIP + + diff --git a/charts/outlook-addin/values.yaml b/charts/outlook-addin/values.yaml new file mode 100644 index 00000000000..6bc14091928 --- /dev/null +++ b/charts/outlook-addin/values.yaml @@ -0,0 +1,22 @@ +# Default values for outlook-addin +# +# +containerImage: "quay.io/wire/outlook-addin:0.1.9" + +config: + ingressClass: nginx + +# host: "outlook.example.com" +# wireApiBaseUrl: "https://nginz-https.example.com" +# wireAuthorizationEndpoint: "https://webapp.example.com/auth" +# whitelisting for CORS +# allowOrigin: "https://webapp.example.com, https://nginz-https.example.com" +tls: {} +# {key,crt} and issuerRef are mutally exclusive + # key: + # crt: + # issuerRef: + # name: letsencrypt-http01 + +# clientId is obtained after registering outlook service with wire OAuth, more details in README +# clientId: "" diff --git a/charts/rabbitmq-external/Chart.yaml b/charts/rabbitmq-external/Chart.yaml new file mode 100644 index 00000000000..bb6f95452eb --- /dev/null +++ b/charts/rabbitmq-external/Chart.yaml @@ -0,0 +1,4 @@ +apiVersion: v1 +description: Refer to rabbitmq IPs located outside kubernetes by specifying IPs manually +name: rabbitmq-external +version: 0.0.42 diff --git a/charts/rabbitmq-external/templates/endpoint.yaml b/charts/rabbitmq-external/templates/endpoint.yaml new file mode 100644 index 00000000000..0a4f9b728ef --- /dev/null +++ b/charts/rabbitmq-external/templates/endpoint.yaml @@ -0,0 +1,38 @@ +# create a headless clusterIP service to create dns name "rabbitmq-external" +# and a custom endpoint, thus forwarding traffic when resolving DNS to custom IPs +kind: Service +apiVersion: v1 +metadata: + name: {{ .Chart.Name }} + labels: + app: {{ .Chart.Name }} + chart: {{ template "rabbitmq-external.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +spec: + type: ClusterIP + clusterIP: None # headless service + ports: + - name: http + port: {{ .Values.portHttp }} + targetPort: {{ .Values.portHttp }} +--- +kind: Endpoints +apiVersion: v1 +metadata: + name: {{ .Chart.Name }} + labels: + app: {{ .Chart.Name }} + chart: {{ template "rabbitmq-external.chart" . }} + release: {{ .Release.Name }} + heritage: {{ .Release.Service }} +subsets: + - addresses: + {{- range .Values.IPs }} + - ip: {{ . }} + {{- end }} + ports: + # port and name in the endpoint must match port and name in the service + # see also https://docs.openshift.com/enterprise/3.0/dev_guide/integrating_external_services.html + - name: http + port: {{ .Values.portHttp }} diff --git a/charts/rabbitmq-external/templates/helpers.tpl b/charts/rabbitmq-external/templates/helpers.tpl new file mode 100644 index 00000000000..4241fe46aa7 --- /dev/null +++ b/charts/rabbitmq-external/templates/helpers.tpl @@ -0,0 +1,11 @@ +{{- define "rabbitmq-external.fullname" -}} +{{- $name := default .Chart.Name .Values.nameOverride -}} +{{- printf "%s" $name | trunc 63 | trimSuffix "-" -}} +{{- end -}} + +{{/* +Create chart name and version as used by the chart label. +*/}} +{{- define "rabbitmq-external.chart" -}} +{{- printf "%s-%s" .Chart.Name .Chart.Version | replace "+" "_" | trunc 63 | trimSuffix "-" -}} +{{- end -}} diff --git a/charts/rabbitmq-external/values.yaml b/charts/rabbitmq-external/values.yaml new file mode 100644 index 00000000000..bf5b1464814 --- /dev/null +++ b/charts/rabbitmq-external/values.yaml @@ -0,0 +1,6 @@ +portHttp: 5672 + +## Configure this helm chart with: +# IPs: +# - 1.2.3.4 +# - 5.6.7.8 diff --git a/charts/spar/templates/tests/secret.yaml b/charts/spar/templates/tests/secret.yaml new file mode 100644 index 00000000000..1be597127b8 --- /dev/null +++ b/charts/spar/templates/tests/secret.yaml @@ -0,0 +1,15 @@ +apiVersion: v1 +kind: Secret +metadata: + name: spar-integration + labels: + app: spar-integration + chart: {{ .Chart.Name }}-{{ .Chart.Version | replace "+" "_" }} + release: "{{ .Release.Name }}" + heritage: "{{ .Release.Service }}" +type: Opaque +data: + {{- with .Values.tests.secrets }} + uploadXmlAwsAccessKeyId: {{ .uploadXmlAwsAccessKeyId | b64enc | quote }} + uploadXmlAwsSecretAccessKey: {{ .uploadXmlAwsSecretAccessKey | b64enc | quote }} + {{- end }} diff --git a/charts/spar/templates/tests/spar-integration.yaml b/charts/spar/templates/tests/spar-integration.yaml index af6aa3d420d..ff937f3d18e 100644 --- a/charts/spar/templates/tests/spar-integration.yaml +++ b/charts/spar/templates/tests/spar-integration.yaml @@ -23,6 +23,34 @@ spec: securityContext: {{- toYaml .Values.podSecurityContext | nindent 6 }} {{- end }} + command: + - /bin/bash + - -c + - | + set -euo pipefail + + if spar-integration -f junit; then + exit_code=$? + else + exit_code=$? + fi + + {{- if .Values.tests.config.uploadXml }} + # In case a different S3 compliant storage is used to upload test result. + if ! [[ -z "${UPLOAD_XML_AWS_ACCESS_KEY_ID+x}" ]]; then + export AWS_ACCESS_KEY_ID="$UPLOAD_XML_AWS_ACCESS_KEY_ID" + export AWS_SECRET_ACCESS_KEY="$UPLOAD_XML_AWS_SECRET_ACCESS_KEY" + fi + TEST_XML="$JUNIT_OUTPUT_DIRECTORY/junit.xml" + + # The `|| echo ..` ensures that the test isn't seen as failed even if the upload fails. + ts=$(date --utc '+%Y%m%d%H%M%S%N') + uploadUrl="$UPLOAD_XML_S3_BASE_URL/spar-integration/${ts}.xml" + echo "Uploading xml result to: $uploadUrl" + aws s3 cp "$TEST_XML" "$uploadUrl" || echo "failed to upload result" + {{- end }} + + exit $exit_code volumeMounts: - name: "spar-integration" mountPath: "/etc/wire/integration" @@ -32,4 +60,25 @@ spec: requests: memory: "512Mi" cpu: "2" + env: + - name: JUNIT_OUTPUT_DIRECTORY + value: /tmp/ + - name: JUNIT_SUITE_NAME + value: spar + {{- if .Values.tests.config.uploadXml }} + - name: UPLOAD_XML_S3_BASE_URL + value: {{ .Values.tests.config.uploadXml.baseUrl }} + {{- if .Values.tests.secrets.uploadXmlAwsAccessKeyId }} + - name: UPLOAD_XML_AWS_ACCESS_KEY_ID + valueFrom: + secretKeyRef: + name: spar-integration + key: uploadXmlAwsAccessKeyId + - name: UPLOAD_XML_AWS_SECRET_ACCESS_KEY + valueFrom: + secretKeyRef: + name: spar-integration + key: uploadXmlAwsSecretAccessKey + {{- end }} + {{- end }} restartPolicy: Never diff --git a/charts/spar/values.yaml b/charts/spar/values.yaml index 512bbf1b7bf..073fd5b0ee6 100644 --- a/charts/spar/values.yaml +++ b/charts/spar/values.yaml @@ -37,3 +37,12 @@ podSecurityContext: runAsNonRoot: true seccompProfile: type: RuntimeDefault + +tests: + config: {} +# config: +# uploadXml: +# baseUrl: s3://bucket/path/ +# secrets: +# uploadXmlAwsAccessKeyId: +# uploadXmlAwsSecretAccessKey: diff --git a/charts/wire-server/requirements.yaml b/charts/wire-server/requirements.yaml index 9a2ff8649b0..b5350be6c80 100644 --- a/charts/wire-server/requirements.yaml +++ b/charts/wire-server/requirements.yaml @@ -129,3 +129,8 @@ dependencies: repository: "file://../integration" tags: - integration +- name: mlsstats + version: "0.0.42" + repository: "file://../mlsstats" + tags: + - mlsstats diff --git a/charts/wire-server/values.yaml b/charts/wire-server/values.yaml index a2ba0c3a518..3a0a3f1f525 100644 --- a/charts/wire-server/values.yaml +++ b/charts/wire-server/values.yaml @@ -12,3 +12,4 @@ tags: federation: false # see also galley.config.enableFederation and brig.config.enableFederation sftd: false backoffice: false + mlsstats: false diff --git a/deploy/dockerephemeral/docker-compose.yaml b/deploy/dockerephemeral/docker-compose.yaml index 04d804817f1..a988af62cae 100644 --- a/deploy/dockerephemeral/docker-compose.yaml +++ b/deploy/dockerephemeral/docker-compose.yaml @@ -14,6 +14,10 @@ services: container_name: demo_wire_dynamodb # image: cnadiminti/dynamodb-local:2018-04-11 image: julialongtin/dynamodb_local:0.0.9 + ulimits: + nofile: + soft: 65536 + hard: 65536 ports: - 127.0.0.1:4567:8000 networks: @@ -53,8 +57,7 @@ services: fake_s3: container_name: demo_wire_s3 -# image: minio/minio:RELEASE.2018-05-25T19-49-13Z - image: julialongtin/minio:0.0.9 + image: minio/minio:RELEASE.2023-07-07T07-13-57Z ports: - "127.0.0.1:4570:9000" environment: @@ -164,6 +167,10 @@ services: image: julialongtin/elasticsearch:0.0.9-amd64 # https://hub.docker.com/_/elastic is deprecated, but 6.2.4 did not work without further changes. # image: docker.elastic.co/elasticsearch/elasticsearch:6.2.4 + ulimits: + nofile: + soft: 65536 + hard: 65536 ports: - "127.0.0.1:9200:9200" - "127.0.0.1:9300:9300" @@ -198,7 +205,7 @@ services: rabbitmq: container_name: rabbitmq - image: rabbitmq:3-management-alpine + image: rabbitmq:3.11-management-alpine environment: - RABBITMQ_DEFAULT_USER=${RABBITMQ_USERNAME} - RABBITMQ_DEFAULT_PASS=${RABBITMQ_PASSWORD} diff --git a/deploy/dockerephemeral/init_vhosts.sh b/deploy/dockerephemeral/init_vhosts.sh index a7f9bd7c4a1..4c169ba4431 100755 --- a/deploy/dockerephemeral/init_vhosts.sh +++ b/deploy/dockerephemeral/init_vhosts.sh @@ -6,6 +6,8 @@ exec_until_ready() { echo 'Creating RabbitMQ resources' +exec_until_ready "curl -u $RABBITMQ_USERNAME:$RABBITMQ_PASSWORD -X PUT http://rabbitmq:15672/api/vhosts/backendA" +exec_until_ready "curl -u $RABBITMQ_USERNAME:$RABBITMQ_PASSWORD -X PUT http://rabbitmq:15672/api/vhosts/backendB" exec_until_ready "curl -u $RABBITMQ_USERNAME:$RABBITMQ_PASSWORD -X PUT http://rabbitmq:15672/api/vhosts/d1.example.com" exec_until_ready "curl -u $RABBITMQ_USERNAME:$RABBITMQ_PASSWORD -X PUT http://rabbitmq:15672/api/vhosts/d2.example.com" exec_until_ready "curl -u $RABBITMQ_USERNAME:$RABBITMQ_PASSWORD -X PUT http://rabbitmq:15672/api/vhosts/d3.example.com" diff --git a/docs/src/developer/developer/api-versioning.md b/docs/src/developer/developer/api-versioning.md index 053c8307496..29ebb8dee32 100644 --- a/docs/src/developer/developer/api-versioning.md +++ b/docs/src/developer/developer/api-versioning.md @@ -110,8 +110,7 @@ are several steps to make apart from deciding what endpoint changes are part of the version: - In `wire-api` extend the `Version` type with a new version by appending the - new version to the end, e.g., by adding `V4`. Make sure to update its - `ToSchema` instance, + new version to the end, e.g., by adding `V6`. - In the same `Version` module update the `developmentVersions` value to list only the new version, - Consider updating the `backendApiVersion` value in Stern, which is diff --git a/docs/src/developer/developer/building.md b/docs/src/developer/developer/building.md index e913a41b24e..ee219d3b98b 100644 --- a/docs/src/developer/developer/building.md +++ b/docs/src/developer/developer/building.md @@ -68,8 +68,8 @@ These services require most of the deployment dependencies as seen in the archit - SNS - S3 - DynamoDB -- Required additional software: - - netcat (in order to allow the services being tested to talk to the dependencies above) + +Furthermore, testing federation requires a local DNS server set up with appropriate SRV records. Setting up these real, but in-memory internal and "fake" external dependencies is done easiest using [`docker-compose`](https://docs.docker.com/compose/install/). Run the following in a separate terminal (it will block that terminal, C-c to shut all these docker images down again): @@ -77,11 +77,18 @@ Setting up these real, but in-memory internal and "fake" external dependencies i deploy/dockerephemeral/run.sh ``` +Also make sure your system is able to resolve the fully qualified domain `localhost.` (note the trailing dot). This is surprisingly not trivial, because of limitations in how libc parses `/etc/hosts`. You can check that with, for example, `ping localhost.`. If you get a name resolution error, you need to add `localhost.` explictly to your `/etc/hosts` file. + After all containers are up you can use these Makefile targets to run the tests locally: +0. Set your resource limits to a high enough number: + ```bash + ulimit 10240 + ``` + 1. Build and run all integration tests ```bash - make ci + make ci-safe ``` 2. Build and run integration tests for a service (say galley) @@ -91,19 +98,19 @@ After all containers are up you can use these Makefile targets to run the tests 3. Run integration tests written using `tasty` for a service (say galley) that match a pattern ```bash - TASTY_PATTERN="/MLS/" make ci package=galley + TASTY_PATTERN="/MLS/" make ci-safe package=galley ``` For more details on pattern formats, see tasty docs: https://github.com/UnkindPartition/tasty#patterns 4. Run integration tests written using `hspec` for a service (say spar) that match a pattern ```bash - HSPEC_MATCH='Scim' make ci package=spar + HSPEC_MATCH='Scim' make ci-safe package=spar ``` For more details on match formats, see hspec docs: https://hspec.github.io/match.html 5. Run integration tests without any parallelism ```bash - TASTY_NUM_THREADS=1 make ci package=brig + TASTY_NUM_THREADS=1 make ci-safe package=brig ``` `TASTY_NUM_THREADS` can also be set to other values, it defaults to number of cores available. diff --git a/docs/src/developer/developer/cassandra-interaction.md b/docs/src/developer/developer/cassandra-interaction.md index 4b59eda0547..08b20cae533 100644 --- a/docs/src/developer/developer/cassandra-interaction.md +++ b/docs/src/developer/developer/cassandra-interaction.md @@ -51,26 +51,13 @@ data you inserted, even if one replica of cassandra that holds that partition ke middle. The replication factor is specified when creating or migrating schemas, which is done in the -`cassandra-migrations` subchart of the `wire-server` chart: - -```{grepinclude} ../charts/cassandra-migrations/values.yaml host name and replication ---- -lines-after: 6 -language: yaml ---- -``` +`cassandra-migrations` subchart of the `wire-server` chart: [See link](https://github.com/wireapp/wire-server/blob/develop/charts/cassandra-migrations/values.yaml#L10) The number of cassandra nodes in use is specified on the infrastructure level (k8ssandra on kubernetes, or the inventory list when using ansible-cassandra) Quorum consistency (or, in our case, `LocalQuorum` consistency) is specified in our code. Random -example: - -```{grepinclude} ../services/brig/src/Brig/Data/User.hs userEmailUpdate \(params ---- -language: Haskell ---- -``` +example: [See link](https://github.com/wireapp/wire-server/blob/develop/services/brig/src/Brig/Data/User.hs#L637) Note that `Quorum` and `LocalQuorum` behave exactly the same in the context of a single datacentre (each datacentre can have multiple racks or availability zones). We switched from `Quorum` to diff --git a/docs/src/developer/developer/pr-guidelines.md b/docs/src/developer/developer/pr-guidelines.md index be0bf1f01b1..09b7e37fd22 100644 --- a/docs/src/developer/developer/pr-guidelines.md +++ b/docs/src/developer/developer/pr-guidelines.md @@ -87,6 +87,9 @@ If a PR adds new configuration options for say brig, the following files need to * [ ] The values files for CI: `hack/helm_vars/wire-server/values.yaml.gotmpl` * [ ] The configuration docs: `docs/src/developer/reference/config-options.md` +Additional configuration may also exist for services in the following locations. +* [ ] `charts/$SERVICE/templates/tests/configmap.yaml` + If any new configuration value is required and has no default, then: * [ ] Write a changelog entry in `0-release-notes` advertising the new configuration value diff --git a/docs/src/developer/reference/config-options.md b/docs/src/developer/reference/config-options.md index 3c681740f7b..d92d461479b 100644 --- a/docs/src/developer/reference/config-options.md +++ b/docs/src/developer/reference/config-options.md @@ -7,8 +7,6 @@ Fragment. This page is about the yaml files that determine the configuration of the Wire backend services. -## Settings in galley - ### MLS private key paths Note: This developer documentation. Documentation for site operators can be found here: {ref}`mls-message-layer-security` @@ -657,39 +655,71 @@ The default setting is that no API version is disabled. ## Settings in cargohold -### (Fake) AWS - AWS S3 (or an alternative provider / service) is used to upload and download assets. The Haddock of [`CargoHold.Options.AWSOpts`](https://github.com/wireapp/wire-server/blob/develop/services/cargohold/src/CargoHold/Options.hs#L64) provides a lot of useful information. -#### Multi-Ingress setup + +## Multi-Ingress setup In a multi-ingress setup the backend is reachable via several domains, each handled by a separate Kubernetes ingress. This is useful to obfuscate the relationship of clients to each other, as an attacker on TCP/IP-level could only see domains and IPs that do not obviously relate to each other. +Each of these backend domains represents a virtual backend. N.B. these backend +domains are *DNS domains* only, not to be confused of the "backend domain" term used for federation (see {ref}`configure-federation`). In single-ingress setups the backend DNS domain and federation backend domain is usually be the same, but this is not true for multi-ingress setups. + + +For a multi-ingress setup multiple services need to be configured: +### Nginz + +nginz sets [CORS +headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). To generate +them for multiple domains (usually, *nginz* works with only one root domain) +these need to be defined with `nginx_conf.additional_external_env_domains`. + +E.g. + +```yaml +nginx_conf: + additional_external_env_domains: + - red.example.com + - green.example.org + - blue.example.net +``` + +### Cannon + +*cannon* sets [CORS +headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) for direct API +accesses by clients. To generate them for multiple domains (usually, *cannon* +works with only one root domain) these need to be defined with +`nginx_conf.additional_external_env_domains`. + +E.g. + +```yaml +nginx_conf: + additional_external_env_domains: + - red.example.com + - green.example.org + - blue.example.net +``` + +### Cargohold -In case of a fake AWS S3 service its identity needs to be obfuscated by making -it accessible via several domains, too. Thus, there isn't one -`s3DownloadEndpoint`, but one per domain at which the backend is reachable. Each -of these backend domains represents a virtual backend. N.B. these backend -domains are *DNS domains*. Do not confuse them with the federation domain! The -latter is just an identifier, and may or may not be equal to the backend's DNS -domain. Backend DNS domain(s) and federation domain are usually set equal by -convention. But, this is not true for multi-ingress setups! The backend domain of a download request is defined by its `Z-Host` header which -is set by `nginz`. (Multi-ingress handlling only applies to download requests as +is set by `nginz`. Multi-ingress handling only applies to download requests as these are implemented by redirects to the S3 assets host for local assets. -Uploads are handled by cargohold directly itself.) +Uploads are handled by cargohold directly itself. -The config `aws.multiIngress` is a map from backend domain (`Z-Host` header -value) to a S3 download endpoint. The `Z-Host` header is set by `nginz` to the + +For a multi-ingress setup `aws.multiIngress` needs to be configured as a map from backend domain (`Z-Host` header value) to a S3 download endpoint. The `Z-Host` header is set by `nginz` to the value of the incoming requests `Host` header. If there's no config map entry for a provided `Z-Host` in a download request for a local asset, then an error is -returned. +returned. When configured the configuration of `s3DownloadEndpoint` is ignored. This example shows a setup with fake backends *red*, *green* and *blue*: @@ -724,45 +754,55 @@ Link to diagram: https://mermaid.live/edit#pako:eNrdVbFu2zAQ_ZUDJ7ewDdhtUkBDgBRB0CHIYCNL4eVEnmWiMk8lKbttkH8vJbsW5dCOUXSqBkHiPT6-e3yinoVkRSITEC5H32syku40FhbXCwP7C6VnC1hqSQNL6l1XeWRPwBuKqxk8OXKwpRyrahxGxvQD11VJY8mvSHPOB4UlMknSrtonbcfStBVar6Wu0HjQJgCdGwUNKfaonMGMax8WeH9acIq5FXKOuwVE7BcqN4U2v9IlibbgFZcqXZ5_ABeMxYK6uiXpwRb5YHp1NYTJ9FN7ixw3jW6ri5UHXva28rZ5BsVbUzIqB-gc-WgTD9DRzU3Pz7v9FChZYnk8L4KGiW23Gdyz3aJVQW7IoYvQbT3gDq2_wsIIbpWCr6MvHF5WhIpsL2p6g6HFhHePvdajFR6Yv0Fd7ZTDquF9mj3AMoR2t0zHcZg1CiJj92akdGP-OLBJ9JpDFOa73YGNxnRAFZ3Te9rxey5L3gZHdmueMrsLyBnHDwpScerGQr_9dn1tzfFeR_2k2MioRFIn15MhTD82Sb0-ndT4fPjM-emcdsDItf23eVlSW_D_ltXYv0uzenTknU_rOd_fzOsfy_9xYvtN_21ixVCsya5Rq_D3fG6KC-FXtKaFyMKjoiXWpV-IhXkJUKw9z38aKTJvaxqKulKBff-jFdkSS0cvvwHKl250 --> -## Settings in cannon +### Galley -### Multi-Ingress setup +For conversation invite links to be correct in a multi-ingress setup `settings.multiIngress` needs to be configured as map from `Z-Host` to the conversation URI prefix. This setting is a `Z-Host` depended version of `settings.conversationCodeURI`. In fact `settings.multiIngress` and `settings.conversationCodeURI` are mutually exclusive. -*cannon* sets [CORS -headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS) for direct API -accesses by clients. To generate them for multiple domains (usually, *cannon* -works with only one root domain) these need to be defined with -`nginx_conf.additional_external_env_domains`. - -E.g. +Example: ```yaml -nginx_conf: - additional_external_env_domains: - - red.example.com - - green.example.org - - blue.example.net +multiIngress: + red.example.com: https://accounts.red.example.com/conversation-join/ + green.example.com: https://accounts.green.example.net/conversation-join/ ``` -This setting has a dual in the *nginz* configuration. +### Webapp -## Settings in nginz +The webapp runs its own web server (a NodeJS server) to serve static files and the webapp config (based on environment variables). +In a multi-ingress configuration, a single webapp instance will be deployed and be accessible from multiple domains (say `webapp.red.example.com` and `webapp.green.example.com`). +When the webapp is loaded from one of those domains it first does a request to the web server to get the config (that will give it, for example, the backend endpoint that it should hit). -### Multi-Ingress setup +Because of the single instance nature of the webapp, by default the configuration is static and the root url to the backend API can be set there (say `nginz-https.root.example.com`). +In order to completely hide this root domain to the webapp, an environment variable can be set to allow the webapp hostname to be used to generate the API endpoint, team settings links, account page links and CSP headers. -nginz sets [CORS -headers](https://developer.mozilla.org/en-US/docs/Web/HTTP/CORS). To generate -them for multiple domains (usually, *nginz* works with only one root domain) -these need to be defined with `nginx_conf.additional_external_env_domains`. +The "hostname" is the result of the domain name minus the `webapp.` part of it. +So querying the webapp on `webapp.red.example.com` will resolve to `red.example.com`. -E.g. +To enable dynamic hostname replacement, first set this variable: -```yaml -nginx_conf: - additional_external_env_domains: - - red.example.com - - green.example.org - - blue.example.net ``` +ENABLE_DYNAMIC_HOSTNAME="true" +``` + +Then, any other variable that will contain the string `[[hostname]]` will be replaced by the hostname of the running webapp. (eg. if a webapp is running on `webapp.red.example.com` then any occurrence of `[[hostname]]` in the config will be replaced by `red.example.com`). + +You may use the template variable `[[hostname]]` in any environment variable to not provide (reveal) actual domain names. -This setting has a dual in the *cannon* configuration. +For example: + +``` +APP_BASE: https://[[hostname]] +BACKEND_REST: https://nginz-https.[[hostname]] +BACKEND_WS: wss://nginz-ssl.[[hostname]] +CSP_EXTRA_CONNECT_SRC: https://*.[[hostname]], wss://*.[[hostname]] +CSP_EXTRA_DEFAULT_SRC: https://*.[[hostname]] +CSP_EXTRA_FONT_SRC: https://*.[[hostname]] +CSP_EXTRA_FRAME_SRC: https://*.[[hostname]] +CSP_EXTRA_IMG_SRC: https://*.[[hostname]] +CSP_EXTRA_MANIFEST_SRC: https://*.[[hostname]] +CSP_EXTRA_MEDIA_SRC: https://*.[[hostname]] +CSP_EXTRA_PREFETCH_SRC: https://*.[[hostname]] +CSP_EXTRA_SCRIPT_SRC: https://*.[[hostname]] +CSP_EXTRA_STYLE_SRC: https://*.[[hostname]] +CSP_EXTRA_WORKER_SRC: https://*.[[hostname]] +``` diff --git a/docs/src/developer/reference/rabbitmq-consumer.md b/docs/src/developer/reference/rabbitmq-consumer.md new file mode 100644 index 00000000000..ec952f1f49c --- /dev/null +++ b/docs/src/developer/reference/rabbitmq-consumer.md @@ -0,0 +1,116 @@ +# RabbitMQ Consumer + +`rabbitmq-consumer` can be used to inspect and drop blocking messages from a RabbitMQ queue containing outgoing messages to another backend. + +E.g. in the following screen shot of the RabbitMQ management UI you can see that a message is stuck in the `backend-notifications.d1.example.com` queue: + +![rabbitmqadmin](rabbitmq-consumer/rabbitmqadmin.png) + +## Interactively inspect/drop messages + +Follow these steps to inspect (and/or drop) the message: + +1. Stop the background-worker because the queues are single active consumer queues. One way to do this is to set the background-worker's `replicas` count to 0 in the k8s deployment. The number of unacked messages should then switch to 0. + +2. Run: + +```shell +RABBITMQ_HOST= # default: "localhost" +RABBITMQ_PORT= # default: 5672 +RABBITMQ_USER= +RABBITMQ_PW= +RABBITMQ_VHOST= # default: "/" +RABBITMQ_QUEUE= +WIRE_VERSION= + +docker run -it --network=host "quay.io/wire/rabbitmq-consumer:$WIRE_VERSION" \ + --host "$RABBITMQ_HOST" \ + --port "$RABBITMQ_PORT" \ + --username "$RABBITMQ_USER" \ + --password "$RABBITMQ_PW" \ + --vhost "$RABBITMQ_VHOST" \ + --queue "$RABBITMQ_QUEUE" \ + interactive +``` + +The output will look similar to: + +``` +vhost: backendA +queue: backend-notifications.d1.example.com +timestamp: Nothing +received message: +{ + "body": { + "conversation": "7d86646e-1122-4979-8629-22dbd6e22afe", + "data": "", + "priority": null, + "push": true, + "recipients": { + "d0c931d3-ee12-43e5-8c97-26f5b9b1ee6d": { + "ea035ddd6d9647d5": "c3VjY2VzcyBtZXNzYWdlIGZvciBkb3duIHVzZXI=" + } + }, + "sender": { + "domain": "example.com", + "id": "83c78b82-545d-4f29-aec4-ae29ea5231d0" + }, + "sender_client": "550d8c614fd20299", + "time": "2023-10-17T10:39:46.38476388Z", + "transient": false + }, + "ownDomain": "example.com", + "path": "/on-message-sent", + "targetComponent": "galley" +} +type 'drop' to drop the message and terminate, or press enter to terminate without dropping the message +``` + +Now the message can be dropped by typing: `drop`. + +## Non-interactive commands + +There are 2 non-interactive commands: + +- `head`: prints the first message in the queue +- `drop-head (-a|--path PATH)`: drops the first message from the queue if the provided path argument matches the path field of the message + +These commands will time out (after 10 seconds per default) if no messages are received within this time. +This can happen when the queue is empty, or when we lose the single active consumer race. + +## Help + +```shell +WIRE_VERSION= + +docker run -it --network=host "quay.io/wire/rabbitmq-consumer:$WIRE_VERSION" --help +``` + +``` +rabbitmq-consumer + +Usage: rabbitmq-consumer [-s|--host HOST] [-p|--port PORT] + [-u|--username USERNAME] [-w|--password PASSWORD] + [-v|--vhost VHOST] [-q|--queue QUEUE] + [-t|--timeout TIMEOUT] COMMAND + + CLI tool to consume messages from a RabbitMQ queue + +Available options: + -h,--help Show this help text + -s,--host HOST RabbitMQ host (default: "localhost") + -p,--port PORT RabbitMQ Port (default: 5672) + -u,--username USERNAME RabbitMQ Username (default: "guest") + -w,--password PASSWORD RabbitMQ Password (default: "alpaca-grapefruit") + -v,--vhost VHOST RabbitMQ VHost (default: "/") + -q,--queue QUEUE RabbitMQ Queue (default: "test") + -t,--timeout TIMEOUT Timeout in seconds. The command will timeout if no + messages are received within this time. This can + happen when the queue is empty, or when we lose the + single active consumer race. (default: 10) + +Available commands: + head Print the first message in the queue + drop-head Drop the first message in the queue + interactive Interactively drop the first message from the queue +``` diff --git a/docs/src/developer/reference/rabbitmq-consumer/rabbitmqadmin.png b/docs/src/developer/reference/rabbitmq-consumer/rabbitmqadmin.png new file mode 100644 index 00000000000..2c304f66f7e Binary files /dev/null and b/docs/src/developer/reference/rabbitmq-consumer/rabbitmqadmin.png differ diff --git a/docs/src/how-to/install/sft.md b/docs/src/how-to/install/sft.md index e4560c72168..9074bd93a21 100644 --- a/docs/src/how-to/install/sft.md +++ b/docs/src/how-to/install/sft.md @@ -18,7 +18,7 @@ tags: sftd: host: sftd.example.com # Replace example.com with your domain - allowOrigin: webapp.example.com # Should be the address you used for the webapp deployment + allowOrigin: https://webapp.example.com # Should be the address you used for the webapp deployment (Note: you must include the uri scheme "https://") ``` In your `secrets.yaml` you should set the TLS keys for sftd domain: diff --git a/docs/src/how-to/install/troubleshooting.md b/docs/src/how-to/install/troubleshooting.md index 7aa9f804791..85a2bc95e03 100644 --- a/docs/src/how-to/install/troubleshooting.md +++ b/docs/src/how-to/install/troubleshooting.md @@ -1,6 +1,6 @@ # Troubleshooting during installation -## Problems with CORS on the web based applications (webapp, team-settings, account-pages) +## Problems with CSP on the web based applications (webapp, team-settings, account-pages) If you have installed wire-server, but the web application page in your browser has connection problems and throws errors in the console such as `"Refused to connect to 'https://assets.example.com' because it violates the following Content Security Policies"`, make sure to check that you have configured the `CSP_EXTRA_` environment variables. @@ -263,3 +263,163 @@ p: the expected ping (how many pings have not returned) Question: Are the connection values for bad networks/disconnect configurable on on-prem? Answer: The values are not currently configurable, they are built into the clients at compile time, we do have a mechanism for sending calling configs to the clients but these values are not currently there. + +## Verifying correct deployment of DNS / DNS troubleshooting. + +After installation, or if you meet some functionality problems, you should check that your DNS setup is correct. + +You'll do this from either your own computer (any public computer connected to the Internet), or from the Wire backend itself. + +### Testing public domains. + +From your own computer (not from the Wire backend), test that you can reach all sub-domains you setup during the Wire installation: + +* `assets.` +* `teams.` +* `webapp.` +* `accounts.` +* `nginz-https.` +* `nginz-ssl.` +* `sftd.` +* `restund01.` +* `restund02.` +* `federator.` + +Some domains (such as the federator) might not apply to your setup. Refer to the domains you configured during installation, and act accordingly. + +You can test if a domain is reachable by typing in your local terminal: + +``` +nslookup assets.yourdomain.com +``` + +If the domain is succesfully resolved, you should see something like: + +``` +Server: 127.0.0.53 +Address: 127.0.0.53#53 + +Non-authoritative answer: +Name: assets.yourdomain.com +Address: 388.114.97.2 +``` + +And if the domain can not be resolved, it will be something like this: + +``` +Server: 127.0.0.53 +Address: 127.0.0.53#53 + +** server can't find assets.yourdomain.com: NXDOMAIN +``` + +Do this for each and every of the domains you configured, make sure each of them is reachable from the open Internet. + +If a domain can not be reached, check your DNS configuration and make sure to solve the issue. + +### Testing internal domain resolution. + +Open a shell inside the SNS pod, and make sure you can resolve the following three domains: + +* `minio-external` +* `cassandra-external` +* `elasticsearch-external` + +First get a list of all pods: + +``` +kubectl get pods --all-namespaces +``` + +In here, find the sns pod (usually its name contains `fake-aws-sns`). + +Open a shell into that pod: + +``` +kubectl exec -it my-sns-pod-name -- /bin/sh +``` + +From inside the pod, you should now test each domain: + +``` +nslookup minio-external +``` + +If the domain is succesfully resolved, you should see something like: + +``` +Server: 127.0.0.53 +Address: 127.0.0.53#53 + +Non-authoritative answer: +Name: minio-external +Address: 173.188.1.14 +``` + +And if the domain can not be resolved, it will be something like this: + +``` +Server: 127.0.0.53 +Address: 127.0.0.53#53 + +** server can't find minio-external: NXDOMAIN +``` + +If you can not resolve any of the three domains, please request support. + +### Testing reachability of AWS. + +First off, use the Amazon AWS documentation to determine your region code: https://docs.aws.amazon.com/AWSEC2/latest/UserGuide/using-regions-availability-zones.html + +Here we will use `us-west-1` but please change this to whichever value you set in your `values.yaml` file during installation. + +First list all pods: + +``` +kubectl get pods --all-namespaces +``` + +In here, find the sns pod (usually its name contains `fake-aws-sns`). + +Open a shell into that pod: + +``` +kubectl exec -it my-sns-pod-name -- /bin/sh +``` + +And test the reachability of the AWS services: + +``` +nslookup sqs.us-west-1.amazonaws.com +``` + +If it can be reached, you'll see something like this: + +``` +Server: 127.0.0.53 +Address: 127.0.0.53#53 + +Non-authoritative answer: +sqs.us-west-1.amazonaws.com canonical name = us-west-1.queue.amazonaws.com. +Name: us-west-1.queue.amazonaws.com +Address: 3.101.114.18 +``` + +And if it can't: + +``` +Server: 127.0.0.53 +Address: 127.0.0.53#53 + +** server can't find sqs.us-west-1.amazonaws.com: NXDOMAIN +``` + +If you can not reach the AWS domain from the SNS pod, you need to try those from one of the servers running kubernetes (kubernetes host): + +``` +ssh kubernetes-server +``` + +Then try the same thing using `nslookup`. + +If either of these steps fail, please request support. \ No newline at end of file diff --git a/docs/src/understand/block-user-creation.md b/docs/src/understand/block-user-creation.md index 5c1e563aaba..a2657014da3 100644 --- a/docs/src/understand/block-user-creation.md +++ b/docs/src/understand/block-user-creation.md @@ -13,7 +13,7 @@ optSettings: If `setRestrictUserCreation` is `true`, creating new personal users or new teams on your instance from outside your backend installation is impossible. (If you want to be more technical: requests to `/register` that create a new personal account or a new team are answered with `403 forbidden`.) -On instances with restricted user creation, the site operator with access to the internal REST API can still circumvent the restriction: just log into a brig service pod via ssh and follow the steps in `hack/bin/create_test_team_admins.sh.` +On instances with restricted user creation, the site operator with access to the internal REST API can still circumvent the restriction: just log into a brig service pod and run the curl commands like `hack/bin/create_test_team_admins.sh` does it. (Running the script is also an option: this will give you a team with a random admin account, and you can use that account to give yourself access under the desired credentials.) ```{note} Once the creation of new users and teams has been disabled, it will still be possible to use the [team creation process](https://support.wire.com/hc/en-us/articles/115003858905-Create-a-team) (enter the new team name, email, password, etc), but it will fail/refuse creation late in the creation process (after the «Create team» button is clicked). @@ -30,5 +30,3 @@ FEATURE_ENABLE_ACCOUNT_REGISTRATION: "false" ```{note} If you only disable the creation of users in the webapp, but do not do so in Brig/the backend, a malicious user would be able to use the API to create users, so make sure to disable both. ``` - - diff --git a/docs/src/understand/classified-domains.md b/docs/src/understand/classified-domains.md index 5d27945abbb..b1c6b25fa66 100644 --- a/docs/src/understand/classified-domains.md +++ b/docs/src/understand/classified-domains.md @@ -17,10 +17,7 @@ galley: domains: ["domain-that-is-classified.link"] ... ``` - -This is not only a `backend` configuration, but also a `team` configuration/feature. - -This means that different combinations of configurations will have different results. +Note: This is only a `backend` level configuration option, the `team` configuration mentioned below only exists for technical reasons and is not actually accessible in any way. Here is a table to navigate the possible configurations: diff --git a/docs/src/understand/configure-federation.md b/docs/src/understand/configure-federation.md index 3477a10242a..455aafd6437 100644 --- a/docs/src/understand/configure-federation.md +++ b/docs/src/understand/configure-federation.md @@ -457,13 +457,8 @@ the sysadmin: * [`PUT`](https://staging-nginz-https.zinfra.io/api-internal/swagger-ui/brig/#/brig/put_i_federation_remotes__domain_) -* [`DELETE`](https://staging-nginz-https.zinfra.io/api-internal/swagger-ui/brig/#/brig/delete_i_federation_remotes__domain_) - - **WARNING:** If you delete a connection, all users from that - remote will be removed from local conversations, and all - conversations hosted by that remote will be removed from the local - backend. Connections between local and remote users that are - removed will be archived, and can be re-established should you - decide to add the same backend later. +* **NOTE:** De-federating (`DELETE`) has been removed from the API to + avoid a scalability issue. Watch out for a fix in the changelog! The `remotes` list looks like this: diff --git a/docs/src/understand/team-feature-settings.md b/docs/src/understand/team-feature-settings.md index e857fad1b3f..0b92daa829a 100644 --- a/docs/src/understand/team-feature-settings.md +++ b/docs/src/understand/team-feature-settings.md @@ -111,3 +111,58 @@ galley: acmeDiscoveryUrl: null lockStatus: unlocked ``` + +## MLS Migration + +The MLS migration configuration determines client behaviour related to +migration from Proteus to MSL, and defines the criteria enforced by the backend +when a conversation is finally migrated to MLS. + +The settings are the following: + + - `startTime`: migration start timestamp. Once this time arrives, clients will + initialise the migration process (no migration-related action will take + place before that time). If the migration feature is enabled, but + `startTime` value is not set (or is set to `null`), migration is never + started. + + - `finaliseRegardlessAfter`: timestamp of the date by which the migration must + be finalised. + + - `usersThreshold`: percentage of migrated users needed for migration to + finalise (0-100). + + - `clientsThreshold`: percentage of migrated clients needed for migration to + finalise (0-100). + +All of the migration finalisation values are technically optional, but at least +one of them must be specified for the configuration to be valid. If +`finaliseRegardlessAfter` is not set, `usersThreshold` or `clientsThreshold` +should be specified. In case both `usersThreshold` and `clientsThreshold` are +specified, even if one of them is set to 0, both have to be fulfilled for the +migration to be finalised. + +The `finaliseRegardlessAfter` timestamp determines a time after which the +threshold criteria are dropped, and finalisation is allowed in any case. + +An example configuration follows: + +``` +galley: + # ... + config: + # ... + settings: + # ... + featureFlags: + # ... + mlsMigration: + defaults: + status: enabled + config: + startTime: "2024-05-16T00:00:00.000Z" + finaliseRegardlessAfter: "2024-10-17T00:00:00.000Z" + usersThreshold: 100 + clientsThreshold: 50 + lockStatus: locked +``` diff --git a/flaky-tests.yaml b/flaky-tests.yaml deleted file mode 100644 index 6c461ec71ea..00000000000 --- a/flaky-tests.yaml +++ /dev/null @@ -1,71 +0,0 @@ -- - test_name: no extra results.new-index - comments: | - I think this is a known flake (Stefan) - -- - test_name: "team tests around truncation limits - no events, too large team" - comments: | - Exception: Timeout: No matching notification received. - Match failure: expected: Just "user.delete" - but got: Just "conversation.delete" - - 2023-03-24: The test has multiple "user.delete" assertions, but since there - is a "conversation.delete" event I think it must be the one - generated by the team deletion. This would also explain why it - might take longer for the event to happen than in other cases. - I've increased the timeout from 4 to 7 seconds for that one - assertion. Let's see if this helps. - -- test_name: "GET /mls/key-packages/claim/local/:user - self claim" - comments: | - /bin/sh: createProcess: posix_spawnp: failed (Bad address) - This is probably to resource exhaustion. - Can we try increasing limits? - -- - test_name: "Brig API Integration.MLS.GET /mls/key-packages/claim/local/:user" - comments: | - Same error as for teset "GET /mls/key-packages/claim/local/:user - self claim" - - Error executing request: /bin/sh: createProcess: posix_spawnp: failed (Bad address) - -- - test_name: "max active tokens" - comments: | - CallStack (from HasCallStack): - assertFailure, called at ./Test/Tasty/HUnit/Orig.hs:71:30 in tasty-hunit-0.10.0.3-BA9Dg64ujOjHrKq3kYOvGI:Test.Tasty.HUnit.Orig - assertBool, called at test/integration/API/OAuth.hs:473:16 in main:API.OAuth - Use -p '(!/turn/&&!/user.auth.cookies.limit/)&&/max active tokens/' to rerun this test only. - -- - test_name: "delete team conversation" - comments: | - Exception: unexpected notification received - Match failure: expected: no notification - but got: "conversation.create" - - 2023-03-24: The test is not waiting for notifications triggered by creating - conversations. When later asserting that no notification is sent - for deleting conversations, the test fails under load when the - create conversation notification happens to come in late. This - is fixed by waiting for all previous notifications before - asserting the absence of (new) notifications. - -- - test_name: "POST /federation/on-user-deleted-conversations : Remove deleted remote user from local conversations" - comments: | - Exception: unexpected notification received - Match failure: expected: no notification - but got: "conversation.create" - - 2023-03-24: The test is not waiting for notifications triggered by creating - conversations. When later asserting that no notification is sent - for deleting user, the test fails under load when the create - conversation notification happens to come in late. This is fixed - by waiting for all previous notifications before asserting the - absence of (new) notifications. -- - test_name: "POST /register - can add team members above fanout limit when whitelisting is enabled" - comments: | - 2023-03-27: Hopefully fix by increasing timeout from 10 to 15 seconds for this test. diff --git a/hack/bin/cassandra_dump_schema b/hack/bin/cassandra_dump_schema index 624e4a0a180..74c657fe5a5 100755 --- a/hack/bin/cassandra_dump_schema +++ b/hack/bin/cassandra_dump_schema @@ -25,7 +25,6 @@ def main(): for keyspace in keyspaces: if keyspace.endswith('_test'): s = run_cqlsh(container, f'DESCRIBE keyspace {keyspace}') - print(s.replace('CREATE TABLE galley_test.member_client','-- NOTE: this table is unused. It was replaced by mls_group_member_client\nCREATE TABLE galley_test.member_client')) print() if __name__ == '__main__': diff --git a/hack/bin/chat.py b/hack/bin/chat.py new file mode 100755 index 00000000000..d770506b267 --- /dev/null +++ b/hack/bin/chat.py @@ -0,0 +1,329 @@ +#!/usr/bin/env python3 +# +# With this tool you can send and receive (unencrypted) messages in conversations. +# It exists to test basic message sending and monitoring of events without relying on using a client. +# +# Create a config file (see example.yaml). +# +# 1. generate a shell script that sets up the port-forwarding for domain col1 via kubectl +# chat.py --config example.yaml port-forward --domain col1 +# +# 2. open websockets and listen on the clients for user u1 and u2 +# chat.py --config example.yaml listen --user u1 --user u2 +# +# 3. send a message to conv 1+2 with as user u1 +# the message will be unencrypted plaintext, so normal clients won't be able to display it +# chat.py --config example.yaml send --user u1 --conv 1+2 +# +# # example.yaml +# +# users: +# # pick any short name for user name +# u1: +# id: 13cfb002-6f07-434a-90fa-1422e8141a30 +# domain_idx: col1 +# client: 139da7a7e0034030 +# u2: +# id: f0e07e83-b573-4689-b366-5efa4a859a72 +# domain_idx: col2 +# client: 1BD4B2DCE638BD9E +# comment: User en7ump0q@wire.com +# off: +# id: 8673c02b-651d-4f4a-96d8-4dbd51fa3e1b +# client: b51351d821a734a3 +# domain_idx: offline-web +# convs: +# # pick any short name for conversation names +# 1+2: +# id: eabb40cc-bf99-5a50-bd56-60c120830235 +# domain_idx: col2 +# domains: +# # pick any short name for the domain +# col1: +# domain: bund-next-column-1.wire.link +# cannon_port: 6086 +# galley_port: 6085 +# namespace: wire +# col2: +# domain: bund-next-column-2.wire.link +# cannon_port: 7086 +# galley_port: 7085 +# namespace: wire +# offline-web: +# domain: bund-next-column-offline-web.wire.link +# cannon_port: 11086 +# galley_port: 11085 +# namespace: column-offline-web + +import websockets +import asyncio +import argparse +import requests +import base64 +from urllib.parse import urljoin, urlparse, urlencode, urlunparse +import uuid +import sys +import subprocess +import random +import json +import datetime +import yaml +import itertools +import tempfile +import wire.otr_pb2 as otr + +port_forward_script = ''' +#!/usr/bin/env bash + +set -eo pipefail +domain="{domain}" +namespace="{namespace}" +galley_port="{galley_port}" +cannon_port="{cannon_port}" + +actual_domain=$(kubectl -n wire get configmap brig -o yaml | sed -n 's/.*setFederationDomain: \(.*\)/\\1/p') +if [ ! "$actual_domain" = "$domain" ]; then echo "Error: backend is $actual_domain, but expected $domain" ; exit 1; fi + +set -x +kubectl -n wire port-forward $(kubectl -n wire get pods -lapp=galley -o=custom-columns=name:.metadata.name --no-headers) $galley_port:8080 & +pid1="$!" +kubectl -n wire port-forward $(kubectl -n wire get pods -lapp=cannon -o=custom-columns=name:.metadata.name --no-headers) $cannon_port:8080 & +pid2="$!" +set +x + +sleep 1 +read -n 1 -p "Press ENTER to kill port-forwarding processes $pid1 and $pid2:"; +kill "$pid1" +kill "$pid2" +''' + +def random_string(): + hiragana = [ "a", "i", "u", "e", "o", "ka", "ki", "ku", "ke", "ko", "sa", "shi", "su", "se", "so",\ + "ta", "chi", "tsu", "te", "to", "na", "ni", "nu", "ne", "no", "ha", "hi", "fu", "he",\ + "ho", "ma", "mi", "mu", "me", "mo", "ya", "yu", "yo", "ra", "ri", "ru", "re", "ro", "wa", "wo" ] + s = '' + n = random.choice([2,3,4]) + words = [] + for _ in range(n): + l = random.choice([2,3]) + word = '' + for i in range(l): + word += random.choice(hiragana) + words.append(word) + return '_'.join(words) + +def get_human_time(): + t = datetime.datetime.now() + return t.strftime('%H:%M:%S') + +def client_identities_from_missing(missing): + cids = [] + for domain, users in missing.items(): + for user_id, client_ids in users.items(): + for client_id in client_ids: + cids.append({'user': user_id, 'domain': domain, 'client': client_id}) + return cids + +class App: + def __init__(self, cfg): + self.cfg = cfg + for k, v in self.cfg['users'].items(): + v['idx'] = k + for k, v in self.cfg['domains'].items(): + v['idx'] = k + for k, v in self.cfg['convs'].items(): + v['idx'] = k + + def user(self, idx): + return self.cfg['users'][idx] + + def domain(self, idx): + return self.cfg['domains'][idx] + + def conv(self, name): + return self.cfg['convs'][name] + + def user_idx(self, user_id): + for k, v in self.cfg['users'].items(): + if v['id'] == user_id: + return v + return f'No user found for <{user_id}>' + + def conv_idx(self, conv_id): + for k, v in self.cfg['convs'].items(): + if v['id'] == conv_id: + return v + return f'No conv found for <{conv_id}>' + + def send_msg(self, user_from_idx, conv_name): + msg = get_human_time() + ' ' + random_string() + payload = msg.encode('utf8') + conv = self.conv(conv_name) + user_from = self.user(user_from_idx) + domain_conv = self.domain(conv['domain_idx']) + domain_from = self.domain(user_from['domain_idx']) + url = f'http://localhost:{domain_from["galley_port"]}/v4/conversations/{domain_conv["domain"]}/{conv["id"]}/proteus/messages' + + data = mk_otr(user_from['client'], [], payload) + response = requests.post(url, headers={'content-type': 'application/x-protobuf', 'z-user': user_from['id'], 'z-connection': 'con'}, data=data) + if response.status_code != 412: + print('got not 412') + print(response.status_code, response.text) + print(':(') + sys.exit(1) + b = response.json() + + client_identities = client_identities_from_missing(b['missing']) + data = mk_otr(user_from['client'], client_identities, payload) + + response = requests.post(url, headers={'content-type': 'application/x-protobuf', 'z-user': user_from['id'], 'z-connection': 'con'}, data=data) + if response.status_code != 201: + print('got not 201') + print(response.status_code, response.text) + print(':(') + sys.exit(1) + + else: + print(f'{user_from_idx} sent: {msg}') + + async def open_websocket(self, user_idx): + user = self.cfg['users'][user_idx] + domain = self.cfg['domains'][user['domain_idx']] + + url = f'ws://127.0.0.1:{domain["cannon_port"]}/await' + # add client param + urlparts = list(urlparse(url)) + params = {"client": user["client"]} + urlparts[4] = urlencode(params) + url = urlunparse(urlparts) + + headers = {"Z-User": user["id"], "Z-Connection": random_string()} + async with websockets.connect(url, extra_headers=headers, open_timeout=4 * 60) as ws: + print(f'{user_idx} opened a websocket') + while True: + message_raw = await ws.recv() + n = json.loads(message_raw.decode('utf8')) + payload = n['payload'][0] + type_ = payload['type'] + if type_ == 'conversation.otr-message-add': + + conv = self.conv_idx(payload['conversation']) + sender_user_id = payload['qualified_from']['id'] + sender = self.user_idx(sender_user_id) + msg = base64.b64decode(payload['data']['data']).decode('utf8') + print(f'{get_human_time()} {user_idx} receives in conv {conv["idx"]} from {sender["idx"]}: {msg}') + else: + print(f'{get_human_time()} {user_idx} receives event fo type {type_}') + + async def open_websockets(self, users): + await asyncio.gather(*[self.open_websocket(u) for u in users]) + + def print_port_forward(self, domain_idx): + d = self.domain(domain_idx) + domain = d['domain'] + namespace = d['namespace'] + galley_port = d['galley_port'] + cannon_port = d['cannon_port'] + script = port_forward_script.format(domain=domain, namespace=namespace, galley_port=galley_port, cannon_port=cannon_port) + with tempfile.NamedTemporaryFile(prefix=f'{domain}-port-forward-', suffix='.sh', delete=False, mode='w') as f: + print(f'Wrote port-forward script to {f.name}') + f.write(script) + +async def main_test_websocket(): + await open_websocket(3) + +def client_id_to_int(client_id): + return int("0x" + client_id, 16) + +def hex_to_bytes(hex): + return bytes(bytearray.fromhex(hex)) + +def uuid_to_bytes(uuid_string): + u = uuid.UUID(uuid_string) + return u.bytes + +def mk_client_id(client_hex): + return otr.ClientId(client=client_id_to_int(client_hex)) + +def mk_client_entry(client_hex): + client_id = mk_client_id(client_hex) + return otr.ClientEntry(client=client_id, text=hex_to_bytes(client_hex)) + +def mk_user_id(uuid_string): + uuid_bytes = uuid_to_bytes(uuid_string) + return otr.UserId(uuid=uuid_bytes) + +def mk_user_entry(user, clients): + user_id = mk_user_id(user) + clients = [mk_client_entry(c) for c in clients] + return otr.UserEntry(user=user_id, clients = clients ) + +def mk_qualified_user_entry(domain, users): + entries = [mk_user_entry(u, users[u]) for u in users] + return otr.QualifiedUserEntry(domain=domain, entries=entries) + +def mk_otr(sender_client_id_hex, client_identities, payload=b'foobar'): + sender = mk_client_id(sender_client_id_hex) + + gdomain = lambda c: c['domain'] + guser = lambda c: c['user'] + recipients = [] + for domain, users_flat in itertools.groupby(sorted(client_identities, key=gdomain), key=gdomain): + users = {} + for user_id, clients_flat in itertools.groupby(sorted(users_flat, key=guser), key=guser): + users[user_id] = [c['client'] for c in clients_flat] + recipients.append(mk_qualified_user_entry(domain, users)) + + report_all = otr.ClientMismatchStrategy.ReportAll() + m = otr.QualifiedNewOtrMessage(sender=sender, recipients=recipients, blob=payload, report_all=report_all) + return m.SerializeToString() + +def main_port_forward(cfg, domain): + app = App(cfg) + app.print_port_forward(domain) + +def main_send(cfg, user, conv): + app = App(cfg) + app.send_msg(user, conv) + +def main_listen(cfg, users): + app = App(cfg) + asyncio.run(app.open_websockets(users)) + +def main(): + parser = argparse.ArgumentParser( + prog=sys.argv[0], description="Send and receive proteus messages across backends" + ) + + subparsers = parser.add_subparsers( + title="subcommand", description="valid subcommands", dest="subparser_name" + ) + + parser.add_argument("--config", type=str, required=True) + + sp = subparsers.add_parser("send") + sp.add_argument("--user", type=str, required=True) + sp.add_argument("--conv", type=str, required=True) + + lp = subparsers.add_parser("listen") + lp.add_argument('--user', action='append', help='can be provided multiple times') + + pf = subparsers.add_parser("port-forward") + pf.add_argument("--domain", type=str, required=True) + + args = parser.parse_args() + + with open(args.config, 'r') as f: + cfg = yaml.safe_load(f) + + if args.subparser_name == "send": + main_send(cfg, args.user, args.conv) + + elif args.subparser_name == "port-forward": + main_port_forward(cfg, args.domain) + + elif args.subparser_name == "listen": + main_listen(cfg, args.user) + +if __name__ == '__main__': + main() \ No newline at end of file diff --git a/hack/bin/create_test_team_admins.sh b/hack/bin/create_test_team_admins.sh index e6af4951314..625da458b1b 100755 --- a/hack/bin/create_test_team_admins.sh +++ b/hack/bin/create_test_team_admins.sh @@ -12,6 +12,9 @@ USAGE=" This bash script can be used to create active team admin users and their teams. +This is the way to create teams if you have set +'setRestrictUserCreation' to 'true' in your 'values.yaml'. + Note that this uses an internal brig endpoint. It is not exposed over nginz and can only be used if you have direct access to brig. diff --git a/hack/bin/flaky_tests.py b/hack/bin/flaky_tests.py deleted file mode 100755 index 84bc8ad861e..00000000000 --- a/hack/bin/flaky_tests.py +++ /dev/null @@ -1,224 +0,0 @@ -#!/usr/bin/env python3 - -import datetime -import requests -import json -import os -import yaml -import argparse -from pydoc import pager - -BUCKET_BASEURL = 'https://s3.eu-west-1.amazonaws.com/public.wire.com/ci/failing-tests' -CONCOURSE_BASEURL = 'https://concourse.ops.zinfra.io/teams/main' -CONCOURSE_LOG_RETENTION_DAYS = 7 - -class Colors: - GREEN = "\x1b[38;5;10m" - YELLOW = "\x1b[38;5;11m" - BLUE = "\x1b[38;5;6m" - PURPLEISH = "\x1b[38;5;13m" - ORANGE = "\x1b[38;5;3m" - RED = "\x1b[38;5;1m" - RESET = "\x1b[0m" - -def read_flaky_tests(): - project_root = os.path.join(os.path.dirname(__file__), os.path.pardir, os.path.pardir) - result = {} - with open(os.path.join(project_root, 'flaky-tests.yaml'), 'r') as f: - d = yaml.safe_load(f) - for item in d: - result[item['test_name']] = item['comments'] - return result - -def current_week_start(now): - k = now.weekday() - return (now - datetime.timedelta(days=k)).replace(hour=0, minute=0, second=0, microsecond=0) - -def format_date(dt): - return dt.strftime('%Y-%m-%d') - -def failing_tests_fn(week_start): - return format_date(week_start) + '_failing_tests.json' - -def fetch_week(week_start): - url = f'{BUCKET_BASEURL}/{failing_tests_fn(week_start)}' - r = requests.get(url) - result = [] - if r.status_code == 200: - for line in r.content.split(b'\n'): - if len(line) > 0: - item = json.loads(line.decode('utf8')) - result.append(item) - return result - -def fetch(today, n_weeks): - ws_start = current_week_start(today) - data = [] - for i in range(n_weeks): - ws = ws_start + datetime.timedelta(days=-7*i) - print(f'\rFetching {i+1}/{n_weeks} {format_date(ws)}') - data += fetch_week(ws) - print() - return data - -def tests_match(s1, s2): - return s1 in s2 or s2 in s1 - -def longer(s1, s2): - if len(s1) > len(s2): - return s1 - else: - return s2 - -def is_flake(item): - b = item['build'] - return ((b['pipeline_name'] == 'mls' and b['job_name'] == 'test') \ - or (b['pipeline_name'] == 'staging' and b['job_name'] == 'test') \ - or (b['pipeline_name'] == 'prod' and b['job_name'] == 'test')) - - -def add_flake(flake_set, test_name): - for k in flake_set: - if tests_match(k, test_name): - k_ = longer(k, test_name) - if k_ != k: - flake_set.remove(k) - flake_set.add(k_) - return - flake_set.add(test_name) - -def discover_flakes(data): - flake_set = set() - for item in data: - if is_flake(item): - add_flake(flake_set, item['test_name']) - return flake_set - -def search_matching_flake(test_names, test_name): - for flake in test_names: - if tests_match(flake, test_name): - return flake - -def associate_fails(test_names, data, default_comment=''): - d = {k: [] for k in test_names} - unassociated = [] - for item in data: - flake = search_matching_flake(test_names, item['test_name']) - if flake: - d[flake].append(item) - else: - unassociated.append(item) - - return [{'test_name': k, 'fails': v, 'comments': default_comment} for k, v in d.items()], unassociated - -def associate_comments(flakes, comments, default_comments=''): - for flake in flakes: - flake['comments'] = comments.get(flake['test_name'], default_comments) - -def sort_flakes(flakes): - flakes.sort(key=lambda f: (-len(f['fails']), f['test_name']), reverse=False) - for flake in flakes: - flake['fails'].sort(key=lambda f: f['build']['end_time'], reverse=True) - -def humanize_days(n): - if n < 7: - if n == 0: - return 'today' - elif n == 1: - return 'yesterday' - else: - return f'{n} days ago' - else: - weeks = n // 7 - if weeks < 4: - return f'{weeks} week{"s" if weeks >= 2 else ""} ago' - else: - return f'{weeks // 4} month{"s" if weeks >= 8 else ""} ago' - -def human_format_date(dt, today): - days = (today - dt).days - return Colors.BLUE + f'· {format_date(dt)} ({humanize_days(days)})' + Colors.RESET - -def create_url(build): - return CONCOURSE_BASEURL + f"/pipelines/{build['pipeline_name']}/jobs/{build['job_name']}/builds/{build['name']}" - -def pretty_flake(flake, today, logs=False): - lines = [] - lines.append(Colors.YELLOW + f"❄ \"{flake['test_name']}\"" + Colors.RESET) - - if not logs: - lines.append(f' Run with --logs "{flake["test_name"]}" to see error logs') - - comments = flake['comments'] - if comments: - for l in comments.splitlines(): - lines.append(' ' + Colors.PURPLEISH + l + Colors.RESET) - lines.append('') - for fail in flake['fails']: - b = fail['build'] - - end_time = datetime.datetime.fromtimestamp(b['end_time']) - s = human_format_date(end_time, today) - if (today - end_time) < datetime.timedelta(days=CONCOURSE_LOG_RETENTION_DAYS): - url = create_url(b) - s = s + ' ' + url - lines.append(' ' + s) - if logs: - lines.append('') - for l in fail['context'].splitlines(): - lines.append(' ' + l) - lines.append('') - - return "\n".join(lines) + '\n' - -def pretty_flakes(flakes, today, logs=False): - lines = [] - for flake in flakes: - lines.append(pretty_flake(flake, today, logs)) - return '\n'.join(lines) - -explain = '''Tips: - Run with --discover to manually discover new flaky tests - -''' - -def main(): - parser = argparse.ArgumentParser(prog='flaky_test.py', description='Shows flaky tests') - parser.add_argument('-d', '--discover', action='store_true', help='Show failing tests that are not marked/discovered as being flaky. Use this to manually discover flaky test.') - parser.add_argument('-l', '--logs', help='Show surrounding logs for given test') - args = parser.parse_args() - - today = datetime.datetime.now() - data = fetch(today=today, n_weeks=4*4) - if args.logs: - flakes, unassociated = associate_fails([args.logs], data) - sort_flakes(flakes) - pager(explain + pretty_flakes(flakes, today, logs=True)) - return - - test_names = discover_flakes(data) - flaky_tests_comments = read_flaky_tests() - test_names = test_names.union(flaky_tests_comments.keys()) - flakes, unassociated = associate_fails(test_names, data) - - - if args.discover: - - test_names = set([i['test_name'] for i in unassociated]) - flake_candidates, _ = associate_fails(test_names, unassociated, '(if this is a flaky test, please add it to flaky-tests.yaml)') - sort_flakes(flake_candidates) - pager(explain + pretty_flakes(flake_candidates, today)) - - else: - associate_comments(flakes, flaky_tests_comments, '(discovered flake, please check and add it to flaky-tests.yaml)') - sort_flakes(flakes) - pager(explain + pretty_flakes(flakes, today)) - - -def test(): - data = fetch(1) - return data - - -if __name__ == '__main__': - main() diff --git a/hack/bin/integration-teardown-federation.sh b/hack/bin/integration-teardown-federation.sh index 5a5ca938e9c..fba96854904 100755 --- a/hack/bin/integration-teardown-federation.sh +++ b/hack/bin/integration-teardown-federation.sh @@ -22,7 +22,6 @@ else fi . "$DIR/helm_overrides.sh" -helmfile --file "${TOP_LEVEL}/hack/helmfile.yaml" destroy --skip-deps --skip-charts --concurrency 0 +helmfile --file "${TOP_LEVEL}/hack/helmfile.yaml" destroy --skip-deps --skip-charts --concurrency 0 || echo "Failed to delete helm deployments, ignoring this failure as next steps will the destroy namespaces anyway." -kubectl delete namespace "$NAMESPACE_1" -kubectl delete namespace "$NAMESPACE_2" +kubectl delete namespace "$NAMESPACE_1" "$NAMESPACE_2" diff --git a/hack/bin/integration-test.sh b/hack/bin/integration-test.sh index 27f85d0275e..0667ebeac28 100755 --- a/hack/bin/integration-test.sh +++ b/hack/bin/integration-test.sh @@ -11,10 +11,10 @@ UPLOAD_LOGS=${UPLOAD_LOGS:-0} echo "Running integration tests on wire-server with parallelism=${HELM_PARALLELISM} ..." CHART=wire-server -tests=(stern galley cargohold gundeck federator spar brig integration) +tests=(integration stern galley cargohold gundeck federator spar brig) cleanup() { - if (( CLEANUP_LOCAL_FILES > 0 )); then + if ((CLEANUP_LOCAL_FILES > 0)); then for t in "${tests[@]}"; do rm -f "stat-$t" rm -f "logs-$t" @@ -24,11 +24,12 @@ cleanup() { # Copy to the concourse output (indetified by $OUTPUT_DIR) for propagation to # following steps. -copyToAwsS3(){ - if (( UPLOAD_LOGS > 0 )); then +copyToAwsS3() { + build_ts=$(date +%s) + if ((UPLOAD_LOGS > 0)); then for t in "${tests[@]}"; do - echo "Copy logs-$t to s3://wire-server-test-logs/test-logs-$VERSION/$t-$VERSION.log" - aws s3 cp "logs-$t" "s3://wire-server-test-logs/test-logs-$VERSION/$t-$VERSION.log" + echo "Copy logs-$t to s3://wire-server-test-logs/test-logs-$VERSION/$t-$VERSION-$build_ts.log" + aws s3 cp "logs-$t" "s3://wire-server-test-logs/test-logs-$VERSION/$t-$VERSION-$build_ts.log" done fi } @@ -89,7 +90,7 @@ if ((exit_code > 0)); then x=$(cat "stat-$t") if ((x > 0)); then echo "=== (relevant) logs for failed $t-integration ===" - "$DIR/integration-logs-relevant-bits.sh" < "logs-$t" + "$DIR/integration-logs-relevant-bits.sh" <"logs-$t" fi done summary diff --git a/hack/bin/performance.py b/hack/bin/performance.py index 0337e366045..bd39b835a3f 100755 --- a/hack/bin/performance.py +++ b/hack/bin/performance.py @@ -75,8 +75,8 @@ def simplify_body(content): else: return content except Exception: - b = b64encode(content).decode("utf8") - return {"base64data": b, "comment": "binary blob has been replaced"} + b = b64encode(content).decode("utf8")[:1000] + return {"base64data": b, "comment": "binary blob has been replaced (and truncated)"} def simplify_response(response): @@ -323,7 +323,7 @@ def save(res, path): if not os.path.exists(d): os.makedirs(d) save_json_file(b, path) - print(f"Saving to {path}") + # print(f"Saving to {path}") return b @@ -816,13 +816,14 @@ def main_send(basedir): msg = random_msg() log.log("message_send_begin") + msg = create_application_message(admin_client_state, msg)["message"] + tbefore = time.time() res_test_msg = save( - api.mls_send_message( - ctx, create_application_message(admin_client_state, msg)["message"], - client=client_id - ), + api.mls_send_message(ctx, msg, client=client_id), j(ud, "res_test_msg.json"), ) + tafter = time.time() + print(f'Message sending took {tafter-tbefore}') log.log("message_send_end") simple_expect_status(201, res_test_msg) diff --git a/hack/helm_vars/common.yaml.gotmpl b/hack/helm_vars/common.yaml.gotmpl index b5748c96012..56f209fcce8 100644 --- a/hack/helm_vars/common.yaml.gotmpl +++ b/hack/helm_vars/common.yaml.gotmpl @@ -5,3 +5,16 @@ federationDomain2: {{ requiredEnv "FEDERATION_DOMAIN_2" }} ingressChart: {{ requiredEnv "INGRESS_CHART" }} rabbitmqUsername: guest rabbitmqPassword: guest + +dynBackendDomain1: dynamic-backend-1.{{ requiredEnv "NAMESPACE_1" }}.svc.cluster.local +dynBackendDomain2: dynamic-backend-2.{{ requiredEnv "NAMESPACE_1" }}.svc.cluster.local +dynBackendDomain3: dynamic-backend-3.{{ requiredEnv "NAMESPACE_1" }}.svc.cluster.local + +{{- if (eq (env "UPLOAD_XML_S3_BASE_URL") "") }} +uploadXml: {} +{{- else }} +uploadXml: + awsAccessKeyId: {{ env "UPLOAD_XML_AWS_ACCESS_KEY_ID" }} + awsSecretAccessKey: {{ env "UPLOAD_XML_AWS_SECRET_ACCESS_KEY" }} + baseUrl: {{ env "UPLOAD_XML_S3_BASE_URL" }} +{{- end }} \ No newline at end of file diff --git a/hack/helm_vars/wire-server/values.yaml.gotmpl b/hack/helm_vars/wire-server/values.yaml.gotmpl index ba7fb2f042a..874bd5103f2 100644 --- a/hack/helm_vars/wire-server/values.yaml.gotmpl +++ b/hack/helm_vars/wire-server/values.yaml.gotmpl @@ -77,14 +77,6 @@ brig: setMaxConvSize: 16 # See helmfile for the real value setFederationDomain: integration.example.com - setFederationDomainConfigs: - # 'setFederationDomainConfigs' is deprecated as of https://github.com/wireapp/wire-server/pull/3260. See - # https://docs.wire.com/understand/federation/backend-communication.html#configuring-remote-connections - # for details. - - domain: integration.example.com - search_policy: full_search - - domain: federation-test-helper.{{ .Release.Namespace }}.svc.cluster.local - search_policy: full_search setFederationStrategy: allowAll setFederationDomainConfigsUpdateFreq: 10 set2FACodeGenerationDelaySecs: 5 @@ -144,6 +136,15 @@ brig: password: {{ .Values.rabbitmqPassword }} tests: enableFederationTests: true + {{- if .Values.uploadXml }} + config: + uploadXml: + baseUrl: {{ .Values.uploadXml.baseUrl }} + secrets: + uploadXmlAwsAccessKeyId: {{ .Values.uploadXml.awsAccessKeyId }} + uploadXmlAwsSecretAccessKey: {{ .Values.uploadXml.awsSecretAccessKey }} + {{- end }} + cannon: replicaCount: 2 imagePullPolicy: {{ .Values.imagePullPolicy }} @@ -170,6 +171,16 @@ cargohold: secrets: awsKeyId: dummykey awsSecretKey: dummysecret + tests: + {{- if .Values.uploadXml }} + config: + uploadXml: + baseUrl: {{ .Values.uploadXml.baseUrl }} + secrets: + uploadXmlAwsAccessKeyId: {{ .Values.uploadXml.awsAccessKeyId }} + uploadXmlAwsSecretAccessKey: {{ .Values.uploadXml.awsSecretAccessKey }} + {{- end }} + galley: replicaCount: 1 imagePullPolicy: {{ .Values.imagePullPolicy }} @@ -194,6 +205,15 @@ galley: status: enabled config: domains: ["example.com"] + mlsMigration: + defaults: + status: enabled + config: + startTime: "2029-05-16T10:11:12.123Z" + finaliseRegardlessAfter: "2029-10-17T00:00:00.000Z" + usersThreshold: 100 + clientsThreshold: 50 + lockStatus: locked journal: endpoint: http://fake-aws-sqs:4568 queueName: integration-team-events.fifo @@ -209,6 +229,15 @@ galley: rabbitmq: username: {{ .Values.rabbitmqUsername }} password: {{ .Values.rabbitmqPassword }} + tests: + {{- if .Values.uploadXml }} + config: + uploadXml: + baseUrl: {{ .Values.uploadXml.baseUrl }} + secrets: + uploadXmlAwsAccessKeyId: {{ .Values.uploadXml.awsAccessKeyId }} + uploadXmlAwsSecretAccessKey: {{ .Values.uploadXml.awsSecretAccessKey }} + {{- end }} gundeck: replicaCount: 1 @@ -238,6 +267,16 @@ gundeck: secrets: awsKeyId: dummykey awsSecretKey: dummysecret + tests: + {{- if .Values.uploadXml }} + config: + uploadXml: + baseUrl: {{ .Values.uploadXml.baseUrl }} + secrets: + uploadXmlAwsAccessKeyId: {{ .Values.uploadXml.awsAccessKeyId }} + uploadXmlAwsSecretAccessKey: {{ .Values.uploadXml.awsSecretAccessKey }} + {{- end }} + nginz: replicaCount: 1 imagePullPolicy: {{ .Values.imagePullPolicy }} @@ -295,6 +334,15 @@ spar: - type: ContactSupport company: Example Company email: email:backend+spar@wire.com + tests: + {{- if .Values.uploadXml }} + config: + uploadXml: + baseUrl: {{ .Values.uploadXml.baseUrl }} + secrets: + uploadXmlAwsAccessKeyId: {{ .Values.uploadXml.awsAccessKeyId }} + uploadXmlAwsSecretAccessKey: {{ .Values.uploadXml.awsSecretAccessKey }} + {{- end }} federator: replicaCount: 1 @@ -304,6 +352,15 @@ federator: config: optSettings: useSystemCAStore: false + tests: + {{- if .Values.uploadXml }} + config: + uploadXml: + baseUrl: {{ .Values.uploadXml.baseUrl }} + secrets: + uploadXmlAwsAccessKeyId: {{ .Values.uploadXml.awsAccessKeyId }} + uploadXmlAwsSecretAccessKey: {{ .Values.uploadXml.awsSecretAccessKey }} + {{- end }} background-worker: replicaCount: 1 @@ -312,7 +369,9 @@ background-worker: imagePullPolicy: {{ .Values.imagePullPolicy }} config: backendNotificationPusher: - remotesRefreshInterval: 1 + pushBackoffMinWait: 1000 # 1ms + pushBackoffMaxWait: 500000 # 0.5s + remotesRefreshInterval: 1000000 # 1s secrets: rabbitmq: username: {{ .Values.rabbitmqUsername }} @@ -320,4 +379,22 @@ background-worker: integration: ingress: - class: "nginx-{{ .Release.Namespace }}" \ No newline at end of file + class: "nginx-{{ .Release.Namespace }}" + {{- if .Values.uploadXml }} + config: + uploadXml: + baseUrl: {{ .Values.uploadXml.baseUrl }} + secrets: + uploadXmlAwsAccessKeyId: {{ .Values.uploadXml.awsAccessKeyId }} + uploadXmlAwsSecretAccessKey: {{ .Values.uploadXml.awsSecretAccessKey }} + {{- end }} +backoffice: + tests: + {{- if .Values.uploadXml }} + config: + uploadXml: + baseUrl: {{ .Values.uploadXml.baseUrl }} + secrets: + uploadXmlAwsAccessKeyId: {{ .Values.uploadXml.awsAccessKeyId }} + uploadXmlAwsSecretAccessKey: {{ .Values.uploadXml.awsSecretAccessKey }} + {{- end }} diff --git a/hack/helmfile.yaml b/hack/helmfile.yaml index d9568f2e5a8..878cb016f50 100644 --- a/hack/helmfile.yaml +++ b/hack/helmfile.yaml @@ -127,8 +127,6 @@ releases: value: {{ .Values.federationDomain1 }} - name: cargohold.config.settings.federationDomain value: {{ .Values.federationDomain1 }} - - name: brig.config.optSettings.setFederationDomainConfigs[0].domain - value: {{ .Values.federationDomain2 }} needs: - 'databases-ephemeral' @@ -145,7 +143,5 @@ releases: value: {{ .Values.federationDomain2 }} - name: cargohold.config.settings.federationDomain value: {{ .Values.federationDomain2 }} - - name: brig.config.optSettings.setFederationDomainConfigs[0].domain - value: {{ .Values.federationDomain1 }} needs: - 'databases-ephemeral' diff --git a/hack/python/wire/api.py b/hack/python/wire/api.py index 6e6c2169298..aea4ce10e8f 100644 --- a/hack/python/wire/api.py +++ b/hack/python/wire/api.py @@ -4,6 +4,7 @@ """ from base64 import b64encode +import time import json import random import requests @@ -134,13 +135,17 @@ def mls_welcome(ctx, user, welcome): def mls_post_commit_bundle(ctx, client, commit_bundle): url = ctx.mkurl("galley", f"/mls/commit-bundles") - return ctx.request( + tbefore = time.time() + res = ctx.request( "POST", url, headers={"Content-Type": "message/mls"}, client=client, data=commit_bundle, ) + tafter = time.time() + print(f'posting commit bundle took {tafter-tbefore:.0f} seconds') + return res def mls_send_message(ctx, msg, **kwargs): diff --git a/hack/python/wire/mlscli.py b/hack/python/wire/mlscli.py index cb7947fbdfe..42b71b459a5 100644 --- a/hack/python/wire/mlscli.py +++ b/hack/python/wire/mlscli.py @@ -40,10 +40,12 @@ def mlscli(state, client_identity, args, stdin=None): else: args_substd.append(arg) + basedir = os.path.join(cdir, cid2str(state.client_identity) ) + os.makedirs(basedir, exist_ok=True) all_args = [ "mls-test-cli", "--store", - os.path.join(cdir, cid2str(state.client_identity), "store"), + os.path.join(basedir, "store"), ] + args_substd # TODO: maybe add cwd=cdir, not sure if necessary @@ -154,6 +156,9 @@ def restore_backup_into(client_dir): os.system(f'cp -r /tmp/client_state_backup {client_dir}') return ClientState.load(client_dir) + def __repr__(self): + values = ', '.join(f'{k}={str(getattr(self, k))}' for k in self.saveable_attrs.keys()) + return f'{self.__class__.__name__}({values})' def key_package_file(state, ref): return os.path.join(state.client_dir, cid2str(state.client_identity), ref.hex()) @@ -197,7 +202,7 @@ def add_member(state, kpfiles): "", "--welcome-out", welcome_file, - "--group-state-out", + "--group-info-out", pgs_file, "--group-out", "", diff --git a/hack/python/wire/otr_pb2.py b/hack/python/wire/otr_pb2.py new file mode 100644 index 00000000000..2596a0749e4 --- /dev/null +++ b/hack/python/wire/otr_pb2.py @@ -0,0 +1,54 @@ +# -*- coding: utf-8 -*- +# Generated by the protocol buffer compiler. DO NOT EDIT! +# source: libs/wire-message-proto-lens/generic-message-proto/proto/otr.proto +"""Generated protocol buffer code.""" +from google.protobuf.internal import builder as _builder +from google.protobuf import descriptor as _descriptor +from google.protobuf import descriptor_pool as _descriptor_pool +from google.protobuf import symbol_database as _symbol_database +# @@protoc_insertion_point(imports) + +_sym_db = _symbol_database.Default() + + + + +DESCRIPTOR = _descriptor_pool.Default().AddSerializedFile(b'\nBlibs/wire-message-proto-lens/generic-message-proto/proto/otr.proto\x12\x07proteus\"\x16\n\x06UserId\x12\x0c\n\x04uuid\x18\x01 \x02(\x0c\"-\n\x0fQualifiedUserId\x12\n\n\x02id\x18\x01 \x02(\t\x12\x0e\n\x06\x64omain\x18\x02 \x02(\t\"\x1a\n\x08\x43lientId\x12\x0e\n\x06\x63lient\x18\x01 \x02(\x04\">\n\x0b\x43lientEntry\x12!\n\x06\x63lient\x18\x01 \x02(\x0b\x32\x11.proteus.ClientId\x12\x0c\n\x04text\x18\x02 \x02(\x0c\"Q\n\tUserEntry\x12\x1d\n\x04user\x18\x01 \x02(\x0b\x32\x0f.proteus.UserId\x12%\n\x07\x63lients\x18\x02 \x03(\x0b\x32\x14.proteus.ClientEntry\"I\n\x12QualifiedUserEntry\x12\x0e\n\x06\x64omain\x18\x01 \x02(\t\x12#\n\x07\x65ntries\x18\x02 \x03(\x0b\x32\x12.proteus.UserEntry\"\xeb\x01\n\rNewOtrMessage\x12!\n\x06sender\x18\x01 \x02(\x0b\x32\x11.proteus.ClientId\x12&\n\nrecipients\x18\x02 \x03(\x0b\x32\x12.proteus.UserEntry\x12\x19\n\x0bnative_push\x18\x03 \x01(\x08:\x04true\x12\x0c\n\x04\x62lob\x18\x04 \x01(\x0c\x12*\n\x0fnative_priority\x18\x05 \x01(\x0e\x32\x11.proteus.Priority\x12\x11\n\ttransient\x18\x06 \x01(\x08\x12\'\n\x0ereport_missing\x18\x07 \x03(\x0b\x32\x0f.proteus.UserId\"\xf8\x03\n\x16QualifiedNewOtrMessage\x12!\n\x06sender\x18\x01 \x02(\x0b\x32\x11.proteus.ClientId\x12/\n\nrecipients\x18\x02 \x03(\x0b\x32\x1b.proteus.QualifiedUserEntry\x12\x19\n\x0bnative_push\x18\x03 \x01(\x08:\x04true\x12\x0c\n\x04\x62lob\x18\x04 \x01(\x0c\x12*\n\x0fnative_priority\x18\x05 \x01(\x0e\x32\x11.proteus.Priority\x12\x11\n\ttransient\x18\x06 \x01(\x08\x12?\n\nreport_all\x18\x07 \x01(\x0b\x32).proteus.ClientMismatchStrategy.ReportAllH\x00\x12?\n\nignore_all\x18\x08 \x01(\x0b\x32).proteus.ClientMismatchStrategy.IgnoreAllH\x00\x12\x41\n\x0breport_only\x18\t \x01(\x0b\x32*.proteus.ClientMismatchStrategy.ReportOnlyH\x00\x12\x41\n\x0bignore_only\x18\n \x01(\x0b\x32*.proteus.ClientMismatchStrategy.IgnoreOnlyH\x00\x42\x1a\n\x18\x63lient_mismatch_strategy\"\xa6\x01\n\x16\x43lientMismatchStrategy\x1a\x0b\n\tReportAll\x1a\x0b\n\tIgnoreAll\x1a\x38\n\nReportOnly\x12*\n\x08user_ids\x18\x01 \x03(\x0b\x32\x18.proteus.QualifiedUserId\x1a\x38\n\nIgnoreOnly\x12*\n\x08user_ids\x18\x01 \x03(\x0b\x32\x18.proteus.QualifiedUserId\"\x8d\x01\n\x0cOtrAssetMeta\x12!\n\x06sender\x18\x01 \x02(\x0b\x32\x11.proteus.ClientId\x12&\n\nrecipients\x18\x02 \x03(\x0b\x32\x12.proteus.UserEntry\x12\x17\n\x08isInline\x18\x03 \x01(\x08:\x05\x66\x61lse\x12\x19\n\x0bnative_push\x18\x04 \x01(\x08:\x04true*/\n\x08Priority\x12\x10\n\x0cLOW_PRIORITY\x10\x01\x12\x11\n\rHIGH_PRIORITY\x10\x02\x42\x1a\n\x11\x63om.wire.messagesB\x03OtrH\x03') + +_builder.BuildMessageAndEnumDescriptors(DESCRIPTOR, globals()) +_builder.BuildTopDescriptorsAndMessages(DESCRIPTOR, 'libs.wire_message_proto_lens.generic_message_proto.proto.otr_pb2', globals()) +if _descriptor._USE_C_DESCRIPTORS == False: + + DESCRIPTOR._options = None + DESCRIPTOR._serialized_options = b'\n\021com.wire.messagesB\003OtrH\003' + _PRIORITY._serialized_start=1458 + _PRIORITY._serialized_end=1505 + _USERID._serialized_start=79 + _USERID._serialized_end=101 + _QUALIFIEDUSERID._serialized_start=103 + _QUALIFIEDUSERID._serialized_end=148 + _CLIENTID._serialized_start=150 + _CLIENTID._serialized_end=176 + _CLIENTENTRY._serialized_start=178 + _CLIENTENTRY._serialized_end=240 + _USERENTRY._serialized_start=242 + _USERENTRY._serialized_end=323 + _QUALIFIEDUSERENTRY._serialized_start=325 + _QUALIFIEDUSERENTRY._serialized_end=398 + _NEWOTRMESSAGE._serialized_start=401 + _NEWOTRMESSAGE._serialized_end=636 + _QUALIFIEDNEWOTRMESSAGE._serialized_start=639 + _QUALIFIEDNEWOTRMESSAGE._serialized_end=1143 + _CLIENTMISMATCHSTRATEGY._serialized_start=1146 + _CLIENTMISMATCHSTRATEGY._serialized_end=1312 + _CLIENTMISMATCHSTRATEGY_REPORTALL._serialized_start=1172 + _CLIENTMISMATCHSTRATEGY_REPORTALL._serialized_end=1183 + _CLIENTMISMATCHSTRATEGY_IGNOREALL._serialized_start=1185 + _CLIENTMISMATCHSTRATEGY_IGNOREALL._serialized_end=1196 + _CLIENTMISMATCHSTRATEGY_REPORTONLY._serialized_start=1198 + _CLIENTMISMATCHSTRATEGY_REPORTONLY._serialized_end=1254 + _CLIENTMISMATCHSTRATEGY_IGNOREONLY._serialized_start=1256 + _CLIENTMISMATCHSTRATEGY_IGNOREONLY._serialized_end=1312 + _OTRASSETMETA._serialized_start=1315 + _OTRASSETMETA._serialized_end=1456 +# @@protoc_insertion_point(module_scope) diff --git a/integration/default.nix b/integration/default.nix index 600ceb7ddc1..979f965d392 100644 --- a/integration/default.nix +++ b/integration/default.nix @@ -15,9 +15,15 @@ , Cabal , case-insensitive , containers +, cql +, cql-io +, cryptonite , data-default +, data-timeout , directory +, errors , exceptions +, extended , extra , filepath , gitignoreSource @@ -28,15 +34,20 @@ , lens , lens-aeson , lib +, memory , mime , monad-control , mtl , network , network-uri , optparse-applicative +, pem , process +, proto-lens , random , raw-strings-qq +, regex-base +, regex-tdfa , retry , scientific , split @@ -53,6 +64,8 @@ , uuid , vector , websockets +, wire-message-proto-lens +, xml , yaml }: mkDerivation { @@ -74,9 +87,15 @@ mkDerivation { bytestring-conversion case-insensitive containers + cql + cql-io + cryptonite data-default + data-timeout directory + errors exceptions + extended extra filepath hex @@ -85,15 +104,20 @@ mkDerivation { kan-extensions lens lens-aeson + memory mime monad-control mtl network network-uri optparse-applicative + pem process + proto-lens random raw-strings-qq + regex-base + regex-tdfa retry scientific split @@ -110,6 +134,8 @@ mkDerivation { uuid vector websockets + wire-message-proto-lens + xml yaml ]; license = lib.licenses.agpl3Only; diff --git a/integration/integration.cabal b/integration/integration.cabal index c44937120fe..8bf42de6aba 100644 --- a/integration/integration.cabal +++ b/integration/integration.cabal @@ -21,8 +21,7 @@ custom-setup common common-all default-language: GHC2021 ghc-options: - -Wall -Wpartial-fields -fwarn-tabs - -optP-Wno-nonportable-include-path + -Wall -Wpartial-fields -fwarn-tabs -Wno-incomplete-uni-patterns default-extensions: NoImplicitPrelude @@ -90,23 +89,34 @@ library API.BrigInternal API.Cargohold API.Common + API.Federator API.Galley API.GalleyInternal API.Gundeck API.GundeckInternal API.Nginz MLS.Util + Notifications RunAllTests SetupHelpers + Test.AccessUpdate Test.AssetDownload Test.B2B Test.Brig Test.Client Test.Conversation Test.Demo + Test.Federation Test.Federator + Test.MessageTimer + Test.MLS + Test.MLS.KeyPackage + Test.MLS.Message + Test.MLS.One2One + Test.MLS.SubConversation Test.Notifications Test.Presence + Test.Roles Test.User Testlib.App Testlib.Assertions @@ -115,7 +125,9 @@ library Testlib.HTTP Testlib.JSON Testlib.ModService + Testlib.One2One Testlib.Options + Testlib.Ports Testlib.Prekeys Testlib.Prelude Testlib.Printing @@ -124,6 +136,7 @@ library Testlib.Run Testlib.RunServices Testlib.Types + Testlib.XML build-depends: , aeson @@ -137,9 +150,15 @@ library , bytestring-conversion , case-insensitive , containers + , cql + , cql-io + , cryptonite , data-default + , data-timeout , directory + , errors , exceptions + , extended , extra , filepath , hex @@ -148,15 +167,20 @@ library , kan-extensions , lens , lens-aeson + , memory , mime , monad-control , mtl , network , network-uri , optparse-applicative + , pem , process + , proto-lens , random , raw-strings-qq + , regex-base + , regex-tdfa , retry , scientific , split @@ -173,4 +197,6 @@ library , uuid , vector , websockets + , wire-message-proto-lens + , xml , yaml diff --git a/integration/test/API/Brig.hs b/integration/test/API/Brig.hs index af7d16487d4..455ce04e9d9 100644 --- a/integration/test/API/Brig.hs +++ b/integration/test/API/Brig.hs @@ -1,6 +1,7 @@ module API.Brig where import API.Common +import Data.Aeson qualified as Aeson import Data.ByteString.Base64 qualified as Base64 import Data.Foldable import Data.Function @@ -8,6 +9,29 @@ import Data.Text.Encoding qualified as T import GHC.Stack import Testlib.Prelude +data AddUser = AddUser + { name :: Maybe String, + email :: Maybe String, + teamCode :: Maybe String, + password :: Maybe String + } + +instance Default AddUser where + def = AddUser Nothing Nothing Nothing Nothing + +addUser :: (HasCallStack, MakesValue dom) => dom -> AddUser -> App Response +addUser dom opts = do + req <- baseRequest dom Brig Versioned "register" + name <- maybe randomName pure opts.name + submit "POST" $ + req + & addJSONObject + [ "name" .= name, + "email" .= opts.email, + "team_code" .= opts.teamCode, + "password" .= fromMaybe defPassword opts.password + ] + getUser :: (HasCallStack, MakesValue user, MakesValue target) => user -> @@ -20,6 +44,14 @@ getUser user target = do joinHttpPath ["users", domain, uid] submit "GET" req +getUserByHandle :: (HasCallStack, MakesValue user, MakesValue domain) => user -> domain -> String -> App Response +getUserByHandle user domain handle = do + domainStr <- asString domain + req <- + baseRequest user Brig Versioned $ + joinHttpPath ["users", "by-handle", domainStr, handle] + submit "GET" req + getClient :: (HasCallStack, MakesValue user, MakesValue client) => user -> @@ -32,6 +64,18 @@ getClient u cli = do joinHttpPath ["clients", c] submit "GET" req +deleteUser :: (HasCallStack, MakesValue user) => user -> App Response +deleteUser user = do + req <- baseRequest user Brig Versioned "/self" + submit "DELETE" $ + req & addJSONObject ["password" .= defPassword] + +putHandle :: (HasCallStack, MakesValue user) => user -> String -> App Response +putHandle user handle = do + req <- baseRequest user Brig Versioned "/self/handle" + submit "PUT" $ + req & addJSONObject ["handle" .= handle] + data AddClient = AddClient { ctype :: String, internal :: Bool, @@ -146,6 +190,12 @@ getClientsQualified user domain otherUser = do <> "/clients" submit "GET" req +listUsersClients :: (HasCallStack, MakesValue user, MakesValue qualifiedUserIds) => user -> [qualifiedUserIds] -> App Response +listUsersClients usr qualifiedUserIds = do + qUsers <- mapM objQidObject qualifiedUserIds + req <- baseRequest usr Brig Versioned $ joinHttpPath ["users", "list-clients"] + submit "POST" (req & addJSONObject ["qualified_users" .= qUsers]) + searchContacts :: ( MakesValue user, MakesValue searchTerm, @@ -212,26 +262,45 @@ putConnection userFrom userTo status = do baseRequest userFrom Brig Versioned $ joinHttpPath ["/connections", userToDomain, userToId] statusS <- asString status - submit "POST" (req & addJSONObject ["status" .= statusS]) + submit "PUT" (req & addJSONObject ["status" .= statusS]) -uploadKeyPackage :: ClientIdentity -> ByteString -> App Response -uploadKeyPackage cid kp = do +getConnections :: (HasCallStack, MakesValue user) => user -> App Response +getConnections user = do + req <- baseRequest user Brig Versioned "/list-connections" + submit "POST" (req & addJSONObject ["size" .= Aeson.Number 500]) + +uploadKeyPackages :: ClientIdentity -> [ByteString] -> App Response +uploadKeyPackages cid kps = do req <- baseRequest cid Brig Versioned $ "/mls/key-packages/self/" <> cid.client submit "POST" ( req - & addJSONObject ["key_packages" .= [T.decodeUtf8 (Base64.encode kp)]] + & addJSONObject ["key_packages" .= map (T.decodeUtf8 . Base64.encode) kps] ) -claimKeyPackages :: (MakesValue u, MakesValue v) => u -> v -> App Response -claimKeyPackages u v = do +claimKeyPackages :: (MakesValue u, MakesValue v) => Ciphersuite -> u -> v -> App Response +claimKeyPackages suite u v = do (targetDom, targetUid) <- objQid v req <- baseRequest u Brig Versioned $ "/mls/key-packages/claim/" <> targetDom <> "/" <> targetUid - submit "POST" req + submit "POST" $ + req + & addQueryParams [("ciphersuite", suite.code)] + +countKeyPackages :: Ciphersuite -> ClientIdentity -> App Response +countKeyPackages suite cid = do + req <- baseRequest cid Brig Versioned ("/mls/key-packages/self/" <> cid.client <> "/count") + submit "GET" $ + req + & addQueryParams [("ciphersuite", suite.code)] + +deleteKeyPackages :: ClientIdentity -> [String] -> App Response +deleteKeyPackages cid kps = do + req <- baseRequest cid Brig Versioned ("/mls/key-packages/self/" <> cid.client) + submit "DELETE" $ req & addJSONObject ["key_packages" .= kps] getSelf :: HasCallStack => String -> String -> App Response getSelf domain uid = do @@ -262,6 +331,27 @@ putUserSupportedProtocols user ps = do joinHttpPath ["self", "supported-protocols"] submit "PUT" (req & addJSONObject ["supported_protocols" .= ps]) +data PostInvitation = PostInvitation + { email :: Maybe String + } + +instance Default PostInvitation where + def = PostInvitation Nothing + +postInvitation :: + (HasCallStack, MakesValue user) => + user -> + PostInvitation -> + App Response +postInvitation user inv = do + tid <- user %. "team" & asString + req <- + baseRequest user Brig Versioned $ + joinHttpPath ["teams", tid, "invitations"] + email <- maybe randomEmail pure inv.email + submit "POST" $ + req & addJSONObject ["email" .= email] + getApiVersions :: HasCallStack => App Response getApiVersions = do req <- diff --git a/integration/test/API/BrigInternal.hs b/integration/test/API/BrigInternal.hs index fdd6b0587d5..e536e57e6a6 100644 --- a/integration/test/API/BrigInternal.hs +++ b/integration/test/API/BrigInternal.hs @@ -11,6 +11,7 @@ data CreateUser = CreateUser password :: Maybe String, name :: Maybe String, team :: Bool, + activate :: Bool, supportedProtocols :: Maybe [String] } @@ -21,23 +22,25 @@ instance Default CreateUser where password = Nothing, name = Nothing, team = False, + activate = True, supportedProtocols = Nothing } createUser :: (HasCallStack, MakesValue domain) => domain -> CreateUser -> App Response createUser domain cu = do - email <- maybe randomEmail pure cu.email + re <- randomEmail + let email :: Maybe String = guard cu.activate $> fromMaybe re cu.email let password = fromMaybe defPassword cu.password - name = fromMaybe email cu.name + name = fromMaybe "default" (cu.name <|> email) req <- baseRequest domain Brig Unversioned "/i/users" submit "POST" $ req & addJSONObject - ( [ "email" .= email, - "name" .= name, - "password" .= password, - "icon" .= "default" - ] + ( ["email" .= e | e <- toList email] + <> [ "name" .= name, + "password" .= password, + "icon" .= "default" + ] <> ["supported_protocols" .= prots | prots <- toList cu.supportedProtocols] <> [ "team" .= object @@ -99,17 +102,6 @@ updateFedConn' owndom dom fedConn = do conn <- make fedConn submit "PUT" $ addJSON conn req -deleteFedConn :: (HasCallStack, MakesValue owndom) => owndom -> String -> App Response -deleteFedConn owndom dom = do - bindResponse (deleteFedConn' owndom dom) $ \res -> do - res.status `shouldMatchRange` (200, 299) - pure res - -deleteFedConn' :: (HasCallStack, MakesValue owndom) => owndom -> String -> App Response -deleteFedConn' owndom dom = do - req <- rawBaseRequest owndom Brig Unversioned ("/i/federation/remotes/" <> dom) - submit "DELETE" req - registerOAuthClient :: (HasCallStack, MakesValue user, MakesValue name, MakesValue url) => user -> name -> url -> App Response registerOAuthClient user name url = do req <- baseRequest user Brig Unversioned "i/oauth/clients" @@ -137,8 +129,28 @@ deleteOAuthClient user cid = do req <- baseRequest user Brig Unversioned $ "i/oauth/clients/" <> clientId submit "DELETE" req +getInvitationCode :: (HasCallStack, MakesValue user, MakesValue inv) => user -> inv -> App Response +getInvitationCode user inv = do + tid <- user %. "team" & asString + invId <- inv %. "id" & asString + req <- + baseRequest user Brig Unversioned $ + "i/teams/invitation-code?team=" <> tid <> "&invitation_id=" <> invId + submit "GET" req + refreshIndex :: (HasCallStack, MakesValue domain) => domain -> App () refreshIndex domain = do req <- baseRequest domain Brig Unversioned "i/index/refresh" res <- submit "POST" req res.status `shouldMatchInt` 200 + +connectWithRemoteUser :: (MakesValue userFrom, MakesValue userTo) => userFrom -> userTo -> App () +connectWithRemoteUser userFrom userTo = do + userFromId <- objId userFrom + qUserTo <- make userTo + let body = ["tag" .= "CreateConnectionForTest", "user" .= userFromId, "other" .= qUserTo] + req <- + baseRequest userFrom Brig Unversioned $ + joinHttpPath ["i", "connections", "connection-update"] + res <- submit "PUT" (req & addJSONObject body) + res.status `shouldMatchInt` 200 diff --git a/integration/test/API/Cargohold.hs b/integration/test/API/Cargohold.hs index 34276fecb38..5e08d84d794 100644 --- a/integration/test/API/Cargohold.hs +++ b/integration/test/API/Cargohold.hs @@ -122,11 +122,8 @@ buildMultipartBody header body bodyMimeType = MIME.mime_val_content = MIME.Single ((decodeUtf8 . LBS.toStrict) c) } -downloadAsset :: (HasCallStack, MakesValue user, MakesValue assetDomain, MakesValue key) => user -> assetDomain -> key -> (HTTP.Request -> HTTP.Request) -> App Response -downloadAsset user assetDomain key trans = downloadAsset' user assetDomain key "nginz-https.example.com" trans - -downloadAsset' :: (HasCallStack, MakesValue user, MakesValue key, MakesValue assetDomain) => user -> assetDomain -> key -> String -> (HTTP.Request -> HTTP.Request) -> App Response -downloadAsset' user assetDomain key zHostHeader trans = do +downloadAsset :: (HasCallStack, MakesValue user, MakesValue key, MakesValue assetDomain) => user -> assetDomain -> key -> String -> (HTTP.Request -> HTTP.Request) -> App Response +downloadAsset user assetDomain key zHostHeader trans = do uid <- objId user domain <- objDomain assetDomain key' <- asString key diff --git a/integration/test/API/Common.hs b/integration/test/API/Common.hs index 85c978cb7a3..b51125fca67 100644 --- a/integration/test/API/Common.hs +++ b/integration/test/API/Common.hs @@ -18,14 +18,26 @@ defPassword :: String defPassword = "hunter2!" randomEmail :: App String -randomEmail = liftIO $ do - n <- randomRIO (8, 15) - u <- replicateM n pick +randomEmail = do + u <- randomName pure $ u <> "@example.com" + +randomName :: App String +randomName = liftIO $ do + n <- randomRIO (8, 15) + replicateM n pick where chars = mkArray $ ['A' .. 'Z'] <> ['a' .. 'z'] <> ['0' .. '9'] pick = (chars !) <$> randomRIO (Array.bounds chars) +randomHandle :: App String +randomHandle = liftIO $ do + n <- randomRIO (50, 256) + replicateM n pick + where + chars = mkArray $ ['a' .. 'z'] <> ['0' .. '9'] <> "_-." + pick = (chars !) <$> randomRIO (Array.bounds chars) + randomHex :: Int -> App String randomHex n = liftIO $ replicateM n pick where diff --git a/integration/test/API/Federator.hs b/integration/test/API/Federator.hs new file mode 100644 index 00000000000..089b79c45c7 --- /dev/null +++ b/integration/test/API/Federator.hs @@ -0,0 +1,24 @@ +module API.Federator where + +import Data.Function +import GHC.Stack +import Network.HTTP.Client qualified as HTTP +import Testlib.Prelude + +getMetrics :: + (HasCallStack, MakesValue domain) => + domain -> + (ServiceMap -> HostPort) -> + App Response +getMetrics domain service = do + req <- rawBaseRequestF domain service "i/metrics" + submit "GET" req + +rawBaseRequestF :: (HasCallStack, MakesValue domain) => domain -> (ServiceMap -> HostPort) -> String -> App HTTP.Request +rawBaseRequestF domain getService path = do + domainV <- objDomain domain + serviceMap <- getServiceMap domainV + + liftIO . HTTP.parseRequest $ + let HostPort h p = getService serviceMap + in "http://" <> h <> ":" <> show p <> ("/" <> joinHttpPath (splitHttpPath path)) diff --git a/integration/test/API/Galley.hs b/integration/test/API/Galley.hs index 3b2446f47df..87e5042bc6d 100644 --- a/integration/test/API/Galley.hs +++ b/integration/test/API/Galley.hs @@ -1,6 +1,20 @@ +{-# LANGUAGE OverloadedLabels #-} + module API.Galley where +import Control.Lens hiding ((.=)) +import Control.Monad.Reader import Data.Aeson qualified as Aeson +import Data.Aeson.Types qualified as Aeson +import Data.ByteString.Base64 qualified as B64 +import Data.ByteString.Base64.URL qualified as B64U +import Data.ByteString.Char8 qualified as BS +import Data.ByteString.Lazy qualified as LBS +import Data.ProtoLens qualified as Proto +import Data.ProtoLens.Labels () +import Data.UUID qualified as UUID +import Numeric.Lens +import Proto.Otr as Proto import Testlib.Prelude data CreateConv = CreateConv @@ -32,6 +46,13 @@ defProteus = defMLS :: CreateConv defMLS = defProteus {protocol = "mls"} +allowGuests :: CreateConv -> CreateConv +allowGuests cc = + cc + { access = Just ["code"], + accessRole = Just ["team_member", "guest"] + } + instance MakesValue CreateConv where make cc = do quids <- for (cc.qualifiedUsers) objQidObject @@ -63,11 +84,25 @@ postConversation user cc = do ccv <- make cc submit "POST" $ req & addJSON ccv +deleteTeamConversation :: + ( HasCallStack, + MakesValue user, + MakesValue conv + ) => + String -> + conv -> + user -> + App Response +deleteTeamConversation tid qcnv user = do + cnv <- snd <$> objQid qcnv + let path = joinHttpPath ["teams", tid, "conversations", cnv] + req <- baseRequest user Galley Versioned path + submit "DELETE" req + putConversationProtocol :: ( HasCallStack, MakesValue user, MakesValue qcnv, - MakesValue conn, MakesValue protocol ) => user -> @@ -115,6 +150,34 @@ getSubConversation user conv sub = do ] submit "GET" req +deleteSubConversation :: + (HasCallStack, MakesValue user, MakesValue sub) => + user -> + sub -> + App Response +deleteSubConversation user sub = do + (conv, Just subId) <- objSubConv sub + (domain, convId) <- objQid conv + groupId <- sub %. "group_id" & asString + epoch :: Int <- sub %. "epoch" & asIntegral + req <- + baseRequest user Galley Versioned $ + joinHttpPath ["conversations", domain, convId, "subconversations", subId] + submit "DELETE" $ req & addJSONObject ["group_id" .= groupId, "epoch" .= epoch] + +leaveSubConversation :: + (HasCallStack, MakesValue user, MakesValue sub) => + user -> + sub -> + App Response +leaveSubConversation user sub = do + (conv, Just subId) <- objSubConv sub + (domain, convId) <- objQid conv + req <- + baseRequest user Galley Versioned $ + joinHttpPath ["conversations", domain, convId, "subconversations", subId, "self"] + submit "DELETE" req + getSelfConversation :: (HasCallStack, MakesValue user) => user -> App Response getSelfConversation user = do req <- baseRequest user Galley Versioned "/conversations/mls-self" @@ -152,6 +215,40 @@ postMLSCommitBundle cid msg = do req <- baseRequest cid Galley Versioned "/mls/commit-bundles" submit "POST" (addMLS msg req) +postProteusMessage :: (HasCallStack, MakesValue user, MakesValue conv) => user -> conv -> QualifiedNewOtrMessage -> App Response +postProteusMessage user conv msgs = do + convDomain <- objDomain conv + convId <- objId conv + let bytes = Proto.encodeMessage msgs + req <- baseRequest user Galley Versioned ("/conversations/" <> convDomain <> "/" <> convId <> "/proteus/messages") + submit "POST" (addProtobuf bytes req) + +mkProteusRecipient :: (HasCallStack, MakesValue user, MakesValue client) => user -> client -> String -> App Proto.QualifiedUserEntry +mkProteusRecipient user client = mkProteusRecipients user [(user, [client])] + +mkProteusRecipients :: (HasCallStack, MakesValue domain, MakesValue user, MakesValue client) => domain -> [(user, [client])] -> String -> App Proto.QualifiedUserEntry +mkProteusRecipients dom userClients msg = do + userDomain <- asString =<< objDomain dom + userEntries <- mapM mkUserEntry userClients + pure $ + Proto.defMessage + & #domain .~ fromString userDomain + & #entries .~ userEntries + where + mkUserEntry (user, clients) = do + userId <- LBS.toStrict . UUID.toByteString . fromJust . UUID.fromString <$> objId user + clientEntries <- mapM mkClientEntry clients + pure $ + Proto.defMessage + & #user . #uuid .~ userId + & #clients .~ clientEntries + mkClientEntry client = do + clientId <- (^?! hex) <$> objId client + pure $ + Proto.defMessage + & #client . #client .~ clientId + & #text .~ fromString msg + getGroupInfo :: (HasCallStack, MakesValue user, MakesValue conv) => user -> @@ -166,8 +263,224 @@ getGroupInfo user conv = do req <- baseRequest user Galley Versioned path submit "GET" req -addMembers :: (HasCallStack, MakesValue user, MakesValue conv) => user -> conv -> [Value] -> App Response -addMembers usr qcnv qUsers = do +removeConversationMember :: + (HasCallStack, MakesValue user, MakesValue conv) => + user -> + conv -> + App Response +removeConversationMember user conv = do + (convDomain, convId) <- objQid conv + (userDomain, userId) <- objQid user + req <- baseRequest user Galley Versioned (joinHttpPath ["conversations", convDomain, convId, "members", userDomain, userId]) + submit "DELETE" req + +updateConversationMember :: + (HasCallStack, MakesValue user, MakesValue conv, MakesValue target) => + user -> + conv -> + target -> + String -> + App Response +updateConversationMember user conv target role = do + (convDomain, convId) <- objQid conv + (targetDomain, targetId) <- objQid target + req <- baseRequest user Galley Versioned (joinHttpPath ["conversations", convDomain, convId, "members", targetDomain, targetId]) + submit "PUT" (req & addJSONObject ["conversation_role" .= role]) + +deleteTeamConv :: + (HasCallStack, MakesValue team, MakesValue conv, MakesValue user) => + team -> + conv -> + user -> + App Response +deleteTeamConv team conv user = do + teamId <- objId team + convId <- objId conv + req <- baseRequest user Galley Versioned (joinHttpPath ["teams", teamId, "conversations", convId]) + submit "DELETE" req + +getMLSOne2OneConversation :: + (HasCallStack, MakesValue self, MakesValue other) => + self -> + other -> + App Response +getMLSOne2OneConversation self other = do + (domain, uid) <- objQid other + req <- + baseRequest self Galley Versioned $ + joinHttpPath ["conversations", "one2one", domain, uid] + submit "GET" req + +getGroupClients :: + (HasCallStack, MakesValue user) => + user -> + String -> + App Response +getGroupClients user groupId = do + req <- + baseRequest + user + Galley + Unversioned + (joinHttpPath ["i", "group", BS.unpack . B64U.encodeUnpadded . B64.decodeLenient $ BS.pack groupId]) + submit "GET" req + +data AddMembers = AddMembers + { users :: [Value], + role :: Maybe String, + version :: Maybe Int + } + +instance Default AddMembers where + def = AddMembers {users = [], role = Nothing, version = Nothing} + +addMembers :: + (HasCallStack, MakesValue user, MakesValue conv) => + user -> + conv -> + AddMembers -> + App Response +addMembers usr qcnv opts = do (convDomain, convId) <- objQid qcnv - req <- baseRequest usr Galley Versioned (joinHttpPath ["conversations", convDomain, convId, "members"]) - submit "POST" (req & addJSONObject ["qualified_users" .= qUsers]) + qUsers <- mapM objQidObject opts.users + let path = case opts.version of + Just v | v <= 1 -> ["conversations", convId, "members", "v2"] + _ -> ["conversations", convDomain, convId, "members"] + req <- + baseRequest + usr + Galley + (maybe Versioned ExplicitVersion opts.version) + (joinHttpPath path) + submit "POST" $ + req + & addJSONObject + ( ["qualified_users" .= qUsers] + <> ["conversation_role" .= r | r <- toList opts.role] + ) + +removeMember :: (HasCallStack, MakesValue remover, MakesValue conv, MakesValue removed) => remover -> conv -> removed -> App Response +removeMember remover qcnv removed = do + (convDomain, convId) <- objQid qcnv + (removedDomain, removedId) <- objQid removed + req <- baseRequest remover Galley Versioned (joinHttpPath ["conversations", convDomain, convId, "members", removedDomain, removedId]) + submit "DELETE" req + +postConversationCode :: + (HasCallStack, MakesValue user, MakesValue conv) => + user -> + conv -> + Maybe String -> + Maybe String -> + App Response +postConversationCode user conv mbpassword mbZHost = do + convId <- objId conv + req <- baseRequest user Galley Versioned (joinHttpPath ["conversations", convId, "code"]) + submit + "POST" + ( req + & addJSONObject ["password" .= pw | pw <- maybeToList mbpassword] + & maybe id zHost mbZHost + ) + +getConversationCode :: + (HasCallStack, MakesValue user, MakesValue conv) => + user -> + conv -> + Maybe String -> + App Response +getConversationCode user conv mbZHost = do + convId <- objId conv + req <- baseRequest user Galley Versioned (joinHttpPath ["conversations", convId, "code"]) + submit + "GET" + ( req + & addQueryParams [("cnv", convId)] + & maybe id zHost mbZHost + ) + +changeConversationName :: + (HasCallStack, MakesValue user, MakesValue conv, MakesValue name) => + user -> + conv -> + name -> + App Response +changeConversationName user qcnv name = do + (convDomain, convId) <- objQid qcnv + let path = joinHttpPath ["conversations", convDomain, convId, "name"] + nameReq <- make name + req <- baseRequest user Galley Versioned path + submit "PUT" (req & addJSONObject ["name" .= nameReq]) + +updateRole :: + ( HasCallStack, + MakesValue callerUser, + MakesValue targetUser, + MakesValue roleUpdate, + MakesValue qcnv + ) => + callerUser -> + targetUser -> + roleUpdate -> + qcnv -> + App Response +updateRole caller target role qcnv = do + (cnvDomain, cnvId) <- objQid qcnv + (tarDomain, tarId) <- objQid target + roleReq <- make role + req <- + baseRequest + caller + Galley + Versioned + ( joinHttpPath ["conversations", cnvDomain, cnvId, "members", tarDomain, tarId] + ) + submit "PUT" (req & addJSONObject ["conversation_role" .= roleReq]) + +updateReceiptMode :: + ( HasCallStack, + MakesValue user, + MakesValue conv, + MakesValue mode + ) => + user -> + conv -> + mode -> + App Response +updateReceiptMode user qcnv mode = do + (cnvDomain, cnvId) <- objQid qcnv + modeReq <- make mode + let path = joinHttpPath ["conversations", cnvDomain, cnvId, "receipt-mode"] + req <- baseRequest user Galley Versioned path + submit "PUT" (req & addJSONObject ["receipt_mode" .= modeReq]) + +updateAccess :: + ( HasCallStack, + MakesValue user, + MakesValue conv + ) => + user -> + conv -> + [Aeson.Pair] -> + App Response +updateAccess user qcnv update = do + (cnvDomain, cnvId) <- objQid qcnv + let path = joinHttpPath ["conversations", cnvDomain, cnvId, "access"] + req <- baseRequest user Galley Versioned path + submit "PUT" (req & addJSONObject update) + +updateMessageTimer :: + ( HasCallStack, + MakesValue user, + MakesValue conv + ) => + user -> + conv -> + Word64 -> + App Response +updateMessageTimer user qcnv update = do + (cnvDomain, cnvId) <- objQid qcnv + updateReq <- make update + let path = joinHttpPath ["conversations", cnvDomain, cnvId, "message-timer"] + req <- baseRequest user Galley Versioned path + submit "PUT" (addJSONObject ["message_timer" .= updateReq] req) diff --git a/integration/test/API/GalleyInternal.hs b/integration/test/API/GalleyInternal.hs index 8e78beb8b81..428199004e5 100644 --- a/integration/test/API/GalleyInternal.hs +++ b/integration/test/API/GalleyInternal.hs @@ -12,7 +12,7 @@ putTeamMember user team perms = do tid <- asString team req <- baseRequest - OwnDomain + user Galley Unversioned ("/i/teams/" <> tid <> "/members") @@ -32,9 +32,9 @@ putTeamMember user team perms = do ] req -getTeamFeature :: HasCallStack => String -> String -> App Response -getTeamFeature featureName tid = do - req <- baseRequest OwnDomain Galley Unversioned $ joinHttpPath ["i", "teams", tid, "features", featureName] +getTeamFeature :: (HasCallStack, MakesValue domain_) => domain_ -> String -> String -> App Response +getTeamFeature domain_ featureName tid = do + req <- baseRequest domain_ Galley Unversioned $ joinHttpPath ["i", "teams", tid, "features", featureName] submit "GET" $ req getFederationStatus :: diff --git a/integration/test/API/Gundeck.hs b/integration/test/API/Gundeck.hs index 42fccb29c15..1f2c1a76429 100644 --- a/integration/test/API/Gundeck.hs +++ b/integration/test/API/Gundeck.hs @@ -5,54 +5,58 @@ import Testlib.Prelude data GetNotifications = GetNotifications { since :: Maybe String, - size :: Maybe Int + size :: Maybe Int, + client :: Maybe String } instance Default GetNotifications where - def = GetNotifications {since = Nothing, size = Nothing} + def = GetNotifications {since = Nothing, size = Nothing, client = Nothing} getNotifications :: - (HasCallStack, MakesValue user, MakesValue client) => + (HasCallStack, MakesValue user) => user -> - client -> GetNotifications -> App Response -getNotifications user client r = do - c <- client & asString +getNotifications user r = do req <- baseRequest user Gundeck Versioned "/notifications" let req' = req & addQueryParams ( [("since", since) | since <- toList r.since] - <> [("client", c)] + <> [("client", c) | c <- toList r.client] <> [("size", show size) | size <- toList r.size] ) submit "GET" req' +data GetNotification = GetNotification + { client :: Maybe String + } + +instance Default GetNotification where + def = GetNotification Nothing + getNotification :: - (HasCallStack, MakesValue user, MakesValue client, MakesValue nid) => + (HasCallStack, MakesValue user, MakesValue nid) => user -> - client -> + GetNotification -> nid -> App Response -getNotification user client nid = do - c <- client & asString +getNotification user opts nid = do n <- nid & asString req <- baseRequest user Gundeck Versioned $ joinHttpPath ["notifications", n] - submit "GET" $ req & addQueryParams [("client", c)] + submit "GET" $ req & addQueryParams [("client", c) | c <- toList opts.client] getLastNotification :: - (HasCallStack, MakesValue user, MakesValue client) => + (HasCallStack, MakesValue user) => user -> - client -> + GetNotification -> App Response -getLastNotification user client = do - c <- client & asString +getLastNotification user opts = do req <- baseRequest user Gundeck Versioned "/notifications/last" - submit "GET" $ req & addQueryParams [("client", c)] + submit "GET" $ req & addQueryParams [("client", c) | c <- toList opts.client] data PostPushToken = PostPushToken { transport :: String, diff --git a/integration/test/API/Nginz.hs b/integration/test/API/Nginz.hs index e01e3505e79..4c34ef639d3 100644 --- a/integration/test/API/Nginz.hs +++ b/integration/test/API/Nginz.hs @@ -6,3 +6,30 @@ getSystemSettingsUnAuthorized :: (HasCallStack, MakesValue domain) => domain -> getSystemSettingsUnAuthorized domain = do req <- baseRequest domain Nginz Versioned "/system/settings/unauthorized" submit "GET" req + +login :: (HasCallStack, MakesValue domain, MakesValue email, MakesValue password) => domain -> email -> password -> App Response +login domain email pw = do + req <- rawBaseRequest domain Nginz Unversioned "/login" + emailStr <- make email >>= asString + pwStr <- make pw >>= asString + submit "POST" (req & addJSONObject ["email" .= emailStr, "password" .= pwStr, "label" .= "auth"]) + +access :: (HasCallStack, MakesValue domain, MakesValue cookie) => domain -> cookie -> App Response +access domain cookie = do + req <- rawBaseRequest domain Nginz Unversioned "/access" + cookieStr <- make cookie >>= asString + submit "POST" (req & setCookie cookieStr) + +logout :: (HasCallStack, MakesValue domain, MakesValue cookie, MakesValue token) => domain -> cookie -> token -> App Response +logout d c t = do + req <- rawBaseRequest d Nginz Unversioned "/access/logout" + cookie <- make c & asString + token <- make t & asString + submit "POST" (req & setCookie cookie & addHeader "Authorization" ("Bearer " <> token)) + +getConversation :: (HasCallStack, MakesValue user, MakesValue qcnv, MakesValue token) => user -> qcnv -> token -> App Response +getConversation user qcnv t = do + (domain, cnv) <- objQid qcnv + token <- make t & asString + req <- rawBaseRequest user Nginz Versioned (joinHttpPath ["conversations", domain, cnv]) + submit "GET" (req & addHeader "Authorization" ("Bearer " <> token)) diff --git a/integration/test/MLS/Util.hs b/integration/test/MLS/Util.hs index 414195a0653..d6bef3fc8a0 100644 --- a/integration/test/MLS/Util.hs +++ b/integration/test/MLS/Util.hs @@ -10,13 +10,14 @@ import Control.Monad.Catch import Control.Monad.Cont import Control.Monad.Reader import Control.Monad.Trans.Maybe +import Data.Aeson qualified as Aeson import Data.ByteString qualified as BS import Data.ByteString.Base64 qualified as Base64 import Data.ByteString.Char8 qualified as B8 +import Data.ByteString.Char8 qualified as C8 import Data.Default import Data.Foldable import Data.Function -import Data.Hex import Data.Map qualified as Map import Data.Maybe import Data.Set qualified as Set @@ -28,13 +29,12 @@ import GHC.Stack import System.Directory import System.Exit import System.FilePath -import System.IO +import System.IO hiding (print, putStrLn) import System.IO.Temp import System.Posix.Files import System.Process import Testlib.App import Testlib.Assertions -import Testlib.Env import Testlib.HTTP import Testlib.JSON import Testlib.Prelude @@ -75,35 +75,46 @@ randomFileName = do mlscli :: HasCallStack => ClientIdentity -> [String] -> Maybe ByteString -> App ByteString mlscli cid args mbstdin = do - bd <- getBaseDir - let cdir = bd cid2Str cid - groupOut <- randomFileName let substOut = argSubst "" groupOut - hasState <- hasClientGroupState cid - substIn <- - if hasState - then do - gs <- getClientGroupState cid - fn <- toRandomFile gs - pure (argSubst "" fn) - else pure id + gs <- getClientGroupState cid + + substIn <- case gs.group of + Nothing -> pure id + Just groupData -> do + fn <- toRandomFile groupData + pure (argSubst "" fn) + store <- maybe randomFileName toRandomFile gs.keystore + + let args' = map (substIn . substOut) args + for_ args' $ \arg -> + when (arg `elem` ["", ""]) $ + assertFailure ("Unbound arg: " <> arg) out <- spawn ( proc "mls-test-cli" - ( ["--store", cdir "store"] - <> map (substIn . substOut) args + ( ["--store", store] + <> args' ) ) mbstdin - groupOutWritten <- liftIO $ doesFileExist groupOut - when groupOutWritten $ do - gs <- liftIO (BS.readFile groupOut) - setClientGroupState cid gs + setGroup <- do + groupOutWritten <- liftIO $ doesFileExist groupOut + if groupOutWritten + then do + groupData <- liftIO (BS.readFile groupOut) + pure $ \x -> x {group = Just groupData} + else pure id + setStore <- do + storeData <- liftIO (BS.readFile store) + pure $ \x -> x {keystore = Just storeData} + + setClientGroupState cid ((setGroup . setStore) gs) + pure out argSubst :: String -> String -> String -> String @@ -116,17 +127,36 @@ createWireClient u = do c <- addClient u def {lastPrekey = Just lpk} >>= getJSON 201 mkClientIdentity u c -initMLSClient :: HasCallStack => ClientIdentity -> App () -initMLSClient cid = do +data CredentialType = BasicCredentialType | X509CredentialType + +instance MakesValue CredentialType where + make BasicCredentialType = make "basic" + make X509CredentialType = make "x509" + +instance HasTests x => HasTests (CredentialType -> x) where + mkTests m n s f x = + mkTests m (n <> "[ctype=basic]") s f (x BasicCredentialType) + <> mkTests m (n <> "[ctype=x509]") s f (x X509CredentialType) + +data InitMLSClient = InitMLSClient + {credType :: CredentialType} + +instance Default InitMLSClient where + def = InitMLSClient {credType = BasicCredentialType} + +initMLSClient :: HasCallStack => InitMLSClient -> ClientIdentity -> App () +initMLSClient opts cid = do bd <- getBaseDir + mls <- getMLSState liftIO $ createDirectory (bd cid2Str cid) - void $ mlscli cid ["init", cid2Str cid] Nothing + ctype <- make opts.credType & asString + void $ mlscli cid ["init", "--ciphersuite", mls.ciphersuite.code, "-t", ctype, cid2Str cid] Nothing -- | Create new mls client and register with backend. -createMLSClient :: (MakesValue u, HasCallStack) => u -> App ClientIdentity -createMLSClient u = do +createMLSClient :: (MakesValue u, HasCallStack) => InitMLSClient -> u -> App ClientIdentity +createMLSClient opts u = do cid <- createWireClient u - initMLSClient cid + initMLSClient opts cid -- set public key pkey <- mlscli cid ["public-key"] Nothing @@ -147,22 +177,23 @@ uploadNewKeyPackage cid = do (kp, ref) <- generateKeyPackage cid -- upload key package - bindResponse (uploadKeyPackage cid kp) $ \resp -> + bindResponse (uploadKeyPackages cid [kp]) $ \resp -> resp.status `shouldMatchInt` 201 pure ref generateKeyPackage :: HasCallStack => ClientIdentity -> App (ByteString, String) generateKeyPackage cid = do - kp <- mlscli cid ["key-package", "create"] Nothing - ref <- B8.unpack . hex <$> mlscli cid ["key-package", "ref", "-"] (Just kp) + mls <- getMLSState + kp <- mlscli cid ["key-package", "create", "--ciphersuite", mls.ciphersuite.code] Nothing + ref <- B8.unpack . Base64.encode <$> mlscli cid ["key-package", "ref", "-"] (Just kp) fp <- keyPackageFile cid ref liftIO $ BS.writeFile fp kp pure (kp, ref) -- | Create conversation and corresponding group. -setupMLSGroup :: HasCallStack => ClientIdentity -> App (String, Value) -setupMLSGroup cid = do +createNewGroup :: HasCallStack => ClientIdentity -> App (String, Value) +createNewGroup cid = do conv <- postConversation cid defMLS >>= getJSON 201 groupId <- conv %. "group_id" & asString convId <- conv %. "qualified_id" @@ -170,8 +201,8 @@ setupMLSGroup cid = do pure (groupId, convId) -- | Retrieve self conversation and create the corresponding group. -setupMLSSelfGroup :: HasCallStack => ClientIdentity -> App (String, Value) -setupMLSSelfGroup cid = do +createSelfGroup :: HasCallStack => ClientIdentity -> App (String, Value) +createSelfGroup cid = do conv <- getSelfConversation cid >>= getJSON 200 conv %. "epoch" `shouldMatchInt` 0 groupId <- conv %. "group_id" & asString @@ -187,9 +218,16 @@ createGroup cid conv = do Nothing -> pure () resetGroup cid conv +createSubConv :: HasCallStack => ClientIdentity -> String -> App () +createSubConv cid subId = do + mls <- getMLSState + sub <- getSubConversation cid mls.convId subId >>= getJSON 200 + resetGroup cid sub + void $ createPendingProposalCommit cid >>= sendAndConsumeCommitBundle + resetGroup :: MakesValue conv => ClientIdentity -> conv -> App () resetGroup cid conv = do - convId <- make conv + convId <- objSubConvObject conv groupId <- conv %. "group_id" & asString modifyMLSState $ \s -> s @@ -204,24 +242,33 @@ resetGroup cid conv = do resetClientGroup :: ClientIdentity -> String -> App () resetClientGroup cid gid = do removalKeyPath <- asks (.removalKeyPath) - groupJSON <- + mls <- getMLSState + void $ mlscli cid [ "group", "create", "--removal-key", removalKeyPath, + "--group-out", + "", + "--ciphersuite", + mls.ciphersuite.code, gid ] Nothing - setClientGroupState cid groupJSON keyPackageFile :: HasCallStack => ClientIdentity -> String -> App FilePath keyPackageFile cid ref = do + let ref' = map urlSafe ref bd <- getBaseDir - pure $ bd cid2Str cid ref + pure $ bd cid2Str cid ref' + where + urlSafe '+' = '-' + urlSafe '/' = '_' + urlSafe c = c -unbundleKeyPackages :: Value -> App [(ClientIdentity, ByteString)] +unbundleKeyPackages :: HasCallStack => Value -> App [(ClientIdentity, ByteString)] unbundleKeyPackages bundle = do let entryIdentity be = do d <- be %. "domain" & asString @@ -242,8 +289,9 @@ unbundleKeyPackages bundle = do -- group to the previous state by using an older version of the group file. createAddCommit :: HasCallStack => ClientIdentity -> [Value] -> App MessagePackage createAddCommit cid users = do + mls <- getMLSState kps <- fmap concat . for users $ \user -> do - bundle <- claimKeyPackages cid user >>= getJSON 200 + bundle <- claimKeyPackages mls.ciphersuite cid user >>= getJSON 200 unbundleKeyPackages bundle createAddCommitWithKeyPackages cid kps @@ -259,6 +307,7 @@ withTempKeyPackageFile bs = do k fp createAddCommitWithKeyPackages :: + HasCallStack => ClientIdentity -> [(ClientIdentity, ByteString)] -> App MessagePackage @@ -300,12 +349,69 @@ createAddCommitWithKeyPackages cid clientsAndKeyPackages = do groupInfo = Just gi } +createRemoveCommit :: HasCallStack => ClientIdentity -> [ClientIdentity] -> App MessagePackage +createRemoveCommit cid targets = do + bd <- getBaseDir + welcomeFile <- liftIO $ emptyTempFile bd "welcome" + giFile <- liftIO $ emptyTempFile bd "gi" + + groupStateMap <- do + gs <- getClientGroupState cid + groupData <- assertJust "Group state not initialised" gs.group + Map.fromList <$> readGroupState groupData + let indices = map (fromMaybe (error "could not find target") . flip Map.lookup groupStateMap) targets + + commit <- + mlscli + cid + ( [ "member", + "remove", + "--group", + "", + "--group-out", + "", + "--welcome-out", + welcomeFile, + "--group-info-out", + giFile + ] + <> map show indices + ) + Nothing + + welcome <- liftIO $ BS.readFile welcomeFile + gi <- liftIO $ BS.readFile giFile + + pure + MessagePackage + { sender = cid, + message = commit, + welcome = Just welcome, + groupInfo = Just gi + } + createAddProposals :: HasCallStack => ClientIdentity -> [Value] -> App [MessagePackage] createAddProposals cid users = do - bundles <- for users $ (claimKeyPackages cid >=> getJSON 200) + mls <- getMLSState + bundles <- for users $ (claimKeyPackages mls.ciphersuite cid >=> getJSON 200) kps <- concat <$> traverse unbundleKeyPackages bundles traverse (createAddProposalWithKeyPackage cid) kps +createReInitProposal :: HasCallStack => ClientIdentity -> App MessagePackage +createReInitProposal cid = do + prop <- + mlscli + cid + ["proposal", "--group-in", "", "--group-out", "", "re-init"] + Nothing + pure + MessagePackage + { sender = cid, + message = prop, + welcome = Nothing, + groupInfo = Nothing + } + createAddProposalWithKeyPackage :: ClientIdentity -> (ClientIdentity, ByteString) -> @@ -446,8 +552,10 @@ consumeWelcome :: HasCallStack => ByteString -> App () consumeWelcome welcome = do mls <- getMLSState for_ mls.newMembers $ \cid -> do - hasState <- hasClientGroupState cid - assertBool "Existing clients in a conversation should not consume welcomes" (not hasState) + gs <- getClientGroupState cid + assertBool + "Existing clients in a conversation should not consume welcomes" + (isNothing gs.group) void $ mlscli cid @@ -489,19 +597,86 @@ spawn cp minput = do (Just out, ExitSuccess) -> pure out _ -> assertFailure "Failed spawning process" -hasClientGroupState :: HasCallStack => ClientIdentity -> App Bool -hasClientGroupState cid = do - mls <- getMLSState - pure $ Map.member cid mls.clientGroupState - -getClientGroupState :: HasCallStack => ClientIdentity -> App ByteString +getClientGroupState :: HasCallStack => ClientIdentity -> App ClientGroupState getClientGroupState cid = do mls <- getMLSState - case Map.lookup cid mls.clientGroupState of - Nothing -> assertFailure ("Attempted to get non-existing group state for client " <> cid2Str cid) - Just g -> pure g + pure $ Map.findWithDefault emptyClientGroupState cid mls.clientGroupState -setClientGroupState :: HasCallStack => ClientIdentity -> ByteString -> App () +setClientGroupState :: HasCallStack => ClientIdentity -> ClientGroupState -> App () setClientGroupState cid g = modifyMLSState $ \s -> s {clientGroupState = Map.insert cid g (clientGroupState s)} + +showMessage :: HasCallStack => ClientIdentity -> ByteString -> App Value +showMessage cid msg = do + bs <- mlscli cid ["show", "message", "-"] (Just msg) + assertOne (Aeson.decode (BS.fromStrict bs)) + +readGroupState :: HasCallStack => ByteString -> App [(ClientIdentity, Word32)] +readGroupState gs = do + v :: Value <- assertJust "Could not decode group state" (Aeson.decode (BS.fromStrict gs)) + lnodes <- v %. "group" %. "public_group" %. "treesync" %. "tree" %. "leaf_nodes" & asList + catMaybes <$$> for (zip lnodes [0 ..]) $ \(el, leafNodeIndex) -> do + lookupField el "node" >>= \case + Just lnode -> do + case lnode of + Null -> pure Nothing + _ -> do + vecb <- lnode %. "payload" %. "credential" %. "credential" %. "Basic" %. "identity" %. "vec" + vec <- asList vecb + ws <- BS.pack <$> for vec (\x -> asIntegral @Word8 x) + [uc, domain] <- pure (C8.split '@' ws) + [uid, client] <- pure (C8.split ':' uc) + let cid = ClientIdentity (C8.unpack domain) (C8.unpack uid) (C8.unpack client) + pure (Just (cid, leafNodeIndex)) + Nothing -> + pure Nothing + +createApplicationMessage :: + HasCallStack => + ClientIdentity -> + String -> + App MessagePackage +createApplicationMessage cid messageContent = do + message <- + mlscli + cid + ["message", "--group", "", messageContent] + Nothing + + pure + MessagePackage + { sender = cid, + message = message, + welcome = Nothing, + groupInfo = Nothing + } + +setMLSCiphersuite :: Ciphersuite -> App () +setMLSCiphersuite suite = modifyMLSState $ \mls -> mls {ciphersuite = suite} + +leaveCurrentConv :: + HasCallStack => + ClientIdentity -> + App () +leaveCurrentConv cid = do + mls <- getMLSState + (_, mSubId) <- objSubConv mls.convId + case mSubId of + -- FUTUREWORK: implement leaving main conversation as well + Nothing -> assertFailure "Leaving conversations is not supported" + Just _ -> do + void $ leaveSubConversation cid mls.convId >>= getBody 200 + modifyMLSState $ \s -> + s + { members = Set.difference mls.members (Set.singleton cid) + } + +getCurrentConv :: HasCallStack => ClientIdentity -> App Value +getCurrentConv cid = do + mls <- getMLSState + (conv, mSubId) <- objSubConv mls.convId + resp <- case mSubId of + Nothing -> getConversation cid conv + Just sub -> getSubConversation cid conv sub + getJSON 200 resp diff --git a/integration/test/Notifications.hs b/integration/test/Notifications.hs new file mode 100644 index 00000000000..d584407e89e --- /dev/null +++ b/integration/test/Notifications.hs @@ -0,0 +1,137 @@ +{-# OPTIONS -Wno-ambiguous-fields #-} +module Notifications where + +import API.Gundeck +import Control.Monad.Extra +import Testlib.Prelude +import UnliftIO.Concurrent + +awaitNotifications :: + (HasCallStack, MakesValue user, MakesValue client) => + user -> + client -> + Maybe String -> + -- | Timeout in seconds + Int -> + -- | Max no. of notifications + Int -> + -- | Selection function. Should not throw any exceptions + (Value -> App Bool) -> + App [Value] +awaitNotifications user client since0 tSecs n selector = + assertAwaitResult =<< go tSecs since0 (AwaitResult False n [] []) + where + go 0 _ res = pure res + go timeRemaining since res0 = do + c <- make client & asString + notifs <- bindResponse + ( getNotifications + user + def {since = since, client = Just c} + ) + $ \resp -> asList (resp.json %. "notifications") + lastNotifId <- case notifs of + [] -> pure since + _ -> Just <$> objId (last notifs) + (matching, notMatching) <- partitionM selector notifs + let matchesSoFar = res0.matches <> matching + res = + res0 + { matches = matchesSoFar, + nonMatches = res0.nonMatches <> notMatching, + success = length matchesSoFar >= res0.nMatchesExpected + } + if res.success + then pure res + else do + threadDelay (1_000_000) + go (timeRemaining - 1) lastNotifId res + +awaitNotification :: + (HasCallStack, MakesValue user, MakesValue client, MakesValue lastNotifId) => + user -> + client -> + Maybe lastNotifId -> + Int -> + (Value -> App Bool) -> + App Value +awaitNotification user client lastNotifId tSecs selector = do + since0 <- mapM objId lastNotifId + head <$> awaitNotifications user client since0 tSecs 1 selector + +isDeleteUserNotif :: MakesValue a => a -> App Bool +isDeleteUserNotif n = + nPayload n %. "type" `isEqual` "user.delete" + +isNewMessageNotif :: MakesValue a => a -> App Bool +isNewMessageNotif n = fieldEquals n "payload.0.type" "conversation.otr-message-add" + +isNewMLSMessageNotif :: MakesValue a => a -> App Bool +isNewMLSMessageNotif n = fieldEquals n "payload.0.type" "conversation.mls-message-add" + +isMemberJoinNotif :: MakesValue a => a -> App Bool +isMemberJoinNotif n = fieldEquals n "payload.0.type" "conversation.member-join" + +isConvLeaveNotif :: MakesValue a => a -> App Bool +isConvLeaveNotif n = fieldEquals n "payload.0.type" "conversation.member-leave" + +isNotifConv :: (MakesValue conv, MakesValue a, HasCallStack) => conv -> a -> App Bool +isNotifConv conv n = fieldEquals n "payload.0.qualified_conversation" (objQidObject conv) + +isNotifForUser :: (MakesValue user, MakesValue a, HasCallStack) => user -> a -> App Bool +isNotifForUser user n = fieldEquals n "payload.0.data.qualified_user_ids.0" (objQidObject user) + +isNotifFromUser :: (MakesValue user, MakesValue a, HasCallStack) => user -> a -> App Bool +isNotifFromUser user n = fieldEquals n "payload.0.qualified_from" (objQidObject user) + +isConvNameChangeNotif :: (HasCallStack, MakesValue a) => a -> App Bool +isConvNameChangeNotif n = fieldEquals n "payload.0.type" "conversation.rename" + +isMemberUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool +isMemberUpdateNotif n = fieldEquals n "payload.0.type" "conversation.member-update" + +isReceiptModeUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool +isReceiptModeUpdateNotif n = + fieldEquals n "payload.0.type" "conversation.receipt-mode-update" + +isConvMsgTimerUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool +isConvMsgTimerUpdateNotif n = + fieldEquals n "payload.0.type" "conversation.message-timer-update" + +isConvAccessUpdateNotif :: (HasCallStack, MakesValue n) => n -> App Bool +isConvAccessUpdateNotif n = + fieldEquals n "payload.0.type" "conversation.access-update" + +isConvCreateNotif :: MakesValue a => a -> App Bool +isConvCreateNotif n = fieldEquals n "payload.0.type" "conversation.create" + +isConvDeleteNotif :: MakesValue a => a -> App Bool +isConvDeleteNotif n = fieldEquals n "payload.0.type" "conversation.delete" + +assertLeaveNotification :: + ( HasCallStack, + MakesValue fromUser, + MakesValue conv, + MakesValue user, + MakesValue kickedUser + ) => + fromUser -> + conv -> + user -> + String -> + kickedUser -> + App () +assertLeaveNotification fromUser conv user client leaver = + void $ + awaitNotification + user + client + noValue + 2 + ( allPreds + [ isConvLeaveNotif, + isNotifConv conv, + isNotifForUser leaver, + isNotifFromUser fromUser + ] + ) diff --git a/integration/test/SetupHelpers.hs b/integration/test/SetupHelpers.hs index e2766e1735c..26f6db76e18 100644 --- a/integration/test/SetupHelpers.hs +++ b/integration/test/SetupHelpers.hs @@ -1,42 +1,71 @@ +{-# OPTIONS_GHC -Wno-ambiguous-fields #-} +{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} + module SetupHelpers where -import API.Brig qualified as Public -import API.BrigInternal qualified as Internal +import API.Brig +import API.BrigInternal +import API.Common import API.Galley -import Control.Concurrent (threadDelay) import Control.Monad.Reader import Data.Aeson hiding ((.=)) import Data.Aeson.Types qualified as Aeson import Data.Default import Data.Function -import Data.List qualified as List +import Data.UUID.V1 (nextUUID) import Data.UUID.V4 (nextRandom) import GHC.Stack import Testlib.Prelude --- | `n` should be 2 x `setFederationDomainConfigsUpdateFreq` in the config -connectAllDomainsAndWaitToSync :: HasCallStack => Int -> [String] -> App () -connectAllDomainsAndWaitToSync n domains = do - sequence_ [Internal.createFedConn x (Internal.FedConn y "full_search") | x <- domains, y <- domains, x /= y] - liftIO $ threadDelay (n * 1000 * 1000) -- wait for federation status to be updated - -randomUser :: (HasCallStack, MakesValue domain) => domain -> Internal.CreateUser -> App Value -randomUser domain cu = bindResponse (Internal.createUser domain cu) $ \resp -> do +randomUser :: (HasCallStack, MakesValue domain) => domain -> CreateUser -> App Value +randomUser domain cu = bindResponse (createUser domain cu) $ \resp -> do resp.status `shouldMatchInt` 201 resp.json +deleteUser :: (HasCallStack, MakesValue user) => user -> App () +deleteUser user = bindResponse (API.Brig.deleteUser user) $ \resp -> do + resp.status `shouldMatchInt` 200 + -- | returns (user, team id) -createTeam :: (HasCallStack, MakesValue domain) => domain -> App (Value, String) -createTeam domain = do - res <- Internal.createUser domain def {Internal.team = True} - user <- res.json - tid <- user %. "team" & asString - -- TODO - -- SQS.assertTeamActivate "create team" tid - -- refreshIndex - pure (user, tid) - -connectUsers :: +createTeam :: (HasCallStack, MakesValue domain) => domain -> Int -> App (Value, String, [Value]) +createTeam domain memberCount = do + res <- createUser domain def {team = True} + owner <- res.json + tid <- owner %. "team" & asString + members <- for [2 .. memberCount] $ \_ -> createTeamMember owner tid + pure (owner, tid, members) + +createTeamMember :: + (HasCallStack, MakesValue inviter) => + inviter -> + String -> + App Value +createTeamMember inviter tid = do + newUserEmail <- randomEmail + let invitationJSON = ["role" .= "member", "email" .= newUserEmail] + invitationReq <- + baseRequest inviter Brig Versioned $ + joinHttpPath ["teams", tid, "invitations"] + invitation <- getJSON 201 =<< submit "POST" (addJSONObject invitationJSON invitationReq) + invitationId <- objId invitation + invitationCodeReq <- + rawBaseRequest inviter Brig Unversioned "/i/teams/invitation-code" + <&> addQueryParams [("team", tid), ("invitation_id", invitationId)] + invitationCode <- bindResponse (submit "GET" invitationCodeReq) $ \res -> do + res.status `shouldMatchInt` 200 + res.json %. "code" & asString + let registerJSON = + [ "name" .= newUserEmail, + "email" .= newUserEmail, + "password" .= defPassword, + "team_code" .= invitationCode + ] + registerReq <- + rawBaseRequest inviter Brig Versioned "/register" + <&> addJSONObject registerJSON + getJSON 201 =<< submit "POST" registerReq + +connectTwoUsers :: ( HasCallStack, MakesValue alice, MakesValue bob @@ -44,21 +73,26 @@ connectUsers :: alice -> bob -> App () -connectUsers alice bob = do - bindResponse (Public.postConnection alice bob) (\resp -> resp.status `shouldMatchInt` 201) - bindResponse (Public.putConnection bob alice "accepted") (\resp -> resp.status `shouldMatchInt` 200) +connectTwoUsers alice bob = do + bindResponse (postConnection alice bob) (\resp -> resp.status `shouldMatchInt` 201) + bindResponse (putConnection bob alice "accepted") (\resp -> resp.status `shouldMatchInt` 200) + +connectUsers :: HasCallStack => [Value] -> App () +connectUsers users = traverse_ (uncurry connectTwoUsers) $ do + t <- tails users + (a, others) <- maybeToList (uncons t) + b <- others + pure (a, b) createAndConnectUsers :: (HasCallStack, MakesValue domain) => [domain] -> App [Value] createAndConnectUsers domains = do users <- for domains (flip randomUser def) - let userPairs = do - t <- tails users - (a, others) <- maybeToList (uncons t) - b <- others - pure (a, b) - for_ userPairs (uncurry connectUsers) + connectUsers users pure users +createUsers :: (HasCallStack, MakesValue domain) => [domain] -> App [Value] +createUsers domains = for domains (flip randomUser def) + getAllConvs :: (HasCallStack, MakesValue u) => u -> App [Value] getAllConvs u = do page <- bindResponse (listConversationIds u def) $ \resp -> do @@ -70,17 +104,68 @@ getAllConvs u = do resp.json result %. "found" & asList -resetFedConns :: (HasCallStack, MakesValue owndom) => owndom -> App () -resetFedConns owndom = do - bindResponse (Internal.readFedConns owndom) $ \resp -> do - rdoms :: [String] <- do - rawlist <- resp.json %. "remotes" & asList - (asString . (%. "domain")) `mapM` rawlist - Internal.deleteFedConn' owndom `mapM_` rdoms +-- | Setup a team user, another user, connect the two, create a proteus +-- conversation, upgrade to mixed. Return the two users and the conversation. +simpleMixedConversationSetup :: + (HasCallStack, MakesValue domain) => + domain -> + App (Value, Value, Value) +simpleMixedConversationSetup secondDomain = do + (alice, tid, _) <- createTeam OwnDomain 1 + bob <- randomUser secondDomain def + connectUsers [alice, bob] + + conv <- + postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} + >>= getJSON 201 + + bindResponse (putConversationProtocol bob conv "mixed") $ \resp -> do + resp.status `shouldMatchInt` 200 + + conv' <- getConversation alice conv >>= getJSON 200 + + pure (alice, bob, conv') + +supportMLS :: (HasCallStack, MakesValue u) => u -> App () +supportMLS u = do + prots <- bindResponse (getUserSupportedProtocols u u) $ \resp -> do + resp.status `shouldMatchInt` 200 + prots <- resp.json & asList + traverse asString prots + let prots' = "mls" : prots + bindResponse (putUserSupportedProtocols u prots') $ \resp -> + resp.status `shouldMatchInt` 200 + +addUserToTeam :: (HasCallStack, MakesValue u) => u -> App Value +addUserToTeam u = do + inv <- postInvitation u def >>= getJSON 201 + email <- inv %. "email" & asString + resp <- getInvitationCode u inv >>= getJSON 200 + code <- resp %. "code" & asString + addUser u def {email = Just email, teamCode = Just code} >>= getJSON 201 + +-- | Create a user on the given domain, such that the 1-1 conversation with +-- 'other' resides on 'convDomain'. This connects the two users as a side-effect. +createMLSOne2OnePartner :: MakesValue user => Domain -> user -> Domain -> App Value +createMLSOne2OnePartner domain other convDomain = loop + where + loop = do + u <- randomUser domain def + connectTwoUsers u other + conv <- getMLSOne2OneConversation other u >>= getJSON 200 + + desiredConvDomain <- make convDomain & asString + actualConvDomain <- conv %. "qualified_id.domain" & asString + + if desiredConvDomain == actualConvDomain + then pure u + else loop randomId :: HasCallStack => App String -randomId = do - liftIO (show <$> nextRandom) +randomId = liftIO (show <$> nextRandom) + +randomUUIDv1 :: HasCallStack => App String +randomUUIDv1 = liftIO (show . fromJust <$> nextUUID) randomUserId :: (HasCallStack, MakesValue domain) => domain -> App Value randomUserId domain = do @@ -88,40 +173,14 @@ randomUserId domain = do uid <- randomId pure $ object ["id" .= uid, "domain" .= d] -addFullSearchFor :: [String] -> Value -> App Value -addFullSearchFor domains val = - modifyField - "optSettings.setFederationDomainConfigs" - ( \configs -> do - cfg <- assertJust "" configs - xs <- cfg & asList - pure (xs <> [object ["domain" .= domain, "search_policy" .= "full_search"] | domain <- domains]) - ) - val - -fullSearchWithAll :: ServiceOverrides -fullSearchWithAll = - def - { dbBrig = \val -> do - ownDomain <- asString =<< val %. "optSettings.setFederationDomain" - env <- ask - let remoteDomains = List.delete ownDomain $ [env.domain1, env.domain2] <> env.dynamicDomains - addFullSearchFor remoteDomains val - } - -withFederatingBackendsAllowDynamic :: HasCallStack => Int -> ((String, String, String) -> App a) -> App a -withFederatingBackendsAllowDynamic n k = do +withFederatingBackendsAllowDynamic :: HasCallStack => ((String, String, String) -> App a) -> App a +withFederatingBackendsAllowDynamic k = do let setFederationConfig = setField "optSettings.setFederationStrategy" "allowDynamic" - >=> removeField "optSettings.setFederationDomainConfigs" >=> setField "optSettings.setFederationDomainConfigsUpdateFreq" (Aeson.Number 1) startDynamicBackends - [ def {dbBrig = setFederationConfig}, - def {dbBrig = setFederationConfig}, - def {dbBrig = setFederationConfig} + [ def {brigCfg = setFederationConfig}, + def {brigCfg = setFederationConfig}, + def {brigCfg = setFederationConfig} ] - $ \dynDomains -> do - domains@[domainA, domainB, domainC] <- pure dynDomains - sequence_ [Internal.createFedConn x (Internal.FedConn y "full_search") | x <- domains, y <- domains, x /= y] - liftIO $ threadDelay (n * 1000 * 1000) -- wait for federation status to be updated - k (domainA, domainB, domainC) + $ \[domainA, domainB, domainC] -> k (domainA, domainB, domainC) diff --git a/integration/test/Test/AccessUpdate.hs b/integration/test/Test/AccessUpdate.hs new file mode 100644 index 00000000000..c2ed1964e16 --- /dev/null +++ b/integration/test/Test/AccessUpdate.hs @@ -0,0 +1,122 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.AccessUpdate where + +import API.Brig +import API.Galley +import Control.Monad.Codensity +import Control.Monad.Reader +import GHC.Stack +import Notifications +import SetupHelpers +import Testlib.Prelude +import Testlib.ResourcePool + +-- @SF.Federation @SF.Separation @TSFI.RESTfulAPI @S2 +-- +-- The test asserts that, among others, remote users are removed from a +-- conversation when an access update occurs that disallows guests from +-- accessing. +testAccessUpdateGuestRemoved :: HasCallStack => App () +testAccessUpdateGuestRemoved = do + (alice, tid, [bob]) <- createTeam OwnDomain 2 + charlie <- randomUser OwnDomain def + dee <- randomUser OtherDomain def + mapM_ (connectTwoUsers alice) [charlie, dee] + [aliceClient, bobClient, charlieClient, deeClient] <- + mapM + (\user -> objId $ bindResponse (addClient user def) $ getJSON 201) + [alice, bob, charlie, dee] + conv <- + postConversation + alice + defProteus + { qualifiedUsers = [bob, charlie, dee], + team = Just tid + } + >>= getJSON 201 + + let update = ["access" .= ([] :: [String]), "access_role" .= ["team_member"]] + void $ updateAccess alice conv update >>= getJSON 200 + + mapM_ (assertLeaveNotification alice conv alice aliceClient) [charlie, dee] + mapM_ (assertLeaveNotification alice conv bob bobClient) [charlie, dee] + mapM_ (assertLeaveNotification alice conv charlie charlieClient) [charlie, dee] + mapM_ (assertLeaveNotification alice conv dee deeClient) [charlie, dee] + + bindResponse (getConversation alice conv) $ \res -> do + res.status `shouldMatchInt` 200 + res.json %. "members.others.0.qualified_id" `shouldMatch` objQidObject bob + +-- @END + +testAccessUpdateGuestRemovedUnreachableRemotes :: HasCallStack => App () +testAccessUpdateGuestRemovedUnreachableRemotes = do + resourcePool <- asks resourcePool + (alice, tid, [bob]) <- createTeam OwnDomain 2 + charlie <- randomUser OwnDomain def + connectTwoUsers alice charlie + [aliceClient, bobClient, charlieClient] <- + mapM + (\user -> objId $ bindResponse (addClient user def) $ getJSON 201) + [alice, bob, charlie] + (conv, dee) <- runCodensity (acquireResources 1 resourcePool) $ \[dynBackend] -> + runCodensity (startDynamicBackend dynBackend mempty) $ \_ -> do + dee <- randomUser dynBackend.berDomain def + connectTwoUsers alice dee + conv <- + postConversation + alice + ( defProteus + { qualifiedUsers = [bob, charlie, dee], + team = Just tid + } + ) + >>= getJSON 201 + pure (conv, dee) + + let update = ["access" .= ([] :: [String]), "access_role" .= ["team_member"]] + void $ updateAccess alice conv update >>= getJSON 200 + + mapM_ (assertLeaveNotification alice conv alice aliceClient) [charlie, dee] + mapM_ (assertLeaveNotification alice conv bob bobClient) [charlie, dee] + mapM_ (assertLeaveNotification alice conv charlie charlieClient) [charlie, dee] + + bindResponse (getConversation alice conv) $ \res -> do + res.status `shouldMatchInt` 200 + res.json %. "members.others.0.qualified_id" `shouldMatch` objQidObject bob + +testAccessUpdateWithRemotes :: HasCallStack => App () +testAccessUpdateWithRemotes = do + [alice, bob, charlie] <- createUsers [OwnDomain, OtherDomain, OwnDomain] + connectTwoUsers alice bob + connectTwoUsers alice charlie + conv <- + postConversation alice (defProteus {qualifiedUsers = [bob, charlie]}) + >>= getJSON 201 + let update_access_value = ["code"] + update_access_role_value = ["team_member", "non_team_member", "guest", "service"] + update = ["access" .= update_access_value, "access_role" .= update_access_role_value] + withWebSockets [alice, bob, charlie] $ \wss -> do + void $ updateAccess alice conv update >>= getJSON 200 + for_ wss $ \ws -> do + notif <- awaitMatch 10 isConvAccessUpdateNotif ws + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.qualified_from" `shouldMatch` objQidObject alice + notif %. "payload.0.data.access" `shouldMatch` update_access_value + notif %. "payload.0.data.access_role_v2" `shouldMatch` update_access_role_value diff --git a/integration/test/Test/AssetDownload.hs b/integration/test/Test/AssetDownload.hs index e9d7b968b4c..c595c84f0e7 100644 --- a/integration/test/Test/AssetDownload.hs +++ b/integration/test/Test/AssetDownload.hs @@ -15,7 +15,7 @@ testDownloadAsset = do resp.status `shouldMatchInt` 201 resp.json %. "key" - bindResponse (downloadAsset user user key id) $ \resp -> do + bindResponse (downloadAsset user user key "nginz-https.example.com" id) $ \resp -> do resp.status `shouldMatchInt` 200 assertBool ("Expect 'Hello World!' as text asset content. Got: " ++ show resp.body) @@ -27,37 +27,53 @@ testDownloadAssetMultiIngressS3DownloadUrl = do -- multi-ingress disabled key <- doUploadAsset user - checkAssetDownload user key - withModifiedService Cargohold modifyConfig $ \_ -> do - -- multi-ingress enabled - key' <- doUploadAsset user - checkAssetDownload user key' - where - checkAssetDownload :: HasCallStack => Value -> Value -> App () - checkAssetDownload user key = withModifiedService Cargohold modifyConfig $ \_ -> do - bindResponse (downloadAsset user user key noRedirects) $ \resp -> do - resp.status `shouldMatchInt` 404 - bindResponse (downloadAsset' user user key "red.example.com" noRedirects) $ \resp -> do - resp.status `shouldMatchInt` 302 - locationHeaderHost resp `shouldMatch` "s3-download.red.example.com" - bindResponse (downloadAsset' user user key "green.example.com" noRedirects) $ \resp -> do - resp.status `shouldMatchInt` 302 - locationHeaderHost resp `shouldMatch` "s3-download.green.example.com" - bindResponse (downloadAsset' user user key "unknown.example.com" noRedirects) $ \resp -> do - resp.status `shouldMatchInt` 404 - resp.json %. "label" `shouldMatch` "not-found" + bindResponse (downloadAsset user user key "nginz-https.example.com" noRedirects) $ \resp -> do + resp.status `shouldMatchInt` 302 + + bindResponse (downloadAsset user user key "red.example.com" noRedirects) $ \resp -> do + resp.status `shouldMatchInt` 302 + + bindResponse (downloadAsset user user key "green.example.com" noRedirects) $ \resp -> do + resp.status `shouldMatchInt` 302 + + bindResponse (downloadAsset user user key "unknown.example.com" noRedirects) $ \resp -> do + resp.status `shouldMatchInt` 302 + + -- multi-ingress enabled + withModifiedBackend modifyConfig $ \domain -> do + user' <- randomUser domain def + key' <- doUploadAsset user' + bindResponse (downloadAsset user' user' key' "nginz-https.example.com" noRedirects) $ \resp -> do + resp.status `shouldMatchInt` 404 + resp.json %. "label" `shouldMatch` "not-found" + + bindResponse (downloadAsset user' user' key' "red.example.com" noRedirects) $ \resp -> do + resp.status `shouldMatchInt` 302 + locationHeaderHost resp `shouldMatch` "s3-download.red.example.com" + + bindResponse (downloadAsset user' user' key' "green.example.com" noRedirects) $ \resp -> do + resp.status `shouldMatchInt` 302 + locationHeaderHost resp `shouldMatch` "s3-download.green.example.com" + + bindResponse (downloadAsset user' user' key' "unknown.example.com" noRedirects) $ \resp -> do + resp.status `shouldMatchInt` 404 + resp.json %. "label" `shouldMatch` "not-found" + where noRedirects :: HTTP.Request -> HTTP.Request noRedirects req = (req {redirectCount = 0}) - modifyConfig :: Value -> App Value + modifyConfig :: ServiceOverrides modifyConfig = - setField "aws.multiIngress" $ - object - [ "red.example.com" .= "http://s3-download.red.example.com", - "green.example.com" .= "http://s3-download.green.example.com" - ] + def + { cargoholdCfg = + setField "aws.multiIngress" $ + object + [ "red.example.com" .= "http://s3-download.red.example.com", + "green.example.com" .= "http://s3-download.green.example.com" + ] + } doUploadAsset :: HasCallStack => Value -> App Value doUploadAsset user = bindResponse (uploadAsset user) $ \resp -> do diff --git a/integration/test/Test/Brig.hs b/integration/test/Test/Brig.hs index 98e046fa6da..b32bb810f15 100644 --- a/integration/test/Test/Brig.hs +++ b/integration/test/Test/Brig.hs @@ -1,10 +1,10 @@ module Test.Brig where -import API.Brig qualified as Public -import API.BrigInternal qualified as Internal +import API.Brig qualified as BrigP +import API.BrigInternal qualified as BrigI import API.Common qualified as API -import API.GalleyInternal qualified as Internal -import Data.Aeson qualified as Aeson +import API.GalleyInternal qualified as GalleyI +import Control.Concurrent (threadDelay) import Data.Aeson.Types hiding ((.=)) import Data.Set qualified as Set import Data.String.Conversions @@ -17,24 +17,19 @@ import Testlib.Prelude testSearchContactForExternalUsers :: HasCallStack => App () testSearchContactForExternalUsers = do - owner <- randomUser OwnDomain def {Internal.team = True} - partner <- randomUser OwnDomain def {Internal.team = True} + owner <- randomUser OwnDomain def {BrigI.team = True} + partner <- randomUser OwnDomain def {BrigI.team = True} - bindResponse (Internal.putTeamMember partner (partner %. "team") (API.teamRole "partner")) $ \resp -> + bindResponse (GalleyI.putTeamMember partner (partner %. "team") (API.teamRole "partner")) $ \resp -> resp.status `shouldMatchInt` 200 - bindResponse (Public.searchContacts partner (owner %. "name") OwnDomain) $ \resp -> + bindResponse (BrigP.searchContacts partner (owner %. "name") OwnDomain) $ \resp -> resp.status `shouldMatchInt` 403 testCrudFederationRemotes :: HasCallStack => App () testCrudFederationRemotes = do otherDomain <- asString OtherDomain - let overrides = - ( setField - "optSettings.setFederationDomainConfigs" - [object ["domain" .= otherDomain, "search_policy" .= "full_search"]] - ) - withModifiedService Brig overrides $ \_ -> do + withModifiedBackend def $ \ownDomain -> do let parseFedConns :: HasCallStack => Response -> App [Value] parseFedConns resp = -- Pick out the list of federation domain configs @@ -43,112 +38,78 @@ testCrudFederationRemotes = do -- Enforce that the values are objects and not something else >>= traverse (fmap Object . asObject) - addOnce :: (MakesValue fedConn, Ord fedConn2, ToJSON fedConn2, MakesValue fedConn2, HasCallStack) => fedConn -> [fedConn2] -> App () - addOnce fedConn want = do - bindResponse (Internal.createFedConn OwnDomain fedConn) $ \res -> do + addTest :: (MakesValue fedConn, Ord fedConn2, ToJSON fedConn2, MakesValue fedConn2, HasCallStack) => fedConn -> [fedConn2] -> App () + addTest fedConn want = do + bindResponse (BrigI.createFedConn ownDomain fedConn) $ \res -> do addFailureContext ("res = " <> show res) $ res.status `shouldMatchInt` 200 - res2 <- parseFedConns =<< Internal.readFedConns OwnDomain + res2 <- parseFedConns =<< BrigI.readFedConns ownDomain sort res2 `shouldMatch` sort want - addFail :: HasCallStack => MakesValue fedConn => fedConn -> App () - addFail fedConn = do - bindResponse (Internal.createFedConn' OwnDomain fedConn) $ \res -> do - addFailureContext ("res = " <> show res) $ res.status `shouldMatchInt` 533 - - deleteOnce :: (Ord fedConn, ToJSON fedConn, MakesValue fedConn) => String -> [fedConn] -> App () - deleteOnce domain want = do - bindResponse (Internal.deleteFedConn OwnDomain domain) $ \res -> do - addFailureContext ("res = " <> show res) $ res.status `shouldMatchInt` 200 - res2 <- parseFedConns =<< Internal.readFedConns OwnDomain - sort res2 `shouldMatch` sort want - - deleteFail :: HasCallStack => String -> App () - deleteFail del = do - bindResponse (Internal.deleteFedConn' OwnDomain del) $ \res -> do - addFailureContext ("res = " <> show res) $ res.status `shouldMatchInt` 533 - - updateOnce :: (MakesValue fedConn, Ord fedConn2, ToJSON fedConn2, MakesValue fedConn2, HasCallStack) => String -> fedConn -> [fedConn2] -> App () - updateOnce domain fedConn want = do - bindResponse (Internal.updateFedConn OwnDomain domain fedConn) $ \res -> do + updateTest :: (MakesValue fedConn, Ord fedConn2, ToJSON fedConn2, MakesValue fedConn2, HasCallStack) => String -> fedConn -> [fedConn2] -> App () + updateTest domain fedConn want = do + bindResponse (BrigI.updateFedConn ownDomain domain fedConn) $ \res -> do addFailureContext ("res = " <> show res) $ res.status `shouldMatchInt` 200 - res2 <- parseFedConns =<< Internal.readFedConns OwnDomain + res2 <- parseFedConns =<< BrigI.readFedConns ownDomain sort res2 `shouldMatch` sort want - updateFail :: (MakesValue fedConn, HasCallStack) => String -> fedConn -> App () - updateFail domain fedConn = do - bindResponse (Internal.updateFedConn' OwnDomain domain fedConn) $ \res -> do - addFailureContext ("res = " <> show res) $ res.status `shouldMatchInt` 533 - dom1 :: String <- (<> ".example.com") . UUID.toString <$> liftIO UUID.nextRandom - dom2 :: String <- (<> ".example.com") . UUID.toString <$> liftIO UUID.nextRandom - let remote1, remote1', remote1'' :: Internal.FedConn - remote1 = Internal.FedConn dom1 "no_search" - remote1' = remote1 {Internal.searchStrategy = "full_search"} - remote1'' = remote1 {Internal.domain = dom2} + let remote1, remote1' :: BrigI.FedConn + remote1 = BrigI.FedConn dom1 "no_search" + remote1' = remote1 {BrigI.searchStrategy = "full_search"} - cfgRemotesExpect :: Internal.FedConn - cfgRemotesExpect = Internal.FedConn (cs otherDomain) "full_search" + cfgRemotesExpect :: BrigI.FedConn + cfgRemotesExpect = BrigI.FedConn (cs otherDomain) "full_search" - remote1J <- make remote1 - remote1J' <- make remote1' - - resetFedConns OwnDomain - cfgRemotes <- parseFedConns =<< Internal.readFedConns OwnDomain - cfgRemotes `shouldMatch` [cfgRemotesExpect] + liftIO $ threadDelay 5_000_000 + cfgRemotes <- parseFedConns =<< BrigI.readFedConns ownDomain + cfgRemotes `shouldMatch` ([] @Value) -- entries present in the config file can be idempotently added if identical, but cannot be - -- updated, deleted or updated. - addOnce cfgRemotesExpect [cfgRemotesExpect] - addFail (cfgRemotesExpect {Internal.searchStrategy = "no_search"}) - deleteFail (Internal.domain cfgRemotesExpect) - updateFail (Internal.domain cfgRemotesExpect) (cfgRemotesExpect {Internal.searchStrategy = "no_search"}) + -- updated. + addTest cfgRemotesExpect [cfgRemotesExpect] -- create - addOnce remote1 $ (remote1J : cfgRemotes) - addOnce remote1 $ (remote1J : cfgRemotes) -- idempotency + addTest remote1 [cfgRemotesExpect, remote1] + addTest remote1 [cfgRemotesExpect, remote1] -- idempotency -- update - updateOnce (Internal.domain remote1) remote1' (remote1J' : cfgRemotes) - updateFail (Internal.domain remote1) remote1'' - -- delete - deleteOnce (Internal.domain remote1) cfgRemotes - deleteOnce (Internal.domain remote1) cfgRemotes -- idempotency + updateTest (BrigI.domain remote1) remote1' [cfgRemotesExpect, remote1'] testCrudOAuthClient :: HasCallStack => App () testCrudOAuthClient = do user <- randomUser OwnDomain def let appName = "foobar" let url = "https://example.com/callback.html" - clientId <- bindResponse (Internal.registerOAuthClient user appName url) $ \resp -> do + clientId <- bindResponse (BrigI.registerOAuthClient user appName url) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "client_id" - bindResponse (Internal.getOAuthClient user clientId) $ \resp -> do + bindResponse (BrigI.getOAuthClient user clientId) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "application_name" `shouldMatch` appName resp.json %. "redirect_url" `shouldMatch` url let newName = "barfoo" let newUrl = "https://example.com/callback2.html" - bindResponse (Internal.updateOAuthClient user clientId newName newUrl) $ \resp -> do + bindResponse (BrigI.updateOAuthClient user clientId newName newUrl) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "application_name" `shouldMatch` newName resp.json %. "redirect_url" `shouldMatch` newUrl - bindResponse (Internal.deleteOAuthClient user clientId) $ \resp -> do + bindResponse (BrigI.deleteOAuthClient user clientId) $ \resp -> do resp.status `shouldMatchInt` 200 - bindResponse (Internal.getOAuthClient user clientId) $ \resp -> do + bindResponse (BrigI.getOAuthClient user clientId) $ \resp -> do resp.status `shouldMatchInt` 404 -- | See https://docs.wire.com/understand/api-client-perspective/swagger.html testSwagger :: HasCallStack => App () testSwagger = do let existingVersions :: [Int] - existingVersions = [0, 1, 2, 3, 4] + existingVersions = [0, 1, 2, 3, 4, 5] internalApis :: [String] internalApis = ["brig", "cannon", "cargohold", "cannon", "spar"] - bindResponse Public.getApiVersions $ \resp -> do + bindResponse BrigP.getApiVersions $ \resp -> do resp.status `shouldMatchInt` 200 actualVersions :: [Int] <- do - sup <- resp.json %. "supported" & asListOf asInt - dev <- resp.json %. "development" & asListOf asInt + sup <- resp.json %. "supported" & asListOf asIntegral + dev <- resp.json %. "development" & asListOf asIntegral pure $ sup <> dev assertBool ("unexpected actually existing versions: " <> show actualVersions) $ -- make sure nobody has added a new version without adding it to `existingVersions`. @@ -156,44 +117,61 @@ testSwagger = do -- documented.) Set.fromList actualVersions `Set.isSubsetOf` Set.fromList existingVersions - bindResponse Public.getSwaggerPublicTOC $ \resp -> do + bindResponse BrigP.getSwaggerPublicTOC $ \resp -> do resp.status `shouldMatchInt` 200 cs resp.body `shouldContainString` "" forM_ existingVersions $ \v -> do - bindResponse (Public.getSwaggerPublicAllUI v) $ \resp -> do + bindResponse (BrigP.getSwaggerPublicAllUI v) $ \resp -> do resp.status `shouldMatchInt` 200 cs resp.body `shouldContainString` "" - bindResponse (Public.getSwaggerPublicAllJson v) $ \resp -> do + bindResponse (BrigP.getSwaggerPublicAllJson v) $ \resp -> do resp.status `shouldMatchInt` 200 void resp.json - -- FUTUREWORK: Implement Public.getSwaggerInternalTOC (including the end-point); make sure + -- ! + -- FUTUREWORK: Implement BrigP.getSwaggerInternalTOC (including the end-point); make sure -- newly added internal APIs make this test fail if not added to `internalApis`. forM_ internalApis $ \api -> do - bindResponse (Public.getSwaggerInternalUI api) $ \resp -> do + bindResponse (BrigP.getSwaggerInternalUI api) $ \resp -> do resp.status `shouldMatchInt` 200 cs resp.body `shouldContainString` "" - bindResponse (Public.getSwaggerInternalJson api) $ \resp -> do + bindResponse (BrigP.getSwaggerInternalJson api) $ \resp -> do resp.status `shouldMatchInt` 200 void resp.json testRemoteUserSearch :: HasCallStack => App () testRemoteUserSearch = do - let overrides = - setField "optSettings.setFederationStrategy" "allowDynamic" - >=> removeField "optSettings.setFederationDomainConfigs" - >=> setField "optSettings.setFederationDomainConfigsUpdateFreq" (Aeson.Number 1) - startDynamicBackends [def {dbBrig = overrides}, def {dbBrig = overrides}] $ \dynDomains -> do - domains@[d1, d2] <- pure dynDomains - connectAllDomainsAndWaitToSync 1 domains - [u1, u2] <- createAndConnectUsers [d1, d2] - Internal.refreshIndex d2 + startDynamicBackends [def, def] $ \[d1, d2] -> do + void $ BrigI.createFedConn d2 (BrigI.FedConn d1 "full_search") + + u1 <- randomUser d1 def + u2 <- randomUser d2 def + BrigI.refreshIndex d2 uidD2 <- objId u2 - bindResponse (Public.searchContacts u1 (u2 %. "name") d2) $ \resp -> do + + bindResponse (BrigP.searchContacts u1 (u2 %. "name") d2) $ \resp -> do resp.status `shouldMatchInt` 200 docs <- resp.json %. "documents" >>= asList case docs of [] -> assertFailure "Expected a non empty result, but got an empty one" doc : _ -> doc %. "id" `shouldMatch` uidD2 + +testRemoteUserSearchExactHandle :: HasCallStack => App () +testRemoteUserSearchExactHandle = do + startDynamicBackends [def, def] $ \[d1, d2] -> do + void $ BrigI.createFedConn d2 (BrigI.FedConn d1 "exact_handle_search") + + u1 <- randomUser d1 def + u2 <- randomUser d2 def + u2Handle <- API.randomHandle + bindResponse (BrigP.putHandle u2 u2Handle) $ assertSuccess + BrigI.refreshIndex d2 + + bindResponse (BrigP.searchContacts u1 u2Handle d2) $ \resp -> do + resp.status `shouldMatchInt` 200 + docs <- resp.json %. "documents" >>= asList + case docs of + [] -> assertFailure "Expected a non empty result, but got an empty one" + doc : _ -> objQid doc `shouldMatch` objQid u2 diff --git a/integration/test/Test/Client.hs b/integration/test/Test/Client.hs index a14de01a24a..caacd2051af 100644 --- a/integration/test/Test/Client.hs +++ b/integration/test/Test/Client.hs @@ -1,19 +1,27 @@ +{-# OPTIONS_GHC -Wno-ambiguous-fields -Wno-incomplete-uni-patterns #-} + module Test.Client where import API.Brig +import API.Brig qualified as API import API.Gundeck -import Data.Aeson +import Control.Lens hiding ((.=)) +import Control.Monad.Codensity +import Control.Monad.Reader +import Data.Aeson hiding ((.=)) +import Data.ProtoLens.Labels () import Data.Time.Clock.POSIX import Data.Time.Clock.System import Data.Time.Format import SetupHelpers import Testlib.Prelude +import Testlib.ResourcePool testClientLastActive :: HasCallStack => App () testClientLastActive = do alice <- randomUser OwnDomain def c0 <- addClient alice def >>= getJSON 201 - cid <- c0 %. "id" + cid <- c0 %. "id" & asString -- newly created clients should not have a last_active value tm0 <- fromMaybe Null <$> lookupField c0 "last_active" @@ -22,7 +30,7 @@ testClientLastActive = do now <- systemSeconds <$> liftIO getSystemTime -- fetching notifications updates last_active - void $ getNotifications alice cid def + void $ getNotifications alice def {client = Just cid} c1 <- getClient alice cid >>= getJSON 200 tm1 <- c1 %. "last_active" & asString @@ -32,3 +40,33 @@ testClientLastActive = do . utcTimeToPOSIXSeconds <$> parseTimeM False defaultTimeLocale "%Y-%m-%dT%H:%M:%SZ" tm1 assertBool "last_active is earlier than expected" $ ts1 >= now + +testListClientsIfBackendIsOffline :: HasCallStack => App () +testListClientsIfBackendIsOffline = do + resourcePool <- asks (.resourcePool) + ownDomain <- asString OwnDomain + otherDomain <- asString OtherDomain + [ownUser1, ownUser2] <- createAndConnectUsers [OwnDomain, OtherDomain] + ownClient1 <- objId $ bindResponse (API.addClient ownUser1 def) $ getJSON 201 + ownClient2 <- objId $ bindResponse (API.addClient ownUser2 def) $ getJSON 201 + ownUser1Id <- objId ownUser1 + ownUser2Id <- objId ownUser2 + + let expectedResponse = + object + [ ownDomain .= object [ownUser1Id .= [object ["id" .= ownClient1]]], + otherDomain .= object [ownUser2Id .= [object ["id" .= ownClient2]]] + ] + + bindResponse (listUsersClients ownUser1 [ownUser1, ownUser2]) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "qualified_user_map" `shouldMatch` expectedResponse + + -- we don't even have to start the backend, but we have to take the resource so that it doesn't get started by another test + runCodensity (acquireResources 1 resourcePool) $ \[downBackend] -> do + rndUsrId <- randomId + let downUser = (object ["domain" .= downBackend.berDomain, "id" .= rndUsrId]) + + bindResponse (listUsersClients ownUser1 [ownUser1, ownUser2, downUser]) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "qualified_user_map" `shouldMatch` expectedResponse diff --git a/integration/test/Test/Conversation.hs b/integration/test/Test/Conversation.hs index 26c687fa487..d8e25bf1f5c 100644 --- a/integration/test/Test/Conversation.hs +++ b/integration/test/Test/Conversation.hs @@ -1,25 +1,45 @@ {-# OPTIONS_GHC -Wno-ambiguous-fields #-} +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + module Test.Conversation where -import API.Brig (getConnection) +import API.Brig import API.BrigInternal import API.Galley import API.GalleyInternal -import API.Gundeck (getNotifications) import Control.Applicative import Control.Concurrent (threadDelay) +import Control.Monad.Codensity +import Control.Monad.Reader import Data.Aeson qualified as Aeson +import Data.Text qualified as T import GHC.Stack -import SetupHelpers +import Notifications +import SetupHelpers hiding (deleteUser) +import Testlib.One2One (generateRemoteAndConvIdWithDomain) import Testlib.Prelude +import Testlib.ResourcePool testDynamicBackendsFullyConnectedWhenAllowAll :: HasCallStack => App () testDynamicBackendsFullyConnectedWhenAllowAll = do - let overrides = - def {dbBrig = setField "optSettings.setFederationStrategy" "allowAll"} - <> fullSearchWithAll - startDynamicBackends [overrides, overrides, overrides] $ \dynDomains -> do + -- The default setting is 'allowAll' + startDynamicBackends [def, def, def] $ \dynDomains -> do [domainA, domainB, domainC] <- pure dynDomains uidA <- randomUser domainA def {team = True} uidB <- randomUser domainA def {team = True} @@ -40,74 +60,58 @@ testDynamicBackendsNotFederating :: HasCallStack => App () testDynamicBackendsNotFederating = do let overrides = def - { dbBrig = + { brigCfg = setField "optSettings.setFederationStrategy" "allowNone" } - startDynamicBackends [overrides, overrides, overrides] $ - \dynDomains -> do - [domainA, domainB, domainC] <- pure dynDomains - uidA <- randomUser domainA def {team = True} - retryT - $ bindResponse - (getFederationStatus uidA [domainB, domainC]) - $ \resp -> do - resp.status `shouldMatchInt` 533 - resp.json %. "unreachable_backends" `shouldMatchSet` [domainB, domainC] + startDynamicBackends [overrides, overrides, overrides] $ \[domainA, domainB, domainC] -> do + uidA <- randomUser domainA def {team = True} + retryT + $ bindResponse + (getFederationStatus uidA [domainB, domainC]) + $ \resp -> do + resp.status `shouldMatchInt` 533 + resp.json %. "unreachable_backends" `shouldMatchSet` [domainB, domainC] testDynamicBackendsFullyConnectedWhenAllowDynamic :: HasCallStack => App () testDynamicBackendsFullyConnectedWhenAllowDynamic = do - let overrides = - setField "optSettings.setFederationStrategy" "allowDynamic" - >=> removeField "optSettings.setFederationDomainConfigs" - >=> setField "optSettings.setFederationDomainConfigsUpdateFreq" (Aeson.Number 1) - startDynamicBackends - [ def {dbBrig = overrides}, - def {dbBrig = overrides}, - def {dbBrig = overrides} - ] - $ \dynDomains -> do - domains@[domainA, domainB, domainC] <- pure dynDomains - sequence_ [createFedConn x (FedConn y "full_search") | x <- domains, y <- domains, x /= y] - uidA <- randomUser domainA def {team = True} - uidB <- randomUser domainB def {team = True} - uidC <- randomUser domainC def {team = True} - let assertConnected u d d' = - bindResponse - (getFederationStatus u [d, d']) - $ \resp -> do - resp.status `shouldMatchInt` 200 - resp.json %. "status" `shouldMatch` "fully-connected" - retryT $ assertConnected uidA domainB domainC - retryT $ assertConnected uidB domainA domainC - retryT $ assertConnected uidC domainA domainB + withFederatingBackendsAllowDynamic $ \(domainA, domainB, domainC) -> do + -- Allowing 'full_search' or any type of search is how we enable federation + -- between backends when the federation strategy is 'allowDynamic'. + sequence_ + [ createFedConn x (FedConn y "full_search") + | x <- [domainA, domainB, domainC], + y <- [domainA, domainB, domainC], + x /= y + ] + uidA <- randomUser domainA def {team = True} + uidB <- randomUser domainB def {team = True} + uidC <- randomUser domainC def {team = True} + let assertConnected u d d' = + bindResponse + (getFederationStatus u [d, d']) + $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "status" `shouldMatch` "fully-connected" + retryT $ assertConnected uidA domainB domainC + retryT $ assertConnected uidB domainA domainC + retryT $ assertConnected uidC domainA domainB testDynamicBackendsNotFullyConnected :: HasCallStack => App () testDynamicBackendsNotFullyConnected = do - let overrides = - def - { dbBrig = - setField "optSettings.setFederationStrategy" "allowDynamic" - >=> removeField "optSettings.setFederationDomainConfigs" - >=> setField "optSettings.setFederationDomainConfigsUpdateFreq" (Aeson.Number 1) - } - startDynamicBackends [overrides, overrides, overrides] $ - \dynDomains -> do - domains@[domainA, domainB, domainC] <- pure dynDomains - -- clean federation config - sequence_ [deleteFedConn x y | x <- domains, y <- domains, x /= y] - -- A is connected to B and C, but B and C are not connected to each other - void $ createFedConn domainA $ FedConn domainB "full_search" - void $ createFedConn domainB $ FedConn domainA "full_search" - void $ createFedConn domainA $ FedConn domainC "full_search" - void $ createFedConn domainC $ FedConn domainA "full_search" - uidA <- randomUser domainA def {team = True} - retryT - $ bindResponse - (getFederationStatus uidA [domainB, domainC]) - $ \resp -> do - resp.status `shouldMatchInt` 200 - resp.json %. "status" `shouldMatch` "non-fully-connected" - resp.json %. "not_connected" `shouldMatchSet` [domainB, domainC] + withFederatingBackendsAllowDynamic $ \(domainA, domainB, domainC) -> do + -- A is connected to B and C, but B and C are not connected to each other + void $ createFedConn domainA $ FedConn domainB "full_search" + void $ createFedConn domainB $ FedConn domainA "full_search" + void $ createFedConn domainA $ FedConn domainC "full_search" + void $ createFedConn domainC $ FedConn domainA "full_search" + uidA <- randomUser domainA def {team = True} + retryT + $ bindResponse + (getFederationStatus uidA [domainB, domainC]) + $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "status" `shouldMatch` "non-fully-connected" + resp.json %. "not_connected" `shouldMatchSet` [domainB, domainC] testFederationStatus :: HasCallStack => App () testFederationStatus = do @@ -134,179 +138,44 @@ testFederationStatus = do testCreateConversationFullyConnected :: HasCallStack => App () testCreateConversationFullyConnected = do - let setFederationConfig = - setField "optSettings.setFederationStrategy" "allowDynamic" - >=> removeField "optSettings.setFederationDomainConfigs" - >=> setField "optSettings.setFederationDomainConfigsUpdateFreq" (Aeson.Number 1) - startDynamicBackends - [ def {dbBrig = setFederationConfig}, - def {dbBrig = setFederationConfig}, - def {dbBrig = setFederationConfig} - ] - $ \dynDomains -> do - domains@[domainA, domainB, domainC] <- pure dynDomains - connectAllDomainsAndWaitToSync 1 domains - [u1, u2, u3] <- createAndConnectUsers [domainA, domainB, domainC] - bindResponse (postConversation u1 (defProteus {qualifiedUsers = [u2, u3]})) $ \resp -> do - resp.status `shouldMatchInt` 201 + startDynamicBackends [def, def, def] $ \[domainA, domainB, domainC] -> do + [u1, u2, u3] <- createUsers [domainA, domainB, domainC] + connectTwoUsers u1 u2 + connectTwoUsers u1 u3 + bindResponse (postConversation u1 (defProteus {qualifiedUsers = [u2, u3]})) $ \resp -> do + resp.status `shouldMatchInt` 201 testCreateConversationNonFullyConnected :: HasCallStack => App () testCreateConversationNonFullyConnected = do - let setFederationConfig = - setField "optSettings.setFederationStrategy" "allowDynamic" - >=> removeField "optSettings.setFederationDomainConfigs" - >=> setField "optSettings.setFederationDomainConfigsUpdateFreq" (Aeson.Number 1) - startDynamicBackends - [ def {dbBrig = setFederationConfig}, - def {dbBrig = setFederationConfig}, - def {dbBrig = setFederationConfig} - ] - $ \dynDomains -> do - domains@[domainA, domainB, domainC] <- pure dynDomains - connectAllDomainsAndWaitToSync 1 domains - [u1, u2, u3] <- createAndConnectUsers [domainA, domainB, domainC] - -- stop federation between B and C - void $ deleteFedConn domainB domainC - void $ deleteFedConn domainC domainB - liftIO $ threadDelay (2 * 1000 * 1000) - bindResponse (postConversation u1 (defProteus {qualifiedUsers = [u2, u3]})) $ \resp -> do - resp.status `shouldMatchInt` 409 - resp.json %. "non_federating_backends" `shouldMatchSet` [domainB, domainC] - -testDefederationGroupConversation :: HasCallStack => App () -testDefederationGroupConversation = do - let setFederationConfig = - setField "optSettings.setFederationStrategy" "allowDynamic" - >=> removeField "optSettings.setFederationDomainConfigs" - >=> setField "optSettings.setFederationDomainConfigsUpdateFreq" (Aeson.Number 1) - startDynamicBackends - [ def {dbBrig = setFederationConfig}, - def {dbBrig = setFederationConfig} - ] - $ \dynDomains -> do - domains@[domainA, domainB] <- pure dynDomains - connectAllDomainsAndWaitToSync 1 domains - [uA, uB] <- createAndConnectUsers [domainA, domainB] - withWebSocket uA $ \ws -> do - -- create group conversation owned by domainB - convId <- bindResponse (postConversation uB (defProteus {qualifiedUsers = [uA]})) $ \r -> do - r.status `shouldMatchInt` 201 - r.json %. "qualified_id" - - -- check conversation exists and uB is a member from POV of uA - bindResponse (getConversation uA convId) $ \r -> do - r.status `shouldMatchInt` 200 - members <- r.json %. "members.others" & asList - qIds <- for members (\m -> m %. "qualified_id") - uBQId <- objQidObject uB - qIds `shouldMatchSet` [uBQId] - - -- check conversation exists and uA is a member from POV of uB - bindResponse (getConversation uB convId) $ \r -> do - r.status `shouldMatchInt` 200 - members <- r.json %. "members.others" & asList - qIds <- for members (\m -> m %. "qualified_id") - uAQId <- objQidObject uA - qIds `shouldMatchSet` [uAQId] - - -- domainA stops federating with domainB - void $ deleteFedConn domainA domainB - - -- assert conversation deleted from domainA - retryT $ - bindResponse (getConversation uA convId) $ \r -> - r.status `shouldMatchInt` 404 - - -- assert federation.delete event is sent twice - void $ awaitNMatches 2 3 (\n -> nPayload n %. "type" `isEqual` "federation.delete") ws - - -- assert no conversation.delete event is sent to uA - eventPayloads <- - getNotifications uA "cA" def - >>= getJSON 200 - >>= \n -> n %. "notifications" & asList >>= \ns -> for ns nPayload - - forM_ eventPayloads $ \p -> - p %. "type" `shouldNotMatch` "conversation.delete" - -testDefederationOneOnOne :: HasCallStack => App () -testDefederationOneOnOne = do - let setFederationConfig = - setField "optSettings.setFederationStrategy" "allowDynamic" - >=> removeField "optSettings.setFederationDomainConfigs" - >=> setField "optSettings.setFederationDomainConfigsUpdateFreq" (Aeson.Number 1) - startDynamicBackends - [ def {dbBrig = setFederationConfig}, - def {dbBrig = setFederationConfig} - ] - $ \dynDomains -> do - domains@[domainA, domainB] <- pure dynDomains - connectAllDomainsAndWaitToSync 1 domains - [uA, uB] <- createAndConnectUsers [domainA, domainB] - -- figure out on which backend the 1:1 conversation is created - qConvId <- getConnection uA uB >>= \c -> c.json %. "qualified_conversation" - - -- check conversation exists and uB is a member from POV of uA - bindResponse (getConversation uA qConvId) $ \r -> do - r.status `shouldMatchInt` 200 - members <- r.json %. "members.others" & asList - qIds <- for members (\m -> m %. "qualified_id") - uBQId <- objQidObject uB - qIds `shouldMatchSet` [uBQId] - - -- check conversation exists and uA is a member from POV of uB - bindResponse (getConversation uB qConvId) $ \r -> do - r.status `shouldMatchInt` 200 - members <- r.json %. "members.others" & asList - qIds <- for members (\m -> m %. "qualified_id") - uAQId <- objQidObject uA - qIds `shouldMatchSet` [uAQId] - - conversationOwningDomain <- objDomain qConvId - - when (domainA == conversationOwningDomain) $ do - -- conversation is created on domainA - assertFederationTerminatingUserNoConvDeleteEvent uB qConvId domainB domainA - - when (domainB == conversationOwningDomain) $ do - -- conversation is created on domainB - assertFederationTerminatingUserNoConvDeleteEvent uA qConvId domainA domainB - - when (domainA /= conversationOwningDomain && domainB /= conversationOwningDomain) $ do - -- this should not happen - error "impossible" - where - assertFederationTerminatingUserNoConvDeleteEvent :: Value -> Value -> String -> String -> App () - assertFederationTerminatingUserNoConvDeleteEvent user convId ownDomain otherDomain = do - withWebSocket user $ \ws -> do - void $ deleteFedConn ownDomain otherDomain - - -- assert conversation deleted eventually - retryT $ - bindResponse (getConversation user convId) $ \r -> - r.status `shouldMatchInt` 404 - - -- assert federation.delete event is sent twice - void $ awaitNMatches 2 3 (\n -> nPayload n %. "type" `isEqual` "federation.delete") ws - - -- assert no conversation.delete event is sent to uA - eventPayloads <- - getNotifications user "user-client" def - >>= getJSON 200 - >>= \n -> n %. "notifications" & asList >>= \ns -> for ns nPayload - - forM_ eventPayloads $ \p -> - p %. "type" `shouldNotMatch` "conversation.delete" + withFederatingBackendsAllowDynamic $ \(domainA, domainB, domainC) -> do + -- A is connected to B and C, but B and C are not connected to each other + void $ createFedConn domainA $ FedConn domainB "full_search" + void $ createFedConn domainB $ FedConn domainA "full_search" + void $ createFedConn domainA $ FedConn domainC "full_search" + void $ createFedConn domainC $ FedConn domainA "full_search" + liftIO $ threadDelay (2 * 1000 * 1000) + + u1 <- randomUser domainA def + u2 <- randomUser domainB def + u3 <- randomUser domainC def + connectTwoUsers u1 u2 + connectTwoUsers u1 u3 + + bindResponse (postConversation u1 (defProteus {qualifiedUsers = [u2, u3]})) $ \resp -> do + resp.status `shouldMatchInt` 409 + resp.json %. "non_federating_backends" `shouldMatchSet` [domainB, domainC] testAddMembersFullyConnectedProteus :: HasCallStack => App () testAddMembersFullyConnectedProteus = do - withFederatingBackendsAllowDynamic 2 $ \(domainA, domainB, domainC) -> do - [u1, u2, u3] <- createAndConnectUsers [domainA, domainB, domainC] + startDynamicBackends [def, def, def] $ \[domainA, domainB, domainC] -> do + [u1, u2, u3] <- createUsers [domainA, domainB, domainC] + connectTwoUsers u1 u2 + connectTwoUsers u1 u3 -- create conversation with no users cid <- postConversation u1 (defProteus {qualifiedUsers = []}) >>= getJSON 201 -- add members from remote backends members <- for [u2, u3] (%. "qualified_id") - bindResponse (addMembers u1 cid members) $ \resp -> do + bindResponse (addMembers u1 cid def {users = members}) $ \resp -> do resp.status `shouldMatchInt` 200 users <- resp.json %. "data.users" >>= asList addedUsers <- forM users (%. "qualified_id") @@ -314,30 +183,92 @@ testAddMembersFullyConnectedProteus = do testAddMembersNonFullyConnectedProteus :: HasCallStack => App () testAddMembersNonFullyConnectedProteus = do - withFederatingBackendsAllowDynamic 2 $ \(domainA, domainB, domainC) -> do - [u1, u2, u3] <- createAndConnectUsers [domainA, domainB, domainC] + withFederatingBackendsAllowDynamic $ \(domainA, domainB, domainC) -> do + void $ createFedConn domainA (FedConn domainB "full_search") + void $ createFedConn domainB (FedConn domainA "full_search") + void $ createFedConn domainA (FedConn domainC "full_search") + void $ createFedConn domainC (FedConn domainA "full_search") + liftIO $ threadDelay (2 * 1000 * 1000) -- wait for federation status to be updated + + -- add users + u1 <- randomUser domainA def + u2 <- randomUser domainB def + u3 <- randomUser domainC def + connectTwoUsers u1 u2 + connectTwoUsers u1 u3 + -- create conversation with no users cid <- postConversation u1 (defProteus {qualifiedUsers = []}) >>= getJSON 201 - -- stop federation between B and C - void $ deleteFedConn domainB domainC - void $ deleteFedConn domainC domainB - liftIO $ threadDelay (2 * 1000 * 1000) -- wait for federation status to be updated -- add members from remote backends members <- for [u2, u3] (%. "qualified_id") - bindResponse (addMembers u1 cid members) $ \resp -> do + bindResponse (addMembers u1 cid def {users = members}) $ \resp -> do resp.status `shouldMatchInt` 409 resp.json %. "non_federating_backends" `shouldMatchSet` [domainB, domainC] +testAddMember :: HasCallStack => App () +testAddMember = do + alice <- randomUser OwnDomain def + aliceId <- alice %. "qualified_id" + -- create conversation with no users + cid <- postConversation alice defProteus >>= getJSON 201 + bob <- randomUser OwnDomain def + bobId <- bob %. "qualified_id" + let addMember = addMembers alice cid def {role = Just "wire_member", users = [bobId]} + bindResponse addMember $ \resp -> do + resp.status `shouldMatchInt` 403 + resp.json %. "label" `shouldMatch` "not-connected" + connectTwoUsers alice bob + bindResponse addMember $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "type" `shouldMatch` "conversation.member-join" + resp.json %. "qualified_from" `shouldMatch` objQidObject alice + resp.json %. "qualified_conversation" `shouldMatch` objQidObject cid + users <- resp.json %. "data.users" >>= asList + addedUsers <- forM users (%. "qualified_id") + addedUsers `shouldMatchSet` [bobId] + + -- check that both users can see the conversation + bindResponse (getConversation alice cid) $ \resp -> do + resp.status `shouldMatchInt` 200 + mems <- resp.json %. "members.others" & asList + mem <- assertOne mems + mem %. "qualified_id" `shouldMatch` bobId + mem %. "conversation_role" `shouldMatch` "wire_member" + + bindResponse (getConversation bob cid) $ \resp -> do + resp.status `shouldMatchInt` 200 + mems <- resp.json %. "members.others" & asList + mem <- assertOne mems + mem %. "qualified_id" `shouldMatch` aliceId + mem %. "conversation_role" `shouldMatch` "wire_admin" + +testAddMemberV1 :: HasCallStack => Domain -> App () +testAddMemberV1 domain = do + [alice, bob] <- createAndConnectUsers [OwnDomain, domain] + conv <- postConversation alice defProteus >>= getJSON 201 + bobId <- bob %. "qualified_id" + let opts = + def + { version = Just 1, + role = Just "wire_member", + users = [bobId] + } + bindResponse (addMembers alice conv opts) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "type" `shouldMatch` "conversation.member-join" + resp.json %. "qualified_from" `shouldMatch` objQidObject alice + resp.json %. "qualified_conversation" `shouldMatch` objQidObject conv + users <- resp.json %. "data.users" >>= asList + traverse (%. "qualified_id") users `shouldMatchSet` [bobId] + testConvWithUnreachableRemoteUsers :: HasCallStack => App () testConvWithUnreachableRemoteUsers = do - let overrides = - def {dbBrig = setField "optSettings.setFederationStrategy" "allowAll"} - <> fullSearchWithAll ([alice, alex, bob, charlie, dylan], domains) <- - startDynamicBackends [overrides, overrides] $ \domains -> do + startDynamicBackends [def, def] $ \domains -> do own <- make OwnDomain & asString other <- make OtherDomain & asString - users <- createAndConnectUsers $ [own, own, other] <> domains + users@(alice : others) <- createUsers $ [own, own, other] <> domains + forM_ others $ connectTwoUsers alice pure (users, domains) let newConv = defProteus {qualifiedUsers = [alex, bob, charlie, dylan]} @@ -351,40 +282,430 @@ testConvWithUnreachableRemoteUsers = do testAddReachableWithUnreachableRemoteUsers :: HasCallStack => App () testAddReachableWithUnreachableRemoteUsers = do - let overrides = - def {dbBrig = setField "optSettings.setFederationStrategy" "allowAll"} - <> fullSearchWithAll - ([alex, bob], conv) <- - startDynamicBackends [overrides, overrides] $ \domains -> do + ([alex, bob], conv, domains) <- + startDynamicBackends [def, def] $ \domains -> do own <- make OwnDomain & asString other <- make OtherDomain & asString - [alice, alex, bob, charlie, dylan] <- - createAndConnectUsers $ [own, own, other] <> domains + [alice, alex, bob, charlie, dylan] <- createUsers $ [own, own, other] <> domains + forM_ [alex, bob, charlie, dylan] $ connectTwoUsers alice let newConv = defProteus {qualifiedUsers = [alex, charlie, dylan]} conv <- postConversation alice newConv >>= getJSON 201 - pure ([alex, bob], conv) + connectTwoUsers alex bob + pure ([alex, bob], conv, domains) bobId <- bob %. "qualified_id" - bindResponse (addMembers alex conv [bobId]) $ \resp -> do - resp.status `shouldMatchInt` 200 + bindResponse (addMembers alex conv def {users = [bobId]}) $ \resp -> do + -- This test is updated to reflect the changes in `performConversationJoin` + -- `performConversationJoin` now does a full check between all federation members + -- that will be in the conversation when adding users to a conversation. This is + -- to ensure that users from domains that aren't federating are not directly + -- connected to each other. + resp.status `shouldMatchInt` 533 + resp.jsonBody %. "unreachable_backends" `shouldMatchSet` domains testAddUnreachable :: HasCallStack => App () testAddUnreachable = do - let overrides = - def {dbBrig = setField "optSettings.setFederationStrategy" "allowAll"} - <> fullSearchWithAll - ([alex, charlie], [charlieDomain, _dylanDomain], conv) <- - startDynamicBackends [overrides, overrides] $ \domains -> do + ([alex, charlie], [charlieDomain, dylanDomain], conv) <- + startDynamicBackends [def, def] $ \domains -> do own <- make OwnDomain & asString - [alice, alex, charlie, dylan] <- - createAndConnectUsers $ [own, own] <> domains + [alice, alex, charlie, dylan] <- createUsers $ [own, own] <> domains + forM_ [alex, charlie, dylan] $ connectTwoUsers alice let newConv = defProteus {qualifiedUsers = [alex, dylan]} conv <- postConversation alice newConv >>= getJSON 201 + connectTwoUsers alex charlie pure ([alex, charlie], domains, conv) charlieId <- charlie %. "qualified_id" - bindResponse (addMembers alex conv [charlieId]) $ \resp -> do + bindResponse (addMembers alex conv def {users = [charlieId]}) $ \resp -> do resp.status `shouldMatchInt` 533 - resp.json %. "unreachable_backends" `shouldMatchSet` [charlieDomain] + -- All of the domains that are in the conversation, or will be in the conversation, + -- need to be reachable so we can check that the graph for those domains is fully connected. + resp.json %. "unreachable_backends" `shouldMatchSet` [charlieDomain, dylanDomain] + +testGetOneOnOneConvInStatusSentFromRemote :: App () +testGetOneOnOneConvInStatusSentFromRemote = do + d1User <- randomUser OwnDomain def + let shouldBeLocal = True + (d2Usr, d2ConvId) <- generateRemoteAndConvIdWithDomain OtherDomain (not shouldBeLocal) d1User + bindResponse (postConnection d1User d2Usr) $ \r -> do + r.status `shouldMatchInt` 201 + r.json %. "status" `shouldMatch` "sent" + bindResponse (listConversationIds d1User def) $ \r -> do + r.status `shouldMatchInt` 200 + convIds <- r.json %. "qualified_conversations" & asList + filter ((==) d2ConvId) convIds `shouldMatch` [d2ConvId] + bindResponse (getConnections d1User) $ \r -> do + qConvIds <- r.json %. "connections" & asList >>= traverse (%. "qualified_conversation") + filter ((==) d2ConvId) qConvIds `shouldMatch` [d2ConvId] + resp <- getConversation d1User d2ConvId + resp.status `shouldMatchInt` 200 + +testAddingUserNonFullyConnectedFederation :: HasCallStack => App () +testAddingUserNonFullyConnectedFederation = do + let overrides = + def + { brigCfg = + setField "optSettings.setFederationStrategy" "allowDynamic" + } + startDynamicBackends [overrides] $ \[dynBackend] -> do + own <- asString OwnDomain + other <- asString OtherDomain + + -- Ensure that dynamic backend only federates with own domain, but not other + -- domain. + void $ createFedConn dynBackend (FedConn own "full_search") + + alice <- randomUser own def + bob <- randomUser other def + charlie <- randomUser dynBackend def + -- We use retryT here so the dynamic federated connection changes can take + -- some time to be propagated. Remove after fixing https://wearezeta.atlassian.net/browse/WPB-3797 + mapM_ (retryT . connectTwoUsers alice) [bob, charlie] + + let newConv = defProteus {qualifiedUsers = []} + conv <- postConversation alice newConv >>= getJSON 201 + + bobId <- bob %. "qualified_id" + charlieId <- charlie %. "qualified_id" + bindResponse (addMembers alice conv def {users = [bobId, charlieId]}) $ \resp -> do + resp.status `shouldMatchInt` 409 + resp.json %. "non_federating_backends" `shouldMatchSet` [other, dynBackend] + +testMultiIngressGuestLinks :: HasCallStack => App () +testMultiIngressGuestLinks = do + do + configuredURI <- readServiceConfig Galley & (%. "settings.conversationCodeURI") & asText + + (user, _, _) <- createTeam OwnDomain 1 + conv <- postConversation user (allowGuests defProteus) >>= getJSON 201 + + bindResponse (postConversationCode user conv Nothing Nothing) $ \resp -> do + res <- getJSON 201 resp + res %. "type" `shouldMatch` "conversation.code-update" + guestLink <- res %. "data.uri" & asText + assertBool "guestlink incorrect" $ configuredURI `T.isPrefixOf` guestLink + + bindResponse (getConversationCode user conv Nothing) $ \resp -> do + res <- getJSON 200 resp + guestLink <- res %. "uri" & asText + assertBool "guestlink incorrect" $ configuredURI `T.isPrefixOf` guestLink + + bindResponse (getConversationCode user conv (Just "red.example.com")) $ \resp -> do + res <- getJSON 200 resp + guestLink <- res %. "uri" & asText + assertBool "guestlink incorrect" $ configuredURI `T.isPrefixOf` guestLink + + withModifiedBackend + ( def + { galleyCfg = \conf -> + conf + & setField "settings.conversationCodeURI" Null + & setField + "settings.multiIngress" + ( object + [ "red.example.com" .= "https://red.example.com", + "blue.example.com" .= "https://blue.example.com" + ] + ) + } + ) + $ \domain -> do + (user, _, _) <- createTeam domain 1 + conv <- postConversation user (allowGuests defProteus) >>= getJSON 201 + + bindResponse (postConversationCode user conv Nothing (Just "red.example.com")) $ \resp -> do + res <- getJSON 201 resp + res %. "type" `shouldMatch` "conversation.code-update" + guestLink <- res %. "data.uri" & asText + assertBool "guestlink incorrect" $ (fromString "https://red.example.com") `T.isPrefixOf` guestLink + + bindResponse (getConversationCode user conv (Just "red.example.com")) $ \resp -> do + res <- getJSON 200 resp + guestLink <- res %. "uri" & asText + assertBool "guestlink incorrect" $ (fromString "https://red.example.com") `T.isPrefixOf` guestLink + + bindResponse (getConversationCode user conv (Just "blue.example.com")) $ \resp -> do + res <- getJSON 200 resp + guestLink <- res %. "uri" & asText + assertBool "guestlink incorrect" $ (fromString "https://blue.example.com") `T.isPrefixOf` guestLink + + bindResponse (getConversationCode user conv Nothing) $ \resp -> do + res <- getJSON 403 resp + res %. "label" `shouldMatch` "access-denied" + + bindResponse (getConversationCode user conv (Just "unknown.example.com")) $ \resp -> do + res <- getJSON 403 resp + res %. "label" `shouldMatch` "access-denied" + +testAddUserWhenOtherBackendOffline :: HasCallStack => App () +testAddUserWhenOtherBackendOffline = do + ([alice, alex], conv) <- + startDynamicBackends [def] $ \domains -> do + own <- make OwnDomain & asString + [alice, alex, charlie] <- createUsers $ [own, own] <> domains + forM_ [alex, charlie] $ connectTwoUsers alice + + let newConv = defProteus {qualifiedUsers = [charlie]} + conv <- postConversation alice newConv >>= getJSON 201 + pure ([alice, alex], conv) + bindResponse (addMembers alice conv def {users = [alex]}) $ \resp -> do + resp.status `shouldMatchInt` 200 + +testSynchroniseUserRemovalNotification :: HasCallStack => App () +testSynchroniseUserRemovalNotification = do + resourcePool <- asks resourcePool + [alice, bob] <- createAndConnectUsers [OwnDomain, OtherDomain] + runCodensity (acquireResources 1 resourcePool) $ \[dynBackend] -> do + (conv, charlie, client) <- + runCodensity (startDynamicBackend dynBackend mempty) $ \_ -> do + charlie <- randomUser dynBackend.berDomain def + client <- objId $ bindResponse (addClient charlie def) $ getJSON 201 + mapM_ (connectTwoUsers charlie) [alice, bob] + conv <- + postConversation alice (defProteus {qualifiedUsers = [bob, charlie]}) + >>= getJSON 201 + pure (conv, charlie, client) + + let newConvName = "The new conversation name" + bindResponse (changeConversationName alice conv newConvName) $ \resp -> + resp.status `shouldMatchInt` 200 + bindResponse (removeMember alice conv charlie) $ \resp -> + resp.status `shouldMatchInt` 200 + runCodensity (startDynamicBackend dynBackend mempty) $ \_ -> do + nameNotif <- awaitNotification charlie client noValue 2 isConvNameChangeNotif + nameNotif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + nameNotif %. "payload.0.data.name" `shouldMatch` newConvName + leaveNotif <- awaitNotification charlie client noValue 2 isConvLeaveNotif + leaveNotif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + +testConvRenaming :: HasCallStack => App () +testConvRenaming = do + [alice, bob] <- createAndConnectUsers [OwnDomain, OtherDomain] + conv <- + postConversation alice (defProteus {qualifiedUsers = [bob]}) + >>= getJSON 201 + let newConvName = "The new conversation name" + withWebSockets [alice, bob] $ \wss -> do + for_ wss $ \ws -> do + void $ changeConversationName alice conv newConvName >>= getBody 200 + nameNotif <- awaitMatch 10 isConvNameChangeNotif ws + nameNotif %. "payload.0.data.name" `shouldMatch` newConvName + nameNotif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + +testReceiptModeWithRemotesOk :: HasCallStack => App () +testReceiptModeWithRemotesOk = do + [alice, bob] <- createAndConnectUsers [OwnDomain, OtherDomain] + conv <- + postConversation alice (defProteus {qualifiedUsers = [bob]}) + >>= getJSON 201 + withWebSockets [alice, bob] $ \wss -> do + void $ updateReceiptMode alice conv (43 :: Int) >>= getBody 200 + for_ wss $ \ws -> do + notif <- awaitMatch 10 isReceiptModeUpdateNotif ws + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.qualified_from" `shouldMatch` objQidObject alice + notif %. "payload.0.data.receipt_mode" `shouldMatchInt` 43 + +testReceiptModeWithRemotesUnreachable :: HasCallStack => App () +testReceiptModeWithRemotesUnreachable = do + ownDomain <- asString OwnDomain + alice <- randomUser ownDomain def + conv <- startDynamicBackends [mempty] $ \[dynBackend] -> do + bob <- randomUser dynBackend def + connectTwoUsers alice bob + postConversation alice (defProteus {qualifiedUsers = [bob]}) + >>= getJSON 201 + withWebSocket alice $ \ws -> do + void $ updateReceiptMode alice conv (43 :: Int) >>= getBody 200 + notif <- awaitMatch 10 isReceiptModeUpdateNotif ws + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.qualified_from" `shouldMatch` objQidObject alice + notif %. "payload.0.data.receipt_mode" `shouldMatchInt` 43 + +testDeleteLocalMember :: HasCallStack => App () +testDeleteLocalMember = do + [alice, alex, bob] <- createUsers [OwnDomain, OwnDomain, OtherDomain] + connectTwoUsers alice alex + connectTwoUsers alice bob + conv <- + postConversation alice (defProteus {qualifiedUsers = [alex, bob]}) + >>= getJSON 201 + bindResponse (removeMember alice conv alex) $ \resp -> do + r <- getJSON 200 resp + r %. "type" `shouldMatch` "conversation.member-leave" + r %. "qualified_conversation" `shouldMatch` objQidObject conv + r %. "qualified_from" `shouldMatch` objQidObject alice + r %. "data.qualified_user_ids.0" `shouldMatch` objQidObject alex + -- Now that Alex is gone, try removing her once again + bindResponse (removeMember alice conv alex) $ \r -> do + r.status `shouldMatchInt` 204 + r.jsonBody `shouldMatch` (Nothing @Aeson.Value) + +testDeleteRemoteMember :: HasCallStack => App () +testDeleteRemoteMember = do + [alice, alex, bob] <- createUsers [OwnDomain, OwnDomain, OtherDomain] + connectTwoUsers alice alex + connectTwoUsers alice bob + conv <- + postConversation alice (defProteus {qualifiedUsers = [alex, bob]}) + >>= getJSON 201 + bindResponse (removeMember alice conv bob) $ \resp -> do + r <- getJSON 200 resp + r %. "type" `shouldMatch` "conversation.member-leave" + r %. "qualified_conversation" `shouldMatch` objQidObject conv + r %. "qualified_from" `shouldMatch` objQidObject alice + r %. "data.qualified_user_ids.0" `shouldMatch` objQidObject bob + -- Now that Bob is gone, try removing him once again + bindResponse (removeMember alice conv bob) $ \r -> do + r.status `shouldMatchInt` 204 + r.jsonBody `shouldMatch` (Nothing @Aeson.Value) + +testDeleteRemoteMemberRemoteUnreachable :: HasCallStack => App () +testDeleteRemoteMemberRemoteUnreachable = do + [alice, bob, bart] <- createUsers [OwnDomain, OtherDomain, OtherDomain] + conv <- startDynamicBackends [mempty] $ \[dynBackend] -> do + charlie <- randomUser dynBackend def + connectTwoUsers alice bob + connectTwoUsers alice bart + connectTwoUsers alice charlie + postConversation + alice + (defProteus {qualifiedUsers = [bob, bart, charlie]}) + >>= getJSON 201 + void $ withWebSockets [alice, bob] $ \wss -> do + void $ removeMember alice conv bob >>= getBody 200 + for wss $ \ws -> do + leaveNotif <- awaitMatch 10 isConvLeaveNotif ws + leaveNotif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + leaveNotif %. "payload.0.qualified_from" `shouldMatch` objQidObject alice + leaveNotif %. "payload.0.data.qualified_user_ids.0" `shouldMatch` objQidObject bob + -- Now that Bob is gone, try removing him once again + bindResponse (removeMember alice conv bob) $ \r -> do + r.status `shouldMatchInt` 204 + r.jsonBody `shouldMatch` (Nothing @Aeson.Value) + +testDeleteTeamConversationWithRemoteMembers :: HasCallStack => App () +testDeleteTeamConversationWithRemoteMembers = do + (alice, team, _) <- createTeam OwnDomain 1 + conv <- postConversation alice (defProteus {team = Just team}) >>= getJSON 201 + bob <- randomUser OtherDomain def + connectTwoUsers alice bob + mem <- bob %. "qualified_id" + void $ addMembers alice conv def {users = [mem]} >>= getBody 200 + + void $ withWebSockets [alice, bob] $ \wss -> do + void $ deleteTeamConversation team conv alice >>= getBody 200 + for wss $ \ws -> do + notif <- awaitMatch 10 isConvDeleteNotif ws + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.qualified_from" `shouldMatch` objQidObject alice + +testDeleteTeamConversationWithUnreachableRemoteMembers :: HasCallStack => App () +testDeleteTeamConversationWithUnreachableRemoteMembers = do + resourcePool <- asks resourcePool + (alice, team, _) <- createTeam OwnDomain 1 + conv <- postConversation alice (defProteus {team = Just team}) >>= getJSON 201 + + let assertNotification :: (HasCallStack, MakesValue n) => n -> App () + assertNotification notif = do + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.qualified_from" `shouldMatch` objQidObject alice + + runCodensity (acquireResources 1 resourcePool) $ \[dynBackend] -> do + (bob, bobClient) <- runCodensity (startDynamicBackend dynBackend mempty) $ \_ -> do + bob <- randomUser dynBackend.berDomain def + bobClient <- objId $ bindResponse (addClient bob def) $ getJSON 201 + connectTwoUsers alice bob + mem <- bob %. "qualified_id" + void $ addMembers alice conv def {users = [mem]} >>= getBody 200 + pure (bob, bobClient) + withWebSocket alice $ \ws -> do + void $ deleteTeamConversation team conv alice >>= getBody 200 + notif <- awaitMatch 10 isConvDeleteNotif ws + assertNotification notif + void $ runCodensity (startDynamicBackend dynBackend mempty) $ \_ -> do + notif <- awaitNotification bob bobClient noValue 2 isConvDeleteNotif + assertNotification notif + +testLeaveConversationSuccess :: HasCallStack => App () +testLeaveConversationSuccess = do + [alice, bob, chad, dee] <- createUsers [OwnDomain, OwnDomain, OtherDomain, OtherDomain] + [aClient, bClient] <- forM [alice, bob] $ \user -> + objId $ bindResponse (addClient user def) $ getJSON 201 + startDynamicBackends [def] $ \[dynDomain] -> do + eve <- randomUser dynDomain def + eClient <- objId $ bindResponse (addClient eve def) $ getJSON 201 + forM_ [bob, chad, dee, eve] $ connectTwoUsers alice + conv <- + postConversation + alice + ( defProteus + { qualifiedUsers = [bob, chad, dee, eve] + } + ) + >>= getJSON 201 + void $ removeMember chad conv chad >>= getBody 200 + assertLeaveNotification chad conv alice aClient chad + assertLeaveNotification chad conv bob bClient chad + assertLeaveNotification chad conv eve eClient chad + +testOnUserDeletedConversations :: HasCallStack => App () +testOnUserDeletedConversations = do + startDynamicBackends [def] $ \[dynDomain] -> do + [ownDomain, otherDomain] <- forM [OwnDomain, OtherDomain] asString + [alice, alex, bob, bart, chad] <- createUsers [ownDomain, ownDomain, otherDomain, otherDomain, dynDomain] + forM_ [alex, bob, bart, chad] $ connectTwoUsers alice + bobId <- bob %. "qualified_id" + ooConvId <- do + l <- getAllConvs alice + let isWith users c = do + t <- (==) <$> (c %. "type" & asInt) <*> pure 2 + others <- c %. "members.others" & asList + qIds <- for others (%. "qualified_id") + pure $ qIds == users && t + c <- head <$> filterM (isWith [bobId]) l + c %. "qualified_id" + + mainConvBefore <- + postConversation alice (defProteus {qualifiedUsers = [alex, bob, bart, chad]}) + >>= getJSON 201 + + void $ withWebSocket alex $ \ws -> do + void $ deleteUser bob >>= getBody 200 + n <- awaitMatch 10 isConvLeaveNotif ws + n %. "payload.0.qualified_from" `shouldMatch` bobId + n %. "payload.0.qualified_conversation" `shouldMatch` (mainConvBefore %. "qualified_id") + + do + -- Bob is not in the one-to-one conversation with Alice any more + conv <- getConversation alice ooConvId >>= getJSON 200 + shouldBeEmpty $ conv %. "members.others" + do + -- Bob is not in the main conversation any more + mainConvAfter <- getConversation alice (mainConvBefore %. "qualified_id") >>= getJSON 200 + mems <- mainConvAfter %. "members.others" & asList + memIds <- for mems (%. "qualified_id") + expectedIds <- for [alex, bart, chad] (%. "qualified_id") + memIds `shouldMatchSet` expectedIds + +testUpdateConversationByRemoteAdmin :: HasCallStack => App () +testUpdateConversationByRemoteAdmin = do + [alice, bob, charlie] <- createUsers [OwnDomain, OtherDomain, OtherDomain] + connectTwoUsers alice bob + connectTwoUsers alice charlie + conv <- + postConversation alice (defProteus {qualifiedUsers = [bob, charlie]}) + >>= getJSON 201 + void $ updateRole alice bob "wire_admin" (conv %. "qualified_id") >>= getBody 200 + void $ withWebSockets [alice, bob, charlie] $ \wss -> do + void $ updateReceiptMode bob conv (41 :: Int) >>= getBody 200 + for_ wss $ \ws -> awaitMatch 10 isReceiptModeUpdateNotif ws + +testGuestCreatesConversation :: HasCallStack => App () +testGuestCreatesConversation = do + alice <- randomUser OwnDomain def {activate = False} + bindResponse (postConversation alice defProteus) $ \resp -> do + resp.status `shouldMatchInt` 403 + resp.json %. "label" `shouldMatch` "operation-denied" diff --git a/integration/test/Test/Demo.hs b/integration/test/Test/Demo.hs index 45d240c1548..3e73c8be4e9 100644 --- a/integration/test/Test/Demo.hs +++ b/integration/test/Test/Demo.hs @@ -1,13 +1,13 @@ +{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} + -- | This module is meant to show how Testlib can be used module Test.Demo where -import API.Brig qualified as Public -import API.BrigInternal qualified as Internal -import API.GalleyInternal qualified as Internal +import API.Brig qualified as BrigP +import API.BrigInternal qualified as BrigI +import API.GalleyInternal qualified as GalleyI import API.Nginz qualified as Nginz -import Control.Monad.Codensity import Control.Monad.Cont -import Data.Map qualified as Map import GHC.Stack import SetupHelpers import Testlib.Prelude @@ -17,10 +17,10 @@ testCantDeleteLHClient :: HasCallStack => App () testCantDeleteLHClient = do user <- randomUser OwnDomain def client <- - Public.addClient user def {Public.ctype = "legalhold", Public.internal = True} + BrigP.addClient user def {BrigP.ctype = "legalhold", BrigP.internal = True} >>= getJSON 201 - bindResponse (Public.deleteClient user client) $ \resp -> do + bindResponse (BrigP.deleteClient user client) $ \resp -> do resp.status `shouldMatchInt` 400 -- | Deleting unknown clients should fail with 404. @@ -28,73 +28,74 @@ testDeleteUnknownClient :: HasCallStack => App () testDeleteUnknownClient = do user <- randomUser OwnDomain def let fakeClientId = "deadbeefdeadbeef" - bindResponse (Public.deleteClient user fakeClientId) $ \resp -> do + bindResponse (BrigP.deleteClient user fakeClientId) $ \resp -> do resp.status `shouldMatchInt` 404 resp.json %. "label" `shouldMatch` "client-not-found" testModifiedBrig :: HasCallStack => App () testModifiedBrig = do - withModifiedService - Brig - (setField "optSettings.setFederationDomain" "overridden.example.com") - $ \_domain -> do - bindResponse (Public.getAPIVersion OwnDomain) + withModifiedBackend + (def {brigCfg = setField "optSettings.setFederationDomain" "overridden.example.com"}) + $ \domain -> do + bindResponse (BrigP.getAPIVersion domain) $ \resp -> do resp.status `shouldMatchInt` 200 (resp.json %. "domain") `shouldMatch` "overridden.example.com" testModifiedGalley :: HasCallStack => App () testModifiedGalley = do - (_user, tid) <- createTeam OwnDomain + (_user, tid, _) <- createTeam OwnDomain 1 - let getFeatureStatus = do - bindResponse (Internal.getTeamFeature "searchVisibility" tid) $ \res -> do + let getFeatureStatus :: (MakesValue domain) => domain -> String -> App Value + getFeatureStatus domain team = do + bindResponse (GalleyI.getTeamFeature domain "searchVisibility" team) $ \res -> do res.status `shouldMatchInt` 200 res.json %. "status" - do - getFeatureStatus `shouldMatch` "disabled" + getFeatureStatus OwnDomain tid `shouldMatch` "disabled" - withModifiedService - Galley - (setField "settings.featureFlags.teamSearchVisibility" "enabled-by-default") - $ \_ -> getFeatureStatus `shouldMatch` "enabled" + withModifiedBackend + def {galleyCfg = setField "settings.featureFlags.teamSearchVisibility" "enabled-by-default"} + $ \domain -> do + (_user, tid', _) <- createTeam domain 1 + getFeatureStatus domain tid' `shouldMatch` "enabled" testModifiedCannon :: HasCallStack => App () testModifiedCannon = do - withModifiedService Cannon pure $ \_ -> pure () + withModifiedBackend def $ \_ -> pure () testModifiedGundeck :: HasCallStack => App () testModifiedGundeck = do - withModifiedService Gundeck pure $ \_ -> pure () + withModifiedBackend def $ \_ -> pure () testModifiedCargohold :: HasCallStack => App () testModifiedCargohold = do - withModifiedService Cargohold pure $ \_ -> pure () + withModifiedBackend def $ \_ -> pure () testModifiedSpar :: HasCallStack => App () testModifiedSpar = do - withModifiedService Spar pure $ \_ -> pure () + withModifiedBackend def $ \_ -> pure () testModifiedServices :: HasCallStack => App () testModifiedServices = do let serviceMap = - Map.fromList - [ (Brig, setField "optSettings.setFederationDomain" "overridden.example.com"), - (Galley, setField "settings.featureFlags.teamSearchVisibility" "enabled-by-default") - ] - runCodensity (withModifiedServices serviceMap) $ \_domain -> do - (_user, tid) <- createTeam OwnDomain - bindResponse (Internal.getTeamFeature "searchVisibility" tid) $ \res -> do + def + { brigCfg = setField "optSettings.setFederationDomain" "overridden.example.com", + galleyCfg = setField "settings.featureFlags.teamSearchVisibility" "enabled-by-default" + } + + withModifiedBackend serviceMap $ \domain -> do + (_user, tid, _) <- createTeam domain 1 + bindResponse (GalleyI.getTeamFeature domain "searchVisibility" tid) $ \res -> do res.status `shouldMatchInt` 200 res.json %. "status" `shouldMatch` "enabled" - bindResponse (Public.getAPIVersion OwnDomain) $ + bindResponse (BrigP.getAPIVersion domain) $ \resp -> do resp.status `shouldMatchInt` 200 (resp.json %. "domain") `shouldMatch` "overridden.example.com" - bindResponse (Nginz.getSystemSettingsUnAuthorized OwnDomain) $ + bindResponse (Nginz.getSystemSettingsUnAuthorized domain) $ \resp -> do resp.status `shouldMatchInt` 200 resp.json %. "setRestrictUserCreation" `shouldMatch` False @@ -104,7 +105,7 @@ testDynamicBackend = do ownDomain <- objDomain OwnDomain user <- randomUser OwnDomain def uid <- objId user - bindResponse (Public.getSelf ownDomain uid) $ \resp -> do + bindResponse (BrigP.getSelf ownDomain uid) $ \resp -> do resp.status `shouldMatchInt` 200 (resp.json %. "id") `shouldMatch` objId user @@ -116,24 +117,24 @@ testDynamicBackend = do resp.json %. "setRestrictUserCreation" `shouldMatch` False -- user created in own domain should not be found in dynamic backend - bindResponse (Public.getSelf dynDomain uid) $ \resp -> do + bindResponse (BrigP.getSelf dynDomain uid) $ \resp -> do resp.status `shouldMatchInt` 404 -- now create a user in the dynamic backend userD1 <- randomUser dynDomain def uidD1 <- objId userD1 - bindResponse (Public.getSelf dynDomain uidD1) $ \resp -> do + bindResponse (BrigP.getSelf dynDomain uidD1) $ \resp -> do resp.status `shouldMatchInt` 200 (resp.json %. "id") `shouldMatch` objId userD1 -- the d1 user should not be found in the own domain - bindResponse (Public.getSelf ownDomain uidD1) $ \resp -> do + bindResponse (BrigP.getSelf ownDomain uidD1) $ \resp -> do resp.status `shouldMatchInt` 404 testStartMultipleDynamicBackends :: HasCallStack => App () testStartMultipleDynamicBackends = do let assertCorrectDomain domain = - bindResponse (Public.getAPIVersion domain) $ + bindResponse (BrigP.getAPIVersion domain) $ \resp -> do resp.status `shouldMatchInt` 200 (resp.json %. "domain") `shouldMatch` domain @@ -144,9 +145,9 @@ testIndependentESIndices = do u1 <- randomUser OwnDomain def u2 <- randomUser OwnDomain def uid2 <- objId u2 - connectUsers u1 u2 - Internal.refreshIndex OwnDomain - bindResponse (Public.searchContacts u1 (u2 %. "name") OwnDomain) $ \resp -> do + connectTwoUsers u1 u2 + BrigI.refreshIndex OwnDomain + bindResponse (BrigP.searchContacts u1 (u2 %. "name") OwnDomain) $ \resp -> do resp.status `shouldMatchInt` 200 docs <- resp.json %. "documents" >>= asList case docs of @@ -156,16 +157,16 @@ testIndependentESIndices = do [dynDomain] <- pure dynDomains uD1 <- randomUser dynDomain def -- searching for u1 on the dyn backend should yield no result - bindResponse (Public.searchContacts uD1 (u2 %. "name") dynDomain) $ \resp -> do + bindResponse (BrigP.searchContacts uD1 (u2 %. "name") dynDomain) $ \resp -> do resp.status `shouldMatchInt` 200 docs <- resp.json %. "documents" >>= asList null docs `shouldMatch` True uD2 <- randomUser dynDomain def uidD2 <- objId uD2 - connectUsers uD1 uD2 - Internal.refreshIndex dynDomain + connectTwoUsers uD1 uD2 + BrigI.refreshIndex dynDomain -- searching for uD2 on the dyn backend should yield a result - bindResponse (Public.searchContacts uD1 (uD2 %. "name") dynDomain) $ \resp -> do + bindResponse (BrigP.searchContacts uD1 (uD2 %. "name") dynDomain) $ \resp -> do resp.status `shouldMatchInt` 200 docs <- resp.json %. "documents" >>= asList case docs of @@ -174,31 +175,23 @@ testIndependentESIndices = do testDynamicBackendsFederation :: HasCallStack => App () testDynamicBackendsFederation = do - startDynamicBackends [def <> fullSearchWithAll, def <> fullSearchWithAll] $ \dynDomains -> do - [aDynDomain, anotherDynDomain] <- pure dynDomains - u1 <- randomUser aDynDomain def - u2 <- randomUser anotherDynDomain def - uid2 <- objId u2 - Internal.refreshIndex anotherDynDomain - bindResponse (Public.searchContacts u1 (u2 %. "name") anotherDynDomain) $ \resp -> do - resp.status `shouldMatchInt` 200 - docs <- resp.json %. "documents" >>= asList - case docs of - [] -> assertFailure "Expected a non empty result, but got an empty one" - doc : _ -> doc %. "id" `shouldMatch` uid2 + startDynamicBackends [def, def] $ \[aDynDomain, anotherDynDomain] -> do + [u1, u2] <- createAndConnectUsers [aDynDomain, anotherDynDomain] + bindResponse (BrigP.getConnection u1 u2) assertSuccess + bindResponse (BrigP.getConnection u2 u1) assertSuccess testWebSockets :: HasCallStack => App () testWebSockets = do user <- randomUser OwnDomain def withWebSocket user $ \ws -> do - client <- Public.addClient user def >>= getJSON 201 + client <- BrigP.addClient user def >>= getJSON 201 n <- awaitMatch 3 (\n -> nPayload n %. "type" `isEqual` "user.client-add") ws nPayload n %. "client.id" `shouldMatch` (client %. "id") testMultipleBackends :: App () testMultipleBackends = do - ownDomainRes <- (Public.getAPIVersion OwnDomain >>= getJSON 200) %. "domain" - otherDomainRes <- (Public.getAPIVersion OtherDomain >>= getJSON 200) %. "domain" + ownDomainRes <- (BrigP.getAPIVersion OwnDomain >>= getJSON 200) %. "domain" + otherDomainRes <- (BrigP.getAPIVersion OtherDomain >>= getJSON 200) %. "domain" ownDomainRes `shouldMatch` OwnDomain otherDomainRes `shouldMatch` OtherDomain OwnDomain `shouldNotMatch` OtherDomain diff --git a/integration/test/Test/Federation.hs b/integration/test/Test/Federation.hs new file mode 100644 index 00000000000..ba584b67bcc --- /dev/null +++ b/integration/test/Test/Federation.hs @@ -0,0 +1,134 @@ +{-# LANGUAGE OverloadedLabels #-} + +module Test.Federation where + +import API.Brig qualified as BrigP +import API.Galley +import Control.Lens +import Control.Monad.Codensity +import Control.Monad.Reader +import Data.ProtoLens qualified as Proto +import Data.ProtoLens.Labels () +import Notifications +import Numeric.Lens +import Proto.Otr qualified as Proto +import Proto.Otr_Fields qualified as Proto +import SetupHelpers +import Testlib.Prelude +import Testlib.ResourcePool + +testNotificationsForOfflineBackends :: HasCallStack => App () +testNotificationsForOfflineBackends = do + resourcePool <- asks (.resourcePool) + -- `delUser` will eventually get deleted. + [delUser, otherUser, otherUser2] <- createUsers [OwnDomain, OtherDomain, OtherDomain] + delClient <- objId $ bindResponse (BrigP.addClient delUser def) $ getJSON 201 + otherClient <- objId $ bindResponse (BrigP.addClient otherUser def) $ getJSON 201 + otherClient2 <- objId $ bindResponse (BrigP.addClient otherUser2 def) $ getJSON 201 + + -- We call it 'downBackend' because it is down for most of this test + -- except for setup and assertions. Perhaps there is a better name. + runCodensity (acquireResources 1 resourcePool) $ \[downBackend] -> do + (downUser1, downClient1, downUser2, upBackendConv, downBackendConv) <- runCodensity (startDynamicBackend downBackend mempty) $ \_ -> do + downUser1 <- randomUser downBackend.berDomain def + downUser2 <- randomUser downBackend.berDomain def + downClient1 <- objId $ bindResponse (BrigP.addClient downUser1 def) $ getJSON 201 + + connectTwoUsers delUser otherUser + connectTwoUsers delUser otherUser2 + connectTwoUsers delUser downUser1 + connectTwoUsers delUser downUser2 + connectTwoUsers downUser1 otherUser + + upBackendConv <- bindResponse (postConversation delUser (defProteus {qualifiedUsers = [otherUser, otherUser2, downUser1]})) $ getJSON 201 + downBackendConv <- bindResponse (postConversation downUser1 (defProteus {qualifiedUsers = [otherUser, delUser]})) $ getJSON 201 + pure (downUser1, downClient1, downUser2, upBackendConv, downBackendConv) + + withWebSocket otherUser $ \ws -> do + -- Even when a participating backend is down, messages to conversations + -- owned by other backends should go. + successfulMsgForOtherUsers <- mkProteusRecipients otherUser [(otherUser, [otherClient]), (otherUser2, [otherClient2])] "success message for other user" + successfulMsgForDownUser <- mkProteusRecipient downUser1 downClient1 "success message for down user" + let successfulMsg = + Proto.defMessage @Proto.QualifiedNewOtrMessage + & #sender . Proto.client .~ (delClient ^?! hex) + & #recipients .~ [successfulMsgForOtherUsers, successfulMsgForDownUser] + & #reportAll .~ Proto.defMessage + bindResponse (postProteusMessage delUser upBackendConv successfulMsg) assertSuccess + + -- When the conversation owning backend is down, messages will fail to be sent. + failedMsgForOtherUser <- mkProteusRecipient otherUser otherClient "failed message for other user" + failedMsgForDownUser <- mkProteusRecipient downUser1 downClient1 "failed message for down user" + let failedMsg = + Proto.defMessage @Proto.QualifiedNewOtrMessage + & #sender . Proto.client .~ (delClient ^?! hex) + & #recipients .~ [failedMsgForOtherUser, failedMsgForDownUser] + & #reportAll .~ Proto.defMessage + bindResponse (postProteusMessage delUser downBackendConv failedMsg) $ \resp -> + -- Due to the way federation breaks in local env vs K8s, it can return 521 + -- (local) or 533 (K8s). + resp.status `shouldMatchOneOf` [Number 521, Number 533] + + -- Conversation creation with people from down backend should fail + bindResponse (postConversation delUser (defProteus {qualifiedUsers = [otherUser, downUser1]})) $ \resp -> + resp.status `shouldMatchInt` 533 + + -- Adding users to an up backend conversation should not work when one of + -- the participating backends is down. This is due to not being able to + -- check non-fully connected graph between all participating backends + -- however, if the backend of the user to be added is already part of the conversation, we do not need to do the check + -- and the user can be added as long as the backend is reachable + otherUser3 <- randomUser OtherDomain def + connectTwoUsers delUser otherUser3 + bindResponse (addMembers delUser upBackendConv def {users = [otherUser3]}) $ \resp -> + resp.status `shouldMatchInt` 200 + + -- Adding users from down backend to a conversation should fail + bindResponse (addMembers delUser upBackendConv def {users = [downUser2]}) $ \resp -> + resp.status `shouldMatchInt` 533 + + -- Removing users from an up backend conversation should work even when one + -- of the participating backends is down. + bindResponse (removeMember delUser upBackendConv otherUser2) $ \resp -> + resp.status `shouldMatchInt` 200 + + -- Even removing a user from the down backend itself should work. + bindResponse (removeMember delUser upBackendConv delUser) $ \resp -> + resp.status `shouldMatchInt` 200 + + -- User deletions should eventually make it to the other backend. + deleteUser delUser + + let isOtherUser2LeaveUpConvNotif = allPreds [isConvLeaveNotif, isNotifConv upBackendConv, isNotifForUser otherUser2] + isDelUserLeaveUpConvNotif = allPreds [isConvLeaveNotif, isNotifConv upBackendConv, isNotifForUser delUser] + + do + newMsgNotif <- awaitMatch 10 isNewMessageNotif ws + newMsgNotif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject upBackendConv + newMsgNotif %. "payload.0.data.text" `shouldMatchBase64` "success message for other user" + + void $ awaitMatch 10 isOtherUser2LeaveUpConvNotif ws + void $ awaitMatch 10 isDelUserLeaveUpConvNotif ws + + delUserDeletedNotif <- nPayload $ awaitMatch 10 isDeleteUserNotif ws + objQid delUserDeletedNotif `shouldMatch` objQid delUser + + runCodensity (startDynamicBackend downBackend mempty) $ \_ -> do + newMsgNotif <- awaitNotification downUser1 downClient1 noValue 5 isNewMessageNotif + newMsgNotif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject upBackendConv + newMsgNotif %. "payload.0.data.text" `shouldMatchBase64` "success message for down user" + + let isDelUserLeaveDownConvNotif = + allPreds + [ isConvLeaveNotif, + isNotifConv downBackendConv, + isNotifForUser delUser + ] + void $ awaitNotification downUser1 downClient1 (Just newMsgNotif) 5 isDelUserLeaveDownConvNotif + + -- FUTUREWORK: Uncomment after fixing this bug: https://wearezeta.atlassian.net/browse/WPB-3664 + -- void $ awaitNotification downUser1 downClient1 (Just newMsgNotif) 1 isOtherUser2LeaveUpConvNotif + -- void $ awaitNotification otherUser otherClient (Just newMsgNotif) 1 isDelUserLeaveDownConvNotif + + delUserDeletedNotif <- nPayload $ awaitNotification downUser1 downClient1 (Just newMsgNotif) 5 isDeleteUserNotif + objQid delUserDeletedNotif `shouldMatch` objQid delUser diff --git a/integration/test/Test/Federator.hs b/integration/test/Test/Federator.hs index 6e90308940c..99bdf155f28 100644 --- a/integration/test/Test/Federator.hs +++ b/integration/test/Test/Federator.hs @@ -1,32 +1,28 @@ +{-# LANGUAGE OverloadedStrings #-} + module Test.Federator where +import API.Brig +import API.Federator (getMetrics) +import Data.Attoparsec.Text import Data.ByteString qualified as BS import Data.String.Conversions -import Network.HTTP.Client qualified as HTTP +import Data.Text +import SetupHelpers (randomUser) import Testlib.Prelude runFederatorMetrics :: (ServiceMap -> HostPort) -> App () runFederatorMetrics getService = do - let req = submit "GET" =<< rawBaseRequestF OwnDomain getService "/i/metrics" - handleRes res = res <$ res.status `shouldMatchInt` 200 - first <- bindResponse req handleRes - second <- bindResponse req handleRes + let handleRes res = res <$ res.status `shouldMatchInt` 200 + first <- bindResponse (getMetrics OwnDomain getService) handleRes + second <- bindResponse (getMetrics OwnDomain getService) handleRes assertBool "Two metric requests should never match" $ first.body /= second.body assertBool "Second metric response should never be 0 length (the first might be)" $ BS.length second.body /= 0 assertBool "The seconds metric response should have text indicating that it is returning metrics" $ - BS.isInfixOf (cs expectedString) second.body + BS.isInfixOf expectedString second.body where expectedString = "# TYPE http_request_duration_seconds histogram" -rawBaseRequestF :: (HasCallStack, MakesValue domain) => domain -> (ServiceMap -> HostPort) -> String -> App HTTP.Request -rawBaseRequestF domain getService path = do - domainV <- objDomain domain - serviceMap <- getServiceMap domainV - - liftIO . HTTP.parseRequest $ - let HostPort h p = getService serviceMap - in "http://" <> h <> ":" <> show p <> ("/" <> joinHttpPath (splitHttpPath path)) - -- The metrics setup for both internal and external federator servers -- are the same, so we can simply run the same test for both. testFederatorMetricsInternal :: App () @@ -34,3 +30,32 @@ testFederatorMetricsInternal = runFederatorMetrics federatorInternal testFederatorMetricsExternal :: App () testFederatorMetricsExternal = runFederatorMetrics federatorExternal + +testFederatorNumRequestsMetrics :: HasCallStack => App () +testFederatorNumRequestsMetrics = do + u1 <- randomUser OwnDomain def + u2 <- randomUser OtherDomain def + incomingBefore <- getMetric parseIncomingRequestCount OtherDomain OwnDomain + outgoingBefore <- getMetric parseOutgoingRequestCount OwnDomain OtherDomain + bindResponse (searchContacts u1 (u2 %. "name") OtherDomain) $ \resp -> + resp.status `shouldMatchInt` 200 + incomingAfter <- getMetric parseIncomingRequestCount OtherDomain OwnDomain + outgoingAfter <- getMetric parseOutgoingRequestCount OwnDomain OtherDomain + assertBool "Incoming requests count should have increased by at least 2" $ incomingAfter >= incomingBefore + 2 + assertBool "Outgoing requests count should have increased by at least 2" $ outgoingAfter >= outgoingBefore + 2 + where + getMetric :: (Text -> Parser Integer) -> Domain -> Domain -> App Integer + getMetric p domain origin = do + m <- getMetrics domain federatorInternal + d <- cs <$> asString origin + pure $ fromRight 0 (parseOnly (p d) (cs m.body)) + + parseIncomingRequestCount :: Text -> Parser Integer + parseIncomingRequestCount d = + manyTill anyChar (string ("com_wire_federator_incoming_requests{origin_domain=\"" <> d <> "\"} ")) + *> decimal + + parseOutgoingRequestCount :: Text -> Parser Integer + parseOutgoingRequestCount d = + manyTill anyChar (string ("com_wire_federator_outgoing_requests{target_domain=\"" <> d <> "\"} ")) + *> decimal diff --git a/integration/test/Test/MLS.hs b/integration/test/Test/MLS.hs new file mode 100644 index 00000000000..5a7cc4a9163 --- /dev/null +++ b/integration/test/Test/MLS.hs @@ -0,0 +1,707 @@ +{-# OPTIONS_GHC -Wno-incomplete-uni-patterns #-} + +module Test.MLS where + +import API.Brig (claimKeyPackages, deleteClient) +import API.Galley +import Data.ByteString.Base64 qualified as Base64 +import Data.ByteString.Char8 qualified as B8 +import Data.Text.Encoding qualified as T +import MLS.Util +import Notifications +import SetupHelpers +import Testlib.Prelude + +testSendMessageNoReturnToSender :: HasCallStack => App () +testSendMessageNoReturnToSender = do + [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] + [alice1, alice2, bob1, bob2] <- traverse (createMLSClient def) [alice, alice, bob, bob] + traverse_ uploadNewKeyPackage [alice2, bob1, bob2] + void $ createNewGroup alice1 + void $ createAddCommit alice1 [alice, bob] >>= sendAndConsumeCommitBundle + + -- alice1 sends a message to the conversation, all clients but alice1 receive + -- the message + withWebSockets [alice1, alice2, bob1, bob2] $ \(wsSender : wss) -> do + mp <- createApplicationMessage alice1 "hello, bob" + void . bindResponse (postMLSMessage mp.sender mp.message) $ \resp -> do + resp.status `shouldMatchInt` 201 + for_ wss $ \ws -> do + n <- awaitMatch 3 (\n -> nPayload n %. "type" `isEqual` "conversation.mls-message-add") ws + nPayload n %. "data" `shouldMatch` T.decodeUtf8 (Base64.encode mp.message) + expectFailure (const $ pure ()) $ + awaitMatch + 3 + ( \n -> + liftM2 + (&&) + (nPayload n %. "type" `isEqual` "conversation.mls-message-add") + (nPayload n %. "data" `isEqual` T.decodeUtf8 (Base64.encode mp.message)) + ) + wsSender + +testStaleApplicationMessage :: HasCallStack => Domain -> App () +testStaleApplicationMessage otherDomain = do + [alice, bob, charlie, dave, eve] <- + createAndConnectUsers [OwnDomain, otherDomain, OwnDomain, OwnDomain, OwnDomain] + [alice1, bob1, charlie1] <- traverse (createMLSClient def) [alice, bob, charlie] + traverse_ uploadNewKeyPackage [bob1, charlie1] + void $ createNewGroup alice1 + + -- alice adds bob first + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + -- bob prepares some application messages + [msg1, msg2] <- replicateM 2 $ createApplicationMessage bob1 "hi alice" + + -- alice adds charlie and dave with different commits + void $ createAddCommit alice1 [charlie] >>= sendAndConsumeCommitBundle + void $ createAddCommit alice1 [dave] >>= sendAndConsumeCommitBundle + + -- bob's application messages still go through + void $ postMLSMessage bob1 msg1.message >>= getJSON 201 + + -- alice adds eve + void $ createAddCommit alice1 [eve] >>= sendAndConsumeCommitBundle + + -- bob's application messages are now rejected + void $ postMLSMessage bob1 msg2.message >>= getJSON 409 + +testMixedProtocolUpgrade :: HasCallStack => Domain -> App () +testMixedProtocolUpgrade secondDomain = do + (alice, tid, _) <- createTeam OwnDomain 1 + [bob, charlie] <- replicateM 2 (randomUser secondDomain def) + connectUsers [alice, bob, charlie] + + qcnv <- + postConversation + alice + defProteus + { qualifiedUsers = [bob, charlie], + team = Just tid + } + >>= getJSON 201 + + bindResponse (putConversationProtocol bob qcnv "mls") $ \resp -> do + resp.status `shouldMatchInt` 403 + + withWebSockets [alice, charlie] $ \websockets -> do + bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "conversation" `shouldMatch` (qcnv %. "id") + resp.json %. "data.protocol" `shouldMatch` "mixed" + + for_ websockets $ \ws -> do + n <- awaitMatch 3 (\value -> nPayload value %. "type" `isEqual` "conversation.protocol-update") ws + nPayload n %. "data.protocol" `shouldMatch` "mixed" + + bindResponse (getConversation alice qcnv) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "protocol" `shouldMatch` "mixed" + + bindResponse (putConversationProtocol alice qcnv "mixed") $ \resp -> do + resp.status `shouldMatchInt` 204 + + bindResponse (putConversationProtocol bob qcnv "proteus") $ \resp -> do + resp.status `shouldMatchInt` 403 + + bindResponse (putConversationProtocol bob qcnv "invalid") $ \resp -> do + resp.status `shouldMatchInt` 400 + +testMixedProtocolNonTeam :: HasCallStack => Domain -> App () +testMixedProtocolNonTeam secondDomain = do + [alice, bob] <- createAndConnectUsers [OwnDomain, secondDomain] + qcnv <- + postConversation alice defProteus {qualifiedUsers = [bob]} + >>= getJSON 201 + + bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do + resp.status `shouldMatchInt` 403 + +testMixedProtocolAddUsers :: HasCallStack => Domain -> App () +testMixedProtocolAddUsers secondDomain = do + (alice, tid, _) <- createTeam OwnDomain 1 + [bob, charlie] <- replicateM 2 (randomUser secondDomain def) + connectUsers [alice, bob, charlie] + + qcnv <- + postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} + >>= getJSON 201 + + bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do + resp.status `shouldMatchInt` 200 + + [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + + bindResponse (getConversation alice qcnv) $ \resp -> do + resp.status `shouldMatchInt` 200 + createGroup alice1 resp.json + + traverse_ uploadNewKeyPackage [bob1] + + withWebSocket bob $ \ws -> do + mp <- createAddCommit alice1 [bob] + welcome <- assertJust "should have welcome" mp.welcome + void $ sendAndConsumeCommitBundle mp + n <- awaitMatch 3 (\n -> nPayload n %. "type" `isEqual` "conversation.mls-welcome") ws + nPayload n %. "data" `shouldMatch` T.decodeUtf8 (Base64.encode welcome) + +testMixedProtocolUserLeaves :: HasCallStack => Domain -> App () +testMixedProtocolUserLeaves secondDomain = do + (alice, tid, _) <- createTeam OwnDomain 1 + bob <- randomUser secondDomain def + connectUsers [alice, bob] + + qcnv <- + postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} + >>= getJSON 201 + + bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do + resp.status `shouldMatchInt` 200 + + [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + + bindResponse (getConversation alice qcnv) $ \resp -> do + resp.status `shouldMatchInt` 200 + createGroup alice1 resp.json + + traverse_ uploadNewKeyPackage [bob1] + + mp <- createAddCommit alice1 [bob] + void $ sendAndConsumeCommitBundle mp + + withWebSocket alice $ \ws -> do + bindResponse (removeConversationMember bob qcnv) $ \resp -> + resp.status `shouldMatchInt` 200 + + n <- awaitMatch 3 (\n -> nPayload n %. "type" `isEqual` "conversation.mls-message-add") ws + + msg <- asByteString (nPayload n %. "data") >>= showMessage alice1 + let leafIndexBob = 1 + msg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexBob + msg %. "message.content.sender.External" `shouldMatchInt` 0 + +testMixedProtocolAddPartialClients :: HasCallStack => Domain -> App () +testMixedProtocolAddPartialClients secondDomain = do + (alice, tid, _) <- createTeam OwnDomain 1 + bob <- randomUser secondDomain def + connectUsers [alice, bob] + + qcnv <- + postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} + >>= getJSON 201 + + bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do + resp.status `shouldMatchInt` 200 + + [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] + + bindResponse (getConversation alice qcnv) $ \resp -> do + resp.status `shouldMatchInt` 200 + createGroup alice1 resp.json + + traverse_ uploadNewKeyPackage [bob1, bob1, bob2, bob2] + + -- create add commit for only one of bob's two clients + do + bundle <- claimKeyPackages def alice1 bob >>= getJSON 200 + kps <- unbundleKeyPackages bundle + kp1 <- assertOne (filter ((== bob1) . fst) kps) + mp <- createAddCommitWithKeyPackages alice1 [kp1] + void $ sendAndConsumeCommitBundle mp + + -- this tests that bob's backend has a mapping of group id to the remote conv + -- this test is only interesting when bob is on OtherDomain + do + bundle <- claimKeyPackages def bob1 bob >>= getJSON 200 + kps <- unbundleKeyPackages bundle + kp2 <- assertOne (filter ((== bob2) . fst) kps) + mp <- createAddCommitWithKeyPackages bob1 [kp2] + void $ postMLSCommitBundle mp.sender (mkBundle mp) >>= getJSON 201 + +testMixedProtocolRemovePartialClients :: HasCallStack => Domain -> App () +testMixedProtocolRemovePartialClients secondDomain = do + (alice, tid, _) <- createTeam OwnDomain 1 + bob <- randomUser secondDomain def + connectUsers [alice, bob] + + qcnv <- + postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} + >>= getJSON 201 + + bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do + resp.status `shouldMatchInt` 200 + + [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] + + bindResponse (getConversation alice qcnv) $ \resp -> do + resp.status `shouldMatchInt` 200 + createGroup alice1 resp.json + + traverse_ uploadNewKeyPackage [bob1, bob2] + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + mp <- createRemoveCommit alice1 [bob1] + + void $ postMLSCommitBundle mp.sender (mkBundle mp) >>= getJSON 201 + +testMixedProtocolAppMessagesAreDenied :: HasCallStack => Domain -> App () +testMixedProtocolAppMessagesAreDenied secondDomain = do + (alice, tid, _) <- createTeam OwnDomain 1 + bob <- randomUser secondDomain def + connectUsers [alice, bob] + + qcnv <- + postConversation alice defProteus {qualifiedUsers = [bob], team = Just tid} + >>= getJSON 201 + + bindResponse (putConversationProtocol bob qcnv "mixed") $ \resp -> do + resp.status `shouldMatchInt` 200 + + [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + + traverse_ uploadNewKeyPackage [bob1] + + bindResponse (getConversation alice qcnv) $ \resp -> do + resp.status `shouldMatchInt` 200 + createGroup alice1 resp.json + + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + mp <- createApplicationMessage bob1 "hello, world" + bindResponse (postMLSMessage mp.sender mp.message) $ \resp -> do + resp.status `shouldMatchInt` 422 + resp.json %. "label" `shouldMatch` "mls-unsupported-message" + +testMLSProtocolUpgrade :: HasCallStack => Domain -> App () +testMLSProtocolUpgrade secondDomain = do + (alice, bob, conv) <- simpleMixedConversationSetup secondDomain + charlie <- randomUser OwnDomain def + + -- alice creates MLS group and bob joins + [alice1, bob1, charlie1] <- traverse (createMLSClient def) [alice, bob, charlie] + createGroup alice1 conv + void $ createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle + void $ createExternalCommit bob1 Nothing >>= sendAndConsumeCommitBundle + + void $ withWebSocket bob $ \ws -> do + -- charlie is added to the group + void $ uploadNewKeyPackage charlie1 + void $ createAddCommit alice1 [charlie] >>= sendAndConsumeCommitBundle + awaitMatch 10 isNewMLSMessageNotif ws + + supportMLS alice + bindResponse (putConversationProtocol bob conv "mls") $ \resp -> do + resp.status `shouldMatchInt` 400 + resp.json %. "label" `shouldMatch` "mls-migration-criteria-not-satisfied" + bindResponse (getConversation alice conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "protocol" `shouldMatch` "mixed" + + supportMLS bob + + withWebSockets [alice1, bob1] $ \wss -> do + bindResponse (putConversationProtocol bob conv "mls") $ \resp -> do + resp.status `shouldMatchInt` 200 + for_ wss $ \ws -> do + n <- awaitMatch 3 isNewMLSMessageNotif ws + msg <- asByteString (nPayload n %. "data") >>= showMessage alice1 + let leafIndexCharlie = 2 + msg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexCharlie + msg %. "message.content.sender.External" `shouldMatchInt` 0 + + bindResponse (getConversation alice conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "protocol" `shouldMatch` "mls" + +testAddUserSimple :: HasCallStack => Ciphersuite -> CredentialType -> App () +testAddUserSimple suite ctype = do + setMLSCiphersuite suite + [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] + [alice1, bob1, bob2] <- traverse (createMLSClient def {credType = ctype}) [alice, bob, bob] + + traverse_ uploadNewKeyPackage [bob1, bob2] + (_, qcnv) <- createNewGroup alice1 + + resp <- createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + events <- resp %. "events" & asList + do + event <- assertOne events + shouldMatch (event %. "qualified_conversation") qcnv + shouldMatch (event %. "type") "conversation.member-join" + shouldMatch (event %. "from") (objId alice) + members <- event %. "data" %. "users" & asList + memberQids <- for members $ \mem -> mem %. "qualified_id" + bobQid <- bob %. "qualified_id" + shouldMatch memberQids [bobQid] + + -- check that bob can now see the conversation + convs <- getAllConvs bob + convIds <- traverse (%. "qualified_id") convs + void $ + assertBool + "Users added to an MLS group should find it when listing conversations" + (qcnv `elem` convIds) + +testRemoteAddUser :: HasCallStack => App () +testRemoteAddUser = do + [alice, bob, charlie] <- createAndConnectUsers [OwnDomain, OtherDomain, OwnDomain] + [alice1, bob1, charlie1] <- traverse (createMLSClient def) [alice, bob, charlie] + traverse_ uploadNewKeyPackage [bob1, charlie1] + (_, conv) <- createNewGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + bindResponse (updateConversationMember alice1 conv bob "wire_admin") $ \resp -> + resp.status `shouldMatchInt` 200 + + mp <- createAddCommit bob1 [charlie] + -- Support for remote admins is not implemeted yet, but this shows that add + -- proposal is being applied action + bindResponse (postMLSCommitBundle mp.sender (mkBundle mp)) $ \resp -> do + resp.status `shouldMatchInt` 500 + resp.json %. "label" `shouldMatch` "federation-not-implemented" + +testRemoteRemoveClient :: HasCallStack => App () +testRemoteRemoveClient = do + [alice, bob] <- createAndConnectUsers [OwnDomain, OtherDomain] + [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + void $ uploadNewKeyPackage bob1 + (_, conv) <- createNewGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + withWebSocket alice $ \wsAlice -> do + void $ deleteClient bob bob1.client >>= getBody 200 + let predicate n = nPayload n %. "type" `isEqual` "conversation.mls-message-add" + n <- awaitMatch 5 predicate wsAlice + shouldMatch (nPayload n %. "conversation") (objId conv) + shouldMatch (nPayload n %. "from") (objId bob) + + msg <- asByteString (nPayload n %. "data") >>= showMessage alice1 + let leafIndexBob = 1 + msg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` leafIndexBob + msg %. "message.content.sender.External" `shouldMatchInt` 0 + +testCreateSubConv :: HasCallStack => Ciphersuite -> App () +testCreateSubConv suite = do + setMLSCiphersuite suite + alice <- randomUser OwnDomain def + alice1 <- createMLSClient def alice + (_, conv) <- createNewGroup alice1 + bindResponse (getSubConversation alice conv "conference") $ \resp -> do + resp.status `shouldMatchInt` 200 + let tm = resp.json %. "epoch_timestamp" + tm `shouldMatch` Null + +testCreateSubConvProteus :: App () +testCreateSubConvProteus = do + alice <- randomUser OwnDomain def + conv <- bindResponse (postConversation alice defProteus) $ \resp -> do + resp.status `shouldMatchInt` 201 + resp.json + bindResponse (getSubConversation alice conv "conference") $ \resp -> + resp.status `shouldMatchInt` 404 + +-- FUTUREWORK: New clients should be adding themselves via external commits, and +-- they shouldn't be added by another client. Change the test so external +-- commits are used. +testSelfConversation :: App () +testSelfConversation = do + alice <- randomUser OwnDomain def + creator : others <- traverse (createMLSClient def) (replicate 3 alice) + traverse_ uploadNewKeyPackage others + (_, cnv) <- createSelfGroup creator + commit <- createAddCommit creator [alice] + welcome <- assertOne (toList commit.welcome) + + withWebSockets others $ \wss -> do + void $ sendAndConsumeCommitBundle commit + let isWelcome n = nPayload n %. "type" `isEqual` "conversation.mls-welcome" + for_ wss $ \ws -> do + n <- awaitMatch 3 isWelcome ws + shouldMatch (nPayload n %. "conversation") (objId cnv) + shouldMatch (nPayload n %. "from") (objId alice) + shouldMatch (nPayload n %. "data") (B8.unpack (Base64.encode welcome)) + +testJoinSubConv :: App () +testJoinSubConv = do + [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] + [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] + traverse_ uploadNewKeyPackage [bob1, bob2] + (_, qcnv) <- createNewGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + void $ createSubConv bob1 "conference" + + -- bob adds his first client to the subconversation + void $ createPendingProposalCommit bob1 >>= sendAndConsumeCommitBundle + sub' <- getSubConversation bob qcnv "conference" >>= getJSON 200 + do + tm <- sub' %. "epoch_timestamp" + assertBool "Epoch timestamp should not be null" (tm /= Null) + + -- now alice joins with her own client + void $ + createExternalCommit alice1 Nothing + >>= sendAndConsumeCommitBundle + +-- | FUTUREWORK: Don't allow partial adds, not even in the first commit +testFirstCommitAllowsPartialAdds :: HasCallStack => App () +testFirstCommitAllowsPartialAdds = do + alice <- randomUser OwnDomain def + + [alice1, alice2, alice3] <- traverse (createMLSClient def) [alice, alice, alice] + traverse_ uploadNewKeyPackage [alice1, alice2, alice2, alice3, alice3] + + (_, _qcnv) <- createNewGroup alice1 + + bundle <- claimKeyPackages def alice1 alice >>= getJSON 200 + kps <- unbundleKeyPackages bundle + + -- first commit only adds kp for alice2 (not alice2 and alice3) + mp <- createAddCommitWithKeyPackages alice1 (filter ((== alice2) . fst) kps) + bindResponse (postMLSCommitBundle mp.sender (mkBundle mp)) $ \resp -> do + resp.status `shouldMatchInt` 409 + resp.json %. "label" `shouldMatch` "mls-client-mismatch" + +testAddUserPartial :: HasCallStack => App () +testAddUserPartial = do + [alice, bob, charlie] <- createAndConnectUsers (replicate 3 OwnDomain) + + -- Bob has 3 clients, Charlie has 2 + alice1 <- createMLSClient def alice + bobClients@[_bob1, _bob2, bob3] <- replicateM 3 (createMLSClient def bob) + charlieClients <- replicateM 2 (createMLSClient def charlie) + + -- Only the first 2 clients of Bob's have uploaded key packages + traverse_ uploadNewKeyPackage (take 2 bobClients <> charlieClients) + + -- alice adds bob's first 2 clients + void $ createNewGroup alice1 + + -- alice sends a commit now, and should get a conflict error + kps <- fmap concat . for [bob, charlie] $ \user -> do + bundle <- claimKeyPackages def alice1 user >>= getJSON 200 + unbundleKeyPackages bundle + mp <- createAddCommitWithKeyPackages alice1 kps + + -- before alice can commit, bob3 uploads a key package + void $ uploadNewKeyPackage bob3 + + err <- postMLSCommitBundle mp.sender (mkBundle mp) >>= getJSON 409 + err %. "label" `shouldMatch` "mls-client-mismatch" + +-- | admin removes user from a conversation but doesn't list all clients +testRemoveClientsIncomplete :: HasCallStack => App () +testRemoveClientsIncomplete = do + [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] + + [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] + traverse_ uploadNewKeyPackage [bob1, bob2] + void $ createNewGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + mp <- createRemoveCommit alice1 [bob1] + + err <- postMLSCommitBundle mp.sender (mkBundle mp) >>= getJSON 409 + err %. "label" `shouldMatch` "mls-client-mismatch" + +testAdminRemovesUserFromConv :: HasCallStack => App () +testAdminRemovesUserFromConv = do + [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] + [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] + void $ createWireClient bob + traverse_ uploadNewKeyPackage [bob1, bob2] + (gid, qcnv) <- createNewGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + events <- createRemoveCommit alice1 [bob1, bob2] >>= sendAndConsumeCommitBundle + + do + event <- assertOne =<< asList (events %. "events") + event %. "qualified_conversation" `shouldMatch` qcnv + event %. "type" `shouldMatch` "conversation.member-leave" + event %. "from" `shouldMatch` objId alice + members <- event %. "data" %. "qualified_user_ids" & asList + bobQid <- bob %. "qualified_id" + shouldMatch members [bobQid] + + convs <- getAllConvs bob + convIds <- traverse (%. "qualified_id") convs + clients <- bindResponse (getGroupClients alice gid) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "client_ids" & asList + void $ assertOne clients + assertBool + "bob is not longer part of conversation after the commit" + (qcnv `notElem` convIds) + +testLocalWelcome :: HasCallStack => App () +testLocalWelcome = do + users@[alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] + + [alice1, bob1] <- traverse (createMLSClient def) users + + void $ uploadNewKeyPackage bob1 + + (_, qcnv) <- createNewGroup alice1 + + commit <- createAddCommit alice1 [bob] + Just welcome <- pure commit.welcome + + es <- withWebSocket bob1 $ \wsBob -> do + es <- sendAndConsumeCommitBundle commit + let isWelcome n = nPayload n %. "type" `isEqual` "conversation.mls-welcome" + + n <- awaitMatch 5 isWelcome wsBob + + shouldMatch (nPayload n %. "conversation") (objId qcnv) + shouldMatch (nPayload n %. "from") (objId alice) + shouldMatch (nPayload n %. "data") (B8.unpack (Base64.encode welcome)) + pure es + + event <- assertOne =<< asList (es %. "events") + event %. "type" `shouldMatch` "conversation.member-join" + event %. "conversation" `shouldMatch` objId qcnv + addedUser <- (event %. "data.users") >>= asList >>= assertOne + objQid addedUser `shouldMatch` objQid bob + +testStaleCommit :: HasCallStack => App () +testStaleCommit = do + (alice : users) <- createAndConnectUsers (replicate 5 OwnDomain) + let (users1, users2) = splitAt 2 users + + (alice1 : clients) <- traverse (createMLSClient def) (alice : users) + traverse_ uploadNewKeyPackage clients + void $ createNewGroup alice1 + + gsBackup <- getClientGroupState alice1 + + -- add the first batch of users to the conversation + void $ createAddCommit alice1 users1 >>= sendAndConsumeCommitBundle + + -- now roll back alice1 and try to add the second batch of users + setClientGroupState alice1 gsBackup + + mp <- createAddCommit alice1 users2 + bindResponse (postMLSCommitBundle mp.sender (mkBundle mp)) $ \resp -> do + resp.status `shouldMatchInt` 409 + resp.json %. "label" `shouldMatch` "mls-stale-message" + +testPropInvalidEpoch :: HasCallStack => App () +testPropInvalidEpoch = do + users@[_alice, bob, charlie, dee] <- createAndConnectUsers (replicate 4 OwnDomain) + [alice1, bob1, charlie1, dee1] <- traverse (createMLSClient def) users + void $ createNewGroup alice1 + + -- Add bob -> epoch 1 + void $ uploadNewKeyPackage bob1 + gsBackup <- getClientGroupState alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + gsBackup2 <- getClientGroupState alice1 + + -- try to send a proposal from an old epoch (0) + do + setClientGroupState alice1 gsBackup + void $ uploadNewKeyPackage dee1 + [prop] <- createAddProposals alice1 [dee] + bindResponse (postMLSMessage alice1 prop.message) $ \resp -> do + resp.status `shouldMatchInt` 409 + resp.json %. "label" `shouldMatch` "mls-stale-message" + + -- try to send a proposal from a newer epoch (2) + do + void $ uploadNewKeyPackage dee1 + void $ uploadNewKeyPackage charlie1 + setClientGroupState alice1 gsBackup2 + void $ createAddCommit alice1 [charlie] -- --> epoch 2 + [prop] <- createAddProposals alice1 [dee] + bindResponse (postMLSMessage alice1 prop.message) $ \resp -> do + resp.status `shouldMatchInt` 409 + resp.json %. "label" `shouldMatch` "mls-stale-message" + -- remove charlie from users expected to get a welcome message + modifyMLSState $ \mls -> mls {newMembers = mempty} + + -- alice send a well-formed proposal and commits it + void $ uploadNewKeyPackage dee1 + setClientGroupState alice1 gsBackup2 + createAddProposals alice1 [dee] >>= traverse_ sendAndConsumeMessage + void $ createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle + +--- | This test submits a ReInit proposal, which is currently ignored by the +-- backend, in order to check that unsupported proposal types are accepted. +testPropUnsupported :: HasCallStack => App () +testPropUnsupported = do + users@[_alice, bob] <- createAndConnectUsers (replicate 2 OwnDomain) + [alice1, bob1] <- traverse (createMLSClient def) users + void $ uploadNewKeyPackage bob1 + void $ createNewGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + mp <- createReInitProposal alice1 + + -- we cannot consume this message, because the membership tag is fake + void $ postMLSMessage mp.sender mp.message >>= getJSON 201 + +testAddUserBareProposalCommit :: HasCallStack => App () +testAddUserBareProposalCommit = do + [alice, bob] <- createAndConnectUsers (replicate 2 OwnDomain) + [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + (_, qcnv) <- createNewGroup alice1 + void $ uploadNewKeyPackage bob1 + void $ createAddCommit alice1 [] >>= sendAndConsumeCommitBundle + + createAddProposals alice1 [bob] + >>= traverse_ sendAndConsumeMessage + commit <- createPendingProposalCommit alice1 + void $ assertJust "Expected welcome" commit.welcome + void $ sendAndConsumeCommitBundle commit + + -- check that bob can now see the conversation + convs <- getAllConvs bob + convIds <- traverse (%. "qualified_id") convs + void $ + assertBool + "Users added to an MLS group should find it when listing conversations" + (qcnv `elem` convIds) + +testPropExistingConv :: HasCallStack => App () +testPropExistingConv = do + [alice, bob] <- createAndConnectUsers (replicate 2 OwnDomain) + [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + void $ uploadNewKeyPackage bob1 + void $ createNewGroup alice1 + void $ createAddCommit alice1 [] >>= sendAndConsumeCommitBundle + res <- createAddProposals alice1 [bob] >>= traverse sendAndConsumeMessage >>= assertOne + shouldBeEmpty (res %. "events") + +testCommitNotReferencingAllProposals :: HasCallStack => App () +testCommitNotReferencingAllProposals = do + users@[_alice, bob, charlie] <- createAndConnectUsers (replicate 3 OwnDomain) + + [alice1, bob1, charlie1] <- traverse (createMLSClient def) users + void $ createNewGroup alice1 + traverse_ uploadNewKeyPackage [bob1, charlie1] + void $ createAddCommit alice1 [] >>= sendAndConsumeCommitBundle + + gsBackup <- getClientGroupState alice1 + + -- create proposals for bob and charlie + createAddProposals alice1 [bob, charlie] + >>= traverse_ sendAndConsumeMessage + + -- now create a commit referencing only the first proposal + setClientGroupState alice1 gsBackup + commit <- createPendingProposalCommit alice1 + + -- send commit and expect and error + bindResponse (postMLSCommitBundle alice1 (mkBundle commit)) $ \resp -> do + resp.status `shouldMatchInt` 400 + resp.json %. "label" `shouldMatch` "mls-commit-missing-references" + +testUnsupportedCiphersuite :: HasCallStack => App () +testUnsupportedCiphersuite = do + setMLSCiphersuite (Ciphersuite "0x0002") + alice <- randomUser OwnDomain def + alice1 <- createMLSClient def alice + void $ createNewGroup alice1 + + mp <- createPendingProposalCommit alice1 + + bindResponse (postMLSCommitBundle alice1 (mkBundle mp)) $ \resp -> do + resp.status `shouldMatchInt` 400 + resp.json %. "label" `shouldMatch` "mls-protocol-error" diff --git a/integration/test/Test/MLS/KeyPackage.hs b/integration/test/Test/MLS/KeyPackage.hs new file mode 100644 index 00000000000..8f6cf9d20d3 --- /dev/null +++ b/integration/test/Test/MLS/KeyPackage.hs @@ -0,0 +1,77 @@ +module Test.MLS.KeyPackage where + +import API.Brig +import MLS.Util +import SetupHelpers +import Testlib.Prelude + +testDeleteKeyPackages :: App () +testDeleteKeyPackages = do + alice <- randomUser OwnDomain def + alice1 <- createMLSClient def alice + kps <- replicateM 3 (uploadNewKeyPackage alice1) + + -- add an extra non-existing key package to the delete request + let kps' = "4B701F521EBE82CEC4AD5CB67FDD8E1C43FC4868DE32D03933CE4993160B75E8" : kps + + bindResponse (deleteKeyPackages alice1 kps') $ \resp -> do + resp.status `shouldMatchInt` 201 + + bindResponse (countKeyPackages def alice1) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "count" `shouldMatchInt` 0 + +testKeyPackageMultipleCiphersuites :: App () +testKeyPackageMultipleCiphersuites = do + alice <- randomUser OwnDomain def + [alice1, alice2] <- replicateM 2 (createMLSClient def alice) + + kp <- uploadNewKeyPackage alice2 + + let suite = Ciphersuite "0xf031" + setMLSCiphersuite suite + void $ uploadNewKeyPackage alice2 + + -- count key packages with default ciphersuite + bindResponse (countKeyPackages def alice2) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "count" `shouldMatchInt` 1 + + -- claim key packages with default ciphersuite + bindResponse (claimKeyPackages def alice1 alice) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "key_packages.0.key_package_ref" `shouldMatch` kp + + -- count key package with the other ciphersuite + bindResponse (countKeyPackages suite alice2) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "count" `shouldMatchInt` 1 + +testKeyPackageCount :: HasCallStack => Ciphersuite -> App () +testKeyPackageCount cs = do + alice <- randomUser OwnDomain def + alice1 <- createMLSClient def alice + + bindResponse (countKeyPackages cs alice1) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "count" `shouldMatchInt` 0 + + setMLSCiphersuite cs + + let count = 10 + kps <- map fst <$> replicateM count (generateKeyPackage alice1) + void $ uploadKeyPackages alice1 kps >>= getBody 201 + + bindResponse (countKeyPackages cs alice1) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "count" `shouldMatchInt` count + +testUnsupportedCiphersuite :: HasCallStack => App () +testUnsupportedCiphersuite = do + setMLSCiphersuite (Ciphersuite "0x0002") + bob <- randomUser OwnDomain def + bob1 <- createMLSClient def bob + (kp, _) <- generateKeyPackage bob1 + bindResponse (uploadKeyPackages bob1 [kp]) $ \resp -> do + resp.status `shouldMatchInt` 400 + resp.json %. "label" `shouldMatch` "mls-protocol-error" diff --git a/integration/test/Test/MLS/Message.hs b/integration/test/Test/MLS/Message.hs new file mode 100644 index 00000000000..7282cfd700e --- /dev/null +++ b/integration/test/Test/MLS/Message.hs @@ -0,0 +1,85 @@ +{-# OPTIONS_GHC -Wno-ambiguous-fields #-} + +module Test.MLS.Message where + +import API.Gundeck +import MLS.Util +import Notifications +import SetupHelpers +import Testlib.Prelude + +-- | Test happy case of federated MLS message sending in both directions. +testApplicationMessage :: HasCallStack => App () +testApplicationMessage = do + -- local alice and alex, remote bob + [alice, alex, bob, betty] <- + createUsers + [OwnDomain, OwnDomain, OtherDomain, OtherDomain] + for_ [alex, bob, betty] $ \user -> connectTwoUsers alice user + + clients@[alice1, _alice2, alex1, _alex2, bob1, _bob2, _, _] <- + traverse + (createMLSClient def) + [alice, alice, alex, alex, bob, bob, betty, betty] + traverse_ uploadNewKeyPackage clients + void $ createNewGroup alice1 + + withWebSockets [alice, alex, bob, betty] $ \wss -> do + -- alice adds all other users (including her own client) + void $ createAddCommit alice1 [alice, alex, bob, betty] >>= sendAndConsumeCommitBundle + traverse_ (awaitMatch 10 isMemberJoinNotif) wss + + -- alex sends a message + void $ createApplicationMessage alex1 "hello" >>= sendAndConsumeMessage + traverse_ (awaitMatch 10 isNewMLSMessageNotif) wss + + -- bob sends a message + void $ createApplicationMessage bob1 "hey" >>= sendAndConsumeMessage + traverse_ (awaitMatch 10 isNewMLSMessageNotif) wss + +testAppMessageSomeReachable :: HasCallStack => App () +testAppMessageSomeReachable = do + alice1 <- startDynamicBackends [mempty] $ \[thirdDomain] -> do + ownDomain <- make OwnDomain & asString + otherDomain <- make OtherDomain & asString + [alice, bob, charlie] <- createAndConnectUsers [ownDomain, otherDomain, thirdDomain] + + [alice1, bob1, charlie1] <- traverse (createMLSClient def) [alice, bob, charlie] + traverse_ uploadNewKeyPackage [bob1, charlie1] + void $ createNewGroup alice1 + void $ withWebSocket charlie $ \ws -> do + void $ createAddCommit alice1 [bob, charlie] >>= sendAndConsumeCommitBundle + awaitMatch 10 isMemberJoinNotif ws + pure alice1 + + void $ createApplicationMessage alice1 "hi, bob!" >>= sendAndConsumeMessage + +testMessageNotifications :: HasCallStack => Domain -> App () +testMessageNotifications bobDomain = do + [alice, bob] <- createAndConnectUsers [OwnDomain, bobDomain] + + [alice1, alice2, bob1, bob2] <- traverse (createMLSClient def) [alice, alice, bob, bob] + bobClient <- bob1 %. "client_id" & asString + + traverse_ uploadNewKeyPackage [alice1, alice2, bob1, bob2] + + void $ createNewGroup alice1 + + void $ withWebSocket bob $ \ws -> do + void $ createAddCommit alice1 [alice, bob] >>= sendAndConsumeCommitBundle + awaitMatch 10 isMemberJoinNotif ws + + let get (opts :: GetNotifications) = do + notifs <- getNotifications bob opts {size = Just 10000} >>= getJSON 200 + notifs %. "has_more" `shouldMatch` False + length <$> (notifs %. "notifications" & asList) + + numNotifs <- get def + numNotifsClient <- get def {client = Just bobClient} + + void $ withWebSocket bob $ \ws -> do + void $ createApplicationMessage alice1 "hi bob" >>= sendAndConsumeMessage + awaitMatch 10 isNewMLSMessageNotif ws + + get def `shouldMatchInt` (numNotifs + 1) + get def {client = Just bobClient} `shouldMatchInt` (numNotifsClient + 1) diff --git a/integration/test/Test/MLS/One2One.hs b/integration/test/Test/MLS/One2One.hs new file mode 100644 index 00000000000..d23362beb9f --- /dev/null +++ b/integration/test/Test/MLS/One2One.hs @@ -0,0 +1,96 @@ +module Test.MLS.One2One where + +import API.Galley +import Data.ByteString.Base64 qualified as Base64 +import Data.ByteString.Char8 qualified as B8 +import MLS.Util +import Notifications +import SetupHelpers +import Testlib.Prelude + +testGetMLSOne2One :: HasCallStack => Domain -> App () +testGetMLSOne2One otherDomain = do + [alice, bob] <- createAndConnectUsers [OwnDomain, otherDomain] + + conv <- getMLSOne2OneConversation alice bob >>= getJSON 200 + conv %. "type" `shouldMatchInt` 2 + shouldBeEmpty (conv %. "members.others") + + conv %. "members.self.conversation_role" `shouldMatch` "wire_member" + conv %. "members.self.qualified_id" `shouldMatch` (alice %. "qualified_id") + + convId <- conv %. "qualified_id" + + -- check that the conversation has the same ID on the other side + conv2 <- bindResponse (getMLSOne2OneConversation bob alice) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json + + conv2 %. "type" `shouldMatchInt` 2 + conv2 %. "qualified_id" `shouldMatch` convId + conv2 %. "epoch" `shouldMatch` (conv %. "epoch") + +testGetMLSOne2OneUnconnected :: HasCallStack => Domain -> App () +testGetMLSOne2OneUnconnected otherDomain = do + [alice, bob] <- for [OwnDomain, otherDomain] $ \domain -> randomUser domain def + + bindResponse (getMLSOne2OneConversation alice bob) $ \resp -> + resp.status `shouldMatchInt` 403 + +testGetMLSOne2OneSameTeam :: App () +testGetMLSOne2OneSameTeam = do + (alice, _, _) <- createTeam OwnDomain 1 + bob <- addUserToTeam alice + void $ getMLSOne2OneConversation alice bob >>= getJSON 200 + +data One2OneScenario + = -- | Both users are local + One2OneScenarioLocal + | -- | One user is remote, conversation is local + One2OneScenarioLocalConv + | -- | One user is remote, conversation is remote + One2OneScenarioRemoteConv + +instance HasTests x => HasTests (One2OneScenario -> x) where + mkTests m n s f x = + mkTests m (n <> "[domain=own]") s f (x One2OneScenarioLocal) + <> mkTests m (n <> "[domain=other;conv=own]") s f (x One2OneScenarioLocalConv) + <> mkTests m (n <> "[domain=other;conv=other]") s f (x One2OneScenarioRemoteConv) + +one2OneScenarioDomain :: One2OneScenario -> Domain +one2OneScenarioDomain One2OneScenarioLocal = OwnDomain +one2OneScenarioDomain _ = OtherDomain + +one2OneScenarioConvDomain :: One2OneScenario -> Domain +one2OneScenarioConvDomain One2OneScenarioLocal = OwnDomain +one2OneScenarioConvDomain One2OneScenarioLocalConv = OwnDomain +one2OneScenarioConvDomain One2OneScenarioRemoteConv = OtherDomain + +testMLSOne2One :: HasCallStack => One2OneScenario -> App () +testMLSOne2One scenario = do + alice <- randomUser OwnDomain def + let otherDomain = one2OneScenarioDomain scenario + convDomain = one2OneScenarioConvDomain scenario + bob <- createMLSOne2OnePartner otherDomain alice convDomain + [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + traverse_ uploadNewKeyPackage [bob1] + + conv <- getMLSOne2OneConversation alice bob >>= getJSON 200 + resetGroup alice1 conv + + commit <- createAddCommit alice1 [bob] + withWebSocket bob1 $ \ws -> do + void $ sendAndConsumeCommitBundle commit + + let isWelcome n = nPayload n %. "type" `isEqual` "conversation.mls-welcome" + n <- awaitMatch 3 isWelcome ws + nPayload n %. "data" `shouldMatch` B8.unpack (Base64.encode (fold commit.welcome)) + + void $ awaitMatch 3 isMemberJoinNotif ws + + withWebSocket bob1 $ \ws -> do + mp <- createApplicationMessage alice1 "hello, world" + void $ sendAndConsumeMessage mp + let isMessage n = nPayload n %. "type" `isEqual` "conversation.mls-message-add" + n <- awaitMatch 3 isMessage ws + nPayload n %. "data" `shouldMatch` B8.unpack (Base64.encode mp.message) diff --git a/integration/test/Test/MLS/SubConversation.hs b/integration/test/Test/MLS/SubConversation.hs new file mode 100644 index 00000000000..ed5aa95c3d4 --- /dev/null +++ b/integration/test/Test/MLS/SubConversation.hs @@ -0,0 +1,181 @@ +module Test.MLS.SubConversation where + +import API.Galley +import MLS.Util +import Notifications +import SetupHelpers +import Testlib.Prelude + +testJoinSubConv :: App () +testJoinSubConv = do + [alice, bob] <- createAndConnectUsers [OwnDomain, OwnDomain] + [alice1, bob1, bob2] <- traverse (createMLSClient def) [alice, bob, bob] + traverse_ uploadNewKeyPackage [bob1, bob2] + (_, qcnv) <- createNewGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + createSubConv bob1 "conference" + + -- bob adds his first client to the subconversation + void $ createPendingProposalCommit bob1 >>= sendAndConsumeCommitBundle + sub' <- getSubConversation bob qcnv "conference" >>= getJSON 200 + do + tm <- sub' %. "epoch_timestamp" + assertBool "Epoch timestamp should not be null" (tm /= Null) + + -- now alice joins with her own client + void $ + createExternalCommit alice1 Nothing + >>= sendAndConsumeCommitBundle + +testDeleteParentOfSubConv :: HasCallStack => Domain -> App () +testDeleteParentOfSubConv secondDomain = do + (alice, tid, _) <- createTeam OwnDomain 1 + bob <- randomUser secondDomain def + connectUsers [alice, bob] + + [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + traverse_ uploadNewKeyPackage [alice1, bob1] + (_, qcnv) <- createNewGroup alice1 + withWebSocket bob $ \ws -> do + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + void $ awaitMatch 10 isMemberJoinNotif ws + + -- bob creates a subconversation and adds his own client + createSubConv bob1 "conference" + void $ createPendingProposalCommit bob1 >>= sendAndConsumeCommitBundle + + -- alice joins with her own client + void $ createExternalCommit alice1 Nothing >>= sendAndConsumeCommitBundle + + -- bob sends a message to the subconversation + do + mp <- createApplicationMessage bob1 "hello, alice" + void . bindResponse (postMLSMessage mp.sender mp.message) $ \resp -> do + resp.status `shouldMatchInt` 201 + + -- alice sends a message to the subconversation + do + mp <- createApplicationMessage bob1 "hello, bob" + void . bindResponse (postMLSMessage mp.sender mp.message) $ \resp -> do + resp.status `shouldMatchInt` 201 + + -- alice deletes main conversation + withWebSocket bob $ \ws -> do + void . bindResponse (deleteTeamConv tid qcnv alice) $ \resp -> do + resp.status `shouldMatchInt` 200 + void $ awaitMatch 3 isConvDeleteNotif ws + + -- bob fails to send a message to the subconversation + do + mp <- createApplicationMessage bob1 "hello, alice" + void . bindResponse (postMLSMessage mp.sender mp.message) $ \resp -> do + resp.status `shouldMatchInt` 404 + case secondDomain of + OwnDomain -> resp.json %. "label" `shouldMatch` "no-conversation" + OtherDomain -> resp.json %. "label" `shouldMatch` "no-conversation-member" + + -- alice fails to send a message to the subconversation + do + mp <- createApplicationMessage alice1 "hello, bob" + void . bindResponse (postMLSMessage mp.sender mp.message) $ \resp -> do + resp.status `shouldMatchInt` 404 + resp.json %. "label" `shouldMatch` "no-conversation" + +testDeleteSubConversation :: HasCallStack => Domain -> App () +testDeleteSubConversation otherDomain = do + [alice, bob] <- createAndConnectUsers [OwnDomain, otherDomain] + charlie <- randomUser OwnDomain def + [alice1, bob1] <- traverse (createMLSClient def) [alice, bob] + void $ uploadNewKeyPackage bob1 + (_, qcnv) <- createNewGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + createSubConv alice1 "conference1" + sub1 <- getSubConversation alice qcnv "conference1" >>= getJSON 200 + void $ deleteSubConversation charlie sub1 >>= getBody 403 + void $ deleteSubConversation alice sub1 >>= getBody 200 + + createSubConv alice1 "conference2" + sub2 <- getSubConversation alice qcnv "conference2" >>= getJSON 200 + void $ deleteSubConversation bob sub2 >>= getBody 200 + + sub2' <- getSubConversation alice1 qcnv "conference2" >>= getJSON 200 + sub2 `shouldNotMatch` sub2' + +data LeaveSubConvVariant = AliceLeaves | BobLeaves + +instance HasTests x => HasTests (LeaveSubConvVariant -> x) where + mkTests m n s f x = + mkTests m (n <> "[leaver=alice]") s f (x AliceLeaves) + <> mkTests m (n <> "[leaver=bob]") s f (x BobLeaves) + +testLeaveSubConv :: HasCallStack => LeaveSubConvVariant -> App () +testLeaveSubConv variant = do + [alice, bob, charlie] <- createAndConnectUsers [OwnDomain, OwnDomain, OtherDomain] + clients@[alice1, bob1, bob2, charlie1] <- traverse (createMLSClient def) [alice, bob, bob, charlie] + traverse_ uploadNewKeyPackage [bob1, bob2, charlie1] + void $ createNewGroup alice1 + + withWebSockets [bob, charlie] $ \wss -> do + void $ createAddCommit alice1 [bob, charlie] >>= sendAndConsumeCommitBundle + traverse_ (awaitMatch 10 isMemberJoinNotif) wss + + createSubConv bob1 "conference" + void $ createExternalCommit alice1 Nothing >>= sendAndConsumeCommitBundle + void $ createExternalCommit bob2 Nothing >>= sendAndConsumeCommitBundle + void $ createExternalCommit charlie1 Nothing >>= sendAndConsumeCommitBundle + + -- a member leaves the subconversation + let (firstLeaver, idxFirstLeaver) = case variant of + BobLeaves -> (bob1, 0) + AliceLeaves -> (alice1, 1) + let idxCharlie1 = 3 + + let others = filter (/= firstLeaver) clients + withWebSockets others $ \wss -> do + leaveCurrentConv firstLeaver + + for_ (zip others wss) $ \(cid, ws) -> do + notif <- awaitMatch 10 isNewMLSMessageNotif ws + msgData <- notif %. "payload.0.data" & asByteString + msg <- showMessage alice1 msgData + msg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` idxFirstLeaver + msg %. "message.content.sender.External" `shouldMatchInt` 0 + consumeMessage1 cid msgData + + withWebSockets (tail others) $ \wss -> do + -- a member commits the pending proposal + void $ createPendingProposalCommit (head others) >>= sendAndConsumeCommitBundle + traverse_ (awaitMatch 10 isNewMLSMessageNotif) wss + + -- send an application message + void $ createApplicationMessage (head others) "good riddance" >>= sendAndConsumeMessage + traverse_ (awaitMatch 10 isNewMLSMessageNotif) wss + + -- check that only 3 clients are left in the subconv + do + conv <- getCurrentConv (head others) + mems <- conv %. "members" & asList + length mems `shouldMatchInt` 3 + + -- charlie1 leaves + let others' = filter (/= charlie1) others + withWebSockets others' $ \wss -> do + leaveCurrentConv charlie1 + + for_ (zip others' wss) $ \(cid, ws) -> do + notif <- awaitMatch 10 isNewMLSMessageNotif ws + msgData <- notif %. "payload.0.data" & asByteString + msg <- showMessage alice1 msgData + msg %. "message.content.body.Proposal.Remove.removed" `shouldMatchInt` idxCharlie1 + msg %. "message.content.sender.External" `shouldMatchInt` 0 + consumeMessage1 cid msgData + + -- a member commits the pending proposal + void $ createPendingProposalCommit (head others') >>= sendAndConsumeCommitBundle + + -- check that only 2 clients are left in the subconv + do + conv <- getCurrentConv (head others) + mems <- conv %. "members" & asList + length mems `shouldMatchInt` 2 diff --git a/integration/test/Test/MessageTimer.hs b/integration/test/Test/MessageTimer.hs new file mode 100644 index 00000000000..7a8aff06c87 --- /dev/null +++ b/integration/test/Test/MessageTimer.hs @@ -0,0 +1,53 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.MessageTimer where + +import API.Galley +import Control.Monad.Codensity +import Control.Monad.Reader +import GHC.Stack +import Notifications +import SetupHelpers +import Testlib.Prelude +import Testlib.ResourcePool + +testMessageTimerChangeWithRemotes :: HasCallStack => App () +testMessageTimerChangeWithRemotes = do + [alice, bob] <- createAndConnectUsers [OwnDomain, OtherDomain] + conv <- postConversation alice defProteus {qualifiedUsers = [bob]} >>= getJSON 201 + withWebSockets [alice, bob] $ \wss -> do + void $ updateMessageTimer alice conv 1000 >>= getBody 200 + for_ wss $ \ws -> do + notif <- awaitMatch 10 isConvMsgTimerUpdateNotif ws + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.qualified_from" `shouldMatch` objQidObject alice + +testMessageTimerChangeWithUnreachableRemotes :: HasCallStack => App () +testMessageTimerChangeWithUnreachableRemotes = do + resourcePool <- asks resourcePool + alice <- randomUser OwnDomain def + conv <- runCodensity (acquireResources 1 resourcePool) $ \[dynBackend] -> + runCodensity (startDynamicBackend dynBackend mempty) $ \_ -> do + bob <- randomUser dynBackend.berDomain def + connectTwoUsers alice bob + postConversation alice (defProteus {qualifiedUsers = [bob]}) >>= getJSON 201 + withWebSocket alice $ \ws -> do + void $ updateMessageTimer alice conv 1000 >>= getBody 200 + notif <- awaitMatch 10 isConvMsgTimerUpdateNotif ws + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.qualified_from" `shouldMatch` objQidObject alice diff --git a/integration/test/Test/Notifications.hs b/integration/test/Test/Notifications.hs index 5e6de9f3a59..741d9b10b0a 100644 --- a/integration/test/Test/Notifications.hs +++ b/integration/test/Test/Notifications.hs @@ -1,3 +1,4 @@ +{-# OPTIONS -Wno-ambiguous-fields #-} module Test.Notifications where import API.Common @@ -25,18 +26,27 @@ testFetchAllNotifications = do bindResponse (postPush user [push]) $ \res -> res.status `shouldMatchInt` 200 - let client = "deadbeeef" - ns <- getNotifications user client def >>= getJSON 200 + let c :: Maybe String = Just "deadbeef" + ns <- getNotifications user (def {client = c} :: GetNotifications) >>= getJSON 200 expected <- replicateM n (push %. "payload") allNotifs <- ns %. "notifications" & asList actual <- traverse (%. "payload") allNotifs actual `shouldMatch` expected - firstNotif <- getNotification user client (head allNotifs %. "id") >>= getJSON 200 + firstNotif <- + getNotification + user + (def {client = c} :: GetNotification) + (head allNotifs %. "id") + >>= getJSON 200 firstNotif `shouldMatch` head allNotifs - lastNotif <- getLastNotification user client >>= getJSON 200 + lastNotif <- + getLastNotification + user + (def {client = c} :: GetNotification) + >>= getJSON 200 lastNotif `shouldMatch` last allNotifs testLastNotification :: App () @@ -59,5 +69,23 @@ testLastNotification = do bindResponse (postPush user [push c]) $ \res -> res.status `shouldMatchInt` 200 - lastNotif <- getLastNotification user "c" >>= getJSON 200 + lastNotif <- getLastNotification user def {client = Just "c"} >>= getJSON 200 lastNotif %. "payload" `shouldMatch` [object ["client" .= "c"]] + +testInvalidNotification :: HasCallStack => App () +testInvalidNotification = do + user <- randomUserId OwnDomain + + -- test uuid v4 as "since" + do + notifId <- randomId + void $ + getNotifications user def {since = Just notifId} + >>= getJSON 400 + + -- test arbitrary uuid v1 as "since" + do + notifId <- randomUUIDv1 + void $ + getNotifications user def {since = Just notifId} + >>= getJSON 404 diff --git a/integration/test/Test/Presence.hs b/integration/test/Test/Presence.hs index e2bc211b598..a733d2bb539 100644 --- a/integration/test/Test/Presence.hs +++ b/integration/test/Test/Presence.hs @@ -1,3 +1,4 @@ +{-# OPTIONS -Wno-ambiguous-fields #-} module Test.Presence where import API.Common @@ -52,6 +53,6 @@ testRemoveUser = do -- check that notifications are deleted do - ns <- getNotifications alice c def >>= getJSON 200 + ns <- getNotifications alice def {client = Just c} >>= getJSON 200 ns %. "notifications" `shouldMatch` ([] :: [Value]) ns %. "has_more" `shouldMatch` False diff --git a/integration/test/Test/Roles.hs b/integration/test/Test/Roles.hs new file mode 100644 index 00000000000..1fde4b95c4b --- /dev/null +++ b/integration/test/Test/Roles.hs @@ -0,0 +1,66 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Roles where + +import API.Galley +import Control.Monad.Reader +import GHC.Stack +import Notifications +import SetupHelpers +import Testlib.Prelude + +testRoleUpdateWithRemotesOk :: HasCallStack => App () +testRoleUpdateWithRemotesOk = do + [bob, charlie, alice] <- createUsers [OwnDomain, OwnDomain, OtherDomain] + connectTwoUsers bob charlie + connectTwoUsers bob alice + conv <- + postConversation bob (defProteus {qualifiedUsers = [charlie, alice]}) + >>= getJSON 201 + adminRole <- make "wire_admin" + + withWebSockets [bob, charlie, alice] $ \wss -> do + void $ updateRole bob charlie adminRole conv >>= getBody 200 + bindResponse (getConversation bob conv) $ \resp -> do + resp.status `shouldMatchInt` 200 + resp.json %. "members.others.0.qualified_id" `shouldMatch` objQidObject charlie + resp.json %. "members.others.0.conversation_role" `shouldMatch` "wire_admin" + for_ wss $ \ws -> do + notif <- awaitMatch 10 isMemberUpdateNotif ws + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.qualified_from" `shouldMatch` objQidObject bob + +testRoleUpdateWithRemotesUnreachable :: HasCallStack => App () +testRoleUpdateWithRemotesUnreachable = do + [bob, charlie] <- createUsers [OwnDomain, OwnDomain] + startDynamicBackends [mempty] $ \[dynBackend] -> do + alice <- randomUser dynBackend def + connectTwoUsers bob alice + connectTwoUsers bob charlie + conv <- + postConversation bob (defProteus {qualifiedUsers = [charlie, alice]}) + >>= getJSON 201 + adminRole <- make "wire_admin" + + withWebSockets [bob, charlie] $ \wss -> do + void $ updateRole bob charlie adminRole conv >>= getBody 200 + + for_ wss $ \ws -> do + notif <- awaitMatch 10 isMemberUpdateNotif ws + notif %. "payload.0.qualified_conversation" `shouldMatch` objQidObject conv + notif %. "payload.0.qualified_from" `shouldMatch` objQidObject bob diff --git a/integration/test/Testlib/App.hs b/integration/test/Testlib/App.hs index a7018959898..3ca68cac789 100644 --- a/integration/test/Testlib/App.hs +++ b/integration/test/Testlib/App.hs @@ -10,7 +10,6 @@ import Data.Yaml qualified as Yaml import GHC.Exception import GHC.Stack (HasCallStack) import System.FilePath -import Testlib.Env import Testlib.JSON import Testlib.Types import Prelude diff --git a/integration/test/Testlib/Assertions.hs b/integration/test/Testlib/Assertions.hs index 1d7a624d0a2..39396ec951e 100644 --- a/integration/test/Testlib/Assertions.hs +++ b/integration/test/Testlib/Assertions.hs @@ -5,11 +5,21 @@ module Testlib.Assertions where import Control.Exception as E import Control.Monad.Reader import Data.Aeson (Value) +import Data.Aeson qualified as Aeson +import Data.Aeson.Encode.Pretty qualified as Aeson +import Data.ByteString.Base64 qualified as B64 +import Data.ByteString.Lazy qualified as BS import Data.Char import Data.Foldable +import Data.Hex import Data.List import Data.Map qualified as Map +import Data.Text qualified as Text +import Data.Text.Encoding qualified as Text +import Data.Text.Lazy qualified as TL +import Data.Text.Lazy.Encoding qualified as TL import GHC.Stack as Stack +import Network.HTTP.Client qualified as HTTP import System.FilePath import Testlib.JSON import Testlib.Printing @@ -20,9 +30,10 @@ assertBool :: HasCallStack => String -> Bool -> App () assertBool _ True = pure () assertBool msg False = assertFailure msg -assertOne :: HasCallStack => [a] -> App a -assertOne [x] = pure x -assertOne xs = assertFailure ("Expected one, but got " <> show (length xs)) +assertOne :: (HasCallStack, Foldable t) => t a -> App a +assertOne xs = case toList xs of + [x] -> pure x + other -> assertFailure ("Expected one, but got " <> show (length other)) expectFailure :: HasCallStack => (AssertionFailure -> App ()) -> App a -> App () expectFailure checkFailure action = do @@ -49,6 +60,17 @@ a `shouldMatch` b = do pb <- prettyJSON xb assertFailure $ "Actual:\n" <> pa <> "\nExpected:\n" <> pb +shouldMatchBase64 :: + (MakesValue a, MakesValue b, HasCallStack) => + -- | The actual value, in base64 + a -> + -- | The expected value, in plain text + b -> + App () +a `shouldMatchBase64` b = do + xa <- Text.decodeUtf8 . B64.decodeLenient . Text.encodeUtf8 . Text.pack <$> asString a + xa `shouldMatch` b + shouldNotMatch :: (MakesValue a, MakesValue b, HasCallStack) => -- | The actual value @@ -92,7 +114,7 @@ shouldMatchRange :: (Int, Int) -> App () shouldMatchRange a (lower, upper) = do - xa :: Int <- asInt a + xa :: Int <- asIntegral a when (xa < lower || xa > upper) $ do pa <- prettyJSON xa assertFailure $ "Actual:\n" <> pa <> "\nExpected:\nin range (" <> show lower <> ", " <> show upper <> ") (including bounds)" @@ -107,6 +129,22 @@ shouldMatchSet a b = do lb <- fmap sort (asList b) la `shouldMatch` lb +shouldBeEmpty :: (MakesValue a, HasCallStack) => a -> App () +shouldBeEmpty a = a `shouldMatch` (mempty :: [Value]) + +shouldMatchOneOf :: + (MakesValue a, MakesValue b, HasCallStack) => + a -> + b -> + App () +shouldMatchOneOf a b = do + lb <- asList b + xa <- make a + unless (xa `elem` lb) $ do + pa <- prettyJSON a + pb <- prettyJSON b + assertFailure $ "Expected:\n" <> pa <> "\n to match at least one of:\n" <> pb + shouldContainString :: HasCallStack => -- | The actual value @@ -118,22 +156,6 @@ super `shouldContainString` sub = do unless (sub `isInfixOf` super) $ do assertFailure $ "String:\n" <> show super <> "\nDoes not contain:\n" <> show sub -liftP2 :: - (MakesValue a, MakesValue b, HasCallStack) => - (Value -> Value -> c) -> - a -> - b -> - App c -liftP2 f a b = do - f <$> make a <*> make b - -isEqual :: - (MakesValue a, MakesValue b, HasCallStack) => - a -> - b -> - App Bool -isEqual = liftP2 (==) - printFailureDetails :: AssertionFailure -> IO String printFailureDetails (AssertionFailure stack mbResponse msg) = do s <- prettierCallStack stack @@ -231,3 +253,27 @@ getLineNumber lineNo s = case drop (lineNo - 1) (lines s) of [] -> Nothing (l : _) -> pure l + +prettyResponse :: Response -> String +prettyResponse r = + unlines $ + concat + [ pure $ colored yellow "request: \n" <> showRequest r.request, + pure $ colored yellow "request headers: \n" <> showHeaders (HTTP.requestHeaders r.request), + case getRequestBody r.request of + Nothing -> [] + Just b -> + [ colored yellow "request body:", + Text.unpack . Text.decodeUtf8 $ case Aeson.decode (BS.fromStrict b) of + Just v -> BS.toStrict (Aeson.encodePretty (v :: Aeson.Value)) + Nothing -> hex b + ], + pure $ colored blue "response status: " <> show r.status, + pure $ colored blue "response body:", + pure $ + ( TL.unpack . TL.decodeUtf8 $ + case r.jsonBody of + Just b -> (Aeson.encodePretty b) + Nothing -> BS.fromStrict r.body + ) + ] diff --git a/integration/test/Testlib/Cannon.hs b/integration/test/Testlib/Cannon.hs index 418823a9ab3..4fefd3dfe3f 100644 --- a/integration/test/Testlib/Cannon.hs +++ b/integration/test/Testlib/Cannon.hs @@ -22,13 +22,20 @@ module Testlib.Cannon ( WebSocket (..), WSConnect (..), ToWSConnect (..), + AwaitResult (..), withWebSocket, withWebSockets, awaitNMatchesResult, awaitNMatches, awaitMatch, + awaitAtLeastNMatchesResult, + awaitAtLeastNMatches, + awaitNToMMatchesResult, + awaitNToMMatches, + assertAwaitResult, nPayload, printAwaitResult, + printAwaitAtLeastResult, ) where @@ -225,6 +232,14 @@ data AwaitResult = AwaitResult nonMatches :: [Value] } +data AwaitAtLeastResult = AwaitAtLeastResult + { success :: Bool, + nMatchesExpectedMin :: Int, + nMatchesExpectedMax :: Maybe Int, + matches :: [Value], + nonMatches :: [Value] + } + prettyAwaitResult :: AwaitResult -> App String prettyAwaitResult r = do matchesS <- for r.matches prettyJSON @@ -240,9 +255,29 @@ prettyAwaitResult r = do ] ) +prettyAwaitAtLeastResult :: AwaitAtLeastResult -> App String +prettyAwaitAtLeastResult r = do + matchesS <- for r.matches prettyJSON + nonMatchesS <- for r.nonMatches prettyJSON + pure $ + "AwaitAtLeastResult\n" + <> indent + 4 + ( unlines + [ "min expected:" <> show r.nMatchesExpectedMin, + "max expected:" <> show r.nMatchesExpectedMax, + "success: " <> show (r.success), + "matches:\n" <> unlines matchesS, + "non-matches:\n" <> unlines nonMatchesS + ] + ) + printAwaitResult :: AwaitResult -> App () printAwaitResult = prettyAwaitResult >=> liftIO . putStrLn +printAwaitAtLeastResult :: AwaitAtLeastResult -> App () +printAwaitAtLeastResult = prettyAwaitAtLeastResult >=> liftIO . putStrLn + awaitAnyEvent :: MonadIO m => Int -> WebSocket -> m (Maybe Value) awaitAnyEvent tSecs = liftIO . timeout (tSecs * 1000 * 1000) . atomically . readTChan . wsChan @@ -293,6 +328,74 @@ awaitNMatchesResult nExpected tSecs checkMatch ws = go nExpected [] [] } refill = mapM_ (liftIO . atomically . writeTChan (wsChan ws)) +awaitAtLeastNMatchesResult :: + HasCallStack => + -- | Minimum number of matches + Int -> + -- | Timeout in seconds + Int -> + -- | Selection function. Exceptions are *not* caught. + (Value -> App Bool) -> + WebSocket -> + App AwaitAtLeastResult +awaitAtLeastNMatchesResult nExpected tSecs checkMatch ws = go 0 [] [] + where + go nSeen nonMatches matches = do + mEvent <- awaitAnyEvent tSecs ws + case mEvent of + Just event -> + do + isMatch <- checkMatch event + if isMatch + then go (nSeen + 1) nonMatches (event : matches) + else go nSeen (event : nonMatches) matches + Nothing -> do + refill nonMatches + pure $ + AwaitAtLeastResult + { success = nSeen >= nExpected, + nMatchesExpectedMin = nExpected, + nMatchesExpectedMax = Nothing, + matches = reverse matches, + nonMatches = reverse nonMatches + } + refill = mapM_ (liftIO . atomically . writeTChan (wsChan ws)) + +awaitNToMMatchesResult :: + HasCallStack => + -- | Minimum number of matches + Int -> + -- | Maximum number of matches + Int -> + -- | Timeout in seconds + Int -> + -- | Selection function. Exceptions are *not* caught. + (Value -> App Bool) -> + WebSocket -> + App AwaitAtLeastResult +awaitNToMMatchesResult nMin nMax tSecs checkMatch ws = go 0 [] [] + where + go nSeen nonMatches matches = do + mEvent <- awaitAnyEvent tSecs ws + case mEvent of + Just event -> + do + isMatch <- checkMatch event + if isMatch + then go (nSeen + 1) nonMatches (event : matches) + else go nSeen (event : nonMatches) matches + Nothing -> do + refill nonMatches + pure $ + AwaitAtLeastResult + { success = nMin <= nSeen && nSeen <= nMax, + nMatchesExpectedMin = nMin, + nMatchesExpectedMax = pure nMax, + matches = reverse matches, + nonMatches = reverse nonMatches + } + refill = mapM_ (liftIO . atomically . writeTChan (wsChan ws)) + awaitNMatches :: HasCallStack => -- | Number of matches @@ -305,13 +408,57 @@ awaitNMatches :: App [Value] awaitNMatches nExpected tSecs checkMatch ws = do res <- awaitNMatchesResult nExpected tSecs checkMatch ws + assertAwaitResult res + +assertAwaitResult :: HasCallStack => AwaitResult -> App [Value] +assertAwaitResult res = do if res.success then pure res.matches else do - let msgHeader = "Expected " <> show nExpected <> " matching events, but got " <> show (length res.matches) <> "." + let msgHeader = "Expected " <> show res.nMatchesExpected <> " matching events, but got " <> show (length res.matches) <> "." details <- ("Details:\n" <>) <$> prettyAwaitResult res assertFailure $ unlines [msgHeader, details] +awaitAtLeastNMatches :: + HasCallStack => + -- | Minumum number of matches + Int -> + -- | Timeout in seconds + Int -> + -- | Selection function. Should not throw any exceptions + (Value -> App Bool) -> + WebSocket -> + App [Value] +awaitAtLeastNMatches nExpected tSecs checkMatch ws = do + res <- awaitAtLeastNMatchesResult nExpected tSecs checkMatch ws + if res.success + then pure res.matches + else do + let msgHeader = "Expected " <> show nExpected <> " matching events, but got " <> show (length res.matches) <> "." + details <- ("Details:\n" <>) <$> prettyAwaitAtLeastResult res + assertFailure $ unlines [msgHeader, details] + +awaitNToMMatches :: + HasCallStack => + -- | Minimum Number of matches + Int -> + -- | Maximum Number of matches + Int -> + -- | Timeout in seconds + Int -> + -- | Selection function. Should not throw any exceptions + (Value -> App Bool) -> + WebSocket -> + App [Value] +awaitNToMMatches nMin nMax tSecs checkMatch ws = do + res <- awaitNToMMatchesResult nMin nMax tSecs checkMatch ws + if res.success + then pure res.matches + else do + let msgHeader = "Expected between" <> show nMin <> " to " <> show nMax <> " matching events, but got " <> show (length res.matches) <> "." + details <- ("Details:\n" <>) <$> prettyAwaitAtLeastResult res + assertFailure $ unlines [msgHeader, details] + awaitMatch :: HasCallStack => -- | Timeout in seconds diff --git a/integration/test/Testlib/Env.hs b/integration/test/Testlib/Env.hs index d0a6a541850..40fd56adc8a 100644 --- a/integration/test/Testlib/Env.hs +++ b/integration/test/Testlib/Env.hs @@ -4,18 +4,15 @@ module Testlib.Env where import Control.Monad.Codensity import Control.Monad.IO.Class -import Data.Aeson hiding ((.=)) -import Data.ByteString (ByteString) +import Data.Default +import Data.Function ((&)) import Data.Functor import Data.IORef -import Data.Map (Map) import Data.Map qualified as Map import Data.Set (Set) import Data.Set qualified as Set -import Data.String -import Data.Word import Data.Yaml qualified as Yaml -import GHC.Generics +import Database.CQL.IO qualified as Cassandra import Network.HTTP.Client qualified as HTTP import System.Exit import System.FilePath @@ -23,126 +20,9 @@ import System.IO import System.IO.Temp import Testlib.Prekeys import Testlib.ResourcePool +import Testlib.Types import Prelude --- | Initialised once per test. -data Env = Env - { serviceMap :: Map String ServiceMap, - domain1 :: String, - domain2 :: String, - dynamicDomains :: [String], - defaultAPIVersion :: Int, - manager :: HTTP.Manager, - servicesCwdBase :: Maybe FilePath, - removalKeyPath :: FilePath, - prekeys :: IORef [(Int, String)], - lastPrekeys :: IORef [String], - mls :: IORef MLSState, - resourcePool :: ResourcePool BackendResource - } - --- | Initialised once per testsuite. -data GlobalEnv = GlobalEnv - { gServiceMap :: Map String ServiceMap, - gDomain1 :: String, - gDomain2 :: String, - gDynamicDomains :: [String], - gDefaultAPIVersion :: Int, - gManager :: HTTP.Manager, - gServicesCwdBase :: Maybe FilePath, - gRemovalKeyPath :: FilePath, - gBackendResourcePool :: ResourcePool BackendResource - } - -data IntegrationConfig = IntegrationConfig - { backendOne :: BackendConfig, - backendTwo :: BackendConfig, - dynamicBackends :: Map String DynamicBackendConfig - } - deriving (Show, Generic) - -instance FromJSON IntegrationConfig where - parseJSON = - withObject "IntegrationConfig" $ \o -> - IntegrationConfig - <$> parseJSON (Object o) - <*> o .: "backendTwo" - <*> o .: "dynamicBackends" - -data ServiceMap = ServiceMap - { brig :: HostPort, - backgroundWorker :: HostPort, - cannon :: HostPort, - cargohold :: HostPort, - federatorInternal :: HostPort, - federatorExternal :: HostPort, - galley :: HostPort, - gundeck :: HostPort, - nginz :: HostPort, - spar :: HostPort, - proxy :: HostPort, - stern :: HostPort - } - deriving (Show, Generic) - -instance FromJSON ServiceMap - -data BackendConfig = BackendConfig - { beServiceMap :: ServiceMap, - originDomain :: String - } - deriving (Show, Generic) - -instance FromJSON BackendConfig where - parseJSON v = - BackendConfig - <$> parseJSON v - <*> withObject "BackendConfig" (\ob -> ob .: fromString "originDomain") v - -data HostPort = HostPort - { host :: String, - port :: Word16 - } - deriving (Show, Generic) - -instance FromJSON HostPort - -data Service = Brig | Galley | Cannon | Gundeck | Cargohold | Nginz | Spar | BackgroundWorker | Stern | FederatorInternal - deriving - ( Show, - Eq, - Ord, - Enum, - Bounded - ) - -serviceName :: Service -> String -serviceName = \case - Brig -> "brig" - Galley -> "galley" - Cannon -> "cannon" - Gundeck -> "gundeck" - Cargohold -> "cargohold" - Nginz -> "nginz" - Spar -> "spar" - BackgroundWorker -> "backgroundWorker" - Stern -> "stern" - FederatorInternal -> "federator" - --- | Converts the service name to kebab-case. -configName :: Service -> String -configName = \case - Brig -> "brig" - Galley -> "galley" - Cannon -> "cannon" - Gundeck -> "gundeck" - Cargohold -> "cargohold" - Nginz -> "nginz" - Spar -> "spar" - BackgroundWorker -> "background-worker" - Stern -> "stern" - FederatorInternal -> "federator" - serviceHostPort :: ServiceMap -> Service -> HostPort serviceHostPort m Brig = m.brig serviceHostPort m Galley = m.galley @@ -155,10 +35,10 @@ serviceHostPort m BackgroundWorker = m.backgroundWorker serviceHostPort m Stern = m.stern serviceHostPort m FederatorInternal = m.federatorInternal -mkGlobalEnv :: FilePath -> IO GlobalEnv +mkGlobalEnv :: FilePath -> Codensity IO GlobalEnv mkGlobalEnv cfgFile = do - eith <- Yaml.decodeFileEither cfgFile - intConfig <- case eith of + eith <- liftIO $ Yaml.decodeFileEither cfgFile + intConfig <- liftIO $ case eith of Left err -> do hPutStrLn stderr $ "Could not parse " <> cfgFile <> ": " <> Yaml.prettyPrintParseException err exitFailure @@ -171,8 +51,19 @@ mkGlobalEnv cfgFile = do then Just (joinPath (init ps)) else Nothing - manager <- HTTP.newManager HTTP.defaultManagerSettings - resourcePool <- createBackendResourcePool (Map.elems intConfig.dynamicBackends) + manager <- liftIO $ HTTP.newManager HTTP.defaultManagerSettings + let cassSettings = + Cassandra.defSettings + & Cassandra.setContacts intConfig.cassandra.host [] + & Cassandra.setPortNumber (fromIntegral intConfig.cassandra.port) + cassClient <- Cassandra.init cassSettings + resourcePool <- + liftIO $ + createBackendResourcePool + (Map.elems intConfig.dynamicBackends) + intConfig.rabbitmq + cassClient + tempDir <- Codensity $ withSystemTempDirectory "test" pure GlobalEnv { gServiceMap = @@ -183,11 +74,13 @@ mkGlobalEnv cfgFile = do gDomain1 = intConfig.backendOne.originDomain, gDomain2 = intConfig.backendTwo.originDomain, gDynamicDomains = (.domain) <$> Map.elems intConfig.dynamicBackends, - gDefaultAPIVersion = 4, + gDefaultAPIVersion = 5, gManager = manager, gServicesCwdBase = devEnvProjectRoot <&> ( "services"), gRemovalKeyPath = error "Uninitialised removal key path", - gBackendResourcePool = resourcePool + gBackendResourcePool = resourcePool, + gRabbitMQConfig = intConfig.rabbitmq, + gTempDir = tempDir } mkEnv :: GlobalEnv -> Codensity IO Env @@ -209,7 +102,8 @@ mkEnv ge = do prekeys = pks, lastPrekeys = lpks, mls = mls, - resourcePool = ge.gBackendResourcePool + resourcePool = ge.gBackendResourcePool, + rabbitMQConfig = ge.gRabbitMQConfig } destroy :: IORef (Set BackendResource) -> BackendResource -> IO () @@ -224,17 +118,11 @@ create ioRef = Nothing -> error "No resources available" Just (r, s') -> (s', r) -data MLSState = MLSState - { baseDir :: FilePath, - members :: Set ClientIdentity, - -- | users expected to receive a welcome message after the next commit - newMembers :: Set ClientIdentity, - groupId :: Maybe String, - convId :: Maybe Value, - clientGroupState :: Map ClientIdentity ByteString, - epoch :: Word64 - } - deriving (Show) +emptyClientGroupState :: ClientGroupState +emptyClientGroupState = ClientGroupState Nothing Nothing + +allCiphersuites :: [Ciphersuite] +allCiphersuites = map Ciphersuite ["0x0001", "0xf031"] mkMLSState :: Codensity IO MLSState mkMLSState = Codensity $ \k -> @@ -247,12 +135,6 @@ mkMLSState = Codensity $ \k -> groupId = Nothing, convId = Nothing, clientGroupState = mempty, - epoch = 0 + epoch = 0, + ciphersuite = def } - -data ClientIdentity = ClientIdentity - { domain :: String, - user :: String, - client :: String - } - deriving (Show, Eq, Ord) diff --git a/integration/test/Testlib/HTTP.hs b/integration/test/Testlib/HTTP.hs index f31bf2a6b4c..d07176f3278 100644 --- a/integration/test/Testlib/HTTP.hs +++ b/integration/test/Testlib/HTTP.hs @@ -52,26 +52,31 @@ addBody body contentType req = addMLS :: ByteString -> HTTP.Request -> HTTP.Request addMLS bytes req = req - { HTTP.requestBody = HTTP.RequestBodyLBS (L.fromStrict bytes), + { HTTP.requestBody = HTTP.RequestBodyBS bytes, HTTP.requestHeaders = (fromString "Content-Type", fromString "message/mls") : HTTP.requestHeaders req } +addProtobuf :: ByteString -> HTTP.Request -> HTTP.Request +addProtobuf bytes req = + req + { HTTP.requestBody = HTTP.RequestBodyBS bytes, + HTTP.requestHeaders = (fromString "Content-Type", fromString "application/x-protobuf") : HTTP.requestHeaders req + } + addHeader :: String -> String -> HTTP.Request -> HTTP.Request addHeader name value req = req {HTTP.requestHeaders = (CI.mk . C8.pack $ name, C8.pack value) : HTTP.requestHeaders req} +setCookie :: String -> HTTP.Request -> HTTP.Request +setCookie c r = + addHeader "Cookie" (cs c) r + addQueryParams :: [(String, String)] -> HTTP.Request -> HTTP.Request addQueryParams params req = HTTP.setQueryString (map (\(k, v) -> (cs k, Just (cs v))) params) req -zType :: String -> HTTP.Request -> HTTP.Request -zType = addHeader "Z-Type" - -zHost :: String -> HTTP.Request -> HTTP.Request -zHost = addHeader "Z-Host" - contentTypeJSON :: HTTP.Request -> HTTP.Request contentTypeJSON = addHeader "Content-Type" "application/json" @@ -93,6 +98,9 @@ getJSON status resp = withResponse resp $ \r -> do r.status `shouldMatch` status r.json +assertSuccess :: HasCallStack => Response -> App () +assertSuccess resp = withResponse resp $ \r -> r.status `shouldMatchRange` (200, 299) + onFailureAddResponse :: HasCallStack => Response -> App a -> App a onFailureAddResponse r m = App $ do e <- ask @@ -141,6 +149,12 @@ zConnection = addHeader "Z-Connection" zClient :: String -> HTTP.Request -> HTTP.Request zClient = addHeader "Z-Client" +zType :: String -> HTTP.Request -> HTTP.Request +zType = addHeader "Z-Type" + +zHost :: String -> HTTP.Request -> HTTP.Request +zHost = addHeader "Z-Host" + submit :: String -> HTTP.Request -> App Response submit method req0 = do let req = req0 {HTTP.method = T.encodeUtf8 (T.pack method)} diff --git a/integration/test/Testlib/JSON.hs b/integration/test/Testlib/JSON.hs index 3c3d2132efa..5aeba81073e 100644 --- a/integration/test/Testlib/JSON.hs +++ b/integration/test/Testlib/JSON.hs @@ -1,6 +1,7 @@ module Testlib.JSON where import Control.Monad +import Control.Monad.Catch import Control.Monad.IO.Class import Control.Monad.Trans.Maybe import Data.Aeson hiding ((.=)) @@ -9,6 +10,8 @@ import Data.Aeson.Encode.Pretty qualified as Aeson import Data.Aeson.Key qualified as KM import Data.Aeson.KeyMap qualified as KM import Data.Aeson.Types qualified as Aeson +import Data.ByteString (ByteString) +import Data.ByteString.Base64 qualified as Base64 import Data.ByteString.Lazy.Char8 qualified as LC8 import Data.Foldable import Data.Function @@ -17,9 +20,9 @@ import Data.List.Split (splitOn) import Data.Scientific qualified as Sci import Data.String import Data.Text qualified as T +import Data.Text.Encoding qualified as T import Data.Vector ((!?)) import GHC.Stack -import Testlib.Env import Testlib.Types import Prelude @@ -72,12 +75,23 @@ asString x = (String s) -> pure (T.unpack s) v -> assertFailureWithJSON x ("String" `typeWasExpectedButGot` v) +asText :: HasCallStack => MakesValue a => a -> App T.Text +asText = (fmap T.pack) . asString + asStringM :: HasCallStack => MakesValue a => a -> App (Maybe String) asStringM x = make x >>= \case (String s) -> pure (Just (T.unpack s)) _ -> pure Nothing +asByteString :: (HasCallStack, MakesValue a) => a -> App ByteString +asByteString x = do + s <- asString x + let bs = T.encodeUtf8 (T.pack s) + case Base64.decode bs of + Left _ -> assertFailure "Could not base64 decode" + Right a -> pure a + asObject :: HasCallStack => MakesValue a => a -> App Object asObject x = make x >>= \case @@ -85,7 +99,10 @@ asObject x = v -> assertFailureWithJSON x ("Object" `typeWasExpectedButGot` v) asInt :: HasCallStack => MakesValue a => a -> App Int -asInt x = +asInt = asIntegral + +asIntegral :: (Integral i, HasCallStack) => MakesValue a => a -> App i +asIntegral x = make x >>= \case (Number n) -> case Sci.floatingOrInteger n of @@ -120,6 +137,30 @@ asBool x = App Value (%.) x k = lookupField x k >>= assertField x k +isEqual :: + (MakesValue a, MakesValue b, HasCallStack) => + a -> + b -> + App Bool +isEqual = liftP2 (==) + +liftP2 :: + (MakesValue a, MakesValue b, HasCallStack) => + (Value -> Value -> c) -> + a -> + b -> + App c +liftP2 f a b = do + f <$> make a <*> make b + +fieldEquals :: (MakesValue a, MakesValue b) => a -> String -> b -> App Bool +fieldEquals a fieldSelector b = do + ma <- lookupField a fieldSelector `catchAll` const (pure Nothing) + case ma of + Nothing -> pure False + Just f -> + f `isEqual` b + assertField :: (HasCallStack, MakesValue a) => a -> String -> Maybe Value -> App Value assertField x k Nothing = assertFailureWithJSON x $ "Field \"" <> k <> "\" is missing from object:" assertField _ _ (Just x) = pure x diff --git a/integration/test/Testlib/ModService.hs b/integration/test/Testlib/ModService.hs index 796af0cf33f..b5e0b526004 100644 --- a/integration/test/Testlib/ModService.hs +++ b/integration/test/Testlib/ModService.hs @@ -1,15 +1,13 @@ {-# LANGUAGE OverloadedStrings #-} module Testlib.ModService - ( withModifiedService, - withModifiedServices, + ( withModifiedBackend, startDynamicBackend, startDynamicBackends, traverseConcurrentlyCodensity, ) where -import Control.Applicative ((<|>)) import Control.Concurrent import Control.Concurrent.Async import Control.Exception (finally) @@ -20,8 +18,7 @@ import Control.Monad.Extra import Control.Monad.Reader import Control.Retry (fibonacciBackoff, limitRetriesByCumulativeDelay, retrying) import Data.Aeson hiding ((.=)) -import Data.Attoparsec.ByteString.Char8 -import Data.Either.Extra (eitherToMaybe) +import Data.Default import Data.Foldable import Data.Function import Data.Functor @@ -37,17 +34,14 @@ import Data.Word (Word16) import Data.Yaml qualified as Yaml import GHC.Stack import Network.HTTP.Client qualified as HTTP -import Network.Socket qualified as N import System.Directory (copyFile, createDirectoryIfMissing, doesDirectoryExist, doesFileExist, listDirectory, removeDirectoryRecursive, removeFile) import System.FilePath import System.IO -import System.IO.Error qualified as Error import System.IO.Temp (createTempDirectory, writeTempFile) import System.Posix (killProcess, signalProcess) import System.Process (CreateProcess (..), ProcessHandle, StdStream (..), createProcess, getPid, proc, terminateProcess, waitForProcess) import System.Timeout (timeout) import Testlib.App -import Testlib.Env import Testlib.HTTP import Testlib.JSON import Testlib.Printing @@ -56,13 +50,9 @@ import Testlib.Types import Text.RawString.QQ import Prelude -withModifiedService :: - Service -> - -- | function that edits the config - (Value -> App Value) -> - (String -> App a) -> - App a -withModifiedService srv modConfig = runCodensity $ withModifiedServices (Map.singleton srv modConfig) +withModifiedBackend :: HasCallStack => ServiceOverrides -> (HasCallStack => String -> App a) -> App a +withModifiedBackend overrides k = + startDynamicBackends [overrides] (\domains -> k (head domains)) copyDirectoryRecursively :: FilePath -> FilePath -> IO () copyDirectoryRecursively from to = do @@ -145,166 +135,108 @@ startDynamicBackends beOverrides k = when (Prelude.length beOverrides > 3) $ lift $ failApp "Too many backends. Currently only 3 are supported." pool <- asks (.resourcePool) resources <- acquireResources (Prelude.length beOverrides) pool - void $ traverseConcurrentlyCodensity (\(res, overrides) -> startDynamicBackend res mempty overrides) (zip resources beOverrides) + void $ traverseConcurrentlyCodensity (uncurry startDynamicBackend) (zip resources beOverrides) pure $ map (.berDomain) resources ) k -startDynamicBackend :: HasCallStack => BackendResource -> Map.Map Service Word16 -> ServiceOverrides -> Codensity App (Env -> Env) -startDynamicBackend resource staticPorts beOverrides = do - defDomain <- asks (.domain1) - let services = - withOverrides beOverrides $ - Map.mapWithKey - ( \srv conf -> - conf - >=> setKeyspace srv - >=> setEsIndex srv - >=> setFederationSettings srv - >=> setAwsAdnQueuesConfigs srv - >=> setLogLevel srv - ) - defaultServiceOverridesToMap - startBackend - resource.berDomain - staticPorts - (Just resource.berNginzSslPort) - (Just setFederatorConfig) - services - ( \ports sm -> do - let templateBackend = fromMaybe (error "no default domain found in backends") $ sm & Map.lookup defDomain - in Map.insert resource.berDomain (setFederatorPorts resource $ updateServiceMap ports templateBackend) sm - ) +startDynamicBackend :: HasCallStack => BackendResource -> ServiceOverrides -> Codensity App (Env -> Env) +startDynamicBackend resource beOverrides = do + let overrides = + mconcat + [ setKeyspace, + setEsIndex, + setFederationSettings, + setAwsConfigs, + setLogLevel, + beOverrides + ] + startBackend resource overrides allServices where - setAwsAdnQueuesConfigs :: Service -> Value -> App Value - setAwsAdnQueuesConfigs = \case - Brig -> - setField "aws.userJournalQueue" resource.berAwsUserJournalQueue - >=> setField "aws.prekeyTable" resource.berAwsPrekeyTable - >=> setField "internalEvents.queueName" resource.berBrigInternalEvents - >=> setField "emailSMS.email.sesQueue" resource.berEmailSMSSesQueue - >=> setField "emailSMS.general.emailSender" resource.berEmailSMSEmailSender - >=> setField "rabbitmq.vHost" resource.berVHost - Cargohold -> setField "aws.s3Bucket" resource.berAwsS3Bucket - Gundeck -> setField "aws.queueName" resource.berAwsQueueName - Galley -> - setField "journal.queueName" resource.berGalleyJournal - >=> setField "rabbitmq.vHost" resource.berVHost - _ -> pure - - setFederationSettings :: Service -> Value -> App Value + setAwsConfigs :: ServiceOverrides + setAwsConfigs = + def + { brigCfg = + setField "aws.userJournalQueue" resource.berAwsUserJournalQueue + >=> setField "aws.prekeyTable" resource.berAwsPrekeyTable + >=> setField "internalEvents.queueName" resource.berBrigInternalEvents + >=> setField "emailSMS.email.sesQueue" resource.berEmailSMSSesQueue + >=> setField "emailSMS.general.emailSender" resource.berEmailSMSEmailSender, + cargoholdCfg = setField "aws.s3Bucket" resource.berAwsS3Bucket, + gundeckCfg = setField "aws.queueName" resource.berAwsQueueName, + galleyCfg = setField "journal.queueName" resource.berGalleyJournal + } + + setFederationSettings :: ServiceOverrides setFederationSettings = - \case - Brig -> - setField "optSettings.setFederationDomain" resource.berDomain - >=> setField - "optSettings.setFederationDomainConfigs" - ([] :: [Value]) - >=> setField "federatorInternal.port" resource.berFederatorInternal - >=> setField "federatorInternal.host" ("127.0.0.1" :: String) - Cargohold -> - setField "settings.federationDomain" resource.berDomain - >=> setField "federator.port" resource.berFederatorInternal - Galley -> - setField "settings.federationDomain" resource.berDomain - >=> setField "settings.featureFlags.classifiedDomains.config.domains" [resource.berDomain] - >=> setField "federator.port" resource.berFederatorInternal - Gundeck -> setField "settings.federationDomain" resource.berDomain - BackgroundWorker -> - setField "federatorInternal.port" resource.berFederatorInternal - >=> setField "rabbitmq.vHost" resource.berVHost - _ -> pure - - setFederatorConfig :: Value -> App Value - setFederatorConfig = - setField "federatorInternal.port" resource.berFederatorInternal - >=> setField "federatorExternal.port" resource.berFederatorExternal - >=> setField "optSettings.setFederationDomain" resource.berDomain - - setKeyspace :: Service -> Value -> App Value - setKeyspace = \case - Galley -> setField "cassandra.keyspace" resource.berGalleyKeyspace - Brig -> setField "cassandra.keyspace" resource.berBrigKeyspace - Spar -> setField "cassandra.keyspace" resource.berSparKeyspace - Gundeck -> setField "cassandra.keyspace" resource.berGundeckKeyspace - -- other services do not have a DB - _ -> pure - - setEsIndex :: Service -> Value -> App Value - setEsIndex = \case - Brig -> setField "elasticsearch.index" resource.berElasticsearchIndex - -- other services do not have an ES index - _ -> pure - - setLogLevel :: Service -> Value -> App Value - setLogLevel = \case - Spar -> setField "saml.logLevel" ("Warn" :: String) - _ -> setField "logLevel" ("Warn" :: String) - -setFederatorPorts :: BackendResource -> ServiceMap -> ServiceMap -setFederatorPorts resource sm = - sm - { federatorInternal = sm.federatorInternal {host = "127.0.0.1", port = resource.berFederatorInternal}, - federatorExternal = sm.federatorExternal {host = "127.0.0.1", port = resource.berFederatorExternal} - } - -withModifiedServices :: Map.Map Service (Value -> App Value) -> Codensity App String -withModifiedServices services = do - domain <- lift $ asks (.domain1) - void $ - startBackend domain mempty Nothing Nothing services (\ports -> Map.adjust (updateServiceMap ports) domain) - pure domain - -updateServiceMap :: Map.Map Service Word16 -> ServiceMap -> ServiceMap -updateServiceMap ports serviceMap = - Map.foldrWithKey - ( \srv newPort sm -> - case srv of - Brig -> sm {brig = sm.brig {host = "127.0.0.1", port = newPort}} - Galley -> sm {galley = sm.galley {host = "127.0.0.1", port = newPort}} - Cannon -> sm {cannon = sm.cannon {host = "127.0.0.1", port = newPort}} - Gundeck -> sm {gundeck = sm.gundeck {host = "127.0.0.1", port = newPort}} - Cargohold -> sm {cargohold = sm.cargohold {host = "127.0.0.1", port = newPort}} - Nginz -> sm {nginz = sm.nginz {host = "127.0.0.1", port = newPort}} - Spar -> sm {spar = sm.spar {host = "127.0.0.1", port = newPort}} - BackgroundWorker -> sm {backgroundWorker = sm.backgroundWorker {host = "127.0.0.1", port = newPort}} - Stern -> sm {stern = sm.stern {host = "127.0.0.1", port = newPort}} - FederatorInternal -> sm {federatorInternal = sm.federatorInternal {host = "127.0.0.1", port = newPort}} - ) - serviceMap - ports + def + { brigCfg = + setField "optSettings.setFederationDomain" resource.berDomain + >=> setField "optSettings.setFederationDomainConfigs" ([] :: [Value]) + >=> setField "federatorInternal.port" resource.berFederatorInternal + >=> setField "federatorInternal.host" ("127.0.0.1" :: String) + >=> setField "rabbitmq.vHost" resource.berVHost, + cargoholdCfg = + setField "settings.federationDomain" resource.berDomain + >=> setField "federator.host" ("127.0.0.1" :: String) + >=> setField "federator.port" resource.berFederatorInternal, + galleyCfg = + setField "settings.federationDomain" resource.berDomain + >=> setField "settings.featureFlags.classifiedDomains.config.domains" [resource.berDomain] + >=> setField "federator.host" ("127.0.0.1" :: String) + >=> setField "federator.port" resource.berFederatorInternal + >=> setField "rabbitmq.vHost" resource.berVHost, + gundeckCfg = setField "settings.federationDomain" resource.berDomain, + backgroundWorkerCfg = + setField "federatorInternal.port" resource.berFederatorInternal + >=> setField "federatorInternal.host" ("127.0.0.1" :: String) + >=> setField "rabbitmq.vHost" resource.berVHost, + federatorInternalCfg = + setField "federatorInternal.port" resource.berFederatorInternal + >=> setField "federatorExternal.port" resource.berFederatorExternal + >=> setField "optSettings.setFederationDomain" resource.berDomain + } + + setKeyspace :: ServiceOverrides + setKeyspace = + def + { galleyCfg = setField "cassandra.keyspace" resource.berGalleyKeyspace, + brigCfg = setField "cassandra.keyspace" resource.berBrigKeyspace, + sparCfg = setField "cassandra.keyspace" resource.berSparKeyspace, + gundeckCfg = setField "cassandra.keyspace" resource.berGundeckKeyspace + } + + setEsIndex :: ServiceOverrides + setEsIndex = + def + { brigCfg = setField "elasticsearch.index" resource.berElasticsearchIndex + } + + setLogLevel :: ServiceOverrides + setLogLevel = + def + { sparCfg = setField "saml.logLevel" ("Warn" :: String), + brigCfg = setField "logLevel" ("Warn" :: String), + cannonCfg = setField "logLevel" ("Warn" :: String), + cargoholdCfg = setField "logLevel" ("Warn" :: String), + galleyCfg = setField "logLevel" ("Warn" :: String), + gundeckCfg = setField "logLevel" ("Warn" :: String), + nginzCfg = setField "logLevel" ("Warn" :: String), + backgroundWorkerCfg = setField "logLevel" ("Warn" :: String), + sternCfg = setField "logLevel" ("Warn" :: String), + federatorInternalCfg = setField "logLevel" ("Warn" :: String) + } startBackend :: HasCallStack => - String -> - Map.Map Service Word16 -> - Maybe Word16 -> - Maybe (Value -> App Value) -> - Map.Map Service (Value -> App Value) -> - (Map.Map Service Word16 -> Map.Map String ServiceMap -> Map.Map String ServiceMap) -> + BackendResource -> + ServiceOverrides -> + [Service] -> Codensity App (Env -> Env) -startBackend domain staticPorts nginzSslPort mFederatorOverrides services modifyBackends = do - -- We already close sockets before starting any services that want to bind to - -- it, because if done later some services might already connect to the - -- dummy sockets (e.g. federator connecting to nginz) and blocking the ports - -- from being bindable - ports <- - Map.traverseWithKey - ( \srv _ -> - case Map.lookup srv staticPorts of - Just port -> pure port - Nothing -> do - (port, sock) <- liftIO openFreePort - liftIO $ N.close sock - pure (fromIntegral port) - ) - services - nginzHttp2Port <- liftIO $ do - (port, sock) <- openFreePort - N.close sock - pure (fromIntegral port) +startBackend resource overrides services = do + let domain = resource.berDomain - let updateServiceMapInConfig :: Maybe Service -> Value -> App Value + let updateServiceMapInConfig :: Service -> Value -> App Value updateServiceMapInConfig forSrv config = foldlM ( \c (srv, port) -> do @@ -320,7 +252,7 @@ startBackend domain staticPorts nginzSslPort mFederatorOverrides services modify ) ) case (srv, forSrv) of - (Spar, Just Spar) -> do + (Spar, Spar) -> do overridden -- FUTUREWORK: override "saml.spAppUri" and "saml.spSsoUri" with correct port, too? & setField "saml.spHost" ("127.0.0.1" :: String) @@ -328,63 +260,63 @@ startBackend domain staticPorts nginzSslPort mFederatorOverrides services modify _ -> pure overridden ) config - (Map.assocs ports) - - -- close all sockets before starting the services - stopInstances <- lift $ do - fedInstance <- - case mFederatorOverrides of - Nothing -> pure [] - Just override -> - readServiceConfig' "federator" - >>= updateServiceMapInConfig Nothing - >>= override - >>= startProcess' domain "federator" - <&> (: []) - - otherInstances <- for (Map.assocs $ Map.filterWithKey (\s _ -> s /= FederatorInternal) services) $ \case - (Nginz, _) -> do + [(srv, berInternalServicePorts resource srv :: Int) | srv <- services] + + let serviceMap = + let g srv = HostPort "127.0.0.1" (berInternalServicePorts resource srv) + in ServiceMap + { brig = g Brig, + backgroundWorker = g BackgroundWorker, + cannon = g Cannon, + cargohold = g Cargohold, + federatorInternal = g FederatorInternal, + federatorExternal = HostPort "127.0.0.1" resource.berFederatorExternal, + galley = g Galley, + gundeck = g Gundeck, + nginz = g Nginz, + spar = g Spar, + -- FUTUREWORK: Set to g Proxy, when we add Proxy to spawned services + proxy = HostPort "127.0.0.1" 9087, + stern = g Stern + } + + instances <- lift $ do + for services $ \case + Nginz -> do env <- ask - sm <- maybe (failApp "the impossible in withServices happened") pure (Map.lookup domain (modifyBackends (fromIntegral <$> ports) env.serviceMap)) - port <- maybe (failApp "the impossible in withServices happened") (pure . fromIntegral) (Map.lookup Nginz ports) case env.servicesCwdBase of - Nothing -> startNginzK8s domain sm - Just _ -> startNginzLocal domain port nginzHttp2Port nginzSslPort sm - (srv, modifyConfig) -> do + Nothing -> startNginzK8s domain serviceMap + Just _ -> startNginzLocal domain resource.berNginzHttp2Port resource.berNginzSslPort serviceMap + srv -> do readServiceConfig srv - >>= updateServiceMapInConfig (Just srv) - >>= modifyConfig + >>= updateServiceMapInConfig srv + >>= lookupConfigOverride overrides srv >>= startProcess domain srv - let instances = fedInstance <> otherInstances - - let stopInstances = liftIO $ do - -- Running waitForProcess would hang for 30 seconds when the test suite - -- is run from within ghci, so we don't wait here. - for_ instances $ \(ph, path) -> do - terminateProcess ph - timeout 50000 (waitForProcess ph) >>= \case - Just _ -> pure () - Nothing -> do - timeout 100000 (waitForProcess ph) >>= \case - Just _ -> pure () - Nothing -> do - mPid <- getPid ph - for_ mPid (signalProcess killProcess) - void $ waitForProcess ph - whenM (doesFileExist path) $ removeFile path - whenM (doesDirectoryExist path) $ removeDirectoryRecursive path - - pure stopInstances - - let modifyEnv env = - env {serviceMap = modifyBackends (fromIntegral <$> ports) env.serviceMap} + let stopInstances = liftIO $ do + -- Running waitForProcess would hang for 30 seconds when the test suite + -- is run from within ghci, so we don't wait here. + for_ instances $ \(ph, path) -> do + terminateProcess ph + timeout 50000 (waitForProcess ph) >>= \case + Just _ -> pure () + Nothing -> do + timeout 100000 (waitForProcess ph) >>= \case + Just _ -> pure () + Nothing -> do + mPid <- getPid ph + for_ mPid (signalProcess killProcess) + void $ waitForProcess ph + whenM (doesFileExist path) $ removeFile path + whenM (doesDirectoryExist path) $ removeDirectoryRecursive path + + let modifyEnv env = env {serviceMap = Map.insert resource.berDomain serviceMap env.serviceMap} Codensity $ \action -> local modifyEnv $ do waitForService <- appToIOKleisli (waitUntilServiceUp domain) ioAction <- appToIO (action ()) liftIO $ - (mapConcurrently_ waitForService (Map.keys ports) >> ioAction) + (mapConcurrently_ waitForService services >> ioAction) `finally` stopInstances pure modifyEnv @@ -452,29 +384,6 @@ waitUntilServiceUp domain = \case unless isUp $ failApp ("Time out for service " <> show srv <> " to come up") --- | Open a TCP socket on a random free port. This is like 'warp''s --- openFreePort. --- --- Since 0.0.0.1 -openFreePort :: IO (Int, N.Socket) -openFreePort = - E.bracketOnError (N.socket N.AF_INET N.Stream N.defaultProtocol) N.close $ - \sock -> do - N.bind sock $ N.SockAddrInet 0 $ N.tupleToHostAddress (127, 0, 0, 1) - N.getSocketName sock >>= \case - N.SockAddrInet port _ -> do - pure (fromIntegral port, sock) - addr -> - E.throwIO $ - Error.mkIOError - Error.userErrorType - ( "openFreePort was unable to create socket with a SockAddrInet. " - <> "Got " - <> show addr - ) - Nothing - Nothing - startNginzK8s :: String -> ServiceMap -> App (ProcessHandle, FilePath) startNginzK8s domain sm = do tmpDir <- liftIO $ createTempDirectory "/tmp" ("nginz" <> "-" <> domain) @@ -498,8 +407,8 @@ startNginzK8s domain sm = do ph <- startNginz domain nginxConfFile "/" pure (ph, tmpDir) -startNginzLocal :: String -> Word16 -> Word16 -> Maybe Word16 -> ServiceMap -> App (ProcessHandle, FilePath) -startNginzLocal domain localPort http2Port mSslPort sm = do +startNginzLocal :: String -> Word16 -> Word16 -> ServiceMap -> App (ProcessHandle, FilePath) +startNginzLocal domain http2Port sslPort sm = do -- Create a whole temporary directory and copy all nginx's config files. -- This is necessary because nginx assumes local imports are relative to -- the location of the main configuration file. @@ -524,25 +433,6 @@ startNginzLocal domain localPort http2Port mSslPort sm = do & Text.replace "access_log /dev/stdout" "access_log /dev/null" ) - conf <- Prelude.lines <$> liftIO (readFile integrationConfFile) - let sslPortParser = do - _ <- string "listen" - _ <- many1 space - p <- many1 digit - _ <- many1 space - _ <- string "ssl" - _ <- many1 space - _ <- string "http2" - _ <- many1 space - _ <- char ';' - pure (read p :: Word16) - - let mParsedPort = - mapMaybe (eitherToMaybe . parseOnly sslPortParser . cs) conf - & (\case [] -> Nothing; (p : _) -> Just p) - - sslPort <- maybe (failApp "could not determine nginz's ssl port") pure (mSslPort <|> mParsedPort) - -- override port configuration let portConfigTemplate = [r|listen {localPort}; @@ -552,7 +442,7 @@ listen [::]:{ssl_port} ssl http2; |] let portConfig = portConfigTemplate - & Text.replace "{localPort}" (cs $ show localPort) + & Text.replace "{localPort}" (cs $ show (sm.nginz.port)) & Text.replace "{http2_port}" (cs $ show http2Port) & Text.replace "{ssl_port}" (cs $ show sslPort) diff --git a/integration/test/Testlib/One2One.hs b/integration/test/Testlib/One2One.hs new file mode 100644 index 00000000000..ebecfd46ee9 --- /dev/null +++ b/integration/test/Testlib/One2One.hs @@ -0,0 +1,102 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +-- This is a duplicate of `Galley.Types.Conversations.One2One` +-- and is needed because we do not have access to galley code in the integration tests +module Testlib.One2One (generateRemoteAndConvIdWithDomain) where + +import Control.Error (atMay) +import Crypto.Hash qualified as Crypto +import Data.Bits +import Data.ByteArray (convert) +import Data.ByteString +import Data.ByteString qualified as B +import Data.ByteString.Conversion +import Data.ByteString.Lazy qualified as L +import Data.UUID as UUID +import SetupHelpers (randomUser) +import Testlib.Prelude + +generateRemoteAndConvIdWithDomain :: (MakesValue domain, MakesValue a) => domain -> Bool -> a -> App (Value, Value) +generateRemoteAndConvIdWithDomain remoteDomain shouldBeLocal lUserId = do + (localDomain, localUser) <- objQid lUserId + otherUsr <- randomUser remoteDomain def >>= objId + otherDomain <- asString remoteDomain + let (cId, cDomain) = + one2OneConvId + (fromMaybe (error "invalid UUID") (UUID.fromString localUser), localDomain) + (fromMaybe (error "invalid UUID") (UUID.fromString otherUsr), otherDomain) + isLocal = localDomain == cDomain + if shouldBeLocal == isLocal + then + pure $ + ( object ["id" .= (otherUsr), "domain" .= otherDomain], + object ["id" .= (UUID.toString cId), "domain" .= cDomain] + ) + else generateRemoteAndConvIdWithDomain remoteDomain shouldBeLocal lUserId + +one2OneConvId :: (UUID, String) -> (UUID, String) -> (UUID, String) +one2OneConvId a@(a1, dom1) b@(a2, dom2) = case compare (dom1, a1) (dom2, a2) of + GT -> one2OneConvId b a + _ -> + let c = + mconcat + [ L.toStrict (UUID.toByteString namespace), + quidToByteString a, + quidToByteString b + ] + x = hash c + result = + toUuidV5 + . mkV5 + . fromMaybe nil + . UUID.fromByteString + . L.fromStrict + . B.take 16 + $ x + domain + | fromMaybe 0 (atMay (B.unpack x) 16) .&. 0x80 == 0 = dom1 + | otherwise = dom2 + in (result, domain) + where + hash :: ByteString -> ByteString + hash = convert . Crypto.hash @ByteString @Crypto.SHA256 + + namespace :: UUID + namespace = fromWords 0x9a51edb8 0x060c0d9a 0x0c2950a8 0x5d152982 + + quidToByteString :: (UUID, String) -> ByteString + quidToByteString (uid, domain) = toASCIIBytes uid <> toByteString' domain + +newtype UuidV5 = UuidV5 {toUuidV5 :: UUID} + deriving (Eq, Ord, Show) + +mkV5 :: UUID -> UuidV5 +mkV5 u = UuidV5 $ + case toWords u of + (x0, x1, x2, x3) -> + fromWords + x0 + (retainVersion 5 x1) + (retainVariant 2 x2) + x3 + where + retainVersion :: Word32 -> Word32 -> Word32 + retainVersion v x = (x .&. 0xFFFF0FFF) .|. (v `shiftL` 12) + + retainVariant :: Word32 -> Word32 -> Word32 + retainVariant v x = (x .&. 0x3FFFFFFF) .|. (v `shiftL` 30) diff --git a/integration/test/Testlib/Options.hs b/integration/test/Testlib/Options.hs index 2ba8fdafd9a..094c5951543 100644 --- a/integration/test/Testlib/Options.hs +++ b/integration/test/Testlib/Options.hs @@ -9,6 +9,7 @@ data TestOptions = TestOptions { includeTests :: [String], excludeTests :: [String], listTests :: Bool, + xmlReport :: Maybe FilePath, configFile :: String } @@ -32,6 +33,13 @@ parser = ) ) <*> switch (long "list" <> short 'l' <> help "Only list tests.") + <*> optional + ( strOption + ( long "xml" + <> metavar "FILE" + <> help "Generate XML report for the tests" + ) + ) <*> strOption ( long "config" <> short 'c' @@ -53,12 +61,16 @@ getOptions :: IO TestOptions getOptions = do defaultsInclude <- maybe [] (splitOn ",") <$> lookupEnv "TEST_INCLUDE" defaultsExclude <- maybe [] (splitOn ",") <$> lookupEnv "TEST_EXCLUDE" + defaultsXMLReport <- lookupEnv "TEST_XML" opts <- execParser optInfo pure opts { includeTests = includeTests opts `orFromEnv` defaultsInclude, - excludeTests = excludeTests opts `orFromEnv` defaultsExclude + excludeTests = excludeTests opts `orFromEnv` defaultsExclude, + xmlReport = xmlReport opts `orFromEnv` defaultsXMLReport } where - orFromEnv [] fromEnv = fromEnv - orFromEnv patterns _ = patterns + orFromEnv fromArgs fromEnv = + if null fromArgs + then fromEnv + else fromArgs diff --git a/integration/test/Testlib/PTest.hs b/integration/test/Testlib/PTest.hs index d2613fa214e..1aa478b720f 100644 --- a/integration/test/Testlib/PTest.hs +++ b/integration/test/Testlib/PTest.hs @@ -1,6 +1,7 @@ module Testlib.PTest where import Testlib.App +import Testlib.Env import Testlib.Types import Prelude @@ -16,3 +17,10 @@ instance HasTests x => HasTests (Domain -> x) where mkTests m n s f x = mkTests m (n <> "[domain=own]") s f (x OwnDomain) <> mkTests m (n <> "[domain=other]") s f (x OtherDomain) + +instance HasTests x => HasTests (Ciphersuite -> x) where + mkTests m n s f x = + mconcat + [ mkTests m (n <> "[suite=" <> suite.code <> "]") s f (x suite) + | suite <- allCiphersuites + ] diff --git a/integration/test/Testlib/Ports.hs b/integration/test/Testlib/Ports.hs new file mode 100644 index 00000000000..4ca16d06910 --- /dev/null +++ b/integration/test/Testlib/Ports.hs @@ -0,0 +1,39 @@ +module Testlib.Ports where + +import Testlib.Types hiding (port) +import Prelude + +data PortNamespace + = NginzSSL + | NginzHttp2 + | FederatorExternal + | ServiceInternal Service + +port :: Num a => PortNamespace -> BackendName -> a +port NginzSSL bn = mkPort 8443 bn +port NginzHttp2 bn = mkPort 8099 bn +port FederatorExternal bn = mkPort 8098 bn +port (ServiceInternal BackgroundWorker) bn = mkPort 8089 bn +port (ServiceInternal Brig) bn = mkPort 8082 bn +port (ServiceInternal Cannon) bn = mkPort 8083 bn +port (ServiceInternal Cargohold) bn = mkPort 8084 bn +port (ServiceInternal FederatorInternal) bn = mkPort 8097 bn +port (ServiceInternal Galley) bn = mkPort 8085 bn +port (ServiceInternal Gundeck) bn = mkPort 8086 bn +port (ServiceInternal Nginz) bn = mkPort 8080 bn +port (ServiceInternal Spar) bn = mkPort 8088 bn +port (ServiceInternal Stern) bn = mkPort 8091 bn + +portForDyn :: Num a => PortNamespace -> Int -> a +portForDyn ns i = port ns (DynamicBackend i) + +mkPort :: Num a => Int -> BackendName -> a +mkPort basePort bn = + let i = case bn of + BackendA -> 0 + BackendB -> 1 + (DynamicBackend k) -> 1 + k + in fromIntegral basePort + (fromIntegral i) * 1000 + +internalServicePorts :: Num a => BackendName -> Service -> a +internalServicePorts backend service = port (ServiceInternal service) backend diff --git a/integration/test/Testlib/Prelude.hs b/integration/test/Testlib/Prelude.hs index 05a04f366a3..27c9db153cd 100644 --- a/integration/test/Testlib/Prelude.hs +++ b/integration/test/Testlib/Prelude.hs @@ -66,6 +66,9 @@ module Testlib.Prelude -- * Functor (<$$>), (<$$$>), + + -- * Applicative + allPreds, ) where @@ -222,3 +225,11 @@ infix 4 <$$> (<$$$>) = fmap . fmap . fmap infix 4 <$$$> + +---------------------------------------------------------------------- +-- Applicative + +allPreds :: (Applicative f) => [a -> f Bool] -> a -> f Bool +allPreds [] _ = pure True +allPreds [p] x = p x +allPreds (p1 : ps) x = (&&) <$> p1 x <*> allPreds ps x diff --git a/integration/test/Testlib/ResourcePool.hs b/integration/test/Testlib/ResourcePool.hs index ae498c4eabf..c7483ca9478 100644 --- a/integration/test/Testlib/ResourcePool.hs +++ b/integration/test/Testlib/ResourcePool.hs @@ -5,6 +5,8 @@ module Testlib.ResourcePool backendResources, createBackendResourcePool, acquireResources, + backendA, + backendB, ) where @@ -12,26 +14,27 @@ import Control.Concurrent import Control.Monad.Catch import Control.Monad.Codensity import Control.Monad.IO.Class -import Data.Aeson +import Data.Foldable (for_) import Data.Function ((&)) import Data.Functor import Data.IORef import Data.Set qualified as Set import Data.String +import Data.Text qualified as T import Data.Tuple -import Data.Word -import GHC.Generics +import Database.CQL.IO import GHC.Stack (HasCallStack) +import Network.AMQP.Extended +import Network.RabbitMqAdmin import System.IO +import Testlib.Ports qualified as Ports +import Testlib.Types import Prelude -data ResourcePool a = ResourcePool - { sem :: QSemN, - resources :: IORef (Set.Set a) - } - acquireResources :: forall m a. (Ord a, MonadIO m, MonadMask m, HasCallStack) => Int -> ResourcePool a -> Codensity m [a] -acquireResources n pool = Codensity $ \f -> bracket acquire release (f . Set.toList) +acquireResources n pool = Codensity $ \f -> bracket acquire release $ \s -> do + liftIO $ mapM_ pool.onAcquire s + f $ Set.toList s where release :: Set.Set a -> m () release s = @@ -44,76 +47,123 @@ acquireResources n pool = Codensity $ \f -> bracket acquire release (f . Set.toL waitQSemN pool.sem n atomicModifyIORef pool.resources $ swap . Set.splitAt n -createBackendResourcePool :: [DynamicBackendConfig] -> IO (ResourcePool BackendResource) -createBackendResourcePool dynConfs = +createBackendResourcePool :: [DynamicBackendConfig] -> RabbitMQConfig -> ClientState -> IO (ResourcePool BackendResource) +createBackendResourcePool dynConfs rabbitmq cassClient = let resources = backendResources dynConfs + cleanupBackend :: BackendResource -> IO () + cleanupBackend resource = do + deleteAllRabbitMQQueues rabbitmq resource + runClient cassClient $ deleteAllDynamicBackendConfigs resource in ResourcePool <$> newQSemN (length dynConfs) <*> newIORef resources + <*> pure cleanupBackend -data BackendResource = BackendResource - { berBrigKeyspace :: String, - berGalleyKeyspace :: String, - berSparKeyspace :: String, - berGundeckKeyspace :: String, - berElasticsearchIndex :: String, - berFederatorInternal :: Word16, - berFederatorExternal :: Word16, - berDomain :: String, - berAwsUserJournalQueue :: String, - berAwsPrekeyTable :: String, - berAwsS3Bucket :: String, - berAwsQueueName :: String, - berBrigInternalEvents :: String, - berEmailSMSSesQueue :: String, - berEmailSMSEmailSender :: String, - berGalleyJournal :: String, - berVHost :: String, - berNginzSslPort :: Word16 - } - deriving (Show, Eq, Ord) - -data DynamicBackendConfig = DynamicBackendConfig - { domain :: String, - federatorExternalPort :: Word16 - } - deriving (Show, Generic) +deleteAllRabbitMQQueues :: RabbitMQConfig -> BackendResource -> IO () +deleteAllRabbitMQQueues rc resource = do + let opts = + RabbitMqAdminOpts + { host = rc.host, + port = 0, + adminPort = fromIntegral rc.adminPort, + vHost = T.pack resource.berVHost + } + client <- mkRabbitMqAdminClientEnv opts + queues <- listQueuesByVHost client (T.pack resource.berVHost) + for_ queues $ \queue -> + deleteQueue client (T.pack resource.berVHost) queue.name -instance FromJSON DynamicBackendConfig +deleteAllDynamicBackendConfigs :: BackendResource -> Client () +deleteAllDynamicBackendConfigs resource = write cql (defQueryParams LocalQuorum ()) + where + cql :: PrepQuery W () () + cql = fromString $ "TRUNCATE " <> resource.berBrigKeyspace <> ".federation_remotes" backendResources :: [DynamicBackendConfig] -> Set.Set BackendResource backendResources dynConfs = (zip dynConfs [1 ..]) <&> ( \(dynConf, i) -> - BackendResource - { berBrigKeyspace = "brig_test_dyn_" <> show i, - berGalleyKeyspace = "galley_test_dyn_" <> show i, - berSparKeyspace = "spar_test_dyn_" <> show i, - berGundeckKeyspace = "gundeck_test_dyn_" <> show i, - berElasticsearchIndex = "directory_dyn_" <> show i <> "_test", - berFederatorInternal = federatorInternalPort i, - berFederatorExternal = dynConf.federatorExternalPort, - berDomain = dynConf.domain, - berAwsUserJournalQueue = "integration-user-events.fifo" <> suffix i, - berAwsPrekeyTable = "integration-brig-prekeys" <> suffix i, - berAwsS3Bucket = "dummy-bucket" <> suffix i, - berAwsQueueName = "integration-gundeck-events" <> suffix i, - berBrigInternalEvents = "integration-brig-events-internal" <> suffix i, - berEmailSMSSesQueue = "integration-brig-events" <> suffix i, - berEmailSMSEmailSender = "backend-integration" <> suffix i <> "@wire.com", - berGalleyJournal = "integration-team-events.fifo" <> suffix i, - berVHost = dynConf.domain, - berNginzSslPort = mkNginzSslPort i - } + let name = DynamicBackend i + in BackendResource + { berName = name, + berBrigKeyspace = "brig_test_dyn_" <> show i, + berGalleyKeyspace = "galley_test_dyn_" <> show i, + berSparKeyspace = "spar_test_dyn_" <> show i, + berGundeckKeyspace = "gundeck_test_dyn_" <> show i, + berElasticsearchIndex = "directory_dyn_" <> show i <> "_test", + berFederatorInternal = Ports.portForDyn (Ports.ServiceInternal FederatorInternal) i, + berFederatorExternal = dynConf.federatorExternalPort, + berDomain = dynConf.domain, + berAwsUserJournalQueue = "integration-user-events.fifo" <> suffix i, + berAwsPrekeyTable = "integration-brig-prekeys" <> suffix i, + berAwsS3Bucket = "dummy-bucket" <> suffix i, + berAwsQueueName = "integration-gundeck-events" <> suffix i, + berBrigInternalEvents = "integration-brig-events-internal" <> suffix i, + berEmailSMSSesQueue = "integration-brig-events" <> suffix i, + berEmailSMSEmailSender = "backend-integration" <> suffix i <> "@wire.com", + berGalleyJournal = "integration-team-events.fifo" <> suffix i, + berVHost = dynConf.domain, + berNginzSslPort = Ports.portForDyn Ports.NginzSSL i, + berNginzHttp2Port = Ports.portForDyn Ports.NginzHttp2 i, + berInternalServicePorts = Ports.internalServicePorts name + } ) & Set.fromList where - suffix :: Word16 -> String + suffix :: (Show a, Num a) => a -> String suffix i = show $ i + 2 - mkNginzSslPort :: Word16 -> Word16 - mkNginzSslPort i = 8443 + ((1 + i) * 1000) +backendA :: BackendResource +backendA = + BackendResource + { berName = BackendA, + berBrigKeyspace = "brig_test", + berGalleyKeyspace = "galley_test", + berSparKeyspace = "spar_test", + berGundeckKeyspace = "gundeck_test", + berElasticsearchIndex = "directory_test", + berFederatorInternal = Ports.port (Ports.ServiceInternal FederatorInternal) BackendA, + berFederatorExternal = Ports.port Ports.FederatorExternal BackendA, + berDomain = "example.com", + berAwsUserJournalQueue = "integration-user-events.fifo", + berAwsPrekeyTable = "integration-brig-prekeys", + berAwsS3Bucket = "dummy-bucket", + berAwsQueueName = "integration-gundeck-events", + berBrigInternalEvents = "integration-brig-events-internal", + berEmailSMSSesQueue = "integration-brig-events", + berEmailSMSEmailSender = "backend-integration@wire.com", + berGalleyJournal = "integration-team-events.fifo", + berVHost = "backendA", + berNginzSslPort = Ports.port Ports.NginzSSL BackendA, + berInternalServicePorts = Ports.internalServicePorts BackendA, + berNginzHttp2Port = Ports.port Ports.NginzHttp2 BackendA + } - -- Fixed internal port for federator, e.g. for dynamic backends: 1 -> 10097, 2 -> 11097, etc. - federatorInternalPort :: Num a => a -> a - federatorInternalPort i = 8097 + ((1 + i) * 1000) +backendB :: BackendResource +backendB = + BackendResource + { berName = BackendB, + berBrigKeyspace = "brig_test2", + berGalleyKeyspace = "galley_test2", + berSparKeyspace = "spar_test2", + berGundeckKeyspace = "gundeck_test2", + berElasticsearchIndex = "directory2_test", + berFederatorInternal = Ports.port (Ports.ServiceInternal FederatorInternal) BackendB, + berFederatorExternal = Ports.port Ports.FederatorExternal BackendB, + berDomain = "b.example.com", + berAwsUserJournalQueue = "integration-user-events.fifo2", + berAwsPrekeyTable = "integration-brig-prekeys2", + berAwsS3Bucket = "dummy-bucket2", + berAwsQueueName = "integration-gundeck-events2", + berBrigInternalEvents = "integration-brig-events-internal2", + berEmailSMSSesQueue = "integration-brig-events2", + berEmailSMSEmailSender = "backend-integration2@wire.com", + berGalleyJournal = "integration-team-events.fifo2", + -- FUTUREWORK: set up vhosts in dev/ci for example.com and b.example.com + -- in case we want backendA and backendB to federate with a third backend + -- (because otherwise both queues will overlap) + berVHost = "backendB", + berNginzSslPort = Ports.port Ports.NginzSSL BackendB, + berInternalServicePorts = Ports.internalServicePorts BackendB, + berNginzHttp2Port = Ports.port Ports.NginzHttp2 BackendB + } diff --git a/integration/test/Testlib/Run.hs b/integration/test/Testlib/Run.hs index 70fa733c7cd..349c44390cb 100644 --- a/integration/test/Testlib/Run.hs +++ b/integration/test/Testlib/Run.hs @@ -6,11 +6,17 @@ import Control.Monad import Control.Monad.Codensity import Control.Monad.IO.Class import Control.Monad.Reader +import Crypto.Error +import Crypto.PubKey.Ed25519 qualified as Ed25519 +import Data.ByteArray (convert) +import Data.ByteString qualified as B import Data.Foldable import Data.Function import Data.Functor import Data.List +import Data.PEM import Data.Time.Clock +import Data.Traversable (for) import RunAllTests import System.Directory import System.Environment @@ -23,22 +29,11 @@ import Testlib.JSON import Testlib.Options import Testlib.Printing import Testlib.Types +import Testlib.XML import Text.Printf import UnliftIO.Async import Prelude -data TestReport = TestReport - { count :: Int, - failures :: [String] - } - deriving (Eq, Show) - -instance Semigroup TestReport where - TestReport s1 f1 <> TestReport s2 f2 = TestReport (s1 + s2) (f1 <> f2) - -instance Monoid TestReport where - mempty = TestReport 0 mempty - runTest :: GlobalEnv -> App a -> IO (Either String a) runTest ge action = lowerCodensity $ do env <- mkEnv ge @@ -54,16 +49,18 @@ pluralise :: Int -> String -> String pluralise 1 x = x pluralise _ x = x <> "s" -printReport :: TestReport -> IO () +printReport :: TestSuiteReport -> IO () printReport report = do - unless (null report.failures) $ putStrLn $ "----------" - putStrLn $ show report.count <> " " <> pluralise report.count "test" <> " run." - unless (null report.failures) $ do + let numTests = length report.cases + failures = filter (\testCase -> testCase.result /= TestSuccess) report.cases + numFailures = length failures + when (numFailures > 0) $ putStrLn $ "----------" + putStrLn $ show numTests <> " " <> pluralise numTests "test" <> " run." + when (numFailures > 0) $ do putStrLn "" - let numFailures = length report.failures putStrLn $ colored red (show numFailures <> " failed " <> pluralise numFailures "test" <> ": ") - for_ report.failures $ \name -> - putStrLn $ " - " <> name + for_ failures $ \testCase -> + putStrLn $ " - " <> testCase.name testFilter :: TestOptions -> String -> Bool testFilter opts n = included n && not (excluded n) @@ -104,14 +101,13 @@ main = do qualifiedName = module0 <> "." <> name in (qualifiedName, summary, full, action) - if opts.listTests then doListTests tests else runTests tests cfg + if opts.listTests then doListTests tests else runTests tests opts.xmlReport cfg -createGlobalEnv :: FilePath -> IO GlobalEnv +createGlobalEnv :: FilePath -> Codensity IO GlobalEnv createGlobalEnv cfg = do genv0 <- mkGlobalEnv cfg - -- save removal key to a file - lowerCodensity $ do + pubkey <- liftIO . lowerCodensity $ do env <- mkEnv genv0 liftIO . runAppWithEnv env $ do config <- readServiceConfig Galley @@ -120,10 +116,24 @@ createGlobalEnv cfg = do asks (.servicesCwdBase) <&> \case Nothing -> relPath Just dir -> dir "galley" relPath - pure genv0 {gRemovalKeyPath = path} - -runTests :: [(String, x, y, App ())] -> FilePath -> IO () -runTests tests cfg = do + bs <- liftIO $ B.readFile path + pems <- case pemParseBS bs of + Left err -> assertFailure $ "Could not parse removal key PEM: " <> err + Right x -> pure x + asn1 <- pemContent <$> assertOne pems + -- quick and dirty ASN.1 decoding: assume the key is of the correct + -- format, and simply skip the 16 byte header + let bytes = B.drop 16 asn1 + priv <- liftIO . throwCryptoErrorIO $ Ed25519.secretKey bytes + pure (convert (Ed25519.toPublic priv)) + + -- save removal key to a temporary file + let removalPath = gTempDir genv0 "removal.key" + liftIO $ B.writeFile removalPath pubkey + pure genv0 {gRemovalKeyPath = removalPath} + +runTests :: [(String, x, y, App ())] -> Maybe FilePath -> FilePath -> IO () +runTests tests mXMLOutput cfg = do output <- newChan let displayOutput = readChan output >>= \case @@ -131,39 +141,36 @@ runTests tests cfg = do Nothing -> pure () let writeOutput = writeChan output . Just - genv <- createGlobalEnv cfg - - withAsync displayOutput $ \displayThread -> do - report <- fmap mconcat $ pooledForConcurrently tests $ \(qname, _, _, action) -> do - do - (mErr, tm) <- withTime (runTest genv action) - case mErr of - Left err -> do - writeOutput $ - "----- " - <> qname - <> colored red " FAIL" - <> " (" - <> printTime tm - <> ") -----\n" - <> err - <> "\n" - pure (TestReport 1 [qname]) - Right _ -> do - writeOutput $ qname <> colored green " OK" <> " (" <> printTime tm <> ")" <> "\n" - pure (TestReport 1 []) - writeChan output Nothing - wait displayThread - printReport report - unless (null report.failures) $ - exitFailure + runCodensity (createGlobalEnv cfg) $ \genv -> + withAsync displayOutput $ \displayThread -> do + report <- fmap mconcat $ for tests $ \(qname, _, _, action) -> do + do + (mErr, tm) <- withTime (runTest genv action) + case mErr of + Left err -> do + writeOutput $ + "----- " + <> qname + <> colored red " FAIL" + <> " (" + <> printTime tm + <> ") -----\n" + <> err + <> "\n" + pure (TestSuiteReport [TestCaseReport qname (TestFailure err) tm]) + Right _ -> do + writeOutput $ qname <> colored green " OK" <> " (" <> printTime tm <> ")" <> "\n" + pure (TestSuiteReport [TestCaseReport qname TestSuccess tm]) + writeChan output Nothing + wait displayThread + printReport report + mapM_ (saveXMLReport report) mXMLOutput + when (any (\testCase -> testCase.result /= TestSuccess) report.cases) $ + exitFailure doListTests :: [(String, String, String, x)] -> IO () -doListTests tests = for_ tests $ \(qname, desc, full, _) -> do - putStrLn $ qname <> " " <> colored gray desc - unless (null full) $ - putStr $ - colored gray (indent 2 full) +doListTests tests = for_ tests $ \(qname, _desc, _full, _) -> do + putStrLn qname -- like `main` but meant to run from a repl mainI :: [String] -> IO () diff --git a/integration/test/Testlib/RunServices.hs b/integration/test/Testlib/RunServices.hs index 01966efe720..23fc024da10 100644 --- a/integration/test/Testlib/RunServices.hs +++ b/integration/test/Testlib/RunServices.hs @@ -1,10 +1,7 @@ -{-# OPTIONS_GHC -Wno-unused-matches #-} - module Testlib.RunServices where import Control.Concurrent -import Control.Monad.Codensity (lowerCodensity) -import Data.Map qualified as Map +import Control.Monad.Codensity import System.Directory import System.Environment (getArgs) import System.Exit (exitWith) @@ -15,83 +12,6 @@ import Testlib.Prelude import Testlib.ResourcePool import Testlib.Run (createGlobalEnv) -backendA :: BackendResource -backendA = - BackendResource - { berBrigKeyspace = "brig_test", - berGalleyKeyspace = "galley_test", - berSparKeyspace = "spar_test", - berGundeckKeyspace = "gundeck_test", - berElasticsearchIndex = "directory_test", - berFederatorInternal = 8097, - berFederatorExternal = 8098, - berDomain = "example.com", - berAwsUserJournalQueue = "integration-user-events.fifo", - berAwsPrekeyTable = "integration-brig-prekeys", - berAwsS3Bucket = "dummy-bucket", - berAwsQueueName = "integration-gundeck-events", - berBrigInternalEvents = "integration-brig-events-internal", - berEmailSMSSesQueue = "integration-brig-events", - berEmailSMSEmailSender = "backend-integration@wire.com", - berGalleyJournal = "integration-team-events.fifo", - berVHost = "/", - berNginzSslPort = 8443 - } - -staticPortsA :: Map.Map Service Word16 -staticPortsA = - Map.fromList - [ (Brig, 8082), - (Galley, 8085), - (Gundeck, 8086), - (Cannon, 8083), - (Cargohold, 8084), - (Spar, 8088), - (BackgroundWorker, 8089), - (Nginz, 8080), - (Stern, 8091) - ] - -backendB :: BackendResource -backendB = - BackendResource - { berBrigKeyspace = "brig_test2", - berGalleyKeyspace = "galley_test2", - berSparKeyspace = "spar_test2", - berGundeckKeyspace = "gundeck_test2", - berElasticsearchIndex = "directory2_test", - berFederatorInternal = 9097, - berFederatorExternal = 9098, - berDomain = "b.example.com", - berAwsUserJournalQueue = "integration-user-events.fifo2", - berAwsPrekeyTable = "integration-brig-prekeys2", - berAwsS3Bucket = "dummy-bucket2", - berAwsQueueName = "integration-gundeck-events2", - berBrigInternalEvents = "integration-brig-events-internal2", - berEmailSMSSesQueue = "integration-brig-events2", - berEmailSMSEmailSender = "backend-integration2@wire.com", - berGalleyJournal = "integration-team-events.fifo2", - -- FUTUREWORK: set up vhosts in dev/ci for example.com and b.example.com - -- in case we want backendA and backendB to federate with a third backend - -- (because otherwise both queues will overlap) - berVHost = "/", - berNginzSslPort = 9443 - } - -staticPortsB :: Map.Map Service Word16 -staticPortsB = - Map.fromList - [ (Brig, 9082), - (Galley, 9085), - (Gundeck, 9086), - (Cannon, 9083), - (Cargohold, 9084), - (Spar, 9088), - (BackgroundWorker, 9089), - (Nginz, 9080), - (Stern, 9091) - ] - parentDir :: FilePath -> Maybe FilePath parentDir path = let dirs = splitPath path @@ -121,9 +41,6 @@ main = do Just projectRoot -> pure $ joinPath [projectRoot, "services/integration.yaml"] - genv <- createGlobalEnv cfg - env <- lowerCodensity $ mkEnv genv - args <- getArgs let run = case args of @@ -135,19 +52,11 @@ main = do (_, _, _, ph) <- createProcess cp exitWith =<< waitForProcess ph - runAppWithEnv env $ do - lowerCodensity $ do - let fedConfig = - def - { dbBrig = - setField - "optSettings.setFederationDomainConfigs" - [ object ["domain" .= backendA.berDomain, "search_policy" .= "full_search"], - object ["domain" .= backendB.berDomain, "search_policy" .= "full_search"] - ] - } - _modifyEnv <- - traverseConcurrentlyCodensity - (\(res, staticPorts, overrides) -> startDynamicBackend res staticPorts overrides) - [(backendA, staticPortsA, fedConfig), (backendB, staticPortsB, fedConfig)] - liftIO run + runCodensity (createGlobalEnv cfg >>= mkEnv) $ \env -> + runAppWithEnv env $ + lowerCodensity $ do + _modifyEnv <- + traverseConcurrentlyCodensity + (\r -> startDynamicBackend r mempty) + [backendA, backendB] + liftIO run diff --git a/integration/test/Testlib/Types.hs b/integration/test/Testlib/Types.hs index 1e0aa03ab16..ca5ac7043f5 100644 --- a/integration/test/Testlib/Types.hs +++ b/integration/test/Testlib/Types.hs @@ -1,36 +1,189 @@ +{- +NOTE: Don't import any other Testlib modules here. Use this module to break dependency cycles. +-} module Testlib.Types where +import Control.Concurrent (QSemN) import Control.Exception as E import Control.Monad.Base import Control.Monad.Catch import Control.Monad.Reader import Control.Monad.Trans.Control -import Data.Aeson (Value) +import Data.Aeson import Data.Aeson qualified as Aeson -import Data.Aeson.Encode.Pretty qualified as Aeson import Data.ByteString (ByteString) import Data.ByteString qualified as BS import Data.ByteString.Char8 qualified as C8 import Data.ByteString.Lazy qualified as L import Data.CaseInsensitive qualified as CI import Data.Default -import Data.Function ((&)) import Data.Functor -import Data.Hex import Data.IORef import Data.List +import Data.Map import Data.Map qualified as Map +import Data.Set (Set) +import Data.Set qualified as Set +import Data.String import Data.Text qualified as T import Data.Text.Encoding qualified as T +import Data.Time +import Data.Word +import GHC.Generics (Generic) import GHC.Records import GHC.Stack import Network.HTTP.Client qualified as HTTP import Network.HTTP.Types qualified as HTTP import Network.URI -import Testlib.Env -import Testlib.Printing +import UnliftIO (MonadUnliftIO) import Prelude +data ResourcePool a = ResourcePool + { sem :: QSemN, + resources :: IORef (Set.Set a), + onAcquire :: a -> IO () + } + +data BackendResource = BackendResource + { berName :: BackendName, + berBrigKeyspace :: String, + berGalleyKeyspace :: String, + berSparKeyspace :: String, + berGundeckKeyspace :: String, + berElasticsearchIndex :: String, + berFederatorInternal :: Word16, + berFederatorExternal :: Word16, + berDomain :: String, + berAwsUserJournalQueue :: String, + berAwsPrekeyTable :: String, + berAwsS3Bucket :: String, + berAwsQueueName :: String, + berBrigInternalEvents :: String, + berEmailSMSSesQueue :: String, + berEmailSMSEmailSender :: String, + berGalleyJournal :: String, + berVHost :: String, + berNginzSslPort :: Word16, + berNginzHttp2Port :: Word16, + berInternalServicePorts :: forall a. Num a => Service -> a + } + +instance Eq BackendResource where + a == b = a.berName == b.berName + +instance Ord BackendResource where + a `compare` b = a.berName `compare` b.berName + +data DynamicBackendConfig = DynamicBackendConfig + { domain :: String, + federatorExternalPort :: Word16 + } + deriving (Show, Generic) + +instance FromJSON DynamicBackendConfig + +data RabbitMQConfig = RabbitMQConfig + { host :: String, + adminPort :: Word16 + } + deriving (Show) + +instance FromJSON RabbitMQConfig where + parseJSON = + withObject "RabbitMQConfig" $ \ob -> + RabbitMQConfig + <$> ob .: fromString "host" + <*> ob .: fromString "adminPort" + +-- | Initialised once per testsuite. +data GlobalEnv = GlobalEnv + { gServiceMap :: Map String ServiceMap, + gDomain1 :: String, + gDomain2 :: String, + gDynamicDomains :: [String], + gDefaultAPIVersion :: Int, + gManager :: HTTP.Manager, + gServicesCwdBase :: Maybe FilePath, + gRemovalKeyPath :: FilePath, + gBackendResourcePool :: ResourcePool BackendResource, + gRabbitMQConfig :: RabbitMQConfig, + gTempDir :: FilePath + } + +data IntegrationConfig = IntegrationConfig + { backendOne :: BackendConfig, + backendTwo :: BackendConfig, + dynamicBackends :: Map String DynamicBackendConfig, + rabbitmq :: RabbitMQConfig, + cassandra :: HostPort + } + deriving (Show, Generic) + +instance FromJSON IntegrationConfig where + parseJSON = + withObject "IntegrationConfig" $ \o -> + IntegrationConfig + <$> parseJSON (Object o) + <*> o .: fromString "backendTwo" + <*> o .: fromString "dynamicBackends" + <*> o .: fromString "rabbitmq" + <*> o .: fromString "cassandra" + +data ServiceMap = ServiceMap + { brig :: HostPort, + backgroundWorker :: HostPort, + cannon :: HostPort, + cargohold :: HostPort, + federatorInternal :: HostPort, + federatorExternal :: HostPort, + galley :: HostPort, + gundeck :: HostPort, + nginz :: HostPort, + spar :: HostPort, + proxy :: HostPort, + stern :: HostPort + } + deriving (Show, Generic) + +instance FromJSON ServiceMap + +data BackendConfig = BackendConfig + { beServiceMap :: ServiceMap, + originDomain :: String + } + deriving (Show, Generic) + +instance FromJSON BackendConfig where + parseJSON v = + BackendConfig + <$> parseJSON v + <*> withObject "BackendConfig" (\ob -> ob .: fromString "originDomain") v + +data HostPort = HostPort + { host :: String, + port :: Word16 + } + deriving (Show, Generic) + +instance FromJSON HostPort + +-- | Initialised once per test. +data Env = Env + { serviceMap :: Map String ServiceMap, + domain1 :: String, + domain2 :: String, + dynamicDomains :: [String], + defaultAPIVersion :: Int, + manager :: HTTP.Manager, + servicesCwdBase :: Maybe FilePath, + removalKeyPath :: FilePath, + prekeys :: IORef [(Int, String)], + lastPrekeys :: IORef [String], + mls :: IORef MLSState, + resourcePool :: ResourcePool BackendResource, + rabbitMQConfig :: RabbitMQConfig + } + data Response = Response { jsonBody :: Maybe Aeson.Value, body :: ByteString, @@ -43,6 +196,38 @@ data Response = Response instance HasField "json" Response (App Aeson.Value) where getField response = maybe (assertFailure "Response has no json body") pure response.jsonBody +data ClientIdentity = ClientIdentity + { domain :: String, + user :: String, + client :: String + } + deriving (Show, Eq, Ord) + +newtype Ciphersuite = Ciphersuite {code :: String} + deriving (Eq, Ord, Show) + +instance Default Ciphersuite where + def = Ciphersuite "0x0001" + +data ClientGroupState = ClientGroupState + { group :: Maybe ByteString, + keystore :: Maybe ByteString + } + deriving (Show) + +data MLSState = MLSState + { baseDir :: FilePath, + members :: Set ClientIdentity, + -- | users expected to receive a welcome message after the next commit + newMembers :: Set ClientIdentity, + groupId :: Maybe String, + convId :: Maybe Value, + clientGroupState :: Map ClientIdentity ClientGroupState, + epoch :: Word64, + ciphersuite :: Ciphersuite + } + deriving (Show) + showRequest :: HTTP.Request -> String showRequest r = T.unpack (T.decodeUtf8 (HTTP.method r)) @@ -61,30 +246,6 @@ getRequestBody req = case HTTP.requestBody req of HTTP.RequestBodyBS bs -> pure bs _ -> Nothing -prettyResponse :: Response -> String -prettyResponse r = - unlines $ - concat - [ pure $ colored yellow "request: \n" <> showRequest r.request, - pure $ colored yellow "request headers: \n" <> showHeaders (HTTP.requestHeaders r.request), - case getRequestBody r.request of - Nothing -> [] - Just b -> - [ colored yellow "request body:", - T.unpack . T.decodeUtf8 $ case Aeson.decode (L.fromStrict b) of - Just v -> L.toStrict (Aeson.encodePretty (v :: Aeson.Value)) - Nothing -> hex b - ], - pure $ colored blue "response status: " <> show r.status, - pure $ colored blue "response body:", - pure $ - ( T.unpack . T.decodeUtf8 $ - case r.jsonBody of - Just b -> L.toStrict (Aeson.encodePretty b) - Nothing -> r.body - ) - ] - data AssertionFailure = AssertionFailure { callstack :: CallStack, response :: Maybe Response, @@ -107,14 +268,11 @@ newtype App a = App {unApp :: ReaderT Env IO a} MonadCatch, MonadThrow, MonadReader Env, - MonadBase IO + MonadBase IO, + MonadUnliftIO, + MonadBaseControl IO ) -instance MonadBaseControl IO App where - type StM App a = StM (ReaderT Env IO) a - liftBaseWith f = App (liftBaseWith (\g -> f (g . unApp))) - restoreM = App . restoreM - runAppWithEnv :: Env -> App a -> IO a runAppWithEnv e m = runReaderT (unApp m) e @@ -129,7 +287,7 @@ appToIOKleisli k = do env <- ask pure $ \a -> runAppWithEnv env (k a) -getServiceMap :: String -> App ServiceMap +getServiceMap :: HasCallStack => String -> App ServiceMap getServiceMap fedDomain = do env <- ask assertJust ("Could not find service map for federation domain: " <> fedDomain) (Map.lookup fedDomain (env.serviceMap)) @@ -194,15 +352,16 @@ modifyFailure modifyAssertion action = do ) data ServiceOverrides = ServiceOverrides - { dbBrig :: Value -> App Value, - dbCannon :: Value -> App Value, - dbCargohold :: Value -> App Value, - dbGalley :: Value -> App Value, - dbGundeck :: Value -> App Value, - dbNginz :: Value -> App Value, - dbSpar :: Value -> App Value, - dbBackgroundWorker :: Value -> App Value, - dbStern :: Value -> App Value + { brigCfg :: Value -> App Value, + cannonCfg :: Value -> App Value, + cargoholdCfg :: Value -> App Value, + galleyCfg :: Value -> App Value, + gundeckCfg :: Value -> App Value, + nginzCfg :: Value -> App Value, + sparCfg :: Value -> App Value, + backgroundWorkerCfg :: Value -> App Value, + sternCfg :: Value -> App Value, + federatorInternalCfg :: Value -> App Value } instance Default ServiceOverrides where @@ -211,15 +370,16 @@ instance Default ServiceOverrides where instance Semigroup ServiceOverrides where a <> b = ServiceOverrides - { dbBrig = dbBrig a >=> dbBrig b, - dbCannon = dbCannon a >=> dbCannon b, - dbCargohold = dbCargohold a >=> dbCargohold b, - dbGalley = dbGalley a >=> dbGalley b, - dbGundeck = dbGundeck a >=> dbGundeck b, - dbNginz = dbNginz a >=> dbNginz b, - dbSpar = dbSpar a >=> dbSpar b, - dbBackgroundWorker = dbBackgroundWorker a >=> dbBackgroundWorker b, - dbStern = dbStern a >=> dbStern b + { brigCfg = brigCfg a >=> brigCfg b, + cannonCfg = cannonCfg a >=> cannonCfg b, + cargoholdCfg = cargoholdCfg a >=> cargoholdCfg b, + galleyCfg = galleyCfg a >=> galleyCfg b, + gundeckCfg = gundeckCfg a >=> gundeckCfg b, + nginzCfg = nginzCfg a >=> nginzCfg b, + sparCfg = sparCfg a >=> sparCfg b, + backgroundWorkerCfg = backgroundWorkerCfg a >=> backgroundWorkerCfg b, + sternCfg = sternCfg a >=> sternCfg b, + federatorInternalCfg = federatorInternalCfg a >=> federatorInternalCfg b } instance Monoid ServiceOverrides where @@ -228,42 +388,87 @@ instance Monoid ServiceOverrides where defaultServiceOverrides :: ServiceOverrides defaultServiceOverrides = ServiceOverrides - { dbBrig = pure, - dbCannon = pure, - dbCargohold = pure, - dbGalley = pure, - dbGundeck = pure, - dbNginz = pure, - dbSpar = pure, - dbBackgroundWorker = pure, - dbStern = pure + { brigCfg = pure, + cannonCfg = pure, + cargoholdCfg = pure, + galleyCfg = pure, + gundeckCfg = pure, + nginzCfg = pure, + sparCfg = pure, + backgroundWorkerCfg = pure, + sternCfg = pure, + federatorInternalCfg = pure } -defaultServiceOverridesToMap :: Map.Map Service (Value -> App Value) -defaultServiceOverridesToMap = ([minBound .. maxBound] <&> (,pure)) & Map.fromList - --- | Overrides the service configurations with the given overrides. --- e.g. --- `let overrides = --- def --- { dbBrig = --- setField "optSettings.setFederationStrategy" "allowDynamic" --- >=> removeField "optSettings.setFederationDomainConfigs" --- } --- withOverrides overrides defaultServiceOverridesToMap` -withOverrides :: ServiceOverrides -> Map.Map Service (Value -> App Value) -> Map.Map Service (Value -> App Value) -withOverrides overrides = - Map.mapWithKey - ( \svr f -> - case svr of - Brig -> f >=> overrides.dbBrig - Cannon -> f >=> overrides.dbCannon - Cargohold -> f >=> overrides.dbCargohold - Galley -> f >=> overrides.dbGalley - Gundeck -> f >=> overrides.dbGundeck - Nginz -> f >=> overrides.dbNginz - Spar -> f >=> overrides.dbSpar - BackgroundWorker -> f >=> overrides.dbBackgroundWorker - Stern -> f >=> overrides.dbStern - FederatorInternal -> f +lookupConfigOverride :: ServiceOverrides -> Service -> (Value -> App Value) +lookupConfigOverride overrides = \case + Brig -> overrides.brigCfg + Cannon -> overrides.cannonCfg + Cargohold -> overrides.cargoholdCfg + Galley -> overrides.galleyCfg + Gundeck -> overrides.gundeckCfg + Nginz -> overrides.nginzCfg + Spar -> overrides.sparCfg + BackgroundWorker -> overrides.backgroundWorkerCfg + Stern -> overrides.sternCfg + FederatorInternal -> overrides.federatorInternalCfg + +data Service = Brig | Galley | Cannon | Gundeck | Cargohold | Nginz | Spar | BackgroundWorker | Stern | FederatorInternal + deriving + ( Show, + Eq, + Ord, + Enum, + Bounded ) + +serviceName :: Service -> String +serviceName = \case + Brig -> "brig" + Galley -> "galley" + Cannon -> "cannon" + Gundeck -> "gundeck" + Cargohold -> "cargohold" + Nginz -> "nginz" + Spar -> "spar" + BackgroundWorker -> "backgroundWorker" + Stern -> "stern" + FederatorInternal -> "federator" + +-- | Converts the service name to kebab-case. +configName :: Service -> String +configName = \case + Brig -> "brig" + Galley -> "galley" + Cannon -> "cannon" + Gundeck -> "gundeck" + Cargohold -> "cargohold" + Nginz -> "nginz" + Spar -> "spar" + BackgroundWorker -> "background-worker" + Stern -> "stern" + FederatorInternal -> "federator" + +data BackendName + = BackendA + | BackendB + | -- | The index of dynamic backends begin with 1 + DynamicBackend Int + deriving (Show, Eq, Ord) + +allServices :: [Service] +allServices = [minBound .. maxBound] + +newtype TestSuiteReport = TestSuiteReport {cases :: [TestCaseReport]} + deriving (Eq, Show) + deriving newtype (Semigroup, Monoid) + +data TestCaseReport = TestCaseReport + { name :: String, + result :: TestResult, + time :: NominalDiffTime + } + deriving (Eq, Show) + +data TestResult = TestSuccess | TestFailure String + deriving (Eq, Show) diff --git a/integration/test/Testlib/XML.hs b/integration/test/Testlib/XML.hs new file mode 100644 index 00000000000..35235dec4d6 --- /dev/null +++ b/integration/test/Testlib/XML.hs @@ -0,0 +1,60 @@ +module Testlib.XML where + +import Data.Array qualified as Array +import Data.Fixed +import Data.Time +import Testlib.Types +import Text.Regex.Base qualified as Regex +import Text.Regex.TDFA.String qualified as Regex +import Text.XML.Light +import Prelude + +saveXMLReport :: TestSuiteReport -> FilePath -> IO () +saveXMLReport report output = + writeFile output $ showTopElement $ xmlReport report + +xmlReport :: TestSuiteReport -> Element +xmlReport report = + unode + "testsuites" + ( Attr (unqual "name") "wire-server", + testSuiteElements + ) + where + testSuiteElements = + unode + "testsuite" + ( attrs, + map encodeTestCase report.cases + ) + attrs = + [ Attr (unqual "name") "integration", + Attr (unqual "tests") $ show $ length report.cases, + Attr (unqual "failures") $ show $ length $ filter (\testCase -> testCase.result /= TestSuccess) report.cases, + Attr (unqual "time") $ showFixed True $ nominalDiffTimeToSeconds $ sum $ map (.time) report.cases + ] + +encodeTestCase :: TestCaseReport -> Element +encodeTestCase TestCaseReport {..} = + unode "testcase" (attrs, content) + where + attrs = + [ Attr (unqual "name") name, + Attr (unqual "time") (showFixed True (nominalDiffTimeToSeconds time)) + ] + content = case result of + TestSuccess -> [] + TestFailure msg -> [failure msg] + failure msg = unode "failure" (blank_cdata {cdData = dropConsoleFormatting msg}) + + -- Drops ANSI control characters which might be used to set colors. + -- Including these breaks XML, there is not much point encoding them. + dropConsoleFormatting input = + let regex = Regex.makeRegex "\x1b\\[[0-9;]*[mGKHF]" :: Regex.Regex + matches = Regex.matchAll regex input + dropMatch (offset, len) input' = + let (begining, rest) = splitAt offset input' + (_, end) = splitAt len rest + in begining <> end + matchTuples = map (Array.! 0) matches + in foldr dropMatch input matchTuples diff --git a/libs/brig-types/brig-types.cabal b/libs/brig-types/brig-types.cabal index fb0afa4af77..faac2030515 100644 --- a/libs/brig-types/brig-types.cabal +++ b/libs/brig-types/brig-types.cabal @@ -156,8 +156,8 @@ test-suite brig-types-tests , brig-types , bytestring-conversion >=0.3.1 , imports + , openapi3 , QuickCheck >=2.9 - , swagger2 >=2.5 , tasty , tasty-hunit , tasty-quickcheck diff --git a/libs/brig-types/default.nix b/libs/brig-types/default.nix index 49028b0de48..173b83591b0 100644 --- a/libs/brig-types/default.nix +++ b/libs/brig-types/default.nix @@ -13,8 +13,8 @@ , gitignoreSource , imports , lib +, openapi3 , QuickCheck -, swagger2 , tasty , tasty-hunit , tasty-quickcheck @@ -47,8 +47,8 @@ mkDerivation { base bytestring-conversion imports + openapi3 QuickCheck - swagger2 tasty tasty-hunit tasty-quickcheck diff --git a/libs/brig-types/test/unit/Test/Brig/Roundtrip.hs b/libs/brig-types/test/unit/Test/Brig/Roundtrip.hs index 9ea421c6c2f..13cfc3570e6 100644 --- a/libs/brig-types/test/unit/Test/Brig/Roundtrip.hs +++ b/libs/brig-types/test/unit/Test/Brig/Roundtrip.hs @@ -20,7 +20,7 @@ module Test.Brig.Roundtrip where import Data.Aeson (FromJSON, ToJSON, parseJSON, toJSON) import Data.Aeson.Types (parseEither) import Data.ByteString.Conversion -import Data.Swagger (ToSchema, validatePrettyToJSON) +import Data.OpenApi (ToSchema, validatePrettyToJSON) import Imports import Test.Tasty (TestTree) import Test.Tasty.QuickCheck (Arbitrary, counterexample, testProperty, (.&&.), (===)) @@ -40,7 +40,7 @@ testRoundTrip = testProperty msg trip testRoundTripWithSwagger :: forall a. - (Arbitrary a, Typeable a, ToJSON a, FromJSON a, ToSchema a, Eq a, Show a) => + (Arbitrary a, ToJSON a, FromJSON a, ToSchema a, Eq a, Show a) => TestTree testRoundTripWithSwagger = testProperty msg (trip .&&. scm) where diff --git a/libs/deriving-swagger2/default.nix b/libs/deriving-swagger2/default.nix index fdf39de254a..5359dbec579 100644 --- a/libs/deriving-swagger2/default.nix +++ b/libs/deriving-swagger2/default.nix @@ -8,12 +8,12 @@ , gitignoreSource , imports , lib -, swagger2 +, openapi3 }: mkDerivation { pname = "deriving-swagger2"; version = "0.1.0"; src = gitignoreSource ./.; - libraryHaskellDepends = [ base extra imports swagger2 ]; + libraryHaskellDepends = [ base extra imports openapi3 ]; license = lib.licenses.agpl3Only; } diff --git a/libs/deriving-swagger2/deriving-swagger2.cabal b/libs/deriving-swagger2/deriving-swagger2.cabal index 4d68184d8c4..6e5b3f9de4a 100644 --- a/libs/deriving-swagger2/deriving-swagger2.cabal +++ b/libs/deriving-swagger2/deriving-swagger2.cabal @@ -62,9 +62,9 @@ library -Wredundant-constraints -Wunused-packages build-depends: - base >=4 && <5 + base >=4 && <5 , extra , imports - , swagger2 >=0.6 + , openapi3 default-language: GHC2021 diff --git a/libs/deriving-swagger2/src/Deriving/Swagger.hs b/libs/deriving-swagger2/src/Deriving/Swagger.hs index 3f0fc3b56f9..95a0c121a3e 100644 --- a/libs/deriving-swagger2/src/Deriving/Swagger.hs +++ b/libs/deriving-swagger2/src/Deriving/Swagger.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE RankNTypes #-} + -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -22,10 +24,10 @@ module Deriving.Swagger where import Data.Char qualified as Char import Data.Kind (Constraint) import Data.List.Extra (stripSuffix) +import Data.OpenApi.Internal.Schema (GToSchema) +import Data.OpenApi.Internal.TypeShape +import Data.OpenApi.Schema import Data.Proxy (Proxy (..)) -import Data.Swagger (SchemaOptions, ToSchema (..), constructorTagModifier, defaultSchemaOptions, fieldLabelModifier, genericDeclareNamedSchema) -import Data.Swagger.Internal.Schema (GToSchema) -import Data.Swagger.Internal.TypeShape (TypeHasSimpleShape) import GHC.Generics (Generic (Rep)) import GHC.TypeLits (ErrorMessage (Text), KnownSymbol, Symbol, TypeError, symbolVal) import Imports @@ -81,6 +83,7 @@ import Imports -- | A newtype wrapper which gives ToSchema instances with modified options. -- 't' has to have an instance of the 'SwaggerOptions' class. newtype CustomSwagger t a = CustomSwagger {unCustomSwagger :: a} + deriving (Generic, Typeable) class SwaggerOptions xs where swaggerOptions :: SchemaOptions @@ -94,14 +97,7 @@ instance (StringModifier f, SwaggerOptions xs) => SwaggerOptions (FieldLabelModi instance (StringModifier f, SwaggerOptions xs) => SwaggerOptions (ConstructorTagModifier f ': xs) where swaggerOptions = (swaggerOptions @xs) {constructorTagModifier = getStringModifier @f} -instance - ( SwaggerOptions t, - Generic a, - GToSchema (Rep a), - TypeHasSimpleShape a "genericDeclareNamedSchemaUnrestricted" - ) => - ToSchema (CustomSwagger t a) - where +instance (SwaggerOptions t, Generic a, Typeable a, GToSchema (Rep a), Typeable (CustomSwagger t a), TypeHasSimpleShape a "genericDeclareNamedSchemaUnrestricted") => ToSchema (CustomSwagger t a) where declareNamedSchema _ = genericDeclareNamedSchema (swaggerOptions @t) (Proxy @a) -- ** Specify __what__ to modify diff --git a/libs/extended/default.nix b/libs/extended/default.nix index d2fd00ab9cb..b44a955a35f 100644 --- a/libs/extended/default.nix +++ b/libs/extended/default.nix @@ -27,8 +27,8 @@ , servant , servant-client , servant-client-core +, servant-openapi3 , servant-server -, servant-swagger , temporary , text , tinylog @@ -60,8 +60,8 @@ mkDerivation { servant servant-client servant-client-core + servant-openapi3 servant-server - servant-swagger text tinylog unliftio diff --git a/libs/extended/extended.cabal b/libs/extended/extended.cabal index 2271f8d1312..389b59b9447 100644 --- a/libs/extended/extended.cabal +++ b/libs/extended/extended.cabal @@ -98,8 +98,8 @@ library , servant , servant-client , servant-client-core + , servant-openapi3 , servant-server - , servant-swagger , text , tinylog , unliftio diff --git a/libs/extended/src/Network/AMQP/Extended.hs b/libs/extended/src/Network/AMQP/Extended.hs index f2c98a84a03..502cdb95a77 100644 --- a/libs/extended/src/Network/AMQP/Extended.hs +++ b/libs/extended/src/Network/AMQP/Extended.hs @@ -144,7 +144,6 @@ openConnectionWithRetries l RabbitMqOpts {..} hooks = do chanExceptionHandler :: Q.Connection -> SomeException -> m () chanExceptionHandler conn e = do - logException l "RabbitMQ channel closed" e hooks.onChannelException e `catch` logException l "onChannelException hook threw an exception" case (Q.isNormalChannelClose e, fromException e) of (True, _) -> @@ -153,7 +152,9 @@ openConnectionWithRetries l RabbitMqOpts {..} hooks = do (_, Just (Q.ConnectionClosedException {})) -> Log.info l $ Log.msg (Log.val "RabbitMQ connection is closed, not attempting to reopen channel") - _ -> openChan conn + _ -> do + logException l "RabbitMQ channel closed" e + openChan conn logException :: (MonadIO m) => Logger -> String -> SomeException -> m () logException l m (SomeException e) = do diff --git a/libs/extended/src/Network/RabbitMqAdmin.hs b/libs/extended/src/Network/RabbitMqAdmin.hs index 3b65d0a5f31..68251f97f23 100644 --- a/libs/extended/src/Network/RabbitMqAdmin.hs +++ b/libs/extended/src/Network/RabbitMqAdmin.hs @@ -11,6 +11,8 @@ type RabbitMqBasicAuth = BasicAuth "RabbitMq Management" BasicAuthData type VHost = Text +type QueueName = Text + -- | Upstream Docs: -- https://rawcdn.githack.com/rabbitmq/rabbitmq-server/v3.12.0/deps/rabbitmq_management/priv/www/api/index.html data AdminAPI route = AdminAPI @@ -22,7 +24,14 @@ data AdminAPI route = AdminAPI :- "api" :> "queues" :> Capture "vhost" VHost - :> Get '[JSON] [Queue] + :> Get '[JSON] [Queue], + deleteQueue :: + route + :- "api" + :> "queues" + :> Capture "vhost" VHost + :> Capture "queue" QueueName + :> DeleteNoContent } deriving (Generic) diff --git a/libs/extended/src/Servant/API/Extended.hs b/libs/extended/src/Servant/API/Extended.hs index 322b029f1b4..c1e87f38beb 100644 --- a/libs/extended/src/Servant/API/Extended.hs +++ b/libs/extended/src/Servant/API/Extended.hs @@ -31,8 +31,8 @@ import Network.Wai import Servant.API import Servant.API.ContentTypes import Servant.API.Modifiers +import Servant.OpenApi import Servant.Server.Internal -import Servant.Swagger import Prelude () -- | Like 'ReqBody'', but takes parsers that throw 'ServerError', not 'String'. @tag@ is used @@ -108,10 +108,10 @@ instance Right v -> pure v instance - HasSwagger (ReqBody' '[Required, Strict] cts a :> api) => - HasSwagger (ReqBodyCustomError cts tag a :> api) + HasOpenApi (ReqBody' '[Required, Strict] cts a :> api) => + HasOpenApi (ReqBodyCustomError cts tag a :> api) where - toSwagger Proxy = toSwagger (Proxy @(ReqBody' '[Required, Strict] cts a :> api)) + toOpenApi Proxy = toOpenApi (Proxy @(ReqBody' '[Required, Strict] cts a :> api)) instance RoutesToPaths rest => RoutesToPaths (ReqBodyCustomError' mods list tag a :> rest) where getRoutes = getRoutes @rest diff --git a/libs/extended/src/Servant/API/Extended/RawM.hs b/libs/extended/src/Servant/API/Extended/RawM.hs index 9f1e1a6395f..f5108d12329 100644 --- a/libs/extended/src/Servant/API/Extended/RawM.hs +++ b/libs/extended/src/Servant/API/Extended/RawM.hs @@ -10,11 +10,11 @@ import Data.Proxy import Imports import Network.Wai import Servant.API (Raw) +import Servant.OpenApi import Servant.Server hiding (respond) import Servant.Server.Internal.Delayed import Servant.Server.Internal.RouteResult import Servant.Server.Internal.Router -import Servant.Swagger type ApplicationM m = Request -> (Response -> IO ResponseReceived) -> m ResponseReceived @@ -51,8 +51,8 @@ instance HasServer RawM context where hoistServerWithContext _ _ f srvM req respond = f (srvM req respond) -instance HasSwagger RawM where - toSwagger _ = toSwagger (Proxy @Raw) +instance HasOpenApi RawM where + toOpenApi _ = toOpenApi (Proxy @Raw) instance RoutesToPaths RawM where getRoutes = [] diff --git a/libs/galley-types/src/Galley/Types/Conversations/One2One.hs b/libs/galley-types/src/Galley/Types/Conversations/One2One.hs index 808fb59e5e5..2101a27a600 100644 --- a/libs/galley-types/src/Galley/Types/Conversations/One2One.hs +++ b/libs/galley-types/src/Galley/Types/Conversations/One2One.hs @@ -30,6 +30,7 @@ import Data.UUID (UUID) import Data.UUID qualified as UUID import Data.UUID.Tagged qualified as U import Imports +import Wire.API.User -- | The hash function used to obtain the 1-1 conversation ID for a pair of users. -- @@ -39,8 +40,9 @@ hash = convert . Crypto.hash @ByteString @Crypto.SHA256 -- | A randomly-generated UUID to use as a namespace for the UUIDv5 of 1-1 -- conversation IDs -namespace :: UUID -namespace = UUID.fromWords 0x9a51edb8 0x060c0d9a 0x0c2950a8 0x5d152982 +namespace :: BaseProtocolTag -> UUID +namespace BaseProtocolProteusTag = UUID.fromWords 0x9a51edb8 0x060c0d9a 0x0c2950a8 0x5d152982 +namespace BaseProtocolMLSTag = UUID.fromWords 0x95589dd5 0xb04540dc 0xa6aadd9c 0x4fad1c2f compareDomains :: Ord a => Qualified a -> Qualified a -> Ordering compareDomains (Qualified a1 dom1) (Qualified a2 dom2) = @@ -88,13 +90,13 @@ quidToByteString (Qualified uid domain) = toByteString' uid <> toByteString' dom -- the most significant bit of the octet at index 16) is 0, and B otherwise. -- This is well-defined, because we assumed the number of bits of x to be -- strictly larger than 128. -one2OneConvId :: Qualified UserId -> Qualified UserId -> Qualified ConvId -one2OneConvId a b = case compareDomains a b of - GT -> one2OneConvId b a +one2OneConvId :: BaseProtocolTag -> Qualified UserId -> Qualified UserId -> Qualified ConvId +one2OneConvId protocol a b = case compareDomains a b of + GT -> one2OneConvId protocol b a _ -> let c = mconcat - [ L.toStrict (UUID.toByteString namespace), + [ L.toStrict (UUID.toByteString (namespace protocol)), quidToByteString a, quidToByteString b ] diff --git a/libs/galley-types/src/Galley/Types/Teams.hs b/libs/galley-types/src/Galley/Types/Teams.hs index afbdf62505c..f61fa764442 100644 --- a/libs/galley-types/src/Galley/Types/Teams.hs +++ b/libs/galley-types/src/Galley/Types/Teams.hs @@ -41,6 +41,7 @@ module Galley.Types.Teams flagOutlookCalIntegration, flagMLS, flagMlsE2EId, + flagMlsMigration, Defaults (..), ImplicitLockStatus (..), unImplicitLockStatus, @@ -163,7 +164,8 @@ data FeatureFlags = FeatureFlags _flagTeamFeatureSearchVisibilityInbound :: !(Defaults (ImplicitLockStatus SearchVisibilityInboundConfig)), _flagMLS :: !(Defaults (ImplicitLockStatus MLSConfig)), _flagOutlookCalIntegration :: !(Defaults (WithStatus OutlookCalIntegrationConfig)), - _flagMlsE2EId :: !(Defaults (WithStatus MlsE2EIdConfig)) + _flagMlsE2EId :: !(Defaults (WithStatus MlsE2EIdConfig)), + _flagMlsMigration :: !(Defaults (WithStatus MlsMigrationConfig)) } deriving (Eq, Show, Generic) @@ -215,6 +217,7 @@ instance FromJSON FeatureFlags where <*> withImplicitLockStatusOrDefault obj "mls" <*> (fromMaybe (Defaults (defFeatureStatus @OutlookCalIntegrationConfig)) <$> (obj .:? "outlookCalIntegration")) <*> (fromMaybe (Defaults (defFeatureStatus @MlsE2EIdConfig)) <$> (obj .:? "mlsE2EId")) + <*> (fromMaybe (Defaults (defFeatureStatus @MlsMigrationConfig)) <$> (obj .:? "mlsMigration")) where withImplicitLockStatusOrDefault :: forall cfg. (IsFeatureConfig cfg, Schema.ToSchema cfg) => Object -> Key -> A.Parser (Defaults (ImplicitLockStatus cfg)) withImplicitLockStatusOrDefault obj fieldName = fromMaybe (Defaults (ImplicitLockStatus (defFeatureStatus @cfg))) <$> obj .:? fieldName @@ -237,6 +240,7 @@ instance ToJSON FeatureFlags where mls outlookCalIntegration mlsE2EId + mlsMigration ) = object [ "sso" .= sso, @@ -253,7 +257,8 @@ instance ToJSON FeatureFlags where "searchVisibilityInbound" .= searchVisibilityInbound, "mls" .= mls, "outlookCalIntegration" .= outlookCalIntegration, - "mlsE2EId" .= mlsE2EId + "mlsE2EId" .= mlsE2EId, + "mlsMigration" .= mlsMigration ] instance FromJSON FeatureSSO where diff --git a/libs/galley-types/test/unit/Test/Galley/Types.hs b/libs/galley-types/test/unit/Test/Galley/Types.hs index 96f2faa3a74..e4922fa5ef8 100644 --- a/libs/galley-types/test/unit/Test/Galley/Types.hs +++ b/libs/galley-types/test/unit/Test/Galley/Types.hs @@ -98,6 +98,7 @@ instance Arbitrary FeatureFlags where <*> fmap (fmap unlocked) arbitrary <*> arbitrary <*> arbitrary + <*> arbitrary where unlocked :: ImplicitLockStatus a -> ImplicitLockStatus a unlocked = ImplicitLockStatus . Public.setLockStatus Public.LockStatusUnlocked . _unImplicitLockStatus diff --git a/libs/gundeck-types/src/Gundeck/Types/Push/V2.hs b/libs/gundeck-types/src/Gundeck/Types/Push/V2.hs index c82067837af..9b26ab71543 100644 --- a/libs/gundeck-types/src/Gundeck/Types/Push/V2.hs +++ b/libs/gundeck-types/src/Gundeck/Types/Push/V2.hs @@ -86,6 +86,7 @@ import Data.Set qualified as Set import Imports hiding (cs) import Wire.API.Message (Priority (..)) import Wire.API.Push.V2.Token +import Wire.Arbitrary ----------------------------------------------------------------------------- -- Route @@ -97,7 +98,8 @@ data Route RouteAny | -- | Avoids causing push notification for mobile clients. RouteDirect - deriving (Eq, Ord, Enum, Bounded, Show) + deriving (Eq, Ord, Enum, Bounded, Show, Generic) + deriving (Arbitrary) via GenericUniform Route instance FromJSON Route where parseJSON (String "any") = pure RouteAny @@ -116,14 +118,21 @@ data Recipient = Recipient _recipientRoute :: !Route, _recipientClients :: !RecipientClients } - deriving (Show, Eq, Ord) + deriving (Show, Eq, Ord, Generic) data RecipientClients = -- | All clients of some user RecipientClientsAll | -- | An explicit list of clients RecipientClientsSome (List1 ClientId) - deriving (Eq, Show, Ord) + deriving (Eq, Show, Ord, Generic) + deriving (Arbitrary) via GenericUniform RecipientClients + +instance Semigroup RecipientClients where + RecipientClientsAll <> _ = RecipientClientsAll + _ <> RecipientClientsAll = RecipientClientsAll + RecipientClientsSome cs1 <> RecipientClientsSome cs2 = + RecipientClientsSome (cs1 <> cs2) makeLenses ''Recipient diff --git a/libs/http2-manager/src/HTTP2/Client/Manager.hs b/libs/http2-manager/src/HTTP2/Client/Manager.hs index f3818835835..2cf1278061b 100644 --- a/libs/http2-manager/src/HTTP2/Client/Manager.hs +++ b/libs/http2-manager/src/HTTP2/Client/Manager.hs @@ -3,6 +3,7 @@ module HTTP2.Client.Manager setCacheLimit, setSSLContext, setSSLRemoveTrailingDot, + setTCPConnectionTimeout, TLSEnabled, HostName, Port, @@ -14,6 +15,10 @@ module HTTP2.Client.Manager ConnectionAlreadyClosed (..), disconnectTarget, disconnectTargetWithTimeout, + startPersistentHTTP2Connection, + sendRequestWithConnection, + HTTP2Conn (..), + ConnectionAction (..), ) where diff --git a/libs/http2-manager/src/HTTP2/Client/Manager/Internal.hs b/libs/http2-manager/src/HTTP2/Client/Manager/Internal.hs index 96e9838d22c..884bc46959a 100644 --- a/libs/http2-manager/src/HTTP2/Client/Manager/Internal.hs +++ b/libs/http2-manager/src/HTTP2/Client/Manager/Internal.hs @@ -24,10 +24,13 @@ import qualified Data.Text as Text import qualified Data.Text.Encoding as Text import Data.Unique import Foreign.Marshal.Alloc (mallocBytes) +import GHC.IO.Exception import qualified Network.HTTP2.Client as HTTP2 import qualified Network.Socket as NS import qualified OpenSSL.Session as SSL +import System.IO.Error import qualified System.TimeManager +import System.Timeout import Prelude data HTTP2Conn = HTTP2Conn @@ -74,6 +77,8 @@ data Request = Request data Http2Manager = Http2Manager { connections :: TVar (Map Target HTTP2Conn), cacheLimit :: Int, + -- | In microseconds, defaults to 30s + tcpConnectionTimeout :: Int, sslContext :: SSL.SSLContext, sslRemoveTrailingDot :: Bool } @@ -95,6 +100,7 @@ http2ManagerWithSSLCtx :: SSL.SSLContext -> IO Http2Manager http2ManagerWithSSLCtx sslContext = do connections <- newTVarIO mempty let cacheLimit = 20 + tcpConnectionTimeout = 30_000_000 sslRemoveTrailingDot = False pure $ Http2Manager {..} @@ -122,6 +128,10 @@ setSSLContext ctx mgr = mgr {sslContext = ctx} setSSLRemoveTrailingDot :: Bool -> Http2Manager -> Http2Manager setSSLRemoveTrailingDot b mgr = mgr {sslRemoveTrailingDot = b} +-- | In microseconds +setTCPConnectionTimeout :: Int -> Http2Manager -> Http2Manager +setTCPConnectionTimeout n mgr = mgr {tcpConnectionTimeout = n} + -- | Does not check whether connection is actually running. Users should use -- 'withHTTP2Request'. This function is good for testing. sendRequestWithConnection :: HTTP2Conn -> HTTP2.Request -> (HTTP2.Response -> IO r) -> IO r @@ -182,7 +192,7 @@ getOrMakeConnection mgr@Http2Manager {..} target = do connect :: IO HTTP2Conn connect = do sendReqMVar <- newEmptyMVar - thread <- liftIO . async $ startPersistentHTTP2Connection sslContext target cacheLimit sslRemoveTrailingDot sendReqMVar + thread <- liftIO . async $ startPersistentHTTP2Connection sslContext target cacheLimit sslRemoveTrailingDot tcpConnectionTimeout sendReqMVar let newConn = HTTP2Conn thread (putMVar sendReqMVar CloseConnection) sendReqMVar (inserted, finalConn) <- atomically $ insertNewConn newConn unless inserted $ do @@ -262,12 +272,14 @@ startPersistentHTTP2Connection :: Int -> -- sslRemoveTrailingDot Bool -> + -- | TCP connect timeout in microseconds + Int -> -- MVar used to communicate requests or the need to close the connection. (We could use a -- queue here to queue several requests, but since the requestor has to wait for the -- response, it might as well block before sending off the request.) MVar ConnectionAction -> IO () -startPersistentHTTP2Connection ctx (tlsEnabled, hostname, port) cl removeTrailingDot sendReqMVar = do +startPersistentHTTP2Connection ctx (tlsEnabled, hostname, port) cl removeTrailingDot tcpConnectTimeout sendReqMVar = do liveReqs <- newIORef mempty let clientConfig = HTTP2.ClientConfig @@ -309,7 +321,7 @@ startPersistentHTTP2Connection ctx (tlsEnabled, hostname, port) cl removeTrailin | otherwise = Nothing handle cleanupThreadsWith $ - bracket (fst <$> getSocketTCP hostname port) NS.close $ \sock -> do + bracket connectTCPWithTimeout NS.close $ \sock -> do bracket (mkTransport sock transportConfig) cleanupTransport $ \transport -> bracket (allocHTTP2Config transport) HTTP2.freeSimpleConfig $ \http2Cfg -> do let runAction = HTTP2.run clientConfig http2Cfg $ \sendReq -> do @@ -354,6 +366,22 @@ startPersistentHTTP2Connection ctx (tlsEnabled, hostname, port) cl removeTrailin generalHandler threadKilled e = putMVar threadKilled e exceptionHandlers threadKilled = [Handler $ tooLateHandler threadKilled, Handler $ generalHandler threadKilled] + connectTCPWithTimeout :: IO NS.Socket + connectTCPWithTimeout = do + mSock <- timeout tcpConnectTimeout $ fst <$> getSocketTCP hostname port + case mSock of + Just sock -> pure sock + Nothing -> do + let errStr = + "TCP connection with " + <> Text.unpack (Text.decodeUtf8 hostname) + <> ":" + <> show port + <> " took longer than " + <> show tcpConnectTimeout + <> " microseconds" + throwIO $ mkIOError TimeExpired errStr Nothing Nothing + type LiveReqs = Map Unique (Async (), MVar SomeException) type SendReqFn = HTTP2.Request -> (HTTP2.Response -> IO ()) -> IO () diff --git a/libs/jwt-tools/test/Spec.hs b/libs/jwt-tools/test/Spec.hs index 7843d45dece..a2881bc5328 100644 --- a/libs/jwt-tools/test/Spec.hs +++ b/libs/jwt-tools/test/Spec.hs @@ -24,11 +24,19 @@ main :: IO () main = hspec $ do describe "generateDpopToken FFI when passing valid inputs" $ do it "should return an access token" $ do + -- FUTUREWORK(leif): fix this test, we need new valid test data, + -- this test exists mainly for debugging purposes + -- a functionality test is also coverd in the integration tests in services/brig/test/integration/API/User/Client.hs (`testCreateAccessToken`) + pending actual <- runExceptT $ generateDpopToken proof uid cid domain nonce uri method maxSkewSecs expires now pem print actual isRight actual `shouldBe` True describe "generateDpopToken FFI when passing a wrong nonce value" $ do it "should return BackendNonceMismatchError" $ do + -- FUTUREWORK(leif): fix this test, we need new valid test data, + -- this test exists mainly for debugging purposes + -- a functionality test is also coverd in the integration tests in services/brig/test/integration/API/User/Client.hs (`testCreateAccessToken`) + pending actual <- runExceptT $ generateDpopToken proof uid cid domain (Nonce "foobar") uri method maxSkewSecs expires now pem actual `shouldBe` Left BackendNonceMismatchError describe "toResult" $ do @@ -73,16 +81,16 @@ main = hspec $ do toResult Nothing Nothing `shouldBe` Left UnknownError where token = "" - proof = Proof "eyJhbGciOiJFZERTQSIsInR5cCI6ImRwb3Arand0IiwiandrIjp7Imt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoidUhNR0paWllUbU9zOEdiaTdaRUJLT255TnJYYnJzNTI1dE1QQUZoYjBzbyJ9fQ.eyJpYXQiOjE2Nzg4MDUyNTgsImV4cCI6MjA4ODc3MzI1OCwibmJmIjoxNjc4ODA1MjU4LCJzdWIiOiJpbTp3aXJlYXBwPVpHSmlNRGRsT1RRM1pESTVOREU0TUdFM09UQmhOVGN6WkdWbU16VmtaRFUvN2M2MzExYTFjNDNjMmJhNkB3aXJlLmNvbSIsImp0aSI6ImQyOWFkYTQ2LTBjMzYtNGNiMS05OTVlLWFlMWNiYTY5M2IzNCIsIm5vbmNlIjoiYzB0RWNtOUNUME00TXpKU04zRjRkMEZIV0V4TGIxUm5aMDQ1U3psSFduTSIsImh0bSI6IlBPU1QiLCJodHUiOiJodHRwczovL3dpcmUuZXhhbXBsZS5jb20vY2xpZW50cy84OTYzMDI3MDY5ODc3MTAzNTI2L2FjY2Vzcy10b2tlbiIsImNoYWwiOiJaa3hVV25GWU1HbHFUVVpVU1hnNFdHdHBOa3h1WWpWU09XRnlVRU5hVGxnIn0.8p0lvdOPjJ8ogjjLP6QtOo216qD9ujP7y9vSOhdYb-O8ikmW09N00gjCf0iGT-ZkxBT-LfDE3eQx27tWQ3JPBQ" - uid = UserId "dbb07e94-7d29-4180-a790-a573def35dd5" - cid = ClientId 8963027069877103526 + proof = Proof "eyJhbGciOiJFZERTQSIsInR5cCI6ImRwb3Arand0IiwiandrIjp7Imt0eSI6Ik9LUCIsImNydiI6IkVkMjU1MTkiLCJ4IjoidXE2c1hXcDdUM1E3YlNtUFd3eFNlRHJoUHFid1RfcTd4SFBQeGpGT0g5VSJ9fQ.eyJpYXQiOjE2OTQxMTc0MjgsImV4cCI6MTY5NDcyMjIyOCwibmJmIjoxNjk0MTE3NDIzLCJzdWIiOiJpbTp3aXJlYXBwPUlHOVl2enVXUUlLVWFSazEyRjVDSVEvOGUxODk2MjZlYWUwMTExZEBlbG5hLndpcmUubGluayIsImp0aSI6ImM0OGZmOTAyLTc5OGEtNDNjYi04YTk2LTE3NzM0NTgxNjIyMCIsIm5vbmNlIjoiR0FxNG5SajlSWVNzUnhoOVh1MWFtQSIsImh0bSI6IlBPU1QiLCJodHUiOiJodHRwczovL2VsbmEud2lyZS5saW5rL2NsaWVudHMvOGUxODk2MjZlYWUwMTExZC9hY2Nlc3MtdG9rZW4iLCJjaGFsIjoiMkxLbEFWMjR2VGtIMHlaaFdacEZrT01mSEE1d3lGQkgifQ.FW5i40CvndSSo3wQdA1DMUkGRmxk86cORAllwC2PCejVuk7TsdZuIKuJZFVa1VTJKWwNCPqPZ05Gsxxeh1DiDA" + uid = UserId "206f58bf-3b96-4082-9469-1935d85e4221" + cid = ClientId 10239098846720299293 domain = Domain "wire.com" - nonce = Nonce "c0tEcm9CT0M4MzJSN3F4d0FHWExLb1RnZ045SzlHWnM" - uri = Uri "https://wire.example.com/clients/8963027069877103526/access-token" + nonce = Nonce "GAq4nRj9RYSsRxh9Xu1amA" + uri = Uri "https://elna.wire.link/clients/10239098846720299293/access-token" method = POST maxSkewSecs = MaxSkewSecs 5 - now = NowEpoch 5435234232 - expires = ExpiryEpoch $ 2136351646 + now = NowEpoch 360 + expires = ExpiryEpoch 2136351646 pem = PemBundle $ "-----BEGIN PRIVATE KEY-----\n\ diff --git a/libs/metrics-wai/src/Data/Metrics/Servant.hs b/libs/metrics-wai/src/Data/Metrics/Servant.hs index 184609ca2c8..372cdc95055 100644 --- a/libs/metrics-wai/src/Data/Metrics/Servant.hs +++ b/libs/metrics-wai/src/Data/Metrics/Servant.hs @@ -154,5 +154,8 @@ instance where getRoutes = getRoutes @route <> getRoutes @routes +instance RoutesToPaths EmptyAPI where + getRoutes = mempty + instance RoutesToPaths Raw where getRoutes = [] diff --git a/libs/schema-profunctor/default.nix b/libs/schema-profunctor/default.nix index a498d97378b..bede1bdeae6 100644 --- a/libs/schema-profunctor/default.nix +++ b/libs/schema-profunctor/default.nix @@ -14,8 +14,8 @@ , insert-ordered-containers , lens , lib +, openapi3 , profunctors -, swagger2 , tasty , tasty-hunit , text @@ -34,8 +34,8 @@ mkDerivation { containers imports lens + openapi3 profunctors - swagger2 text transformers vector @@ -47,7 +47,7 @@ mkDerivation { imports insert-ordered-containers lens - swagger2 + openapi3 tasty tasty-hunit text diff --git a/libs/schema-profunctor/schema-profunctor.cabal b/libs/schema-profunctor/schema-profunctor.cabal index c9c534c0165..236a68a841b 100644 --- a/libs/schema-profunctor/schema-profunctor.cabal +++ b/libs/schema-profunctor/schema-profunctor.cabal @@ -69,8 +69,8 @@ library , containers , imports , lens + , openapi3 , profunctors - , swagger2 >=2 && <2.9 , text , transformers , vector @@ -139,8 +139,8 @@ test-suite schemas-tests , imports , insert-ordered-containers , lens + , openapi3 , schema-profunctor - , swagger2 , tasty , tasty-hunit , text diff --git a/libs/schema-profunctor/src/Data/Schema.hs b/libs/schema-profunctor/src/Data/Schema.hs index 6ff30f7ed38..9ae1187481f 100644 --- a/libs/schema-profunctor/src/Data/Schema.hs +++ b/libs/schema-profunctor/src/Data/Schema.hs @@ -100,12 +100,11 @@ import Data.List.NonEmpty (NonEmpty) import Data.List.NonEmpty qualified as NonEmpty import Data.Map qualified as Map import Data.Monoid hiding (Product) +import Data.OpenApi qualified as S +import Data.OpenApi.Declare qualified as S import Data.Profunctor (Star (..)) import Data.Proxy (Proxy (..)) import Data.Set qualified as Set -import Data.Swagger qualified as S -import Data.Swagger.Declare qualified as S -import Data.Swagger.Internal qualified as S import Data.Text qualified as T import Data.Text.Lazy qualified as TL import Data.Vector qualified as V @@ -624,7 +623,7 @@ text name = (A.withText (T.unpack name) pure) (pure . A.String) where - d = mempty & S.type_ ?~ S.SwaggerString + d = mempty & S.type_ ?~ S.OpenApiString -- | A schema for a textual value with possible failure. parsedText :: @@ -764,7 +763,7 @@ instance HasSchemaRef doc => HasField doc SwaggerDoc where where f ref = mempty - & S.type_ ?~ S.SwaggerObject + & S.type_ ?~ S.OpenApiObject & S.properties . at name ?~ ref & S.required .~ [name] @@ -780,8 +779,8 @@ instance HasSchemaRef ndoc => HasArray ndoc SwaggerDoc where f :: S.Referenced S.Schema -> S.Schema f ref = mempty - & S.type_ ?~ S.SwaggerArray - & S.items ?~ S.SwaggerItemsObject ref + & S.type_ ?~ S.OpenApiArray + & S.items ?~ S.OpenApiItemsObject ref instance HasSchemaRef ndoc => HasMap ndoc SwaggerDoc where mkMap = fmap f . schemaRef @@ -789,7 +788,7 @@ instance HasSchemaRef ndoc => HasMap ndoc SwaggerDoc where f :: S.Referenced S.Schema -> S.Schema f ref = mempty - & S.type_ ?~ S.SwaggerObject + & S.type_ ?~ S.OpenApiObject & S.additionalProperties ?~ S.AdditionalPropertiesSchema ref class HasMinItems s a where @@ -799,19 +798,19 @@ instance HasMinItems SwaggerDoc (Maybe Integer) where minItems = declared . S.minItems instance HasEnum Text NamedSwaggerDoc where - mkEnum = mkSwaggerEnum S.SwaggerString + mkEnum = mkSwaggerEnum S.OpenApiString instance HasEnum Integer NamedSwaggerDoc where - mkEnum = mkSwaggerEnum S.SwaggerInteger + mkEnum = mkSwaggerEnum S.OpenApiInteger instance HasEnum Natural NamedSwaggerDoc where - mkEnum = mkSwaggerEnum S.SwaggerInteger + mkEnum = mkSwaggerEnum S.OpenApiInteger instance HasEnum Bool NamedSwaggerDoc where - mkEnum = mkSwaggerEnum S.SwaggerBoolean + mkEnum = mkSwaggerEnum S.OpenApiBoolean mkSwaggerEnum :: - S.SwaggerType 'S.SwaggerKindSchema -> + S.OpenApiType -> Text -> [A.Value] -> NamedSwaggerDoc @@ -839,11 +838,12 @@ class ToSchema a where -- Newtype wrappers for deriving via newtype Schema a = Schema {getSchema :: a} + deriving (Generic) schemaToSwagger :: forall a. ToSchema a => Proxy a -> Declare S.NamedSchema schemaToSwagger _ = runDeclare (schemaDoc (schema @a)) -instance ToSchema a => S.ToSchema (Schema a) where +instance (Typeable a, ToSchema a) => S.ToSchema (Schema a) where declareNamedSchema _ = schemaToSwagger (Proxy @a) -- | JSON serialiser for an instance of 'ToSchema'. @@ -920,8 +920,14 @@ instance S.HasSchema d S.Schema => S.HasSchema (SchemaP d v w a b) S.Schema wher instance S.HasDescription NamedSwaggerDoc (Maybe Text) where description = declared . S.schema . S.description +instance S.HasDeprecated NamedSwaggerDoc (Maybe Bool) where + deprecated = declared . S.schema . S.deprecated + instance {-# OVERLAPPABLE #-} S.HasDescription s a => S.HasDescription (WithDeclare s) a where description = declared . S.description +instance {-# OVERLAPPABLE #-} S.HasDeprecated s a => S.HasDeprecated (WithDeclare s) a where + deprecated = declared . S.deprecated + instance {-# OVERLAPPABLE #-} S.HasExample s a => S.HasExample (WithDeclare s) a where example = declared . S.example diff --git a/libs/schema-profunctor/test/unit/Test/Data/Schema.hs b/libs/schema-profunctor/test/unit/Test/Data/Schema.hs index 5ee7af68a77..d29b69b2365 100644 --- a/libs/schema-profunctor/test/unit/Test/Data/Schema.hs +++ b/libs/schema-profunctor/test/unit/Test/Data/Schema.hs @@ -27,10 +27,10 @@ import Data.Aeson.QQ import Data.Aeson.Types qualified as A import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap import Data.List.NonEmpty (NonEmpty ((:|))) +import Data.OpenApi qualified as S +import Data.OpenApi.Declare qualified as S import Data.Proxy import Data.Schema hiding (getName) -import Data.Swagger qualified as S -import Data.Swagger.Declare qualified as S import Data.Text qualified as Text import Imports import Test.Tasty @@ -290,7 +290,7 @@ testNonEmptySchema = Nothing -> assertFailure "expected schema to have a property called 'nl'" Just (S.Ref _) -> assertFailure "expected property 'nl' to have inline schema" Just (S.Inline nlSch) -> do - assertEqual "type should be Array" (Just S.SwaggerArray) (nlSch ^. S.type_) + assertEqual "type should be Array" (Just S.OpenApiArray) (nlSch ^. S.type_) assertEqual "minItems should be 1" (Just 1) (nlSch ^. S.minItems) testRefField :: TestTree @@ -332,7 +332,7 @@ testEnumType = assertEqual "Text enum has Swagger type \"string\"" (s1 ^. S.type_) - (Just S.SwaggerString) + (Just S.OpenApiString) let e2 :: ValueSchema NamedSwaggerDoc Integer e2 = enum @Integer "IntEnum" (element (3 :: Integer) (3 :: Integer)) @@ -340,7 +340,7 @@ testEnumType = assertEqual "Integer enum has Swagger type \"integer\"" (s2 ^. S.type_) - (Just S.SwaggerInteger) + (Just S.OpenApiInteger) testNullable :: TestTree testNullable = diff --git a/libs/types-common/default.nix b/libs/types-common/default.nix index 8b4da990200..a5c57f5f05d 100644 --- a/libs/types-common/default.nix +++ b/libs/types-common/default.nix @@ -32,6 +32,7 @@ , lens-datetime , lib , mime +, openapi3 , optparse-applicative , pem , protobuf @@ -40,7 +41,6 @@ , random , schema-profunctor , servant-server -, swagger2 , tagged , tasty , tasty-hunit @@ -86,6 +86,7 @@ mkDerivation { lens lens-datetime mime + openapi3 optparse-applicative pem protobuf @@ -94,7 +95,6 @@ mkDerivation { random schema-profunctor servant-server - swagger2 tagged tasty tasty-hunit diff --git a/libs/types-common/src/Data/Code.hs b/libs/types-common/src/Data/Code.hs index 8d9d3c783d4..ba176629701 100644 --- a/libs/types-common/src/Data/Code.hs +++ b/libs/types-common/src/Data/Code.hs @@ -31,11 +31,11 @@ import Data.Aeson.TH import Data.Bifunctor (Bifunctor (first)) import Data.ByteString.Conversion import Data.Json.Util +import Data.OpenApi qualified as S +import Data.OpenApi.ParamSchema import Data.Proxy (Proxy (Proxy)) import Data.Range import Data.Schema -import Data.Swagger qualified as S -import Data.Swagger.ParamSchema import Data.Text (pack) import Data.Text.Ascii import Data.Text.Encoding (encodeUtf8) @@ -119,8 +119,8 @@ deriving instance Cql Value -- (but without a type, using plain fields). This will make it easier to re-use a key/value -- pair in the API, keeping "code" in the JSON for backwards compatibility data KeyValuePair = KeyValuePair - { kcKey :: !Key, - kcCode :: !Value + { key :: !Key, + code :: !Value } deriving (Eq, Generic, Show) diff --git a/libs/types-common/src/Data/CommaSeparatedList.hs b/libs/types-common/src/Data/CommaSeparatedList.hs index 8e3ebd0edd8..8c13c49f4cf 100644 --- a/libs/types-common/src/Data/CommaSeparatedList.hs +++ b/libs/types-common/src/Data/CommaSeparatedList.hs @@ -22,9 +22,9 @@ module Data.CommaSeparatedList where import Control.Lens ((?~)) import Data.Bifunctor qualified as Bifunctor import Data.ByteString.Conversion (FromByteString, List, fromList, parser, runParser) +import Data.OpenApi import Data.Proxy (Proxy (..)) import Data.Range (Bounds, Range) -import Data.Swagger (CollectionFormat (CollectionCSV), SwaggerItems (SwaggerItemsPrimitive), SwaggerType (SwaggerString), ToParamSchema (..), items, type_) import Data.Text qualified as Text import Data.Text.Encoding (encodeUtf8) import Imports @@ -40,10 +40,10 @@ instance FromByteString (List a) => FromHttpApiData (CommaSeparatedList a) where CommaSeparatedList . fromList <$> Bifunctor.first Text.pack (runParser parser $ encodeUtf8 t) instance ToParamSchema (CommaSeparatedList a) where - toParamSchema _ = mempty & type_ ?~ SwaggerString + toParamSchema _ = mempty & type_ ?~ OpenApiString -- | TODO: is this obsoleted by the instances in "Data.Range"? instance (ToParamSchema a, ToParamSchema (Range n m [a])) => ToParamSchema (Range n m (CommaSeparatedList a)) where toParamSchema _ = toParamSchema (Proxy @(Range n m [a])) - & items ?~ SwaggerItemsPrimitive (Just CollectionCSV) (toParamSchema (Proxy @a)) + & items ?~ OpenApiItemsArray [Inline $ toParamSchema (Proxy @a)] diff --git a/libs/types-common/src/Data/Domain.hs b/libs/types-common/src/Data/Domain.hs index 8f96dc18bcb..6f9d0884405 100644 --- a/libs/types-common/src/Data/Domain.hs +++ b/libs/types-common/src/Data/Domain.hs @@ -31,8 +31,8 @@ import Data.ByteString qualified as BS import Data.ByteString.Builder qualified as Builder import Data.ByteString.Char8 qualified as BS.Char8 import Data.ByteString.Conversion +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text import Data.Text.Encoding qualified as Text.E import Imports hiding (isAlphaNum) diff --git a/libs/types-common/src/Data/Handle.hs b/libs/types-common/src/Data/Handle.hs index 0d1e5220076..29d1570cc32 100644 --- a/libs/types-common/src/Data/Handle.hs +++ b/libs/types-common/src/Data/Handle.hs @@ -31,8 +31,8 @@ import Data.Bifunctor (Bifunctor (first)) import Data.ByteString qualified as BS import Data.ByteString.Conversion (FromByteString (parser), ToByteString) import Data.Hashable (Hashable) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text import Data.Text.Encoding qualified as Text.E import Imports diff --git a/libs/types-common/src/Data/Id.hs b/libs/types-common/src/Data/Id.hs index 4fccc60e942..c96ae4dac2e 100644 --- a/libs/types-common/src/Data/Id.hs +++ b/libs/types-common/src/Data/Id.hs @@ -66,16 +66,17 @@ import Data.Attoparsec.ByteString.Char8 qualified as Atto import Data.Bifunctor (first) import Data.Binary import Data.ByteString.Builder (byteString) +import Data.ByteString.Char8 qualified as B8 import Data.ByteString.Conversion import Data.ByteString.Lazy qualified as L import Data.Char qualified as Char import Data.Default (Default (..)) import Data.Hashable (Hashable) +import Data.OpenApi qualified as S +import Data.OpenApi.Internal.ParamSchema (ToParamSchema (..)) import Data.ProtocolBuffers.Internal import Data.Proxy import Data.Schema -import Data.Swagger qualified as S -import Data.Swagger.Internal.ParamSchema (ToParamSchema (..)) import Data.Text qualified as T import Data.Text.Encoding (decodeUtf8, encodeUtf8) import Data.Text.Lazy (toStrict) @@ -298,6 +299,9 @@ instance S.ToParamSchema ConnId where instance FromHttpApiData ConnId where parseUrlPiece = Right . ConnId . encodeUtf8 +instance Arbitrary ConnId where + arbitrary = ConnId . B8.pack <$> resize 10 (listOf arbitraryPrintableChar) + -- ClientId -------------------------------------------------------------------- -- | Handle for a device. Corresponds to the device fingerprints exposed in the UI. It is unique @@ -354,10 +358,11 @@ newtype BotId = BotId FromHttpApiData, Hashable, NFData, - FromJSON, - ToJSON, - Generic + Generic, + ToParamSchema ) + deriving newtype (ToSchema) + deriving (FromJSON, ToJSON, S.ToSchema) via Schema BotId instance Show BotId where show = show . botUserId diff --git a/libs/types-common/src/Data/Json/Util.hs b/libs/types-common/src/Data/Json/Util.hs index 5235a039b48..408dfe41cbc 100644 --- a/libs/types-common/src/Data/Json/Util.hs +++ b/libs/types-common/src/Data/Json/Util.hs @@ -62,8 +62,8 @@ import Data.ByteString.Builder qualified as BB import Data.ByteString.Conversion qualified as BS import Data.ByteString.Lazy qualified as L import Data.Fixed +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text import Data.Text.Encoding qualified as Text import Data.Text.Encoding.Error qualified as Text @@ -161,7 +161,7 @@ instance ToJSONObject A.Object where instance S.ToParamSchema A.Object where toParamSchema _ = - mempty & S.type_ ?~ S.SwaggerString + mempty & S.type_ ?~ S.OpenApiString instance ToSchema A.Object where schema = @@ -172,20 +172,16 @@ instance ToSchema A.Object where -- toJSONFieldName -- | Convenient helper to convert field names to use as JSON fields. --- it removes the prefix (assumed to be anything before an uppercase --- character) and converts the rest to underscore +-- it converts the field names to snake_case. -- -- Example: --- newtype TeamName = TeamName { tnTeamName :: Text } --- deriveJSON toJSONFieldName ''tnTeamName +-- newtype TeamName = TeamName { teamName :: Text } +-- deriveJSON toJSONFieldName ''teamName -- -- would generate {To/From}JSON instances where -- the field name is "team_name" toJSONFieldName :: A.Options -toJSONFieldName = A.defaultOptions {A.fieldLabelModifier = A.camelTo2 '_' . dropPrefix} - where - dropPrefix :: String -> String - dropPrefix = dropWhile (not . isUpper) +toJSONFieldName = A.defaultOptions {A.fieldLabelModifier = A.camelTo2 '_'} -------------------------------------------------------------------------------- @@ -213,7 +209,7 @@ instance ToHttpApiData Base64ByteString where toUrlPiece = Text.decodeUtf8With Text.lenientDecode . B64U.encodeUnpadded . fromBase64ByteString instance S.ToParamSchema Base64ByteString where - toParamSchema _ = mempty & S.type_ ?~ S.SwaggerString + toParamSchema _ = mempty & S.type_ ?~ S.OpenApiString -- base64("example") ~> "ZXhhbXBsZQo=" base64SchemaN :: ValueSchema NamedSwaggerDoc ByteString @@ -249,7 +245,7 @@ instance ToHttpApiData Base64ByteStringL where toUrlPiece = toUrlPiece . base64ToStrict instance S.ToParamSchema Base64ByteStringL where - toParamSchema _ = mempty & S.type_ ?~ S.SwaggerString + toParamSchema _ = mempty & S.type_ ?~ S.OpenApiString base64SchemaLN :: ValueSchema NamedSwaggerDoc LByteString base64SchemaLN = L.toStrict .= fmap L.fromStrict base64SchemaN diff --git a/libs/types-common/src/Data/LegalHold.hs b/libs/types-common/src/Data/LegalHold.hs index 7b328820e6c..02955c03f3d 100644 --- a/libs/types-common/src/Data/LegalHold.hs +++ b/libs/types-common/src/Data/LegalHold.hs @@ -20,8 +20,8 @@ module Data.LegalHold where import Cassandra.CQL import Control.Lens ((?~)) import Data.Aeson hiding (constructorTagModifier) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports import Test.QuickCheck diff --git a/libs/types-common/src/Data/List1.hs b/libs/types-common/src/Data/List1.hs index f77d578fed9..8a1d31555d2 100644 --- a/libs/types-common/src/Data/List1.hs +++ b/libs/types-common/src/Data/List1.hs @@ -25,8 +25,8 @@ import Cassandra import Data.Aeson import Data.List.NonEmpty (NonEmpty) import Data.List.NonEmpty qualified as N +import Data.OpenApi qualified as Swagger import Data.Schema as S -import Data.Swagger qualified as Swagger import Imports import Test.QuickCheck (Arbitrary) import Test.QuickCheck.Instances () @@ -72,8 +72,8 @@ instance ToSchema a => ToSchema (List1 a) where instance Swagger.ToParamSchema (List1 a) where toParamSchema _ = mempty - { Swagger._paramSchemaType = Just Swagger.SwaggerArray, - Swagger._paramSchemaMinLength = Just 1 + { Swagger._schemaType = Just Swagger.OpenApiArray, + Swagger._schemaMinLength = Just 1 } instance (Cql a) => Cql (List1 a) where diff --git a/libs/types-common/src/Data/Misc.hs b/libs/types-common/src/Data/Misc.hs index 1b81d37aa31..8acd18dee2b 100644 --- a/libs/types-common/src/Data/Misc.hs +++ b/libs/types-common/src/Data/Misc.hs @@ -77,9 +77,9 @@ import Data.ByteString.Char8 (unpack) import Data.ByteString.Conversion import Data.ByteString.Lazy (toStrict) import Data.IP (IP (IPv4, IPv6), toIPv4, toIPv6b) +import Data.OpenApi qualified as S import Data.Range import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text import Data.Text.Encoding (decodeUtf8, encodeUtf8) import GHC.TypeLits (Nat) @@ -100,7 +100,7 @@ newtype IpAddr = IpAddr {ipAddr :: IP} deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema IpAddr) instance S.ToParamSchema IpAddr where - toParamSchema _ = mempty & S.type_ ?~ S.SwaggerString + toParamSchema _ = mempty & S.type_ ?~ S.OpenApiString instance FromHttpApiData IpAddr where parseQueryParam p = first Text.pack (runParser parser (encodeUtf8 p)) @@ -296,7 +296,7 @@ data Rsa newtype Fingerprint a = Fingerprint { fingerprintBytes :: ByteString } - deriving stock (Eq, Show, Generic) + deriving stock (Eq, Show, Generic, Typeable) deriving newtype (FromByteString, ToByteString, NFData) deriving via @@ -314,7 +314,7 @@ deriving via deriving via (Schema (Fingerprint a)) instance - (ToSchema (Fingerprint a)) => + (Typeable (Fingerprint a), ToSchema (Fingerprint a)) => S.ToSchema (Fingerprint a) instance ToSchema (Fingerprint Rsa) where @@ -378,7 +378,7 @@ deriving via (Schema (PlainTextPassword' tag)) instance ToSchema (PlainTextPassw deriving via (Schema (PlainTextPassword' tag)) instance ToSchema (PlainTextPassword' tag) => ToJSON (PlainTextPassword' tag) -deriving via (Schema (PlainTextPassword' tag)) instance ToSchema (PlainTextPassword' tag) => S.ToSchema (PlainTextPassword' tag) +deriving via (Schema (PlainTextPassword' tag)) instance (KnownNat tag, ToSchema (PlainTextPassword' tag)) => S.ToSchema (PlainTextPassword' tag) instance Show (PlainTextPassword' minLen) where show _ = "PlainTextPassword' " diff --git a/libs/types-common/src/Data/Nonce.hs b/libs/types-common/src/Data/Nonce.hs index 91befc4c3e8..1f094bab764 100644 --- a/libs/types-common/src/Data/Nonce.hs +++ b/libs/types-common/src/Data/Nonce.hs @@ -31,10 +31,10 @@ import Data.Aeson qualified as A import Data.ByteString.Base64.URL qualified as Base64 import Data.ByteString.Conversion import Data.ByteString.Lazy (fromStrict, toStrict) +import Data.OpenApi qualified as S +import Data.OpenApi.ParamSchema import Data.Proxy (Proxy (Proxy)) import Data.Schema -import Data.Swagger qualified as S -import Data.Swagger.ParamSchema import Data.UUID as UUID (UUID, fromByteString, toByteString) import Data.UUID.V4 (nextRandom) import Imports diff --git a/libs/types-common/src/Data/Qualified.hs b/libs/types-common/src/Data/Qualified.hs index 964f91e1ef5..1c1ba088e10 100644 --- a/libs/types-common/src/Data/Qualified.hs +++ b/libs/types-common/src/Data/Qualified.hs @@ -48,15 +48,16 @@ module Data.Qualified ) where -import Control.Lens (Lens, lens, (?~)) +import Control.Lens (Lens, lens, over, (?~)) import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Bifunctor (first) import Data.Domain (Domain) import Data.Handle (Handle (..)) import Data.Id import Data.Map qualified as Map +import Data.OpenApi (deprecated) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports hiding (local) import Test.QuickCheck (Arbitrary (arbitrary)) @@ -163,8 +164,11 @@ isLocal loc = foldQualified loc (const True) (const False) ---------------------------------------------------------------------- -deprecatedSchema :: S.HasDescription doc (Maybe Text) => Text -> ValueSchema doc a -> ValueSchema doc a -deprecatedSchema new = doc . description ?~ ("Deprecated, use " <> new) +deprecatedSchema :: (S.HasDeprecated doc (Maybe Bool), S.HasDescription doc (Maybe Text)) => Text -> ValueSchema doc a -> ValueSchema doc a +deprecatedSchema new = + over doc $ + (description ?~ ("Deprecated, use " <> new)) + . (deprecated ?~ True) qualifiedSchema :: HasSchemaRef doc => @@ -198,7 +202,7 @@ instance KnownIdTag t => ToJSON (Qualified (Id t)) where instance KnownIdTag t => FromJSON (Qualified (Id t)) where parseJSON = schemaParseJSON -instance KnownIdTag t => S.ToSchema (Qualified (Id t)) where +instance (Typeable t, KnownIdTag t) => S.ToSchema (Qualified (Id t)) where declareNamedSchema = schemaToSwagger instance ToJSON (Qualified Handle) where diff --git a/libs/types-common/src/Data/Range.hs b/libs/types-common/src/Data/Range.hs index e4c5be14781..898df2142c1 100644 --- a/libs/types-common/src/Data/Range.hs +++ b/libs/types-common/src/Data/Range.hs @@ -74,13 +74,13 @@ import Data.List.NonEmpty (NonEmpty) import Data.List.NonEmpty qualified as N import Data.List1 (List1, toNonEmpty) import Data.Map qualified as Map +import Data.OpenApi (Schema, ToParamSchema (..)) +import Data.OpenApi qualified as S import Data.Proxy -import Data.Schema +import Data.Schema hiding (Schema) import Data.Sequence (Seq) import Data.Sequence qualified as Seq import Data.Set qualified as Set -import Data.Swagger (ParamSchema, ToParamSchema (..)) -import Data.Swagger qualified as S import Data.Text qualified as T import Data.Text.Ascii (AsciiChar, AsciiChars, AsciiText, fromAsciiChars) import Data.Text.Ascii qualified as Ascii @@ -152,6 +152,9 @@ numRangedSchemaDocModifier n m = S.schema %~ ((S.minimum_ ?~ fromIntegral n) . ( instance S.HasSchema d S.Schema => HasRangedSchemaDocModifier d [a] where rangedSchemaDocModifier _ = listRangedSchemaDocModifier +-- Sets are similar to lists, so use that as our defininition +instance S.HasSchema d S.Schema => HasRangedSchemaDocModifier d (Set a) where rangedSchemaDocModifier _ = listRangedSchemaDocModifier + instance S.HasSchema d S.Schema => HasRangedSchemaDocModifier d Text where rangedSchemaDocModifier _ = stringRangedSchemaDocModifier instance S.HasSchema d S.Schema => HasRangedSchemaDocModifier d String where rangedSchemaDocModifier _ = stringRangedSchemaDocModifier @@ -232,7 +235,7 @@ instance (KnownNat n, KnownNat m) => ToParamSchema (Range n m TL.Text) where & S.maxLength ?~ fromKnownNat (Proxy @n) & S.minLength ?~ fromKnownNat (Proxy @m) -instance S.ToSchema a => S.ToSchema (Range n m a) where +instance (KnownNat n, S.ToSchema a, KnownNat m) => S.ToSchema (Range n m a) where declareNamedSchema _ = S.declareNamedSchema (Proxy @a) @@ -316,7 +319,7 @@ rappend (Range a) (Range b) = Range (a <> b) rsingleton :: a -> Range 1 1 [a] rsingleton = Range . pure -rangedNumToParamSchema :: forall a n m t. (ToParamSchema a, Num a, KnownNat n, KnownNat m) => Proxy (Range n m a) -> ParamSchema t +rangedNumToParamSchema :: forall a n m. (ToParamSchema a, Num a, KnownNat n, KnownNat m) => Proxy (Range n m a) -> Schema rangedNumToParamSchema _ = toParamSchema (Proxy @a) & S.minimum_ ?~ fromKnownNat (Proxy @n) diff --git a/libs/types-common/src/Data/Text/Ascii.hs b/libs/types-common/src/Data/Text/Ascii.hs index 70712eabdba..0fac4b07e2f 100644 --- a/libs/types-common/src/Data/Text/Ascii.hs +++ b/libs/types-common/src/Data/Text/Ascii.hs @@ -86,8 +86,8 @@ import Data.ByteString.Base64.URL qualified as B64Url import Data.ByteString.Char8 qualified as C8 import Data.ByteString.Conversion import Data.Hashable (Hashable) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text import Data.Text.Encoding (decodeLatin1, decodeUtf8') import Imports @@ -156,7 +156,7 @@ instance AsciiChars c => ToJSON (AsciiText c) where instance AsciiChars c => FromJSON (AsciiText c) where parseJSON = schemaParseJSON -instance AsciiChars c => S.ToSchema (AsciiText c) where +instance (Typeable c, AsciiChars c) => S.ToSchema (AsciiText c) where declareNamedSchema = schemaToSwagger instance AsciiChars c => Cql (AsciiText c) where diff --git a/libs/types-common/src/Util/Options.hs b/libs/types-common/src/Util/Options.hs index 925a8be7560..823f9bcc68a 100644 --- a/libs/types-common/src/Util/Options.hs +++ b/libs/types-common/src/Util/Options.hs @@ -75,8 +75,8 @@ urlPort u = do makeLenses ''AWSEndpoint data Endpoint = Endpoint - { _epHost :: !Text, - _epPort :: !Word16 + { _host :: !Text, + _port :: !Word16 } deriving (Show, Generic) @@ -85,14 +85,14 @@ deriveFromJSON toOptionFieldName ''Endpoint makeLenses ''Endpoint data CassandraOpts = CassandraOpts - { _casEndpoint :: !Endpoint, - _casKeyspace :: !Text, + { _endpoint :: !Endpoint, + _keyspace :: !Text, -- | If this option is unset, use all available nodes. -- If this option is set, use only cassandra nodes in the given datacentre -- -- This option is most likely only necessary during a cassandra DC migration -- FUTUREWORK: remove this option again, or support a datacentre migration feature - _casFilterNodesByDatacentre :: !(Maybe Text) + _filterNodesByDatacentre :: !(Maybe Text) } deriving (Show, Generic) diff --git a/libs/types-common/src/Util/Options/Common.hs b/libs/types-common/src/Util/Options/Common.hs index 97ac6a9cefd..c052a53c33b 100644 --- a/libs/types-common/src/Util/Options/Common.hs +++ b/libs/types-common/src/Util/Options/Common.hs @@ -28,12 +28,11 @@ import System.Posix.Env qualified as Posix -- NOTE: We typically use this for options in the configuration files! -- If you are looking into converting record field name to JSON to be used -- over the API, look for toJSONFieldName in the Data.Json.Util module. --- It removes the prefix (assumed to be anything before an uppercase --- character) and lowers the first character +-- It converts field names into snake_case -- -- Example: --- newtype TeamName = TeamName { tnTeamName :: Text } --- deriveJSON toJSONFieldName ''tnTeamName +-- newtype TeamName = TeamName { teamName :: Text } +-- deriveJSON toJSONFieldName ''teamName -- -- would generate {To/From}JSON instances where -- the field name is "teamName" @@ -44,7 +43,7 @@ toOptionFieldName = defaultOptions {fieldLabelModifier = lowerFirst . dropPrefix lowerFirst (x : xs) = toLower x : xs lowerFirst [] = "" dropPrefix :: String -> String - dropPrefix = dropWhile (not . isUpper) + dropPrefix = dropWhile ('_' ==) optOrEnv :: (a -> b) -> Maybe a -> (String -> b) -> String -> IO b optOrEnv getter conf reader var = case conf of diff --git a/libs/types-common/types-common.cabal b/libs/types-common/types-common.cabal index 9f2eb9391c7..4ce602225f1 100644 --- a/libs/types-common/types-common.cabal +++ b/libs/types-common/types-common.cabal @@ -116,6 +116,7 @@ library , lens >=4.10 , lens-datetime >=0.3 , mime >=0.4.0.2 + , openapi3 , optparse-applicative >=0.10 , pem , protobuf >=0.2 @@ -124,7 +125,6 @@ library , random >=1.1 , schema-profunctor , servant-server - , swagger2 , tagged >=0.8 , tasty >=0.11 , tasty-hunit diff --git a/libs/wai-utilities/default.nix b/libs/wai-utilities/default.nix index 33988b17bfd..bc345ab3586 100644 --- a/libs/wai-utilities/default.nix +++ b/libs/wai-utilities/default.nix @@ -18,12 +18,12 @@ , lib , metrics-core , metrics-wai +, openapi3 , pipes , prometheus-client , schema-profunctor , servant-server , streaming-commons -, swagger2 , text , tinylog , types-common @@ -52,12 +52,12 @@ mkDerivation { kan-extensions metrics-core metrics-wai + openapi3 pipes prometheus-client schema-profunctor servant-server streaming-commons - swagger2 text tinylog types-common diff --git a/libs/wai-utilities/src/Network/Wai/Utilities/Error.hs b/libs/wai-utilities/src/Network/Wai/Utilities/Error.hs index 01b7a4cee8f..6aeb602ede2 100644 --- a/libs/wai-utilities/src/Network/Wai/Utilities/Error.hs +++ b/libs/wai-utilities/src/Network/Wai/Utilities/Error.hs @@ -31,7 +31,7 @@ import Control.Error import Data.Aeson hiding (Error) import Data.Aeson.Types (Pair) import Data.Domain -import Data.Text.Lazy.Encoding (decodeUtf8) +import Data.Text.Lazy.Encoding (decodeUtf8, encodeUtf8) import Imports import Network.HTTP.Types @@ -50,16 +50,18 @@ instance Exception Error data ErrorData = FederationErrorData { federrDomain :: !Domain, - federrPath :: !Text + federrPath :: !Text, + federrResp :: !(Maybe LByteString) } deriving (Eq, Show, Typeable) instance ToJSON ErrorData where - toJSON (FederationErrorData d p) = + toJSON (FederationErrorData d p b) = object [ "type" .= ("federation" :: Text), "domain" .= d, - "path" .= p + "path" .= p, + "response" .= fmap decodeUtf8 b ] instance FromJSON ErrorData where @@ -67,6 +69,7 @@ instance FromJSON ErrorData where FederationErrorData <$> o .: "domain" <*> o .: "path" + <*> (fmap encodeUtf8 <$> (o .: "response")) -- | Assumes UTF-8 encoding. byteStringError :: Status -> LByteString -> LByteString -> Error diff --git a/libs/wai-utilities/src/Network/Wai/Utilities/Headers.hs b/libs/wai-utilities/src/Network/Wai/Utilities/Headers.hs index 2cf2b2e644e..f1673e7de13 100644 --- a/libs/wai-utilities/src/Network/Wai/Utilities/Headers.hs +++ b/libs/wai-utilities/src/Network/Wai/Utilities/Headers.hs @@ -18,7 +18,7 @@ module Network.Wai.Utilities.Headers where import Data.ByteString.Conversion (FromByteString (..), ToByteString (..), fromByteString', toByteString') -import Data.Swagger.ParamSchema (ToParamSchema (..)) +import Data.OpenApi.ParamSchema (ToParamSchema (..)) import Data.Text as T import Imports import Servant (FromHttpApiData (..), Proxy (Proxy), ToHttpApiData (..)) diff --git a/libs/wai-utilities/src/Network/Wai/Utilities/Server.hs b/libs/wai-utilities/src/Network/Wai/Utilities/Server.hs index a0eaa4d5886..05856b3974b 100644 --- a/libs/wai-utilities/src/Network/Wai/Utilities/Server.hs +++ b/libs/wai-utilities/src/Network/Wai/Utilities/Server.hs @@ -398,9 +398,10 @@ logErrorMsg (Wai.Error c l m md) = . maybe id logErrorData md . msg (val "\"" +++ m +++ val "\"") where - logErrorData (Wai.FederationErrorData d p) = + logErrorData (Wai.FederationErrorData d p b) = field "domain" (domainText d) . field "path" p + . field "response" (fromMaybe "" b) logErrorMsgWithRequest :: Maybe ByteString -> Wai.Error -> Msg -> Msg logErrorMsgWithRequest mr e = diff --git a/libs/wai-utilities/wai-utilities.cabal b/libs/wai-utilities/wai-utilities.cabal index 44a3769dbf1..1c1ae75cbcc 100644 --- a/libs/wai-utilities/wai-utilities.cabal +++ b/libs/wai-utilities/wai-utilities.cabal @@ -86,12 +86,12 @@ library , kan-extensions , metrics-core >=0.1 , metrics-wai >=0.5.7 + , openapi3 , pipes >=4.1 , prometheus-client , schema-profunctor , servant-server , streaming-commons >=0.1 - , swagger2 , text >=0.11 , tinylog >=0.8 , types-common >=0.12 diff --git a/libs/wire-api-federation/default.nix b/libs/wire-api-federation/default.nix index 2ac0f43e549..78342495155 100644 --- a/libs/wire-api-federation/default.nix +++ b/libs/wire-api-federation/default.nix @@ -6,6 +6,7 @@ , aeson , aeson-pretty , amqp +, async , base , bytestring , bytestring-conversion @@ -26,6 +27,7 @@ , lib , metrics-wai , mtl +, openapi3 , QuickCheck , schema-profunctor , servant @@ -34,7 +36,6 @@ , servant-server , singletons , singletons-th -, swagger2 , text , time , transformers @@ -51,6 +52,7 @@ mkDerivation { libraryHaskellDepends = [ aeson amqp + async base bytestring bytestring-conversion @@ -66,6 +68,7 @@ mkDerivation { lens metrics-wai mtl + openapi3 QuickCheck schema-profunctor servant @@ -73,7 +76,6 @@ mkDerivation { servant-client-core servant-server singletons-th - swagger2 text time transformers @@ -93,6 +95,7 @@ mkDerivation { imports QuickCheck singletons + time types-common uuid wire-api diff --git a/libs/wire-api-federation/src/Wire/API/Federation/API.hs b/libs/wire-api-federation/src/Wire/API/Federation/API.hs index f344a80ced2..b1859df2339 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/API.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/API.hs @@ -33,17 +33,24 @@ module Wire.API.Federation.API ) where +import Data.Aeson +import Data.Domain import Data.Kind import Data.Proxy import GHC.TypeLits import Imports +import Network.AMQP +import Servant import Servant.Client import Servant.Client.Core import Wire.API.Federation.API.Brig import Wire.API.Federation.API.Cargohold +import Wire.API.Federation.API.Common import Wire.API.Federation.API.Galley import Wire.API.Federation.BackendNotifications import Wire.API.Federation.Client +import Wire.API.Federation.Component +import Wire.API.Federation.HasNotificationEndpoint import Wire.API.MakesFederatedCall import Wire.API.Routes.Named @@ -64,6 +71,20 @@ type HasFedEndpoint comp api name = (HasUnsafeFedEndpoint comp api name) -- you to forget about some federated calls. type HasUnsafeFedEndpoint comp api name = 'Just api ~ LookupEndpoint (FedApi comp) name +-- | Constrains which endpoints can be used with FedQueueClient. +-- +-- Since the servant client implementation underlying FedQueueClient is +-- returning a "fake" response consisting of an empty object, we need to make +-- sure that an API type is compatible with an empty response if we want to +-- invoke it using `fedQueueClient` +class HasEmptyResponse api + +instance HasEmptyResponse (Post '[JSON] EmptyResponse) + +instance HasEmptyResponse api => HasEmptyResponse (x :> api) + +instance HasEmptyResponse api => HasEmptyResponse (UntypedNamed name api) + -- | Return a client for a named endpoint. -- -- This function introduces an 'AddAnnotation' constraint, which is @@ -78,10 +99,32 @@ fedClient :: fedClient = clientIn (Proxy @api) (Proxy @m) fedQueueClient :: - forall (comp :: Component) (name :: Symbol) m api. - (HasFedEndpoint comp api name, HasClient m api, m ~ FedQueueClient comp) => - Client m api -fedQueueClient = clientIn (Proxy @api) (Proxy @m) + forall tag api. + ( HasNotificationEndpoint tag, + -- FUTUREWORK: Include this API constraint and get it working + -- api ~ NotificationAPI tag (NotificationComponent tag), + HasEmptyResponse api, + KnownSymbol (NotificationPath tag), + KnownComponent (NotificationComponent tag), + ToJSON (Payload tag), + HasFedEndpoint (NotificationComponent tag) api (NotificationPath tag) + ) => + Payload tag -> + FedQueueClient (NotificationComponent tag) () +fedQueueClient payload = do + env <- ask + let notif = fedNotifToBackendNotif @tag env.originDomain payload + msg = + newMsg + { msgBody = encode notif, + msgDeliveryMode = Just (env.deliveryMode), + msgContentType = Just "application/json" + } + -- Empty string means default exchange + exchange = "" + liftIO $ do + ensureQueue env.channel env.targetDomain._domainText + void $ publishMsg env.channel exchange (routingKey env.targetDomain._domainText) msg fedClientIn :: forall (comp :: Component) (name :: Symbol) m api. diff --git a/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs b/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs index 66963015786..8703e3d8501 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/API/Brig.hs @@ -15,20 +15,23 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.API.Federation.API.Brig where +module Wire.API.Federation.API.Brig + ( module Notifications, + module Wire.API.Federation.API.Brig, + ) +where import Data.Aeson import Data.Domain (Domain) import Data.Handle (Handle) import Data.Id -import Data.Range import Imports import Servant.API import Test.QuickCheck (Arbitrary) -import Wire.API.Federation.API.Common +import Wire.API.Federation.API.Brig.Notifications as Notifications import Wire.API.Federation.Endpoint import Wire.API.Federation.Version -import Wire.API.MLS.Credential +import Wire.API.MLS.CipherSuite import Wire.API.MLS.KeyPackage import Wire.API.User (UserProfile) import Wire.API.User.Client @@ -70,31 +73,36 @@ type BrigApi = :<|> FedEndpoint "get-user-clients" GetUserClients (UserMap (Set PubClient)) :<|> FedEndpoint "get-mls-clients" MLSClientsRequest (Set ClientInfo) :<|> FedEndpoint "send-connection-action" NewConnectionRequest NewConnectionResponse - :<|> FedEndpoint "on-user-deleted-connections" UserDeletedConnectionsNotification EmptyResponse :<|> FedEndpoint "claim-key-packages" ClaimKeyPackageRequest (Maybe KeyPackageBundle) :<|> FedEndpoint "get-not-fully-connected-backends" DomainSet NonConnectedBackends + -- All the notification endpoints that go through the queue-based + -- federation client ('fedQueueClient'). + :<|> BrigNotificationAPI newtype DomainSet = DomainSet - { dsDomains :: Set Domain + { domains :: Set Domain } deriving stock (Eq, Show, Generic) deriving (ToJSON, FromJSON) via (CustomEncoded DomainSet) newtype NonConnectedBackends = NonConnectedBackends + -- TODO: + -- The encoding rules that were in place would make this "connectedBackends" over the wire. + -- I do not think that this was intended, so I'm leaving this note as it will be an API break. { nonConnectedBackends :: Set Domain } deriving stock (Eq, Show, Generic) deriving (ToJSON, FromJSON) via (CustomEncoded NonConnectedBackends) newtype GetUserClients = GetUserClients - { gucUsers :: [UserId] + { users :: [UserId] } deriving stock (Eq, Show, Generic) deriving (ToJSON, FromJSON) via (CustomEncoded GetUserClients) data MLSClientsRequest = MLSClientsRequest - { mcrUserId :: UserId, -- implicitly qualified by the local domain - mcrSignatureScheme :: SignatureSchemeTag + { userId :: UserId, -- implicitly qualified by the local domain + cipherSuite :: CipherSuite } deriving stock (Eq, Show, Generic) deriving (ToJSON, FromJSON) via (CustomEncoded MLSClientsRequest) @@ -117,10 +125,10 @@ data MLSClientsRequest = MLSClientsRequest data NewConnectionRequest = NewConnectionRequest { -- | The 'from' userId is understood to always have the domain of the backend making the connection request - ncrFrom :: UserId, + from :: UserId, -- | The 'to' userId is understood to always have the domain of the receiving backend. - ncrTo :: UserId, - ncrAction :: RemoteConnectionAction + to :: UserId, + action :: RemoteConnectionAction } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform NewConnectionRequest) @@ -140,24 +148,14 @@ data NewConnectionResponse deriving (Arbitrary) via (GenericUniform NewConnectionResponse) deriving (FromJSON, ToJSON) via (CustomEncoded NewConnectionResponse) -type UserDeletedNotificationMaxConnections = 1000 - -data UserDeletedConnectionsNotification = UserDeletedConnectionsNotification - { -- | This is qualified implicitly by the origin domain - udcnUser :: UserId, - -- | These are qualified implicitly by the target domain - udcnConnections :: Range 1 UserDeletedNotificationMaxConnections [UserId] - } - deriving stock (Eq, Show, Generic) - deriving (Arbitrary) via (GenericUniform UserDeletedConnectionsNotification) - deriving (FromJSON, ToJSON) via (CustomEncoded UserDeletedConnectionsNotification) - data ClaimKeyPackageRequest = ClaimKeyPackageRequest { -- | The user making the request, implictly qualified by the origin domain. - ckprClaimant :: UserId, + claimant :: UserId, -- | The user whose key packages are being claimed, implictly qualified by -- the target domain. - ckprTarget :: UserId + target :: UserId, + -- | The ciphersuite of the key packages being claimed. + cipherSuite :: CipherSuite } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform ClaimKeyPackageRequest) diff --git a/libs/wire-api-federation/src/Wire/API/Federation/API/Brig/Notifications.hs b/libs/wire-api-federation/src/Wire/API/Federation/API/Brig/Notifications.hs new file mode 100644 index 00000000000..efdc16722b9 --- /dev/null +++ b/libs/wire-api-federation/src/Wire/API/Federation/API/Brig/Notifications.hs @@ -0,0 +1,56 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Federation.API.Brig.Notifications where + +import Data.Aeson +import Data.Id +import Data.Range +import Imports +import Wire.API.Federation.Component +import Wire.API.Federation.Endpoint +import Wire.API.Federation.HasNotificationEndpoint +import Wire.API.Util.Aeson +import Wire.Arbitrary + +type UserDeletedNotificationMaxConnections = 1000 + +data UserDeletedConnectionsNotification = UserDeletedConnectionsNotification + { -- | This is qualified implicitly by the origin domain + user :: UserId, + -- | These are qualified implicitly by the target domain + connections :: Range 1 UserDeletedNotificationMaxConnections [UserId] + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform UserDeletedConnectionsNotification) + deriving (FromJSON, ToJSON) via (CustomEncoded UserDeletedConnectionsNotification) + +data BrigNotificationTag = OnUserDeletedConnectionsTag + deriving (Show, Eq, Generic, Bounded, Enum) + +instance HasNotificationEndpoint 'OnUserDeletedConnectionsTag where + type Payload 'OnUserDeletedConnectionsTag = UserDeletedConnectionsNotification + type NotificationPath 'OnUserDeletedConnectionsTag = "on-user-deleted-connections" + type NotificationComponent 'OnUserDeletedConnectionsTag = 'Brig + type + NotificationAPI 'OnUserDeletedConnectionsTag 'Brig = + NotificationFedEndpoint 'OnUserDeletedConnectionsTag + +-- | All the notification endpoints return an 'EmptyResponse'. +type BrigNotificationAPI = + -- FUTUREWORK: Use NotificationAPI 'OnUserDeletedConnectionsTag 'Brig instead + NotificationFedEndpoint 'OnUserDeletedConnectionsTag diff --git a/libs/wire-api-federation/src/Wire/API/Federation/API/Cargohold.hs b/libs/wire-api-federation/src/Wire/API/Federation/API/Cargohold.hs index bcff826a17b..debe7a2a5d5 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/API/Cargohold.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/API/Cargohold.hs @@ -29,19 +29,19 @@ import Wire.Arbitrary (Arbitrary, GenericUniform (..)) data GetAsset = GetAsset { -- | User requesting the asset. Implictly qualified with the source domain. - gaUser :: UserId, + user :: UserId, -- | Asset key for the asset to download. Implictly qualified with the -- target domain. - gaKey :: AssetKey, + key :: AssetKey, -- | Optional asset token. - gaToken :: Maybe AssetToken + token :: Maybe AssetToken } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform GetAsset) deriving (ToJSON, FromJSON) via (CustomEncoded GetAsset) data GetAssetResponse = GetAssetResponse - {gaAvailable :: Bool} + {available :: Bool} deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform GetAssetResponse) deriving (ToJSON, FromJSON) via (CustomEncoded GetAssetResponse) diff --git a/libs/wire-api-federation/src/Wire/API/Federation/API/Galley.hs b/libs/wire-api-federation/src/Wire/API/Federation/API/Galley.hs index 7400abaa4da..f40417e303b 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/API/Galley.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/API/Galley.hs @@ -15,7 +15,11 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.API.Federation.API.Galley where +module Wire.API.Federation.API.Galley + ( module Wire.API.Federation.API.Galley, + module Notifications, + ) +where import Data.Aeson (FromJSON, ToJSON) import Data.Domain @@ -23,7 +27,6 @@ import Data.Id import Data.Json.Util import Data.Misc (Milliseconds) import Data.Qualified -import Data.Range import Data.Time.Clock (UTCTime) import Imports import Network.Wai.Utilities.JSONResponse @@ -35,12 +38,12 @@ import Wire.API.Conversation.Role (RoleName) import Wire.API.Conversation.Typing import Wire.API.Error.Galley import Wire.API.Federation.API.Common +import Wire.API.Federation.API.Galley.Notifications as Notifications import Wire.API.Federation.Endpoint import Wire.API.MLS.SubConversation import Wire.API.MakesFederatedCall import Wire.API.Message import Wire.API.Routes.Public.Galley.Messaging -import Wire.API.Unreachable import Wire.API.Util.Aeson (CustomEncoded (..)) import Wire.Arbitrary (Arbitrary, GenericUniform (..)) @@ -58,20 +61,15 @@ type GalleyApi = -- This endpoint is called the first time a user from this backend is -- added to a remote conversation. :<|> FedEndpoint "get-conversations" GetConversationsRequest GetConversationsResponse - -- used by the backend that owns a conversation to inform this backend of - -- changes to the conversation - :<|> FedEndpoint "on-conversation-updated" ConversationUpdate EmptyResponse :<|> FedEndpointWithMods '[ MakesFederatedCall 'Galley "on-conversation-updated", MakesFederatedCall 'Galley "on-mls-message-sent", + MakesFederatedCall 'Brig "get-users-by-ids", MakesFederatedCall 'Brig "api-version" ] "leave-conversation" LeaveConversationRequest LeaveConversationResponse - -- used to notify this backend that a new message has been posted to a - -- remote conversation - :<|> FedEndpoint "on-message-sent" (RemoteMessage ConvId) EmptyResponse -- used by a remote backend to send a message to a conversation owned by -- this backend :<|> FedEndpointWithMods @@ -81,23 +79,16 @@ type GalleyApi = "send-message" ProteusMessageSendRequest MessageSendResponse - :<|> FedEndpointWithMods - '[ MakesFederatedCall 'Galley "on-mls-message-sent", - MakesFederatedCall 'Galley "on-conversation-updated", - MakesFederatedCall 'Brig "api-version" - ] - "on-user-deleted-conversations" - UserDeletedConversationsNotification - EmptyResponse :<|> FedEndpointWithMods '[ MakesFederatedCall 'Galley "on-conversation-updated", + MakesFederatedCall 'Galley "on-mls-message-sent", + MakesFederatedCall 'Brig "get-users-by-ids", MakesFederatedCall 'Galley "on-mls-message-sent" ] "update-conversation" ConversationUpdateRequest ConversationUpdateResponse :<|> FedEndpoint "mls-welcome" MLSWelcomeRequest MLSWelcomeResponse - :<|> FedEndpoint "on-mls-message-sent" RemoteMLSMessage RemoteMLSMessageResponse :<|> FedEndpointWithMods '[ MakesFederatedCall 'Galley "on-conversation-updated", MakesFederatedCall 'Galley "on-mls-message-sent", @@ -112,18 +103,14 @@ type GalleyApi = MakesFederatedCall 'Galley "on-conversation-updated", MakesFederatedCall 'Galley "on-mls-message-sent", MakesFederatedCall 'Galley "send-mls-commit-bundle", - MakesFederatedCall 'Brig "get-mls-clients" + MakesFederatedCall 'Brig "get-mls-clients", + MakesFederatedCall 'Brig "get-users-by-ids", + MakesFederatedCall 'Brig "api-version" ] "send-mls-commit-bundle" MLSMessageSendRequest MLSMessageResponse :<|> FedEndpoint "query-group-info" GetGroupInfoRequest GetGroupInfoResponse - :<|> FedEndpointWithMods - '[ MakesFederatedCall 'Galley "on-mls-message-sent" - ] - "on-client-removed" - ClientRemovedRequest - EmptyResponse :<|> FedEndpointWithMods '[ MakesFederatedCall 'Galley "on-typing-indicator-updated" ] @@ -131,11 +118,31 @@ type GalleyApi = TypingDataUpdateRequest TypingDataUpdateResponse :<|> FedEndpoint "on-typing-indicator-updated" TypingDataUpdated EmptyResponse + :<|> FedEndpoint "get-sub-conversation" GetSubConversationsRequest GetSubConversationsResponse + :<|> FedEndpointWithMods + '[ + ] + "delete-sub-conversation" + DeleteSubConversationFedRequest + DeleteSubConversationResponse + :<|> FedEndpointWithMods + '[ MakesFederatedCall 'Galley "on-mls-message-sent" + ] + "leave-sub-conversation" + LeaveSubConversationRequest + LeaveSubConversationResponse + :<|> FedEndpoint + "get-one2one-conversation" + GetOne2OneConversationRequest + GetOne2OneConversationResponse + -- All the notification endpoints that go through the queue-based + -- federation client ('fedQueueClient'). + :<|> GalleyNotificationAPI data TypingDataUpdateRequest = TypingDataUpdateRequest - { tdurTypingStatus :: TypingStatus, - tdurUserId :: UserId, - tdurConvId :: ConvId + { typingStatus :: TypingStatus, + userId :: UserId, + convId :: ConvId } deriving stock (Eq, Show, Generic) deriving (FromJSON, ToJSON) via (CustomEncoded TypingDataUpdateRequest) @@ -147,37 +154,38 @@ data TypingDataUpdateResponse deriving (FromJSON, ToJSON) via (CustomEncoded TypingDataUpdateResponse) data TypingDataUpdated = TypingDataUpdated - { tudTime :: UTCTime, - tudOrigUserId :: Qualified UserId, + { time :: UTCTime, + origUserId :: Qualified UserId, -- | Implicitely qualified by sender's domain - tudConvId :: ConvId, + convId :: ConvId, -- | Implicitely qualified by receiver's domain - tudUsersInConv :: [UserId], - tudTypingStatus :: TypingStatus + usersInConv :: [UserId], + typingStatus :: TypingStatus } deriving stock (Eq, Show, Generic) deriving (FromJSON, ToJSON) via (CustomEncoded TypingDataUpdated) -data ClientRemovedRequest = ClientRemovedRequest - { crrUser :: UserId, - crrClient :: ClientId, - crrConvs :: [ConvId] - } - deriving stock (Eq, Show, Generic) - deriving (Arbitrary) via (GenericUniform ClientRemovedRequest) - deriving (FromJSON, ToJSON) via (CustomEncoded ClientRemovedRequest) - data GetConversationsRequest = GetConversationsRequest - { gcrUserId :: UserId, - gcrConvIds :: [ConvId] + { userId :: UserId, + convIds :: [ConvId] } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform GetConversationsRequest) deriving (ToJSON, FromJSON) via (CustomEncoded GetConversationsRequest) +data GetOne2OneConversationRequest = GetOne2OneConversationRequest + { -- The user on the sender's domain + goocSenderUser :: UserId, + -- The user on the receiver's domain + goocReceiverUser :: UserId + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform GetOne2OneConversationRequest) + deriving (ToJSON, FromJSON) via (CustomEncoded GetOne2OneConversationRequest) + data RemoteConvMembers = RemoteConvMembers - { rcmSelfRole :: RoleName, - rcmOthers :: [OtherMember] + { selfRole :: RoleName, + others :: [OtherMember] } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform RemoteConvMembers) @@ -190,82 +198,72 @@ data RemoteConvMembers = RemoteConvMembers data RemoteConversation = RemoteConversation { -- | Id of the conversation, implicitly qualified with the domain of the -- backend that created this value. - rcnvId :: ConvId, - rcnvMetadata :: ConversationMetadata, - rcnvMembers :: RemoteConvMembers, - rcnvProtocol :: Protocol + id :: ConvId, + metadata :: ConversationMetadata, + members :: RemoteConvMembers, + protocol :: Protocol } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform RemoteConversation) deriving (FromJSON, ToJSON) via (CustomEncoded RemoteConversation) newtype GetConversationsResponse = GetConversationsResponse - { gcresConvs :: [RemoteConversation] + { convs :: [RemoteConversation] } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform GetConversationsResponse) deriving (ToJSON, FromJSON) via (CustomEncoded GetConversationsResponse) +data GetOne2OneConversationResponse + = GetOne2OneConversationOk RemoteConversation + | -- | This is returned when the local backend is asked for a 1-1 conversation + -- that should reside on the other backend. + GetOne2OneConversationBackendMismatch + | -- | This is returned when a 1-1 conversation between two unconnected users + -- is requested. + GetOne2OneConversationNotConnected + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform GetOne2OneConversationResponse) + deriving (ToJSON, FromJSON) via (CustomEncoded GetOne2OneConversationResponse) + -- | A record type describing a new federated conversation -- -- FUTUREWORK: Think about extracting common conversation metadata into a -- separarate data type that can be reused in several data types in this module. data ConversationCreated conv = ConversationCreated { -- | The time when the conversation was created - ccTime :: UTCTime, + time :: UTCTime, -- | The user that created the conversation. This is implicitly qualified -- by the requesting domain, since it is impossible to create a regular/group -- conversation on a remote backend. - ccOrigUserId :: UserId, + origUserId :: UserId, -- | The conversation ID, local to the backend invoking the RPC - ccCnvId :: conv, + cnvId :: conv, -- | The conversation type - ccCnvType :: ConvType, - ccCnvAccess :: [Access], - ccCnvAccessRoles :: Set AccessRole, + cnvType :: ConvType, + cnvAccess :: [Access], + cnvAccessRoles :: Set AccessRole, -- | The conversation name, - ccCnvName :: Maybe Text, + cnvName :: Maybe Text, -- | Members of the conversation apart from the creator - ccNonCreatorMembers :: Set OtherMember, - ccMessageTimer :: Maybe Milliseconds, - ccReceiptMode :: Maybe ReceiptMode, - ccProtocol :: Protocol + nonCreatorMembers :: Set OtherMember, + messageTimer :: Maybe Milliseconds, + receiptMode :: Maybe ReceiptMode, + protocol :: Protocol } deriving stock (Eq, Show, Generic, Functor) deriving (ToJSON, FromJSON) via (CustomEncoded (ConversationCreated conv)) ccRemoteOrigUserId :: ConversationCreated (Remote ConvId) -> Remote UserId -ccRemoteOrigUserId cc = qualifyAs (ccCnvId cc) (ccOrigUserId cc) - -data ConversationUpdate = ConversationUpdate - { cuTime :: UTCTime, - cuOrigUserId :: Qualified UserId, - -- | The unqualified ID of the conversation where the update is happening. - -- The ID is local to the sender to prevent putting arbitrary domain that - -- is different than that of the backend making a conversation membership - -- update request. - cuConvId :: ConvId, - -- | A list of users from the receiving backend that need to be sent - -- notifications about this change. This is required as we do not expect a - -- non-conversation owning backend to have an indexed mapping of - -- conversation to users. - cuAlreadyPresentUsers :: [UserId], - -- | Information on the specific action that caused the update. - cuAction :: SomeConversationAction - } - deriving (Eq, Show, Generic) - -instance ToJSON ConversationUpdate - -instance FromJSON ConversationUpdate +ccRemoteOrigUserId cc = qualifyAs cc.cnvId cc.origUserId data LeaveConversationRequest = LeaveConversationRequest { -- | The conversation is assumed to be owned by the target domain, which -- allows us to protect against relay attacks - lcConvId :: ConvId, + convId :: ConvId, -- | The leaver is assumed to be owned by the origin domain, which allows us -- to protect against spoofing attacks - lcLeaver :: UserId + leaver :: UserId } deriving stock (Generic, Eq, Show) deriving (ToJSON, FromJSON) via (CustomEncoded LeaveConversationRequest) @@ -280,37 +278,6 @@ data RemoveFromConversationError (ToJSON, FromJSON) via (CustomEncoded RemoveFromConversationError) --- Note: this is parametric in the conversation type to allow it to be used --- both for conversations with a fixed known domain (e.g. as the argument of the --- federation RPC), and for conversations with an arbitrary Qualified or Remote id --- (e.g. as the argument of the corresponding handler). -data RemoteMessage conv = RemoteMessage - { rmTime :: UTCTime, - rmData :: Maybe Text, - rmSender :: Qualified UserId, - rmSenderClient :: ClientId, - rmConversation :: conv, - rmPriority :: Maybe Priority, - rmPush :: Bool, - rmTransient :: Bool, - rmRecipients :: UserClientMap Text - } - deriving stock (Eq, Show, Generic, Functor) - deriving (Arbitrary) via (GenericUniform (RemoteMessage conv)) - deriving (ToJSON, FromJSON) via (CustomEncoded (RemoteMessage conv)) - -data RemoteMLSMessage = RemoteMLSMessage - { rmmTime :: UTCTime, - rmmMetadata :: MessageMetadata, - rmmSender :: Qualified UserId, - rmmConversation :: ConvId, - rmmRecipients :: [(UserId, ClientId)], - rmmMessage :: Base64ByteString - } - deriving stock (Eq, Show, Generic) - deriving (Arbitrary) via (GenericUniform RemoteMLSMessage) - deriving (ToJSON, FromJSON) via (CustomEncoded RemoteMLSMessage) - data RemoteMLSMessageResponse = RemoteMLSMessageOk | RemoteMLSMessageMLSNotEnabled @@ -320,11 +287,11 @@ data RemoteMLSMessageResponse data ProteusMessageSendRequest = ProteusMessageSendRequest { -- | Conversation is assumed to be owned by the target domain, this allows -- us to protect against relay attacks - pmsrConvId :: ConvId, + convId :: ConvId, -- | Sender is assumed to be owned by the origin domain, this allows us to -- protect against spoofing attacks - pmsrSender :: UserId, - pmsrRawMessage :: Base64ByteString + sender :: UserId, + rawMessage :: Base64ByteString } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform ProteusMessageSendRequest) @@ -333,18 +300,19 @@ data ProteusMessageSendRequest = ProteusMessageSendRequest data MLSMessageSendRequest = MLSMessageSendRequest { -- | Conversation (or sub conversation) is assumed to be owned by the target -- domain, this allows us to protect against relay attacks - mmsrConvOrSubId :: ConvOrSubConvId, + convOrSubId :: ConvOrSubConvId, -- | Sender is assumed to be owned by the origin domain, this allows us to -- protect against spoofing attacks - mmsrSender :: UserId, - mmsrRawMessage :: Base64ByteString + sender :: UserId, + senderClient :: ClientId, + rawMessage :: Base64ByteString } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform MLSMessageSendRequest) deriving (ToJSON, FromJSON) via (CustomEncoded MLSMessageSendRequest) newtype MessageSendResponse = MessageSendResponse - {msResponse :: PostOtrResponse MessageSendingStatus} + {response :: PostOtrResponse MessageSendingStatus} deriving stock (Eq, Show) deriving (ToJSON, FromJSON) @@ -354,32 +322,20 @@ newtype MessageSendResponse = MessageSendResponse ) newtype LeaveConversationResponse = LeaveConversationResponse - {leaveResponse :: Either RemoveFromConversationError ()} + {response :: Either RemoveFromConversationError ()} deriving stock (Eq, Show) deriving (ToJSON, FromJSON) via (Either (CustomEncoded RemoveFromConversationError) ()) -type UserDeletedNotificationMaxConvs = 1000 - -data UserDeletedConversationsNotification = UserDeletedConversationsNotification - { -- | This is qualified implicitly by the origin domain - udcvUser :: UserId, - -- | These are qualified implicitly by the target domain - udcvConversations :: Range 1 UserDeletedNotificationMaxConvs [ConvId] - } - deriving stock (Eq, Show, Generic) - deriving (Arbitrary) via (GenericUniform UserDeletedConversationsNotification) - deriving (FromJSON, ToJSON) via (CustomEncoded UserDeletedConversationsNotification) - data ConversationUpdateRequest = ConversationUpdateRequest { -- | The user that is attempting to perform the action. This is qualified -- implicitly by the origin domain - curUser :: UserId, + user :: UserId, -- | Id of conversation the action should be performed on. The is qualified -- implicity by the owning backend which receives this request. - curConvId :: ConvId, - curAction :: SomeConversationAction + convId :: ConvId, + action :: SomeConversationAction } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform ConversationUpdateRequest) @@ -397,8 +353,15 @@ data ConversationUpdateResponse via (CustomEncoded ConversationUpdateResponse) -- | A wrapper around a raw welcome message -newtype MLSWelcomeRequest = MLSWelcomeRequest - { unMLSWelcomeRequest :: Base64ByteString +data MLSWelcomeRequest = MLSWelcomeRequest + { -- | Implicitely qualified by origin domain + originatingUser :: UserId, + -- | A serialised welcome message. + welcomeMessage :: Base64ByteString, + -- | Recipients local to the target backend. + recipients :: [(UserId, ClientId)], + -- | The conversation id, qualified to the owning domain + qualifiedConvId :: Qualified ConvId } deriving stock (Eq, Generic, Show) deriving (Arbitrary) via (GenericUniform MLSWelcomeRequest) @@ -419,18 +382,18 @@ data MLSMessageResponse MLSMessageResponseUnreachableBackends (Set Domain) | -- | If the list of unreachable users is non-empty, it corresponds to users -- that an application message could not be sent to. - MLSMessageResponseUpdates [ConversationUpdate] (Maybe UnreachableUsers) + MLSMessageResponseUpdates [ConversationUpdate] | MLSMessageResponseNonFederatingBackends NonFederatingBackends deriving stock (Eq, Show, Generic) deriving (ToJSON, FromJSON) via (CustomEncoded MLSMessageResponse) data GetGroupInfoRequest = GetGroupInfoRequest - { -- | Conversation is assumed to be owned by the target domain, this allows - -- us to protect against relay attacks - ggireqConv :: ConvId, + { -- | Conversation (or subconversation) is assumed to be owned by the target + -- domain, this allows us to protect against relay attacks + conv :: ConvOrSubConvId, -- | Sender is assumed to be owned by the origin domain, this allows us to -- protect against spoofing attacks - ggireqSender :: UserId + sender :: UserId } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform GetGroupInfoRequest) @@ -441,3 +404,50 @@ data GetGroupInfoResponse | GetGroupInfoResponseState Base64ByteString deriving stock (Eq, Show, Generic) deriving (ToJSON, FromJSON) via (CustomEncoded GetGroupInfoResponse) + +data GetSubConversationsRequest = GetSubConversationsRequest + { gsreqUser :: UserId, + gsreqConv :: ConvId, + gsreqSubConv :: SubConvId + } + deriving stock (Eq, Show, Generic) + deriving (ToJSON, FromJSON) via (CustomEncoded GetSubConversationsRequest) + +data GetSubConversationsResponse + = GetSubConversationsResponseError GalleyError + | GetSubConversationsResponseSuccess PublicSubConversation + deriving stock (Eq, Show, Generic) + deriving (ToJSON, FromJSON) via (CustomEncoded GetSubConversationsResponse) + +data LeaveSubConversationRequest = LeaveSubConversationRequest + { lscrUser :: UserId, + lscrClient :: ClientId, + lscrConv :: ConvId, + lscrSubConv :: SubConvId + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform LeaveSubConversationRequest) + deriving (ToJSON, FromJSON) via (CustomEncoded LeaveSubConversationRequest) + +data LeaveSubConversationResponse + = LeaveSubConversationResponseError GalleyError + | LeaveSubConversationResponseProtocolError Text + | LeaveSubConversationResponseOk + deriving stock (Eq, Show, Generic) + deriving (ToJSON, FromJSON) via (CustomEncoded LeaveSubConversationResponse) + +data DeleteSubConversationFedRequest = DeleteSubConversationFedRequest + { dscreqUser :: UserId, + dscreqConv :: ConvId, + dscreqSubConv :: SubConvId, + dscreqGroupId :: GroupId, + dscreqEpoch :: Epoch + } + deriving stock (Eq, Show, Generic) + deriving (ToJSON, FromJSON) via (CustomEncoded DeleteSubConversationFedRequest) + +data DeleteSubConversationResponse + = DeleteSubConversationResponseError GalleyError + | DeleteSubConversationResponseSuccess + deriving stock (Eq, Show, Generic) + deriving (ToJSON, FromJSON) via (CustomEncoded DeleteSubConversationResponse) diff --git a/libs/wire-api-federation/src/Wire/API/Federation/API/Galley/Notifications.hs b/libs/wire-api-federation/src/Wire/API/Federation/API/Galley/Notifications.hs new file mode 100644 index 00000000000..e5a401f3940 --- /dev/null +++ b/libs/wire-api-federation/src/Wire/API/Federation/API/Galley/Notifications.hs @@ -0,0 +1,181 @@ +{-# OPTIONS_GHC -Wno-incomplete-patterns #-} +{-# OPTIONS_GHC -Wno-unused-matches #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Federation.API.Galley.Notifications where + +import Data.Aeson +import Data.Id +import Data.Json.Util +import Data.List.NonEmpty +import Data.Qualified +import Data.Range +import Data.Time.Clock +import Imports +import Servant.API +import Wire.API.Conversation.Action +import Wire.API.Federation.Component +import Wire.API.Federation.Endpoint +import Wire.API.Federation.HasNotificationEndpoint +import Wire.API.MLS.SubConversation +import Wire.API.MakesFederatedCall +import Wire.API.Message +import Wire.API.Util.Aeson +import Wire.Arbitrary + +data GalleyNotificationTag + = OnClientRemovedTag + | OnMessageSentTag + | OnMLSMessageSentTag + | OnConversationUpdatedTag + | OnUserDeletedConversationsTag + deriving (Show, Eq, Generic, Bounded, Enum) + +instance HasNotificationEndpoint 'OnClientRemovedTag where + type Payload 'OnClientRemovedTag = ClientRemovedRequest + type NotificationPath 'OnClientRemovedTag = "on-client-removed" + type NotificationComponent 'OnClientRemovedTag = 'Galley + type + NotificationAPI 'OnClientRemovedTag 'Galley = + NotificationFedEndpointWithMods + '[ MakesFederatedCall 'Galley "on-mls-message-sent" + ] + (NotificationPath 'OnClientRemovedTag) + (Payload 'OnClientRemovedTag) + +instance HasNotificationEndpoint 'OnMessageSentTag where + type Payload 'OnMessageSentTag = RemoteMessage ConvId + type NotificationPath 'OnMessageSentTag = "on-message-sent" + type NotificationComponent 'OnMessageSentTag = 'Galley + + -- used to notify this backend that a new message has been posted to a + -- remote conversation + type NotificationAPI 'OnMessageSentTag 'Galley = NotificationFedEndpoint 'OnMessageSentTag + +instance HasNotificationEndpoint 'OnMLSMessageSentTag where + type Payload 'OnMLSMessageSentTag = RemoteMLSMessage + type NotificationPath 'OnMLSMessageSentTag = "on-mls-message-sent" + type NotificationComponent 'OnMLSMessageSentTag = 'Galley + type NotificationAPI 'OnMLSMessageSentTag 'Galley = NotificationFedEndpoint 'OnMLSMessageSentTag + +instance HasNotificationEndpoint 'OnConversationUpdatedTag where + type Payload 'OnConversationUpdatedTag = ConversationUpdate + type NotificationPath 'OnConversationUpdatedTag = "on-conversation-updated" + type NotificationComponent 'OnConversationUpdatedTag = 'Galley + + -- used by the backend that owns a conversation to inform this backend of + -- changes to the conversation + type NotificationAPI 'OnConversationUpdatedTag 'Galley = NotificationFedEndpoint 'OnConversationUpdatedTag + +instance HasNotificationEndpoint 'OnUserDeletedConversationsTag where + type Payload 'OnUserDeletedConversationsTag = UserDeletedConversationsNotification + type NotificationPath 'OnUserDeletedConversationsTag = "on-user-deleted-conversations" + type NotificationComponent 'OnUserDeletedConversationsTag = 'Galley + type + NotificationAPI 'OnUserDeletedConversationsTag 'Galley = + NotificationFedEndpointWithMods + '[ MakesFederatedCall 'Galley "on-mls-message-sent", + MakesFederatedCall 'Galley "on-conversation-updated", + MakesFederatedCall 'Brig "api-version" + ] + (NotificationPath 'OnUserDeletedConversationsTag) + (Payload 'OnUserDeletedConversationsTag) + +-- | All the notification endpoints return an 'EmptyResponse'. +type GalleyNotificationAPI = + NotificationAPI 'OnClientRemovedTag 'Galley + :<|> NotificationAPI 'OnMessageSentTag 'Galley + :<|> NotificationAPI 'OnMLSMessageSentTag 'Galley + :<|> NotificationAPI 'OnConversationUpdatedTag 'Galley + :<|> NotificationAPI 'OnUserDeletedConversationsTag 'Galley + +data ClientRemovedRequest = ClientRemovedRequest + { user :: UserId, + client :: ClientId, + convs :: [ConvId] + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform ClientRemovedRequest) + deriving (FromJSON, ToJSON) via (CustomEncoded ClientRemovedRequest) + +-- Note: this is parametric in the conversation type to allow it to be used +-- both for conversations with a fixed known domain (e.g. as the argument of the +-- federation RPC), and for conversations with an arbitrary Qualified or Remote id +-- (e.g. as the argument of the corresponding handler). +data RemoteMessage conv = RemoteMessage + { time :: UTCTime, + _data :: Maybe Text, + sender :: Qualified UserId, + senderClient :: ClientId, + conversation :: conv, + priority :: Maybe Priority, + push :: Bool, + transient :: Bool, + recipients :: UserClientMap Text + } + deriving stock (Eq, Show, Generic, Functor) + deriving (Arbitrary) via (GenericUniform (RemoteMessage conv)) + deriving (ToJSON, FromJSON) via (CustomEncodedLensable (RemoteMessage conv)) + +data RemoteMLSMessage = RemoteMLSMessage + { time :: UTCTime, + metadata :: MessageMetadata, + sender :: Qualified UserId, + conversation :: ConvId, + subConversation :: Maybe SubConvId, + recipients :: Map UserId (NonEmpty ClientId), + message :: Base64ByteString + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform RemoteMLSMessage) + deriving (ToJSON, FromJSON) via (CustomEncoded RemoteMLSMessage) + +data ConversationUpdate = ConversationUpdate + { cuTime :: UTCTime, + cuOrigUserId :: Qualified UserId, + -- | The unqualified ID of the conversation where the update is happening. + -- The ID is local to the sender to prevent putting arbitrary domain that + -- is different than that of the backend making a conversation membership + -- update request. + cuConvId :: ConvId, + -- | A list of users from the receiving backend that need to be sent + -- notifications about this change. This is required as we do not expect a + -- non-conversation owning backend to have an indexed mapping of + -- conversation to users. + cuAlreadyPresentUsers :: [UserId], + -- | Information on the specific action that caused the update. + cuAction :: SomeConversationAction + } + deriving (Eq, Show, Generic) + +instance ToJSON ConversationUpdate + +instance FromJSON ConversationUpdate + +type UserDeletedNotificationMaxConvs = 1000 + +data UserDeletedConversationsNotification = UserDeletedConversationsNotification + { -- | This is qualified implicitly by the origin domain + user :: UserId, + -- | These are qualified implicitly by the target domain + conversations :: Range 1 UserDeletedNotificationMaxConvs [ConvId] + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform UserDeletedConversationsNotification) + deriving (FromJSON, ToJSON) via (CustomEncoded UserDeletedConversationsNotification) diff --git a/libs/wire-api-federation/src/Wire/API/Federation/BackendNotifications.hs b/libs/wire-api-federation/src/Wire/API/Federation/BackendNotifications.hs index 3560e2c5e44..6ad8ddde899 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/BackendNotifications.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/BackendNotifications.hs @@ -6,22 +6,15 @@ module Wire.API.Federation.BackendNotifications where import Control.Exception import Control.Monad.Except import Data.Aeson -import Data.ByteString.Builder qualified as Builder -import Data.ByteString.Lazy qualified as LBS import Data.Domain import Data.Map qualified as Map -import Data.Sequence qualified as Seq import Data.Text qualified as Text -import Data.Text.Encoding import Data.Text.Lazy.Encoding qualified as TL import Imports import Network.AMQP qualified as Q import Network.AMQP.Types qualified as Q -import Network.HTTP.Types import Servant -import Servant.Client import Servant.Client.Core -import Servant.Types.SourceT import Wire.API.Federation.API.Common import Wire.API.Federation.Client import Wire.API.Federation.Component @@ -77,7 +70,7 @@ sendNotification env component path body = runFederatorClient env . void $ clientIn (Proxy @BackendNotificationAPI) (Proxy @(FederatorClient c)) (withoutFirstSlash path) body -enqueue :: Q.Channel -> Domain -> Domain -> Q.DeliveryMode -> FedQueueClient c () -> IO () +enqueue :: Q.Channel -> Domain -> Domain -> Q.DeliveryMode -> FedQueueClient c a -> IO a enqueue channel originDomain targetDomain deliveryMode (FedQueueClient action) = runReaderT action FedQueueEnv {..} @@ -125,7 +118,7 @@ ensureQueue chan queue = do -- queue. Perhaps none of this should be servant code anymore. But it is here to -- allow smooth transition to RabbitMQ based notification pushing. -- --- Use 'Wire.API.Federation.API.fedQueueClient' to create and action and pass it +-- Use 'Wire.API.Federation.API.fedQueueClient' to create an action and pass it -- to 'enqueue' newtype FedQueueClient c a = FedQueueClient (ReaderT FedQueueEnv IO a) deriving (Functor, Applicative, Monad, MonadIO, MonadReader FedQueueEnv) @@ -141,42 +134,3 @@ data EnqueueError = EnqueueError String deriving (Show) instance Exception EnqueueError - -instance (KnownComponent c) => RunClient (FedQueueClient c) where - runRequestAcceptStatus :: Maybe [Status] -> Request -> FedQueueClient c Response - runRequestAcceptStatus _ req = do - env <- ask - bodyLBS <- case requestBody req of - Just (RequestBodyLBS lbs, _) -> pure lbs - Just (RequestBodyBS bs, _) -> pure (LBS.fromStrict bs) - Just (RequestBodySource src, _) -> liftIO $ do - errOrRes <- runExceptT $ runSourceT src - either (throwIO . EnqueueError) (pure . mconcat) errOrRes - Nothing -> pure mempty - let notif = - BackendNotification - { ownDomain = env.originDomain, - targetComponent = componentVal @c, - path = decodeUtf8 $ LBS.toStrict $ Builder.toLazyByteString req.requestPath, - body = RawJson bodyLBS - } - let msg = - Q.newMsg - { Q.msgBody = encode notif, - Q.msgDeliveryMode = Just (env.deliveryMode), - Q.msgContentType = Just "application/json" - } - -- Empty string means default exchange - exchange = "" - liftIO $ do - ensureQueue env.channel env.targetDomain._domainText - void $ Q.publishMsg env.channel exchange (routingKey env.targetDomain._domainText) msg - pure $ - Response - { responseHttpVersion = http20, - responseStatusCode = status200, - responseHeaders = Seq.singleton (hContentType, "application/json"), - responseBody = "{}" - } - throwClientError :: ClientError -> FedQueueClient c a - throwClientError = liftIO . throwIO diff --git a/libs/wire-api-federation/src/Wire/API/Federation/Client.hs b/libs/wire-api-federation/src/Wire/API/Federation/Client.hs index f2a220a2c3d..b27b833b2e7 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/Client.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/Client.hs @@ -32,6 +32,7 @@ module Wire.API.Federation.Client ) where +import Control.Concurrent.Async import Control.Exception qualified as E import Control.Monad.Catch import Control.Monad.Codensity @@ -58,6 +59,7 @@ import Network.HTTP.Media qualified as HTTP import Network.HTTP.Types qualified as HTTP import Network.HTTP2.Client qualified as HTTP2 import Network.Wai.Utilities.Error qualified as Wai +import OpenSSL.Session qualified as SSL import Servant.Client import Servant.Client.Core import Servant.Types.SourceT @@ -113,13 +115,27 @@ liftCodensity = FederatorClient . lift . lift . lift headersFromTable :: HTTP2.HeaderTable -> [HTTP.Header] headersFromTable (headerList, _) = flip map headerList $ first HTTP2.tokenKey +-- This opens a new http2 connection. Using a http2-manager leads to this problem https://wearezeta.atlassian.net/browse/WPB-4787 +-- FUTUREWORK: Replace with H2Manager.withHTTP2Request once the bugs are solved. +withNewHttpRequest :: H2Manager.Target -> HTTP2.Request -> (HTTP2.Response -> IO a) -> IO a +withNewHttpRequest target req k = do + ctx <- SSL.context + let cacheLimit = 20 + sslRemoveTrailingDot = False + tcpConnectionTimeout = 30_000_000 + sendReqMVar <- newEmptyMVar + thread <- liftIO . async $ H2Manager.startPersistentHTTP2Connection ctx target cacheLimit sslRemoveTrailingDot tcpConnectionTimeout sendReqMVar + let newConn = H2Manager.HTTP2Conn thread (putMVar sendReqMVar H2Manager.CloseConnection) sendReqMVar + H2Manager.sendRequestWithConnection newConn req $ \resp -> do + k resp <* newConn.disconnect + performHTTP2Request :: Http2Manager -> H2Manager.Target -> HTTP2.Request -> IO (Either FederatorClientHTTP2Error (ResponseF Builder)) -performHTTP2Request mgr target req = try $ do - H2Manager.withHTTP2Request mgr target req $ consumeStreamingResponseWith $ \resp -> do +performHTTP2Request _mgr target req = try $ do + withNewHttpRequest target req $ consumeStreamingResponseWith $ \resp -> do b <- fmap (fromRight mempty) . runExceptT @@ -210,7 +226,7 @@ withHTTP2StreamingRequest successfulStatus req handleResponse = do either throwError pure <=< liftCodensity $ Codensity $ \k -> E.catches - (H2Manager.withHTTP2Request (ceHttp2Manager env) (False, hostname, port) req' (consumeStreamingResponseWith (k . Right))) + (withNewHttpRequest (False, hostname, port) req' (consumeStreamingResponseWith (k . Right))) [ E.Handler $ k . Left . FederatorClientHTTP2Error, E.Handler $ k . Left . FederatorClientHTTP2Error . FederatorClientConnectionError, E.Handler $ k . Left . FederatorClientHTTP2Error . FederatorClientHTTP2Exception, @@ -254,7 +270,8 @@ mkFailureResponse status domain path body { Wai.federrDomain = domain, Wai.federrPath = "/federation" - <> Text.decodeUtf8With Text.lenientDecode (LBS.toStrict path) + <> Text.decodeUtf8With Text.lenientDecode (LBS.toStrict path), + Wai.federrResp = pure body } } where diff --git a/libs/wire-api-federation/src/Wire/API/Federation/Endpoint.hs b/libs/wire-api-federation/src/Wire/API/Federation/Endpoint.hs index 509e73aa61b..664835848f0 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/Endpoint.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/Endpoint.hs @@ -24,7 +24,9 @@ where import Data.Kind import Servant.API import Wire.API.ApplyMods +import Wire.API.Federation.API.Common import Wire.API.Federation.Domain +import Wire.API.Federation.HasNotificationEndpoint import Wire.API.Routes.Named type FedEndpointWithMods (mods :: [Type]) name input output = @@ -35,8 +37,14 @@ type FedEndpointWithMods (mods :: [Type]) name input output = (name :> OriginDomainHeader :> ReqBody '[JSON] input :> Post '[JSON] output) ) +type NotificationFedEndpointWithMods (mods :: [Type]) name input = + FedEndpointWithMods mods name input EmptyResponse + type FedEndpoint name input output = FedEndpointWithMods '[] name input output +type NotificationFedEndpoint tag = + FedEndpoint (NotificationPath tag) (Payload tag) EmptyResponse + type StreamingFedEndpoint name input output = Named name diff --git a/libs/wire-api-federation/src/Wire/API/Federation/Error.hs b/libs/wire-api-federation/src/Wire/API/Federation/Error.hs index 02fd6403a44..cfa99767e6c 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/Error.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/Error.hs @@ -29,7 +29,7 @@ -- corresponding to a failure at the level of the federator client. It -- includes, for example, a failure to reach a remote federator, or an -- error on the remote side. --- * 'FederatorError': this is created by users of the federator client. It +-- * 'FederationError': this is created by users of the federator client. It -- can either wrap a 'FederatorClientError', or be an error that is outside -- the scope of the client, such as when a federated request succeeds with -- an unexpected result. @@ -83,6 +83,7 @@ module Wire.API.Federation.Error ) where +import Data.Domain (Domain (..)) import Data.Text qualified as T import Data.Text.Encoding qualified as T import Data.Text.Lazy qualified as LT @@ -206,27 +207,35 @@ federationClientErrorToWai FederatorClientVersionMismatch = "internal-error" "Endpoint version mismatch in federation client" -federationRemoteHTTP2Error :: FederatorClientHTTP2Error -> Wai.Error -federationRemoteHTTP2Error FederatorClientNoStatusCode = - Wai.mkError - unexpectedFederationResponseStatus - "federation-http2-error" - "No status code in HTTP2 response" -federationRemoteHTTP2Error (FederatorClientHTTP2Exception e) = - Wai.mkError - unexpectedFederationResponseStatus - "federation-http2-error" - (LT.pack (displayException e)) -federationRemoteHTTP2Error (FederatorClientTLSException e) = - Wai.mkError - (HTTP.mkStatus 525 "SSL Handshake Failure") - "federation-tls-error" - (LT.pack (displayException e)) -federationRemoteHTTP2Error (FederatorClientConnectionError e) = - Wai.mkError - federatorConnectionRefusedStatus - "federation-connection-refused" - (LT.pack (displayException e)) +federationRemoteHTTP2Error :: Domain -> Text -> FederatorClientHTTP2Error -> Wai.Error +federationRemoteHTTP2Error domain path FederatorClientNoStatusCode = + let err = + Wai.mkError + unexpectedFederationResponseStatus + "federation-http2-error" + "No status code in HTTP2 response" + in err {Wai.errorData = pure $ Wai.FederationErrorData domain path Nothing} +federationRemoteHTTP2Error domain path (FederatorClientHTTP2Exception e) = + let err = + Wai.mkError + unexpectedFederationResponseStatus + "federation-http2-error" + (LT.pack (displayException e)) + in err {Wai.errorData = pure $ Wai.FederationErrorData domain path Nothing} +federationRemoteHTTP2Error domain path (FederatorClientTLSException e) = + let err = + Wai.mkError + (HTTP.mkStatus 525 "SSL Handshake Failure") + "federation-tls-error" + (LT.pack (displayException e)) + in err {Wai.errorData = pure $ Wai.FederationErrorData domain path Nothing} +federationRemoteHTTP2Error domain path (FederatorClientConnectionError e) = + let err = + Wai.mkError + federatorConnectionRefusedStatus + "federation-connection-refused" + (LT.pack (displayException e)) + in err {Wai.errorData = pure $ Wai.FederationErrorData domain path Nothing} federationClientHTTP2Error :: FederatorClientHTTP2Error -> Wai.Error federationClientHTTP2Error (FederatorClientConnectionError e) = @@ -240,14 +249,21 @@ federationClientHTTP2Error e = "federation-local-error" (LT.pack (displayException e)) -federationRemoteResponseError :: HTTP.Status -> Wai.Error -federationRemoteResponseError status = - Wai.mkError - unexpectedFederationResponseStatus - "federation-remote-error" - ( "A remote federator failed with status code " - <> LT.pack (show (HTTP.statusCode status)) - ) +federationRemoteResponseError :: Domain -> Text -> HTTP.Status -> LByteString -> Wai.Error +federationRemoteResponseError domain path status resp = + err + { Wai.errorData = pure $ Wai.FederationErrorData domain path $ pure resp + } + where + err = + Wai.mkError + unexpectedFederationResponseStatus + "federation-remote-error" + ( "A remote federator (" + <> LT.fromStrict domain._domainText + <> ") failed with status code " + <> LT.pack (show (HTTP.statusCode status)) + ) federationServantErrorToWai :: ClientError -> Wai.Error federationServantErrorToWai (DecodeFailure msg _) = federationInvalidBody msg diff --git a/libs/wire-api-federation/src/Wire/API/Federation/HasNotificationEndpoint.hs b/libs/wire-api-federation/src/Wire/API/Federation/HasNotificationEndpoint.hs new file mode 100644 index 00000000000..d9d147b6fc3 --- /dev/null +++ b/libs/wire-api-federation/src/Wire/API/Federation/HasNotificationEndpoint.hs @@ -0,0 +1,67 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Federation.HasNotificationEndpoint where + +import Data.Aeson +import Data.Domain +import Data.Kind +import Data.Proxy +import Data.Text qualified as T +import GHC.TypeLits +import Imports +import Wire.API.Federation.BackendNotifications +import Wire.API.Federation.Component +import Wire.API.RawJson + +class HasNotificationEndpoint t where + -- | The type of the payload for this endpoint + type Payload t :: Type + + -- | The central path component of a notification endpoint, e.g., + -- "on-conversation-updated". + type NotificationPath t :: Symbol + + -- | The server component this endpoint is associated with + type NotificationComponent t :: Component + + -- | The Servant API endpoint type + type NotificationAPI t (c :: Component) :: Type + +-- | Convert a federation endpoint to a backend notification to be enqueued to a +-- RabbitMQ queue. +fedNotifToBackendNotif :: + forall tag. + KnownSymbol (NotificationPath tag) => + KnownComponent (NotificationComponent tag) => + ToJSON (Payload tag) => + Domain -> + Payload tag -> + BackendNotification +fedNotifToBackendNotif ownDomain payload = + let p = T.pack . symbolVal $ Proxy @(NotificationPath tag) + b = RawJson . encode $ payload + in toNotif p b + where + toNotif :: Text -> RawJson -> BackendNotification + toNotif path body = + BackendNotification + { ownDomain = ownDomain, + targetComponent = componentVal @(NotificationComponent tag), + path = path, + body = body + } diff --git a/libs/wire-api-federation/src/Wire/API/Federation/Version.hs b/libs/wire-api-federation/src/Wire/API/Federation/Version.hs index 3a433e3fb2a..0f3e113db95 100644 --- a/libs/wire-api-federation/src/Wire/API/Federation/Version.hs +++ b/libs/wire-api-federation/src/Wire/API/Federation/Version.hs @@ -21,10 +21,10 @@ module Wire.API.Federation.Version where import Control.Lens ((?~)) import Data.Aeson (FromJSON (..), ToJSON (..)) +import Data.OpenApi qualified as S import Data.Schema import Data.Set qualified as Set import Data.Singletons.TH -import Data.Swagger qualified as S import Imports import Wire.API.VersionInfo diff --git a/libs/wire-api-federation/test/Test/Wire/API/Federation/Golden/ConversationCreated.hs b/libs/wire-api-federation/test/Test/Wire/API/Federation/Golden/ConversationCreated.hs index 6f0765b8138..432ea013b98 100644 --- a/libs/wire-api-federation/test/Test/Wire/API/Federation/Golden/ConversationCreated.hs +++ b/libs/wire-api-federation/test/Test/Wire/API/Federation/Golden/ConversationCreated.hs @@ -22,6 +22,8 @@ import Data.Id import Data.Misc import Data.Qualified import Data.Set qualified as Set +import Data.Time.Calendar +import Data.Time.Clock import Data.UUID qualified as UUID import Imports import Wire.API.Conversation @@ -34,14 +36,14 @@ import Wire.API.Provider.Service testObject_ConversationCreated1 :: ConversationCreated ConvId testObject_ConversationCreated1 = ConversationCreated - { ccTime = read "1864-04-12 12:22:43.673 UTC", - ccOrigUserId = Id (fromJust (UUID.fromString "eed9dea3-5468-45f8-b562-7ad5de2587d0")), - ccCnvId = Id (fromJust (UUID.fromString "d13dbe58-d4e3-450f-9c0c-1e632f548740")), - ccCnvType = RegularConv, - ccCnvAccess = [InviteAccess, CodeAccess], - ccCnvAccessRoles = Set.fromList [TeamMemberAccessRole, NonTeamMemberAccessRole], - ccCnvName = Just "gossip", - ccNonCreatorMembers = + { time = read "1864-04-12 12:22:43.673 UTC", + origUserId = Id (fromJust (UUID.fromString "eed9dea3-5468-45f8-b562-7ad5de2587d0")), + cnvId = Id (fromJust (UUID.fromString "d13dbe58-d4e3-450f-9c0c-1e632f548740")), + cnvType = RegularConv, + cnvAccess = [InviteAccess, CodeAccess], + cnvAccessRoles = Set.fromList [TeamMemberAccessRole, NonTeamMemberAccessRole], + cnvName = Just "gossip", + nonCreatorMembers = Set.fromList [ OtherMember { omQualifiedId = @@ -66,23 +68,23 @@ testObject_ConversationCreated1 = omConvRoleName = roleNameWireMember } ], - ccMessageTimer = Just (Ms 1000), - ccReceiptMode = Just (ReceiptMode 42), - ccProtocol = ProtocolProteus + messageTimer = Just (Ms 1000), + receiptMode = Just (ReceiptMode 42), + protocol = ProtocolProteus } testObject_ConversationCreated2 :: ConversationCreated ConvId testObject_ConversationCreated2 = ConversationCreated - { ccTime = read "1864-04-12 12:22:43.673 UTC", - ccOrigUserId = Id (fromJust (UUID.fromString "eed9dea3-5468-45f8-b562-7ad5de2587d0")), - ccCnvId = Id (fromJust (UUID.fromString "d13dbe58-d4e3-450f-9c0c-1e632f548740")), - ccCnvType = One2OneConv, - ccCnvAccess = [], - ccCnvAccessRoles = Set.fromList [TeamMemberAccessRole, NonTeamMemberAccessRole], - ccCnvName = Nothing, - ccNonCreatorMembers = Set.fromList [], - ccMessageTimer = Nothing, - ccReceiptMode = Nothing, - ccProtocol = ProtocolMLS (ConversationMLSData (GroupId "group") (Epoch 3) MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519) + { time = read "1864-04-12 12:22:43.673 UTC", + origUserId = Id (fromJust (UUID.fromString "eed9dea3-5468-45f8-b562-7ad5de2587d0")), + cnvId = Id (fromJust (UUID.fromString "d13dbe58-d4e3-450f-9c0c-1e632f548740")), + cnvType = One2OneConv, + cnvAccess = [], + cnvAccessRoles = Set.fromList [TeamMemberAccessRole, NonTeamMemberAccessRole], + cnvName = Nothing, + nonCreatorMembers = Set.fromList [], + messageTimer = Nothing, + receiptMode = Nothing, + protocol = ProtocolMLS (ConversationMLSData (GroupId "group") (Epoch 3) (Just (UTCTime (fromGregorian 2020 8 29) 0)) MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519) } diff --git a/libs/wire-api-federation/test/Test/Wire/API/Federation/Golden/MLSMessageSendingStatus.hs b/libs/wire-api-federation/test/Test/Wire/API/Federation/Golden/MLSMessageSendingStatus.hs index b230278e198..27fba120068 100644 --- a/libs/wire-api-federation/test/Test/Wire/API/Federation/Golden/MLSMessageSendingStatus.hs +++ b/libs/wire-api-federation/test/Test/Wire/API/Federation/Golden/MLSMessageSendingStatus.hs @@ -24,30 +24,26 @@ import Data.Qualified import Data.UUID qualified as UUID import Imports import Wire.API.MLS.Message -import Wire.API.Unreachable testObject_MLSMessageSendingStatus1 :: MLSMessageSendingStatus testObject_MLSMessageSendingStatus1 = MLSMessageSendingStatus { mmssEvents = [], - mmssTime = toUTCTimeMillis (read "1864-04-12 12:22:43.673 UTC"), - mmssFailedToSendTo = mempty + mmssTime = toUTCTimeMillis (read "1864-04-12 12:22:43.673 UTC") } testObject_MLSMessageSendingStatus2 :: MLSMessageSendingStatus testObject_MLSMessageSendingStatus2 = MLSMessageSendingStatus { mmssEvents = [], - mmssTime = toUTCTimeMillis (read "2001-04-12 12:22:43.673 UTC"), - mmssFailedToSendTo = unreachableFromList failed1 + mmssTime = toUTCTimeMillis (read "2001-04-12 12:22:43.673 UTC") } testObject_MLSMessageSendingStatus3 :: MLSMessageSendingStatus testObject_MLSMessageSendingStatus3 = MLSMessageSendingStatus { mmssEvents = [], - mmssTime = toUTCTimeMillis (read "1999-04-12 12:22:43.673 UTC"), - mmssFailedToSendTo = unreachableFromList failed2 + mmssTime = toUTCTimeMillis (read "1999-04-12 12:22:43.673 UTC") } failed1 :: [Qualified UserId] diff --git a/libs/wire-api-federation/test/Test/Wire/API/Federation/Golden/NewConnectionRequest.hs b/libs/wire-api-federation/test/Test/Wire/API/Federation/Golden/NewConnectionRequest.hs index 514cb18a53d..de8b20ca950 100644 --- a/libs/wire-api-federation/test/Test/Wire/API/Federation/Golden/NewConnectionRequest.hs +++ b/libs/wire-api-federation/test/Test/Wire/API/Federation/Golden/NewConnectionRequest.hs @@ -25,15 +25,15 @@ import Wire.API.Federation.API.Brig testObject_NewConnectionRequest1 :: NewConnectionRequest testObject_NewConnectionRequest1 = NewConnectionRequest - { ncrFrom = Id (fromJust (UUID.fromString "69f66843-6cf1-48fb-8c05-1cf58c23566a")), - ncrTo = Id (fromJust (UUID.fromString "1669240c-c510-43e0-bf1a-33378fa4ba55")), - ncrAction = RemoteConnect + { from = Id (fromJust (UUID.fromString "69f66843-6cf1-48fb-8c05-1cf58c23566a")), + to = Id (fromJust (UUID.fromString "1669240c-c510-43e0-bf1a-33378fa4ba55")), + action = RemoteConnect } testObject_NewConnectionRequest2 :: NewConnectionRequest testObject_NewConnectionRequest2 = NewConnectionRequest - { ncrFrom = Id (fromJust (UUID.fromString "69f66843-6cf1-48fb-8c05-1cf58c23566a")), - ncrTo = Id (fromJust (UUID.fromString "1669240c-c510-43e0-bf1a-33378fa4ba55")), - ncrAction = RemoteRescind + { from = Id (fromJust (UUID.fromString "69f66843-6cf1-48fb-8c05-1cf58c23566a")), + to = Id (fromJust (UUID.fromString "1669240c-c510-43e0-bf1a-33378fa4ba55")), + action = RemoteRescind } diff --git a/libs/wire-api-federation/test/golden/testObject_ConversationCreated2.json b/libs/wire-api-federation/test/golden/testObject_ConversationCreated2.json index cca0a6bb7c9..ae542d36e7d 100644 --- a/libs/wire-api-federation/test/golden/testObject_ConversationCreated2.json +++ b/libs/wire-api-federation/test/golden/testObject_ConversationCreated2.json @@ -13,6 +13,7 @@ "protocol": { "cipher_suite": 1, "epoch": 3, + "epoch_timestamp": "2020-08-29T00:00:00Z", "group_id": "Z3JvdXA=", "protocol": "mls" }, diff --git a/libs/wire-api-federation/test/golden/testObject_MLSMessageSendingStatus2.json b/libs/wire-api-federation/test/golden/testObject_MLSMessageSendingStatus2.json index fb932f00593..5d03a97ba11 100644 --- a/libs/wire-api-federation/test/golden/testObject_MLSMessageSendingStatus2.json +++ b/libs/wire-api-federation/test/golden/testObject_MLSMessageSendingStatus2.json @@ -1,10 +1,4 @@ { "events": [], - "time": "2001-04-12T12:22:43.673Z", - "failed_to_send": [ - { - "domain": "offline.example.com", - "id": "00000000-0000-0000-0000-000200000008" - } - ] + "time": "2001-04-12T12:22:43.673Z" } diff --git a/libs/wire-api-federation/test/golden/testObject_MLSMessageSendingStatus3.json b/libs/wire-api-federation/test/golden/testObject_MLSMessageSendingStatus3.json index 87c92624480..47e408103f9 100644 --- a/libs/wire-api-federation/test/golden/testObject_MLSMessageSendingStatus3.json +++ b/libs/wire-api-federation/test/golden/testObject_MLSMessageSendingStatus3.json @@ -1,14 +1,4 @@ { "events": [], - "time": "1999-04-12T12:22:43.673Z", - "failed_to_send": [ - { - "domain": "golden.example.com", - "id": "00000000-0000-0000-0000-000200000008" - }, - { - "domain": "golden.example.com", - "id": "00000000-0000-0000-0000-000100000007" - } - ] + "time": "1999-04-12T12:22:43.673Z" } diff --git a/libs/wire-api-federation/wire-api-federation.cabal b/libs/wire-api-federation/wire-api-federation.cabal index 53862a2f92f..3d46abff3d6 100644 --- a/libs/wire-api-federation/wire-api-federation.cabal +++ b/libs/wire-api-federation/wire-api-federation.cabal @@ -18,15 +18,18 @@ library exposed-modules: Wire.API.Federation.API Wire.API.Federation.API.Brig + Wire.API.Federation.API.Brig.Notifications Wire.API.Federation.API.Cargohold Wire.API.Federation.API.Common Wire.API.Federation.API.Galley + Wire.API.Federation.API.Galley.Notifications Wire.API.Federation.BackendNotifications Wire.API.Federation.Client Wire.API.Federation.Component Wire.API.Federation.Domain Wire.API.Federation.Endpoint Wire.API.Federation.Error + Wire.API.Federation.HasNotificationEndpoint Wire.API.Federation.Version other-modules: Paths_wire_api_federation @@ -82,6 +85,7 @@ library build-depends: aeson >=2.0.1.0 , amqp + , async , base >=4.6 && <5.0 , bytestring , bytestring-conversion @@ -97,6 +101,7 @@ library , lens , metrics-wai , mtl + , openapi3 , QuickCheck >=2.13 , schema-profunctor , servant >=0.16 @@ -104,7 +109,6 @@ library , servant-client-core , servant-server , singletons-th - , swagger2 , text >=0.11 , time >=1.8 , transformers @@ -195,6 +199,7 @@ test-suite spec , imports , QuickCheck >=2.13 , singletons + , time , types-common , uuid , wire-api diff --git a/libs/wire-api/default.nix b/libs/wire-api/default.nix index e6ec675d546..3793ac70e0b 100644 --- a/libs/wire-api/default.nix +++ b/libs/wire-api/default.nix @@ -46,7 +46,6 @@ , hspec , hspec-wai , http-api-data -, http-client , http-media , http-types , imports @@ -61,9 +60,9 @@ , metrics-wai , mime , mtl +, openapi3 , pem , polysemy -, pretty , process , proto-lens , protobuf @@ -71,7 +70,6 @@ , quickcheck-instances , random , resourcet -, retry , saml2-web-sso , schema-profunctor , scientific @@ -81,13 +79,12 @@ , servant-client-core , servant-conduit , servant-multipart +, servant-openapi3 , servant-server -, servant-swagger , singletons , singletons-base , singletons-th , sop-core -, swagger2 , tagged , tasty , tasty-hspec @@ -95,7 +92,6 @@ , tasty-quickcheck , text , time -, tinylog , transitive-anns , types-common , unliftio @@ -153,7 +149,6 @@ mkDerivation { hscim HsOpenSSL http-api-data - http-client http-media http-types imports @@ -167,6 +162,7 @@ mkDerivation { metrics-wai mime mtl + openapi3 pem polysemy proto-lens @@ -175,7 +171,6 @@ mkDerivation { quickcheck-instances random resourcet - retry saml2-web-sso schema-profunctor scientific @@ -185,17 +180,15 @@ mkDerivation { servant-client-core servant-conduit servant-multipart + servant-openapi3 servant-server - servant-swagger singletons singletons-base singletons-th sop-core - swagger2 tagged text time - tinylog transitive-anns types-common unordered-containers @@ -239,15 +232,16 @@ mkDerivation { lens memory metrics-wai + openapi3 pem - pretty process proto-lens QuickCheck + random + saml2-web-sso schema-profunctor servant servant-server - swagger2 tasty tasty-hspec tasty-hunit diff --git a/libs/wire-api/src/Wire/API/Asset.hs b/libs/wire-api/src/Wire/API/Asset.hs index d8505a038f1..1658056c6d0 100644 --- a/libs/wire-api/src/Wire/API/Asset.hs +++ b/libs/wire-api/src/Wire/API/Asset.hs @@ -74,11 +74,11 @@ import Data.ByteString.Conversion import Data.ByteString.Lazy qualified as LBS import Data.Id import Data.Json.Util (UTCTimeMillis (fromUTCTimeMillis), toUTCTimeMillis) +import Data.OpenApi qualified as S import Data.Proxy import Data.Qualified import Data.SOP import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as T import Data.Text.Ascii (AsciiBase64Url) import Data.Text.Encoding qualified as T @@ -109,7 +109,7 @@ deriving via Schema (Asset' key) instance ToSchema (Asset' key) => (ToJSON (Asse deriving via Schema (Asset' key) instance ToSchema (Asset' key) => (FromJSON (Asset' key)) -deriving via Schema (Asset' key) instance ToSchema (Asset' key) => (S.ToSchema (Asset' key)) +deriving via Schema (Asset' key) instance (Typeable key, ToSchema (Asset' key)) => (S.ToSchema (Asset' key)) -- Generate expiry time with millisecond precision instance Arbitrary key => Arbitrary (Asset' key) where @@ -394,7 +394,7 @@ instance FromHttpApiData (AssetLocation Absolute) where instance S.ToParamSchema (AssetLocation r) where toParamSchema _ = mempty - & S.type_ ?~ S.SwaggerString + & S.type_ ?~ S.OpenApiString & S.format ?~ "url" -- | An asset as returned by the download API: if the asset is local, only a diff --git a/libs/wire-api/src/Wire/API/Call/Config.hs b/libs/wire-api/src/Wire/API/Call/Config.hs index a458891e34c..18289ca1706 100644 --- a/libs/wire-api/src/Wire/API/Call/Config.hs +++ b/libs/wire-api/src/Wire/API/Call/Config.hs @@ -81,8 +81,8 @@ import Data.ByteString.Conversion qualified as BC import Data.IP qualified as IP import Data.List.NonEmpty (NonEmpty) import Data.Misc (HttpsUrl (..), IpAddr (IpAddr), Port (..)) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text import Data.Text.Ascii import Data.Text.Encoding qualified as TE diff --git a/libs/wire-api/src/Wire/API/Connection.hs b/libs/wire-api/src/Wire/API/Connection.hs index a093c10c72e..138b6c3eb4b 100644 --- a/libs/wire-api/src/Wire/API/Connection.hs +++ b/libs/wire-api/src/Wire/API/Connection.hs @@ -45,10 +45,10 @@ import Control.Lens ((?~)) import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Id import Data.Json.Util (UTCTimeMillis) +import Data.OpenApi qualified as S import Data.Qualified (Qualified (qUnqualified), deprecatedSchema) import Data.Range import Data.Schema -import Data.Swagger qualified as S import Data.Text as Text import Imports import Servant.API @@ -142,7 +142,7 @@ data Relation deriving (FromJSON, ToJSON, S.ToSchema) via (Schema Relation) instance S.ToParamSchema Relation where - toParamSchema _ = mempty & S.type_ ?~ S.SwaggerString + toParamSchema _ = mempty & S.type_ ?~ S.OpenApiString -- | 'updateConnectionInternal', requires knowledge of the previous state (before -- 'MissingLegalholdConsent'), but the clients don't need that information. To avoid having diff --git a/libs/wire-api/src/Wire/API/Conversation.hs b/libs/wire-api/src/Wire/API/Conversation.hs index f28e178727a..22a715678fb 100644 --- a/libs/wire-api/src/Wire/API/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Conversation.hs @@ -96,12 +96,13 @@ import Data.List.NonEmpty (NonEmpty) import Data.List1 import Data.Map qualified as Map import Data.Misc +import Data.OpenApi (deprecated) +import Data.OpenApi qualified as S import Data.Qualified import Data.Range (Range, fromRange, rangedSchema) import Data.SOP import Data.Schema import Data.Set qualified as Set -import Data.Swagger qualified as S import Data.UUID qualified as UUID import Data.UUID.V5 qualified as UUIDV5 import Imports @@ -115,6 +116,7 @@ import Wire.API.Routes.MultiTablePaging import Wire.API.Routes.MultiVerb import Wire.API.Routes.Version import Wire.API.Routes.Versioned +import Wire.API.User import Wire.Arbitrary -------------------------------------------------------------------------------- @@ -123,7 +125,7 @@ import Wire.Arbitrary data ConversationMetadata = ConversationMetadata { cnvmType :: ConvType, -- FUTUREWORK: Make this a qualified user ID. - cnvmCreator :: UserId, + cnvmCreator :: Maybe UserId, cnvmAccess :: [Access], cnvmAccessRoles :: Set AccessRole, cnvmName :: Maybe Text, @@ -137,11 +139,11 @@ data ConversationMetadata = ConversationMetadata deriving (Arbitrary) via (GenericUniform ConversationMetadata) deriving (FromJSON, ToJSON) via Schema ConversationMetadata -defConversationMetadata :: UserId -> ConversationMetadata -defConversationMetadata creator = +defConversationMetadata :: Maybe UserId -> ConversationMetadata +defConversationMetadata mCreator = ConversationMetadata { cnvmType = RegularConv, - cnvmCreator = creator, + cnvmCreator = mCreator, cnvmAccess = [PrivateAccess], cnvmAccessRoles = mempty, cnvmName = Nothing, @@ -190,10 +192,10 @@ conversationMetadataObjectSchema sch = ConversationMetadata <$> cnvmType .= field "type" schema <*> cnvmCreator - .= fieldWithDocModifier + .= optFieldWithDocModifier "creator" (description ?~ "The creator's user ID") - schema + (maybeWithDefault A.Null schema) <*> cnvmAccess .= field "access" (array schema) <*> cnvmAccessRoles .= sch <*> cnvmName .= optField "name" (maybeWithDefault A.Null schema) @@ -239,7 +241,7 @@ data Conversation = Conversation cnvType :: Conversation -> ConvType cnvType = cnvmType . cnvMetadata -cnvCreator :: Conversation -> UserId +cnvCreator :: Conversation -> Maybe UserId cnvCreator = cnvmCreator . cnvMetadata cnvAccess :: Conversation -> [Access] @@ -567,14 +569,15 @@ instance ToSchema AccessRole where instance ToSchema AccessRoleLegacy where schema = - (S.schema . description ?~ desc) $ - enum @Text "AccessRoleLegacy" $ - mconcat - [ element "private" PrivateAccessRole, - element "team" TeamAccessRole, - element "activated" ActivatedAccessRole, - element "non_activated" NonActivatedAccessRole - ] + (S.schema . S.deprecated ?~ True) $ + (S.schema . description ?~ desc) $ + enum @Text "AccessRoleLegacy" $ + mconcat + [ element "private" PrivateAccessRole, + element "team" TeamAccessRole, + element "activated" ActivatedAccessRole, + element "non_activated" NonActivatedAccessRole + ] where desc = "Which users can join conversations (deprecated, use `access_role_v2` instead).\ @@ -596,7 +599,7 @@ data ConvType | SelfConv | One2OneConv | ConnectConv - deriving stock (Eq, Show, Generic) + deriving stock (Eq, Show, Enum, Generic) deriving (Arbitrary) via (GenericUniform ConvType) deriving (FromJSON, ToJSON, S.ToSchema) via Schema ConvType @@ -645,7 +648,7 @@ data NewConv = NewConv -- | Every member except for the creator will have this role newConvUsersRole :: RoleName, -- | The protocol of the conversation. It can be Proteus or MLS (1.0). - newConvProtocol :: ProtocolTag + newConvProtocol :: BaseProtocolTag } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform NewConv) @@ -670,7 +673,9 @@ newConvSchema sch = <$> newConvUsers .= ( fieldWithDocModifier "users" - (description ?~ usersDesc) + ( (deprecated ?~ True) + . (description ?~ usersDesc) + ) (array schema) <|> pure [] ) @@ -704,7 +709,10 @@ newConvSchema sch = .= ( fieldWithDocModifier "conversation_role" (description ?~ usersRoleDesc) schema <|> pure roleNameWireAdmin ) - <*> newConvProtocol .= protocolTagSchema + <*> newConvProtocol + .= fmap + (fromMaybe BaseProtocolProteusTag) + (optField "protocol" schema) where usersDesc = "List of user IDs (excluding the requestor) to be \ diff --git a/libs/wire-api/src/Wire/API/Conversation/Action.hs b/libs/wire-api/src/Wire/API/Conversation/Action.hs index 8d8b9883cc8..d6930d14488 100644 --- a/libs/wire-api/src/Wire/API/Conversation/Action.hs +++ b/libs/wire-api/src/Wire/API/Conversation/Action.hs @@ -38,14 +38,15 @@ import Data.Aeson.KeyMap qualified as A import Data.Id import Data.Kind import Data.List.NonEmpty qualified as NonEmptyList +import Data.OpenApi qualified as S import Data.Qualified (Qualified) import Data.Schema hiding (tag) import Data.Singletons.TH -import Data.Swagger qualified as S import Data.Time.Clock import Imports import Wire.API.Conversation import Wire.API.Conversation.Action.Tag +import Wire.API.Conversation.Protocol (ProtocolTag) import Wire.API.Conversation.Role import Wire.API.Event.Conversation import Wire.API.MLS.SubConversation @@ -63,13 +64,18 @@ type family ConversationAction (tag :: ConversationActionTag) :: Type where ConversationAction 'ConversationReceiptModeUpdateTag = ConversationReceiptModeUpdate ConversationAction 'ConversationAccessDataTag = ConversationAccessData ConversationAction 'ConversationRemoveMembersTag = NonEmptyList.NonEmpty (Qualified UserId) + ConversationAction 'ConversationUpdateProtocolTag = ProtocolTag data SomeConversationAction where SomeConversationAction :: Sing tag -> ConversationAction tag -> SomeConversationAction instance Show SomeConversationAction where show (SomeConversationAction tag action) = - $(sCases ''ConversationActionTag [|tag|] [|show action|]) + "SomeConversationAction {tag = " + <> show (fromSing tag) + <> ", action = " + <> $(sCases ''ConversationActionTag [|tag|] [|show action|]) + <> "}" instance Eq SomeConversationAction where (SomeConversationAction tag1 action1) == (SomeConversationAction tag2 action2) = @@ -105,6 +111,7 @@ conversationActionSchema SConversationRenameTag = schema conversationActionSchema SConversationMessageTimerUpdateTag = schema conversationActionSchema SConversationReceiptModeUpdateTag = schema conversationActionSchema SConversationAccessDataTag = schema +conversationActionSchema SConversationUpdateProtocolTag = schema instance FromJSON SomeConversationAction where parseJSON = A.withObject "SomeConversationAction" $ \ob -> do @@ -136,6 +143,7 @@ $( singletons conversationActionPermission ConversationMessageTimerUpdateTag = ModifyConversationMessageTimer conversationActionPermission ConversationReceiptModeUpdateTag = ModifyConversationReceiptMode conversationActionPermission ConversationAccessDataTag = ModifyConversationAccess + conversationActionPermission ConversationUpdateProtocolTag = LeaveConversation |] ) @@ -154,9 +162,9 @@ conversationActionToEvent tag now quid qcnv subconv action = let ConversationJoin newMembers role = action in EdMembersJoin $ SimpleMembers (map (`SimpleMember` role) (toList newMembers)) SConversationLeaveTag -> - EdMembersLeave (QualifiedUserIdList [quid]) + EdMembersLeave EdReasonLeft (QualifiedUserIdList [quid]) SConversationRemoveMembersTag -> - EdMembersLeave (QualifiedUserIdList (toList action)) + EdMembersLeave EdReasonRemoved (QualifiedUserIdList (toList action)) SConversationMemberUpdateTag -> let ConversationMemberUpdate target (OtherMemberUpdate role) = action update = MemberUpdateData target Nothing Nothing Nothing Nothing Nothing Nothing role @@ -166,4 +174,5 @@ conversationActionToEvent tag now quid qcnv subconv action = SConversationMessageTimerUpdateTag -> EdConvMessageTimerUpdate action SConversationReceiptModeUpdateTag -> EdConvReceiptModeUpdate action SConversationAccessDataTag -> EdConvAccessUpdate action + SConversationUpdateProtocolTag -> EdProtocolUpdate action in Event qcnv subconv quid now edata diff --git a/libs/wire-api/src/Wire/API/Conversation/Action/Tag.hs b/libs/wire-api/src/Wire/API/Conversation/Action/Tag.hs index 3445e3794ff..00e46cdfdf5 100644 --- a/libs/wire-api/src/Wire/API/Conversation/Action/Tag.hs +++ b/libs/wire-api/src/Wire/API/Conversation/Action/Tag.hs @@ -38,6 +38,7 @@ data ConversationActionTag | ConversationMessageTimerUpdateTag | ConversationReceiptModeUpdateTag | ConversationAccessDataTag + | ConversationUpdateProtocolTag deriving (Show, Eq, Generic, Bounded, Enum) instance Arbitrary ConversationActionTag where @@ -55,7 +56,8 @@ instance ToSchema ConversationActionTag where element "ConversationRenameTag" ConversationRenameTag, element "ConversationMessageTimerUpdateTag" ConversationMessageTimerUpdateTag, element "ConversationReceiptModeUpdateTag" ConversationReceiptModeUpdateTag, - element "ConversationAccessDataTag" ConversationAccessDataTag + element "ConversationAccessDataTag" ConversationAccessDataTag, + element "ConversationUpdateProtocolTag" ConversationUpdateProtocolTag ] instance ToJSON ConversationActionTag where diff --git a/libs/wire-api/src/Wire/API/Conversation/Bot.hs b/libs/wire-api/src/Wire/API/Conversation/Bot.hs index 4b4da2f0466..f46a83869d4 100644 --- a/libs/wire-api/src/Wire/API/Conversation/Bot.hs +++ b/libs/wire-api/src/Wire/API/Conversation/Bot.hs @@ -26,9 +26,10 @@ module Wire.API.Conversation.Bot ) where -import Data.Aeson +import Data.Aeson qualified as A import Data.Id -import Data.Json.Util ((#)) +import Data.OpenApi qualified as S +import Data.Schema import Imports import Wire.API.Event.Conversation (Event) import Wire.API.User.Client.Prekey (Prekey) @@ -46,21 +47,15 @@ data AddBot = AddBot } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform AddBot) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema AddBot -instance ToJSON AddBot where - toJSON n = - object $ - "provider" .= addBotProvider n - # "service" .= addBotService n - # "locale" .= addBotLocale n - # [] - -instance FromJSON AddBot where - parseJSON = withObject "NewBot" $ \o -> - AddBot - <$> o .: "provider" - <*> o .: "service" - <*> o .:? "locale" +instance ToSchema AddBot where + schema = + object "AddBot" $ + AddBot + <$> addBotProvider .= field "provider" schema + <*> addBotService .= field "service" schema + <*> addBotLocale .= maybe_ (optField "locale" schema) data AddBotResponse = AddBotResponse { rsAddBotId :: BotId, @@ -72,27 +67,18 @@ data AddBotResponse = AddBotResponse } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform AddBotResponse) - -instance ToJSON AddBotResponse where - toJSON r = - object - [ "id" .= rsAddBotId r, - "client" .= rsAddBotClient r, - "name" .= rsAddBotName r, - "accent_id" .= rsAddBotColour r, - "assets" .= rsAddBotAssets r, - "event" .= rsAddBotEvent r - ] - -instance FromJSON AddBotResponse where - parseJSON = withObject "AddBotResponse" $ \o -> - AddBotResponse - <$> o .: "id" - <*> o .: "client" - <*> o .: "name" - <*> o .: "accent_id" - <*> o .: "assets" - <*> o .: "event" + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema AddBotResponse + +instance ToSchema AddBotResponse where + schema = + object "AddBotResponse" $ + AddBotResponse + <$> rsAddBotId .= field "id" schema + <*> rsAddBotClient .= field "client" schema + <*> rsAddBotName .= field "name" schema + <*> rsAddBotColour .= field "accent_id" schema + <*> rsAddBotAssets .= field "assets" (array schema) + <*> rsAddBotEvent .= field "event" schema -------------------------------------------------------------------------------- -- RemoveBot @@ -104,16 +90,13 @@ newtype RemoveBotResponse = RemoveBotResponse } deriving stock (Eq, Show) deriving newtype (Arbitrary) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema RemoveBotResponse -instance ToJSON RemoveBotResponse where - toJSON r = - object - [ "event" .= rsRemoveBotEvent r - ] - -instance FromJSON RemoveBotResponse where - parseJSON = withObject "RemoveBotResponse" $ \o -> - RemoveBotResponse <$> o .: "event" +instance ToSchema RemoveBotResponse where + schema = + object "RemoveBotResponse" $ + RemoveBotResponse + <$> rsRemoveBotEvent .= field "event" schema -------------------------------------------------------------------------------- -- UpdateBotPrekeys @@ -123,13 +106,10 @@ newtype UpdateBotPrekeys = UpdateBotPrekeys } deriving stock (Eq, Show) deriving newtype (Arbitrary) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema UpdateBotPrekeys -instance ToJSON UpdateBotPrekeys where - toJSON u = - object - [ "prekeys" .= updateBotPrekeyList u - ] - -instance FromJSON UpdateBotPrekeys where - parseJSON = withObject "UpdateBotPrekeys" $ \o -> - UpdateBotPrekeys <$> o .: "prekeys" +instance ToSchema UpdateBotPrekeys where + schema = + object "UpdateBotPrekeys" $ + UpdateBotPrekeys + <$> updateBotPrekeyList .= field "prekeys" (array schema) diff --git a/libs/wire-api/src/Wire/API/Conversation/Code.hs b/libs/wire-api/src/Wire/API/Conversation/Code.hs index b99b4012df2..51a142ddd09 100644 --- a/libs/wire-api/src/Wire/API/Conversation/Code.hs +++ b/libs/wire-api/src/Wire/API/Conversation/Code.hs @@ -40,8 +40,8 @@ import Data.ByteString.Conversion (toByteString') -- FUTUREWORK: move content of Data.Code here? import Data.Code as Code import Data.Misc +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports import URI.ByteString qualified as URI import Wire.Arbitrary (Arbitrary, GenericUniform (..)) diff --git a/libs/wire-api/src/Wire/API/Conversation/Member.hs b/libs/wire-api/src/Wire/API/Conversation/Member.hs index 2bb1d7e3817..f07f619b3e5 100644 --- a/libs/wire-api/src/Wire/API/Conversation/Member.hs +++ b/libs/wire-api/src/Wire/API/Conversation/Member.hs @@ -23,8 +23,10 @@ module Wire.API.Conversation.Member -- * Member Member (..), + defMember, MutedStatus (..), OtherMember (..), + defOtherMember, -- * Member Update MemberUpdate (..), @@ -38,9 +40,10 @@ import Control.Lens ((?~)) import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Aeson qualified as A import Data.Id +import Data.OpenApi (deprecated) +import Data.OpenApi qualified as S import Data.Qualified import Data.Schema -import Data.Swagger qualified as S import Imports import Test.QuickCheck qualified as QC import Wire.API.Conversation.Role @@ -88,6 +91,20 @@ data Member = Member deriving (Arbitrary) via (GenericUniform Member) deriving (FromJSON, ToJSON, S.ToSchema) via Schema Member +defMember :: Qualified UserId -> Member +defMember uid = + Member + { memId = uid, + memService = Nothing, + memOtrMutedStatus = Nothing, + memOtrMutedRef = Nothing, + memOtrArchived = False, + memOtrArchivedRef = Nothing, + memHidden = False, + memHiddenRef = Nothing, + memConvRoleName = roleNameWireMember + } + instance ToSchema Member where schema = object "Member" $ @@ -133,6 +150,14 @@ data OtherMember = OtherMember deriving (Arbitrary) via (GenericUniform OtherMember) deriving (FromJSON, ToJSON, S.ToSchema) via Schema OtherMember +defOtherMember :: Qualified UserId -> OtherMember +defOtherMember uid = + OtherMember + { omQualifiedId = uid, + omService = Nothing, + omConvRoleName = roleNameWireMember + } + instance ToSchema OtherMember where schema = object "OtherMember" $ @@ -141,7 +166,7 @@ instance ToSchema OtherMember where <* (qUnqualified . omQualifiedId) .= optional (field "id" schema) <*> omService .= maybe_ (optFieldWithDocModifier "service" (description ?~ desc) schema) <*> omConvRoleName .= (field "conversation_role" schema <|> pure roleNameWireAdmin) - <* const (0 :: Int) .= optional (fieldWithDocModifier "status" (description ?~ "deprecated") schema) -- TODO: remove + <* const (0 :: Int) .= optional (fieldWithDocModifier "status" ((deprecated ?~ True) . (description ?~ "deprecated")) schema) -- TODO: remove where desc = "The reference to the owning service, if the member is a 'bot'." diff --git a/libs/wire-api/src/Wire/API/Conversation/Protocol.hs b/libs/wire-api/src/Wire/API/Conversation/Protocol.hs index 30ca0b6591d..5eb4a3dc66e 100644 --- a/libs/wire-api/src/Wire/API/Conversation/Protocol.hs +++ b/libs/wire-api/src/Wire/API/Conversation/Protocol.hs @@ -26,25 +26,31 @@ module Wire.API.Conversation.Protocol Epoch (..), Protocol (..), _ProtocolMLS, + _ProtocolMixed, _ProtocolProteus, + conversationMLSData, protocolSchema, ConversationMLSData (..), + ProtocolUpdate (..), ) where import Control.Arrow -import Control.Lens (makePrisms, (?~)) +import Control.Lens (Traversal', makePrisms, (?~)) import Data.Aeson (FromJSON (..), ToJSON (..)) +import Data.OpenApi qualified as S import Data.Schema +import Data.Time.Clock import Imports import Wire.API.Conversation.Action.Tag import Wire.API.MLS.CipherSuite import Wire.API.MLS.Epoch import Wire.API.MLS.Group +import Wire.API.MLS.SubConversation import Wire.Arbitrary -data ProtocolTag = ProtocolProteusTag | ProtocolMLSTag - deriving stock (Eq, Show, Enum, Bounded, Generic) +data ProtocolTag = ProtocolProteusTag | ProtocolMLSTag | ProtocolMixedTag + deriving stock (Eq, Show, Enum, Ord, Bounded, Generic) deriving (Arbitrary) via GenericUniform ProtocolTag data ConversationMLSData = ConversationMLSData @@ -52,24 +58,61 @@ data ConversationMLSData = ConversationMLSData cnvmlsGroupId :: GroupId, -- | The current epoch number of the corresponding MLS group. cnvmlsEpoch :: Epoch, + -- | The time stamp of the epoch. + cnvmlsEpochTimestamp :: Maybe UTCTime, -- | The cipher suite to be used in the MLS group. cnvmlsCipherSuite :: CipherSuiteTag } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via GenericUniform ConversationMLSData + deriving (ToJSON, FromJSON) via Schema ConversationMLSData + +mlsDataSchema :: ObjectSchema SwaggerDoc ConversationMLSData +mlsDataSchema = + ConversationMLSData + <$> cnvmlsGroupId + .= fieldWithDocModifier + "group_id" + (description ?~ "An MLS group identifier (at most 256 bytes long)") + schema + <*> cnvmlsEpoch + .= fieldWithDocModifier + "epoch" + (description ?~ "The epoch number of the corresponding MLS group") + schema + <*> cnvmlsEpochTimestamp + .= fieldWithDocModifier + "epoch_timestamp" + (description ?~ "The timestamp of the epoch number") + schemaEpochTimestamp + <*> cnvmlsCipherSuite + .= fieldWithDocModifier + "cipher_suite" + (description ?~ "The cipher suite of the corresponding MLS group") + schema + +instance ToSchema ConversationMLSData where + schema = object "ConversationMLSData" mlsDataSchema -- | Conversation protocol and protocol-specific data. data Protocol = ProtocolProteus | ProtocolMLS ConversationMLSData + | ProtocolMixed ConversationMLSData deriving (Eq, Show, Generic) deriving (Arbitrary) via GenericUniform Protocol $(makePrisms ''Protocol) +conversationMLSData :: Traversal' Protocol ConversationMLSData +conversationMLSData _ ProtocolProteus = pure ProtocolProteus +conversationMLSData f (ProtocolMLS mls) = ProtocolMLS <$> f mls +conversationMLSData f (ProtocolMixed mls) = ProtocolMixed <$> f mls + protocolTag :: Protocol -> ProtocolTag protocolTag ProtocolProteus = ProtocolProteusTag protocolTag (ProtocolMLS _) = ProtocolMLSTag +protocolTag (ProtocolMixed _) = ProtocolMixedTag -- | Certain actions need to be performed at the level of the underlying -- protocol (MLS, mostly) before being applied to conversations. This function @@ -77,6 +120,7 @@ protocolTag (ProtocolMLS _) = ProtocolMLSTag -- with the given protocol. protocolValidAction :: Protocol -> ConversationActionTag -> Bool protocolValidAction ProtocolProteus _ = True +protocolValidAction (ProtocolMixed _) _ = True protocolValidAction (ProtocolMLS _) ConversationJoinTag = False protocolValidAction (ProtocolMLS _) ConversationLeaveTag = True protocolValidAction (ProtocolMLS _) ConversationRemoveMembersTag = False @@ -88,9 +132,14 @@ instance ToSchema ProtocolTag where enum @Text "Protocol" $ mconcat [ element "proteus" ProtocolProteusTag, - element "mls" ProtocolMLSTag + element "mls" ProtocolMLSTag, + element "mixed" ProtocolMixedTag ] +deriving via (Schema ProtocolTag) instance FromJSON ProtocolTag + +deriving via (Schema ProtocolTag) instance ToJSON ProtocolTag + protocolTagSchema :: ObjectSchema SwaggerDoc ProtocolTag protocolTagSchema = fmap (fromMaybe ProtocolProteusTag) (optField "protocol" schema) @@ -112,22 +161,17 @@ deriving via (Schema Protocol) instance ToJSON Protocol protocolDataSchema :: ProtocolTag -> ObjectSchema SwaggerDoc Protocol protocolDataSchema ProtocolProteusTag = tag _ProtocolProteus (pure ()) protocolDataSchema ProtocolMLSTag = tag _ProtocolMLS mlsDataSchema +protocolDataSchema ProtocolMixedTag = tag _ProtocolMixed mlsDataSchema -mlsDataSchema :: ObjectSchema SwaggerDoc ConversationMLSData -mlsDataSchema = - ConversationMLSData - <$> cnvmlsGroupId - .= fieldWithDocModifier - "group_id" - (description ?~ "An MLS group identifier (at most 256 bytes long)") - schema - <*> cnvmlsEpoch - .= fieldWithDocModifier - "epoch" - (description ?~ "The epoch number of the corresponding MLS group") - schema - <*> cnvmlsCipherSuite - .= fieldWithDocModifier - "cipher_suite" - (description ?~ "The cipher suite of the corresponding MLS group") - schema +newtype ProtocolUpdate = ProtocolUpdate {unProtocolUpdate :: ProtocolTag} + deriving (Show, Eq, Generic) + deriving (Arbitrary) via GenericUniform ProtocolUpdate + +instance ToSchema ProtocolUpdate where + schema = object "ProtocolUpdate" (ProtocolUpdate <$> unProtocolUpdate .= protocolTagSchema) + +deriving via (Schema ProtocolUpdate) instance FromJSON ProtocolUpdate + +deriving via (Schema ProtocolUpdate) instance ToJSON ProtocolUpdate + +deriving via (Schema ProtocolUpdate) instance S.ToSchema ProtocolUpdate diff --git a/libs/wire-api/src/Wire/API/Conversation/Role.hs b/libs/wire-api/src/Wire/API/Conversation/Role.hs index 1df79697f80..edb97c23f42 100644 --- a/libs/wire-api/src/Wire/API/Conversation/Role.hs +++ b/libs/wire-api/src/Wire/API/Conversation/Role.hs @@ -68,11 +68,11 @@ import Data.Aeson.TH qualified as A import Data.Attoparsec.Text import Data.ByteString.Conversion import Data.Hashable +import Data.OpenApi qualified as S import Data.Range (fromRange, genRangeText) import Data.Schema import Data.Set qualified as Set import Data.Singletons.TH -import Data.Swagger qualified as S import Deriving.Swagger qualified as S import GHC.TypeLits import Imports diff --git a/libs/wire-api/src/Wire/API/Conversation/Typing.hs b/libs/wire-api/src/Wire/API/Conversation/Typing.hs index 65e728c87c6..076dbde5e47 100644 --- a/libs/wire-api/src/Wire/API/Conversation/Typing.hs +++ b/libs/wire-api/src/Wire/API/Conversation/Typing.hs @@ -21,8 +21,8 @@ module Wire.API.Conversation.Typing where import Data.Aeson (FromJSON (..), ToJSON (..)) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports import Wire.Arbitrary (Arbitrary, GenericUniform (..)) diff --git a/libs/wire-api/src/Wire/API/CustomBackend.hs b/libs/wire-api/src/Wire/API/CustomBackend.hs index 73c3a525a06..f7c12e0140d 100644 --- a/libs/wire-api/src/Wire/API/CustomBackend.hs +++ b/libs/wire-api/src/Wire/API/CustomBackend.hs @@ -24,8 +24,8 @@ where import Control.Lens ((?~)) import Data.Misc (HttpsUrl) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Deriving.Aeson import Imports import Wire.Arbitrary (Arbitrary, GenericUniform (..)) diff --git a/libs/wire-api/src/Wire/API/Deprecated.hs b/libs/wire-api/src/Wire/API/Deprecated.hs new file mode 100644 index 00000000000..c68120be996 --- /dev/null +++ b/libs/wire-api/src/Wire/API/Deprecated.hs @@ -0,0 +1,60 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Deprecated + ( Deprecated, + ) +where + +import Control.Lens +import Data.Kind (Type) +import Data.Metrics.Servant +import Data.OpenApi hiding (HasServer) +import Imports +import Servant +import Servant.Client +import Servant.OpenApi + +-- Annotate that the route is deprecated +data Deprecated deriving (Typeable) + +-- All of these instances are very similar to the instances +-- for Summary. These don't impact the API directly, but are +-- for marking the deprecated flag in the openapi output. +instance HasLink sub => HasLink (Deprecated :> sub :: Type) where + type MkLink (Deprecated :> sub) a = MkLink sub a + toLink = + let simpleToLink toA _ = toLink toA (Proxy :: Proxy sub) + in simpleToLink + +instance HasOpenApi api => HasOpenApi (Deprecated :> api :: Type) where + toOpenApi _ = + toOpenApi (Proxy @api) + & allOperations . deprecated ?~ True + +instance HasServer api ctx => HasServer (Deprecated :> api) ctx where + type ServerT (Deprecated :> api) m = ServerT api m + route _ = route $ Proxy @api + hoistServerWithContext _ pc nt s = hoistServerWithContext (Proxy @api) pc nt s + +instance HasClient m api => HasClient m (Deprecated :> api) where + type Client m (Deprecated :> api) = Client m api + clientWithRoute pm _ = clientWithRoute pm (Proxy :: Proxy api) + hoistClientMonad pm _ f cl = hoistClientMonad pm (Proxy :: Proxy api) f cl + +instance (RoutesToPaths rest) => RoutesToPaths (Deprecated :> rest) where + getRoutes = getRoutes @rest diff --git a/libs/wire-api/src/Wire/API/Error.hs b/libs/wire-api/src/Wire/API/Error.hs index 1edf2eda329..fbc743cfe65 100644 --- a/libs/wire-api/src/Wire/API/Error.hs +++ b/libs/wire-api/src/Wire/API/Error.hs @@ -49,12 +49,13 @@ where import Control.Lens (at, (%~), (.~), (?~)) import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Aeson qualified as A +import Data.HashMap.Strict.InsOrd import Data.Kind import Data.Metrics.Servant +import Data.OpenApi qualified as S import Data.Proxy import Data.SOP import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text import Data.Text.Lazy qualified as LT import GHC.TypeLits @@ -65,9 +66,10 @@ import Network.Wai.Utilities.JSONResponse import Polysemy import Polysemy.Error import Servant -import Servant.Swagger +import Servant.OpenApi import Wire.API.Routes.MultiVerb -import Wire.API.Routes.Named (Named) +import Wire.API.Routes.Named (UntypedNamed) +import Wire.API.Routes.Version -- | Runtime representation of a statically-known error. data DynError = DynError @@ -167,6 +169,10 @@ instance RoutesToPaths api => RoutesToPaths (CanThrow err :> api) where instance RoutesToPaths api => RoutesToPaths (CanThrowMany errs :> api) where getRoutes = getRoutes @api +type instance + SpecialiseToVersion v (CanThrow e :> api) = + CanThrow e :> SpecialiseToVersion v api + instance (HasServer api ctx) => HasServer (CanThrow e :> api) ctx where type ServerT (CanThrow e :> api) m = ServerT api m @@ -180,37 +186,49 @@ instance (HasServer api ctx) => HasServer (CanThrowMany es :> api) ctx where hoistServerWithContext _ = hoistServerWithContext (Proxy @api) instance - (HasSwagger api, IsSwaggerError e) => - HasSwagger (CanThrow e :> api) + (HasOpenApi api, IsSwaggerError e) => + HasOpenApi (CanThrow e :> api) where - toSwagger _ = addToSwagger @e (toSwagger (Proxy @api)) + toOpenApi _ = addToOpenApi @e (toOpenApi (Proxy @api)) + +type instance + SpecialiseToVersion v (CanThrowMany es :> api) = + CanThrowMany es :> SpecialiseToVersion v api -instance HasSwagger api => HasSwagger (CanThrowMany '() :> api) where - toSwagger _ = toSwagger (Proxy @api) +instance HasOpenApi api => HasOpenApi (CanThrowMany '() :> api) where + toOpenApi _ = toOpenApi (Proxy @api) instance - (HasSwagger (CanThrowMany es :> api), IsSwaggerError e) => - HasSwagger (CanThrowMany '(e, es) :> api) + (HasOpenApi (CanThrowMany es :> api), IsSwaggerError e) => + HasOpenApi (CanThrowMany '(e, es) :> api) where - toSwagger _ = addToSwagger @e (toSwagger (Proxy @(CanThrowMany es :> api))) + toOpenApi _ = addToOpenApi @e (toOpenApi (Proxy @(CanThrowMany es :> api))) type family DeclaredErrorEffects api :: EffectRow where DeclaredErrorEffects (CanThrow e :> api) = (ErrorEffect e ': DeclaredErrorEffects api) DeclaredErrorEffects (CanThrowMany '(e, es) :> api) = DeclaredErrorEffects (CanThrow e :> CanThrowMany es :> api) DeclaredErrorEffects (x :> api) = DeclaredErrorEffects api - DeclaredErrorEffects (Named n api) = DeclaredErrorEffects api + DeclaredErrorEffects (UntypedNamed n api) = DeclaredErrorEffects api DeclaredErrorEffects api = '[] -errorResponseSwagger :: forall e. KnownError e => S.Response +errorResponseSwagger :: forall e. (Typeable e, KnownError e) => S.Response errorResponseSwagger = mempty & S.description .~ (eMessage err <> " (label: `" <> eLabel err <> "`)") - & S.schema ?~ S.Inline (S.toSchema (Proxy @(SStaticError e))) + -- Defaulting this to JSON, as openapi3 needs something to map a schema against. + -- This _should_ be overridden with the actual media types once we are at the + -- point of rendering out the schemas for MultiVerb. + -- Check the instance of `S.HasOpenApi (MultiVerb method (cs :: [Type]) as r)` + & S.content .~ singleton mediaType mediaTypeObject where err = dynError @e + mediaType = contentType $ Proxy @JSON + mediaTypeObject = + mempty + & S.schema ?~ S.Inline (S.toSchema (Proxy @(SStaticError e))) -addErrorResponseToSwagger :: Int -> S.Response -> S.Swagger -> S.Swagger +addErrorResponseToSwagger :: Int -> S.Response -> S.OpenApi -> S.OpenApi addErrorResponseToSwagger code resp = S.allOperations . S.responses @@ -224,7 +242,7 @@ addErrorResponseToSwagger code resp = addRef (Just (S.Inline resp1)) = S.Inline (combineResponseSwagger resp1 resp) addRef (Just r@(S.Ref _)) = r -addStaticErrorToSwagger :: forall e. KnownError e => S.Swagger -> S.Swagger +addStaticErrorToSwagger :: forall e. (Typeable e, KnownError e) => S.OpenApi -> S.OpenApi addStaticErrorToSwagger = addErrorResponseToSwagger (fromIntegral (eCode (dynError @e))) @@ -235,7 +253,7 @@ type family MapError (e :: k) :: StaticError type family ErrorEffect (e :: k) :: Effect class IsSwaggerError e where - addToSwagger :: S.Swagger -> S.Swagger + addToOpenApi :: S.OpenApi -> S.OpenApi -- | An effect for a static error type with no data. type ErrorS e = Error (Tagged e ()) @@ -314,7 +332,7 @@ instance KnownError (MapError e) => AsConstructor '[] (ErrorResponse e) where toConstructor _ = Nil fromConstructor _ = dynError @(MapError e) -instance KnownError (MapError e) => IsSwaggerResponse (ErrorResponse e) where +instance (KnownError (MapError e), Typeable (MapError e)) => IsSwaggerResponse (ErrorResponse e) where responseSwagger = pure $ errorResponseSwagger @(MapError e) instance diff --git a/libs/wire-api/src/Wire/API/Error/Brig.hs b/libs/wire-api/src/Wire/API/Error/Brig.hs index 949d3c45725..467af72786d 100644 --- a/libs/wire-api/src/Wire/API/Error/Brig.hs +++ b/libs/wire-api/src/Wire/API/Error/Brig.hs @@ -17,6 +17,7 @@ module Wire.API.Error.Brig where +import Data.Data import Wire.API.Error data BrigError @@ -80,12 +81,46 @@ data BrigError | NotificationNotFound | PendingInvitationNotFound | ConflictingInvitations + | AccessDenied + | InvalidConversation + | TooManyConversationMembers + | ServiceDisabled + | InvalidBot + | InvalidServiceKey + | ServiceNotFound + | VerificationCodeThrottled + | InvalidProvider + | ProviderNotFound -instance KnownError (MapError e) => IsSwaggerError (e :: BrigError) where - addToSwagger = addStaticErrorToSwagger @(MapError e) +instance (Typeable (MapError e), KnownError (MapError e)) => IsSwaggerError (e :: BrigError) where + addToOpenApi = addStaticErrorToSwagger @(MapError e) + +type instance MapError 'ServiceNotFound = 'StaticError 404 "not-found" "Service not found." + +type instance MapError 'InvalidServiceKey = 'StaticError 400 "invalid-service-key" "Invalid service key." + +type instance MapError 'ProviderNotFound = 'StaticError 404 "not-found" "Provider not found." + +type instance MapError 'InvalidProvider = 'StaticError 403 "invalid-provider" "The provider does not exist." + +type instance MapError 'VerificationCodeThrottled = 'StaticError 429 "too-many-requests" "Too many request to generate a verification code." + +type instance MapError 'ServiceDisabled = 'StaticError 403 "service-disabled" "The desired service is currently disabled." + +type instance MapError 'InvalidBot = 'StaticError 403 "invalid-bot" "The targeted user is not a bot." + +type instance MapError 'ServiceDisabled = 'StaticError 403 "service-disabled" "The desired service is currently disabled." + +type instance MapError 'InvalidBot = 'StaticError 403 "invalid-bot" "The targeted user is not a bot." type instance MapError 'UserNotFound = 'StaticError 404 "not-found" "User not found" +type instance MapError 'InvalidConversation = 'StaticError 403 "invalid-conversation" "The operation is not allowed in this conversation." + +type instance MapError 'TooManyConversationMembers = 'StaticError 403 "too-many-members" "Maximum number of members per conversation reached." + +type instance MapError 'AccessDenied = 'StaticError 403 "access-denied" "Access denied." + type instance MapError 'InvalidUser = 'StaticError 400 "invalid-user" "Invalid user" type instance MapError 'InvalidCode = 'StaticError 403 "invalid-code" "Invalid verification code" diff --git a/libs/wire-api/src/Wire/API/Error/Cannon.hs b/libs/wire-api/src/Wire/API/Error/Cannon.hs index 7cdca830697..6dea237c1fc 100644 --- a/libs/wire-api/src/Wire/API/Error/Cannon.hs +++ b/libs/wire-api/src/Wire/API/Error/Cannon.hs @@ -17,14 +17,15 @@ module Wire.API.Error.Cannon where +import Data.Data import Wire.API.Error data CannonError = ClientGone | PresenceNotRegistered -instance KnownError (MapError e) => IsSwaggerError (e :: CannonError) where - addToSwagger = addStaticErrorToSwagger @(MapError e) +instance (Typeable (MapError e), KnownError (MapError e)) => IsSwaggerError (e :: CannonError) where + addToOpenApi = addStaticErrorToSwagger @(MapError e) type instance MapError 'ClientGone = 'StaticError 410 "general" "client gone" diff --git a/libs/wire-api/src/Wire/API/Error/Cargohold.hs b/libs/wire-api/src/Wire/API/Error/Cargohold.hs index 26087509d12..0c4f17015cc 100644 --- a/libs/wire-api/src/Wire/API/Error/Cargohold.hs +++ b/libs/wire-api/src/Wire/API/Error/Cargohold.hs @@ -17,6 +17,7 @@ module Wire.API.Error.Cargohold where +import Data.Typeable import Wire.API.Error data CargoholdError @@ -26,8 +27,8 @@ data CargoholdError | InvalidLength | NoMatchingAssetEndpoint -instance KnownError (MapError e) => IsSwaggerError (e :: CargoholdError) where - addToSwagger = addStaticErrorToSwagger @(MapError e) +instance (Typeable (MapError e), KnownError (MapError e)) => IsSwaggerError (e :: CargoholdError) where + addToOpenApi = addStaticErrorToSwagger @(MapError e) type instance MapError 'AssetNotFound = 'StaticError 404 "not-found" "Asset not found" diff --git a/libs/wire-api/src/Wire/API/Error/Empty.hs b/libs/wire-api/src/Wire/API/Error/Empty.hs index 474841ef1fd..290c75c978d 100644 --- a/libs/wire-api/src/Wire/API/Error/Empty.hs +++ b/libs/wire-api/src/Wire/API/Error/Empty.hs @@ -18,7 +18,7 @@ module Wire.API.Error.Empty where import Control.Lens ((.~)) -import Data.Swagger qualified as S +import Data.OpenApi qualified as S import Data.Text qualified as Text import GHC.TypeLits import Imports diff --git a/libs/wire-api/src/Wire/API/Error/Galley.hs b/libs/wire-api/src/Wire/API/Error/Galley.hs index 48aba881d42..59b72799992 100644 --- a/libs/wire-api/src/Wire/API/Error/Galley.hs +++ b/libs/wire-api/src/Wire/API/Error/Galley.hs @@ -37,11 +37,12 @@ import Control.Lens ((%~), (.~), (?~)) import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Containers.ListUtils import Data.Domain +import Data.HashMap.Strict.InsOrd (singleton) +import Data.OpenApi qualified as S import Data.Proxy import Data.Qualified import Data.Schema import Data.Singletons.TH (genSingletons) -import Data.Swagger qualified as S import Data.Tagged import GHC.TypeLits import Imports @@ -51,6 +52,7 @@ import Network.Wai.Utilities.JSONResponse import Polysemy import Polysemy.Error import Prelude.Singletons (Show_) +import Servant.API.ContentTypes (JSON, contentType) import Wire.API.Conversation.Role import Wire.API.Error import Wire.API.Error.Brig qualified as BrigError @@ -80,11 +82,12 @@ data GalleyError | InvalidTarget | ConvNotFound | ConvAccessDenied + | ConvInvalidProtocolTransition | -- MLS Errors MLSNotEnabled | MLSNonEmptyMemberList | MLSDuplicatePublicKey - | MLSKeyPackageRefNotFound + | MLSInvalidLeafNodeIndex | MLSUnsupportedMessage | MLSProposalNotFound | MLSUnsupportedProposal @@ -97,7 +100,10 @@ data GalleyError | MLSClientSenderUserMismatch | MLSWelcomeMismatch | MLSMissingGroupInfo - | MLSMissingSenderClient + | MLSUnexpectedSenderClient + | MLSSubConvUnsupportedConvType + | MLSSubConvClientNotInParent + | MLSMigrationCriteriaNotSatisfied | -- NoBindingTeamMembers | NoBindingTeam @@ -139,8 +145,8 @@ data GalleyError $(genSingletons [''GalleyError]) -instance KnownError (MapError e) => IsSwaggerError (e :: GalleyError) where - addToSwagger = addStaticErrorToSwagger @(MapError e) +instance (Typeable (MapError e), KnownError (MapError e)) => IsSwaggerError (e :: GalleyError) where + addToOpenApi = addStaticErrorToSwagger @(MapError e) instance KnownError (MapError e) => APIError (Tagged (e :: GalleyError) ()) where toResponse _ = toResponse $ dynError @(MapError e) @@ -197,6 +203,8 @@ type instance MapError 'ConvNotFound = 'StaticError 404 "no-conversation" "Conve type instance MapError 'ConvAccessDenied = 'StaticError 403 "access-denied" "Conversation access denied" +type instance MapError 'ConvInvalidProtocolTransition = 'StaticError 403 "invalid-protocol-transition" "Protocol transition is invalid" + type instance MapError 'InvalidTeamNotificationId = 'StaticError 400 "invalid-notification-id" "Could not parse notification id (must be UUIDv1)." type instance @@ -210,7 +218,7 @@ type instance MapError 'MLSNonEmptyMemberList = 'StaticError 400 "non-empty-memb type instance MapError 'MLSDuplicatePublicKey = 'StaticError 400 "mls-duplicate-public-key" "MLS public key for the given signature scheme already exists" -type instance MapError 'MLSKeyPackageRefNotFound = 'StaticError 404 "mls-key-package-ref-not-found" "A referenced key package could not be mapped to a known client" +type instance MapError 'MLSInvalidLeafNodeIndex = 'StaticError 400 "mls-invalid-leaf-node-index" "A referenced leaf node index points to a blank or non-existing node" type instance MapError 'MLSUnsupportedMessage = 'StaticError 422 "mls-unsupported-message" "Attempted to send a message with an unsupported combination of content type and wire format" @@ -236,7 +244,11 @@ type instance MapError 'MLSWelcomeMismatch = 'StaticError 400 "mls-welcome-misma type instance MapError 'MLSMissingGroupInfo = 'StaticError 404 "mls-missing-group-info" "The conversation has no group information" -type instance MapError 'MLSMissingSenderClient = 'StaticError 403 "mls-missing-sender-client" "The client has to refresh their access token and provide their client ID" +type instance MapError 'MLSSubConvUnsupportedConvType = 'StaticError 403 "mls-subconv-unsupported-convtype" "MLS subconversations are only supported for regular conversations" + +type instance MapError 'MLSSubConvClientNotInParent = 'StaticError 403 "mls-subconv-join-parent-missing" "MLS client cannot join the subconversation because it is not member of the parent conversation" + +type instance MapError 'MLSMigrationCriteriaNotSatisfied = 'StaticError 400 "mls-migration-criteria-not-satisfied" "The migration criteria for mixed to MLS protocol transition are not satisfied for this conversation" type instance MapError 'NoBindingTeamMembers = 'StaticError 403 "non-binding-team-members" "Both users must be members of the same binding team" @@ -324,7 +336,7 @@ type instance MapError 'VerificationCodeAuthFailed = 'StaticError 403 "code-auth type instance MapError 'VerificationCodeRequired = 'StaticError 403 "code-authentication-required" "Verification code required" instance IsSwaggerError AuthenticationError where - addToSwagger = + addToOpenApi = addStaticErrorToSwagger @(MapError 'ReAuthFailed) . addStaticErrorToSwagger @(MapError 'VerificationCodeAuthFailed) . addStaticErrorToSwagger @(MapError 'VerificationCodeRequired) @@ -348,10 +360,11 @@ data TeamFeatureError | LegalHoldWhitelistedOnly | DisableSsoNotImplemented | FeatureLocked + | MLSProtocolMismatch instance IsSwaggerError TeamFeatureError where -- Do not display in Swagger - addToSwagger = id + addToOpenApi = id type instance MapError 'AppLockInactivityTimeoutTooLow = 'StaticError 400 "inactivity-timeout-too-low" "Applock inactivity timeout must be at least 30 seconds" @@ -377,6 +390,8 @@ type instance type instance MapError 'FeatureLocked = 'StaticError 409 "feature-locked" "Feature config cannot be updated (e.g. because it is configured to be locked, or because you need to upgrade your plan)" +type instance MapError 'MLSProtocolMismatch = 'StaticError 400 "mls-protocol-mismatch" "The default protocol needs to be part of the supported protocols" + type instance ErrorEffect TeamFeatureError = Error TeamFeatureError instance Member (Error DynError) r => ServerEffect (Error TeamFeatureError) r where @@ -386,6 +401,7 @@ instance Member (Error DynError) r => ServerEffect (Error TeamFeatureError) r wh LegalHoldWhitelistedOnly -> dynError @(MapError 'LegalHoldWhitelistedOnly) DisableSsoNotImplemented -> dynError @(MapError 'DisableSsoNotImplemented) FeatureLocked -> dynError @(MapError 'FeatureLocked) + MLSProtocolMismatch -> dynError @(MapError 'MLSProtocolMismatch) -------------------------------------------------------------------------------- -- Proposal failure @@ -398,7 +414,7 @@ type instance ErrorEffect MLSProposalFailure = Error MLSProposalFailure -- Proposal failures are only reported generically in Swagger instance IsSwaggerError MLSProposalFailure where - addToSwagger = S.allOperations . S.description %~ Just . (<> desc) . fold + addToOpenApi = S.allOperations . S.description %~ Just . (<> desc) . fold where desc = "\n\n**Note**: this endpoint can execute proposals, and therefore \ @@ -449,11 +465,16 @@ instance ToSchema NonFederatingBackends where nonFederatingBackendsFromList instance IsSwaggerError NonFederatingBackends where - addToSwagger = + addToOpenApi = addErrorResponseToSwagger (HTTP.statusCode nonFederatingBackendsStatus) $ mempty & S.description .~ "Adding members to the conversation is not possible because the backends involved do not form a fully connected graph" - & S.schema ?~ S.Inline (S.toSchema (Proxy @NonFederatingBackends)) + & S.content .~ singleton mediaType mediaTypeObject + where + mediaType = contentType $ Proxy @JSON + mediaTypeObject = + mempty + & S.schema ?~ S.Inline (S.toSchema (Proxy @NonFederatingBackends)) type instance ErrorEffect NonFederatingBackends = Error NonFederatingBackends @@ -486,11 +507,18 @@ instance ToSchema UnreachableBackends where <$> (.backends) .= field "unreachable_backends" (array schema) instance IsSwaggerError UnreachableBackends where - addToSwagger = + addToOpenApi = addErrorResponseToSwagger (HTTP.statusCode unreachableBackendsStatus) $ mempty & S.description .~ "Some domains are unreachable" - & S.schema ?~ S.Inline (S.toSchema (Proxy @UnreachableBackends)) + -- Defaulting this to JSON, as openapi3 needs something to map a schema against. + -- This _should_ be overridden with the actual media types once we are at the + -- point of rendering out the schemas for MultiVerb. + -- Check the instance of `S.HasOpenApi (MultiVerb method (cs :: [Type]) as r)` + & S.content .~ singleton mediaType mediaTypeObject + where + mediaType = contentType $ Proxy @JSON + mediaTypeObject = mempty & S.schema ?~ S.Inline (S.toSchema (Proxy @UnreachableBackends)) type instance ErrorEffect UnreachableBackends = Error UnreachableBackends diff --git a/libs/wire-api/src/Wire/API/Error/Gundeck.hs b/libs/wire-api/src/Wire/API/Error/Gundeck.hs index f28432f45f1..ac9b6ce363f 100644 --- a/libs/wire-api/src/Wire/API/Error/Gundeck.hs +++ b/libs/wire-api/src/Wire/API/Error/Gundeck.hs @@ -17,6 +17,7 @@ module Wire.API.Error.Gundeck where +import Data.Typeable import Wire.API.Error data GundeckError @@ -28,8 +29,8 @@ data GundeckError | TokenNotFound | NotificationNotFound -instance KnownError (MapError e) => IsSwaggerError (e :: GundeckError) where - addToSwagger = addStaticErrorToSwagger @(MapError e) +instance (Typeable (MapError e), KnownError (MapError e)) => IsSwaggerError (e :: GundeckError) where + addToOpenApi = addStaticErrorToSwagger @(MapError e) type instance MapError 'AddTokenErrorNoBudget = 'StaticError 413 "sns-thread-budget-reached" "Too many concurrent calls to SNS; is SNS down?" diff --git a/libs/wire-api/src/Wire/API/Event/Conversation.hs b/libs/wire-api/src/Wire/API/Event/Conversation.hs index 2aeb06e131d..cfac6ae07de 100644 --- a/libs/wire-api/src/Wire/API/Event/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Event/Conversation.hs @@ -25,6 +25,7 @@ module Wire.API.Event.Conversation evtType, EventType (..), EventData (..), + EdMemberLeftReason (..), AddCodeResult (..), -- * Event lenses @@ -71,22 +72,25 @@ import Data.Aeson qualified as A import Data.Aeson.KeyMap qualified as KeyMap import Data.Id import Data.Json.Util +import Data.OpenApi (deprecated) +import Data.OpenApi qualified as S import Data.Qualified import Data.SOP import Data.Schema -import Data.Swagger qualified as S import Data.Time import Imports import Test.QuickCheck qualified as QC import URI.ByteString () import Wire.API.Conversation import Wire.API.Conversation.Code (ConversationCode (..), ConversationCodeInfo) +import Wire.API.Conversation.Protocol (ProtocolUpdate (unProtocolUpdate)) +import Wire.API.Conversation.Protocol qualified as P import Wire.API.Conversation.Role import Wire.API.Conversation.Typing import Wire.API.MLS.SubConversation import Wire.API.Routes.MultiVerb import Wire.API.Routes.Version -import Wire.API.User (QualifiedUserIdList (..)) +import Wire.API.User (QualifiedUserIdList (..), qualifiedUserIdListObjectSchema) import Wire.Arbitrary (Arbitrary (arbitrary), GenericUniform (..)) -------------------------------------------------------------------------------- @@ -133,6 +137,7 @@ data EventType | MLSMessageAdd | MLSWelcome | Typing + | ProtocolUpdate deriving stock (Eq, Show, Generic, Enum, Bounded, Ord) deriving (Arbitrary) via (GenericUniform EventType) deriving (FromJSON, ToJSON, S.ToSchema) via Schema EventType @@ -156,12 +161,37 @@ instance ToSchema EventType where element "conversation.typing" Typing, element "conversation.otr-message-add" OtrMessageAdd, element "conversation.mls-message-add" MLSMessageAdd, - element "conversation.mls-welcome" MLSWelcome + element "conversation.mls-welcome" MLSWelcome, + element "conversation.protocol-update" ProtocolUpdate + ] + +-- | The reason for a member to leave +-- There are three reasons +-- - the member has left on their own +-- - the member was removed from the team +-- - the member was removed by another member +data EdMemberLeftReason + = -- | The member has left on their own + EdReasonLeft + | -- | The member was removed from the team and/or deleted + EdReasonDeleted + | -- | The member was removed by another member + EdReasonRemoved + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via GenericUniform EdMemberLeftReason + +instance ToSchema EdMemberLeftReason where + schema = + enum @Text "EdMemberLeftReason" $ + mconcat + [ element "left" EdReasonLeft, + element "user-deleted" EdReasonDeleted, + element "removed" EdReasonRemoved ] data EventData = EdMembersJoin SimpleMembers - | EdMembersLeave QualifiedUserIdList + | EdMembersLeave EdMemberLeftReason QualifiedUserIdList | EdConnect Connect | EdConvReceiptModeUpdate ConversationReceiptModeUpdate | EdConvRename ConversationRename @@ -176,12 +206,13 @@ data EventData | EdOtrMessage OtrMessage | EdMLSMessage ByteString | EdMLSWelcome ByteString + | EdProtocolUpdate P.ProtocolTag deriving stock (Eq, Show, Generic) genEventData :: EventType -> QC.Gen EventData genEventData = \case MemberJoin -> EdMembersJoin <$> arbitrary - MemberLeave -> EdMembersLeave <$> arbitrary + MemberLeave -> EdMembersLeave <$> arbitrary <*> arbitrary MemberStateUpdate -> EdMemberUpdate <$> arbitrary ConvRename -> EdConvRename <$> arbitrary ConvAccessUpdate -> EdConvAccessUpdate <$> arbitrary @@ -196,10 +227,11 @@ genEventData = \case MLSMessageAdd -> EdMLSMessage <$> arbitrary MLSWelcome -> EdMLSWelcome <$> arbitrary ConvDelete -> pure EdConvDelete + ProtocolUpdate -> EdProtocolUpdate <$> arbitrary eventDataType :: EventData -> EventType eventDataType (EdMembersJoin _) = MemberJoin -eventDataType (EdMembersLeave _) = MemberLeave +eventDataType (EdMembersLeave _ _) = MemberLeave eventDataType (EdMemberUpdate _) = MemberStateUpdate eventDataType (EdConvRename _) = ConvRename eventDataType (EdConvAccessUpdate _) = ConvAccessUpdate @@ -214,6 +246,7 @@ eventDataType (EdOtrMessage _) = OtrMessageAdd eventDataType (EdMLSMessage _) = MLSMessageAdd eventDataType (EdMLSWelcome _) = MLSWelcome eventDataType EdConvDelete = ConvDelete +eventDataType (EdProtocolUpdate _) = ProtocolUpdate -------------------------------------------------------------------------------- -- Event data helpers @@ -234,7 +267,9 @@ instance ToSchema SimpleMembers where .= optional ( fieldWithDocModifier "user_ids" - (description ?~ "deprecated") + ( (description ?~ "deprecated") + . (deprecated ?~ True) + ) (array schema) ) @@ -373,7 +408,7 @@ taggedEventDataSchema = where edata = dispatch $ \case MemberJoin -> tag _EdMembersJoin (unnamed schema) - MemberLeave -> tag _EdMembersLeave (unnamed schema) + MemberLeave -> tag _EdMembersLeave (unnamed memberLeaveSchema) MemberStateUpdate -> tag _EdMemberUpdate (unnamed schema) ConvRename -> tag _EdConvRename (unnamed schema) -- FUTUREWORK: when V2 is dropped, it is fine to change this schema to @@ -394,6 +429,12 @@ taggedEventDataSchema = Typing -> tag _EdTyping (unnamed schema) ConvCodeDelete -> tag _EdConvCodeDelete null_ ConvDelete -> tag _EdConvDelete null_ + ProtocolUpdate -> tag _EdProtocolUpdate (unnamed (unProtocolUpdate <$> P.ProtocolUpdate .= schema)) + +memberLeaveSchema :: ValueSchema NamedSwaggerDoc (EdMemberLeftReason, QualifiedUserIdList) +memberLeaveSchema = + object "QualifiedUserIdList with EdMemberLeftReason" $ + (,) <$> fst .= field "reason" schema <*> snd .= qualifiedUserIdListObjectSchema instance ToSchema Event where schema = object "Event" eventObjectSchema diff --git a/libs/wire-api/src/Wire/API/Event/FeatureConfig.hs b/libs/wire-api/src/Wire/API/Event/FeatureConfig.hs index e6982d8f45f..32e67dcfaf6 100644 --- a/libs/wire-api/src/Wire/API/Event/FeatureConfig.hs +++ b/libs/wire-api/src/Wire/API/Event/FeatureConfig.hs @@ -26,8 +26,8 @@ import Data.Aeson (toJSON) import Data.Aeson qualified as A import Data.Aeson.KeyMap qualified as KeyMap import Data.Json.Util (ToJSONObject (toJSONObject)) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import GHC.TypeLits (KnownSymbol) import Imports import Test.QuickCheck.Gen (oneof) diff --git a/libs/wire-api/src/Wire/API/Event/Federation.hs b/libs/wire-api/src/Wire/API/Event/Federation.hs index 17d5120c0ce..55130a9ec84 100644 --- a/libs/wire-api/src/Wire/API/Event/Federation.hs +++ b/libs/wire-api/src/Wire/API/Event/Federation.hs @@ -9,8 +9,8 @@ import Data.Aeson qualified as A import Data.Aeson.KeyMap qualified as KeyMap import Data.Domain import Data.Json.Util (ToJSONObject (toJSONObject)) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports import Wire.Arbitrary diff --git a/libs/wire-api/src/Wire/API/Event/Team.hs b/libs/wire-api/src/Wire/API/Event/Team.hs index a6404dd851b..d5dac32eb39 100644 --- a/libs/wire-api/src/Wire/API/Event/Team.hs +++ b/libs/wire-api/src/Wire/API/Event/Team.hs @@ -42,8 +42,8 @@ import Data.Aeson.KeyMap qualified as KeyMap import Data.Aeson.Types (Parser) import Data.Id (ConvId, TeamId, UserId) import Data.Json.Util +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Data.Time (UTCTime) import Imports import Test.QuickCheck qualified as QC diff --git a/libs/wire-api/src/Wire/API/FederationStatus.hs b/libs/wire-api/src/Wire/API/FederationStatus.hs index 257d95c96b3..b0e7a3c9859 100644 --- a/libs/wire-api/src/Wire/API/FederationStatus.hs +++ b/libs/wire-api/src/Wire/API/FederationStatus.hs @@ -10,8 +10,8 @@ import Data.Aeson (FromJSON (..), ToJSON (..), (.:)) import Data.Aeson qualified as A import Data.Aeson.Types qualified as A import Data.Domain +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports import Wire.Arbitrary diff --git a/libs/wire-api/src/Wire/API/FederationUpdate.hs b/libs/wire-api/src/Wire/API/FederationUpdate.hs index bd5d3ec6a8d..d1930d7740f 100644 --- a/libs/wire-api/src/Wire/API/FederationUpdate.hs +++ b/libs/wire-api/src/Wire/API/FederationUpdate.hs @@ -1,107 +1,13 @@ module Wire.API.FederationUpdate - ( syncFedDomainConfigs, - SyncFedDomainConfigsCallback (..), - emptySyncFedDomainConfigsCallback, - deleteFederationRemoteGalley, + ( getFederationDomainConfigs, ) where -import Control.Concurrent.Async -import Control.Exception -import Control.Retry qualified as R -import Data.Domain -import Data.Set qualified as Set -import Data.Text -import Data.Typeable (cast) import Imports -import Network.HTTP.Client (defaultManagerSettings, newManager) -import Servant.Client (BaseUrl (BaseUrl), ClientEnv (ClientEnv), ClientError, ClientM, Scheme (Http), runClientM) -import Servant.Client.Internal.HttpClient (defaultMakeClientRequest) -import System.Logger qualified as L -import Util.Options +import Servant.Client (ClientEnv, ClientError, runClientM) import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.Internal.Brig qualified as IAPI import Wire.API.Routes.Named (namedClient) --- | 'FedUpdateCallback' is not called if a new settings cannot be fetched, or if they are --- equal to the old settings. -syncFedDomainConfigs :: Endpoint -> L.Logger -> SyncFedDomainConfigsCallback -> IO (IORef FederationDomainConfigs, Async ()) -syncFedDomainConfigs (Endpoint h p) log' cb = do - let baseUrl = BaseUrl Http (unpack h) (fromIntegral p) "" - clientEnv <- newManager defaultManagerSettings <&> \mgr -> ClientEnv mgr baseUrl Nothing defaultMakeClientRequest - ioref <- newIORef =<< initialize log' clientEnv - updateDomainsThread <- async $ loop log' clientEnv cb ioref - pure (ioref, updateDomainsThread) - -deleteFedRemoteGalley :: Domain -> ClientM () -deleteFedRemoteGalley dom = namedClient @IAPI.API @"delete-federation-remote-from-galley" dom - --- | Initial function for getting the set of domains from brig, and an update interval -initialize :: L.Logger -> ClientEnv -> IO FederationDomainConfigs -initialize logger clientEnv = - let policy :: R.RetryPolicy - policy = R.capDelay 30_000_000 $ R.exponentialBackoff 3_000 - - go :: IO (Maybe FederationDomainConfigs) - go = do - fetch clientEnv >>= \case - Right s -> pure $ Just s - Left e -> do - L.log logger L.Info $ - L.msg (L.val "Failed to reach brig for federation setup, retrying...") - L.~~ "error" L..= show e - pure Nothing - in R.retrying policy (const (pure . isNothing)) (const go) >>= \case - Just c -> pure c - Nothing -> throwIO $ ErrorCall "*** Failed to reach brig for federation setup, giving up!" - -deleteFederationRemoteGalley :: Domain -> ClientEnv -> IO (Either ClientError ()) -deleteFederationRemoteGalley dom = runClientM $ deleteFedRemoteGalley dom - -loop :: L.Logger -> ClientEnv -> SyncFedDomainConfigsCallback -> IORef FederationDomainConfigs -> IO () -loop logger clientEnv (SyncFedDomainConfigsCallback callback) env = forever $ - catch go $ \(e :: SomeException) -> do - -- log synchronous exceptions - case fromException e of - -- Rethrow async exceptions so that we can kill this thread with the `async` tools - -- The use of cast here comes from https://hackage.haskell.org/package/base-4.18.0.0/docs/src/GHC.IO.Exception.html#asyncExceptionFromException - -- But I only want to check for AsyncCancelled while leaving non-async exception - -- logging in place. - Just (SomeAsyncException e') -> case cast e' of - Just AsyncCancelled -> throwIO e - Nothing -> pure () - Nothing -> - L.log logger L.Error $ - L.msg (L.val "Federation domain sync thread died, restarting domain synchronization.") - L.~~ "error" L..= displayException e - where - go = do - fetch clientEnv >>= \case - Left e -> - L.log logger L.Info $ - L.msg (L.val "Could not retrieve an updated list of federation domains from Brig; I'll keep trying!") - L.~~ "error" L..= displayException e - Right new -> do - old <- readIORef env - unless (domainListsEqual old new) $ callback old new - atomicWriteIORef env new - delay <- updateInterval <$> readIORef env - threadDelay (delay * 1_000_000) - - domainListsEqual o n = - Set.fromList (domain <$> remotes o) - == Set.fromList (domain <$> remotes n) - -fetch :: ClientEnv -> IO (Either ClientError FederationDomainConfigs) -fetch = runClientM (namedClient @IAPI.API @"get-federation-remotes") - --- | The callback takes the previous and the new settings and runs a given action. -newtype SyncFedDomainConfigsCallback = SyncFedDomainConfigsCallback - { fromFedUpdateCallback :: - FederationDomainConfigs -> -- old value - FederationDomainConfigs -> -- new value - IO () - } - -emptySyncFedDomainConfigsCallback :: SyncFedDomainConfigsCallback -emptySyncFedDomainConfigsCallback = SyncFedDomainConfigsCallback $ \_ _ -> pure () +getFederationDomainConfigs :: ClientEnv -> IO (Either ClientError FederationDomainConfigs) +getFederationDomainConfigs = runClientM $ namedClient @IAPI.API @"get-federation-remotes" diff --git a/libs/wire-api/src/Wire/API/Internal/BulkPush.hs b/libs/wire-api/src/Wire/API/Internal/BulkPush.hs index 8c07c074a2c..0ffb9eec618 100644 --- a/libs/wire-api/src/Wire/API/Internal/BulkPush.hs +++ b/libs/wire-api/src/Wire/API/Internal/BulkPush.hs @@ -20,9 +20,9 @@ module Wire.API.Internal.BulkPush where import Control.Lens import Data.Aeson import Data.Id +import Data.OpenApi qualified as Swagger import Data.Schema (ValueSchema) import Data.Schema qualified as S -import Data.Swagger qualified as Swagger import Imports import Wire.API.Internal.Notification diff --git a/libs/wire-api/src/Wire/API/Internal/Notification.hs b/libs/wire-api/src/Wire/API/Internal/Notification.hs index 3c252180668..849c8125460 100644 --- a/libs/wire-api/src/Wire/API/Internal/Notification.hs +++ b/libs/wire-api/src/Wire/API/Internal/Notification.hs @@ -45,8 +45,8 @@ import Control.Lens (makeLenses) import Data.Aeson import Data.Id import Data.List1 +import Data.OpenApi qualified as Swagger import Data.Schema qualified as S -import Data.Swagger qualified as Swagger import Imports hiding (cs) import Wire.API.Notification diff --git a/libs/wire-api/src/Wire/API/MLS/AuthenticatedContent.hs b/libs/wire-api/src/Wire/API/MLS/AuthenticatedContent.hs new file mode 100644 index 00000000000..521217f7c53 --- /dev/null +++ b/libs/wire-api/src/Wire/API/MLS/AuthenticatedContent.hs @@ -0,0 +1,111 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.MLS.AuthenticatedContent + ( AuthenticatedContent (..), + TaggedSender (..), + authContentRef, + publicMessageRef, + mkSignedPublicMessage, + ) +where + +import Crypto.PubKey.Ed25519 +import Imports hiding (cs) +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.Context +import Wire.API.MLS.Epoch +import Wire.API.MLS.Group +import Wire.API.MLS.LeafNode +import Wire.API.MLS.Message +import Wire.API.MLS.Proposal +import Wire.API.MLS.ProtocolVersion +import Wire.API.MLS.Serialisation + +-- | Needed to compute proposal refs. +-- https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6-7 +data AuthenticatedContent = AuthenticatedContent + { wireFormat :: WireFormatTag, + content :: RawMLS FramedContent, + authData :: RawMLS FramedContentAuthData + } + deriving (Eq, Show) + +instance SerialiseMLS AuthenticatedContent where + serialiseMLS ac = do + serialiseMLS ac.wireFormat + serialiseMLS ac.content + serialiseMLS ac.authData + +msgAuthContent :: PublicMessage -> AuthenticatedContent +msgAuthContent msg = + AuthenticatedContent + { wireFormat = WireFormatPublicTag, + content = msg.content, + authData = msg.authData + } + +-- | Compute the proposal ref given a ciphersuite and the raw proposal data. +authContentRef :: CipherSuiteTag -> AuthenticatedContent -> ProposalRef +authContentRef cs = ProposalRef . csHash cs proposalContext . mkRawMLS + +publicMessageRef :: CipherSuiteTag -> PublicMessage -> ProposalRef +publicMessageRef cs = authContentRef cs . msgAuthContent + +-- | Sender, plus with a membership tag in the case of a member sender. +data TaggedSender + = TaggedSenderMember LeafIndex ByteString + | TaggedSenderExternal Word32 + | TaggedSenderNewMemberProposal + | TaggedSenderNewMemberCommit + +taggedSenderToSender :: TaggedSender -> Sender +taggedSenderToSender (TaggedSenderMember i _) = SenderMember i +taggedSenderToSender (TaggedSenderExternal n) = SenderExternal n +taggedSenderToSender TaggedSenderNewMemberProposal = SenderNewMemberProposal +taggedSenderToSender TaggedSenderNewMemberCommit = SenderNewMemberCommit + +taggedSenderMembershipTag :: TaggedSender -> Maybe ByteString +taggedSenderMembershipTag (TaggedSenderMember _ t) = Just t +taggedSenderMembershipTag _ = Nothing + +-- | Craft a message with the backend itself as a sender. Return the message and its ref. +mkSignedPublicMessage :: + SecretKey -> PublicKey -> GroupId -> Epoch -> TaggedSender -> FramedContentData -> PublicMessage +mkSignedPublicMessage priv pub gid epoch sender payload = + let framedContent = + mkRawMLS + FramedContent + { groupId = gid, + epoch = epoch, + sender = taggedSenderToSender sender, + content = payload, + authenticatedData = mempty + } + tbs = + FramedContentTBS + { protocolVersion = defaultProtocolVersion, + wireFormat = WireFormatPublicTag, + content = framedContent, + groupContext = Nothing + } + sig = signWithLabel "FramedContentTBS" priv pub (mkRawMLS tbs) + in PublicMessage + { content = framedContent, + authData = mkRawMLS (FramedContentAuthData sig Nothing), + membershipTag = taggedSenderMembershipTag sender + } diff --git a/libs/wire-api/src/Wire/API/MLS/Capabilities.hs b/libs/wire-api/src/Wire/API/MLS/Capabilities.hs new file mode 100644 index 00000000000..589aa7dbcab --- /dev/null +++ b/libs/wire-api/src/Wire/API/MLS/Capabilities.hs @@ -0,0 +1,52 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.MLS.Capabilities where + +import Imports +import Test.QuickCheck +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.Serialisation +import Wire.Arbitrary + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-7.2-2 +data Capabilities = Capabilities + { versions :: [Word16], + ciphersuites :: [CipherSuite], + extensions :: [Word16], + proposals :: [Word16], + credentials :: [Word16] + } + deriving (Show, Eq, Generic) + deriving (Arbitrary) via (GenericUniform Capabilities) + +instance ParseMLS Capabilities where + parseMLS = + Capabilities + <$> parseMLSVector @VarInt parseMLS + <*> parseMLSVector @VarInt parseMLS + <*> parseMLSVector @VarInt parseMLS + <*> parseMLSVector @VarInt parseMLS + <*> parseMLSVector @VarInt parseMLS + +instance SerialiseMLS Capabilities where + serialiseMLS caps = do + serialiseMLSVector @VarInt serialiseMLS caps.versions + serialiseMLSVector @VarInt serialiseMLS caps.ciphersuites + serialiseMLSVector @VarInt serialiseMLS caps.extensions + serialiseMLSVector @VarInt serialiseMLS caps.proposals + serialiseMLSVector @VarInt serialiseMLS caps.credentials diff --git a/libs/wire-api/src/Wire/API/MLS/CipherSuite.hs b/libs/wire-api/src/Wire/API/MLS/CipherSuite.hs index 4327cee928d..1f358b58e61 100644 --- a/libs/wire-api/src/Wire/API/MLS/CipherSuite.hs +++ b/libs/wire-api/src/Wire/API/MLS/CipherSuite.hs @@ -17,37 +17,97 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.API.MLS.CipherSuite where +module Wire.API.MLS.CipherSuite + ( -- * MLS ciphersuites + CipherSuite (..), + defCipherSuite, + CipherSuiteTag (..), + cipherSuiteTag, + tagCipherSuite, + -- * MLS signature schemes + SignatureScheme (..), + SignatureSchemeTag (..), + signatureScheme, + signatureSchemeName, + signatureSchemeTag, + csSignatureScheme, + + -- * Utilities + csHash, + csVerifySignatureWithLabel, + csVerifySignature, + signWithLabel, + ) +where + +import Cassandra.CQL +import Control.Error (note) import Control.Lens ((?~)) import Crypto.Error +import Crypto.Hash (hashWith) import Crypto.Hash.Algorithms -import Crypto.KDF.HKDF qualified as HKDF import Crypto.PubKey.Ed25519 qualified as Ed25519 -import Data.Aeson (parseJSON, toJSON) +import Data.Aeson qualified as Aeson +import Data.Aeson.Types (FromJSON (..), FromJSONKey (..), ToJSON (..), ToJSONKey (..)) +import Data.Aeson.Types qualified as Aeson +import Data.Bifunctor +import Data.ByteArray hiding (index) +import Data.ByteArray qualified as BA +import Data.OpenApi qualified as S +import Data.OpenApi.Internal.Schema qualified as S import Data.Proxy import Data.Schema -import Data.Swagger qualified as S -import Data.Swagger.Internal.Schema qualified as S +import Data.Text qualified as T +import Data.Text.Lazy qualified as LT +import Data.Text.Lazy.Builder qualified as LT +import Data.Text.Lazy.Builder.Int qualified as LT +import Data.Text.Read qualified as T import Data.Word -import Imports -import Wire.API.MLS.Credential +import Imports hiding (cs) +import Web.HttpApiData import Wire.API.MLS.Serialisation import Wire.Arbitrary newtype CipherSuite = CipherSuite {cipherSuiteNumber :: Word16} deriving stock (Eq, Show) deriving newtype (ParseMLS, SerialiseMLS, Arbitrary) + deriving (FromJSON, ToJSON) via Schema CipherSuite instance ToSchema CipherSuite where schema = named "CipherSuite" $ cipherSuiteNumber .= fmap CipherSuite (unnamed schema) -data CipherSuiteTag = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 +instance S.ToParamSchema CipherSuite where + toParamSchema _ = + mempty + & S.type_ ?~ S.OpenApiNumber + +instance FromHttpApiData CipherSuite where + parseUrlPiece t = do + (x, rest) <- first T.pack $ T.hexadecimal t + unless (T.null rest) $ + Left "Trailing characters after ciphersuite number" + pure (CipherSuite x) + +instance ToHttpApiData CipherSuite where + toUrlPiece = + LT.toStrict + . LT.toLazyText + . ("0x" <>) + . LT.hexadecimal + . cipherSuiteNumber + +data CipherSuiteTag + = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + | MLS_128_X25519Kyber768Draft00_AES128GCM_SHA256_Ed25519 deriving stock (Bounded, Enum, Eq, Show, Generic, Ord) deriving (Arbitrary) via (GenericUniform CipherSuiteTag) +defCipherSuite :: CipherSuiteTag +defCipherSuite = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + instance S.ToSchema CipherSuiteTag where declareNamedSchema _ = pure . S.named "CipherSuiteTag" $ @@ -71,24 +131,135 @@ instance ToSchema CipherSuiteTag where -- | See https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol.html#table-5. cipherSuiteTag :: CipherSuite -> Maybe CipherSuiteTag -cipherSuiteTag (CipherSuite n) = case n of - 1 -> pure MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 - _ -> Nothing +cipherSuiteTag cs = listToMaybe $ do + t <- [minBound .. maxBound] + guard (tagCipherSuite t == cs) + pure t -- | Inverse of 'cipherSuiteTag' tagCipherSuite :: CipherSuiteTag -> CipherSuite tagCipherSuite MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 = CipherSuite 1 +tagCipherSuite MLS_128_X25519Kyber768Draft00_AES128GCM_SHA256_Ed25519 = CipherSuite 0xf031 + +csHash :: CipherSuiteTag -> ByteString -> RawMLS a -> ByteString +csHash MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 = sha256Hash +csHash MLS_128_X25519Kyber768Draft00_AES128GCM_SHA256_Ed25519 = sha256Hash + +sha256Hash :: ByteString -> RawMLS a -> ByteString +sha256Hash ctx value = convert . hashWith SHA256 . encodeMLS' $ RefHashInput ctx value -csHash :: CipherSuiteTag -> ByteString -> ByteString -> ByteString -csHash MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 ctx value = - HKDF.expand (HKDF.extract @SHA256 (mempty :: ByteString) value) ctx 16 +csVerifySignature :: CipherSuiteTag -> ByteString -> RawMLS a -> ByteString -> Bool +csVerifySignature MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 = ed25519VerifySignature +csVerifySignature MLS_128_X25519Kyber768Draft00_AES128GCM_SHA256_Ed25519 = ed25519VerifySignature -csVerifySignature :: CipherSuiteTag -> ByteString -> ByteString -> ByteString -> Bool -csVerifySignature MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 pub x sig = +ed25519VerifySignature :: ByteString -> RawMLS a -> ByteString -> Bool +ed25519VerifySignature pub x sig = fromMaybe False . maybeCryptoError $ do pub' <- Ed25519.publicKey pub sig' <- Ed25519.signature sig - pure $ Ed25519.verify pub' x sig' + pure $ Ed25519.verify pub' x.raw sig' + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-5.2-5 +type RefHashInput = SignContent + +pattern RefHashInput :: ByteString -> RawMLS a -> RefHashInput a +pattern RefHashInput label content = SignContent label content + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-5.1.2-6 +data SignContent a = SignContent + { sigLabel :: ByteString, + content :: RawMLS a + } + +instance SerialiseMLS (SignContent a) where + serialiseMLS c = do + serialiseMLSBytes @VarInt c.sigLabel + serialiseMLSBytes @VarInt c.content.raw + +mkSignContent :: ByteString -> RawMLS a -> SignContent a +mkSignContent sigLabel content = + SignContent + { sigLabel = "MLS 1.0 " <> sigLabel, + content = content + } + +csVerifySignatureWithLabel :: + CipherSuiteTag -> + ByteString -> + ByteString -> + RawMLS a -> + ByteString -> + Bool +csVerifySignatureWithLabel cs pub label x sig = + csVerifySignature cs pub (mkRawMLS (mkSignContent label x)) sig + +-- FUTUREWORK: generalise to arbitrary ciphersuites +signWithLabel :: ByteString -> Ed25519.SecretKey -> Ed25519.PublicKey -> RawMLS a -> ByteString +signWithLabel sigLabel priv pub x = BA.convert $ Ed25519.sign priv pub (encodeMLS' (mkSignContent sigLabel x)) csSignatureScheme :: CipherSuiteTag -> SignatureSchemeTag csSignatureScheme MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 = Ed25519 +csSignatureScheme MLS_128_X25519Kyber768Draft00_AES128GCM_SHA256_Ed25519 = Ed25519 + +-- | A TLS signature scheme. +-- +-- See . +newtype SignatureScheme = SignatureScheme {unSignatureScheme :: Word16} + deriving stock (Eq, Show) + deriving newtype (ParseMLS, Arbitrary) + +signatureScheme :: SignatureSchemeTag -> SignatureScheme +signatureScheme = SignatureScheme . signatureSchemeNumber + +data SignatureSchemeTag = Ed25519 + deriving stock (Bounded, Enum, Eq, Ord, Show, Generic) + deriving (Arbitrary) via GenericUniform SignatureSchemeTag + +instance Cql SignatureSchemeTag where + ctype = Tagged TextColumn + toCql = CqlText . signatureSchemeName + fromCql (CqlText name) = + note ("Unexpected signature scheme: " <> T.unpack name) $ + signatureSchemeFromName name + fromCql _ = Left "SignatureScheme: Text expected" + +signatureSchemeNumber :: SignatureSchemeTag -> Word16 +signatureSchemeNumber Ed25519 = 0x807 + +signatureSchemeName :: SignatureSchemeTag -> Text +signatureSchemeName Ed25519 = "ed25519" + +signatureSchemeTag :: SignatureScheme -> Maybe SignatureSchemeTag +signatureSchemeTag (SignatureScheme n) = getAlt $ + flip foldMap [minBound .. maxBound] $ \s -> + guard (signatureSchemeNumber s == n) $> s + +signatureSchemeFromName :: Text -> Maybe SignatureSchemeTag +signatureSchemeFromName name = getAlt $ + flip foldMap [minBound .. maxBound] $ \s -> + guard (signatureSchemeName s == name) $> s + +parseSignatureScheme :: MonadFail f => Text -> f SignatureSchemeTag +parseSignatureScheme name = + maybe + (fail ("Unsupported signature scheme " <> T.unpack name)) + pure + (signatureSchemeFromName name) + +instance FromJSON SignatureSchemeTag where + parseJSON = Aeson.withText "SignatureScheme" parseSignatureScheme + +instance FromJSONKey SignatureSchemeTag where + fromJSONKey = Aeson.FromJSONKeyTextParser parseSignatureScheme + +instance S.ToParamSchema SignatureSchemeTag where + toParamSchema _ = mempty & S.type_ ?~ S.OpenApiString + +instance FromHttpApiData SignatureSchemeTag where + parseQueryParam = note "Unknown signature scheme" . signatureSchemeFromName + +instance ToJSON SignatureSchemeTag where + toJSON = Aeson.String . signatureSchemeName + +instance ToJSONKey SignatureSchemeTag where + toJSONKey = Aeson.toJSONKeyText signatureSchemeName diff --git a/libs/wire-api/src/Wire/API/MLS/Commit.hs b/libs/wire-api/src/Wire/API/MLS/Commit.hs index 8f1a17c8ce6..81223db5504 100644 --- a/libs/wire-api/src/Wire/API/MLS/Commit.hs +++ b/libs/wire-api/src/Wire/API/MLS/Commit.hs @@ -18,49 +18,74 @@ module Wire.API.MLS.Commit where import Imports -import Wire.API.MLS.KeyPackage +import Wire.API.MLS.LeafNode import Wire.API.MLS.Proposal import Wire.API.MLS.Serialisation import Wire.Arbitrary +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-12.4-3 data Commit = Commit - { cProposals :: [ProposalOrRef], - cPath :: Maybe UpdatePath + { proposals :: [ProposalOrRef], + path :: Maybe UpdatePath } - deriving (Eq, Show) + deriving (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform Commit) instance ParseMLS Commit where - parseMLS = Commit <$> parseMLSVector @Word32 parseMLS <*> parseMLSOptional parseMLS + parseMLS = + Commit + <$> parseMLSVector @VarInt parseMLS + <*> parseMLSOptional parseMLS + +instance SerialiseMLS Commit where + serialiseMLS c = do + serialiseMLSVector @VarInt serialiseMLS c.proposals + serialiseMLSOptional serialiseMLS c.path +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-7.6-2 data UpdatePath = UpdatePath - { upLeaf :: RawMLS KeyPackage, - upNodes :: [UpdatePathNode] + { leaf :: RawMLS LeafNode, + nodes :: [UpdatePathNode] } - deriving (Eq, Show) + deriving (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform UpdatePath) instance ParseMLS UpdatePath where - parseMLS = UpdatePath <$> parseMLS <*> parseMLSVector @Word32 parseMLS + parseMLS = UpdatePath <$> parseMLS <*> parseMLSVector @VarInt parseMLS +instance SerialiseMLS UpdatePath where + serialiseMLS up = do + serialiseMLS up.leaf + serialiseMLSVector @VarInt serialiseMLS up.nodes + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-7.6-2 data UpdatePathNode = UpdatePathNode - { upnPublicKey :: ByteString, - upnSecret :: [HPKECiphertext] + { publicKey :: ByteString, + secret :: [HPKECiphertext] } - deriving (Eq, Show) + deriving (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform UpdatePathNode) instance ParseMLS UpdatePathNode where - parseMLS = UpdatePathNode <$> parseMLSBytes @Word16 <*> parseMLSVector @Word32 parseMLS + parseMLS = UpdatePathNode <$> parseMLSBytes @VarInt <*> parseMLSVector @VarInt parseMLS + +instance SerialiseMLS UpdatePathNode where + serialiseMLS upn = do + serialiseMLSBytes @VarInt upn.publicKey + serialiseMLSVector @VarInt serialiseMLS upn.secret +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-7.6-2 data HPKECiphertext = HPKECiphertext - { hcOutput :: ByteString, - hcCiphertext :: ByteString + { output :: ByteString, + ciphertext :: ByteString } deriving (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform HPKECiphertext) instance ParseMLS HPKECiphertext where - parseMLS = HPKECiphertext <$> parseMLSBytes @Word16 <*> parseMLSBytes @Word16 + parseMLS = HPKECiphertext <$> parseMLSBytes @VarInt <*> parseMLSBytes @VarInt instance SerialiseMLS HPKECiphertext where serialiseMLS (HPKECiphertext out ct) = do - serialiseMLSBytes @Word16 out - serialiseMLSBytes @Word16 ct + serialiseMLSBytes @VarInt out + serialiseMLSBytes @VarInt ct diff --git a/libs/wire-api/src/Wire/API/MLS/CommitBundle.hs b/libs/wire-api/src/Wire/API/MLS/CommitBundle.hs index a6c4e6753cd..ccfe3006284 100644 --- a/libs/wire-api/src/Wire/API/MLS/CommitBundle.hs +++ b/libs/wire-api/src/Wire/API/MLS/CommitBundle.hs @@ -15,65 +15,82 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.API.MLS.CommitBundle where +module Wire.API.MLS.CommitBundle (CommitBundle (..)) where -import Control.Lens (view, (.~), (?~)) -import Data.Bifunctor (first) -import Data.ByteString qualified as BS -import Data.ProtoLens (decodeMessage, encodeMessage) -import Data.ProtoLens qualified (Message (defMessage)) -import Data.Swagger qualified as S +import Control.Applicative +import Data.OpenApi qualified as S import Data.Text qualified as T import Imports -import Proto.Mls qualified -import Proto.Mls_Fields qualified as Proto.Mls -import Wire.API.ConverProtoLens -import Wire.API.MLS.GroupInfoBundle +import Wire.API.MLS.GroupInfo import Wire.API.MLS.Message import Wire.API.MLS.Serialisation import Wire.API.MLS.Welcome data CommitBundle = CommitBundle - { cbCommitMsg :: RawMLS (Message 'MLSPlainText), - cbWelcome :: Maybe (RawMLS Welcome), - cbGroupInfoBundle :: GroupInfoBundle + { commitMsg :: RawMLS Message, + welcome :: Maybe (RawMLS Welcome), + groupInfo :: RawMLS GroupInfo } - deriving (Eq, Show) + deriving stock (Eq, Show, Generic) -instance ConvertProtoLens Proto.Mls.CommitBundle CommitBundle where - fromProtolens protoBundle = protoLabel "CommitBundle" $ do - CommitBundle - <$> protoLabel "commit" (decodeMLS' (view Proto.Mls.commit protoBundle)) - <*> protoLabel - "welcome" - ( let bs = view Proto.Mls.welcome protoBundle - in if BS.length bs == 0 - then pure Nothing - else Just <$> decodeMLS' bs - ) - <*> protoLabel "group_info_bundle" (fromProtolens (view Proto.Mls.groupInfoBundle protoBundle)) - toProtolens bundle = - let commitData = rmRaw (cbCommitMsg bundle) - welcomeData = foldMap rmRaw (cbWelcome bundle) - groupInfoData = toProtolens (cbGroupInfoBundle bundle) - in ( Data.ProtoLens.defMessage - & Proto.Mls.commit .~ commitData - & Proto.Mls.welcome .~ welcomeData - & Proto.Mls.groupInfoBundle .~ groupInfoData - ) +data CommitBundleF f = CommitBundleF + { commitMsg :: f (RawMLS Message), + welcome :: f (RawMLS Welcome), + groupInfo :: f (RawMLS GroupInfo) + } -instance S.ToSchema CommitBundle where - declareNamedSchema _ = - pure $ - S.NamedSchema (Just "CommitBundle") $ - mempty - & S.description - ?~ "A protobuf-serialized object. See wireapp/generic-message-proto for the definition." +deriving instance Show (CommitBundleF []) + +instance Alternative f => Semigroup (CommitBundleF f) where + cb1 <> cb2 = + CommitBundleF + (cb1.commitMsg <|> cb2.commitMsg) + (cb1.welcome <|> cb2.welcome) + (cb1.groupInfo <|> cb2.groupInfo) + +instance Alternative f => Monoid (CommitBundleF f) where + mempty = CommitBundleF empty empty empty + +checkCommitBundleF :: CommitBundleF [] -> Either Text CommitBundle +checkCommitBundleF cb = + CommitBundle + <$> check "commit" cb.commitMsg + <*> checkOpt "welcome" cb.welcome + <*> check "group info" cb.groupInfo + where + check :: Text -> [a] -> Either Text a + check _ [x] = pure x + check name [] = Left ("Missing " <> name) + check name _ = Left ("Redundant occurrence of " <> name) -deserializeCommitBundle :: ByteString -> Either Text CommitBundle -deserializeCommitBundle b = do - protoCommitBundle :: Proto.Mls.CommitBundle <- first (("Parsing protobuf failed: " <>) . T.pack) (decodeMessage b) - first ("Converting from protobuf failed: " <>) (fromProtolens protoCommitBundle) + checkOpt :: Text -> [a] -> Either Text (Maybe a) + checkOpt _ [] = pure Nothing + checkOpt _ [x] = pure (Just x) + checkOpt name _ = Left ("Redundant occurrence of " <> name) -serializeCommitBundle :: CommitBundle -> ByteString -serializeCommitBundle = encodeMessage . (toProtolens @Proto.Mls.CommitBundle @CommitBundle) +findMessageInStream :: Alternative f => RawMLS Message -> Either Text (CommitBundleF f) +findMessageInStream msg = case msg.value.content of + MessagePublic mp -> case mp.content.value.content of + FramedContentCommit _ -> pure (CommitBundleF (pure msg) empty empty) + _ -> Left "unexpected public message" + MessageWelcome w -> pure (CommitBundleF empty (pure w) empty) + MessageGroupInfo gi -> pure (CommitBundleF empty empty (pure gi)) + _ -> Left "unexpected message type" + +findMessagesInStream :: Alternative f => [RawMLS Message] -> Either Text (CommitBundleF f) +findMessagesInStream = getAp . foldMap (Ap . findMessageInStream) + +instance ParseMLS CommitBundle where + parseMLS = do + msgs <- parseMLSStream parseMLS + either (fail . T.unpack) pure $ + findMessagesInStream msgs >>= checkCommitBundleF + +instance SerialiseMLS CommitBundle where + serialiseMLS cb = do + serialiseMLS cb.commitMsg + traverse_ (serialiseMLS . mkMessage . MessageWelcome) cb.welcome + serialiseMLS $ mkMessage (MessageGroupInfo cb.groupInfo) + +instance S.ToSchema CommitBundle where + declareNamedSchema _ = pure (mlsSwagger "CommitBundle") diff --git a/libs/wire-api/src/Wire/API/MLS/Context.hs b/libs/wire-api/src/Wire/API/MLS/Context.hs index 661b7ce6322..4324b61d7ae 100644 --- a/libs/wire-api/src/Wire/API/MLS/Context.hs +++ b/libs/wire-api/src/Wire/API/MLS/Context.hs @@ -19,15 +19,6 @@ module Wire.API.MLS.Context where import Imports --- Warning: the "context" string here is different from the one mandated by --- the spec, but it is the one that happens to be used by openmls. Until --- openmls is patched and we switch to a fixed version, we will have to use --- the "wrong" string here as well. --- --- This is used when invoking 'csHash'. -context :: ByteString -context = "MLS 1.0 ref" - proposalContext, keyPackageContext :: ByteString -proposalContext = context -keyPackageContext = context +proposalContext = "MLS 1.0 Proposal Reference" +keyPackageContext = "MLS 1.0 KeyPackage Reference" diff --git a/libs/wire-api/src/Wire/API/MLS/Credential.hs b/libs/wire-api/src/Wire/API/MLS/Credential.hs index dc229102392..1e9dd7d0b80 100644 --- a/libs/wire-api/src/Wire/API/MLS/Credential.hs +++ b/libs/wire-api/src/Wire/API/MLS/Credential.hs @@ -1,5 +1,3 @@ -{-# LANGUAGE GeneralizedNewtypeDeriving #-} - -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -19,7 +17,6 @@ module Wire.API.MLS.Credential where -import Cassandra.CQL import Control.Error.Util import Control.Lens ((?~)) import Data.Aeson (FromJSON (..), FromJSONKey (..), ToJSON (..), ToJSONKey (..)) @@ -30,12 +27,16 @@ import Data.Binary import Data.Binary.Get import Data.Binary.Parser import Data.Binary.Parser.Char8 +import Data.Binary.Put +import Data.ByteString.Base64.URL qualified as B64URL +import Data.ByteString.Lazy qualified as L import Data.Domain import Data.Id +import Data.OpenApi qualified as S import Data.Qualified import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as T +import Data.Text.Encoding qualified as T import Data.UUID import Imports import Web.HttpApiData @@ -44,95 +45,42 @@ import Wire.Arbitrary -- | An MLS credential. -- --- Only the @BasicCredential@ type is supported. -data Credential = BasicCredential - { bcIdentity :: ByteString, - bcSignatureScheme :: SignatureScheme, - bcSignatureKey :: ByteString - } +-- https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-5.3-3 +data Credential = BasicCredential ByteString | X509Credential [ByteString] deriving stock (Eq, Show, Generic) deriving (Arbitrary) via GenericUniform Credential -data CredentialTag = BasicCredentialTag - deriving stock (Enum, Bounded, Eq, Show) +data CredentialTag = BasicCredentialTag | X509CredentialTag + deriving stock (Enum, Bounded, Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform CredentialTag) instance ParseMLS CredentialTag where parseMLS = parseMLSEnum @Word16 "credential type" +instance SerialiseMLS CredentialTag where + serialiseMLS = serialiseMLSEnum @Word16 + instance ParseMLS Credential where parseMLS = parseMLS >>= \case BasicCredentialTag -> BasicCredential - <$> parseMLSBytes @Word16 - <*> parseMLS - <*> parseMLSBytes @Word16 + <$> parseMLSBytes @VarInt + X509CredentialTag -> + X509Credential + <$> parseMLSVector @VarInt (parseMLSBytes @VarInt) + +instance SerialiseMLS Credential where + serialiseMLS (BasicCredential i) = do + serialiseMLS BasicCredentialTag + serialiseMLSBytes @VarInt i + serialiseMLS (X509Credential certs) = do + serialiseMLS X509CredentialTag + serialiseMLSVector @VarInt (serialiseMLSBytes @VarInt) certs credentialTag :: Credential -> CredentialTag -credentialTag BasicCredential {} = BasicCredentialTag - --- | A TLS signature scheme. --- --- See . -newtype SignatureScheme = SignatureScheme {unSignatureScheme :: Word16} - deriving stock (Eq, Show) - deriving newtype (ParseMLS, Arbitrary) - -signatureScheme :: SignatureSchemeTag -> SignatureScheme -signatureScheme = SignatureScheme . signatureSchemeNumber - -data SignatureSchemeTag = Ed25519 - deriving stock (Bounded, Enum, Eq, Ord, Show, Generic) - deriving (Arbitrary) via GenericUniform SignatureSchemeTag - -instance Cql SignatureSchemeTag where - ctype = Tagged TextColumn - toCql = CqlText . signatureSchemeName - fromCql (CqlText name) = - note ("Unexpected signature scheme: " <> T.unpack name) $ - signatureSchemeFromName name - fromCql _ = Left "SignatureScheme: Text expected" - -signatureSchemeNumber :: SignatureSchemeTag -> Word16 -signatureSchemeNumber Ed25519 = 0x807 - -signatureSchemeName :: SignatureSchemeTag -> Text -signatureSchemeName Ed25519 = "ed25519" - -signatureSchemeTag :: SignatureScheme -> Maybe SignatureSchemeTag -signatureSchemeTag (SignatureScheme n) = getAlt $ - flip foldMap [minBound .. maxBound] $ \s -> - guard (signatureSchemeNumber s == n) $> s - -signatureSchemeFromName :: Text -> Maybe SignatureSchemeTag -signatureSchemeFromName name = getAlt $ - flip foldMap [minBound .. maxBound] $ \s -> - guard (signatureSchemeName s == name) $> s - -parseSignatureScheme :: MonadFail f => Text -> f SignatureSchemeTag -parseSignatureScheme name = - maybe - (fail ("Unsupported signature scheme " <> T.unpack name)) - pure - (signatureSchemeFromName name) - -instance FromJSON SignatureSchemeTag where - parseJSON = Aeson.withText "SignatureScheme" parseSignatureScheme - -instance FromJSONKey SignatureSchemeTag where - fromJSONKey = Aeson.FromJSONKeyTextParser parseSignatureScheme - -instance S.ToParamSchema SignatureSchemeTag where - toParamSchema _ = mempty & S.type_ ?~ S.SwaggerString - -instance FromHttpApiData SignatureSchemeTag where - parseQueryParam = note "Unknown signature scheme" . signatureSchemeFromName - -instance ToJSON SignatureSchemeTag where - toJSON = Aeson.String . signatureSchemeName - -instance ToJSONKey SignatureSchemeTag where - toJSONKey = Aeson.toJSONKeyText signatureSchemeName +credentialTag (BasicCredential _) = BasicCredentialTag +credentialTag (X509Credential _) = X509CredentialTag data ClientIdentity = ClientIdentity { ciDomain :: Domain, @@ -141,6 +89,7 @@ data ClientIdentity = ClientIdentity } deriving stock (Eq, Ord, Generic) deriving (FromJSON, ToJSON, S.ToSchema) via Schema ClientIdentity + deriving (Arbitrary) via (GenericUniform ClientIdentity) instance Show ClientIdentity where show (ClientIdentity dom u c) = @@ -164,6 +113,17 @@ instance ToSchema ClientIdentity where <*> ciUser .= field "user_id" schema <*> ciClient .= field "client_id" schema +instance S.ToParamSchema ClientIdentity where + toParamSchema _ = mempty & S.type_ ?~ S.OpenApiString + +instance FromHttpApiData ClientIdentity where + parseHeader = decodeMLS' + parseUrlPiece = decodeMLS' . T.encodeUtf8 + +instance ToHttpApiData ClientIdentity where + toHeader = encodeMLS' + toUrlPiece = T.decodeUtf8 . encodeMLS' + instance ParseMLS ClientIdentity where parseMLS = do uid <- @@ -175,6 +135,26 @@ instance ParseMLS ClientIdentity where either fail pure . (mkDomain . T.pack) =<< many' anyChar pure $ ClientIdentity dom uid cid +parseX509ClientIdentity :: Get ClientIdentity +parseX509ClientIdentity = do + b64uuid <- getByteString 22 + uidBytes <- either fail pure $ B64URL.decodeUnpadded b64uuid + uid <- maybe (fail "Invalid UUID") (pure . Id) $ fromByteString (L.fromStrict uidBytes) + char '/' + cid <- newClientId <$> hexadecimal + char '@' + dom <- + either fail pure . (mkDomain . T.pack) =<< many' anyChar + pure $ ClientIdentity dom uid cid + +instance SerialiseMLS ClientIdentity where + serialiseMLS cid = do + putByteString $ toASCIIBytes (toUUID (ciUser cid)) + putCharUtf8 ':' + putStringUtf8 $ T.unpack (client (ciClient cid)) + putCharUtf8 '@' + putStringUtf8 $ T.unpack (domainText (ciDomain cid)) + mkClientIdentity :: Qualified UserId -> ClientId -> ClientIdentity mkClientIdentity (Qualified uid domain) = ClientIdentity domain uid @@ -206,7 +186,7 @@ instance FromJSONKey SignaturePurpose where either fail pure . signaturePurposeFromName instance S.ToParamSchema SignaturePurpose where - toParamSchema _ = mempty & S.type_ ?~ S.SwaggerString + toParamSchema _ = mempty & S.type_ ?~ S.OpenApiString instance FromHttpApiData SignaturePurpose where parseQueryParam = first T.pack . signaturePurposeFromName diff --git a/libs/wire-api/src/Wire/API/MLS/Epoch.hs b/libs/wire-api/src/Wire/API/MLS/Epoch.hs index fb247712535..a12b65179f8 100644 --- a/libs/wire-api/src/Wire/API/MLS/Epoch.hs +++ b/libs/wire-api/src/Wire/API/MLS/Epoch.hs @@ -19,6 +19,7 @@ module Wire.API.MLS.Epoch where +import Data.Aeson qualified as A import Data.Binary import Data.Schema import Imports @@ -28,6 +29,7 @@ import Wire.Arbitrary newtype Epoch = Epoch {epochNumber :: Word64} deriving stock (Eq, Show) deriving newtype (Arbitrary, Enum, ToSchema) + deriving (A.FromJSON, A.ToJSON) via (Schema Epoch) instance ParseMLS Epoch where parseMLS = Epoch <$> parseMLS diff --git a/libs/wire-api/src/Wire/API/MLS/Extension.hs b/libs/wire-api/src/Wire/API/MLS/Extension.hs index 5093398adf9..eab027e7158 100644 --- a/libs/wire-api/src/Wire/API/MLS/Extension.hs +++ b/libs/wire-api/src/Wire/API/MLS/Extension.hs @@ -1,7 +1,3 @@ -{-# LANGUAGE GeneralizedNewtypeDeriving #-} -{-# LANGUAGE StandaloneKindSignatures #-} -{-# LANGUAGE TemplateHaskell #-} - -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -19,52 +15,14 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.API.MLS.Extension - ( -- * Extensions - Extension (..), - decodeExtension, - parseExtension, - ExtensionTag (..), - CapabilitiesExtensionTagSym0, - LifetimeExtensionTagSym0, - SExtensionTag (..), - SomeExtension (..), - Capabilities (..), - Lifetime (..), - - -- * Other types - Timestamp (..), - ProtocolVersion (..), - ProtocolVersionTag (..), - - -- * Utilities - pvTag, - tsPOSIX, - ) -where +module Wire.API.MLS.Extension where import Data.Binary -import Data.Kind -import Data.Singletons.TH -import Data.Time.Clock.POSIX import Imports -import Wire.API.MLS.CipherSuite import Wire.API.MLS.Serialisation import Wire.Arbitrary -newtype ProtocolVersion = ProtocolVersion {pvNumber :: Word8} - deriving newtype (Eq, Ord, Show, Binary, Arbitrary, ParseMLS, SerialiseMLS) - -data ProtocolVersionTag = ProtocolMLS10 | ProtocolMLSDraft11 - deriving stock (Bounded, Enum, Eq, Show, Generic) - deriving (Arbitrary) via GenericUniform ProtocolVersionTag - -pvTag :: ProtocolVersion -> Maybe ProtocolVersionTag -pvTag (ProtocolVersion v) = case v of - 1 -> pure ProtocolMLS10 - 200 -> pure ProtocolMLSDraft11 - _ -> Nothing - +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-7.2-2 data Extension = Extension { extType :: Word16, extData :: ByteString @@ -73,78 +31,9 @@ data Extension = Extension deriving (Arbitrary) via GenericUniform Extension instance ParseMLS Extension where - parseMLS = Extension <$> parseMLS <*> parseMLSBytes @Word32 + parseMLS = Extension <$> parseMLS <*> parseMLSBytes @VarInt instance SerialiseMLS Extension where serialiseMLS (Extension ty d) = do serialiseMLS ty - serialiseMLSBytes @Word32 d - -data ExtensionTag - = CapabilitiesExtensionTag - | LifetimeExtensionTag - deriving (Bounded, Enum) - -$(genSingletons [''ExtensionTag]) - -type family ExtensionType (t :: ExtensionTag) :: Type where - ExtensionType 'CapabilitiesExtensionTag = Capabilities - ExtensionType 'LifetimeExtensionTag = Lifetime - -parseExtension :: Sing t -> Get (ExtensionType t) -parseExtension SCapabilitiesExtensionTag = parseMLS -parseExtension SLifetimeExtensionTag = parseMLS - -data SomeExtension where - SomeExtension :: Sing t -> ExtensionType t -> SomeExtension - -instance Eq SomeExtension where - SomeExtension SCapabilitiesExtensionTag caps1 == SomeExtension SCapabilitiesExtensionTag caps2 = caps1 == caps2 - SomeExtension SLifetimeExtensionTag lt1 == SomeExtension SLifetimeExtensionTag lt2 = lt1 == lt2 - _ == _ = False - -instance Show SomeExtension where - show (SomeExtension SCapabilitiesExtensionTag caps) = show caps - show (SomeExtension SLifetimeExtensionTag lt) = show lt - -decodeExtension :: Extension -> Either Text (Maybe SomeExtension) -decodeExtension e = do - case toMLSEnum' (extType e) of - Left MLSEnumUnknown -> pure Nothing - Left MLSEnumInvalid -> Left "Invalid extension type" - Right t -> withSomeSing t $ \st -> - Just <$> decodeMLSWith' (SomeExtension st <$> parseExtension st) (extData e) - -data Capabilities = Capabilities - { capVersions :: [ProtocolVersion], - capCiphersuites :: [CipherSuite], - capExtensions :: [Word16], - capProposals :: [Word16] - } - deriving stock (Eq, Show, Generic) - deriving (Arbitrary) via (GenericUniform Capabilities) - -instance ParseMLS Capabilities where - parseMLS = - Capabilities - <$> parseMLSVector @Word8 parseMLS - <*> parseMLSVector @Word8 parseMLS - <*> parseMLSVector @Word8 parseMLS - <*> parseMLSVector @Word8 parseMLS - --- | Seconds since the UNIX epoch. -newtype Timestamp = Timestamp {timestampSeconds :: Word64} - deriving newtype (Eq, Show, Arbitrary, ParseMLS) - -tsPOSIX :: Timestamp -> POSIXTime -tsPOSIX = fromIntegral . timestampSeconds - -data Lifetime = Lifetime - { ltNotBefore :: Timestamp, - ltNotAfter :: Timestamp - } - deriving stock (Eq, Show, Generic) - deriving (Arbitrary) via GenericUniform Lifetime - -instance ParseMLS Lifetime where - parseMLS = Lifetime <$> parseMLS <*> parseMLS + serialiseMLSBytes @VarInt d diff --git a/libs/wire-api/src/Wire/API/MLS/Group.hs b/libs/wire-api/src/Wire/API/MLS/Group.hs index fbbefd015e1..afe0c3049ae 100644 --- a/libs/wire-api/src/Wire/API/MLS/Group.hs +++ b/libs/wire-api/src/Wire/API/MLS/Group.hs @@ -17,32 +17,29 @@ module Wire.API.MLS.Group where -import Crypto.Hash qualified as Crypto import Data.Aeson qualified as A -import Data.ByteArray (convert) -import Data.ByteString.Conversion -import Data.Id import Data.Json.Util -import Data.Qualified +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports +import Servant import Wire.API.MLS.Serialisation import Wire.Arbitrary newtype GroupId = GroupId {unGroupId :: ByteString} - deriving (Eq, Show, Generic) + deriving (Eq, Show, Generic, Ord) deriving (Arbitrary) via (GenericUniform GroupId) + deriving (FromHttpApiData, ToHttpApiData, S.ToParamSchema) via Base64ByteString deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema GroupId) instance IsString GroupId where fromString = GroupId . fromString instance ParseMLS GroupId where - parseMLS = GroupId <$> parseMLSBytes @Word8 + parseMLS = GroupId <$> parseMLSBytes @VarInt instance SerialiseMLS GroupId where - serialiseMLS (GroupId gid) = serialiseMLSBytes @Word8 gid + serialiseMLS (GroupId gid) = serialiseMLSBytes @VarInt gid instance ToSchema GroupId where schema = @@ -50,9 +47,6 @@ instance ToSchema GroupId where <$> unGroupId .= named "GroupId" (Base64ByteString .= fmap fromBase64ByteString (unnamed schema)) --- | Return the group ID associated to a conversation ID. Note that is not --- assumed to be stable over time or even consistent among different backends. -convToGroupId :: Local ConvId -> GroupId -convToGroupId (tUntagged -> qcnv) = - GroupId . convert . Crypto.hash @ByteString @Crypto.SHA256 $ - toByteString' (qUnqualified qcnv) <> toByteString' (qDomain qcnv) +newtype GroupIdGen = GroupIdGen {unGroupIdGen :: Word32} + deriving (Eq, Show, Generic, Ord) + deriving (Arbitrary) via (GenericUniform GroupIdGen) diff --git a/libs/wire-api/src/Wire/API/MLS/Group/Serialisation.hs b/libs/wire-api/src/Wire/API/MLS/Group/Serialisation.hs new file mode 100644 index 00000000000..9a52f1a0879 --- /dev/null +++ b/libs/wire-api/src/Wire/API/MLS/Group/Serialisation.hs @@ -0,0 +1,105 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.MLS.Group.Serialisation + ( GroupIdParts (..), + groupIdParts, + convToGroupId, + groupIdToConv, + nextGenGroupId, + ) +where + +import Data.Bifunctor +import Data.Binary.Get +import Data.Binary.Put +import Data.ByteString.Conversion +import Data.ByteString.Lazy qualified as L +import Data.Domain +import Data.Id +import Data.Qualified +import Data.Text qualified as T +import Data.Text.Encoding qualified as T +import Data.UUID qualified as UUID +import Imports hiding (cs) +import Web.HttpApiData (FromHttpApiData (parseHeader)) +import Wire.API.Conversation +import Wire.API.MLS.Group +import Wire.API.MLS.SubConversation + +data GroupIdParts = GroupIdParts + { convType :: ConvType, + qConvId :: Qualified ConvOrSubConvId, + gidGen :: GroupIdGen + } + deriving (Show, Eq) + +groupIdParts :: ConvType -> Qualified ConvOrSubConvId -> GroupIdParts +groupIdParts ct qcs = + GroupIdParts + { convType = ct, + qConvId = qcs, + gidGen = GroupIdGen 0 + } + +-- | Return the group ID associated to a conversation ID. Note that is not +-- assumed to be stable over time or even consistent among different backends. +convToGroupId :: GroupIdParts -> GroupId +convToGroupId parts = GroupId . L.toStrict . runPut $ do + let cs = qUnqualified parts.qConvId + subId = foldMap unSubConvId cs.subconv + putWord16be 1 -- Version 1 of the GroupId format + putWord16be (fromIntegral $ fromEnum parts.convType) + putLazyByteString . UUID.toByteString . toUUID $ cs.conv + putWord8 $ fromIntegral (T.length subId) + putByteString $ T.encodeUtf8 subId + maybe (pure ()) (const $ putWord32be (unGroupIdGen parts.gidGen)) cs.subconv + putLazyByteString . toByteString $ qDomain parts.qConvId + +groupIdToConv :: GroupId -> Either String GroupIdParts +groupIdToConv gid = do + (rem', _, (ct, conv, gen)) <- first (\(_, _, msg) -> msg) $ runGetOrFail readConv (L.fromStrict (unGroupId gid)) + domain <- first displayException . T.decodeUtf8' . L.toStrict $ rem' + pure + GroupIdParts + { convType = toEnum $ fromIntegral ct, + qConvId = Qualified conv (Domain domain), + gidGen = gen + } + where + readConv = do + version <- getWord16be + ct <- getWord16be + unless (version == 1) $ fail "unsupported groupId version" + mUUID <- UUID.fromByteString . L.fromStrict <$> getByteString 16 + uuid <- maybe (fail "invalid conversation UUID in groupId") pure mUUID + n <- getWord8 + if n == 0 + then pure $ (ct, Conv (Id uuid), GroupIdGen 0) + else do + subConvIdBS <- getByteString $ fromIntegral n + subConvId <- either (fail . T.unpack) pure $ parseHeader subConvIdBS + gen <- getWord32be + pure $ (ct, SubConv (Id uuid) (SubConvId subConvId), GroupIdGen gen) + +nextGenGroupId :: GroupId -> Either String GroupId +nextGenGroupId gid = convToGroupId . succGen <$> groupIdToConv gid + where + succGen parts = + parts + { gidGen = GroupIdGen (succ $ unGroupIdGen parts.gidGen) + } diff --git a/libs/wire-api/src/Wire/API/MLS/GroupInfo.hs b/libs/wire-api/src/Wire/API/MLS/GroupInfo.hs new file mode 100644 index 00000000000..1865918c2d7 --- /dev/null +++ b/libs/wire-api/src/Wire/API/MLS/GroupInfo.hs @@ -0,0 +1,140 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.MLS.GroupInfo + ( GroupContext (..), + GroupInfo (..), + GroupInfoData (..), + ) +where + +import Data.Binary.Get +import Data.Binary.Put +import Data.ByteString.Lazy qualified as LBS +import Data.OpenApi qualified as S +import GHC.Records +import Imports +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.Epoch +import Wire.API.MLS.Extension +import Wire.API.MLS.Group +import Wire.API.MLS.ProtocolVersion +import Wire.API.MLS.Serialisation +import Wire.Arbitrary + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-8.1-2 +data GroupContext = GroupContext + { protocolVersion :: ProtocolVersion, + cipherSuite :: CipherSuite, + groupId :: GroupId, + epoch :: Epoch, + treeHash :: ByteString, + confirmedTranscriptHash :: ByteString, + extensions :: [Extension] + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform GroupContext) + +instance ParseMLS GroupContext where + parseMLS = + GroupContext + <$> parseMLS + <*> parseMLS + <*> parseMLS + <*> parseMLS + <*> parseMLSBytes @VarInt + <*> parseMLSBytes @VarInt + <*> parseMLSVector @VarInt parseMLS + +instance SerialiseMLS GroupContext where + serialiseMLS gc = do + serialiseMLS gc.protocolVersion + serialiseMLS gc.cipherSuite + serialiseMLS gc.groupId + serialiseMLS gc.epoch + serialiseMLSBytes @VarInt gc.treeHash + serialiseMLSBytes @VarInt gc.confirmedTranscriptHash + serialiseMLSVector @VarInt serialiseMLS gc.extensions + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-12.4.3-7 +data GroupInfoTBS = GroupInfoTBS + { groupContext :: GroupContext, + extensions :: [Extension], + confirmationTag :: ByteString, + signer :: Word32 + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform GroupInfoTBS) + +instance ParseMLS GroupInfoTBS where + parseMLS = + GroupInfoTBS + <$> parseMLS + <*> parseMLSVector @VarInt parseMLS + <*> parseMLSBytes @VarInt + <*> parseMLS + +instance SerialiseMLS GroupInfoTBS where + serialiseMLS tbs = do + serialiseMLS tbs.groupContext + serialiseMLSVector @VarInt serialiseMLS tbs.extensions + serialiseMLSBytes @VarInt tbs.confirmationTag + serialiseMLS tbs.signer + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-12.4.3-2 +data GroupInfo = GroupInfo + { tbs :: GroupInfoTBS, + signature_ :: ByteString + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform GroupInfo) + +instance ParseMLS GroupInfo where + parseMLS = + GroupInfo + <$> parseMLS + <*> parseMLSBytes @VarInt + +instance SerialiseMLS GroupInfo where + serialiseMLS gi = do + serialiseMLS gi.tbs + serialiseMLSBytes @VarInt gi.signature_ + +instance HasField "groupContext" GroupInfo GroupContext where + getField = (.tbs.groupContext) + +instance HasField "extensions" GroupInfo [Extension] where + getField = (.tbs.extensions) + +instance HasField "confirmationTag" GroupInfo ByteString where + getField = (.tbs.confirmationTag) + +instance HasField "signer" GroupInfo Word32 where + getField = (.tbs.signer) + +newtype GroupInfoData = GroupInfoData {unGroupInfoData :: ByteString} + deriving stock (Eq, Ord, Show) + deriving newtype (Arbitrary) + +instance ParseMLS GroupInfoData where + parseMLS = GroupInfoData . LBS.toStrict <$> getRemainingLazyByteString + +instance SerialiseMLS GroupInfoData where + serialiseMLS (GroupInfoData bs) = putByteString bs + +instance S.ToSchema GroupInfoData where + declareNamedSchema _ = pure (mlsSwagger "GroupInfoData") diff --git a/libs/wire-api/src/Wire/API/MLS/GroupInfoBundle.hs b/libs/wire-api/src/Wire/API/MLS/GroupInfoBundle.hs deleted file mode 100644 index 7b937fa64b3..00000000000 --- a/libs/wire-api/src/Wire/API/MLS/GroupInfoBundle.hs +++ /dev/null @@ -1,98 +0,0 @@ --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Wire.API.MLS.GroupInfoBundle where - -import Control.Lens (view, (.~)) -import Data.ProtoLens (Message (defMessage)) -import Imports -import Proto.Mls qualified -import Proto.Mls_Fields qualified as Proto.Mls -import Test.QuickCheck -import Wire.API.ConverProtoLens -import Wire.API.MLS.PublicGroupState -import Wire.API.MLS.Serialisation -import Wire.Arbitrary - -data GroupInfoType = GroupInfoTypePublicGroupState | UnencryptedGroupInfo | JweEncryptedGroupInfo - deriving stock (Eq, Show, Generic, Enum, Bounded) - deriving (Arbitrary) via (GenericUniform GroupInfoType) - -instance ConvertProtoLens Proto.Mls.GroupInfoType GroupInfoType where - fromProtolens Proto.Mls.PUBLIC_GROUP_STATE = pure GroupInfoTypePublicGroupState - fromProtolens Proto.Mls.GROUP_INFO = pure UnencryptedGroupInfo - fromProtolens Proto.Mls.GROUP_INFO_JWE = pure JweEncryptedGroupInfo - - toProtolens GroupInfoTypePublicGroupState = Proto.Mls.PUBLIC_GROUP_STATE - toProtolens UnencryptedGroupInfo = Proto.Mls.GROUP_INFO - toProtolens JweEncryptedGroupInfo = Proto.Mls.GROUP_INFO_JWE - -data RatchetTreeType = TreeFull | TreeDelta | TreeByRef - deriving stock (Eq, Show, Generic, Bounded, Enum) - deriving (Arbitrary) via (GenericUniform RatchetTreeType) - -instance ConvertProtoLens Proto.Mls.RatchetTreeType RatchetTreeType where - fromProtolens Proto.Mls.FULL = pure TreeFull - fromProtolens Proto.Mls.DELTA = pure TreeDelta - fromProtolens Proto.Mls.REFERENCE = pure TreeByRef - - toProtolens TreeFull = Proto.Mls.FULL - toProtolens TreeDelta = Proto.Mls.DELTA - toProtolens TreeByRef = Proto.Mls.REFERENCE - -data GroupInfoBundle = GroupInfoBundle - { gipGroupInfoType :: GroupInfoType, - gipRatchetTreeType :: RatchetTreeType, - gipGroupState :: RawMLS PublicGroupState - } - deriving stock (Eq, Show, Generic) - -instance ConvertProtoLens Proto.Mls.GroupInfoBundle GroupInfoBundle where - fromProtolens protoBundle = - protoLabel "GroupInfoBundle" $ - GroupInfoBundle - <$> protoLabel "field group_info_type" (fromProtolens (view Proto.Mls.groupInfoType protoBundle)) - <*> protoLabel "field ratchet_tree_type" (fromProtolens (view Proto.Mls.ratchetTreeType protoBundle)) - <*> protoLabel "field group_info" (decodeMLS' (view Proto.Mls.groupInfo protoBundle)) - toProtolens bundle = - let encryptionType = toProtolens (gipGroupInfoType bundle) - treeType = toProtolens (gipRatchetTreeType bundle) - in ( defMessage - & Proto.Mls.groupInfoType .~ encryptionType - & Proto.Mls.ratchetTreeType .~ treeType - & Proto.Mls.groupInfo .~ rmRaw (gipGroupState bundle) - ) - -instance Arbitrary GroupInfoBundle where - arbitrary = - GroupInfoBundle - <$> arbitrary - <*> arbitrary - <*> (mkRawMLS <$> arbitrary) - -instance ParseMLS GroupInfoBundle where - parseMLS = - GroupInfoBundle - <$> parseMLSEnum @Word8 "GroupInfoTypeEnum" - <*> parseMLSEnum @Word8 "RatchetTreeEnum" - <*> parseMLS - -instance SerialiseMLS GroupInfoBundle where - serialiseMLS (GroupInfoBundle e t pgs) = do - serialiseMLSEnum @Word8 e - serialiseMLSEnum @Word8 t - serialiseMLS pgs diff --git a/services/galley/src/Galley/API/MLS/KeyPackage.hs b/libs/wire-api/src/Wire/API/MLS/HPKEPublicKey.hs similarity index 58% rename from services/galley/src/Galley/API/MLS/KeyPackage.hs rename to libs/wire-api/src/Wire/API/MLS/HPKEPublicKey.hs index 50ad0b8137d..3d0d947f083 100644 --- a/services/galley/src/Galley/API/MLS/KeyPackage.hs +++ b/libs/wire-api/src/Wire/API/MLS/HPKEPublicKey.hs @@ -1,3 +1,5 @@ +{-# LANGUAGE GeneralizedNewtypeDeriving #-} + -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -15,24 +17,18 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Galley.API.MLS.KeyPackage where +module Wire.API.MLS.HPKEPublicKey where -import Data.ByteString qualified as BS -import Galley.Effects.BrigAccess import Imports -import Polysemy -import Wire.API.Error -import Wire.API.Error.Galley -import Wire.API.MLS.Credential -import Wire.API.MLS.KeyPackage +import Test.QuickCheck +import Wire.API.MLS.Serialisation + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-5.1.1-2 +newtype HPKEPublicKey = HPKEPublicKey {unHPKEPublicKey :: ByteString} + deriving (Show, Eq, Arbitrary) -nullKeyPackageRef :: KeyPackageRef -nullKeyPackageRef = KeyPackageRef (BS.replicate 16 0) +instance ParseMLS HPKEPublicKey where + parseMLS = HPKEPublicKey <$> parseMLSBytes @VarInt -derefKeyPackage :: - ( Member BrigAccess r, - Member (ErrorS 'MLSKeyPackageRefNotFound) r - ) => - KeyPackageRef -> - Sem r ClientIdentity -derefKeyPackage = noteS @'MLSKeyPackageRefNotFound <=< getClientByKeyPackageRef +instance SerialiseMLS HPKEPublicKey where + serialiseMLS = serialiseMLSBytes @VarInt . unHPKEPublicKey diff --git a/libs/wire-api/src/Wire/API/MLS/KeyPackage.hs b/libs/wire-api/src/Wire/API/MLS/KeyPackage.hs index 51a7267450f..1402ff17b9a 100644 --- a/libs/wire-api/src/Wire/API/MLS/KeyPackage.hs +++ b/libs/wire-api/src/Wire/API/MLS/KeyPackage.hs @@ -1,5 +1,3 @@ -{-# LANGUAGE GeneralizedNewtypeDeriving #-} - -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -23,18 +21,14 @@ module Wire.API.MLS.KeyPackage KeyPackageBundleEntry (..), KeyPackageCount (..), KeyPackageData (..), + DeleteKeyPackages (..), KeyPackage (..), - kpProtocolVersion, - kpCipherSuite, - kpInitKey, - kpCredential, - kpExtensions, - kpIdentity, + credentialIdentityAndKey, + keyPackageIdentity, kpRef, kpRef', KeyPackageTBS (..), KeyPackageRef (..), - KeyPackageUpdate (..), ) where @@ -42,16 +36,18 @@ import Cassandra.CQL hiding (Set) import Control.Applicative import Control.Lens hiding (set, (.=)) import Data.Aeson (FromJSON, ToJSON) -import Data.Binary -import Data.Binary.Get -import Data.Binary.Put -import Data.ByteString qualified as B +import Data.Bifunctor import Data.ByteString.Lazy qualified as LBS import Data.Id import Data.Json.Util +import Data.OpenApi qualified as S import Data.Qualified -import Data.Schema -import Data.Swagger qualified as S +import Data.Range +import Data.Schema hiding (HasField) +import Data.Text qualified as T +import Data.Text.Encoding qualified as T +import Data.X509 qualified as X509 +import GHC.Records import Imports hiding (cs) import Test.QuickCheck import Web.HttpApiData @@ -59,18 +55,21 @@ import Wire.API.MLS.CipherSuite import Wire.API.MLS.Context import Wire.API.MLS.Credential import Wire.API.MLS.Extension +import Wire.API.MLS.HPKEPublicKey +import Wire.API.MLS.LeafNode +import Wire.API.MLS.ProtocolVersion import Wire.API.MLS.Serialisation import Wire.Arbitrary data KeyPackageUpload = KeyPackageUpload - {kpuKeyPackages :: [RawMLS KeyPackage]} + {keyPackages :: [RawMLS KeyPackage]} deriving (FromJSON, ToJSON, S.ToSchema) via Schema KeyPackageUpload instance ToSchema KeyPackageUpload where schema = object "KeyPackageUpload" $ KeyPackageUpload - <$> kpuKeyPackages .= field "key_packages" (array rawKeyPackageSchema) + <$> keyPackages .= field "key_packages" (array rawKeyPackageSchema) newtype KeyPackageData = KeyPackageData {kpData :: ByteString} deriving stock (Eq, Ord, Show) @@ -90,10 +89,10 @@ instance Cql KeyPackageData where fromCql _ = Left "Expected CqlBlob" data KeyPackageBundleEntry = KeyPackageBundleEntry - { kpbeUser :: Qualified UserId, - kpbeClient :: ClientId, - kpbeRef :: KeyPackageRef, - kpbeKeyPackage :: KeyPackageData + { user :: Qualified UserId, + client :: ClientId, + ref :: KeyPackageRef, + keyPackage :: KeyPackageData } deriving stock (Eq, Ord, Show) @@ -101,12 +100,12 @@ instance ToSchema KeyPackageBundleEntry where schema = object "KeyPackageBundleEntry" $ KeyPackageBundleEntry - <$> kpbeUser .= qualifiedObjectSchema "user" schema - <*> kpbeClient .= field "client" schema - <*> kpbeRef .= field "key_package_ref" schema - <*> kpbeKeyPackage .= field "key_package" schema + <$> (.user) .= qualifiedObjectSchema "user" schema + <*> (.client) .= field "client" schema + <*> (.ref) .= field "key_package_ref" schema + <*> (.keyPackage) .= field "key_package" schema -newtype KeyPackageBundle = KeyPackageBundle {kpbEntries :: Set KeyPackageBundleEntry} +newtype KeyPackageBundle = KeyPackageBundle {entries :: Set KeyPackageBundleEntry} deriving stock (Eq, Show) deriving (FromJSON, ToJSON, S.ToSchema) via Schema KeyPackageBundle @@ -114,7 +113,7 @@ instance ToSchema KeyPackageBundle where schema = object "KeyPackageBundle" $ KeyPackageBundle - <$> kpbEntries .= field "key_packages" (set schema) + <$> (.entries) .= field "key_packages" (set schema) newtype KeyPackageCount = KeyPackageCount {unKeyPackageCount :: Int} deriving newtype (Eq, Ord, Num, Show) @@ -125,22 +124,34 @@ instance ToSchema KeyPackageCount where object "OwnKeyPackages" $ KeyPackageCount <$> unKeyPackageCount .= field "count" schema +newtype DeleteKeyPackages = DeleteKeyPackages + {unDeleteKeyPackages :: [KeyPackageRef]} + deriving newtype (Eq, Ord, Show) + deriving (FromJSON, ToJSON, S.ToSchema) via Schema DeleteKeyPackages + +instance ToSchema DeleteKeyPackages where + schema = + object "DeleteKeyPackages" $ + DeleteKeyPackages + <$> unDeleteKeyPackages + .= field + "key_packages" + (untypedRangedSchema 1 1000 (array schema)) + newtype KeyPackageRef = KeyPackageRef {unKeyPackageRef :: ByteString} deriving stock (Eq, Ord, Show) deriving (FromHttpApiData, ToHttpApiData, S.ToParamSchema) via Base64ByteString deriving (ToJSON, FromJSON, S.ToSchema) via (Schema KeyPackageRef) - -instance Arbitrary KeyPackageRef where - arbitrary = KeyPackageRef . B.pack <$> vectorOf 16 arbitrary + deriving newtype (Arbitrary) instance ToSchema KeyPackageRef where schema = named "KeyPackageRef" $ unKeyPackageRef .= fmap KeyPackageRef base64Schema instance ParseMLS KeyPackageRef where - parseMLS = KeyPackageRef <$> getByteString 16 + parseMLS = KeyPackageRef <$> parseMLSBytes @VarInt instance SerialiseMLS KeyPackageRef where - serialiseMLS = putByteString . unKeyPackageRef + serialiseMLS = serialiseMLSBytes @VarInt . unKeyPackageRef instance Cql KeyPackageRef where ctype = Tagged BlobColumn @@ -153,6 +164,7 @@ kpRef :: CipherSuiteTag -> KeyPackageData -> KeyPackageRef kpRef cs = KeyPackageRef . csHash cs keyPackageContext + . flip RawMLS () . kpData -- | Compute ref of a key package. Return 'Nothing' if the key package cipher @@ -160,17 +172,18 @@ kpRef cs = kpRef' :: RawMLS KeyPackage -> Maybe KeyPackageRef kpRef' kp = kpRef - <$> cipherSuiteTag (kpCipherSuite (rmValue kp)) - <*> pure (KeyPackageData (rmRaw kp)) + <$> cipherSuiteTag (kp.value.cipherSuite) + <*> pure (KeyPackageData (raw kp)) -------------------------------------------------------------------------------- +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-10-6 data KeyPackageTBS = KeyPackageTBS - { kpuProtocolVersion :: ProtocolVersion, - kpuCipherSuite :: CipherSuite, - kpuInitKey :: ByteString, - kpuCredential :: Credential, - kpuExtensions :: [Extension] + { protocolVersion :: ProtocolVersion, + cipherSuite :: CipherSuite, + initKey :: HPKEPublicKey, + leafNode :: LeafNode, + extensions :: [Extension] } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via GenericUniform KeyPackageTBS @@ -180,36 +193,81 @@ instance ParseMLS KeyPackageTBS where KeyPackageTBS <$> parseMLS <*> parseMLS - <*> parseMLSBytes @Word16 <*> parseMLS - <*> parseMLSVector @Word32 parseMLS + <*> parseMLS + <*> parseMLSVector @VarInt parseMLS + +instance SerialiseMLS KeyPackageTBS where + serialiseMLS tbs = do + serialiseMLS tbs.protocolVersion + serialiseMLS tbs.cipherSuite + serialiseMLS tbs.initKey + serialiseMLS tbs.leafNode + serialiseMLSVector @VarInt serialiseMLS tbs.extensions +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-10-6 data KeyPackage = KeyPackage - { kpTBS :: RawMLS KeyPackageTBS, - kpSignature :: ByteString + { tbs :: RawMLS KeyPackageTBS, + signature_ :: ByteString } - deriving stock (Eq, Show) + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform KeyPackage) instance S.ToSchema KeyPackage where declareNamedSchema _ = pure (mlsSwagger "KeyPackage") -kpProtocolVersion :: KeyPackage -> ProtocolVersion -kpProtocolVersion = kpuProtocolVersion . rmValue . kpTBS - -kpCipherSuite :: KeyPackage -> CipherSuite -kpCipherSuite = kpuCipherSuite . rmValue . kpTBS - -kpInitKey :: KeyPackage -> ByteString -kpInitKey = kpuInitKey . rmValue . kpTBS - -kpCredential :: KeyPackage -> Credential -kpCredential = kpuCredential . rmValue . kpTBS - -kpExtensions :: KeyPackage -> [Extension] -kpExtensions = kpuExtensions . rmValue . kpTBS - -kpIdentity :: KeyPackage -> Either Text ClientIdentity -kpIdentity = decodeMLS' @ClientIdentity . bcIdentity . kpCredential +instance HasField "protocolVersion" KeyPackage ProtocolVersion where + getField = (.tbs.value.protocolVersion) + +instance HasField "cipherSuite" KeyPackage CipherSuite where + getField = (.tbs.value.cipherSuite) + +instance HasField "initKey" KeyPackage HPKEPublicKey where + getField = (.tbs.value.initKey) + +instance HasField "extensions" KeyPackage [Extension] where + getField = (.tbs.value.extensions) + +instance HasField "leafNode" KeyPackage LeafNode where + getField = (.tbs.value.leafNode) + +credentialIdentityAndKey :: Credential -> Either Text (ClientIdentity, Maybe X509.PubKey) +credentialIdentityAndKey (BasicCredential i) = (,) <$> decodeMLS' i <*> pure Nothing +credentialIdentityAndKey (X509Credential certs) = do + bs <- case certs of + [] -> Left "Invalid x509 certificate chain" + (c : _) -> pure c + signed <- + first (\e -> "Failed to decode x509 certificate: " <> T.pack e) $ + X509.decodeSignedCertificate bs + -- FUTUREWORK: verify signature + let cert = X509.getCertificate signed + certificateIdentityAndKey cert + +keyPackageIdentity :: KeyPackage -> Either Text ClientIdentity +keyPackageIdentity kp = fst <$> credentialIdentityAndKey kp.leafNode.credential + +certificateIdentityAndKey :: X509.Certificate -> Either Text (ClientIdentity, Maybe X509.PubKey) +certificateIdentityAndKey cert = + let getNames (X509.ExtSubjectAltName names) = names + getURI (X509.AltNameURI u) = Just u + getURI _ = Nothing + altNames = maybe [] getNames (X509.extensionGet (X509.certExtensions cert)) + ids = map sanIdentity (mapMaybe getURI altNames) + in case partitionEithers ids of + (_, (cid : _)) -> pure (cid, Just (X509.certPubKey cert)) + ((e : _), []) -> Left e + _ -> Left "No SAN URIs found" + +sanIdentity :: String -> Either Text ClientIdentity +sanIdentity s = case break (== '=') s of + ("im:wireapp", '=' : s') -> + first (\e -> e <> " (while parsing identity string " <> T.pack (show s') <> ")") + . decodeMLSWith' parseX509ClientIdentity + . T.encodeUtf8 + . T.pack + $ s' + _ -> Left "No im:wireapp label found" rawKeyPackageSchema :: ValueSchema NamedSwaggerDoc (RawMLS KeyPackage) rawKeyPackageSchema = @@ -223,11 +281,9 @@ instance ParseMLS KeyPackage where parseMLS = KeyPackage <$> parseRawMLS parseMLS - <*> parseMLSBytes @Word16 - --------------------------------------------------------------------------------- + <*> parseMLSBytes @VarInt -data KeyPackageUpdate = KeyPackageUpdate - { kpupPrevious :: KeyPackageRef, - kpupNext :: KeyPackageRef - } +instance SerialiseMLS KeyPackage where + serialiseMLS kp = do + serialiseMLS kp.tbs + serialiseMLSBytes @VarInt kp.signature_ diff --git a/libs/wire-api/src/Wire/API/MLS/Keys.hs b/libs/wire-api/src/Wire/API/MLS/Keys.hs index 3bb54a9be20..179ec9909cd 100644 --- a/libs/wire-api/src/Wire/API/MLS/Keys.hs +++ b/libs/wire-api/src/Wire/API/MLS/Keys.hs @@ -29,9 +29,10 @@ import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.ByteArray import Data.Json.Util import Data.Map qualified as Map +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports +import Wire.API.MLS.CipherSuite import Wire.API.MLS.Credential data MLSKeys = MLSKeys diff --git a/libs/wire-api/src/Wire/API/MLS/LeafNode.hs b/libs/wire-api/src/Wire/API/MLS/LeafNode.hs new file mode 100644 index 00000000000..e7ebab85580 --- /dev/null +++ b/libs/wire-api/src/Wire/API/MLS/LeafNode.hs @@ -0,0 +1,201 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.MLS.LeafNode + ( LeafIndex, + LeafNode (..), + LeafNodeCore (..), + LeafNodeTBS (..), + LeafNodeTBSExtra (..), + LeafNodeSource (..), + LeafNodeSourceTag (..), + leafNodeSourceTag, + ) +where + +import Data.Binary +import Data.OpenApi qualified as S +import GHC.Records +import Imports +import Test.QuickCheck +import Wire.API.MLS.Capabilities +import Wire.API.MLS.Credential +import Wire.API.MLS.Extension +import Wire.API.MLS.Group +import Wire.API.MLS.HPKEPublicKey +import Wire.API.MLS.Lifetime +import Wire.API.MLS.Serialisation +import Wire.Arbitrary + +type LeafIndex = Word32 + +-- LeafNodeCore contains fields in the intersection of LeafNode and LeafNodeTBS +-- +-- https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-7.2-2 +data LeafNodeCore = LeafNodeCore + { encryptionKey :: HPKEPublicKey, + signatureKey :: ByteString, + credential :: Credential, + capabilities :: Capabilities, + source :: LeafNodeSource, + extensions :: [Extension] + } + deriving (Show, Eq, Generic) + deriving (Arbitrary) via (GenericUniform LeafNodeCore) + +-- extra fields in LeafNodeTBS, but not in LeafNode +-- +-- https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-7.2-2 +data LeafNodeTBSExtra + = LeafNodeTBSExtraKeyPackage + | LeafNodeTBSExtraUpdate GroupId LeafIndex + | LeafNodeTBSExtraCommit GroupId LeafIndex + +serialiseUntaggedLeafNodeTBSExtra :: LeafNodeTBSExtra -> Put +serialiseUntaggedLeafNodeTBSExtra LeafNodeTBSExtraKeyPackage = pure () +serialiseUntaggedLeafNodeTBSExtra (LeafNodeTBSExtraUpdate gid idx) = do + serialiseMLS gid + serialiseMLS idx +serialiseUntaggedLeafNodeTBSExtra (LeafNodeTBSExtraCommit gid idx) = do + serialiseMLS gid + serialiseMLS idx + +instance HasField "tag" LeafNodeTBSExtra LeafNodeSourceTag where + getField = \case + LeafNodeTBSExtraKeyPackage -> LeafNodeSourceKeyPackageTag + LeafNodeTBSExtraCommit _ _ -> LeafNodeSourceCommitTag + LeafNodeTBSExtraUpdate _ _ -> LeafNodeSourceUpdateTag + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-7.2-2 +data LeafNodeTBS = LeafNodeTBS + { core :: RawMLS LeafNodeCore, + extra :: LeafNodeTBSExtra + } + +instance SerialiseMLS LeafNodeTBS where + serialiseMLS tbs = do + serialiseMLS tbs.core + serialiseUntaggedLeafNodeTBSExtra tbs.extra + +instance ParseMLS LeafNodeCore where + parseMLS = + LeafNodeCore + <$> parseMLS + <*> parseMLSBytes @VarInt + <*> parseMLS + <*> parseMLS + <*> parseMLS + <*> parseMLSVector @VarInt parseMLS + +instance SerialiseMLS LeafNodeCore where + serialiseMLS core = do + serialiseMLS core.encryptionKey + serialiseMLSBytes @VarInt core.signatureKey + serialiseMLS core.credential + serialiseMLS core.capabilities + serialiseMLS core.source + serialiseMLSVector @VarInt serialiseMLS core.extensions + +-- | This type can only verify the signature when the LeafNodeSource is +-- LeafNodeSourceKeyPackage +-- +-- https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-7.2-2 +data LeafNode = LeafNode + { core :: RawMLS LeafNodeCore, + signature_ :: ByteString + } + deriving (Show, Eq, Generic) + deriving (Arbitrary) via (GenericUniform LeafNode) + +instance ParseMLS LeafNode where + parseMLS = + LeafNode + <$> parseMLS + <*> parseMLSBytes @VarInt + +instance SerialiseMLS LeafNode where + serialiseMLS ln = do + serialiseMLS ln.core + serialiseMLSBytes @VarInt ln.signature_ + +instance S.ToSchema LeafNode where + declareNamedSchema _ = pure (mlsSwagger "LeafNode") + +instance HasField "encryptionKey" LeafNode HPKEPublicKey where + getField = (.core.value.encryptionKey) + +instance HasField "signatureKey" LeafNode ByteString where + getField = (.core.value.signatureKey) + +instance HasField "credential" LeafNode Credential where + getField = (.core.value.credential) + +instance HasField "capabilities" LeafNode Capabilities where + getField = (.core.value.capabilities) + +instance HasField "source" LeafNode LeafNodeSource where + getField = (.core.value.source) + +instance HasField "extensions" LeafNode [Extension] where + getField = (.core.value.extensions) + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-7.2-2 +data LeafNodeSource + = LeafNodeSourceKeyPackage Lifetime + | LeafNodeSourceUpdate + | LeafNodeSourceCommit ByteString + deriving (Show, Eq, Generic) + deriving (Arbitrary) via (GenericUniform LeafNodeSource) + +instance ParseMLS LeafNodeSource where + parseMLS = + parseMLS >>= \case + LeafNodeSourceKeyPackageTag -> LeafNodeSourceKeyPackage <$> parseMLS + LeafNodeSourceUpdateTag -> pure LeafNodeSourceUpdate + LeafNodeSourceCommitTag -> LeafNodeSourceCommit <$> parseMLSBytes @VarInt + +instance SerialiseMLS LeafNodeSource where + serialiseMLS (LeafNodeSourceKeyPackage lt) = do + serialiseMLS LeafNodeSourceKeyPackageTag + serialiseMLS lt + serialiseMLS LeafNodeSourceUpdate = + serialiseMLS LeafNodeSourceUpdateTag + serialiseMLS (LeafNodeSourceCommit bs) = do + serialiseMLS LeafNodeSourceCommitTag + serialiseMLSBytes @VarInt bs + +data LeafNodeSourceTag + = LeafNodeSourceKeyPackageTag + | LeafNodeSourceUpdateTag + | LeafNodeSourceCommitTag + deriving (Show, Eq, Ord, Enum, Bounded) + +instance ParseMLS LeafNodeSourceTag where + parseMLS = parseMLSEnum @Word8 "leaf node source" + +instance SerialiseMLS LeafNodeSourceTag where + serialiseMLS = serialiseMLSEnum @Word8 + +instance HasField "name" LeafNodeSourceTag Text where + getField LeafNodeSourceKeyPackageTag = "key_package" + getField LeafNodeSourceUpdateTag = "update" + getField LeafNodeSourceCommitTag = "commit" + +leafNodeSourceTag :: LeafNodeSource -> LeafNodeSourceTag +leafNodeSourceTag (LeafNodeSourceKeyPackage _) = LeafNodeSourceKeyPackageTag +leafNodeSourceTag LeafNodeSourceUpdate = LeafNodeSourceUpdateTag +leafNodeSourceTag (LeafNodeSourceCommit _) = LeafNodeSourceCommitTag diff --git a/libs/wire-api/src/Wire/API/MLS/Lifetime.hs b/libs/wire-api/src/Wire/API/MLS/Lifetime.hs new file mode 100644 index 00000000000..0f17c2978d4 --- /dev/null +++ b/libs/wire-api/src/Wire/API/MLS/Lifetime.hs @@ -0,0 +1,48 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . +{-# LANGUAGE GeneralizedNewtypeDeriving #-} + +module Wire.API.MLS.Lifetime where + +import Data.Time.Clock.POSIX +import Imports +import Test.QuickCheck +import Wire.API.MLS.Serialisation +import Wire.Arbitrary + +-- | Seconds since the UNIX epoch. +newtype Timestamp = Timestamp {timestampSeconds :: Word64} + deriving newtype (Eq, Show, Arbitrary, ParseMLS, SerialiseMLS) + +tsPOSIX :: Timestamp -> POSIXTime +tsPOSIX = fromIntegral . timestampSeconds + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-7.2-2 +data Lifetime = Lifetime + { ltNotBefore :: Timestamp, + ltNotAfter :: Timestamp + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via GenericUniform Lifetime + +instance ParseMLS Lifetime where + parseMLS = Lifetime <$> parseMLS <*> parseMLS + +instance SerialiseMLS Lifetime where + serialiseMLS lt = do + serialiseMLS lt.ltNotBefore + serialiseMLS lt.ltNotAfter diff --git a/libs/wire-api/src/Wire/API/MLS/Message.hs b/libs/wire-api/src/Wire/API/MLS/Message.hs index 517ef2a7fa6..c13dcc0d96f 100644 --- a/libs/wire-api/src/Wire/API/MLS/Message.hs +++ b/libs/wire-api/src/Wire/API/MLS/Message.hs @@ -1,7 +1,3 @@ -{-# LANGUAGE GeneralizedNewtypeDeriving #-} -{-# LANGUAGE StandaloneKindSignatures #-} -{-# LANGUAGE TemplateHaskell #-} - -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -20,40 +16,35 @@ -- with this program. If not, see . module Wire.API.MLS.Message - ( Message (..), - msgGroupId, - msgEpoch, - msgSender, - msgPayload, - MessageTBS (..), - MessageExtraFields (..), + ( -- * MLS Message types WireFormatTag (..), - SWireFormatTag (..), - SomeMessage (..), - ContentType (..), - MessagePayload (..), + Message (..), + mkMessage, + MessageContent (..), + PublicMessage (..), + PrivateMessage (..), + FramedContent (..), + FramedContentData (..), + FramedContentDataTag (..), + FramedContentTBS (..), + FramedContentAuthData (..), Sender (..), - MLSPlainTextSym0, - MLSCipherTextSym0, - MLSMessageSendingStatus (..), - KnownFormatTag (..), + + -- * Utilities verifyMessageSignature, - mkSignedMessage, + + -- * Servant types + MLSMessageSendingStatus (..), ) where import Control.Lens ((?~)) -import Crypto.PubKey.Ed25519 import Data.Aeson qualified as A import Data.Binary -import Data.Binary.Get -import Data.Binary.Put -import Data.ByteArray qualified as BA import Data.Json.Util -import Data.Kind -import Data.Schema -import Data.Singletons.TH -import Data.Swagger qualified as S +import Data.OpenApi qualified as S +import Data.Schema hiding (HasField) +import GHC.Records import Imports hiding (cs) import Test.QuickCheck hiding (label) import Wire.API.Event.Conversation @@ -61,181 +52,155 @@ import Wire.API.MLS.CipherSuite import Wire.API.MLS.Commit import Wire.API.MLS.Epoch import Wire.API.MLS.Group +import Wire.API.MLS.GroupInfo import Wire.API.MLS.KeyPackage +import Wire.API.MLS.LeafNode import Wire.API.MLS.Proposal +import Wire.API.MLS.ProtocolVersion import Wire.API.MLS.Serialisation -import Wire.API.Unreachable -import Wire.Arbitrary (GenericUniform (..)) +import Wire.API.MLS.Welcome +import Wire.Arbitrary -data WireFormatTag = MLSPlainText | MLSCipherText - deriving (Bounded, Enum, Eq, Show) - -$(genSingletons [''WireFormatTag]) +data WireFormatTag + = WireFormatPublicTag + | WireFormatPrivateTag + | WireFormatWelcomeTag + | WireFormatGroupInfoTag + | WireFormatKeyPackageTag + deriving (Enum, Bounded, Eq, Show) instance ParseMLS WireFormatTag where - parseMLS = parseMLSEnum @Word8 "wire format" - -data family MessageExtraFields (tag :: WireFormatTag) :: Type - -data instance MessageExtraFields 'MLSPlainText = MessageExtraFields - { msgSignature :: ByteString, - msgConfirmation :: Maybe ByteString, - msgMembership :: Maybe ByteString - } - deriving (Generic) - deriving (Arbitrary) via (GenericUniform (MessageExtraFields 'MLSPlainText)) + parseMLS = parseMLSEnum @Word16 "wire format" -instance ParseMLS (MessageExtraFields 'MLSPlainText) where - parseMLS = - MessageExtraFields - <$> label "msgSignature" (parseMLSBytes @Word16) - <*> label "msgConfirmation" (parseMLSOptional (parseMLSBytes @Word8)) - <*> label "msgMembership" (parseMLSOptional (parseMLSBytes @Word8)) - -instance SerialiseMLS (MessageExtraFields 'MLSPlainText) where - serialiseMLS (MessageExtraFields sig mconf mmemb) = do - serialiseMLSBytes @Word16 sig - serialiseMLSOptional (serialiseMLSBytes @Word8) mconf - serialiseMLSOptional (serialiseMLSBytes @Word8) mmemb - -data instance MessageExtraFields 'MLSCipherText = NoExtraFields - -instance ParseMLS (MessageExtraFields 'MLSCipherText) where - parseMLS = pure NoExtraFields - -deriving instance Eq (MessageExtraFields 'MLSPlainText) +instance SerialiseMLS WireFormatTag where + serialiseMLS = serialiseMLSEnum @Word16 -deriving instance Eq (MessageExtraFields 'MLSCipherText) - -deriving instance Show (MessageExtraFields 'MLSPlainText) - -deriving instance Show (MessageExtraFields 'MLSCipherText) - -data Message (tag :: WireFormatTag) = Message - { msgTBS :: RawMLS (MessageTBS tag), - msgExtraFields :: MessageExtraFields tag +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6-4 +data Message = Message + { protocolVersion :: ProtocolVersion, + content :: MessageContent } + deriving (Eq, Show) -deriving instance Eq (Message 'MLSPlainText) - -deriving instance Eq (Message 'MLSCipherText) - -deriving instance Show (Message 'MLSPlainText) - -deriving instance Show (Message 'MLSCipherText) - -instance ParseMLS (Message 'MLSPlainText) where - parseMLS = Message <$> label "tbs" parseMLS <*> label "MessageExtraFields" parseMLS - -instance SerialiseMLS (Message 'MLSPlainText) where - serialiseMLS (Message msgTBS msgExtraFields) = do - putByteString (rmRaw msgTBS) - serialiseMLS msgExtraFields - -instance ParseMLS (Message 'MLSCipherText) where - parseMLS = Message <$> parseMLS <*> parseMLS - --- | This corresponds to the format byte at the beginning of a message. --- It does not convey any information, but it needs to be present in --- order for signature verification to work. -data KnownFormatTag (tag :: WireFormatTag) = KnownFormatTag - -instance ParseMLS (KnownFormatTag tag) where - parseMLS = parseMLS @WireFormatTag $> KnownFormatTag - -instance SerialiseMLS (KnownFormatTag 'MLSPlainText) where - serialiseMLS _ = put (fromMLSEnum @Word8 MLSPlainText) - -instance SerialiseMLS (KnownFormatTag 'MLSCipherText) where - serialiseMLS _ = put (fromMLSEnum @Word8 MLSCipherText) - -deriving instance Eq (KnownFormatTag 'MLSPlainText) +mkMessage :: MessageContent -> Message +mkMessage = Message defaultProtocolVersion -deriving instance Eq (KnownFormatTag 'MLSCipherText) +instance ParseMLS Message where + parseMLS = + Message + <$> parseMLS + <*> parseMLS + +instance SerialiseMLS Message where + serialiseMLS msg = do + serialiseMLS msg.protocolVersion + serialiseMLS msg.content + +instance HasField "wireFormat" Message WireFormatTag where + getField = (.content.wireFormat) + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6-4 +data MessageContent + = MessagePrivate (RawMLS PrivateMessage) + | MessagePublic PublicMessage + | MessageWelcome (RawMLS Welcome) + | MessageGroupInfo (RawMLS GroupInfo) + | MessageKeyPackage (RawMLS KeyPackage) + deriving (Eq, Show) -deriving instance Show (KnownFormatTag 'MLSPlainText) +instance HasField "wireFormat" MessageContent WireFormatTag where + getField (MessagePrivate _) = WireFormatPrivateTag + getField (MessagePublic _) = WireFormatPublicTag + getField (MessageWelcome _) = WireFormatWelcomeTag + getField (MessageGroupInfo _) = WireFormatGroupInfoTag + getField (MessageKeyPackage _) = WireFormatKeyPackageTag -deriving instance Show (KnownFormatTag 'MLSCipherText) +instance ParseMLS MessageContent where + parseMLS = + parseMLS >>= \case + WireFormatPrivateTag -> MessagePrivate <$> parseMLS + WireFormatPublicTag -> MessagePublic <$> parseMLS + WireFormatWelcomeTag -> MessageWelcome <$> parseMLS + WireFormatGroupInfoTag -> MessageGroupInfo <$> parseMLS + WireFormatKeyPackageTag -> MessageKeyPackage <$> parseMLS + +instance SerialiseMLS MessageContent where + serialiseMLS (MessagePrivate msg) = do + serialiseMLS WireFormatPrivateTag + serialiseMLS msg + serialiseMLS (MessagePublic msg) = do + serialiseMLS WireFormatPublicTag + serialiseMLS msg + serialiseMLS (MessageWelcome welcome) = do + serialiseMLS WireFormatWelcomeTag + serialiseMLS welcome + serialiseMLS (MessageGroupInfo gi) = do + serialiseMLS WireFormatGroupInfoTag + serialiseMLS gi + serialiseMLS (MessageKeyPackage kp) = do + serialiseMLS WireFormatKeyPackageTag + serialiseMLS kp + +instance S.ToSchema Message where + declareNamedSchema _ = pure (mlsSwagger "MLSMessage") -data MessageTBS (tag :: WireFormatTag) = MessageTBS - { tbsMsgFormat :: KnownFormatTag tag, - tbsMsgGroupId :: GroupId, - tbsMsgEpoch :: Epoch, - tbsMsgAuthData :: ByteString, - tbsMsgSender :: Sender tag, - tbsMsgPayload :: MessagePayload tag +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6.2-2 +data PublicMessage = PublicMessage + { content :: RawMLS FramedContent, + authData :: RawMLS FramedContentAuthData, + -- Present iff content.value.sender is of type Member. + -- https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6.2-4 + membershipTag :: Maybe ByteString } + deriving (Eq, Show) -msgGroupId :: Message tag -> GroupId -msgGroupId = tbsMsgGroupId . rmValue . msgTBS - -msgEpoch :: Message tag -> Epoch -msgEpoch = tbsMsgEpoch . rmValue . msgTBS - -msgSender :: Message tag -> Sender tag -msgSender = tbsMsgSender . rmValue . msgTBS - -msgPayload :: Message tag -> MessagePayload tag -msgPayload = tbsMsgPayload . rmValue . msgTBS - -instance ParseMLS (MessageTBS 'MLSPlainText) where +instance ParseMLS PublicMessage where parseMLS = do - f <- parseMLS - g <- parseMLS - e <- parseMLS - s <- parseMLS - d <- parseMLSBytes @Word32 - MessageTBS f g e d s <$> parseMLS - -instance ParseMLS (MessageTBS 'MLSCipherText) where - parseMLS = do - f <- parseMLS - g <- parseMLS - e <- parseMLS - ct <- parseMLS - d <- parseMLSBytes @Word32 - s <- parseMLS - p <- parseMLSBytes @Word32 - pure $ MessageTBS f g e d s (CipherText ct p) - -instance SerialiseMLS (MessageTBS 'MLSPlainText) where - serialiseMLS (MessageTBS f g e d s p) = do - serialiseMLS f - serialiseMLS g - serialiseMLS e - serialiseMLS s - serialiseMLSBytes @Word32 d - serialiseMLS p - -deriving instance Eq (MessageTBS 'MLSPlainText) - -deriving instance Eq (MessageTBS 'MLSCipherText) - -deriving instance Show (MessageTBS 'MLSPlainText) - -deriving instance Show (MessageTBS 'MLSCipherText) - -data SomeMessage where - SomeMessage :: Sing tag -> Message tag -> SomeMessage - -instance S.ToSchema SomeMessage where - declareNamedSchema _ = pure (mlsSwagger "MLSMessage") - -instance ParseMLS SomeMessage where - parseMLS = - lookAhead parseMLS >>= \case - MLSPlainText -> SomeMessage SMLSPlainText <$> parseMLS - MLSCipherText -> SomeMessage SMLSCipherText <$> parseMLS - -data family Sender (tag :: WireFormatTag) :: Type - -data instance Sender 'MLSCipherText = EncryptedSender {esData :: ByteString} + content <- parseMLS + authData <- parseRawMLS (parseFramedContentAuthData (framedContentDataTag (content.value.content))) + membershipTag <- case content.value.sender of + SenderMember _ -> Just <$> parseMLSBytes @VarInt + _ -> pure Nothing + pure + PublicMessage + { content = content, + authData = authData, + membershipTag = membershipTag + } + +instance SerialiseMLS PublicMessage where + serialiseMLS msg = do + serialiseMLS msg.content + serialiseMLS msg.authData + traverse_ (serialiseMLSBytes @VarInt) msg.membershipTag + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6.3.1-2 +data PrivateMessage = PrivateMessage + { groupId :: GroupId, + epoch :: Epoch, + tag :: FramedContentDataTag, + authenticatedData :: ByteString, + encryptedSenderData :: ByteString, + ciphertext :: ByteString + } deriving (Eq, Show) -instance ParseMLS (Sender 'MLSCipherText) where - parseMLS = EncryptedSender <$> parseMLSBytes @Word8 - -data SenderTag = MemberSenderTag | PreconfiguredSenderTag | NewMemberSenderTag +instance ParseMLS PrivateMessage where + parseMLS = + PrivateMessage + <$> parseMLS + <*> parseMLS + <*> parseMLS + <*> parseMLSBytes @VarInt + <*> parseMLSBytes @VarInt + <*> parseMLSBytes @VarInt + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6-4 +data SenderTag + = SenderMemberTag + | SenderExternalTag + | SenderNewMemberProposalTag + | SenderNewMemberCommitTag deriving (Bounded, Enum, Show, Eq) instance ParseMLS SenderTag where @@ -244,85 +209,174 @@ instance ParseMLS SenderTag where instance SerialiseMLS SenderTag where serialiseMLS = serialiseMLSEnum @Word8 --- NOTE: according to the spec, the preconfigured sender case contains a --- bytestring, not a u32. However, as of 2022-08-02, the openmls fork used by --- the clients is using a u32 here. -data instance Sender 'MLSPlainText - = MemberSender KeyPackageRef - | PreconfiguredSender Word32 - | NewMemberSender +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6-4 +data Sender + = SenderMember LeafIndex + | SenderExternal Word32 + | SenderNewMemberProposal + | SenderNewMemberCommit deriving (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform Sender) -instance ParseMLS (Sender 'MLSPlainText) where +instance ParseMLS Sender where parseMLS = parseMLS >>= \case - MemberSenderTag -> MemberSender <$> parseMLS - PreconfiguredSenderTag -> PreconfiguredSender <$> get - NewMemberSenderTag -> pure NewMemberSender - -instance SerialiseMLS (Sender 'MLSPlainText) where - serialiseMLS (MemberSender r) = do - serialiseMLS MemberSenderTag - serialiseMLS r - serialiseMLS (PreconfiguredSender x) = do - serialiseMLS PreconfiguredSenderTag - put x - serialiseMLS NewMemberSender = serialiseMLS NewMemberSenderTag - -data family MessagePayload (tag :: WireFormatTag) :: Type - -deriving instance Eq (MessagePayload 'MLSPlainText) - -deriving instance Eq (MessagePayload 'MLSCipherText) - -deriving instance Show (MessagePayload 'MLSPlainText) - -deriving instance Show (MessagePayload 'MLSCipherText) - -data instance MessagePayload 'MLSCipherText = CipherText - { msgContentType :: Word8, - msgCipherText :: ByteString + SenderMemberTag -> SenderMember <$> parseMLS + SenderExternalTag -> SenderExternal <$> parseMLS + SenderNewMemberProposalTag -> pure SenderNewMemberProposal + SenderNewMemberCommitTag -> pure SenderNewMemberCommit + +instance SerialiseMLS Sender where + serialiseMLS (SenderMember i) = do + serialiseMLS SenderMemberTag + serialiseMLS i + serialiseMLS (SenderExternal w) = do + serialiseMLS SenderExternalTag + serialiseMLS w + serialiseMLS SenderNewMemberProposal = + serialiseMLS SenderNewMemberProposalTag + serialiseMLS SenderNewMemberCommit = + serialiseMLS SenderNewMemberCommitTag + +needsGroupContext :: Sender -> Bool +needsGroupContext (SenderMember _) = True +needsGroupContext (SenderExternal _) = True +needsGroupContext _ = False + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6-4 +data FramedContent = FramedContent + { groupId :: GroupId, + epoch :: Epoch, + sender :: Sender, + authenticatedData :: ByteString, + content :: FramedContentData } + deriving (Eq, Show) -data ContentType - = ApplicationMessageTag - | ProposalMessageTag - | CommitMessageTag - deriving (Bounded, Enum, Eq, Show) +instance ParseMLS FramedContent where + parseMLS = + FramedContent + <$> parseMLS + <*> parseMLS + <*> parseMLS + <*> parseMLSBytes @VarInt + <*> parseMLS + +instance SerialiseMLS FramedContent where + serialiseMLS fc = do + serialiseMLS fc.groupId + serialiseMLS fc.epoch + serialiseMLS fc.sender + serialiseMLSBytes @VarInt fc.authenticatedData + serialiseMLS fc.content + +data FramedContentDataTag + = FramedContentApplicationDataTag + | FramedContentProposalTag + | FramedContentCommitTag + deriving (Enum, Bounded, Eq, Ord, Show) + +instance ParseMLS FramedContentDataTag where + parseMLS = parseMLSEnum @Word8 "ContentType" + +instance SerialiseMLS FramedContentDataTag where + serialiseMLS = serialiseMLSEnum @Word8 -instance ParseMLS ContentType where - parseMLS = parseMLSEnum @Word8 "content type" +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6-4 +data FramedContentData + = FramedContentApplicationData ByteString + | FramedContentProposal (RawMLS Proposal) + | FramedContentCommit (RawMLS Commit) + deriving (Eq, Show) -data instance MessagePayload 'MLSPlainText - = ApplicationMessage ByteString - | ProposalMessage (RawMLS Proposal) - | CommitMessage Commit +framedContentDataTag :: FramedContentData -> FramedContentDataTag +framedContentDataTag (FramedContentApplicationData _) = FramedContentApplicationDataTag +framedContentDataTag (FramedContentProposal _) = FramedContentProposalTag +framedContentDataTag (FramedContentCommit _) = FramedContentCommitTag -instance ParseMLS (MessagePayload 'MLSPlainText) where +instance ParseMLS FramedContentData where parseMLS = parseMLS >>= \case - ApplicationMessageTag -> ApplicationMessage <$> parseMLSBytes @Word32 - ProposalMessageTag -> ProposalMessage <$> parseMLS - CommitMessageTag -> CommitMessage <$> parseMLS + FramedContentApplicationDataTag -> + FramedContentApplicationData <$> parseMLSBytes @VarInt + FramedContentProposalTag -> FramedContentProposal <$> parseMLS + FramedContentCommitTag -> FramedContentCommit <$> parseMLS + +instance SerialiseMLS FramedContentData where + serialiseMLS (FramedContentApplicationData bs) = do + serialiseMLS FramedContentApplicationDataTag + serialiseMLSBytes @VarInt bs + serialiseMLS (FramedContentProposal prop) = do + serialiseMLS FramedContentProposalTag + serialiseMLS prop + serialiseMLS (FramedContentCommit commit) = do + serialiseMLS FramedContentCommitTag + serialiseMLS commit + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6.1-2 +data FramedContentTBS = FramedContentTBS + { protocolVersion :: ProtocolVersion, + wireFormat :: WireFormatTag, + content :: RawMLS FramedContent, + groupContext :: Maybe (RawMLS GroupContext) + } + deriving (Eq, Show) -instance SerialiseMLS ContentType where - serialiseMLS = serialiseMLSEnum @Word8 +instance SerialiseMLS FramedContentTBS where + serialiseMLS tbs = do + serialiseMLS tbs.protocolVersion + serialiseMLS tbs.wireFormat + serialiseMLS tbs.content + traverse_ serialiseMLS tbs.groupContext + +framedContentTBS :: RawMLS GroupContext -> RawMLS FramedContent -> FramedContentTBS +framedContentTBS ctx msgContent = + FramedContentTBS + { protocolVersion = defaultProtocolVersion, + wireFormat = WireFormatPublicTag, + content = msgContent, + groupContext = guard (needsGroupContext msgContent.value.sender) $> ctx + } + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6.1-2 +data FramedContentAuthData = FramedContentAuthData + { signature_ :: ByteString, + -- Present iff it is part of a commit. + confirmationTag :: Maybe ByteString + } + deriving (Eq, Show) -instance SerialiseMLS (MessagePayload 'MLSPlainText) where - serialiseMLS (ProposalMessage raw) = do - serialiseMLS ProposalMessageTag - putByteString (rmRaw raw) - -- We do not need to serialise Commit and Application messages, - -- so the next case is left as a stub - serialiseMLS _ = pure () +parseFramedContentAuthData :: FramedContentDataTag -> Get FramedContentAuthData +parseFramedContentAuthData t = do + sig <- parseMLSBytes @VarInt + confirmationTag <- case t of + FramedContentCommitTag -> Just <$> parseMLSBytes @VarInt + _ -> pure Nothing + pure (FramedContentAuthData sig confirmationTag) + +instance SerialiseMLS FramedContentAuthData where + serialiseMLS ad = do + serialiseMLSBytes @VarInt ad.signature_ + traverse_ (serialiseMLSBytes @VarInt) ad.confirmationTag + +verifyMessageSignature :: + RawMLS GroupContext -> + RawMLS FramedContent -> + RawMLS FramedContentAuthData -> + ByteString -> + Bool +verifyMessageSignature ctx msgContent authData pubkey = isJust $ do + let tbs = mkRawMLS (framedContentTBS ctx msgContent) + sig = authData.value.signature_ + cs <- cipherSuiteTag ctx.value.cipherSuite + guard $ csVerifySignature cs pubkey tbs sig + +-------------------------------------------------------------------------------- +-- Servant data MLSMessageSendingStatus = MLSMessageSendingStatus { mmssEvents :: [Event], - mmssTime :: UTCTimeMillis, - -- | An optional list of unreachable users an application message could not - -- be sent to. In case of commits and unreachable users use the - -- MLSMessageResponseUnreachableBackends data constructor. - mmssFailedToSendTo :: Maybe UnreachableUsers + mmssTime :: UTCTimeMillis } deriving (Eq, Show) deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema MLSMessageSendingStatus @@ -341,35 +395,3 @@ instance ToSchema MLSMessageSendingStatus where "time" (description ?~ "The time of sending the message.") schema - <*> mmssFailedToSendTo - .= maybe_ - ( optFieldWithDocModifier - "failed_to_send" - (description ?~ "List of federated users who could not be reached and did not receive the message") - schema - ) - -verifyMessageSignature :: CipherSuiteTag -> Message 'MLSPlainText -> ByteString -> Bool -verifyMessageSignature cs msg pubkey = - csVerifySignature cs pubkey (rmRaw (msgTBS msg)) (msgSignature (msgExtraFields msg)) - -mkSignedMessage :: - SecretKey -> - PublicKey -> - GroupId -> - Epoch -> - MessagePayload 'MLSPlainText -> - Message 'MLSPlainText -mkSignedMessage priv pub gid epoch payload = - let tbs = - mkRawMLS $ - MessageTBS - { tbsMsgFormat = KnownFormatTag, - tbsMsgGroupId = gid, - tbsMsgEpoch = epoch, - tbsMsgAuthData = mempty, - tbsMsgSender = PreconfiguredSender 0, - tbsMsgPayload = payload - } - sig = BA.convert $ sign priv pub (rmRaw tbs) - in Message tbs (MessageExtraFields sig Nothing Nothing) diff --git a/libs/wire-api/src/Wire/API/MLS/Proposal.hs b/libs/wire-api/src/Wire/API/MLS/Proposal.hs index d17b9e87339..125364b8362 100644 --- a/libs/wire-api/src/Wire/API/MLS/Proposal.hs +++ b/libs/wire-api/src/Wire/API/MLS/Proposal.hs @@ -1,4 +1,6 @@ {-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TemplateHaskell #-} + -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -15,54 +17,46 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -{-# LANGUAGE TemplateHaskell #-} module Wire.API.MLS.Proposal where import Cassandra -import Control.Arrow import Control.Lens (makePrisms) import Data.Binary -import Data.Binary.Get -import Data.Binary.Put -import Data.ByteString.Lazy qualified as LBS +import Data.ByteString as B +import GHC.Records import Imports hiding (cs) +import Test.QuickCheck import Wire.API.MLS.CipherSuite -import Wire.API.MLS.Context import Wire.API.MLS.Extension import Wire.API.MLS.Group import Wire.API.MLS.KeyPackage +import Wire.API.MLS.LeafNode +import Wire.API.MLS.ProposalTag +import Wire.API.MLS.ProtocolVersion import Wire.API.MLS.Serialisation import Wire.Arbitrary -data ProposalTag - = AddProposalTag - | UpdateProposalTag - | RemoveProposalTag - | PreSharedKeyProposalTag - | ReInitProposalTag - | ExternalInitProposalTag - | AppAckProposalTag - | GroupContextExtensionsProposalTag - deriving stock (Bounded, Enum, Eq, Generic, Show) - deriving (Arbitrary) via GenericUniform ProposalTag - -instance ParseMLS ProposalTag where - parseMLS = parseMLSEnum @Word16 "proposal type" - -instance SerialiseMLS ProposalTag where - serialiseMLS = serialiseMLSEnum @Word16 - +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-12.1-2 data Proposal = AddProposal (RawMLS KeyPackage) - | UpdateProposal KeyPackage - | RemoveProposal KeyPackageRef - | PreSharedKeyProposal PreSharedKeyID - | ReInitProposal ReInit + | UpdateProposal (RawMLS LeafNode) + | RemoveProposal LeafIndex + | PreSharedKeyProposal (RawMLS PreSharedKeyID) + | ReInitProposal (RawMLS ReInit) | ExternalInitProposal ByteString - | AppAckProposal [MessageRange] | GroupContextExtensionsProposal [Extension] - deriving stock (Eq, Show) + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform Proposal) + +instance HasField "tag" Proposal ProposalTag where + getField (AddProposal _) = AddProposalTag + getField (UpdateProposal _) = UpdateProposalTag + getField (RemoveProposal _) = RemoveProposalTag + getField (PreSharedKeyProposal _) = PreSharedKeyProposalTag + getField (ReInitProposal _) = ReInitProposalTag + getField (ExternalInitProposal _) = ExternalInitProposalTag + getField (GroupContextExtensionsProposal _) = GroupContextExtensionsProposalTag instance ParseMLS Proposal where parseMLS = @@ -72,57 +66,86 @@ instance ParseMLS Proposal where RemoveProposalTag -> RemoveProposal <$> parseMLS PreSharedKeyProposalTag -> PreSharedKeyProposal <$> parseMLS ReInitProposalTag -> ReInitProposal <$> parseMLS - ExternalInitProposalTag -> ExternalInitProposal <$> parseMLSBytes @Word16 - AppAckProposalTag -> AppAckProposal <$> parseMLSVector @Word32 parseMLS + ExternalInitProposalTag -> ExternalInitProposal <$> parseMLSBytes @VarInt GroupContextExtensionsProposalTag -> - GroupContextExtensionsProposal <$> parseMLSVector @Word32 parseMLS - -mkRemoveProposal :: KeyPackageRef -> RawMLS Proposal -mkRemoveProposal ref = RawMLS bytes (RemoveProposal ref) - where - bytes = LBS.toStrict . runPut $ do - serialiseMLS RemoveProposalTag - serialiseMLS ref - -serialiseAppAckProposal :: [MessageRange] -> Put -serialiseAppAckProposal mrs = do - serialiseMLS AppAckProposalTag - serialiseMLSVector @Word32 serialiseMLS mrs - -mkAppAckProposal :: [MessageRange] -> RawMLS Proposal -mkAppAckProposal = uncurry RawMLS . (bytes &&& AppAckProposal) - where - bytes = LBS.toStrict . runPut . serialiseAppAckProposal - --- | Compute the proposal ref given a ciphersuite and the raw proposal data. -proposalRef :: CipherSuiteTag -> RawMLS Proposal -> ProposalRef -proposalRef cs = - ProposalRef - . csHash cs proposalContext - . rmRaw - + GroupContextExtensionsProposal <$> parseMLSVector @VarInt parseMLS + +instance SerialiseMLS Proposal where + serialiseMLS (AddProposal kp) = do + serialiseMLS AddProposalTag + serialiseMLS kp + serialiseMLS (UpdateProposal ln) = do + serialiseMLS UpdateProposalTag + serialiseMLS ln + serialiseMLS (RemoveProposal i) = do + serialiseMLS RemoveProposalTag + serialiseMLS i + serialiseMLS (PreSharedKeyProposal k) = do + serialiseMLS PreSharedKeyProposalTag + serialiseMLS k + serialiseMLS (ReInitProposal ri) = do + serialiseMLS ReInitProposalTag + serialiseMLS ri + serialiseMLS (ExternalInitProposal ko) = do + serialiseMLS ExternalInitProposalTag + serialiseMLSBytes @VarInt ko + serialiseMLS (GroupContextExtensionsProposal es) = do + serialiseMLS GroupContextExtensionsProposalTag + serialiseMLSVector @VarInt serialiseMLS es + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-8.4-6 data PreSharedKeyTag = ExternalKeyTag | ResumptionKeyTag deriving (Bounded, Enum, Eq, Show) instance ParseMLS PreSharedKeyTag where - parseMLS = parseMLSEnum @Word16 "PreSharedKeyID type" + parseMLS = parseMLSEnum @Word8 "PreSharedKeyID type" -data PreSharedKeyID = ExternalKeyID ByteString | ResumptionKeyID Resumption - deriving stock (Eq, Show) +instance SerialiseMLS PreSharedKeyTag where + serialiseMLS = serialiseMLSEnum @Word8 -instance ParseMLS PreSharedKeyID where +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-8.4-6 +data PreSharedKeyIDCore = ExternalKeyID ByteString | ResumptionKeyID Resumption + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform PreSharedKeyIDCore) + +instance ParseMLS PreSharedKeyIDCore where parseMLS = do t <- parseMLS case t of - ExternalKeyTag -> ExternalKeyID <$> parseMLSBytes @Word8 + ExternalKeyTag -> ExternalKeyID <$> parseMLSBytes @VarInt ResumptionKeyTag -> ResumptionKeyID <$> parseMLS +instance SerialiseMLS PreSharedKeyIDCore where + serialiseMLS (ExternalKeyID bs) = do + serialiseMLS ExternalKeyTag + serialiseMLSBytes @VarInt bs + serialiseMLS (ResumptionKeyID r) = do + serialiseMLS ResumptionKeyTag + serialiseMLS r + +data PreSharedKeyID = PreSharedKeyID + { core :: PreSharedKeyIDCore, + nonce :: ByteString + } + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform PreSharedKeyID) + +instance ParseMLS PreSharedKeyID where + parseMLS = PreSharedKeyID <$> parseMLS <*> parseMLSBytes @VarInt + +instance SerialiseMLS PreSharedKeyID where + serialiseMLS psk = do + serialiseMLS psk.core + serialiseMLSBytes @VarInt psk.nonce + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-8.4-6 data Resumption = Resumption - { resUsage :: Word8, - resGroupId :: GroupId, - resEpoch :: Word64 + { usage :: Word8, + groupId :: GroupId, + epoch :: Word64 } - deriving stock (Eq, Show) + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform Resumption) instance ParseMLS Resumption where parseMLS = @@ -131,13 +154,21 @@ instance ParseMLS Resumption where <*> parseMLS <*> parseMLS +instance SerialiseMLS Resumption where + serialiseMLS r = do + serialiseMLS r.usage + serialiseMLS r.groupId + serialiseMLS r.epoch + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-12.1.5-2 data ReInit = ReInit - { riGroupId :: GroupId, - riProtocolVersion :: ProtocolVersion, - riCipherSuite :: CipherSuite, - riExtensions :: [Extension] + { groupId :: GroupId, + protocolVersion :: ProtocolVersion, + cipherSuite :: CipherSuite, + extensions :: [Extension] } - deriving stock (Eq, Show) + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform ReInit) instance ParseMLS ReInit where parseMLS = @@ -145,12 +176,19 @@ instance ParseMLS ReInit where <$> parseMLS <*> parseMLS <*> parseMLS - <*> parseMLSVector @Word32 parseMLS + <*> parseMLSVector @VarInt parseMLS + +instance SerialiseMLS ReInit where + serialiseMLS ri = do + serialiseMLS ri.groupId + serialiseMLS ri.protocolVersion + serialiseMLS ri.cipherSuite + serialiseMLSVector @VarInt serialiseMLS ri.extensions data MessageRange = MessageRange - { mrSender :: KeyPackageRef, - mrFirstGeneration :: Word32, - mrLastGeneration :: Word32 + { sender :: KeyPackageRef, + firstGeneration :: Word32, + lastGeneration :: Word32 } deriving stock (Eq, Show) @@ -166,18 +204,24 @@ instance ParseMLS MessageRange where instance SerialiseMLS MessageRange where serialiseMLS MessageRange {..} = do - serialiseMLS mrSender - serialiseMLS mrFirstGeneration - serialiseMLS mrLastGeneration + serialiseMLS sender + serialiseMLS firstGeneration + serialiseMLS lastGeneration +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-12.4-3 data ProposalOrRefTag = InlineTag | RefTag deriving stock (Bounded, Enum, Eq, Show) instance ParseMLS ProposalOrRefTag where parseMLS = parseMLSEnum @Word8 "ProposalOrRef type" +instance SerialiseMLS ProposalOrRefTag where + serialiseMLS = serialiseMLSEnum @Word8 + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-12.4-3 data ProposalOrRef = Inline Proposal | Ref ProposalRef - deriving stock (Eq, Show) + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform ProposalOrRef) instance ParseMLS ProposalOrRef where parseMLS = @@ -185,11 +229,23 @@ instance ParseMLS ProposalOrRef where InlineTag -> Inline <$> parseMLS RefTag -> Ref <$> parseMLS +instance SerialiseMLS ProposalOrRef where + serialiseMLS (Inline p) = do + serialiseMLS InlineTag + serialiseMLS p + serialiseMLS (Ref r) = do + serialiseMLS RefTag + serialiseMLS r + newtype ProposalRef = ProposalRef {unProposalRef :: ByteString} - deriving stock (Eq, Show, Ord) + deriving stock (Eq, Show, Ord, Generic) + deriving newtype (Arbitrary) instance ParseMLS ProposalRef where - parseMLS = ProposalRef <$> getByteString 16 + parseMLS = ProposalRef <$> parseMLSBytes @VarInt + +instance SerialiseMLS ProposalRef where + serialiseMLS = serialiseMLSBytes @VarInt . unProposalRef makePrisms ''ProposalOrRef diff --git a/libs/wire-api/src/Wire/API/MLS/ProposalTag.hs b/libs/wire-api/src/Wire/API/MLS/ProposalTag.hs new file mode 100644 index 00000000000..8e7d8b36705 --- /dev/null +++ b/libs/wire-api/src/Wire/API/MLS/ProposalTag.hs @@ -0,0 +1,40 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.MLS.ProposalTag where + +import Data.Binary +import Imports +import Wire.API.MLS.Serialisation +import Wire.Arbitrary + +data ProposalTag + = AddProposalTag + | UpdateProposalTag + | RemoveProposalTag + | PreSharedKeyProposalTag + | ReInitProposalTag + | ExternalInitProposalTag + | GroupContextExtensionsProposalTag + deriving stock (Bounded, Enum, Eq, Ord, Generic, Show) + deriving (Arbitrary) via GenericUniform ProposalTag + +instance ParseMLS ProposalTag where + parseMLS = parseMLSEnum @Word16 "proposal type" + +instance SerialiseMLS ProposalTag where + serialiseMLS = serialiseMLSEnum @Word16 diff --git a/libs/wire-api/src/Wire/API/MLS/ProtocolVersion.hs b/libs/wire-api/src/Wire/API/MLS/ProtocolVersion.hs new file mode 100644 index 00000000000..9d8a0220682 --- /dev/null +++ b/libs/wire-api/src/Wire/API/MLS/ProtocolVersion.hs @@ -0,0 +1,53 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . +{-# LANGUAGE GeneralizedNewtypeDeriving #-} + +module Wire.API.MLS.ProtocolVersion + ( ProtocolVersion (..), + ProtocolVersionTag (..), + pvTag, + protocolVersionFromTag, + defaultProtocolVersion, + ) +where + +import Data.Binary +import Imports +import Wire.API.MLS.Serialisation +import Wire.Arbitrary + +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-6-4 +newtype ProtocolVersion = ProtocolVersion {pvNumber :: Word16} + deriving newtype (Eq, Ord, Show, Binary, Arbitrary, ParseMLS, SerialiseMLS) + +data ProtocolVersionTag = ProtocolMLS10 | ProtocolMLSDraft11 + deriving stock (Bounded, Enum, Eq, Show, Generic) + deriving (Arbitrary) via GenericUniform ProtocolVersionTag + +pvTag :: ProtocolVersion -> Maybe ProtocolVersionTag +pvTag (ProtocolVersion v) = case v of + 1 -> pure ProtocolMLS10 + -- used by openmls + 200 -> pure ProtocolMLSDraft11 + _ -> Nothing + +protocolVersionFromTag :: ProtocolVersionTag -> ProtocolVersion +protocolVersionFromTag ProtocolMLS10 = ProtocolVersion 1 +protocolVersionFromTag ProtocolMLSDraft11 = ProtocolVersion 200 + +defaultProtocolVersion :: ProtocolVersion +defaultProtocolVersion = protocolVersionFromTag ProtocolMLS10 diff --git a/libs/wire-api/src/Wire/API/MLS/PublicGroupState.hs b/libs/wire-api/src/Wire/API/MLS/PublicGroupState.hs index 870b46f549d..81ee1095616 100644 --- a/libs/wire-api/src/Wire/API/MLS/PublicGroupState.hs +++ b/libs/wire-api/src/Wire/API/MLS/PublicGroupState.hs @@ -22,14 +22,14 @@ import Data.Binary import Data.Binary.Get import Data.Binary.Put import Data.ByteString.Lazy qualified as LBS -import Data.Swagger qualified as S +import Data.OpenApi qualified as S import Imports import Test.QuickCheck hiding (label) import Wire.API.MLS.CipherSuite import Wire.API.MLS.Epoch -import Wire.API.MLS.Extension import Wire.API.MLS.Group import Wire.API.MLS.KeyPackage +import Wire.API.MLS.ProtocolVersion import Wire.API.MLS.Serialisation import Wire.Arbitrary @@ -101,7 +101,7 @@ instance S.ToSchema OpaquePublicGroupState where declareNamedSchema _ = pure (mlsSwagger "OpaquePublicGroupState") toOpaquePublicGroupState :: RawMLS PublicGroupState -> OpaquePublicGroupState -toOpaquePublicGroupState = OpaquePublicGroupState . rmRaw +toOpaquePublicGroupState = OpaquePublicGroupState . (.raw) instance Arbitrary PublicGroupState where arbitrary = diff --git a/libs/wire-api/src/Wire/API/MLS/Serialisation.hs b/libs/wire-api/src/Wire/API/MLS/Serialisation.hs index 946bcbc7c39..7ae47e8493a 100644 --- a/libs/wire-api/src/Wire/API/MLS/Serialisation.hs +++ b/libs/wire-api/src/Wire/API/MLS/Serialisation.hs @@ -1,3 +1,6 @@ +{-# LANGUAGE BinaryLiterals #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} + -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -18,6 +21,9 @@ module Wire.API.MLS.Serialisation ( ParseMLS (..), SerialiseMLS (..), + VarInt (..), + parseMLSStream, + serialiseMLSStream, parseMLSVector, serialiseMLSVector, parseMLSBytes, @@ -42,6 +48,7 @@ module Wire.API.MLS.Serialisation mlsSwagger, parseRawMLS, mkRawMLS, + traceMLS, ) where @@ -52,18 +59,21 @@ import Data.Aeson (FromJSON (..)) import Data.Aeson qualified as Aeson import Data.Bifunctor import Data.Binary -import Data.Binary.Builder +import Data.Binary.Builder (toLazyByteString) import Data.Binary.Get import Data.Binary.Put +import Data.Bits import Data.ByteString qualified as BS import Data.ByteString.Lazy qualified as LBS import Data.Json.Util import Data.Kind +import Data.OpenApi qualified as S import Data.Proxy import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text +import Debug.Trace import Imports +import Test.QuickCheck (Arbitrary (..), chooseInt) -- | Parse a value encoded using the "TLS presentation" format. class ParseMLS a where @@ -73,6 +83,55 @@ class ParseMLS a where class SerialiseMLS a where serialiseMLS :: a -> Put +-- | An integer value serialised with a variable-size encoding. +-- +-- The underlying Word32 must be strictly less than 2^30. +newtype VarInt = VarInt {unVarInt :: Word32} + deriving newtype (Eq, Ord, Num, Enum, Integral, Real, Show) + +instance Arbitrary VarInt where + arbitrary = fromIntegral <$> chooseInt (0, 1073741823) + +-- From the MLS spec: +-- +-- Prefix | Length | Usable Bits | Min | Max +-- -------+--------+-------------+-----+--------- +-- 00 1 6 0 63 +-- 01 2 14 64 16383 +-- 10 4 30 16384 1073741823 +-- 11 invalid - - - +-- +instance Binary VarInt where + put :: VarInt -> Put + put (VarInt w) + | w < 64 = putWord8 (fromIntegral w) + | w < 16384 = putWord16be (0x4000 .|. fromIntegral w) + | w < 1073741824 = putWord32be (0x80000000 .|. w) + | otherwise = error "invalid VarInt" + + get :: Get VarInt + get = do + w <- lookAhead getWord8 + case shiftR (w .&. 0xc0) 6 of + 0b00 -> VarInt . fromIntegral <$> getWord8 + 0b01 -> VarInt . (.&. 0x3fff) . fromIntegral <$> getWord16be + 0b10 -> VarInt . (.&. 0x3fffffff) . fromIntegral <$> getWord32be + _ -> fail "invalid VarInt prefix" + +instance SerialiseMLS VarInt where serialiseMLS = put + +instance ParseMLS VarInt where parseMLS = get + +parseMLSStream :: Get a -> Get [a] +parseMLSStream p = do + e <- isEmpty + if e + then pure [] + else (:) <$> p <*> parseMLSStream p + +serialiseMLSStream :: (a -> Put) -> [a] -> Put +serialiseMLSStream = traverse_ + parseMLSVector :: forall w a. (Binary w, Integral w) => Get a -> Get [a] parseMLSVector getItem = do len <- get @w @@ -139,19 +198,19 @@ serialiseMLSEnum :: Put serialiseMLSEnum = put . fromMLSEnum @w -data MLSEnumError = MLSEnumUnknown | MLSEnumInvalid +data MLSEnumError = MLSEnumUnknown Int | MLSEnumInvalid toMLSEnum' :: forall a w. (Bounded a, Enum a, Integral w) => w -> Either MLSEnumError a toMLSEnum' w = case fromIntegral w - 1 of n | n < 0 -> Left MLSEnumInvalid - | n < fromEnum @a minBound || n > fromEnum @a maxBound -> Left MLSEnumUnknown + | n < fromEnum @a minBound || n > fromEnum @a maxBound -> Left (MLSEnumUnknown n) | otherwise -> pure (toEnum n) toMLSEnum :: forall a w f. (Bounded a, Enum a, MonadFail f, Integral w) => String -> w -> f a toMLSEnum name = either err pure . toMLSEnum' where - err MLSEnumUnknown = fail $ "Unknown " <> name + err (MLSEnumUnknown value) = fail $ "Unknown " <> name <> ": " <> show value err MLSEnumInvalid = fail $ "Invalid " <> name fromMLSEnum :: (Integral w, Enum a) => a -> w @@ -205,11 +264,14 @@ decodeMLSWith' p = decodeMLSWith p . LBS.fromStrict -- retain the original serialised bytes (e.g. for signature verification, or to -- forward them verbatim). data RawMLS a = RawMLS - { rmRaw :: ByteString, - rmValue :: a + { raw :: ByteString, + value :: a } deriving stock (Eq, Show, Foldable) +instance (Arbitrary a, SerialiseMLS a) => Arbitrary (RawMLS a) where + arbitrary = mkRawMLS <$> arbitrary + -- | A schema for a raw MLS object. -- -- This can be used for embedding MLS objects into JSON. It expresses the @@ -219,7 +281,7 @@ data RawMLS a = RawMLS -- Note that a 'ValueSchema' for the underlying type @a@ is /not/ required. rawMLSSchema :: Text -> (ByteString -> Either Text a) -> ValueSchema NamedSwaggerDoc (RawMLS a) rawMLSSchema name p = - (toBase64Text . rmRaw) + (toBase64Text . raw) .= parsedText name (rawMLSFromText p) mlsSwagger :: Text -> S.NamedSchema @@ -260,7 +322,15 @@ instance ParseMLS a => ParseMLS (RawMLS a) where parseMLS = parseRawMLS parseMLS instance SerialiseMLS (RawMLS a) where - serialiseMLS = putByteString . rmRaw + serialiseMLS = putByteString . raw mkRawMLS :: SerialiseMLS a => a -> RawMLS a mkRawMLS x = RawMLS (LBS.toStrict (runPut (serialiseMLS x))) x + +traceMLS :: Show a => String -> Get a -> Get a +traceMLS l g = do + begin <- bytesRead + r <- g + end <- bytesRead + traceM $ l <> " " <> show begin <> ":" <> show end <> " " <> show r + pure r diff --git a/libs/wire-api/src/Wire/API/MLS/Servant.hs b/libs/wire-api/src/Wire/API/MLS/Servant.hs index c63e008a6a2..4807d82d930 100644 --- a/libs/wire-api/src/Wire/API/MLS/Servant.hs +++ b/libs/wire-api/src/Wire/API/MLS/Servant.hs @@ -15,17 +15,14 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Wire.API.MLS.Servant (MLS, mimeUnrenderMLSWith, CommitBundleMimeType) where +module Wire.API.MLS.Servant (MLS, mimeUnrenderMLSWith) where import Data.Bifunctor import Data.Binary -import Data.ByteString.Lazy qualified as LBS import Data.Text qualified as T import Imports import Network.HTTP.Media ((//)) import Servant.API hiding (Get) -import Wire.API.MLS.CommitBundle -import Wire.API.MLS.PublicGroupState (OpaquePublicGroupState, unOpaquePublicGroupState) import Wire.API.MLS.Serialisation data MLS @@ -36,19 +33,8 @@ instance Accept MLS where instance {-# OVERLAPPABLE #-} ParseMLS a => MimeUnrender MLS a where mimeUnrender _ = mimeUnrenderMLSWith parseMLS -instance MimeRender MLS OpaquePublicGroupState where - mimeRender _ = LBS.fromStrict . unOpaquePublicGroupState +instance {-# OVERLAPPABLE #-} SerialiseMLS a => MimeRender MLS a where + mimeRender _ = encodeMLS mimeUnrenderMLSWith :: Get a -> LByteString -> Either String a mimeUnrenderMLSWith p = first T.unpack . decodeMLSWith p - -data CommitBundleMimeType - -instance Accept CommitBundleMimeType where - contentType _ = "application" // "x-protobuf" - -instance MimeUnrender CommitBundleMimeType CommitBundle where - mimeUnrender _ = first T.unpack . deserializeCommitBundle . LBS.toStrict - -instance MimeRender CommitBundleMimeType CommitBundle where - mimeRender _ = LBS.fromStrict . serializeCommitBundle diff --git a/libs/wire-api/src/Wire/API/MLS/SubConversation.hs b/libs/wire-api/src/Wire/API/MLS/SubConversation.hs index 69ec37ade05..29fde7700e2 100644 --- a/libs/wire-api/src/Wire/API/MLS/SubConversation.hs +++ b/libs/wire-api/src/Wire/API/MLS/SubConversation.hs @@ -21,40 +21,88 @@ module Wire.API.MLS.SubConversation where -import Control.Lens (makePrisms) +import Control.Lens (makePrisms, (?~)) import Control.Lens.Tuple (_1) import Control.Monad.Except import Data.Aeson (FromJSON (..), ToJSON (..)) +import Data.Aeson qualified as A +import Data.ByteString.Conversion import Data.Id -import Data.Schema -import Data.Swagger qualified as S +import Data.Json.Util +import Data.OpenApi qualified as S +import Data.Qualified +import Data.Schema hiding (HasField) import Data.Text qualified as T -import Imports +import Data.Time.Clock +import GHC.Records +import Imports hiding (cs) import Servant (FromHttpApiData (..), ToHttpApiData (toQueryParam)) import Test.QuickCheck +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.Credential +import Wire.API.MLS.Epoch +import Wire.API.MLS.Group import Wire.Arbitrary -- | An MLS subconversation ID, which identifies a subconversation within a -- conversation. The pair of a qualified conversation ID and a subconversation -- ID identifies globally. newtype SubConvId = SubConvId {unSubConvId :: Text} - deriving newtype (Eq, ToSchema, Ord) + deriving newtype (Eq, ToSchema, Ord, S.ToParamSchema, ToByteString, ToJSON, FromJSON) deriving stock (Generic) - deriving (Arbitrary) via (GenericUniform SubConvId) - deriving newtype (S.ToParamSchema) deriving stock (Show) instance FromHttpApiData SubConvId where parseQueryParam s = do unless (T.length s > 0) $ throwError "The subconversation ID cannot be empty" - unless (T.all isValid s) $ throwError "The subconversation ID contains invalid characters" + unless (T.length s < 256) $ throwError "The subconversation ID cannot be longer than 255 characters" + unless (T.all isValidSubConvChar s) $ throwError "The subconversation ID contains invalid characters" pure (SubConvId s) - where - isValid c = isPrint c && isAscii c && not (isSpace c) instance ToHttpApiData SubConvId where toQueryParam = unSubConvId +instance Arbitrary SubConvId where + arbitrary = do + n <- choose (1, 255) + cs <- replicateM n (arbitrary `suchThat` isValidSubConvChar) + pure $ SubConvId (T.pack cs) + +isValidSubConvChar :: Char -> Bool +isValidSubConvChar c = isPrint c && isAscii c && not (isSpace c) + +data PublicSubConversation = PublicSubConversation + { pscParentConvId :: Qualified ConvId, + pscSubConvId :: SubConvId, + pscGroupId :: GroupId, + pscEpoch :: Epoch, + -- | It is 'Nothing' when the epoch is 0, and otherwise a timestamp when the + -- epoch was bumped, i.e., it is a timestamp of the most recent commit. + pscEpochTimestamp :: Maybe UTCTime, + pscCipherSuite :: CipherSuiteTag, + pscMembers :: [ClientIdentity] + } + deriving (Eq, Show) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema PublicSubConversation) + +instance ToSchema PublicSubConversation where + schema = + objectWithDocModifier + "PublicSubConversation" + (description ?~ "An MLS subconversation") + $ PublicSubConversation + <$> pscParentConvId .= field "parent_qualified_id" schema + <*> pscSubConvId .= field "subconv_id" schema + <*> pscGroupId .= field "group_id" schema + <*> pscEpoch .= field "epoch" schema + <*> pscEpochTimestamp .= field "epoch_timestamp" schemaEpochTimestamp + <*> pscCipherSuite .= field "cipher_suite" schema + <*> pscMembers .= field "members" (array schema) + +schemaEpochTimestamp :: ValueSchema NamedSwaggerDoc (Maybe UTCTime) +schemaEpochTimestamp = + named "Epoch Timestamp" . nullable . unnamed $ utcTimeSchema + data ConvOrSubTag = ConvTag | SubConvTag deriving (Eq, Enum, Bounded) @@ -73,6 +121,14 @@ deriving via instance (Generic c, Generic s, Arbitrary c, Arbitrary s) => Arbitrary (ConvOrSubChoice c s) +instance HasField "conv" (ConvOrSubChoice c s) c where + getField (Conv c) = c + getField (SubConv c _) = c + +instance HasField "subconv" (ConvOrSubChoice c s) (Maybe s) where + getField (Conv _) = Nothing + getField (SubConv _ s) = Just s + type ConvOrSubConvId = ConvOrSubChoice ConvId SubConvId makePrisms ''ConvOrSubChoice @@ -122,3 +178,20 @@ deriving via Schema ConvOrSubConvId instance FromJSON ConvOrSubConvId deriving via Schema ConvOrSubConvId instance ToJSON ConvOrSubConvId deriving via Schema ConvOrSubConvId instance S.ToSchema ConvOrSubConvId + +-- | The body of the delete subconversation request +data DeleteSubConversationRequest = DeleteSubConversationRequest + { dscGroupId :: GroupId, + dscEpoch :: Epoch + } + deriving (Eq, Show) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via (Schema DeleteSubConversationRequest) + +instance ToSchema DeleteSubConversationRequest where + schema = + objectWithDocModifier + "DeleteSubConversationRequest" + (description ?~ "Delete an MLS subconversation") + $ DeleteSubConversationRequest + <$> dscGroupId .= field "group_id" schema + <*> dscEpoch .= field "epoch" schema diff --git a/libs/wire-api/src/Wire/API/MLS/Validation.hs b/libs/wire-api/src/Wire/API/MLS/Validation.hs new file mode 100644 index 00000000000..2f98d969426 --- /dev/null +++ b/libs/wire-api/src/Wire/API/MLS/Validation.hs @@ -0,0 +1,142 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.MLS.Validation + ( -- * Main key package validation function + validateKeyPackage, + validateLeafNode, + ) +where + +import Control.Applicative +import Control.Error.Util +import Data.ByteArray qualified as BA +import Data.Text.Lazy qualified as LT +import Data.Text.Lazy.Builder qualified as LT +import Data.Text.Lazy.Builder.Int qualified as LT +import Data.X509 qualified as X509 +import Imports hiding (cs) +import Wire.API.MLS.Capabilities +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.Credential +import Wire.API.MLS.KeyPackage +import Wire.API.MLS.LeafNode +import Wire.API.MLS.Lifetime +import Wire.API.MLS.ProtocolVersion +import Wire.API.MLS.Serialisation + +validateKeyPackage :: + Maybe ClientIdentity -> + KeyPackage -> + Either Text (CipherSuiteTag, Lifetime) +validateKeyPackage mIdentity kp = do + -- get ciphersuite + cs <- + maybe + ( Left + ( "Unsupported ciphersuite 0x" + <> LT.toStrict (LT.toLazyText (LT.hexadecimal kp.cipherSuite.cipherSuiteNumber)) + ) + ) + pure + $ cipherSuiteTag kp.cipherSuite + + -- validate signature + unless + ( csVerifySignatureWithLabel + cs + kp.leafNode.signatureKey + "KeyPackageTBS" + kp.tbs + kp.signature_ + ) + $ Left "Invalid KeyPackage signature" + + -- validate protocol version + maybe + (Left "Unsupported protocol version") + pure + (pvTag (kp.protocolVersion) >>= guard . (== ProtocolMLS10)) + + -- validate credential, lifetime and capabilities + validateLeafNode cs mIdentity LeafNodeTBSExtraKeyPackage kp.leafNode + + lt <- case kp.leafNode.source of + LeafNodeSourceKeyPackage lt -> pure lt + -- unreachable + _ -> Left "Unexpected leaf node source" + + pure (cs, lt) + +validateLeafNode :: + CipherSuiteTag -> + Maybe ClientIdentity -> + LeafNodeTBSExtra -> + LeafNode -> + Either Text () +validateLeafNode cs mIdentity extra leafNode = do + let tbs = LeafNodeTBS leafNode.core extra + unless + ( csVerifySignatureWithLabel + cs + leafNode.signatureKey + "LeafNodeTBS" + (mkRawMLS tbs) + leafNode.signature_ + ) + $ Left "Invalid LeafNode signature" + + validateCredential cs leafNode.signatureKey mIdentity leafNode.credential + validateSource extra.tag leafNode.source + validateCapabilities (credentialTag leafNode.credential) leafNode.capabilities + +validateCredential :: CipherSuiteTag -> ByteString -> Maybe ClientIdentity -> Credential -> Either Text () +validateCredential cs pkey mIdentity cred = do + -- FUTUREWORK: check signature in the case of an x509 credential + (identity, mkey) <- + either credentialError pure $ + credentialIdentityAndKey cred + traverse_ (validateCredentialKey (csSignatureScheme cs) pkey) mkey + unless (maybe True (identity ==) mIdentity) $ + Left "client identity does not match credential identity" + where + credentialError e = + Left $ + "Failed to parse identity: " <> e + +validateCredentialKey :: SignatureSchemeTag -> ByteString -> X509.PubKey -> Either Text () +validateCredentialKey Ed25519 pk1 (X509.PubKeyEd25519 pk2) = + note "Certificate public key does not match client's" $ guard (pk1 == BA.convert pk2) +validateCredentialKey _ _ _ = Left "Certificate signature scheme does not match client's public key" + +validateSource :: LeafNodeSourceTag -> LeafNodeSource -> Either Text () +validateSource t s = do + let t' = leafNodeSourceTag s + if t == t' + then pure () + else + Left $ + "Expected '" + <> t.name + <> "' source, got '" + <> t'.name + <> "'" + +validateCapabilities :: CredentialTag -> Capabilities -> Either Text () +validateCapabilities ctag caps = + unless (fromMLSEnum ctag `elem` caps.credentials) $ + Left "missing BasicCredential capability" diff --git a/libs/wire-api/src/Wire/API/MLS/Welcome.hs b/libs/wire-api/src/Wire/API/MLS/Welcome.hs index ac95b53dee0..08028e31219 100644 --- a/libs/wire-api/src/Wire/API/MLS/Welcome.hs +++ b/libs/wire-api/src/Wire/API/MLS/Welcome.hs @@ -17,18 +17,17 @@ module Wire.API.MLS.Welcome where -import Data.Swagger qualified as S +import Data.OpenApi qualified as S import Imports hiding (cs) import Wire.API.MLS.CipherSuite import Wire.API.MLS.Commit -import Wire.API.MLS.Extension import Wire.API.MLS.KeyPackage import Wire.API.MLS.Serialisation import Wire.Arbitrary +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-12.4.3.1-5 data Welcome = Welcome - { welProtocolVersion :: ProtocolVersion, - welCipherSuite :: CipherSuite, + { welCipherSuite :: CipherSuite, welSecrets :: [GroupSecrets], welGroupInfo :: ByteString } @@ -41,18 +40,17 @@ instance S.ToSchema Welcome where instance ParseMLS Welcome where parseMLS = Welcome - <$> parseMLS @ProtocolVersion - <*> parseMLS - <*> parseMLSVector @Word32 parseMLS - <*> parseMLSBytes @Word32 + <$> parseMLS + <*> parseMLSVector @VarInt parseMLS + <*> parseMLSBytes @VarInt instance SerialiseMLS Welcome where - serialiseMLS (Welcome pv cs ss gi) = do - serialiseMLS pv + serialiseMLS (Welcome cs ss gi) = do serialiseMLS cs - serialiseMLSVector @Word32 serialiseMLS ss - serialiseMLSBytes @Word32 gi + serialiseMLSVector @VarInt serialiseMLS ss + serialiseMLSBytes @VarInt gi +-- | https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol-20/draft-ietf-mls-protocol.html#section-12.4.3.1-5 data GroupSecrets = GroupSecrets { gsNewMember :: KeyPackageRef, gsSecrets :: HPKECiphertext diff --git a/libs/wire-api/src/Wire/API/MakesFederatedCall.hs b/libs/wire-api/src/Wire/API/MakesFederatedCall.hs index f1f571106aa..ba24fd4ee16 100644 --- a/libs/wire-api/src/Wire/API/MakesFederatedCall.hs +++ b/libs/wire-api/src/Wire/API/MakesFederatedCall.hs @@ -31,23 +31,26 @@ module Wire.API.MakesFederatedCall ) where +import Control.Lens ((<>~)) import Data.Aeson import Data.Constraint +import Data.HashSet.InsOrd (singleton) import Data.Kind import Data.Metrics.Servant +import Data.OpenApi qualified as S import Data.Proxy import Data.Schema -import Data.Swagger.Operation (addExtensions) import Data.Text qualified as T import GHC.TypeLits import Imports import Servant.API import Servant.Client +import Servant.OpenApi import Servant.Server -import Servant.Swagger import Test.QuickCheck (Arbitrary) import TransitiveAnns.Types import Unsafe.Coerce (unsafeCoerce) +import Wire.API.Routes.Version import Wire.Arbitrary (GenericUniform (..)) -- | This function exists only to provide a convenient place for the @@ -151,26 +154,38 @@ type family ShowComponent (x :: Component) = (res :: Symbol) | res -> x where ShowComponent 'Galley = "galley" ShowComponent 'Cargohold = "cargohold" +type instance + SpecialiseToVersion v (MakesFederatedCall comp name :> api) = + MakesFederatedCall comp name :> SpecialiseToVersion v api + -- | 'MakesFederatedCall' annotates the swagger documentation with an extension -- tag @x-wire-makes-federated-calls-to@. -instance (HasSwagger api, KnownSymbol name, KnownSymbol (ShowComponent comp)) => HasSwagger (MakesFederatedCall comp name :> api :: Type) where - toSwagger _ = - toSwagger (Proxy @api) - & addExtensions - mergeJSONArray - [ ( "wire-makes-federated-call-to", - Array - [ Array - [ String $ T.pack $ symbolVal $ Proxy @(ShowComponent comp), - String $ T.pack $ symbolVal $ Proxy @name - ] - ] +instance (HasOpenApi api, KnownSymbol name, KnownSymbol (ShowComponent comp)) => HasOpenApi (MakesFederatedCall comp name :> api :: Type) where + toOpenApi _ = + toOpenApi (Proxy @api) + -- Since extensions aren't in the openapi3 library yet, + -- and the PRs for their support seem be going no where quickly, I'm using + -- tags instead. https://github.com/biocad/openapi3/pull/43 + -- Basically, this is similar to the old system, except we don't have nested JSON to + -- work with. So I'm using the magic string and sticking the call name on the end + -- and sticking the component in the description. This ordering is important as we + -- can't have duplicate tag names on an object. + + -- Set the tags at the top of OpenApi object + & S.tags + <>~ singleton + ( S.Tag + name + (pure $ T.pack (symbolVal $ Proxy @(ShowComponent comp))) + Nothing ) - ] - -mergeJSONArray :: Value -> Value -> Value -mergeJSONArray (Array x) (Array y) = Array $ x <> y -mergeJSONArray _ _ = error "impossible! bug in construction of federated calls JSON" + -- Set the tags on the specific path we're looking at + -- This is where the tag is actually registered on the path + -- so it can be picked up by fedcalls. + & S.allOperations . S.tags <>~ setName + where + name = "wire-makes-federated-call-to-" <> T.pack (symbolVal $ Proxy @name) + setName = singleton name instance HasClient m api => HasClient m (MakesFederatedCall comp name :> api :: Type) where type Client m (MakesFederatedCall comp name :> api) = Client m api diff --git a/libs/wire-api/src/Wire/API/Message.hs b/libs/wire-api/src/Wire/API/Message.hs index e258cc3e74a..3b651796e21 100644 --- a/libs/wire-api/src/Wire/API/Message.hs +++ b/libs/wire-api/src/Wire/API/Message.hs @@ -67,6 +67,7 @@ import Data.Domain (Domain, domainText, mkDomain) import Data.Id import Data.Json.Util import Data.Map.Strict qualified as Map +import Data.OpenApi qualified as S import Data.ProtoLens qualified as ProtoLens import Data.ProtoLens.Field qualified as ProtoLens import Data.ProtocolBuffers qualified as Protobuf @@ -74,7 +75,6 @@ import Data.Qualified (Qualified (..)) import Data.Schema import Data.Serialize (runGet) import Data.Set qualified as Set -import Data.Swagger qualified as S import Data.Text.Read qualified as Reader import Data.UUID qualified as UUID import Imports @@ -553,7 +553,7 @@ data IgnoreMissing deriving (Show, Eq) instance S.ToParamSchema IgnoreMissing where - toParamSchema _ = mempty & S.type_ ?~ S.SwaggerString + toParamSchema _ = mempty & S.type_ ?~ S.OpenApiString instance FromHttpApiData IgnoreMissing where parseQueryParam = \case @@ -566,7 +566,7 @@ data ReportMissing | ReportMissingList (Set UserId) instance S.ToParamSchema ReportMissing where - toParamSchema _ = mempty & S.type_ ?~ S.SwaggerString + toParamSchema _ = mempty & S.type_ ?~ S.OpenApiString instance FromHttpApiData ReportMissing where parseQueryParam = \case diff --git a/libs/wire-api/src/Wire/API/Notification.hs b/libs/wire-api/src/Wire/API/Notification.hs index 3e808c02ef2..1b7601bce10 100644 --- a/libs/wire-api/src/Wire/API/Notification.hs +++ b/libs/wire-api/src/Wire/API/Notification.hs @@ -20,6 +20,7 @@ module Wire.API.Notification ( NotificationId, + isValidNotificationId, RawNotificationId (..), Event, @@ -41,15 +42,17 @@ import Control.Lens (makeLenses, (.~)) import Control.Lens.Operators ((?~)) import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Aeson.Types qualified as Aeson +import Data.Bits import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap import Data.Id import Data.Json.Util import Data.List.NonEmpty (NonEmpty) +import Data.OpenApi (ToParamSchema (..)) +import Data.OpenApi qualified as S import Data.SOP import Data.Schema -import Data.Swagger (ToParamSchema (..)) -import Data.Swagger qualified as S import Data.Time.Clock (UTCTime) +import Data.UUID qualified as UUID import Imports import Servant import Wire.API.Routes.MultiVerb @@ -80,6 +83,12 @@ eventSchema = mkSchema sdoc Aeson.parseJSON (Just . Aeson.toJSON) ) ] +isValidNotificationId :: NotificationId -> Bool +isValidNotificationId (Id uuid) = + -- check that the version bits are set to 1 + case UUID.toWords uuid of + (_, w, _, _) -> (w `shiftR` 12) .&. 0xf == 1 + -------------------------------------------------------------------------------- -- QueuedNotification diff --git a/libs/wire-api/src/Wire/API/OAuth.hs b/libs/wire-api/src/Wire/API/OAuth.hs index b34b3ae38d6..8b0a8617ad3 100644 --- a/libs/wire-api/src/Wire/API/OAuth.hs +++ b/libs/wire-api/src/Wire/API/OAuth.hs @@ -14,7 +14,6 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -{-# LANGUAGE GeneralizedNewtypeDeriving #-} module Wire.API.OAuth where @@ -31,11 +30,11 @@ import Data.ByteString.Lazy (toStrict) import Data.Either.Combinators (mapLeft) import Data.HashMap.Strict qualified as HM import Data.Id as Id +import Data.OpenApi (ToParamSchema (..)) +import Data.OpenApi qualified as S import Data.Range import Data.Schema import Data.Set qualified as Set -import Data.Swagger (ToParamSchema (..)) -import Data.Swagger qualified as S import Data.Text qualified as T import Data.Text.Ascii import Data.Text.Encoding qualified as TE @@ -45,7 +44,7 @@ import GHC.TypeLits (Nat, symbolVal) import Imports hiding (exp, head) import Prelude.Singletons (Show_) import Servant hiding (Handler, JSON, Tagged, addHeader, respond) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Test.QuickCheck (Arbitrary (..)) import URI.ByteString import URI.ByteString.QQ qualified as URI.QQ @@ -641,8 +640,8 @@ data OAuthError | OAuthInvalidRefreshToken | OAuthInvalidGrant -instance KnownError (MapError e) => IsSwaggerError (e :: OAuthError) where - addToSwagger = addStaticErrorToSwagger @(MapError e) +instance (Typeable (MapError e), KnownError (MapError e)) => IsSwaggerError (e :: OAuthError) where + addToOpenApi = addStaticErrorToSwagger @(MapError e) type instance MapError 'OAuthClientNotFound = 'StaticError 404 "not-found" "OAuth client not found" diff --git a/libs/wire-api/src/Wire/API/Properties.hs b/libs/wire-api/src/Wire/API/Properties.hs index 70e03c71437..debcf9016d7 100644 --- a/libs/wire-api/src/Wire/API/Properties.hs +++ b/libs/wire-api/src/Wire/API/Properties.hs @@ -30,7 +30,7 @@ import Data.Aeson (FromJSON (..), ToJSON (..), Value) import Data.Aeson qualified as A import Data.ByteString.Conversion import Data.Hashable (Hashable) -import Data.Swagger qualified as S +import Data.OpenApi qualified as S import Data.Text.Ascii import Imports import Servant @@ -43,7 +43,7 @@ instance S.ToSchema PropertyKeysAndValues where declareNamedSchema _ = pure $ S.NamedSchema (Just "PropertyKeysAndValues") $ - mempty & S.type_ ?~ S.SwaggerObject + mempty & S.type_ ?~ S.OpenApiObject newtype PropertyKey = PropertyKey {propertyKeyName :: AsciiPrintable} @@ -64,7 +64,7 @@ newtype PropertyKey = PropertyKey instance S.ToParamSchema PropertyKey where toParamSchema _ = mempty - & S.type_ ?~ S.SwaggerString + & S.type_ ?~ S.OpenApiString & S.format ?~ "printable" -- | A raw, unparsed property value. diff --git a/libs/wire-api/src/Wire/API/Provider.hs b/libs/wire-api/src/Wire/API/Provider.hs index 67bec9b77cb..1fc5c34c114 100644 --- a/libs/wire-api/src/Wire/API/Provider.hs +++ b/libs/wire-api/src/Wire/API/Provider.hs @@ -1,6 +1,5 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} {-# LANGUAGE StrictData #-} -{-# LANGUAGE TemplateHaskell #-} -- This file is part of the Wire Server implementation. -- @@ -52,12 +51,12 @@ module Wire.API.Provider ) where -import Data.Aeson -import Data.Aeson.TH +import Data.Aeson qualified as A import Data.Id -import Data.Json.Util import Data.Misc (HttpsUrl (..), PlainTextPassword6, PlainTextPassword8) +import Data.OpenApi qualified as S import Data.Range +import Data.Schema import Imports import Wire.API.Conversation.Code as Code import Wire.API.Provider.Service (ServiceToken (..)) @@ -79,32 +78,24 @@ data Provider = Provider } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform Provider) - -instance ToJSON Provider where - toJSON p = - object $ - "id" .= providerId p - # "name" .= providerName p - # "email" .= providerEmail p - # "url" .= providerUrl p - # "description" .= providerDescr p - # [] - -instance FromJSON Provider where - parseJSON = withObject "Provider" $ \o -> - Provider - <$> o .: "id" - <*> o .: "name" - <*> o .: "email" - <*> o .: "url" - <*> o .: "description" + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema Provider + +instance ToSchema Provider where + schema = + object "Provider" $ + Provider + <$> providerId .= field "id" schema + <*> providerName .= field "name" schema + <*> providerEmail .= field "email" schema + <*> providerUrl .= field "url" schema + <*> providerDescr .= field "description" schema -- | A provider profile as seen by regular users. -- Note: This is a placeholder that may evolve to contain only a subset of -- the full provider information. newtype ProviderProfile = ProviderProfile Provider deriving stock (Eq, Show) - deriving newtype (FromJSON, ToJSON, Arbitrary) + deriving newtype (A.FromJSON, A.ToJSON, Arbitrary, S.ToSchema) -------------------------------------------------------------------------------- -- NewProvider @@ -120,25 +111,17 @@ data NewProvider = NewProvider } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform NewProvider) - -instance ToJSON NewProvider where - toJSON p = - object $ - "name" .= newProviderName p - # "email" .= newProviderEmail p - # "url" .= newProviderUrl p - # "description" .= newProviderDescr p - # "password" .= newProviderPassword p - # [] - -instance FromJSON NewProvider where - parseJSON = withObject "NewProvider" $ \o -> - NewProvider - <$> o .: "name" - <*> o .: "email" - <*> o .: "url" - <*> o .: "description" - <*> o .:? "password" + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema NewProvider + +instance ToSchema NewProvider where + schema = + object "NewProvider" $ + NewProvider + <$> newProviderName .= field "name" schema + <*> newProviderEmail .= field "email" schema + <*> newProviderUrl .= field "url" schema + <*> newProviderDescr .= field "description" schema + <*> newProviderPassword .= maybe_ (optField "password" schema) -- | Response data upon registering a new provider. data NewProviderResponse = NewProviderResponse @@ -149,19 +132,14 @@ data NewProviderResponse = NewProviderResponse } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform NewProviderResponse) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema NewProviderResponse -instance ToJSON NewProviderResponse where - toJSON r = - object $ - "id" .= rsNewProviderId r - # "password" .= rsNewProviderPassword r - # [] - -instance FromJSON NewProviderResponse where - parseJSON = withObject "NewProviderResponse" $ \o -> - NewProviderResponse - <$> o .: "id" - <*> o .:? "password" +instance ToSchema NewProviderResponse where + schema = + object "NewProviderResponse" $ + NewProviderResponse + <$> rsNewProviderId .= field "id" schema + <*> rsNewProviderPassword .= maybe_ (optField "password" schema) -------------------------------------------------------------------------------- -- UpdateProvider @@ -174,21 +152,15 @@ data UpdateProvider = UpdateProvider } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UpdateProvider) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema UpdateProvider -instance ToJSON UpdateProvider where - toJSON p = - object $ - "name" .= updateProviderName p - # "url" .= updateProviderUrl p - # "description" .= updateProviderDescr p - # [] - -instance FromJSON UpdateProvider where - parseJSON = withObject "UpdateProvider" $ \o -> - UpdateProvider - <$> o .:? "name" - <*> o .:? "url" - <*> o .:? "description" +instance ToSchema UpdateProvider where + schema = + object "UpdateProvider" $ + UpdateProvider + <$> updateProviderName .= maybe_ (optField "name" schema) + <*> updateProviderUrl .= maybe_ (optField "url" schema) + <*> updateProviderDescr .= maybe_ (optField "description" schema) -------------------------------------------------------------------------------- -- ProviderActivationResponse @@ -199,14 +171,13 @@ newtype ProviderActivationResponse = ProviderActivationResponse {activatedProviderIdentity :: Email} deriving stock (Eq, Show) deriving newtype (Arbitrary) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema ProviderActivationResponse -instance ToJSON ProviderActivationResponse where - toJSON (ProviderActivationResponse e) = - object ["email" .= e] - -instance FromJSON ProviderActivationResponse where - parseJSON = withObject "ProviderActivationResponse" $ \o -> - ProviderActivationResponse <$> o .: "email" +instance ToSchema ProviderActivationResponse where + schema = + object "ProviderActivationResponse" $ + ProviderActivationResponse + <$> activatedProviderIdentity .= field "email" schema -------------------------------------------------------------------------------- -- ProviderLogin @@ -218,19 +189,14 @@ data ProviderLogin = ProviderLogin } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform ProviderLogin) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema ProviderLogin -instance ToJSON ProviderLogin where - toJSON l = - object - [ "email" .= providerLoginEmail l, - "password" .= providerLoginPassword l - ] - -instance FromJSON ProviderLogin where - parseJSON = withObject "ProviderLogin" $ \o -> - ProviderLogin - <$> o .: "email" - <*> o .: "password" +instance ToSchema ProviderLogin where + schema = + object "ProviderLogin" $ + ProviderLogin + <$> providerLoginEmail .= field "email" schema + <*> providerLoginPassword .= field "password" schema -------------------------------------------------------------------------------- -- DeleteProvider @@ -240,51 +206,71 @@ newtype DeleteProvider = DeleteProvider {deleteProviderPassword :: PlainTextPassword6} deriving stock (Eq, Show) deriving newtype (Arbitrary) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema DeleteProvider -instance ToJSON DeleteProvider where - toJSON d = - object - [ "password" .= deleteProviderPassword d - ] - -instance FromJSON DeleteProvider where - parseJSON = withObject "DeleteProvider" $ \o -> - DeleteProvider <$> o .: "password" +instance ToSchema DeleteProvider where + schema = + object "DeleteProvider" $ + DeleteProvider + <$> deleteProviderPassword .= field "password" schema -------------------------------------------------------------------------------- -- Password Change/Reset -- | The payload for initiating a password reset. -newtype PasswordReset = PasswordReset {nprEmail :: Email} +newtype PasswordReset = PasswordReset {email :: Email} deriving stock (Eq, Show) deriving newtype (Arbitrary) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema PasswordReset -deriveJSON toJSONFieldName ''PasswordReset +instance ToSchema PasswordReset where + schema = + object "PasswordReset" $ + PasswordReset + <$> (.email) .= field "email" schema -- | The payload for completing a password reset. data CompletePasswordReset = CompletePasswordReset - { cpwrKey :: Code.Key, - cpwrCode :: Code.Value, - cpwrPassword :: PlainTextPassword6 + { key :: Code.Key, + code :: Code.Value, + password :: PlainTextPassword6 } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform CompletePasswordReset) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema CompletePasswordReset -deriveJSON toJSONFieldName ''CompletePasswordReset +instance ToSchema CompletePasswordReset where + schema = + object "CompletePasswordReset" $ + CompletePasswordReset + <$> key .= field "key" schema + <*> (.code) .= field "code" schema + <*> (.password) .= field "password" schema -- | The payload for changing a password. data PasswordChange = PasswordChange - { cpOldPassword :: PlainTextPassword6, - cpNewPassword :: PlainTextPassword6 + { oldPassword :: PlainTextPassword6, + newPassword :: PlainTextPassword6 } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform PasswordChange) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema PasswordChange -deriveJSON toJSONFieldName ''PasswordChange +instance ToSchema PasswordChange where + schema = + object "PasswordChange" $ + PasswordChange + <$> oldPassword .= field "old_password" schema + <*> newPassword .= field "new_password" schema -- | The payload for updating an email address -newtype EmailUpdate = EmailUpdate {euEmail :: Email} +newtype EmailUpdate = EmailUpdate {email :: Email} deriving stock (Eq, Show, Generic) deriving newtype (Arbitrary) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema EmailUpdate -deriveJSON toJSONFieldName ''EmailUpdate +instance ToSchema EmailUpdate where + schema = + object "EmailUpdate" $ + EmailUpdate + <$> (.email) .= field "email" schema diff --git a/libs/wire-api/src/Wire/API/Provider/Bot.hs b/libs/wire-api/src/Wire/API/Provider/Bot.hs index b8aabf3af01..e8a1f5b1c4a 100644 --- a/libs/wire-api/src/Wire/API/Provider/Bot.hs +++ b/libs/wire-api/src/Wire/API/Provider/Bot.hs @@ -31,10 +31,11 @@ module Wire.API.Provider.Bot where import Control.Lens (makeLenses) -import Data.Aeson +import Data.Aeson qualified as A import Data.Handle (Handle) import Data.Id -import Data.Json.Util ((#)) +import Data.OpenApi qualified as S +import Data.Schema import Imports import Wire.API.Conversation.Member (OtherMember (..)) import Wire.API.User.Profile (ColourId, Name) @@ -51,25 +52,19 @@ data BotConvView = BotConvView } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform BotConvView) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema BotConvView + +instance ToSchema BotConvView where + schema = + object "BotConvView" $ + BotConvView + <$> _botConvId .= field "id" schema + <*> _botConvName .= maybe_ (optField "name" schema) + <*> _botConvMembers .= field "members" (array schema) botConvView :: ConvId -> Maybe Text -> [OtherMember] -> BotConvView botConvView = BotConvView -instance ToJSON BotConvView where - toJSON c = - object $ - "id" .= _botConvId c - # "name" .= _botConvName c - # "members" .= _botConvMembers c - # [] - -instance FromJSON BotConvView where - parseJSON = withObject "BotConvView" $ \o -> - BotConvView - <$> o .: "id" - <*> o .:? "name" - <*> o .: "members" - -------------------------------------------------------------------------------- -- BotUserView @@ -82,24 +77,16 @@ data BotUserView = BotUserView } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform BotUserView) + deriving (A.ToJSON, A.FromJSON, S.ToSchema) via Schema BotUserView -instance ToJSON BotUserView where - toJSON u = - object - [ "id" .= botUserViewId u, - "name" .= botUserViewName u, - "accent_id" .= botUserViewColour u, - "handle" .= botUserViewHandle u, - "team" .= botUserViewTeam u - ] - -instance FromJSON BotUserView where - parseJSON = withObject "BotUserView" $ \o -> - BotUserView - <$> o .: "id" - <*> o .: "name" - <*> o .: "accent_id" - <*> o .:? "handle" - <*> o .:? "team" +instance ToSchema BotUserView where + schema = + object "BotUserView" $ + BotUserView + <$> botUserViewId .= field "id" schema + <*> botUserViewName .= field "name" schema + <*> botUserViewColour .= field "accent_id" schema + <*> botUserViewHandle .= optField "handle" (maybeWithDefault A.Null schema) + <*> botUserViewTeam .= optField "team" (maybeWithDefault A.Null schema) makeLenses ''BotConvView diff --git a/libs/wire-api/src/Wire/API/Provider/Service.hs b/libs/wire-api/src/Wire/API/Provider/Service.hs index 28a1e5609a1..7b181183e1e 100644 --- a/libs/wire-api/src/Wire/API/Provider/Service.hs +++ b/libs/wire-api/src/Wire/API/Provider/Service.hs @@ -47,6 +47,7 @@ module Wire.API.Provider.Service -- * UpdateServiceWhitelist UpdateServiceWhitelist (..), + UpdateServiceWhitelistResp (..), ) where @@ -58,19 +59,20 @@ import Data.ByteString.Builder qualified as BB import Data.ByteString.Char8 qualified as BS import Data.ByteString.Conversion import Data.Id -import Data.Json.Util ((#)) import Data.List1 (List1) import Data.Misc (HttpsUrl (..), PlainTextPassword6) +import Data.OpenApi qualified as S import Data.PEM (PEM, pemParseBS, pemWriteLBS) import Data.Proxy -import Data.Range (Range) +import Data.Range (Range, fromRange, rangedSchema) +import Data.SOP import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text import Data.Text.Ascii import Data.Text.Encoding qualified as Text import Imports import Wire.API.Provider.Service.Tag (ServiceTag (..)) +import Wire.API.Routes.MultiVerb import Wire.API.User.Profile (Asset, Name) import Wire.Arbitrary (Arbitrary (arbitrary), GenericUniform (..)) @@ -205,35 +207,22 @@ data Service = Service } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform Service) + deriving (S.ToSchema, ToJSON, FromJSON) via (Schema Service) -instance ToJSON Service where - toJSON s = - A.object $ - "id" A..= serviceId s - # "name" A..= serviceName s - # "summary" A..= serviceSummary s - # "description" A..= serviceDescr s - # "base_url" A..= serviceUrl s - # "auth_tokens" A..= serviceTokens s - # "public_keys" A..= serviceKeys s - # "assets" A..= serviceAssets s - # "tags" A..= serviceTags s - # "enabled" A..= serviceEnabled s - # [] - -instance FromJSON Service where - parseJSON = A.withObject "Service" $ \o -> - Service - <$> o A..: "id" - <*> o A..: "name" - <*> o A..: "summary" - <*> o A..: "description" - <*> o A..: "base_url" - <*> o A..: "auth_tokens" - <*> o A..: "public_keys" - <*> o A..: "assets" - <*> o A..: "tags" - <*> o A..: "enabled" +instance ToSchema Service where + schema = + object "Service" $ + Service + <$> serviceId .= field "id" schema + <*> serviceName .= field "name" schema + <*> serviceSummary .= field "summary" schema + <*> serviceDescr .= field "description" schema + <*> serviceUrl .= field "base_url" schema + <*> serviceTokens .= field "auth_tokens" schema + <*> serviceKeys .= field "public_keys" schema + <*> serviceAssets .= field "assets" (array schema) + <*> serviceTags .= field "tags" (set schema) + <*> serviceEnabled .= field "enabled" schema -- | A /secret/ bearer token used to authenticate and authorise requests @towards@ -- a 'Service' via inclusion in the HTTP 'Authorization' header. @@ -265,31 +254,20 @@ data ServiceProfile = ServiceProfile } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform ServiceProfile) + deriving (S.ToSchema, ToJSON, FromJSON) via (Schema ServiceProfile) -instance ToJSON ServiceProfile where - toJSON s = - A.object $ - "id" A..= serviceProfileId s - # "provider" A..= serviceProfileProvider s - # "name" A..= serviceProfileName s - # "summary" A..= serviceProfileSummary s - # "description" A..= serviceProfileDescr s - # "assets" A..= serviceProfileAssets s - # "tags" A..= serviceProfileTags s - # "enabled" A..= serviceProfileEnabled s - # [] - -instance FromJSON ServiceProfile where - parseJSON = A.withObject "ServiceProfile" $ \o -> - ServiceProfile - <$> o A..: "id" - <*> o A..: "provider" - <*> o A..: "name" - <*> o A..: "summary" - <*> o A..: "description" - <*> o A..: "assets" - <*> o A..: "tags" - <*> o A..: "enabled" +instance ToSchema ServiceProfile where + schema = + object "ServiceProfile" $ + ServiceProfile + <$> serviceProfileId .= field "id" schema + <*> serviceProfileProvider .= field "provider" schema + <*> serviceProfileName .= field "name" schema + <*> serviceProfileSummary .= field "summary" schema + <*> serviceProfileDescr .= field "description" schema + <*> serviceProfileAssets .= field "assets" (array schema) + <*> serviceProfileTags .= field "tags" (set schema) + <*> serviceProfileEnabled .= field "enabled" schema -------------------------------------------------------------------------------- -- ServiceProfilePage @@ -300,19 +278,14 @@ data ServiceProfilePage = ServiceProfilePage } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform ServiceProfilePage) + deriving (S.ToSchema, FromJSON, ToJSON) via (Schema ServiceProfilePage) -instance ToJSON ServiceProfilePage where - toJSON p = - A.object - [ "has_more" A..= serviceProfilePageHasMore p, - "services" A..= serviceProfilePageResults p - ] - -instance FromJSON ServiceProfilePage where - parseJSON = A.withObject "ServiceProfilePage" $ \o -> - ServiceProfilePage - <$> o A..: "has_more" - <*> o A..: "services" +instance ToSchema ServiceProfilePage where + schema = + object "ServiceProfile" $ + ServiceProfilePage + <$> serviceProfilePageHasMore .= field "has_more" schema + <*> serviceProfilePageResults .= field "services" (array schema) -------------------------------------------------------------------------------- -- NewService @@ -330,31 +303,20 @@ data NewService = NewService } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform NewService) + deriving (S.ToSchema, ToJSON, FromJSON) via (Schema NewService) -instance ToJSON NewService where - toJSON s = - A.object $ - "name" A..= newServiceName s - # "summary" A..= newServiceSummary s - # "description" A..= newServiceDescr s - # "base_url" A..= newServiceUrl s - # "public_key" A..= newServiceKey s - # "auth_token" A..= newServiceToken s - # "assets" A..= newServiceAssets s - # "tags" A..= newServiceTags s - # [] - -instance FromJSON NewService where - parseJSON = A.withObject "NewService" $ \o -> - NewService - <$> o A..: "name" - <*> o A..: "summary" - <*> o A..: "description" - <*> o A..: "base_url" - <*> o A..: "public_key" - <*> o A..:? "auth_token" - <*> o A..:? "assets" A..!= [] - <*> o A..: "tags" +instance ToSchema NewService where + schema = + object "NewService" $ + NewService + <$> newServiceName .= field "name" schema + <*> newServiceSummary .= field "summary" schema + <*> newServiceDescr .= field "description" schema + <*> newServiceUrl .= field "base_url" schema + <*> newServiceKey .= field "public_key" schema + <*> newServiceToken .= maybe_ (optField "auth_token" schema) + <*> newServiceAssets .= field "assets" (array schema) + <*> newServiceTags .= field "tags" (fromRange .= rangedSchema (set schema)) -- | Response data upon adding a new service. data NewServiceResponse = NewServiceResponse @@ -366,19 +328,14 @@ data NewServiceResponse = NewServiceResponse } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform NewServiceResponse) + deriving (S.ToSchema, ToJSON, FromJSON) via (Schema NewServiceResponse) -instance ToJSON NewServiceResponse where - toJSON r = - A.object $ - "id" A..= rsNewServiceId r - # "auth_token" A..= rsNewServiceToken r - # [] - -instance FromJSON NewServiceResponse where - parseJSON = A.withObject "NewServiceResponse" $ \o -> - NewServiceResponse - <$> o A..: "id" - <*> o A..:? "auth_token" +instance ToSchema NewServiceResponse where + schema = + object "NewServiceResponse" $ + NewServiceResponse + <$> rsNewServiceId .= field "id" schema + <*> rsNewServiceToken .= maybe_ (optField "auth_token" schema) -------------------------------------------------------------------------------- -- UpdateService @@ -393,25 +350,17 @@ data UpdateService = UpdateService } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UpdateService) + deriving (S.ToSchema, FromJSON, ToJSON) via (Schema UpdateService) -instance ToJSON UpdateService where - toJSON u = - A.object $ - "name" A..= updateServiceName u - # "summary" A..= updateServiceSummary u - # "description" A..= updateServiceDescr u - # "assets" A..= updateServiceAssets u - # "tags" A..= updateServiceTags u - # [] - -instance FromJSON UpdateService where - parseJSON = A.withObject "UpdateService" $ \o -> - UpdateService - <$> o A..:? "name" - <*> o A..:? "summary" - <*> o A..:? "description" - <*> o A..:? "assets" - <*> o A..:? "tags" +instance ToSchema UpdateService where + schema = + object "UpdateService" $ + UpdateService + <$> updateServiceName .= maybe_ (optField "name" schema) + <*> updateServiceSummary .= maybe_ (optField "summary" schema) + <*> updateServiceDescr .= maybe_ (optField "description" schema) + <*> updateServiceAssets .= maybe_ (optField "assets" $ array schema) + <*> updateServiceTags .= maybe_ (optField "tags" (fromRange .= rangedSchema (set schema))) -------------------------------------------------------------------------------- -- UpdateServiceConn @@ -427,29 +376,21 @@ data UpdateServiceConn = UpdateServiceConn } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UpdateServiceConn) + deriving (S.ToSchema, ToJSON, FromJSON) via (Schema UpdateServiceConn) + +instance ToSchema UpdateServiceConn where + schema = + object "UpdateServiceConn" $ + UpdateServiceConn + <$> updateServiceConnPassword .= field "password" schema + <*> updateServiceConnUrl .= maybe_ (optField "base_url" schema) + <*> updateServiceConnKeys .= maybe_ (optField "public_keys" (fromRange .= rangedSchema (array schema))) + <*> updateServiceConnTokens .= maybe_ (optField "auth_tokens" (fromRange .= rangedSchema (array schema))) + <*> updateServiceConnEnabled .= maybe_ (optField "enabled" schema) mkUpdateServiceConn :: PlainTextPassword6 -> UpdateServiceConn mkUpdateServiceConn pw = UpdateServiceConn pw Nothing Nothing Nothing Nothing -instance ToJSON UpdateServiceConn where - toJSON u = - A.object $ - "password" A..= updateServiceConnPassword u - # "base_url" A..= updateServiceConnUrl u - # "public_keys" A..= updateServiceConnKeys u - # "auth_tokens" A..= updateServiceConnTokens u - # "enabled" A..= updateServiceConnEnabled u - # [] - -instance FromJSON UpdateServiceConn where - parseJSON = A.withObject "UpdateServiceConn" $ \o -> - UpdateServiceConn - <$> o A..: "password" - <*> o A..:? "base_url" - <*> o A..:? "public_keys" - <*> o A..:? "auth_tokens" - <*> o A..:? "enabled" - -------------------------------------------------------------------------------- -- DeleteService @@ -458,16 +399,13 @@ newtype DeleteService = DeleteService {deleteServicePassword :: PlainTextPassword6} deriving stock (Eq, Show) deriving newtype (Arbitrary) + deriving (S.ToSchema, ToJSON, FromJSON) via (Schema DeleteService) -instance ToJSON DeleteService where - toJSON d = - A.object - [ "password" A..= deleteServicePassword d - ] - -instance FromJSON DeleteService where - parseJSON = A.withObject "DeleteService" $ \o -> - DeleteService <$> o A..: "password" +instance ToSchema DeleteService where + schema = + object "DeleteService" $ + DeleteService + <$> deleteServicePassword .= field "password" schema -------------------------------------------------------------------------------- -- UpdateServiceWhitelist @@ -479,18 +417,30 @@ data UpdateServiceWhitelist = UpdateServiceWhitelist } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform UpdateServiceWhitelist) + deriving (ToJSON, FromJSON, S.ToSchema) via (Schema UpdateServiceWhitelist) -instance ToJSON UpdateServiceWhitelist where - toJSON u = - A.object - [ "provider" A..= updateServiceWhitelistProvider u, - "id" A..= updateServiceWhitelistService u, - "whitelisted" A..= updateServiceWhitelistStatus u - ] - -instance FromJSON UpdateServiceWhitelist where - parseJSON = A.withObject "UpdateServiceWhitelist" $ \o -> - UpdateServiceWhitelist - <$> o A..: "provider" - <*> o A..: "id" - <*> o A..: "whitelisted" +instance ToSchema UpdateServiceWhitelist where + schema = + object "UpdateServiceWhitelist" $ + UpdateServiceWhitelist + <$> updateServiceWhitelistProvider .= field "provider" schema + <*> updateServiceWhitelistService .= field "id" schema + <*> updateServiceWhitelistStatus .= field "whitelisted" schema + +data UpdateServiceWhitelistResp + = UpdateServiceWhitelistRespChanged + | UpdateServiceWhitelistRespUnchanged + +-- basically the same as the instance for CheckBlacklistResponse +instance + AsUnion + '[ RespondEmpty 200 "UpdateServiceWhitelistRespChanged", + RespondEmpty 204 "UpdateServiceWhitelistRespUnchanged" + ] + UpdateServiceWhitelistResp + where + toUnion UpdateServiceWhitelistRespChanged = Z (I ()) + toUnion UpdateServiceWhitelistRespUnchanged = S (Z (I ())) + fromUnion (Z (I ())) = UpdateServiceWhitelistRespChanged + fromUnion (S (Z (I ()))) = UpdateServiceWhitelistRespUnchanged + fromUnion (S (S x)) = case x of {} diff --git a/libs/wire-api/src/Wire/API/Provider/Service/Tag.hs b/libs/wire-api/src/Wire/API/Provider/Service/Tag.hs index 522c519ff87..1df9b6a14bc 100644 --- a/libs/wire-api/src/Wire/API/Provider/Service/Tag.hs +++ b/libs/wire-api/src/Wire/API/Provider/Service/Tag.hs @@ -39,18 +39,26 @@ module Wire.API.Provider.Service.Tag ) where -import Data.Aeson (FromJSON (parseJSON), ToJSON (toJSON)) -import Data.Aeson qualified as JSON +import Control.Lens (Prism', prism) +import Data.Aeson (FromJSON, ToJSON (toJSON)) +import Data.Attoparsec.ByteString (IResult (..), parse) +import Data.ByteString (toStrict) import Data.ByteString.Builder qualified as BB import Data.ByteString.Char8 qualified as C8 import Data.ByteString.Conversion -import Data.Range (Range, fromRange) +import Data.OpenApi qualified as S +import Data.Range (Range, fromRange, rangedSchema) import Data.Range qualified as Range +import Data.Schema import Data.Set qualified as Set +import Data.Text qualified as Text +import Data.Text.Encoding (decodeUtf8With) import Data.Text.Encoding qualified as Text +import Data.Text.Encoding.Error (lenientDecode) import Data.Type.Ord import GHC.TypeLits (KnownNat, Nat) import Imports +import Web.HttpApiData (FromHttpApiData (parseUrlPiece)) import Wire.Arbitrary (Arbitrary (..), GenericUniform (..)) -------------------------------------------------------------------------------- @@ -59,6 +67,13 @@ import Wire.Arbitrary (Arbitrary (..), GenericUniform (..)) newtype ServiceTagList = ServiceTagList [ServiceTag] deriving stock (Eq, Ord, Show) deriving newtype (FromJSON, ToJSON, Arbitrary) + deriving (S.ToSchema) via (Schema ServiceTagList) + +_ServiceTagList :: Prism' ServiceTagList [ServiceTag] +_ServiceTagList = prism ServiceTagList (\(ServiceTagList l) -> pure l) + +instance ToSchema ServiceTagList where + schema = named "ServiceTagList" $ tag _ServiceTagList $ array schema -- | A fixed enumeration of tags for services. data ServiceTag @@ -95,6 +110,7 @@ data ServiceTag | WeatherTag deriving stock (Eq, Show, Ord, Enum, Bounded, Generic) deriving (Arbitrary) via (GenericUniform ServiceTag) + deriving (S.ToSchema, ToJSON, FromJSON) via (Schema ServiceTag) instance FromByteString ServiceTag where parser = @@ -165,13 +181,15 @@ instance ToByteString ServiceTag where builder VideoTag = "video" builder WeatherTag = "weather" -instance ToJSON ServiceTag where - toJSON = JSON.String . Text.decodeUtf8 . toByteString' +instance ToSchema ServiceTag where + schema = enum @Text "" . mconcat $ (\a -> element (decodeUtf8With lenientDecode $ toStrict $ toByteString a) a) <$> [minBound ..] -instance FromJSON ServiceTag where - parseJSON = - JSON.withText "ServiceTag" $ - either fail pure . runParser parser . Text.encodeUtf8 +instance S.ToParamSchema ServiceTag where + toParamSchema _ = + mempty + { S._schemaType = Just S.OpenApiString, + S._schemaEnum = Just (toJSON <$> [(minBound :: ServiceTag) ..]) + } -------------------------------------------------------------------------------- -- Bounded ServiceTag Queries @@ -181,6 +199,19 @@ newtype QueryAnyTags (m :: Nat) (n :: Nat) = QueryAnyTags {queryAnyTagsRange :: Range m n (Set (QueryAllTags m n))} deriving stock (Eq, Show, Ord) +instance (m <= n) => S.ToParamSchema (QueryAnyTags m n) where + toParamSchema _ = + mempty + { S._schemaType = Just S.OpenApiString, + S._schemaEnum = Just (toJSON <$> [(minBound :: ServiceTag) ..]) + } + +instance (KnownNat n, KnownNat m, m <= n) => ToSchema (QueryAnyTags m n) where + schema = + let sch :: ValueSchema NamedSwaggerDoc (Range m n (Set (QueryAllTags m n))) + sch = fromRange .= rangedSchema (named "QueryAnyTags" $ set schema) + in queryAnyTagsRange .= (QueryAnyTags <$> sch) + instance (KnownNat m, KnownNat n, m <= n) => Arbitrary (QueryAnyTags m n) where arbitrary = QueryAnyTags <$> arbitrary @@ -207,11 +238,31 @@ instance (KnownNat n, KnownNat m, m <= n) => FromByteString (QueryAnyTags m n) w rs <- either fail pure (Range.checkedEither (Set.fromList ts)) pure $! QueryAnyTags rs +runPartial :: IsString i => Bool -> IResult i b -> Either Text b +runPartial alreadyRun result = case result of + Fail _ _ e -> Left $ Text.pack e + Partial f -> + if alreadyRun + then Left "A partial parse returned another partial parse." + else runPartial True $ f "" + Done _ r -> pure r + +instance (KnownNat n, KnownNat m, m <= n) => FromHttpApiData (QueryAnyTags m n) where + parseUrlPiece t = do + txt <- parseUrlPiece t + runPartial False $ parse parser $ Text.encodeUtf8 txt + -- | Bounded logical conjunction of 'm' to 'n' 'ServiceTag's to match. newtype QueryAllTags (m :: Nat) (n :: Nat) = QueryAllTags {queryAllTagsRange :: Range m n (Set ServiceTag)} deriving stock (Eq, Show, Ord) +instance (KnownNat n, KnownNat m, m <= n) => ToSchema (QueryAllTags m n) where + schema = + let sch :: ValueSchema NamedSwaggerDoc (Range m n (Set ServiceTag)) + sch = fromRange .= rangedSchema (named "QueryAllTags" $ set schema) + in queryAllTagsRange .= (QueryAllTags <$> sch) + instance (KnownNat m, KnownNat n, m <= n) => Arbitrary (QueryAllTags m n) where arbitrary = QueryAllTags <$> arbitrary @@ -236,6 +287,11 @@ instance (KnownNat m, KnownNat n, m <= n) => FromByteString (QueryAllTags m n) w rs <- either fail pure (Range.checkedEither (Set.fromList ts)) pure $! QueryAllTags rs +instance (KnownNat n, KnownNat m, m <= n) => FromHttpApiData (QueryAllTags m n) where + parseUrlPiece t = do + txt <- parseUrlPiece t + runPartial False $ parse parser $ Text.encodeUtf8 txt + -------------------------------------------------------------------------------- -- ServiceTag Matchers diff --git a/libs/wire-api/src/Wire/API/Push/V2/Token.hs b/libs/wire-api/src/Wire/API/Push/V2/Token.hs index ee8e828670d..0cf7b292af4 100644 --- a/libs/wire-api/src/Wire/API/Push/V2/Token.hs +++ b/libs/wire-api/src/Wire/API/Push/V2/Token.hs @@ -47,10 +47,10 @@ import Data.Aeson qualified as A import Data.Attoparsec.ByteString (takeByteString) import Data.ByteString.Conversion import Data.Id +import Data.OpenApi (ToParamSchema) +import Data.OpenApi qualified as S import Data.SOP import Data.Schema -import Data.Swagger (ToParamSchema) -import Data.Swagger qualified as S import Generics.SOP qualified as GSOP import Imports import Servant diff --git a/libs/wire-api/src/Wire/API/RawJson.hs b/libs/wire-api/src/Wire/API/RawJson.hs index fd0517ea289..08529ded900 100644 --- a/libs/wire-api/src/Wire/API/RawJson.hs +++ b/libs/wire-api/src/Wire/API/RawJson.hs @@ -20,7 +20,7 @@ module Wire.API.RawJson where import Control.Lens -import Data.Swagger qualified as Swagger +import Data.OpenApi qualified as Swagger import Imports import Servant import Test.QuickCheck @@ -43,6 +43,6 @@ instance Swagger.ToSchema RawJson where declareNamedSchema _ = pure . Swagger.NamedSchema (Just "RawJson") $ mempty - & Swagger.type_ ?~ Swagger.SwaggerObject + & Swagger.type_ ?~ Swagger.OpenApiObject & Swagger.description ?~ "Any JSON as plain string. The object structure is not specified in this schema." diff --git a/libs/wire-api/src/Wire/API/Routes/API.hs b/libs/wire-api/src/Wire/API/Routes/API.hs index 607933e2ed4..23ac38e6fed 100644 --- a/libs/wire-api/src/Wire/API/Routes/API.hs +++ b/libs/wire-api/src/Wire/API/Routes/API.hs @@ -16,7 +16,8 @@ -- with this program. If not, see . module Wire.API.Routes.API - ( API, + ( ServiceAPI (..), + API, hoistAPIHandler, hoistAPI, mkAPI, @@ -29,14 +30,28 @@ module Wire.API.Routes.API where import Data.Domain +import Data.Kind +import Data.OpenApi qualified as S import Data.Proxy import Imports import Polysemy import Polysemy.Error import Polysemy.Internal import Servant hiding (Union) +import Servant.OpenApi import Wire.API.Error import Wire.API.Routes.Named +import Wire.API.Routes.Version + +class ServiceAPI service (v :: Version) where + type ServiceAPIRoutes service + type SpecialisedAPIRoutes v service :: Type + type SpecialisedAPIRoutes v service = SpecialiseToVersion v (ServiceAPIRoutes service) + serviceSwagger :: HasOpenApi (SpecialisedAPIRoutes v service) => S.OpenApi + serviceSwagger = toOpenApi (Proxy @(SpecialisedAPIRoutes v service)) + +instance ServiceAPI VersionAPITag v where + type ServiceAPIRoutes VersionAPITag = VersionAPI -- | A Servant handler on a polysemy stack. This is used to help with type inference. newtype API api r = API {unAPI :: ServerT api (Sem r)} diff --git a/libs/wire-api/src/Wire/API/Routes/AssetBody.hs b/libs/wire-api/src/Wire/API/Routes/AssetBody.hs index 2b6989d308b..4998c10f538 100644 --- a/libs/wire-api/src/Wire/API/Routes/AssetBody.hs +++ b/libs/wire-api/src/Wire/API/Routes/AssetBody.hs @@ -25,13 +25,13 @@ where import Conduit import Data.ByteString.Lazy qualified as LBS -import Data.Swagger -import Data.Swagger.Internal.Schema +import Data.OpenApi +import Data.OpenApi.Internal.Schema import Imports import Network.HTTP.Media ((//)) import Servant import Servant.Conduit () -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () data MultipartMixed diff --git a/libs/wire-api/src/Wire/API/Routes/Bearer.hs b/libs/wire-api/src/Wire/API/Routes/Bearer.hs index b2b0c1918eb..64a1baed79f 100644 --- a/libs/wire-api/src/Wire/API/Routes/Bearer.hs +++ b/libs/wire-api/src/Wire/API/Routes/Bearer.hs @@ -21,11 +21,12 @@ import Control.Lens ((<>~)) import Data.ByteString qualified as BS import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap import Data.Metrics.Servant -import Data.Swagger hiding (Header) +import Data.OpenApi hiding (HasServer, Header) import Data.Text.Encoding qualified as T import Imports import Servant -import Servant.Swagger +import Servant.OpenApi +import Wire.API.Routes.Version newtype Bearer a = Bearer {unBearer :: a} @@ -42,9 +43,13 @@ type BearerQueryParam = [Lenient, Description "Access token"] "access_token" -instance HasSwagger api => HasSwagger (Bearer a :> api) where - toSwagger _ = - toSwagger (Proxy @api) +type instance + SpecialiseToVersion v (Bearer a :> api) = + Bearer a :> SpecialiseToVersion v api + +instance HasOpenApi api => HasOpenApi (Bearer a :> api) where + toOpenApi _ = + toOpenApi (Proxy @api) & security <>~ [SecurityRequirement $ InsOrdHashMap.singleton "ZAuth" []] instance RoutesToPaths api => RoutesToPaths (Bearer a :> api) where diff --git a/libs/wire-api/src/Wire/API/Routes/CSV.hs b/libs/wire-api/src/Wire/API/Routes/CSV.hs index 0d09941545c..0345336c378 100644 --- a/libs/wire-api/src/Wire/API/Routes/CSV.hs +++ b/libs/wire-api/src/Wire/API/Routes/CSV.hs @@ -17,6 +17,10 @@ module Wire.API.Routes.CSV where +import Control.Lens +import Data.OpenApi qualified as O +import Data.OpenApi.Internal.Schema +import Imports import Network.HTTP.Media.MediaType import Servant.API @@ -24,3 +28,11 @@ data CSV instance Accept CSV where contentType _ = "text" // "csv" + +instance ToSchema CSV where + declareNamedSchema _ = + plain $ + mempty + & O.title ?~ "CSV" + & O.type_ ?~ O.OpenApiString + & O.format ?~ "text/csv" diff --git a/libs/wire-api/src/Wire/API/Routes/Cookies.hs b/libs/wire-api/src/Wire/API/Routes/Cookies.hs index 644435d205d..2449f074c76 100644 --- a/libs/wire-api/src/Wire/API/Routes/Cookies.hs +++ b/libs/wire-api/src/Wire/API/Routes/Cookies.hs @@ -27,8 +27,9 @@ import Data.Text.Encoding qualified as T import GHC.TypeLits import Imports import Servant -import Servant.Swagger +import Servant.OpenApi import Web.Cookie (parseCookies) +import Wire.API.Routes.Version data (:::) a b @@ -58,8 +59,12 @@ newtype CookieTuple cs = CookieTuple {unCookieTuple :: NP I (CookieTypes cs)} type CookieMap = Map ByteString (NonEmpty ByteString) -instance HasSwagger api => HasSwagger (Cookies cs :> api) where - toSwagger _ = toSwagger (Proxy @api) +type instance + SpecialiseToVersion v (Cookies cs :> api) = + Cookies cs :> SpecialiseToVersion v api + +instance HasOpenApi api => HasOpenApi (Cookies cs :> api) where + toOpenApi _ = toOpenApi (Proxy @api) class CookieArgs (cs :: [Type]) where -- example: AddArgs ["foo" :: Foo, "bar" :: Bar] a = Foo -> Bar -> a diff --git a/libs/wire-api/src/Wire/API/Routes/FederationDomainConfig.hs b/libs/wire-api/src/Wire/API/Routes/FederationDomainConfig.hs index 5ce5e7ca871..8530f78275a 100644 --- a/libs/wire-api/src/Wire/API/Routes/FederationDomainConfig.hs +++ b/libs/wire-api/src/Wire/API/Routes/FederationDomainConfig.hs @@ -26,8 +26,8 @@ where import Control.Lens ((?~)) import Data.Aeson (FromJSON, ToJSON) import Data.Domain (Domain) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import GHC.Generics import Imports import Wire.API.User.Search (FederatedUserSearchPolicy) diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs index beab0e82a20..3a131a098e7 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig.hs @@ -33,9 +33,7 @@ module Wire.API.Routes.Internal.Brig DeleteAccountConferenceCallingConfig, swaggerDoc, module Wire.API.Routes.Internal.Brig.EJPD, - NewKeyPackageRef (..), - NewKeyPackage (..), - NewKeyPackageResult (..), + FoundInvitationCode (..), ) where @@ -46,19 +44,18 @@ import Data.CommaSeparatedList import Data.Domain (Domain) import Data.Handle (Handle) import Data.Id as Id +import Data.OpenApi (HasInfo (info), HasTitle (title), OpenApi) +import Data.OpenApi qualified as S import Data.Qualified (Qualified) import Data.Schema hiding (swaggerDoc) -import Data.Swagger (HasInfo (info), HasTitle (title), Swagger) -import Data.Swagger qualified as S import Imports hiding (head) import Servant hiding (Handler, WithStatus, addHeader, respond) -import Servant.Swagger (HasSwagger (toSwagger)) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi (HasOpenApi (toOpenApi)) +import Servant.OpenApi.Internal.Orphans () import Wire.API.Connection import Wire.API.Error import Wire.API.Error.Brig -import Wire.API.MLS.Credential -import Wire.API.MLS.KeyPackage +import Wire.API.MLS.CipherSuite import Wire.API.MakesFederatedCall import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.Internal.Brig.Connection @@ -68,10 +65,13 @@ import Wire.API.Routes.Internal.Brig.SearchIndex (ISearchIndexAPI) import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti qualified as Multi import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named -import Wire.API.Routes.Public (ZUser {- yes, this is a bit weird -}) +import Wire.API.Routes.Public (ZUser) import Wire.API.Team.Feature +import Wire.API.Team.Invitation (Invitation) import Wire.API.Team.LegalHold.Internal -import Wire.API.User +import Wire.API.Team.Size qualified as Teamsize +import Wire.API.User hiding (InvitationCode) +import Wire.API.User qualified as User import Wire.API.User.Auth import Wire.API.User.Auth.LegalHold import Wire.API.User.Auth.ReAuth @@ -283,7 +283,7 @@ type AccountAPI = :> QueryParam' [Optional, Strict] "email" Email :> QueryParam' [Optional, Strict] "phone" Phone :> MultiVerb - 'HEAD + 'GET '[Servant.JSON] '[ Respond 404 "Not blacklisted" (), Respond 200 "Yes blacklisted" () @@ -497,134 +497,19 @@ instance ToSchema NewKeyPackageRef where <*> nkprClientId .= field "client_id" schema <*> nkprConversation .= field "conversation" schema -data NewKeyPackage = NewKeyPackage - { nkpConversation :: Qualified ConvId, - nkpKeyPackage :: KeyPackageData - } - deriving stock (Eq, Show, Generic) - deriving (ToJSON, FromJSON, S.ToSchema) via (Schema NewKeyPackage) - -instance ToSchema NewKeyPackage where - schema = - object "NewKeyPackage" $ - NewKeyPackage - <$> nkpConversation .= field "conversation" schema - <*> nkpKeyPackage .= field "key_package" schema - -data NewKeyPackageResult = NewKeyPackageResult - { nkpresClientIdentity :: ClientIdentity, - nkpresKeyPackageRef :: KeyPackageRef - } - deriving stock (Eq, Show, Generic) - deriving (ToJSON, FromJSON, S.ToSchema) via (Schema NewKeyPackageResult) - -instance ToSchema NewKeyPackageResult where - schema = - object "NewKeyPackageResult" $ - NewKeyPackageResult - <$> nkpresClientIdentity .= field "client_identity" schema - <*> nkpresKeyPackageRef .= field "key_package_ref" schema - -type MLSAPI = - "mls" - :> ( ( "key-packages" - :> Capture "ref" KeyPackageRef - :> ( Named - "get-client-by-key-package-ref" - ( Summary "Resolve an MLS key package ref to a qualified client ID" - :> MultiVerb - 'GET - '[Servant.JSON] - '[ RespondEmpty 404 "Key package ref not found", - Respond 200 "Key package ref found" ClientIdentity - ] - (Maybe ClientIdentity) - ) - :<|> ( "conversation" - :> ( PutConversationByKeyPackageRef - :<|> GetConversationByKeyPackageRef - ) - ) - :<|> Named - "put-key-package-ref" - ( Summary "Create a new KeyPackageRef mapping" - :> ReqBody '[Servant.JSON] NewKeyPackageRef - :> MultiVerb - 'PUT - '[Servant.JSON] - '[RespondEmpty 201 "Key package ref mapping created"] - () - ) - :<|> Named - "post-key-package-ref" - ( Summary "Update a KeyPackageRef in mapping" - :> ReqBody '[Servant.JSON] KeyPackageRef - :> MultiVerb - 'POST - '[Servant.JSON] - '[RespondEmpty 201 "Key package ref mapping updated"] - () - ) - ) - ) - :<|> GetMLSClients - :<|> MapKeyPackageRefs - :<|> Named - "put-key-package-add" - ( "key-package-add" - :> ReqBody '[Servant.JSON] NewKeyPackage - :> MultiVerb1 - 'PUT - '[Servant.JSON] - (Respond 200 "Key package ref mapping updated" NewKeyPackageResult) - ) - ) - -type PutConversationByKeyPackageRef = - Named - "put-conversation-by-key-package-ref" - ( Summary "Associate a conversation with a key package" - :> ReqBody '[Servant.JSON] (Qualified ConvId) - :> MultiVerb - 'PUT - '[Servant.JSON] - [ RespondEmpty 404 "No key package found by reference", - RespondEmpty 204 "Converstaion associated" - ] - Bool - ) - -type GetConversationByKeyPackageRef = - Named - "get-conversation-by-key-package-ref" - ( Summary - "Retrieve the conversation associated with a key package" - :> MultiVerb - 'GET - '[Servant.JSON] - [ RespondEmpty 404 "No associated conversation or bad key package", - Respond 200 "Conversation found" (Qualified ConvId) - ] - (Maybe (Qualified ConvId)) - ) +type MLSAPI = "mls" :> GetMLSClients type GetMLSClients = Summary "Return all clients and all MLS-capable clients of a user" :> "clients" :> CanThrow 'UserNotFound :> Capture "user" UserId - :> QueryParam' '[Required, Strict] "sig_scheme" SignatureSchemeTag + :> QueryParam' '[Required, Strict] "ciphersuite" CipherSuite :> MultiVerb1 'GET '[Servant.JSON] (Respond 200 "MLS clients" (Set ClientInfo)) -type MapKeyPackageRefs = - Summary "Insert bundle into the KeyPackage ref mapping. Only for tests." - :> "key-package-refs" - :> ReqBody '[Servant.JSON] KeyPackageBundle - :> MultiVerb 'PUT '[Servant.JSON] '[RespondEmpty 204 "Mapping was updated"] () - type GetVerificationCode = Summary "Get verification code for a given email and action" :> "users" @@ -664,6 +549,82 @@ type TeamsAPI = :> ReqBody '[Servant.JSON] (Multi.TeamStatus SearchVisibilityInboundConfig) :> Post '[Servant.JSON] () ) + :<|> InvitationByEmail + :<|> InvitationCode + :<|> SuspendTeam + :<|> UnsuspendTeam + :<|> TeamSize + :<|> TeamInvitations + +type InvitationByEmail = + Named + "get-invitation-by-email" + ( "teams" + :> "invitations" + :> "by-email" + :> QueryParam' [Required, Strict] "email" Email + :> Get '[Servant.JSON] Invitation + ) + +type InvitationCode = + Named + "get-invitation-code" + ( "teams" + :> "invitation-code" + :> QueryParam' [Required, Strict] "team" TeamId + :> QueryParam' [Required, Strict] "invitation_id" InvitationId + :> Get '[Servant.JSON] FoundInvitationCode + ) + +newtype FoundInvitationCode = FoundInvitationCode {getFoundInvitationCode :: User.InvitationCode} + deriving stock (Eq, Show, Generic) + deriving (FromJSON, ToJSON, S.ToSchema) via (Schema FoundInvitationCode) + +instance ToSchema FoundInvitationCode where + schema = + FoundInvitationCode + <$> getFoundInvitationCode .= object "FoundInvitationCode" (field "code" (schema @User.InvitationCode)) + +type SuspendTeam = + Named + "suspend-team" + ( "teams" + :> Capture "tid" TeamId + :> "suspend" + :> Post + '[Servant.JSON] + NoContent + ) + +type UnsuspendTeam = + Named + "unsuspend-team" + ( "teams" + :> Capture "tid" TeamId + :> "unsuspend" + :> Post + '[Servant.JSON] + NoContent + ) + +type TeamSize = + Named + "team-size" + ( "teams" + :> Capture "tid" TeamId + :> "size" + :> Get '[JSON] Teamsize.TeamSize + ) + +type TeamInvitations = + Named + "create-invitations-via-scim" + ( "teams" + :> Capture "tid" TeamId + :> "invitations" + :> Servant.ReqBody '[JSON] NewUserScimInvitation + :> Post '[JSON] UserAccount + ) type UserAPI = UpdateUserLocale @@ -764,41 +725,11 @@ type FederationRemotesAPI = :> ReqBody '[JSON] FederationDomainConfig :> Put '[JSON] () ) - :<|> Named - "delete-federation-remotes" - ( Description FederationRemotesAPIDescription - :> Description FederationRemotesAPIDeleteDescription - :> "federation" - :> "remotes" - :> Capture "domain" Domain - :> Delete '[JSON] () - ) - -- This is nominally similar to delete-federation-remotes, - -- but is called from Galley to delete the one-on-one coversations. - -- This is needed as Galley doesn't have access to the tables - -- that hold these values. We don't want these deletes to happen - -- in delete-federation-remotes as brig might fall over and leave - -- some records hanging around. Galley uses a Rabbit queue to track - -- what is has done and can recover from a service falling over. - :<|> Named - "delete-federation-remote-from-galley" - ( Description FederationRemotesAPIDescription - :> Description FederationRemotesAPIDeleteDescription - :> "federation" - :> "remote" - :> Capture "domain" Domain - :> "galley" - :> Delete '[JSON] () - ) type FederationRemotesAPIDescription = "See https://docs.wire.com/understand/federation/backend-communication.html#configuring-remote-connections for background. " -type FederationRemotesAPIDeleteDescription = - "**WARNING!** If you remove a remote connection, all users from that remote will be removed from local conversations, and all \ - \group conversations hosted by that remote will be removed from the local backend. This cannot be reverted! " - -swaggerDoc :: Swagger +swaggerDoc :: OpenApi swaggerDoc = - toSwagger (Proxy @API) + toOpenApi (Proxy @API) & info . title .~ "Wire-Server internal brig API" diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig/Connection.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig/Connection.hs index f9226607259..7f3d76810cf 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig/Connection.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig/Connection.hs @@ -21,9 +21,9 @@ module Wire.API.Routes.Internal.Brig.Connection where import Data.Aeson (FromJSON, ToJSON) import Data.Id +import Data.OpenApi qualified as S import Data.Qualified import Data.Schema -import Data.Swagger qualified as S import Imports import Wire.API.Connection diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig/EJPD.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig/EJPD.hs index efd26df2ee0..93db38b2974 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig/EJPD.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig/EJPD.hs @@ -28,7 +28,7 @@ where import Data.Aeson hiding (json) import Data.Handle (Handle) import Data.Id (TeamId, UserId) -import Data.Swagger (ToSchema) +import Data.OpenApi (ToSchema) import Deriving.Swagger (CamelToSnake, CustomSwagger (..), FieldLabelModifier, StripSuffix) import Imports hiding (head) import Test.QuickCheck (Arbitrary) diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig/OAuth.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig/OAuth.hs index a8a2747af7d..70d478643a0 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig/OAuth.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig/OAuth.hs @@ -20,10 +20,10 @@ module Wire.API.Routes.Internal.Brig.OAuth where import Data.Id (OAuthClientId) import Servant (JSON) import Servant hiding (Handler, JSON, Tagged, addHeader, respond) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Error import Wire.API.OAuth -import Wire.API.Routes.Named (Named (..)) +import Wire.API.Routes.Named (Named) -------------------------------------------------------------------------------- -- API Internal diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Brig/SearchIndex.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Brig/SearchIndex.hs index 6b45b977e68..0b90fd43524 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Brig/SearchIndex.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Brig/SearchIndex.hs @@ -19,8 +19,8 @@ module Wire.API.Routes.Internal.Brig.SearchIndex where import Servant (JSON) import Servant hiding (Handler, JSON, Tagged, addHeader, respond) -import Servant.Swagger.Internal.Orphans () -import Wire.API.Routes.Named (Named (..)) +import Servant.OpenApi.Internal.Orphans () +import Wire.API.Routes.Named (Named) type ISearchIndexAPI = Named diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Cannon.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Cannon.hs index ff0fe916a1a..b8f1652bc7a 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Cannon.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Cannon.hs @@ -2,10 +2,10 @@ module Wire.API.Routes.Internal.Cannon where import Control.Lens ((.~)) import Data.Id -import Data.Swagger (HasInfo (info), HasTitle (title), Swagger) +import Data.OpenApi (HasInfo (info), HasTitle (title), OpenApi) import Imports import Servant -import Servant.Swagger (HasSwagger (toSwagger)) +import Servant.OpenApi (HasOpenApi (toOpenApi)) import Wire.API.Error import Wire.API.Error.Cannon import Wire.API.Internal.BulkPush @@ -59,7 +59,7 @@ type API = ) ) -swaggerDoc :: Swagger +swaggerDoc :: OpenApi swaggerDoc = - toSwagger (Proxy @API) + toOpenApi (Proxy @API) & info . title .~ "Wire-Server internal cannon API" diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Cargohold.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Cargohold.hs index 825623ac9c6..cb9599b441e 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Cargohold.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Cargohold.hs @@ -18,10 +18,10 @@ module Wire.API.Routes.Internal.Cargohold where import Control.Lens -import Data.Swagger +import Data.OpenApi import Imports import Servant -import Servant.Swagger +import Servant.OpenApi import Wire.API.Routes.MultiVerb type InternalAPI = @@ -29,7 +29,7 @@ type InternalAPI = :> "status" :> MultiVerb 'GET '() '[RespondEmpty 200 "OK"] () -swaggerDoc :: Swagger +swaggerDoc :: OpenApi swaggerDoc = - toSwagger (Proxy @InternalAPI) + toOpenApi (Proxy @InternalAPI) & info . title .~ "Wire-Server internal cargohold API" diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs index e9d4e7e834f..0189df23a34 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Galley.hs @@ -19,14 +19,15 @@ module Wire.API.Routes.Internal.Galley where import Control.Lens ((.~)) import Data.Id as Id +import Data.OpenApi (OpenApi, info, title) import Data.Range -import Data.Swagger (Swagger, info, title) import GHC.TypeLits (AppendSymbol) import Imports hiding (head) import Servant hiding (JSON, WithStatus) import Servant qualified hiding (WithStatus) -import Servant.Swagger +import Servant.OpenApi import Wire.API.ApplyMods +import Wire.API.Conversation import Wire.API.Conversation.Role import Wire.API.Error import Wire.API.Error.Galley @@ -45,6 +46,7 @@ import Wire.API.Team import Wire.API.Team.Feature import Wire.API.Team.Member import Wire.API.Team.SearchVisibility +import Wire.API.User.Client type LegalHoldFeatureStatusChangeErrors = '( 'ActionDenied 'RemoveConversationMember, @@ -152,6 +154,11 @@ type IFeatureAPI = :<|> IFeatureStatusPut '[] '() MlsE2EIdConfig :<|> IFeatureStatusPatch '[] '() MlsE2EIdConfig :<|> IFeatureStatusLockStatusPut MlsE2EIdConfig + -- MlsMigrationConfig + :<|> IFeatureStatusGet MlsMigrationConfig + :<|> IFeatureStatusPut '[] '() MlsMigrationConfig + :<|> IFeatureStatusPatch '[] '() MlsMigrationConfig + :<|> IFeatureStatusLockStatusPut MlsMigrationConfig -- all feature configs :<|> Named "feature-configs-internal" @@ -210,6 +217,18 @@ type InternalAPIBase = :> ReqBody '[Servant.JSON] Connect :> ConversationVerb ) + -- This endpoint is meant for testing membership of a conversation + :<|> Named + "get-conversation-clients" + ( Summary "Get mls conversation client list" + :> CanThrow 'ConvNotFound + :> "group" + :> Capture "gid" GroupId + :> MultiVerb1 + 'GET + '[Servant.JSON] + (Respond 200 "Clients" ClientList) + ) :<|> Named "guard-legalhold-policy-conflicts" ( "guard-legalhold-policy-conflicts" @@ -426,7 +445,7 @@ type IFederationAPI = :> Get '[Servant.JSON] FederationStatus ) -swaggerDoc :: Swagger +swaggerDoc :: OpenApi swaggerDoc = - toSwagger (Proxy @InternalAPI) + toOpenApi (Proxy @InternalAPI) & info . title .~ "Wire-Server internal galley API" diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Galley/ConversationsIntra.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Galley/ConversationsIntra.hs index cd81ed7473e..b644906cd95 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Galley/ConversationsIntra.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Galley/ConversationsIntra.hs @@ -26,9 +26,9 @@ where import Data.Aeson qualified as A import Data.Aeson.Types (FromJSON, ToJSON) import Data.Id (ConvId, UserId) +import Data.OpenApi qualified as Swagger import Data.Qualified import Data.Schema -import Data.Swagger qualified as Swagger import Imports data DesiredMembership = Included | Excluded diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Galley/TeamFeatureNoConfigMulti.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Galley/TeamFeatureNoConfigMulti.hs index fdb40b05aec..9f96c0b024c 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Galley/TeamFeatureNoConfigMulti.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Galley/TeamFeatureNoConfigMulti.hs @@ -24,8 +24,8 @@ where import Data.Aeson qualified as A import Data.Id +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports import Wire.API.Team.Feature qualified as Public diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Galley/TeamsIntra.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Galley/TeamsIntra.hs index 09432560ea5..0bc3ae5a593 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Galley/TeamsIntra.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Galley/TeamsIntra.hs @@ -31,8 +31,8 @@ import Control.Lens ((?~)) import Data.Aeson import Data.Currency qualified as Currency import Data.Json.Util +import Data.OpenApi qualified as Swagger import Data.Schema qualified as S -import Data.Swagger qualified as Swagger import Data.Time (UTCTime) import Imports import Test.QuickCheck.Arbitrary (Arbitrary) diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/LegalHold.hs b/libs/wire-api/src/Wire/API/Routes/Internal/LegalHold.hs index 69d114dca82..ffde2e561c3 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/LegalHold.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/LegalHold.hs @@ -19,11 +19,12 @@ module Wire.API.Routes.Internal.LegalHold where import Control.Lens import Data.Id +import Data.OpenApi (OpenApi) +import Data.OpenApi.Lens import Data.Proxy -import Data.Swagger import Imports import Servant.API hiding (Header, WithStatus) -import Servant.Swagger +import Servant.OpenApi import Wire.API.Team.Feature type InternalLegalHoldAPI = @@ -38,7 +39,7 @@ type InternalLegalHoldAPI = :> Put '[] NoContent ) -swaggerDoc :: Swagger +swaggerDoc :: OpenApi swaggerDoc = - toSwagger (Proxy @InternalLegalHoldAPI) + toOpenApi (Proxy @InternalLegalHoldAPI) & info . title .~ "Wire-Server internal legalhold API" diff --git a/libs/wire-api/src/Wire/API/Routes/Internal/Spar.hs b/libs/wire-api/src/Wire/API/Routes/Internal/Spar.hs index 63f2358f5e1..8cc2207031c 100644 --- a/libs/wire-api/src/Wire/API/Routes/Internal/Spar.hs +++ b/libs/wire-api/src/Wire/API/Routes/Internal/Spar.hs @@ -19,10 +19,10 @@ module Wire.API.Routes.Internal.Spar where import Control.Lens import Data.Id -import Data.Swagger +import Data.OpenApi import Imports import Servant -import Servant.Swagger +import Servant.OpenApi import Wire.API.User import Wire.API.User.Saml @@ -34,7 +34,7 @@ type InternalAPI = :<|> "scim" :> "userinfos" :> ReqBody '[JSON] UserSet :> Post '[JSON] ScimUserInfos ) -swaggerDoc :: Swagger +swaggerDoc :: OpenApi swaggerDoc = - toSwagger (Proxy @InternalAPI) + toOpenApi (Proxy @InternalAPI) & info . title .~ "Wire-Server internal spar API" diff --git a/libs/wire-api/src/Wire/API/Routes/LowLevelStream.hs b/libs/wire-api/src/Wire/API/Routes/LowLevelStream.hs index 209afbf64a9..f39080b54f7 100644 --- a/libs/wire-api/src/Wire/API/Routes/LowLevelStream.hs +++ b/libs/wire-api/src/Wire/API/Routes/LowLevelStream.hs @@ -17,13 +17,13 @@ module Wire.API.Routes.LowLevelStream where -import Control.Lens (at, (.~), (?~)) +import Control.Lens (at, (.~), (?~), _Just) import Data.ByteString.Char8 as B8 import Data.CaseInsensitive qualified as CI import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap import Data.Metrics.Servant +import Data.OpenApi qualified as S import Data.Proxy -import Data.Swagger qualified as S import Data.Text qualified as Text import GHC.TypeLits import Imports @@ -33,10 +33,11 @@ import Network.Wai import Servant.API import Servant.API.ContentTypes import Servant.API.Status +import Servant.OpenApi as S +import Servant.OpenApi.Internal as S import Servant.Server hiding (respond) import Servant.Server.Internal -import Servant.Swagger as S -import Servant.Swagger.Internal as S +import Wire.API.Routes.Version -- FUTUREWORK: make it possible to generate headers at runtime data LowLevelStream method status (headers :: [(Symbol, Symbol)]) desc ctype @@ -84,28 +85,35 @@ instance status = statusFromNat (Proxy :: Proxy status) extraHeaders = renderHeaders @headers +type instance + SpecialiseToVersion v (LowLevelStream m s h d t) = + LowLevelStream m s h d t + instance - (Accept ctype, KnownNat status, KnownSymbol desc, SwaggerMethod method) => - HasSwagger (LowLevelStream method status headers desc ctype) + (S.ToSchema ctype, Accept ctype, KnownNat status, KnownSymbol desc, OpenApiMethod method) => + HasOpenApi (LowLevelStream method status headers desc ctype) where - toSwagger _ = + toOpenApi _ = mempty & S.paths . at "/" ?~ ( mempty & method ?~ ( mempty - & S.produces ?~ S.MimeList [contentType (Proxy @ctype)] & S.responses . S.responses .~ fmap S.Inline responses ) ) where - method = S.swaggerMethod (Proxy @method) + method = S.openApiMethod (Proxy @method) responses = InsOrdHashMap.singleton (fromIntegral (natVal (Proxy @status))) $ mempty & S.description .~ Text.pack (symbolVal (Proxy @desc)) + & S.content + .~ InsOrdHashMap.singleton + (contentType $ Proxy @ctype) + (mempty & S.schema . _Just . S._Inline .~ S.toSchema (Proxy @ctype)) instance RoutesToPaths (LowLevelStream method status headers desc ctype) where getRoutes = [] diff --git a/libs/wire-api/src/Wire/API/Routes/MultiTablePaging.hs b/libs/wire-api/src/Wire/API/Routes/MultiTablePaging.hs index e0438210f77..0fc48cdaf06 100644 --- a/libs/wire-api/src/Wire/API/Routes/MultiTablePaging.hs +++ b/libs/wire-api/src/Wire/API/Routes/MultiTablePaging.hs @@ -26,10 +26,10 @@ where import Control.Lens ((?~)) import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Kind +import Data.OpenApi qualified as S import Data.Proxy import Data.Range import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text import GHC.TypeLits import Imports @@ -77,7 +77,10 @@ deriving via deriving via Schema (GetMultiTablePageRequest name tables max def) instance - RequestSchemaConstraint name tables max def => S.ToSchema (GetMultiTablePageRequest name tables max def) + ( Typeable tables, + RequestSchemaConstraint name tables max def + ) => + S.ToSchema (GetMultiTablePageRequest name tables max def) instance RequestSchemaConstraint name tables max def => ToSchema (GetMultiTablePageRequest name tables max def) where schema = @@ -126,7 +129,7 @@ deriving via deriving via (Schema (MultiTablePage name resultsKey tables a)) instance - PageSchemaConstraints name resultsKey tables a => + (Typeable tables, Typeable a, PageSchemaConstraints name resultsKey tables a) => S.ToSchema (MultiTablePage name resultsKey tables a) instance diff --git a/libs/wire-api/src/Wire/API/Routes/MultiTablePaging/State.hs b/libs/wire-api/src/Wire/API/Routes/MultiTablePaging/State.hs index 197e44a959b..7d43b3009be 100644 --- a/libs/wire-api/src/Wire/API/Routes/MultiTablePaging/State.hs +++ b/libs/wire-api/src/Wire/API/Routes/MultiTablePaging/State.hs @@ -26,9 +26,9 @@ import Data.Attoparsec.ByteString qualified as AB import Data.ByteString qualified as BS import Data.ByteString.Base64.URL qualified as Base64Url import Data.Either.Combinators (mapLeft) +import Data.OpenApi qualified as S import Data.Proxy import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text import Data.Text.Encoding qualified as Text import GHC.TypeLits diff --git a/libs/wire-api/src/Wire/API/Routes/MultiVerb.hs b/libs/wire-api/src/Wire/API/Routes/MultiVerb.hs index db16fb8fc01..ed24bbfdbe5 100644 --- a/libs/wire-api/src/Wire/API/Routes/MultiVerb.hs +++ b/libs/wire-api/src/Wire/API/Routes/MultiVerb.hs @@ -41,6 +41,7 @@ module Wire.API.Routes.MultiVerb ResponseType, IsResponse (..), IsSwaggerResponse (..), + IsSwaggerResponseList (..), simpleResponseSwagger, combineResponseSwagger, ResponseTypes, @@ -54,18 +55,18 @@ import Control.Lens hiding (Context, (<|)) import Data.ByteString.Builder import Data.ByteString.Lazy qualified as LBS import Data.CaseInsensitive qualified as CI -import Data.Containers.ListUtils import Data.Either.Combinators (leftToMaybe) -import Data.HashMap.Strict.InsOrd (InsOrdHashMap) +import Data.HashMap.Strict.InsOrd (InsOrdHashMap, unionWith) import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap import Data.Kind import Data.Metrics.Servant +import Data.OpenApi hiding (HasServer, Response, contentType) +import Data.OpenApi qualified as S +import Data.OpenApi.Declare qualified as S import Data.Proxy import Data.SOP import Data.Sequence (Seq, (<|), pattern (:<|)) import Data.Sequence qualified as Seq -import Data.Swagger qualified as S -import Data.Swagger.Declare qualified as S import Data.Text qualified as Text import Data.Text.Encoding qualified as Text import Data.Typeable @@ -82,10 +83,10 @@ import Servant.API.ContentTypes import Servant.API.Status (KnownStatus (..)) import Servant.Client import Servant.Client.Core hiding (addHeader) +import Servant.OpenApi as S +import Servant.OpenApi.Internal as S import Servant.Server import Servant.Server.Internal -import Servant.Swagger as S -import Servant.Swagger.Internal as S import Servant.Types.SourceT type Declare = S.Declare (S.Definitions S.Schema) @@ -191,19 +192,25 @@ instance (AllMimeRender cs a, AllMimeUnrender cs a, KnownStatus s) => IsResponse Nothing -> empty Just f -> either UnrenderError UnrenderSuccess (f (responseBody output)) -simpleResponseSwagger :: forall a desc. (S.ToSchema a, KnownSymbol desc) => Declare S.Response +simpleResponseSwagger :: forall a cs desc. (S.ToSchema a, KnownSymbol desc, AllMime cs) => Declare S.Response simpleResponseSwagger = do ref <- S.declareSchemaRef (Proxy @a) + let resps :: InsOrdHashMap M.MediaType MediaTypeObject + resps = InsOrdHashMap.fromList $ (,MediaTypeObject (pure ref) Nothing mempty mempty) <$> cs pure $ mempty & S.description .~ Text.pack (symbolVal (Proxy @desc)) - & S.schema ?~ ref + & S.content .~ resps + where + cs :: [M.MediaType] + cs = allMime $ Proxy @cs instance (KnownSymbol desc, S.ToSchema a) => IsSwaggerResponse (Respond s desc a) where - responseSwagger = simpleResponseSwagger @a @desc + -- Defaulting this to JSON, as openapi3 needs something to map a schema against. + responseSwagger = simpleResponseSwagger @a @'[JSON] @desc type instance ResponseType (RespondAs ct s desc a) = a @@ -248,10 +255,10 @@ instance KnownStatus s => IsResponse cs (RespondAs '() s desc ()) where guard (responseStatusCode output == statusVal (Proxy @s)) instance - (KnownSymbol desc, S.ToSchema a) => + (KnownSymbol desc, S.ToSchema a, Accept ct) => IsSwaggerResponse (RespondAs (ct :: Type) s desc a) where - responseSwagger = simpleResponseSwagger @a @desc + responseSwagger = simpleResponseSwagger @a @'[ct] @desc instance (KnownSymbol desc) => @@ -348,8 +355,8 @@ instance -- FUTUREWORK: should we concatenate all the matching headers instead of just -- taking the first one? extractHeaders hs = do - let name = headerName @name - (hs0, hs1) = Seq.partition (\(h, _) -> h == name) hs + let name' = headerName @name + (hs0, hs1) = Seq.partition (\(h, _) -> h == name') hs x <- case hs0 of Seq.Empty -> empty ((_, h) :<| _) -> either (const empty) pure (parseHeader h) @@ -378,11 +385,11 @@ instance (KnownSymbol name, KnownSymbol desc, S.ToParamSchema a) => ToResponseHeader (DescHeader name desc a) where - toResponseHeader _ = (name, S.Header (Just desc) sch) + toResponseHeader _ = (name', S.Header (Just desc) Nothing Nothing Nothing Nothing Nothing mempty sch) where - name = Text.pack (symbolVal (Proxy @name)) + name' = Text.pack (symbolVal (Proxy @name)) desc = Text.pack (symbolVal (Proxy @desc)) - sch = S.toParamSchema (Proxy @a) + sch = pure $ Inline $ S.toParamSchema (Proxy @a) instance ToResponseHeader h => ToResponseHeader (OptHeader h) where toResponseHeader _ = toResponseHeader (Proxy @h) @@ -419,7 +426,7 @@ instance where responseSwagger = fmap - (S.headers .~ toAllResponseHeaders (Proxy @hs)) + (S.headers .~ fmap S.Inline (toAllResponseHeaders (Proxy @hs))) (responseSwagger @r) class IsSwaggerResponseList as where @@ -477,7 +484,17 @@ combineResponseSwagger :: S.Response -> S.Response -> S.Response combineResponseSwagger r1 r2 = r1 & S.description <>~ ("\n\n" <> r2 ^. S.description) - & S.schema . _Just . S._Inline %~ flip combineSwaggerSchema (r2 ^. S.schema . _Just . S._Inline) + & S.content %~ flip (unionWith combineMediaTypeObject) (r2 ^. S.content) + +combineMediaTypeObject :: S.MediaTypeObject -> S.MediaTypeObject -> S.MediaTypeObject +combineMediaTypeObject m1 m2 = + m1 & S.schema .~ merge (m1 ^. S.schema) (m2 ^. S.schema) + where + merge Nothing a = a + merge a Nothing = a + merge (Just (Inline a)) (Just (Inline b)) = pure $ Inline $ combineSwaggerSchema a b + merge a@(Just (Ref _)) _ = a + merge _ a@(Just (Ref _)) = a combineSwaggerSchema :: S.Schema -> S.Schema -> S.Schema combineSwaggerSchema s1 s2 @@ -698,44 +715,61 @@ instance fromUnion (S (S x)) = case x of {} instance - (SwaggerMethod method, IsSwaggerResponseList as) => - S.HasSwagger (MultiVerb method '() as r) + (OpenApiMethod method, IsSwaggerResponseList as) => + S.HasOpenApi (MultiVerb method '() as r) where - toSwagger _ = + toOpenApi _ = mempty - & S.definitions <>~ defs + & S.components . S.schemas <>~ defs & S.paths . at "/" ?~ ( mempty & method ?~ ( mempty - & S.responses . S.responses .~ fmap S.Inline responses + & S.responses . S.responses .~ refResps ) ) where - method = S.swaggerMethod (Proxy @method) - (defs, responses) = S.runDeclare (responseListSwagger @as) mempty + method = S.openApiMethod (Proxy @method) + (defs, resps) = S.runDeclare (responseListSwagger @as) mempty + refResps = S.Inline <$> resps instance - (SwaggerMethod method, IsSwaggerResponseList as, AllMime cs) => - S.HasSwagger (MultiVerb method (cs :: [Type]) as r) + (OpenApiMethod method, IsSwaggerResponseList as, AllMime cs) => + S.HasOpenApi (MultiVerb method (cs :: [Type]) as r) where - toSwagger _ = + toOpenApi _ = mempty - & S.definitions <>~ defs + & S.components . S.schemas <>~ defs & S.paths . at "/" ?~ ( mempty & method ?~ ( mempty - & S.produces ?~ S.MimeList (nubOrd cs) - & S.responses . S.responses .~ fmap S.Inline responses + & S.responses . S.responses .~ refResps ) ) where - method = S.swaggerMethod (Proxy @method) + method = S.openApiMethod (Proxy @method) + -- This has our content types. cs = allMime (Proxy @cs) - (defs, responses) = S.runDeclare (responseListSwagger @as) mempty + -- This has our schemas + (defs, resps) = S.runDeclare (responseListSwagger @as) mempty + -- We need to zip them together, and stick it all back into the contentMap + -- Since we have a single schema per type, and are only changing the content-types, + -- we should be able to pick a schema out of the resps' map, and then use it for + -- all of the values of cs + addMime :: S.Response -> S.Response + addMime resp = + resp + & S.content + %~ + -- pick out an element from the map, if any exist. + -- These will all have the same schemas, and we are reapplying the content types. + foldMap (\c -> InsOrdHashMap.fromList $ (,c) <$> cs) + . listToMaybe + . toList + refResps = S.Inline . addMime <$> resps class Typeable a => IsWaiBody a where responseToWai :: ResponseF a -> Wai.Response diff --git a/libs/wire-api/src/Wire/API/Routes/Named.hs b/libs/wire-api/src/Wire/API/Routes/Named.hs index 804e3161ea2..e7bf7224a74 100644 --- a/libs/wire-api/src/Wire/API/Routes/Named.hs +++ b/libs/wire-api/src/Wire/API/Routes/Named.hs @@ -22,17 +22,26 @@ module Wire.API.Routes.Named where import Control.Lens ((%~)) import Data.Kind import Data.Metrics.Servant +import Data.OpenApi.Lens hiding (HasServer) +import Data.OpenApi.Operation import Data.Proxy -import Data.Swagger import GHC.TypeLits import Imports import Servant import Servant.Client import Servant.Client.Core (clientIn) -import Servant.Swagger +import Servant.OpenApi -- | See http://docs.wire.com/developer/developer/servant.html#named-and-internal-route-ids-in-swagger -newtype Named name x = Named {unnamed :: x} +-- +-- as 'UntypedNamed' is of kind $k -> Type -> Type$, we can pass any +-- argument to it, however, most commonly we want to pass a 'Symbol' to +-- it. To avoid mistakes, we make it possible to rule out untyped arguments +-- like 'Type', this is done by the 'IsStronglyTyped' TyFam that will throw +-- a type error when passed a 'Type' +type Named name = UntypedNamed (IsStronglyTyped name) + +newtype UntypedNamed name x = Named {unnamed :: x} deriving (Functor) -- | For 'HasSwagger' instance of 'Named'. 'KnownSymbol' isn't enough because we're using @@ -46,9 +55,14 @@ instance {-# OVERLAPPABLE #-} KnownSymbol a => RenderableSymbol a where instance {-# OVERLAPPING #-} (RenderableSymbol a, RenderableSymbol b) => RenderableSymbol '(a, b) where renderSymbol = "(" <> (renderSymbol @a) <> ", " <> (renderSymbol @b) <> ")" -instance (HasSwagger api, RenderableSymbol name) => HasSwagger (Named name api) where - toSwagger _ = - toSwagger (Proxy @api) +type IsStronglyTyped :: forall k. k -> k +type family IsStronglyTyped typ where + IsStronglyTyped (typ :: Type) = TypeError ('Text "Please don't use \"Type\" as first parameter to \"Named\"") + IsStronglyTyped typ = typ + +instance (HasOpenApi api, RenderableSymbol name) => HasOpenApi (UntypedNamed name api) where + toOpenApi _ = + toOpenApi (Proxy @api) & allOperations . description %~ (Just (dscr <> "\n\n") <>) where dscr :: Text @@ -57,27 +71,27 @@ instance (HasSwagger api, RenderableSymbol name) => HasSwagger (Named name api) <> cs (renderSymbol @name) <> "]" -instance HasServer api ctx => HasServer (Named name api) ctx where - type ServerT (Named name api) m = Named name (ServerT api m) +instance HasServer api ctx => HasServer (UntypedNamed name api) ctx where + type ServerT (UntypedNamed name api) m = UntypedNamed name (ServerT api m) route _ ctx action = route (Proxy @api) ctx (fmap unnamed action) hoistServerWithContext _ ctx f = fmap (hoistServerWithContext (Proxy @api) ctx f) -instance HasLink endpoint => HasLink (Named name endpoint) where - type MkLink (Named name endpoint) a = MkLink endpoint a +instance HasLink endpoint => HasLink (UntypedNamed name endpoint) where + type MkLink (UntypedNamed name endpoint) a = MkLink endpoint a toLink toA _ = toLink toA (Proxy @endpoint) -instance RoutesToPaths api => RoutesToPaths (Named name api) where +instance RoutesToPaths api => RoutesToPaths (UntypedNamed name api) where getRoutes = getRoutes @api -instance HasClient m api => HasClient m (Named n api) where - type Client m (Named n api) = Client m api +instance HasClient m api => HasClient m (UntypedNamed n api) where + type Client m (UntypedNamed n api) = Client m api clientWithRoute pm _ req = clientWithRoute pm (Proxy @api) req hoistClientMonad pm _ f = hoistClientMonad pm (Proxy @api) f type family FindName n (api :: Type) :: (n, Type) where - FindName n (Named name api) = '(name, api) + FindName n (UntypedNamed name api) = '(name, api) FindName n (x :> api) = AddPrefix x (FindName n api) FindName n api = '(TypeError ('Text "Named combinator not found"), api) @@ -115,7 +129,7 @@ type family FMap (f :: a -> b) (m :: Maybe a) :: Maybe b where FMap f ('Just a) = 'Just (f a) type family LookupEndpoint api name :: Maybe Type where - LookupEndpoint (Named name endpoint) name = 'Just endpoint + LookupEndpoint (UntypedNamed name endpoint) name = 'Just endpoint LookupEndpoint (api1 :<|> api2) name = MappendMaybe (LookupEndpoint api1 name) @@ -134,3 +148,12 @@ namedClient :: (HasEndpoint api endpoint name, HasClient m endpoint) => Client m endpoint namedClient = clientIn (Proxy @endpoint) (Proxy @m) + +--------------------------------------------- +-- Utility to add a combinator to a Named API + +type family x ::> api + +type instance + x ::> (UntypedNamed name api) = + Named name (x :> api) diff --git a/libs/wire-api/src/Wire/API/Routes/Public.hs b/libs/wire-api/src/Wire/API/Routes/Public.hs index befd0855009..68886e65407 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public.hs @@ -30,8 +30,10 @@ module Wire.API.Routes.Public ZBot, ZConversation, ZProvider, + ZAccess, DescriptionOAuthScope, ZHostOpt, + ZHostValue, ) where @@ -42,19 +44,21 @@ import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap import Data.Id as Id import Data.Kind import Data.Metrics.Servant +import Data.OpenApi hiding (HasServer, Header, Server) +import Data.OpenApi qualified as S import Data.Qualified -import Data.Swagger hiding (Header) import GHC.Base (Symbol) import GHC.TypeLits (KnownSymbol) import Imports hiding (All, head) import Network.Wai qualified as Wai import Servant hiding (Handler, JSON, addHeader, respond) import Servant.API.Modifiers +import Servant.OpenApi (HasOpenApi (toOpenApi)) import Servant.Server.Internal.Delayed import Servant.Server.Internal.DelayedIO import Servant.Server.Internal.Router (Router) -import Servant.Swagger (HasSwagger (toSwagger)) import Wire.API.OAuth qualified as OAuth +import Wire.API.Routes.Version mapRequestArgument :: forall mods a b. @@ -84,9 +88,19 @@ data ZType | ZAuthBot | ZAuthConv | ZAuthProvider + | -- | (Typically short-lived) access token. + ZAuthAccess + +class HasTokenType (ztype :: ZType) where + -- | The expected value of the "Z-Type" header. + tokenType :: Maybe ByteString + tokenType = Nothing class - (KnownSymbol (ZHeader ztype), FromHttpApiData (ZParam ztype)) => + ( KnownSymbol (ZHeader ztype), + FromHttpApiData (ZParam ztype), + HasTokenType ztype + ) => IsZType (ztype :: ZType) ctx where type ZHeader ztype :: Symbol @@ -95,12 +109,7 @@ class qualifyZParam :: Context ctx -> ZParam ztype -> ZQualifiedParam ztype -class HasTokenType ztype where - -- | The expected value of the "Z-Type" header. - tokenType :: Maybe ByteString - -instance {-# OVERLAPPABLE #-} HasTokenType ztype where - tokenType = Nothing +instance HasTokenType 'ZLocalAuthUser instance HasContextEntry ctx Domain => IsZType 'ZLocalAuthUser ctx where type ZHeader 'ZLocalAuthUser = "Z-User" @@ -109,6 +118,8 @@ instance HasContextEntry ctx Domain => IsZType 'ZLocalAuthUser ctx where qualifyZParam ctx = toLocalUnsafe (getContextEntry ctx) +instance HasTokenType 'ZAuthUser + instance IsZType 'ZAuthUser ctx where type ZHeader 'ZAuthUser = "Z-User" type ZParam 'ZAuthUser = UserId @@ -116,6 +127,8 @@ instance IsZType 'ZAuthUser ctx where qualifyZParam _ = id +instance HasTokenType 'ZAuthClient + instance IsZType 'ZAuthClient ctx where type ZHeader 'ZAuthClient = "Z-Client" type ZParam 'ZAuthClient = ClientId @@ -123,6 +136,8 @@ instance IsZType 'ZAuthClient ctx where qualifyZParam _ = id +instance HasTokenType 'ZAuthConn + instance IsZType 'ZAuthConn ctx where type ZHeader 'ZAuthConn = "Z-Connection" type ZParam 'ZAuthConn = ConnId @@ -130,6 +145,9 @@ instance IsZType 'ZAuthConn ctx where qualifyZParam _ = id +instance HasTokenType 'ZAuthBot where + tokenType = Just "bot" + instance IsZType 'ZAuthBot ctx where type ZHeader 'ZAuthBot = "Z-Bot" type ZParam 'ZAuthBot = BotId @@ -137,6 +155,8 @@ instance IsZType 'ZAuthBot ctx where qualifyZParam _ = id +instance HasTokenType 'ZAuthConv + instance IsZType 'ZAuthConv ctx where type ZHeader 'ZAuthConv = "Z-Conversation" type ZParam 'ZAuthConv = ConvId @@ -144,8 +164,8 @@ instance IsZType 'ZAuthConv ctx where qualifyZParam _ = id -instance HasTokenType 'ZAuthBot where - tokenType = Just "bot" +instance HasTokenType 'ZAuthProvider where + tokenType = Just "provider" instance IsZType 'ZAuthProvider ctx where type ZHeader 'ZAuthProvider = "Z-Provider" @@ -154,8 +174,15 @@ instance IsZType 'ZAuthProvider ctx where qualifyZParam _ = id -instance HasTokenType 'ZAuthProvider where - tokenType = Just "provider" +instance HasTokenType 'ZAuthAccess where + tokenType = Just "access" + +instance IsZType 'ZAuthAccess ctx where + type ZHeader 'ZAuthAccess = "Z-User" + type ZParam 'ZAuthAccess = UserId + type ZQualifiedParam 'ZAuthAccess = UserId + + qualifyZParam _ = id data ZAuthServant (ztype :: ZType) (opts :: [Type]) @@ -181,6 +208,8 @@ type ZConversation = ZAuthServant 'ZAuthConv InternalAuthDefOpts type ZProvider = ZAuthServant 'ZAuthProvider InternalAuthDefOpts +type ZAccess = ZAuthServant 'ZAuthAccess InternalAuthDefOpts + type ZOptUser = ZAuthServant 'ZAuthUser '[Servant.Optional, Servant.Strict] type ZOptClient = ZAuthServant 'ZAuthClient '[Servant.Optional, Servant.Strict] @@ -190,26 +219,37 @@ type ZOptConn = ZAuthServant 'ZAuthConn '[Servant.Optional, Servant.Strict] -- | Optional @Z-Host@ header (added by @nginz@) data ZHostOpt +type ZHostValue = Text + type ZOptHostHeader = - Header' '[Servant.Optional, Strict] "Z-Host" Text + Header' '[Servant.Optional, Strict] "Z-Host" ZHostValue -instance HasSwagger api => HasSwagger (ZHostOpt :> api) where - toSwagger _ = toSwagger (Proxy @api) +instance HasOpenApi api => HasOpenApi (ZHostOpt :> api) where + toOpenApi _ = toOpenApi (Proxy @api) -instance HasSwagger api => HasSwagger (ZAuthServant 'ZAuthUser _opts :> api) where - toSwagger _ = - toSwagger (Proxy @api) - & securityDefinitions <>~ SecurityDefinitions (InsOrdHashMap.singleton "ZAuth" secScheme) - & security <>~ [SecurityRequirement $ InsOrdHashMap.singleton "ZAuth" []] - where - secScheme = - SecurityScheme - { _securitySchemeType = SecuritySchemeApiKey (ApiKeyParams "Authorization" ApiKeyHeader), - _securitySchemeDescription = Just "Must be a token retrieved by calling 'POST /login' or 'POST /access'. It must be presented in this format: 'Bearer \\'." - } +type instance SpecialiseToVersion v (ZHostOpt :> api) = ZHostOpt :> SpecialiseToVersion v api -instance HasSwagger api => HasSwagger (ZAuthServant 'ZLocalAuthUser opts :> api) where - toSwagger _ = toSwagger (Proxy @(ZAuthServant 'ZAuthUser opts :> api)) +addZAuthSwagger :: OpenApi -> OpenApi +addZAuthSwagger s = + s + & S.components . S.securitySchemes <>~ SecurityDefinitions (InsOrdHashMap.singleton "ZAuth" secScheme) + & security <>~ [SecurityRequirement $ InsOrdHashMap.singleton "ZAuth" []] + where + secScheme = + SecurityScheme + { _securitySchemeType = SecuritySchemeApiKey (ApiKeyParams "Authorization" ApiKeyHeader), + _securitySchemeDescription = Just "Must be a token retrieved by calling 'POST /login' or 'POST /access'. It must be presented in this format: 'Bearer \\'." + } + +type instance + SpecialiseToVersion v (ZAuthServant t opts :> api) = + ZAuthServant t opts :> SpecialiseToVersion v api + +instance HasOpenApi api => HasOpenApi (ZAuthServant 'ZAuthUser _opts :> api) where + toOpenApi _ = addZAuthSwagger (toOpenApi (Proxy @api)) + +instance HasOpenApi api => HasOpenApi (ZAuthServant 'ZLocalAuthUser opts :> api) where + toOpenApi _ = addZAuthSwagger (toOpenApi (Proxy @api)) instance HasLink endpoint => HasLink (ZAuthServant usr opts :> endpoint) where type MkLink (ZAuthServant _ _ :> endpoint) a = MkLink endpoint a @@ -217,10 +257,10 @@ instance HasLink endpoint => HasLink (ZAuthServant usr opts :> endpoint) where instance {-# OVERLAPPABLE #-} - HasSwagger api => - HasSwagger (ZAuthServant ztype _opts :> api) + HasOpenApi api => + HasOpenApi (ZAuthServant ztype _opts :> api) where - toSwagger _ = toSwagger (Proxy @api) + toOpenApi _ = toOpenApi (Proxy @api) instance ( HasContextEntry (ctx .++ DefaultErrorFormatters) ErrorFormatters, @@ -260,17 +300,18 @@ instance ) where checkType :: Maybe ByteString -> Wai.Request -> DelayedIO () - checkType token req = case (token, lookup "Z-Type" (Wai.requestHeaders req)) of - (Just t, value) - | value /= Just t -> - delayedFail - ServerError - { errHTTPCode = 403, - errReasonPhrase = "Access denied", - errBody = "", - errHeaders = [] - } - _ -> pure () + checkType token req = + case (token, lookup "Z-Type" (Wai.requestHeaders req)) of + (Just t, v) + | v /= Just t -> + delayedFail + ServerError + { errHTTPCode = 403, + errReasonPhrase = "Access denied", + errBody = "", + errHeaders = [] + } + _ -> pure () hoistServerWithContext _ pc nt s = hoistServerWithContext (Proxy :: Proxy api) pc nt . s @@ -281,16 +322,23 @@ instance RoutesToPaths api => RoutesToPaths (ZHostOpt :> api) where getRoutes = getRoutes @api -- FUTUREWORK: Make a PR to the servant-swagger package with this instance -instance ToSchema a => ToSchema (Headers ls a) where +instance (Typeable ls, ToSchema a) => ToSchema (Headers ls a) where declareNamedSchema _ = declareNamedSchema (Proxy @a) data DescriptionOAuthScope (scope :: OAuth.OAuthScope) -instance (HasSwagger api, OAuth.IsOAuthScope scope) => HasSwagger (DescriptionOAuthScope scope :> api) where - toSwagger _ = toSwagger (Proxy @api) & addScopeDescription - where - addScopeDescription :: Swagger -> Swagger - addScopeDescription = allOperations . description %~ Just . (<> "\nOAuth scope: `" <> cs (toByteString (OAuth.toOAuthScope @scope)) <> "`") . fold +type instance + SpecialiseToVersion v (DescriptionOAuthScope scope :> api) = + DescriptionOAuthScope scope :> SpecialiseToVersion v api + +instance + (HasOpenApi api, OAuth.IsOAuthScope scope) => + HasOpenApi (DescriptionOAuthScope scope :> api) + where + toOpenApi _ = addScopeDescription @scope (toOpenApi (Proxy @api)) + +addScopeDescription :: forall scope. OAuth.IsOAuthScope scope => OpenApi -> OpenApi +addScopeDescription = allOperations . description %~ Just . (<> "\nOAuth scope: `" <> cs (toByteString (OAuth.toOAuthScope @scope)) <> "`") . fold instance (HasServer api ctx) => HasServer (DescriptionOAuthScope scope :> api) ctx where type ServerT (DescriptionOAuthScope scope :> api) m = ServerT api m diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs index ceec7e951c0..6e780914b7c 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig.hs @@ -19,6 +19,7 @@ module Wire.API.Routes.Public.Brig where +import Control.Lens ((?~)) import Data.Aeson qualified as A (FromJSON, ToJSON, Value) import Data.ByteString.Conversion import Data.Code (Timeout) @@ -28,35 +29,40 @@ import Data.Handle import Data.Id as Id import Data.Misc (IpAddr) import Data.Nonce (Nonce) +import Data.OpenApi hiding (Contact, Header, Schema, ToSchema) +import Data.OpenApi qualified as S import Data.Qualified (Qualified (..)) import Data.Range import Data.SOP import Data.Schema as Schema -import Data.Swagger hiding (Contact, Header, Schema, ToSchema) -import Data.Swagger qualified as S import Generics.SOP qualified as GSOP import Imports hiding (head) import Network.Wai.Utilities import Servant (JSON) import Servant hiding (Handler, JSON, addHeader, respond) -import Servant.Swagger (HasSwagger (toSwagger)) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Call.Config (RTCConfiguration) import Wire.API.Connection hiding (MissingLegalholdConsent) +import Wire.API.Deprecated import Wire.API.Error import Wire.API.Error.Brig import Wire.API.Error.Empty +import Wire.API.MLS.CipherSuite import Wire.API.MLS.KeyPackage import Wire.API.MLS.Servant import Wire.API.MakesFederatedCall import Wire.API.OAuth -import Wire.API.Properties +import Wire.API.Properties (PropertyKey, PropertyKeysAndValues, RawPropertyValue) +import Wire.API.Routes.API import Wire.API.Routes.Bearer import Wire.API.Routes.Cookies import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public +import Wire.API.Routes.Public.Brig.Bot (BotAPI) import Wire.API.Routes.Public.Brig.OAuth (OAuthAPI) +import Wire.API.Routes.Public.Brig.Provider (ProviderAPI) +import Wire.API.Routes.Public.Brig.Services (ServicesAPI) import Wire.API.Routes.Public.Util import Wire.API.Routes.QualifiedCapture import Wire.API.Routes.Version @@ -92,9 +98,14 @@ type BrigAPI = :<|> TeamsAPI :<|> SystemSettingsAPI :<|> OAuthAPI + :<|> BotAPI + :<|> ServicesAPI + :<|> ProviderAPI -brigSwagger :: Swagger -brigSwagger = toSwagger (Proxy @BrigAPI) +data BrigAPITag + +instance ServiceAPI BrigAPITag v where + type ServiceAPIRoutes BrigAPITag = BrigAPI ------------------------------------------------------------------------------- -- User API @@ -272,6 +283,7 @@ type UserAPI = :<|> Named "get-supported-protocols" ( Summary "Get a user's supported protocols" + :> From 'V5 :> ZLocalUser :> "users" :> QualifiedCaptureUserId "uid" @@ -414,6 +426,7 @@ type SelfAPI = :<|> Named "change-supported-protocols" ( Summary "Change your supported protocols" + :> From 'V5 :> ZLocalUser :> ZConn :> "self" @@ -561,6 +574,7 @@ type AccountAPI = :<|> Named "post-password-reset-key-deprecated" ( Summary "Complete a password reset." + :> Deprecated :> CanThrow 'PasswordResetInProgress :> CanThrow 'InvalidPasswordResetKey :> CanThrow 'InvalidPasswordResetCode @@ -574,6 +588,7 @@ type AccountAPI = :<|> Named "onboarding" ( Summary "Upload contacts and invoke matching." + :> Deprecated :> Description "DEPRECATED: the feature has been turned off, the end-point does \ \nothing and always returns '{\"results\":[],\"auto-connects\":[]}'." @@ -595,8 +610,9 @@ data DeprecatedMatchingResult = DeprecatedMatchingResult instance ToSchema DeprecatedMatchingResult where schema = - object + objectWithDocModifier "DeprecatedMatchingResult" + (S.deprecated ?~ True) $ DeprecatedMatchingResult <$ const [] .= field "results" (array (null_ @SwaggerDoc)) @@ -912,6 +928,7 @@ type ClientAPI = :<|> Named "list-clients-bulk@v2" ( Summary "List all clients for a set of user ids" + :> Description "If a backend is unreachable, the clients from that backend will be omitted from the response" :> From 'V2 :> MakesFederatedCall 'Brig "get-user-clients" :> ZUser @@ -1096,6 +1113,8 @@ type ConnectionAPI = :> Get '[Servant.JSON] (SearchResult Contact) ) +-- Properties API ----------------------------------------------------- + type PropertiesAPI = LiftNamed ( ZUser @@ -1156,7 +1175,16 @@ type PropertiesAPI = :> Get '[JSON] PropertyKeysAndValues ) --- Properties API ----------------------------------------------------- +-- MLS API --------------------------------------------------------------------- + +type CipherSuiteParam = + QueryParam' + [ Optional, + Strict, + Description "Ciphersuite in hex format (e.g. 0xf031) - default is 0x0001" + ] + "ciphersuite" + CipherSuite type MLSKeyPackageAPI = "key-packages" @@ -1164,6 +1192,7 @@ type MLSKeyPackageAPI = "mls-key-packages-upload" ( "self" :> Summary "Upload a fresh batch of key packages" + :> From 'V5 :> Description "The request body should be a json object containing a list of base64-encoded key packages." :> ZLocalUser :> CanThrow 'MLSProtocolError @@ -1176,29 +1205,40 @@ type MLSKeyPackageAPI = "mls-key-packages-claim" ( "claim" :> Summary "Claim one key package for each client of the given user" - :> MakesFederatedCall 'Brig "claim-key-packages" + :> From 'V5 + :> Description "Only key packages for the specified ciphersuite are claimed. For backwards compatibility, the `ciphersuite` parameter is optional, defaulting to ciphersuite 0x0001 when omitted." :> ZLocalUser + :> ZOptClient :> QualifiedCaptureUserId "user" - :> QueryParam' - [ Optional, - Strict, - Description "Do not claim a key package for the given own client" - ] - "skip_own" - ClientId + :> CipherSuiteParam :> MultiVerb1 'POST '[JSON] (Respond 200 "Claimed key packages" KeyPackageBundle) ) :<|> Named "mls-key-packages-count" ( "self" + :> Summary "Return the number of unclaimed key packages for a given ciphersuite and client" + :> From 'V5 :> ZLocalUser :> CaptureClientId "client" :> "count" - :> Summary "Return the number of unused key packages for the given client" + :> CipherSuiteParam :> MultiVerb1 'GET '[JSON] (Respond 200 "Number of key packages" KeyPackageCount) ) + :<|> Named + "mls-key-packages-delete" + ( "self" + :> From 'V5 + :> ZLocalUser + :> CaptureClientId "client" + :> Summary "Delete all key packages for a given ciphersuite and client" + :> CipherSuiteParam + :> ReqBody '[JSON] DeleteKeyPackages + :> MultiVerb1 'DELETE '[JSON] (RespondEmpty 201 "OK") + ) ) +type MLSAPI = LiftNamed ("mls" :> MLSKeyPackageAPI) + -- Search API ----------------------------------------------------- type SearchAPI = @@ -1260,8 +1300,6 @@ type SearchAPI = (SearchResult TeamContact) ) -type MLSAPI = LiftNamed ("mls" :> MLSKeyPackageAPI) - type AuthAPI = Named "access" @@ -1384,8 +1422,9 @@ type CallingAPI = Named "get-calls-config" ( Summary - "[deprecated] Retrieve TURN server addresses and credentials for \ - \ IP addresses, scheme `turn` and transport `udp` only" + "Retrieve TURN server addresses and credentials for \ + \ IP addresses, scheme `turn` and transport `udp` only (deprecated)" + :> Deprecated :> ZUser :> ZConn :> "calls" diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig/Bot.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig/Bot.hs new file mode 100644 index 00000000000..70b75bf40dc --- /dev/null +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig/Bot.hs @@ -0,0 +1,162 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Routes.Public.Brig.Bot where + +import Data.CommaSeparatedList (CommaSeparatedList) +import Data.Id as Id +import Imports +import Servant (JSON) +import Servant hiding (Handler, JSON, Tagged, addHeader, respond) +import Servant.OpenApi.Internal.Orphans () +import Wire.API.Conversation.Bot +import Wire.API.Error (CanThrow, ErrorResponse) +import Wire.API.Error.Brig (BrigError (..)) +import Wire.API.Provider.Bot (BotUserView) +import Wire.API.Routes.MultiVerb +import Wire.API.Routes.Named (Named) +import Wire.API.Routes.Public +import Wire.API.User +import Wire.API.User.Client +import Wire.API.User.Client.Prekey (PrekeyId) + +type DeleteResponses = + '[ RespondEmpty 204 "", + Respond 200 "User found" RemoveBotResponse + ] + +type GetClientResponses = + '[ ErrorResponse 'ClientNotFound, + Respond 200 "Client found" Client + ] + +type BotAPI = + Named + "add-bot" + ( Summary "Add bot" + :> CanThrow 'AccessDenied + :> CanThrow 'InvalidConversation + :> CanThrow 'TooManyConversationMembers + :> CanThrow 'ServiceDisabled + :> ZAccess + :> ZConn + :> "conversations" + :> Capture "Conversation ID" ConvId + :> "bots" + :> ReqBody '[JSON] AddBot + :> MultiVerb1 'POST '[JSON] (Respond 201 "" AddBotResponse) + ) + :<|> Named + "remove-bot" + ( Summary "Remove bot" + :> CanThrow 'AccessDenied + :> CanThrow 'InvalidConversation + :> ZAccess + :> ZConn + :> "conversations" + :> Capture "Conversation ID" ConvId + :> "bots" + :> Capture "Bot ID" BotId + :> MultiVerb 'DELETE '[JSON] DeleteResponses (Maybe RemoveBotResponse) + ) + :<|> Named + "bot-get-self" + ( Summary "Get self" + :> CanThrow 'UserNotFound + :> CanThrow 'AccessDenied + :> ZBot + :> "bot" + :> "self" + :> Get '[JSON] UserProfile + ) + :<|> Named + "bot-delete-self" + ( Summary "Delete self" + :> CanThrow 'AccessDenied + :> CanThrow 'InvalidBot + :> ZBot + :> ZConversation + :> "bot" + :> "self" + :> MultiVerb1 'DELETE '[JSON] (RespondEmpty 200 "") + ) + :<|> Named + "bot-list-prekeys" + ( Summary "List prekeys for bot" + :> CanThrow 'AccessDenied + :> ZBot + :> "bot" + :> "client" + :> "prekeys" + :> Get '[JSON] [PrekeyId] + ) + :<|> Named + "bot-update-prekeys" + ( Summary "Update prekeys for bot" + :> CanThrow 'AccessDenied + :> CanThrow 'ClientNotFound + :> ZBot + :> "bot" + :> "client" + :> "prekeys" + :> ReqBody '[JSON] UpdateBotPrekeys + :> MultiVerb1 'POST '[JSON] (RespondEmpty 200 "") + ) + :<|> Named + "bot-get-client" + ( Summary "Get client for bot" + :> CanThrow 'AccessDenied + :> CanThrow 'ClientNotFound + :> ZBot + :> "bot" + :> "client" + :> MultiVerb 'GET '[JSON] GetClientResponses (Maybe Client) + ) + :<|> Named + "bot-claim-users-prekeys" + ( Summary "Claim users prekeys" + :> CanThrow 'AccessDenied + :> CanThrow 'TooManyClients + :> CanThrow 'MissingLegalholdConsent + :> ZBot + :> "bot" + :> "users" + :> "prekeys" + :> ReqBody '[JSON] UserClients + :> Post '[JSON] UserClientPrekeyMap + ) + :<|> Named + "bot-list-users" + ( Summary "List users" + :> CanThrow 'AccessDenied + :> ZBot + :> "bot" + :> "users" + :> QueryParam' [Required, Strict] "ids" (CommaSeparatedList UserId) + :> Get '[JSON] [BotUserView] + ) + :<|> Named + "bot-get-user-clients" + ( Summary "Get user clients" + :> CanThrow 'AccessDenied + :> ZBot + :> "bot" + :> "users" + :> Capture "User ID" UserId + :> "clients" + :> Get '[JSON] [PubClient] + ) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig/OAuth.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig/OAuth.hs index 2a37bd71c66..a3173c0700b 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Brig/OAuth.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig/OAuth.hs @@ -19,16 +19,15 @@ module Wire.API.Routes.Public.Brig.OAuth where import Data.Id as Id import Data.SOP -import Data.Swagger (Swagger) import Imports hiding (exp, head) import Servant (JSON) import Servant hiding (Handler, JSON, Tagged, addHeader, respond) -import Servant.Swagger -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Error import Wire.API.OAuth +import Wire.API.Routes.API import Wire.API.Routes.MultiVerb -import Wire.API.Routes.Named (Named (..)) +import Wire.API.Routes.Named (Named) import Wire.API.Routes.Public type OAuthAPI = @@ -156,5 +155,7 @@ instance AsUnion CreateOAuthAuthorizationCodeResponses CreateOAuthCodeResponse w fromUnion (S (S (S (S (Z (I _)))))) = CreateOAuthCodeRedirectUrlMissMatch fromUnion (S (S (S (S (S x))))) = case x of {} -swaggerDoc :: Swagger -swaggerDoc = toSwagger (Proxy @OAuthAPI) +data OAuthAPITag + +instance ServiceAPI OAuthAPITag v where + type ServiceAPIRoutes OAuthAPITag = OAuthAPI diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig/Provider.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig/Provider.hs new file mode 100644 index 00000000000..4145161611c --- /dev/null +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig/Provider.hs @@ -0,0 +1,183 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Routes.Public.Brig.Provider where + +import Data.Code qualified as Code +import Data.Id (ProviderId) +import Imports +import Servant (JSON) +import Servant hiding (Handler, JSON, Tagged, addHeader, respond) +import Servant.OpenApi.Internal.Orphans () +import Wire.API.Error +import Wire.API.Error.Brig +import Wire.API.Provider +import Wire.API.Routes.MultiVerb +import Wire.API.Routes.Named (Named) +import Wire.API.Routes.Public +import Wire.API.User.Auth + +type ActivateResponses = + '[ RespondEmpty 204 "", + Respond 200 "" ProviderActivationResponse + ] + +type GetProviderResponses = + '[ ErrorResponse 'ProviderNotFound, + Respond 200 "" Provider + ] + +type GetProviderProfileResponses = + '[ ErrorResponse 'ProviderNotFound, + Respond 200 "" ProviderProfile + ] + +type ProviderAPI = + Named + "provider-register" + ( Summary "Register a new provider" + :> CanThrow 'AccessDenied + :> CanThrow 'InvalidEmail + :> CanThrow 'VerificationCodeThrottled + :> "provider" + :> "register" + :> ReqBody '[JSON] NewProvider + :> MultiVerb1 'POST '[JSON] (Respond 201 "" NewProviderResponse) + ) + :<|> Named + "provider-activate" + ( Summary "Activate a provider" + :> CanThrow 'AccessDenied + :> CanThrow 'InvalidCode + :> "provider" + :> "activate" + :> QueryParam' '[Required, Strict] "key" Code.Key + :> QueryParam' '[Required, Strict] "code" Code.Value + :> MultiVerb 'GET '[JSON] ActivateResponses (Maybe ProviderActivationResponse) + ) + :<|> Named + "provider-login" + ( Summary "Login as a provider" + :> CanThrow 'AccessDenied + :> CanThrow 'BadCredentials + :> "provider" + :> "login" + :> ReqBody '[JSON] ProviderLogin + :> MultiVerb + 'POST + '[JSON] + '[ WithHeaders + '[ Header "Set-Cookie" ProviderTokenCookie + ] + ProviderTokenCookie + (RespondEmpty 200 "OK") + ] + ProviderTokenCookie + ) + :<|> Named + "provider-password-reset" + ( Summary "Begin a password reset" + :> CanThrow 'AccessDenied + :> CanThrow 'BadCredentials + :> CanThrow 'InvalidPasswordResetKey + :> CanThrow 'InvalidPasswordResetCode + :> CanThrow 'PasswordResetInProgress + :> CanThrow 'PasswordResetInProgress + :> CanThrow 'ResetPasswordMustDiffer + :> CanThrow 'VerificationCodeThrottled + :> "provider" + :> "password-reset" + :> ReqBody '[JSON] PasswordReset + :> MultiVerb1 'POST '[JSON] (RespondEmpty 201 "") + ) + :<|> Named + "provider-password-reset-complete" + ( Summary "Complete a password reset" + :> CanThrow 'AccessDenied + :> CanThrow 'InvalidCode + :> CanThrow 'ResetPasswordMustDiffer + :> CanThrow 'BadCredentials + :> CanThrow 'InvalidPasswordResetCode + :> "provider" + :> "password-reset" + :> "complete" + :> ReqBody '[JSON] CompletePasswordReset + :> MultiVerb1 'POST '[JSON] (RespondEmpty 200 "") + ) + :<|> Named + "provider-delete" + ( Summary "Delete a provider" + :> CanThrow 'AccessDenied + :> CanThrow 'InvalidProvider + :> CanThrow 'BadCredentials + :> ZProvider + :> "provider" + :> ReqBody '[JSON] DeleteProvider + :> MultiVerb1 'DELETE '[JSON] (RespondEmpty 200 "") + ) + :<|> Named + "provider-update" + ( Summary "Update a provider" + :> CanThrow 'AccessDenied + :> CanThrow 'InvalidProvider + :> ZProvider + :> "provider" + :> ReqBody '[JSON] UpdateProvider + :> MultiVerb1 'PUT '[JSON] (RespondEmpty 200 "") + ) + :<|> Named + "provider-update-email" + ( Summary "Update a provider email" + :> CanThrow 'AccessDenied + :> CanThrow 'InvalidEmail + :> CanThrow 'InvalidProvider + :> CanThrow 'VerificationCodeThrottled + :> ZProvider + :> "provider" + :> "email" + :> ReqBody '[JSON] EmailUpdate + :> MultiVerb1 'PUT '[JSON] (RespondEmpty 202 "") + ) + :<|> Named + "provider-update-password" + ( Summary "Update a provider password" + :> CanThrow 'AccessDenied + :> CanThrow 'BadCredentials + :> CanThrow 'ResetPasswordMustDiffer + :> ZProvider + :> "provider" + :> "password" + :> ReqBody '[JSON] PasswordChange + :> MultiVerb1 'PUT '[JSON] (RespondEmpty 200 "") + ) + :<|> Named + "provider-get-account" + ( Summary "Get account" + :> CanThrow 'AccessDenied + :> CanThrow 'ProviderNotFound + :> ZProvider + :> "provider" + :> MultiVerb 'GET '[JSON] GetProviderResponses (Maybe Provider) + ) + :<|> Named + "provider-get-profile" + ( Summary "Get profile" + :> ZUser + :> "providers" + :> Capture "pid" ProviderId + :> MultiVerb 'GET '[JSON] GetProviderProfileResponses (Maybe ProviderProfile) + ) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Brig/Services.hs b/libs/wire-api/src/Wire/API/Routes/Public/Brig/Services.hs new file mode 100644 index 00000000000..8fab900fca6 --- /dev/null +++ b/libs/wire-api/src/Wire/API/Routes/Public/Brig/Services.hs @@ -0,0 +1,178 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Wire.API.Routes.Public.Brig.Services where + +import Data.Id as Id +import Data.Range +import Imports hiding (head) +import Servant (JSON) +import Servant hiding (Handler, JSON, addHeader, respond) +import Wire.API.Error (CanThrow) +import Wire.API.Error.Brig +import Wire.API.Provider.Service qualified as Public +import Wire.API.Provider.Service.Tag +import Wire.API.Routes.MultiVerb +import Wire.API.Routes.Named +import Wire.API.Routes.Public + +type ServicesAPI = + Named + "post-provider-services" + ( Summary "Create a new service" + :> CanThrow 'AccessDenied + :> CanThrow 'InvalidServiceKey + :> ZProvider + :> "provider" + :> "services" + :> ReqBody '[JSON] Public.NewService + :> MultiVerb1 'POST '[JSON] (Respond 201 "" Public.NewServiceResponse) + ) + :<|> Named + "get-provider-services" + ( Summary "List provider services" + :> CanThrow 'AccessDenied + :> ZProvider + :> "provider" + :> "services" + :> Get '[JSON] [Public.Service] + ) + :<|> Named + "get-provider-services-by-service-id" + ( Summary "Get provider service by service id" + :> CanThrow 'AccessDenied + :> CanThrow 'ServiceNotFound + :> ZProvider + :> "provider" + :> "services" + :> Capture "service-id" ServiceId + :> Get '[JSON] Public.Service + ) + :<|> Named + "put-provider-services-by-service-id" + ( Summary "Update provider service" + :> CanThrow 'AccessDenied + :> CanThrow 'ServiceNotFound + :> CanThrow 'ProviderNotFound + :> ZProvider + :> "provider" + :> "services" + :> Capture "service-id" ServiceId + :> ReqBody '[JSON] Public.UpdateService + :> Put '[PlainText] NoContent + ) + :<|> Named + "put-provider-services-connection-by-service-id" + ( Summary "Update provider service connection" + :> CanThrow 'AccessDenied + :> CanThrow 'ServiceNotFound + :> CanThrow 'BadCredentials + :> CanThrow 'InvalidServiceKey + :> ZProvider + :> "provider" + :> "services" + :> Capture "service-id" ServiceId + :> "connection" + :> ReqBody '[JSON] Public.UpdateServiceConn + :> Put '[PlainText] NoContent + ) + :<|> Named + "delete-provider-services-by-service-id" + ( Summary "Delete service" + :> CanThrow 'AccessDenied + :> CanThrow 'BadCredentials + :> CanThrow 'ServiceNotFound + :> ZProvider + :> "provider" + :> "services" + :> Capture "service-id" ServiceId + :> ReqBody '[JSON] Public.DeleteService + :> MultiVerb1 'DELETE '[PlainText] (RespondEmpty 202 "") + ) + :<|> Named + "get-provider-services-by-provider-id" + ( Summary "Get provider services by provider id" + :> CanThrow 'AccessDenied + :> ZUser + :> "providers" + :> Capture "provider-id" ProviderId + :> "services" + :> Get '[JSON] [Public.ServiceProfile] + ) + :<|> Named + "get-services" + ( Summary "List services" + :> CanThrow 'AccessDenied + :> ZUser + :> "services" + :> QueryParam "tags" (QueryAnyTags 1 3) + :> QueryParam "start" Text + :> QueryParam "size" (Range 10 100 Int32) -- Default to 20 + :> Get '[JSON] Public.ServiceProfilePage + ) + :<|> Named + "get-services-tags" + ( Summary "Get services tags" + :> CanThrow 'AccessDenied + :> ZUser + :> Get '[JSON] ServiceTagList + ) + :<|> Named + "get-provider-services-by-provider-id-and-service-id" + ( Summary "Get provider service by provider id and service id" + :> CanThrow 'AccessDenied + :> CanThrow 'ServiceNotFound + :> ZUser + :> "providers" + :> Capture "provider-id" ProviderId + :> "services" + :> Capture "service-id" ServiceId + :> Get '[JSON] Public.ServiceProfile + ) + :<|> Named + "get-whitelisted-services-by-team-id" + ( Summary "Get whitelisted services by team id" + :> ZUser + :> "teams" + :> Capture "team-id" TeamId + :> "services" + :> "whitelisted" + :> QueryParam "prefix" (Range 1 128 Text) + -- Default to True + :> QueryParam "filter_disabled" Bool + -- Default to 20 + :> QueryParam "size" (Range 10 100 Int32) + :> Get '[JSON] Public.ServiceProfilePage + ) + :<|> Named + "post-team-whitelist-by-team-id" + ( Summary "Update service whitelist" + :> ZUser + :> ZConn + :> "teams" + :> Capture "team-id" TeamId + :> "services" + :> "whitelist" + :> ReqBody '[JSON] Public.UpdateServiceWhitelist + :> MultiVerb + 'POST + '[PlainText] + '[ RespondEmpty 200 "UpdateServiceWhitelistRespChanged", + RespondEmpty 204 "UpdateServiceWhitelistRespUnchanged" + ] + Public.UpdateServiceWhitelistResp + ) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Cannon.hs b/libs/wire-api/src/Wire/API/Routes/Public/Cannon.hs index ceacf45518a..eda1f01a8e3 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Cannon.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Cannon.hs @@ -18,14 +18,13 @@ module Wire.API.Routes.Public.Cannon where import Data.Id -import Data.Swagger import Servant -import Servant.Swagger +import Wire.API.Routes.API import Wire.API.Routes.Named import Wire.API.Routes.Public (ZConn, ZUser) import Wire.API.Routes.WebSocket -type PublicAPI = +type CannonAPI = Named "await-notifications" ( Summary "Establish websocket connection" @@ -43,5 +42,7 @@ type PublicAPI = :> WebSocketPending ) -swaggerDoc :: Swagger -swaggerDoc = toSwagger (Proxy @PublicAPI) +data CannonAPITag + +instance ServiceAPI CannonAPITag v where + type ServiceAPIRoutes CannonAPITag = CannonAPI diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Cargohold.hs b/libs/wire-api/src/Wire/API/Routes/Public/Cargohold.hs index 2260d3953e5..a1dc8001504 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Cargohold.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Cargohold.hs @@ -22,16 +22,15 @@ import Data.Kind import Data.Metrics.Servant import Data.Qualified import Data.SOP -import Data.Swagger qualified as Swagger import Imports import Servant -import Servant.Swagger.Internal -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import URI.ByteString import Wire.API.Asset import Wire.API.Error import Wire.API.Error.Cargohold import Wire.API.MakesFederatedCall +import Wire.API.Routes.API import Wire.API.Routes.AssetBody import Wire.API.Routes.MultiVerb import Wire.API.Routes.Public @@ -56,8 +55,9 @@ type instance ApplyPrincipalPath 'BotPrincipalTag api = ZBot :> "bot" :> "assets type instance ApplyPrincipalPath 'ProviderPrincipalTag api = ZProvider :> "provider" :> "assets" :> api -instance HasSwagger (ApplyPrincipalPath tag api) => HasSwagger (tag :> api) where - toSwagger _ = toSwagger (Proxy @(ApplyPrincipalPath tag api)) +type instance + SpecialiseToVersion v ((tag :: PrincipalTag) :> api) = + SpecialiseToVersion v (ApplyPrincipalPath tag api) instance HasServer (ApplyPrincipalPath tag api) ctx => HasServer (tag :> api) ctx where type ServerT (tag :> api) m = ServerT (ApplyPrincipalPath tag api) m @@ -90,7 +90,7 @@ type GetAsset = '[ErrorResponse 'AssetNotFound, AssetRedirect] (Maybe (AssetLocation Absolute)) -type ServantAPI = +type CargoholdAPI = ( Summary "Renew an asset token" :> Until 'V2 :> CanThrow 'AssetNotFound @@ -315,5 +315,7 @@ type MainAPI = () ) -swaggerDoc :: Swagger.Swagger -swaggerDoc = toSwagger (Proxy @ServantAPI) +data CargoholdAPITag + +instance ServiceAPI CargoholdAPITag v where + type ServiceAPIRoutes CargoholdAPITag = CargoholdAPI diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs index ff1b80fe0b3..52ec0ee5022 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley.hs @@ -20,11 +20,9 @@ module Wire.API.Routes.Public.Galley where -import Data.SOP -import Data.Swagger qualified as Swagger import Servant hiding (WithStatus) -import Servant.Swagger.Internal -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () +import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.Bot import Wire.API.Routes.Public.Galley.Conversation import Wire.API.Routes.Public.Galley.CustomBackend @@ -37,7 +35,7 @@ import Wire.API.Routes.Public.Galley.TeamConversation import Wire.API.Routes.Public.Galley.TeamMember import Wire.API.Routes.Public.Galley.TeamNotification (TeamNotificationAPI) -type ServantAPI = +type GalleyAPI = ConversationAPI :<|> TeamConversationAPI :<|> MessagingAPI @@ -50,5 +48,7 @@ type ServantAPI = :<|> TeamMemberAPI :<|> TeamNotificationAPI -swaggerDoc :: Swagger.Swagger -swaggerDoc = toSwagger (Proxy @ServantAPI) +data GalleyAPITag + +instance ServiceAPI GalleyAPITag v where + type ServiceAPIRoutes GalleyAPITag = GalleyAPI diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Bot.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Bot.hs index 2c4752fda43..3eb711a96c8 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Bot.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Bot.hs @@ -18,7 +18,7 @@ module Wire.API.Routes.Public.Galley.Bot where import Servant hiding (WithStatus) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Error import Wire.API.Error.Galley import Wire.API.MakesFederatedCall diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs index ce778e7d62e..e2a045dd806 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Conversation.hs @@ -24,16 +24,19 @@ import Data.Range import Data.SOP (I (..), NS (..)) import Imports hiding (head) import Servant hiding (WithStatus) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Conversation import Wire.API.Conversation.Code +import Wire.API.Conversation.Protocol import Wire.API.Conversation.Role import Wire.API.Conversation.Typing +import Wire.API.Deprecated import Wire.API.Error import Wire.API.Error.Galley import Wire.API.Event.Conversation -import Wire.API.MLS.PublicGroupState +import Wire.API.MLS.GroupInfo import Wire.API.MLS.Servant +import Wire.API.MLS.SubConversation import Wire.API.MakesFederatedCall import Wire.API.OAuth import Wire.API.Routes.MultiVerb @@ -199,7 +202,7 @@ type ConversationAPI = :<|> Named "get-group-info" ( Summary "Get MLS group information" - :> From 'V4 + :> From 'V5 :> MakesFederatedCall 'Galley "query-group-info" :> CanThrow 'ConvNotFound :> CanThrow 'MLSMissingGroupInfo @@ -214,7 +217,7 @@ type ConversationAPI = ( Respond 200 "The group information" - OpaquePublicGroupState + GroupInfoData ) ) :<|> Named @@ -374,7 +377,6 @@ type ConversationAPI = :> MakesFederatedCall 'Galley "on-conversation-updated" :> Until 'V3 :> CanThrow 'ConvAccessDenied - :> CanThrow 'MLSMissingSenderClient :> CanThrow 'MLSNonEmptyMemberList :> CanThrow 'MLSNotEnabled :> CanThrow 'NotConnected @@ -384,7 +386,6 @@ type ConversationAPI = :> CanThrow UnreachableBackendsLegacy :> Description "This returns 201 when a new conversation is created, and 200 when the conversation already existed" :> ZLocalUser - :> ZOptClient :> ZOptConn :> "conversations" :> VersionedReqBody 'V2 '[Servant.JSON] NewConv @@ -400,7 +401,6 @@ type ConversationAPI = :> From 'V3 :> Until 'V4 :> CanThrow 'ConvAccessDenied - :> CanThrow 'MLSMissingSenderClient :> CanThrow 'MLSNonEmptyMemberList :> CanThrow 'MLSNotEnabled :> CanThrow 'NotConnected @@ -410,7 +410,6 @@ type ConversationAPI = :> CanThrow UnreachableBackendsLegacy :> Description "This returns 201 when a new conversation is created, and 200 when the conversation already existed" :> ZLocalUser - :> ZOptClient :> ZOptConn :> "conversations" :> ReqBody '[Servant.JSON] NewConv @@ -425,7 +424,6 @@ type ConversationAPI = :> MakesFederatedCall 'Galley "on-conversation-updated" :> From 'V4 :> CanThrow 'ConvAccessDenied - :> CanThrow 'MLSMissingSenderClient :> CanThrow 'MLSNonEmptyMemberList :> CanThrow 'MLSNotEnabled :> CanThrow 'NotConnected @@ -436,7 +434,6 @@ type ConversationAPI = :> CanThrow UnreachableBackends :> Description "This returns 201 when a new conversation is created, and 200 when the conversation already existed" :> ZLocalUser - :> ZOptClient :> ZOptConn :> "conversations" :> ReqBody '[Servant.JSON] NewConv @@ -463,7 +460,7 @@ type ConversationAPI = :<|> Named "get-mls-self-conversation" ( Summary "Get the user's MLS self-conversation" - :> From 'V4 + :> From 'V5 :> ZLocalUser :> "conversations" :> "mls-self" @@ -477,6 +474,94 @@ type ConversationAPI = Conversation ) ) + :<|> Named + "get-subconversation" + ( Summary "Get information about an MLS subconversation" + :> From 'V5 + :> MakesFederatedCall 'Galley "get-sub-conversation" + :> CanThrow 'ConvNotFound + :> CanThrow 'ConvAccessDenied + :> CanThrow 'MLSSubConvUnsupportedConvType + :> ZLocalUser + :> "conversations" + :> QualifiedCapture "cnv" ConvId + :> "subconversations" + :> Capture "subconv" SubConvId + :> MultiVerb1 + 'GET + '[JSON] + ( Respond + 200 + "Subconversation" + PublicSubConversation + ) + ) + :<|> Named + "leave-subconversation" + ( Summary "Leave an MLS subconversation" + :> From 'V5 + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "leave-sub-conversation" + :> CanThrow 'ConvNotFound + :> CanThrow 'ConvAccessDenied + :> CanThrow 'MLSProtocolErrorTag + :> CanThrow 'MLSStaleMessage + :> CanThrow 'MLSNotEnabled + :> ZLocalUser + :> ZClient + :> "conversations" + :> QualifiedCapture "cnv" ConvId + :> "subconversations" + :> Capture "subconv" SubConvId + :> "self" + :> MultiVerb1 + 'DELETE + '[JSON] + (RespondEmpty 200 "OK") + ) + :<|> Named + "delete-subconversation" + ( Summary "Delete an MLS subconversation" + :> From 'V5 + :> MakesFederatedCall 'Galley "delete-sub-conversation" + :> CanThrow 'ConvAccessDenied + :> CanThrow 'ConvNotFound + :> CanThrow 'MLSNotEnabled + :> CanThrow 'MLSStaleMessage + :> ZLocalUser + :> "conversations" + :> QualifiedCapture "cnv" ConvId + :> "subconversations" + :> Capture "subconv" SubConvId + :> ReqBody '[JSON] DeleteSubConversationRequest + :> MultiVerb1 + 'DELETE + '[JSON] + (Respond 200 "Deletion successful" ()) + ) + :<|> Named + "get-subconversation-group-info" + ( Summary "Get MLS group information of subconversation" + :> From 'V5 + :> MakesFederatedCall 'Galley "query-group-info" + :> CanThrow 'ConvNotFound + :> CanThrow 'MLSMissingGroupInfo + :> CanThrow 'MLSNotEnabled + :> ZLocalUser + :> "conversations" + :> QualifiedCapture "cnv" ConvId + :> "subconversations" + :> Capture "subconv" SubConvId + :> "groupinfo" + :> MultiVerb1 + 'GET + '[MLS] + ( Respond + 200 + "The group information" + GroupInfoData + ) + ) -- This endpoint can lead to the following events being sent: -- - ConvCreate event to members -- TODO: add note: "On 201, the conversation ID is the `Location` header" @@ -525,6 +610,18 @@ type ConversationAPI = :> ReqBody '[JSON] NewConv :> ConversationVerb ) + :<|> Named + "get-one-to-one-mls-conversation" + ( Summary "Get an MLS 1:1 conversation" + :> From 'V5 + :> ZLocalUser + :> CanThrow 'MLSNotEnabled + :> CanThrow 'NotConnected + :> "conversations" + :> "one2one" + :> QualifiedCapture "usr" UserId + :> MultiVerb1 'GET '[JSON] (Respond 200 "MLS 1-1 conversation" Conversation) + ) -- This endpoint can lead to the following events being sent: -- - MemberJoin event to members :<|> Named @@ -607,7 +704,8 @@ type ConversationAPI = -- - MemberJoin event to members :<|> Named "join-conversation-by-id-unqualified" - ( Summary "Join a conversation by its ID (if link access enabled)" + ( Summary "Join a conversation by its ID (if link access enabled) (deprecated)" + :> Until 'V5 :> MakesFederatedCall 'Galley "on-conversation-updated" :> CanThrow 'ConvAccessDenied :> CanThrow 'ConvNotFound @@ -675,6 +773,7 @@ type ConversationAPI = :> CanThrow 'GuestLinksDisabled :> CanThrow 'CreateConversationCodeConflict :> ZUser + :> ZHostOpt :> ZOptConn :> "conversations" :> Capture' '[Description "Conversation ID"] "cnv" ConvId @@ -693,6 +792,7 @@ type ConversationAPI = :> CanThrow 'GuestLinksDisabled :> CanThrow 'CreateConversationCodeConflict :> ZUser + :> ZHostOpt :> ZOptConn :> "conversations" :> Capture' '[Description "Conversation ID"] "cnv" ConvId @@ -737,6 +837,7 @@ type ConversationAPI = :> CanThrow 'ConvAccessDenied :> CanThrow 'ConvNotFound :> CanThrow 'GuestLinksDisabled + :> ZHostOpt :> ZLocalUser :> "conversations" :> Capture' '[Description "Conversation ID"] "cnv" ConvId @@ -786,6 +887,7 @@ type ConversationAPI = :> MakesFederatedCall 'Galley "leave-conversation" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> Until 'V2 :> ZLocalUser :> ZConn @@ -806,6 +908,7 @@ type ConversationAPI = :> MakesFederatedCall 'Galley "leave-conversation" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> ZLocalUser :> ZConn :> CanThrow ('ActionDenied 'RemoveConversationMember) @@ -822,9 +925,11 @@ type ConversationAPI = :<|> Named "update-other-member-unqualified" ( Summary "Update membership of the specified user (deprecated)" + :> Deprecated :> Description "Use `PUT /conversations/:cnv_domain/:cnv/members/:usr_domain/:usr` instead" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> ZLocalUser :> ZConn :> CanThrow 'ConvNotFound @@ -849,6 +954,7 @@ type ConversationAPI = :> Description "**Note**: at least one field has to be provided." :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> ZLocalUser :> ZConn :> CanThrow 'ConvNotFound @@ -872,9 +978,11 @@ type ConversationAPI = :<|> Named "update-conversation-name-deprecated" ( Summary "Update conversation name (deprecated)" + :> Deprecated :> Description "Use `/conversations/:domain/:conv/name` instead." :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> CanThrow ('ActionDenied 'ModifyConversationName) :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation @@ -892,9 +1000,11 @@ type ConversationAPI = :<|> Named "update-conversation-name-unqualified" ( Summary "Update conversation name (deprecated)" + :> Deprecated :> Description "Use `/conversations/:domain/:conv/name` instead." :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> CanThrow ('ActionDenied 'ModifyConversationName) :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation @@ -915,6 +1025,7 @@ type ConversationAPI = ( Summary "Update conversation name" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> CanThrow ('ActionDenied 'ModifyConversationName) :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation @@ -935,9 +1046,11 @@ type ConversationAPI = :<|> Named "update-conversation-message-timer-unqualified" ( Summary "Update the message timer for a conversation (deprecated)" + :> Deprecated :> Description "Use `/conversations/:domain/:cnv/message-timer` instead." :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> ZLocalUser :> ZConn :> CanThrow ('ActionDenied 'ModifyConversationMessageTimer) @@ -959,6 +1072,7 @@ type ConversationAPI = ( Summary "Update the message timer for a conversation" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> ZLocalUser :> ZConn :> CanThrow ('ActionDenied 'ModifyConversationMessageTimer) @@ -980,10 +1094,12 @@ type ConversationAPI = :<|> Named "update-conversation-receipt-mode-unqualified" ( Summary "Update receipt mode for a conversation (deprecated)" + :> Deprecated :> Description "Use `PUT /conversations/:domain/:cnv/receipt-mode` instead." :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" :> MakesFederatedCall 'Galley "update-conversation" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> ZLocalUser :> ZConn :> CanThrow ('ActionDenied 'ModifyConversationReceiptMode) @@ -1006,6 +1122,7 @@ type ConversationAPI = :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" :> MakesFederatedCall 'Galley "update-conversation" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> ZLocalUser :> ZConn :> CanThrow ('ActionDenied 'ModifyConversationReceiptMode) @@ -1030,6 +1147,7 @@ type ConversationAPI = ( Summary "Update access modes for a conversation (deprecated)" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> Until 'V3 :> Description "Use PUT `/conversations/:domain/:cnv/access` instead." :> ZLocalUser @@ -1055,6 +1173,7 @@ type ConversationAPI = ( Summary "Update access modes for a conversation" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> Until 'V3 :> ZLocalUser :> ZConn @@ -1079,6 +1198,7 @@ type ConversationAPI = ( Summary "Update access modes for a conversation" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> From 'V3 :> ZLocalUser :> ZConn @@ -1101,6 +1221,7 @@ type ConversationAPI = :<|> Named "get-conversation-self-unqualified" ( Summary "Get self membership properties (deprecated)" + :> Deprecated :> ZLocalUser :> "conversations" :> Capture' '[Description "Conversation ID"] "cnv" ConvId @@ -1110,6 +1231,7 @@ type ConversationAPI = :<|> Named "update-conversation-self-unqualified" ( Summary "Update self membership properties (deprecated)" + :> Deprecated :> Description "Use `/conversations/:domain/:conv/self` instead." :> CanThrow 'ConvNotFound :> ZLocalUser @@ -1141,3 +1263,24 @@ type ConversationAPI = '[RespondEmpty 200 "Update successful"] () ) + :<|> Named + "update-conversation-protocol" + ( Summary "Update the protocol of the conversation" + :> From 'V5 + :> Description "**Note**: Only proteus->mixed upgrade is supported." + :> CanThrow 'ConvNotFound + :> CanThrow 'ConvInvalidProtocolTransition + :> CanThrow ('ActionDenied 'LeaveConversation) + :> CanThrow 'InvalidOperation + :> CanThrow 'MLSMigrationCriteriaNotSatisfied + :> CanThrow 'NotATeamMember + :> CanThrow OperationDenied + :> CanThrow 'TeamNotFound + :> ZLocalUser + :> ZConn + :> "conversations" + :> QualifiedCapture' '[Description "Conversation ID"] "cnv" ConvId + :> "protocol" + :> ReqBody '[JSON] ProtocolUpdate + :> MultiVerb 'PUT '[Servant.JSON] ConvUpdateResponses (UpdateResult Event) + ) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/CustomBackend.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/CustomBackend.hs index 079858baa0e..607a6e62573 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/CustomBackend.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/CustomBackend.hs @@ -19,7 +19,7 @@ module Wire.API.Routes.Public.Galley.CustomBackend where import Data.Domain import Servant hiding (WithStatus) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.CustomBackend import Wire.API.Error import Wire.API.Error.Galley diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs index 1a7a5d11965..a59144b926d 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Feature.hs @@ -20,7 +20,7 @@ module Wire.API.Routes.Public.Galley.Feature where import Data.Id import GHC.TypeLits import Servant hiding (WithStatus) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.ApplyMods import Wire.API.Conversation.Role import Wire.API.Error @@ -79,16 +79,18 @@ type FeatureAPI = :<|> FeatureStatusPut '[] '() GuestLinksConfig :<|> FeatureStatusGet SndFactorPasswordChallengeConfig :<|> FeatureStatusPut '[] '() SndFactorPasswordChallengeConfig - :<|> FeatureStatusGet MLSConfig - :<|> FeatureStatusPut '[] '() MLSConfig + :<|> From 'V5 ::> FeatureStatusGet MLSConfig + :<|> From 'V5 ::> FeatureStatusPut '[] '() MLSConfig :<|> FeatureStatusGet ExposeInvitationURLsToTeamAdminConfig :<|> FeatureStatusPut '[] '() ExposeInvitationURLsToTeamAdminConfig :<|> FeatureStatusGet SearchVisibilityInboundConfig :<|> FeatureStatusPut '[] '() SearchVisibilityInboundConfig :<|> FeatureStatusGet OutlookCalIntegrationConfig :<|> FeatureStatusPut '[] '() OutlookCalIntegrationConfig - :<|> FeatureStatusGet MlsE2EIdConfig - :<|> FeatureStatusPut '[] '() MlsE2EIdConfig + :<|> From 'V5 ::> FeatureStatusGet MlsE2EIdConfig + :<|> From 'V5 ::> FeatureStatusPut '[] '() MlsE2EIdConfig + :<|> From 'V5 ::> FeatureStatusGet MlsMigrationConfig + :<|> From 'V5 ::> FeatureStatusPut '[] '() MlsMigrationConfig :<|> AllFeatureConfigsUserGet :<|> AllFeatureConfigsTeamGet :<|> FeatureConfigDeprecatedGet "The usage of this endpoint was removed in iOS in version 3.101. It is not used by team management, or webapp, and is potentially used by the old Android client as of June 2022" LegalholdConfig diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/LegalHold.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/LegalHold.hs index 24848c46fd2..f04ad6c3e70 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/LegalHold.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/LegalHold.hs @@ -21,7 +21,7 @@ import Data.Id import GHC.Generics import Generics.SOP qualified as GSOP import Servant hiding (WithStatus) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Conversation.Role import Wire.API.Error import Wire.API.Error.Galley @@ -65,6 +65,7 @@ type LegalHoldAPI = ( Summary "Delete legal hold service settings" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> CanThrow AuthenticationError :> CanThrow OperationDenied :> CanThrow 'NotATeamMember @@ -103,6 +104,7 @@ type LegalHoldAPI = ( Summary "Consent to legal hold" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> CanThrow ('ActionDenied 'RemoveConversationMember) :> CanThrow 'InvalidOperation :> CanThrow 'TeamMemberNotFound @@ -120,6 +122,7 @@ type LegalHoldAPI = ( Summary "Request legal hold device" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> CanThrow ('ActionDenied 'RemoveConversationMember) :> CanThrow 'NotATeamMember :> CanThrow OperationDenied @@ -150,6 +153,7 @@ type LegalHoldAPI = ( Summary "Disable legal hold for user" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> CanThrow AuthenticationError :> CanThrow ('ActionDenied 'RemoveConversationMember) :> CanThrow 'NotATeamMember @@ -178,6 +182,7 @@ type LegalHoldAPI = ( Summary "Approve legal hold device" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> CanThrow AuthenticationError :> CanThrow 'AccessDenied :> CanThrow ('ActionDenied 'RemoveConversationMember) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/MLS.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/MLS.hs index f026628c4e6..6266979e8c3 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/MLS.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/MLS.hs @@ -18,16 +18,14 @@ module Wire.API.Routes.Public.Galley.MLS where import Servant hiding (WithStatus) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Error import Wire.API.Error.Galley -import Wire.API.Event.Conversation import Wire.API.MLS.CommitBundle import Wire.API.MLS.Keys import Wire.API.MLS.Message import Wire.API.MLS.Serialisation import Wire.API.MLS.Servant -import Wire.API.MLS.Welcome import Wire.API.MakesFederatedCall import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named @@ -36,132 +34,85 @@ import Wire.API.Routes.Version type MLSMessagingAPI = Named - "mls-welcome-message" - ( Summary "Post an MLS welcome message" - :> Until 'V3 - :> MakesFederatedCall 'Galley "mls-welcome" - :> CanThrow 'MLSKeyPackageRefNotFound + "mls-message" + ( Summary "Post an MLS message" + :> From 'V5 + :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Galley "send-mls-message" + :> MakesFederatedCall 'Galley "on-conversation-updated" + :> MakesFederatedCall 'Brig "get-mls-clients" + :> CanThrow 'ConvAccessDenied + :> CanThrow 'ConvMemberNotFound + :> CanThrow 'ConvNotFound + :> CanThrow 'LegalHoldNotEnabled + :> CanThrow 'MissingLegalholdConsent + :> CanThrow 'MLSClientMismatch + :> CanThrow 'MLSClientSenderUserMismatch + :> CanThrow 'MLSCommitMissingReferences + :> CanThrow 'MLSGroupConversationMismatch + :> CanThrow 'MLSInvalidLeafNodeIndex :> CanThrow 'MLSNotEnabled - :> "welcome" + :> CanThrow 'MLSProposalNotFound + :> CanThrow 'MLSProtocolErrorTag + :> CanThrow 'MLSSelfRemovalNotAllowed + :> CanThrow 'MLSStaleMessage + :> CanThrow 'MLSSubConvClientNotInParent + :> CanThrow 'MLSUnsupportedMessage + :> CanThrow 'MLSUnsupportedProposal + :> CanThrow MLSProposalFailure + :> CanThrow NonFederatingBackends + :> CanThrow UnreachableBackends + :> "messages" :> ZLocalUser + :> ZClient :> ZConn - :> ReqBody '[MLS] (RawMLS Welcome) - :> MultiVerb1 'POST '[JSON] (RespondEmpty 201 "Welcome message sent") + :> ReqBody '[MLS] (RawMLS Message) + :> MultiVerb1 'POST '[JSON] (Respond 201 "Message sent" MLSMessageSendingStatus) ) - :<|> Named - "mls-message-v1" - ( Summary "Post an MLS message" - :> MakesFederatedCall 'Galley "on-mls-message-sent" - :> MakesFederatedCall 'Galley "send-mls-message" - :> MakesFederatedCall 'Galley "on-conversation-updated" - :> MakesFederatedCall 'Brig "get-mls-clients" - :> Until 'V2 - :> CanThrow 'ConvAccessDenied - :> CanThrow 'ConvMemberNotFound - :> CanThrow 'ConvNotFound - :> CanThrow 'LegalHoldNotEnabled - :> CanThrow 'MLSClientMismatch - :> CanThrow 'MLSCommitMissingReferences - :> CanThrow 'MLSKeyPackageRefNotFound - :> CanThrow 'MLSNotEnabled - :> CanThrow 'MLSProposalNotFound - :> CanThrow 'MLSProtocolErrorTag - :> CanThrow 'MLSSelfRemovalNotAllowed - :> CanThrow 'MLSStaleMessage - :> CanThrow 'MLSUnsupportedMessage - :> CanThrow 'MLSUnsupportedProposal - :> CanThrow 'MLSClientSenderUserMismatch - :> CanThrow 'MLSGroupConversationMismatch - :> CanThrow 'MLSMissingSenderClient - :> CanThrow 'MissingLegalholdConsent - :> CanThrow MLSProposalFailure - :> CanThrow NonFederatingBackends - :> CanThrow UnreachableBackends - :> "messages" - :> ZLocalUser - :> ZOptClient - :> ZConn - :> ReqBody '[MLS] (RawMLS SomeMessage) - :> MultiVerb1 'POST '[JSON] (Respond 201 "Message sent" [Event]) - ) - :<|> Named - "mls-message" - ( Summary "Post an MLS message" - :> MakesFederatedCall 'Galley "on-mls-message-sent" - :> MakesFederatedCall 'Galley "send-mls-message" - :> MakesFederatedCall 'Galley "on-conversation-updated" - :> MakesFederatedCall 'Brig "get-mls-clients" - :> From 'V2 - :> CanThrow 'ConvAccessDenied - :> CanThrow 'ConvMemberNotFound - :> CanThrow 'ConvNotFound - :> CanThrow 'LegalHoldNotEnabled - :> CanThrow 'MLSClientMismatch - :> CanThrow 'MLSCommitMissingReferences - :> CanThrow 'MLSKeyPackageRefNotFound - :> CanThrow 'MLSNotEnabled - :> CanThrow 'MLSProposalNotFound - :> CanThrow 'MLSProtocolErrorTag - :> CanThrow 'MLSSelfRemovalNotAllowed - :> CanThrow 'MLSStaleMessage - :> CanThrow 'MLSUnsupportedMessage - :> CanThrow 'MLSUnsupportedProposal - :> CanThrow 'MLSClientSenderUserMismatch - :> CanThrow 'MLSGroupConversationMismatch - :> CanThrow 'MLSMissingSenderClient - :> CanThrow 'MissingLegalholdConsent - :> CanThrow MLSProposalFailure - :> CanThrow NonFederatingBackends - :> CanThrow UnreachableBackends - :> "messages" - :> ZLocalUser - :> ZOptClient - :> ZConn - :> ReqBody '[MLS] (RawMLS SomeMessage) - :> MultiVerb1 'POST '[JSON] (Respond 201 "Message sent" MLSMessageSendingStatus) - ) :<|> Named "mls-commit-bundle" ( Summary "Post a MLS CommitBundle" + :> From 'V5 :> MakesFederatedCall 'Galley "on-mls-message-sent" :> MakesFederatedCall 'Galley "mls-welcome" :> MakesFederatedCall 'Galley "send-mls-commit-bundle" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Brig "get-mls-clients" - :> From 'V4 + :> MakesFederatedCall 'Brig "get-users-by-ids" + :> MakesFederatedCall 'Brig "api-version" :> CanThrow 'ConvAccessDenied :> CanThrow 'ConvMemberNotFound :> CanThrow 'ConvNotFound :> CanThrow 'LegalHoldNotEnabled + :> CanThrow 'MissingLegalholdConsent :> CanThrow 'MLSClientMismatch + :> CanThrow 'MLSClientSenderUserMismatch :> CanThrow 'MLSCommitMissingReferences - :> CanThrow 'MLSKeyPackageRefNotFound + :> CanThrow 'MLSGroupConversationMismatch + :> CanThrow 'MLSInvalidLeafNodeIndex :> CanThrow 'MLSNotEnabled :> CanThrow 'MLSProposalNotFound :> CanThrow 'MLSProtocolErrorTag :> CanThrow 'MLSSelfRemovalNotAllowed :> CanThrow 'MLSStaleMessage + :> CanThrow 'MLSSubConvClientNotInParent :> CanThrow 'MLSUnsupportedMessage :> CanThrow 'MLSUnsupportedProposal - :> CanThrow 'MLSClientSenderUserMismatch - :> CanThrow 'MLSGroupConversationMismatch - :> CanThrow 'MLSMissingSenderClient :> CanThrow 'MLSWelcomeMismatch - :> CanThrow 'MissingLegalholdConsent :> CanThrow MLSProposalFailure :> CanThrow NonFederatingBackends :> CanThrow UnreachableBackends :> "commit-bundles" :> ZLocalUser - :> ZOptClient + :> ZClient :> ZConn - :> ReqBody '[CommitBundleMimeType] CommitBundle + :> ReqBody '[MLS] (RawMLS CommitBundle) :> MultiVerb1 'POST '[JSON] (Respond 201 "Commit accepted and forwarded" MLSMessageSendingStatus) ) :<|> Named "mls-public-keys" ( Summary "Get public keys used by the backend to sign external proposals" - :> From 'V4 + :> From 'V5 :> CanThrow 'MLSNotEnabled :> "public-keys" :> ZLocalUser diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Messaging.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Messaging.hs index b5f07834e7f..72aa70f4125 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Messaging.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Messaging.hs @@ -22,7 +22,7 @@ import Data.SOP import Generics.SOP qualified as GSOP import Imports import Servant hiding (WithStatus) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Error import Wire.API.Error.Brig qualified as BrigError import Wire.API.Error.Galley diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Team.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Team.hs index 3d3571dd23c..fd3fd392a4a 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/Team.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/Team.hs @@ -20,7 +20,7 @@ module Wire.API.Routes.Public.Galley.Team where import Data.Id import Imports import Servant hiding (WithStatus) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Error import Wire.API.Error.Galley import Wire.API.Routes.MultiVerb diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamConversation.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamConversation.hs index ce5fed146d5..0f45c2ac92c 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamConversation.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamConversation.hs @@ -19,7 +19,7 @@ module Wire.API.Routes.Public.Galley.TeamConversation where import Data.Id import Servant hiding (WithStatus) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Conversation.Role import Wire.API.Error import Wire.API.Error.Galley @@ -70,6 +70,7 @@ type TeamConversationAPI = ( Summary "Remove a team conversation" :> MakesFederatedCall 'Galley "on-conversation-updated" :> MakesFederatedCall 'Galley "on-mls-message-sent" + :> MakesFederatedCall 'Brig "get-users-by-ids" :> CanThrow ('ActionDenied 'DeleteConversation) :> CanThrow 'ConvNotFound :> CanThrow 'InvalidOperation diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs index 6d14ebc1483..4c71df03e49 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Galley/TeamMember.hs @@ -23,7 +23,7 @@ import Data.Range import GHC.Generics import Generics.SOP qualified as GSOP import Servant hiding (WithStatus) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Error import Wire.API.Error.Galley import Wire.API.Routes.CSV diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Gundeck.hs b/libs/wire-api/src/Wire/API/Routes/Public/Gundeck.hs index 7fad8afba98..b2d0d329dae 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Gundeck.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Gundeck.hs @@ -19,15 +19,13 @@ module Wire.API.Routes.Public.Gundeck where import Data.Id (ClientId) import Data.Range -import Data.SOP -import Data.Swagger qualified as Swagger import Imports import Servant -import Servant.Swagger import Wire.API.Error import Wire.API.Error.Gundeck as E import Wire.API.Notification import Wire.API.Push.V2.Token +import Wire.API.Routes.API import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.Routes.Public @@ -132,5 +130,7 @@ type NotificationAPI = (Maybe QueuedNotificationList) ) -swaggerDoc :: Swagger.Swagger -swaggerDoc = toSwagger (Proxy @GundeckAPI) +data GundeckAPITag + +instance ServiceAPI GundeckAPITag v where + type ServiceAPIRoutes GundeckAPITag = GundeckAPI diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Proxy.hs b/libs/wire-api/src/Wire/API/Routes/Public/Proxy.hs index 4f507e1635c..4fa0e100c83 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Proxy.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Proxy.hs @@ -17,11 +17,9 @@ module Wire.API.Routes.Public.Proxy where -import Data.SOP -import Data.Swagger qualified as Swagger import Servant import Servant.API.Extended.RawM (RawM) -import Servant.Swagger +import Wire.API.Routes.API import Wire.API.Routes.Named type ProxyAPI = @@ -50,6 +48,8 @@ type family ProxyAPISummary name where ProxyAPISummary "gmaps-path" = "[DEPRECATED] proxy: `get /proxy/googlemaps/maps/api/geocode/:path`; see google maps API docs" +data ProxyAPITag + -- | FUTUREWORK(fisx): (1) the verb could be added to the swagger docs in the appropriate -- place here; it's always defined in the `Summary`, but the `RawM` doesn't allow to constrain -- it. (2) there should be a way to make this more type-safe: `assertMethod` in @@ -58,5 +58,5 @@ type family ProxyAPISummary name where -- "api" :> "token" :> OnlyMethod "POST" :> RawM`, and then the `ServerT` instance for -- `OnlyMethod` requires a proxy argument in the handler of the same type. Or something. (am -- i massifly over-engineering things here?) -swaggerDoc :: Swagger.Swagger -swaggerDoc = toSwagger (Proxy @ProxyAPI) +instance ServiceAPI ProxyAPITag v where + type ServiceAPIRoutes ProxyAPITag = ProxyAPI diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Spar.hs b/libs/wire-api/src/Wire/API/Routes/Public/Spar.hs index adaf1c3b729..107ed1de9a5 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Spar.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Spar.hs @@ -20,19 +20,20 @@ module Wire.API.Routes.Public.Spar where import Data.Id import Data.Proxy import Data.Range -import Data.Swagger (Swagger) import Imports import SAML2.WebSSO qualified as SAML import Servant import Servant.API.Extended import Servant.Multipart -import Servant.Swagger (toSwagger) +import Servant.OpenApi import URI.ByteString qualified as URI import Web.Scim.Capabilities.MetaSchema as Scim.Meta import Web.Scim.Class.Auth as Scim.Auth import Web.Scim.Class.User as Scim.User +import Wire.API.Deprecated (Deprecated) import Wire.API.Error import Wire.API.Error.Brig +import Wire.API.Routes.API import Wire.API.Routes.Internal.Spar import Wire.API.Routes.Public import Wire.API.SwaggerServant @@ -45,7 +46,7 @@ import Wire.API.User.Scim -- FUTUREWORK: use https://hackage.haskell.org/package/servant-0.14.1/docs/Servant-API-Generic.html? -type API = +type SparAPI = "sso" :> APISSO :<|> "identity-providers" :> APIIDP :<|> "scim" :> APIScim @@ -57,7 +58,7 @@ type DeprecateSSOAPIV1 = \Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams" type APISSO = - DeprecateSSOAPIV1 :> "metadata" :> SAML.APIMeta + DeprecateSSOAPIV1 :> Deprecated :> "metadata" :> SAML.APIMeta :<|> "metadata" :> Capture "team" TeamId :> SAML.APIMeta :<|> "initiate-login" :> APIAuthReqPrecheck :<|> "initiate-login" :> APIAuthReq @@ -82,6 +83,7 @@ type APIAuthReq = type APIAuthRespLegacy = DeprecateSSOAPIV1 + :> Deprecated :> "finalize-login" -- (SAML.APIAuthResp from here on, except for response) :> MultipartForm Mem SAML.AuthnResponseBody @@ -186,5 +188,9 @@ type APIScimTokenDelete = type APIScimTokenList = Get '[JSON] ScimTokenList -swaggerDoc :: Swagger -swaggerDoc = toSwagger (Proxy @API) +data SparAPITag + +instance ServiceAPI SparAPITag v where + type ServiceAPIRoutes SparAPITag = SparAPI + type SpecialisedAPIRoutes v SparAPITag = SparAPI + serviceSwagger = toOpenApi (Proxy @SparAPI) diff --git a/libs/wire-api/src/Wire/API/Routes/Public/Util.hs b/libs/wire-api/src/Wire/API/Routes/Public/Util.hs index 694230f7574..ab34186bb12 100644 --- a/libs/wire-api/src/Wire/API/Routes/Public/Util.hs +++ b/libs/wire-api/src/Wire/API/Routes/Public/Util.hs @@ -23,7 +23,7 @@ module Wire.API.Routes.Public.Util where import Control.Comonad import Data.SOP (I (..), NS (..)) import Servant -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Routes.MultiVerb instance diff --git a/libs/wire-api/src/Wire/API/Routes/QualifiedCapture.hs b/libs/wire-api/src/Wire/API/Routes/QualifiedCapture.hs index 4fd030267d0..9147e008fda 100644 --- a/libs/wire-api/src/Wire/API/Routes/QualifiedCapture.hs +++ b/libs/wire-api/src/Wire/API/Routes/QualifiedCapture.hs @@ -24,16 +24,17 @@ where import Data.Domain import Data.Kind import Data.Metrics.Servant +import Data.OpenApi hiding (HasServer, value) import Data.Qualified -import Data.Swagger import GHC.TypeLits import Imports import Servant import Servant.API.Description import Servant.API.Modifiers import Servant.Client.Core.HasClient +import Servant.OpenApi import Servant.Server.Internal.ErrorFormatter -import Servant.Swagger +import Wire.API.Routes.Version -- | Capture a value qualified by a domain, with modifiers. data QualifiedCapture' (mods :: [Type]) (capture :: Symbol) (a :: Type) @@ -50,17 +51,20 @@ type WithDomain mods capture a api = :> Capture' mods capture a :> api +type instance + SpecialiseToVersion v (QualifiedCapture' mods capture a :> api) = + QualifiedCapture' mods capture a :> SpecialiseToVersion v api + instance - ( Typeable a, - ToParamSchema a, - HasSwagger api, + ( ToParamSchema a, + HasOpenApi api, KnownSymbol capture, KnownSymbol (AppendSymbol capture "_domain"), KnownSymbol (FoldDescription mods) ) => - HasSwagger (QualifiedCapture' mods capture a :> api) + HasOpenApi (QualifiedCapture' mods capture a :> api) where - toSwagger _ = toSwagger (Proxy @(WithDomain mods capture a api)) + toOpenApi _ = toOpenApi (Proxy @(WithDomain mods capture a api)) instance ( KnownSymbol capture, diff --git a/libs/wire-api/src/Wire/API/Routes/Version.hs b/libs/wire-api/src/Wire/API/Routes/Version.hs index 95fba403022..98a184592ed 100644 --- a/libs/wire-api/src/Wire/API/Routes/Version.hs +++ b/libs/wire-api/src/Wire/API/Routes/Version.hs @@ -22,8 +22,8 @@ module Wire.API.Routes.Version ( -- * API version endpoint VersionAPI, + VersionAPITag, VersionInfo (..), - versionSwagger, versionHeader, VersionHeader, @@ -31,11 +31,15 @@ module Wire.API.Routes.Version Version (..), VersionNumber (..), supportedVersions, + isDevelopmentVersion, developmentVersions, -- * Servant combinators Until, From, + + -- * Swagger instances + SpecialiseToVersion, ) where @@ -48,14 +52,17 @@ import Data.Binary.Builder qualified as Builder import Data.ByteString.Conversion (ToByteString (builder), toByteString') import Data.ByteString.Lazy qualified as LBS import Data.Domain +import Data.OpenApi qualified as S import Data.Schema -import Data.Singletons.TH -import Data.Swagger qualified as S -import Data.Text as Text +import Data.Singletons.Base.TH +import Data.Text qualified as Text import Data.Text.Encoding as Text +import GHC.TypeLits import Imports import Servant -import Servant.Swagger +import Servant.API.Extended.RawM +import Wire.API.Deprecated +import Wire.API.Routes.MultiVerb import Wire.API.Routes.Named import Wire.API.VersionInfo import Wire.Arbitrary (Arbitrary, GenericUniform (GenericUniform)) @@ -68,7 +75,7 @@ import Wire.Arbitrary (Arbitrary, GenericUniform (GenericUniform)) -- and 'developmentVersions' stay in sync; everything else here should keep working without -- change. See also documentation in the *docs* directory. -- https://docs.wire.com/developer/developer/api-versioning.html#version-bump-checklist -data Version = V0 | V1 | V2 | V3 | V4 +data Version = V0 | V1 | V2 | V3 | V4 | V5 deriving stock (Eq, Ord, Bounded, Enum, Show, Generic) deriving (FromJSON, ToJSON) via (Schema Version) deriving (Arbitrary) via (GenericUniform Version) @@ -85,12 +92,10 @@ versionInt V1 = 1 versionInt V2 = 2 versionInt V3 = 3 versionInt V4 = 4 +versionInt V5 = 5 supportedVersions :: [Version] -supportedVersions = [minBound .. V4] - -developmentVersions :: [Version] -developmentVersions = [V4] +supportedVersions = [minBound .. maxBound] ---------------------------------------------------------------------- @@ -179,7 +184,89 @@ type VersionAPI = :> Get '[JSON] VersionInfo ) -versionSwagger :: S.Swagger -versionSwagger = toSwagger (Proxy @VersionAPI) +data VersionAPITag + +-- Development versions $(genSingletons [''Version]) + +isDevelopmentVersion :: Version -> Bool +isDevelopmentVersion V0 = False +isDevelopmentVersion V1 = False +isDevelopmentVersion V2 = False +isDevelopmentVersion V3 = False +isDevelopmentVersion V4 = False +isDevelopmentVersion _ = True + +developmentVersions :: [Version] +developmentVersions = filter isDevelopmentVersion supportedVersions + +-- Version-aware swagger generation + +$(promoteOrdInstances [''Version]) + +type family SpecialiseToVersion (v :: Version) api + +type instance + SpecialiseToVersion v (From w :> api) = + If (v < w) EmptyAPI (SpecialiseToVersion v api) + +type instance + SpecialiseToVersion v (Until w :> api) = + If (v < w) (SpecialiseToVersion v api) EmptyAPI + +type instance + SpecialiseToVersion v ((s :: Symbol) :> api) = + s :> SpecialiseToVersion v api + +type instance + SpecialiseToVersion v (UntypedNamed n api) = + Named n (SpecialiseToVersion v api) + +type instance + SpecialiseToVersion v (Capture' mod sym a :> api) = + Capture' mod sym a :> SpecialiseToVersion v api + +type instance + SpecialiseToVersion v (Summary s :> api) = + Summary s :> SpecialiseToVersion v api + +type instance + SpecialiseToVersion v (Deprecated :> api) = + Deprecated :> SpecialiseToVersion v api + +type instance + SpecialiseToVersion v (Verb m s t r) = + Verb m s t r + +type instance + SpecialiseToVersion v (MultiVerb m t r x) = + MultiVerb m t r x + +type instance SpecialiseToVersion v RawM = RawM + +type instance + SpecialiseToVersion v (ReqBody t x :> api) = + ReqBody t x :> SpecialiseToVersion v api + +type instance + SpecialiseToVersion v (QueryParam' mods l x :> api) = + QueryParam' mods l x :> SpecialiseToVersion v api + +type instance + SpecialiseToVersion v (Header' opts l x :> api) = + Header' opts l x :> SpecialiseToVersion v api + +type instance + SpecialiseToVersion v (Description desc :> api) = + Description desc :> SpecialiseToVersion v api + +type instance + SpecialiseToVersion v (StreamBody' opts f t x :> api) = + StreamBody' opts f t x :> SpecialiseToVersion v api + +type instance SpecialiseToVersion v EmptyAPI = EmptyAPI + +type instance + SpecialiseToVersion v (api1 :<|> api2) = + SpecialiseToVersion v api1 :<|> SpecialiseToVersion v api2 diff --git a/libs/wire-api/src/Wire/API/Routes/Versioned.hs b/libs/wire-api/src/Wire/API/Routes/Versioned.hs index be309b77fd9..7707e3441e6 100644 --- a/libs/wire-api/src/Wire/API/Routes/Versioned.hs +++ b/libs/wire-api/src/Wire/API/Routes/Versioned.hs @@ -20,15 +20,15 @@ module Wire.API.Routes.Versioned where import Data.Aeson (FromJSON, ToJSON) import Data.Kind import Data.Metrics.Servant +import Data.OpenApi qualified as S import Data.Schema import Data.Singletons -import Data.Swagger qualified as S import GHC.TypeLits import Imports import Servant import Servant.API.ContentTypes -import Servant.Swagger -import Servant.Swagger.Internal +import Servant.OpenApi +import Servant.OpenApi.Internal import Wire.API.Routes.MultiVerb import Wire.API.Routes.Version @@ -57,14 +57,18 @@ instance route _p ctx d = route (Proxy :: Proxy (ReqBody cts (Versioned v a) :> api)) ctx (fmap (. unVersioned) d) +type instance + SpecialiseToVersion w (VersionedReqBody v cts a :> api) = + VersionedReqBody v cts a :> SpecialiseToVersion w api + instance ( S.ToSchema (Versioned v a), - HasSwagger api, + HasOpenApi api, AllAccept cts ) => - HasSwagger (VersionedReqBody v cts a :> api) + HasOpenApi (VersionedReqBody v cts a :> api) where - toSwagger _ = toSwagger (Proxy @(ReqBody cts (Versioned v a) :> api)) + toOpenApi _ = toOpenApi (Proxy @(ReqBody cts (Versioned v a) :> api)) -------------------------------------------------------------------------------- -- Versioned responses @@ -88,7 +92,7 @@ instance (KnownSymbol desc, S.ToSchema a) => IsSwaggerResponse (VersionedRespond v s desc a) where - responseSwagger = simpleResponseSwagger @a @desc + responseSwagger = simpleResponseSwagger @a @'[JSON] @desc ------------------------------------------------------------------------------- -- Versioned newtype wrapper @@ -107,7 +111,7 @@ deriving via Schema (Versioned v a) instance ToSchema (Versioned v a) => FromJSO deriving via Schema (Versioned v a) instance ToSchema (Versioned v a) => ToJSON (Versioned v a) -- add version suffix to swagger schema to prevent collisions -instance (SingI v, ToSchema (Versioned v a)) => S.ToSchema (Versioned v a) where +instance (SingI v, ToSchema (Versioned v a), Typeable a, Typeable v) => S.ToSchema (Versioned v a) where declareNamedSchema _ = do S.NamedSchema n s <- schemaToSwagger (Proxy @(Versioned v a)) pure $ S.NamedSchema (fmap (<> toUrlPiece (demote @v)) n) s diff --git a/libs/wire-api/src/Wire/API/Routes/WebSocket.hs b/libs/wire-api/src/Wire/API/Routes/WebSocket.hs index 756605366ad..0405b58d094 100644 --- a/libs/wire-api/src/Wire/API/Routes/WebSocket.hs +++ b/libs/wire-api/src/Wire/API/Routes/WebSocket.hs @@ -21,16 +21,17 @@ import Control.Lens import Control.Monad.Trans.Resource import Data.HashMap.Strict.InsOrd import Data.Metrics.Servant +import Data.OpenApi hiding (HasServer) import Data.Proxy -import Data.Swagger import Imports import Network.Wai.Handler.WebSockets import Network.WebSockets +import Servant.OpenApi import Servant.Server hiding (respond) import Servant.Server.Internal.Delayed import Servant.Server.Internal.RouteResult import Servant.Server.Internal.Router -import Servant.Swagger +import Wire.API.Routes.Version -- | A websocket that relates to a 'PendingConnection' -- Copied and adapted from: @@ -62,8 +63,10 @@ instance HasServer WebSocketPending ctx where errHeaders = mempty } -instance HasSwagger WebSocketPending where - toSwagger _ = +type instance SpecialiseToVersion v WebSocketPending = WebSocketPending + +instance HasOpenApi WebSocketPending where + toOpenApi _ = mempty & paths . at "/" @@ -79,7 +82,7 @@ instance HasSwagger WebSocketPending where ) ) where - resps :: InsOrdHashMap HttpStatusCode (Referenced Data.Swagger.Response) + resps :: InsOrdHashMap HttpStatusCode (Referenced Data.OpenApi.Response) resps = mempty & at 101 ?~ Inline (mempty & description .~ "Connection upgraded.") diff --git a/libs/wire-api/src/Wire/API/ServantProto.hs b/libs/wire-api/src/Wire/API/ServantProto.hs index 3eb06458fab..6e2dbd6140b 100644 --- a/libs/wire-api/src/Wire/API/ServantProto.hs +++ b/libs/wire-api/src/Wire/API/ServantProto.hs @@ -19,7 +19,7 @@ module Wire.API.ServantProto where import Data.ByteString.Lazy qualified as LBS import Data.List.NonEmpty (NonEmpty (..)) -import Data.Swagger +import Data.OpenApi import Imports import Network.HTTP.Media ((//)) import Servant diff --git a/libs/wire-api/src/Wire/API/SwaggerHelper.hs b/libs/wire-api/src/Wire/API/SwaggerHelper.hs index 9e1927156c1..3e882b8ab5c 100644 --- a/libs/wire-api/src/Wire/API/SwaggerHelper.hs +++ b/libs/wire-api/src/Wire/API/SwaggerHelper.hs @@ -19,23 +19,33 @@ module Wire.API.SwaggerHelper where import Control.Lens import Data.Containers.ListUtils (nubOrd) -import Data.Swagger hiding (Contact, Header, Schema, ToSchema) -import Data.Swagger qualified as S +import Data.HashMap.Strict.InsOrd +import Data.OpenApi hiding (Contact, Header, Schema, ToSchema) +import Data.OpenApi qualified as S +import Data.Text qualified as T import Imports hiding (head) -cleanupSwagger :: Swagger -> Swagger +cleanupSwagger :: OpenApi -> OpenApi cleanupSwagger = (S.security %~ nub) -- sanitise definitions - . (S.definitions . traverse %~ sanitise) + . (S.components . S.schemas . traverse %~ sanitise) + -- strip the default errors + . ( S.allOperations + . S.responses + . S.responses + %~ foldrWithKey stripDefaultErrors mempty + ) -- sanitise general responses - . (S.responses . traverse . S.schema . _Just . S._Inline %~ sanitise) + . (S.components . S.responses . traverse . S.content . traverse . S.schema . _Just . S._Inline %~ sanitise) -- sanitise all responses of all paths . ( S.allOperations . S.responses . S.responses . traverse . S._Inline + . S.content + . traverse . S.schema . _Just . S._Inline @@ -47,3 +57,49 @@ cleanupSwagger = (S.properties . traverse . S._Inline %~ sanitise) . (S.required %~ nubOrd) . (S.enum_ . _Just %~ nub) + -- servant-openapi and servant-swagger both insert default responses with codes 404 and 400. + -- They have a simple structure that we can match against, and remove from the final structure. + stripDefaultErrors :: HttpStatusCode -> Referenced Response -> Responses' -> Responses' + stripDefaultErrors code resp resps = + case code of + 400 -> case resp ^? _Inline . S.description of + (Just desc) -> + if "Invalid " + `T.isPrefixOf` desc + && resp + ^? _Inline + . links + == pure mempty + && resp + ^? _Inline + . content + == pure mempty + && resp + ^? _Inline + . headers + == pure mempty + then resps + else insert code resp resps + Nothing -> insert code resp resps + 404 -> case resp ^? _Inline . S.description of + (Just desc) -> + if " not found" + `T.isSuffixOf` desc + && resp + ^? _Inline + . links + == pure mempty + && resp + ^? _Inline + . content + == pure mempty + && resp + ^? _Inline + . headers + == pure mempty + then resps + else insert code resp resps + Nothing -> insert code resp resps + _ -> insert code resp resps + +type Responses' = InsOrdHashMap HttpStatusCode (Referenced Response) diff --git a/libs/wire-api/src/Wire/API/SwaggerServant.hs b/libs/wire-api/src/Wire/API/SwaggerServant.hs index 89973fb59ae..5c3918cf39c 100644 --- a/libs/wire-api/src/Wire/API/SwaggerServant.hs +++ b/libs/wire-api/src/Wire/API/SwaggerServant.hs @@ -25,7 +25,7 @@ import Data.Metrics.Servant import Data.Proxy import Imports hiding (head) import Servant -import Servant.Swagger (HasSwagger (toSwagger)) +import Servant.OpenApi (HasOpenApi (toOpenApi)) -- | A type-level tag that lets us omit any branch from Swagger docs. -- @@ -34,8 +34,8 @@ import Servant.Swagger (HasSwagger (toSwagger)) -- it's only justification is laziness. data OmitDocs -instance HasSwagger (OmitDocs :> a) where - toSwagger _ = mempty +instance HasOpenApi (OmitDocs :> a) where + toOpenApi _ = mempty instance HasServer api ctx => HasServer (OmitDocs :> api) ctx where type ServerT (OmitDocs :> api) m = ServerT api m diff --git a/libs/wire-api/src/Wire/API/SystemSettings.hs b/libs/wire-api/src/Wire/API/SystemSettings.hs index d6098ac4ec5..d07d7152a44 100644 --- a/libs/wire-api/src/Wire/API/SystemSettings.hs +++ b/libs/wire-api/src/Wire/API/SystemSettings.hs @@ -19,10 +19,10 @@ module Wire.API.SystemSettings where import Control.Lens hiding ((.=)) import Data.Aeson qualified as A +import Data.OpenApi qualified as S import Data.Schema as Schema -import Data.Swagger qualified as S import Imports -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Test.QuickCheck import Wire.Arbitrary diff --git a/libs/wire-api/src/Wire/API/Team.hs b/libs/wire-api/src/Wire/API/Team.hs index fe3ed5e596c..13c09ab567b 100644 --- a/libs/wire-api/src/Wire/API/Team.hs +++ b/libs/wire-api/src/Wire/API/Team.hs @@ -67,7 +67,7 @@ module Wire.API.Team ) where -import Control.Lens (makeLenses, (?~)) +import Control.Lens (makeLenses, over, (?~)) import Data.Aeson (FromJSON, ToJSON, Value (..)) import Data.Aeson.Types (Parser) import Data.Attoparsec.ByteString qualified as Atto (Parser, string) @@ -76,9 +76,10 @@ import Data.ByteString.Conversion import Data.Code qualified as Code import Data.Id (TeamId, UserId) import Data.Misc (PlainTextPassword6) +import Data.OpenApi (HasDeprecated (deprecated)) +import Data.OpenApi qualified as S import Data.Range import Data.Schema -import Data.Swagger qualified as S import Data.Text.Encoding qualified as T import Imports import Test.QuickCheck.Gen (suchThat) @@ -118,7 +119,10 @@ instance ToSchema Team where <*> _teamSplashScreen .= (fromMaybe DefaultIcon <$> optField "splash_screen" schema) where desc = description ?~ "`binding` is deprecated, and should be ignored. The non-binding teams API is not used (and will not be supported from API version V4 onwards), and `binding` will always be `true`." - bindingDesc = description ?~ "Deprecated, please ignore." + bindingDesc v = + v + & description ?~ "Deprecated, please ignore." + & deprecated ?~ True -- | How a team "binds" its members (users) -- @@ -145,8 +149,9 @@ data TeamBinding instance ToSchema TeamBinding where schema = - enum @Bool "TeamBinding" $ - mconcat [element True Binding, element False NonBinding] + over doc (deprecated ?~ True) $ + enum @Bool "TeamBinding" $ + mconcat [element True Binding, element False NonBinding] -------------------------------------------------------------------------------- -- TeamList diff --git a/libs/wire-api/src/Wire/API/Team/Conversation.hs b/libs/wire-api/src/Wire/API/Team/Conversation.hs index ae020086104..3822a614923 100644 --- a/libs/wire-api/src/Wire/API/Team/Conversation.hs +++ b/libs/wire-api/src/Wire/API/Team/Conversation.hs @@ -35,8 +35,8 @@ where import Control.Lens (makeLenses, (?~)) import Data.Aeson qualified as A import Data.Id (ConvId) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports import Wire.Arbitrary (Arbitrary, GenericUniform (..)) diff --git a/libs/wire-api/src/Wire/API/Team/Feature.hs b/libs/wire-api/src/Wire/API/Team/Feature.hs index 5e3a3bf9f39..c8b1e00c205 100644 --- a/libs/wire-api/src/Wire/API/Team/Feature.hs +++ b/libs/wire-api/src/Wire/API/Team/Feature.hs @@ -80,6 +80,7 @@ module Wire.API.Team.Feature MLSConfig (..), OutlookCalIntegrationConfig (..), MlsE2EIdConfig (..), + MlsMigrationConfig (..), AllFeatureConfigs (..), unImplicitLockStatus, ImplicitLockStatus (..), @@ -96,23 +97,24 @@ import Data.ByteString.UTF8 qualified as UTF8 import Data.Domain (Domain) import Data.Either.Extra (maybeToEither) import Data.Id +import Data.Json.Util import Data.Kind import Data.Misc (HttpsUrl) +import Data.OpenApi qualified as S import Data.Proxy import Data.Schema import Data.Scientific (toBoundedInteger) -import Data.Swagger qualified as S import Data.Text qualified as T import Data.Text.Encoding qualified as T import Data.Text.Lazy qualified as TL -import Data.Time (NominalDiffTime) +import Data.Time import Deriving.Aeson import GHC.TypeLits import Imports import Servant (FromHttpApiData (..), ToHttpApiData (..)) import Test.QuickCheck.Arbitrary (arbitrary) import Test.QuickCheck.Gen (suchThat) -import Wire.API.Conversation.Protocol (ProtocolTag (ProtocolProteusTag)) +import Wire.API.Conversation.Protocol import Wire.API.MLS.CipherSuite (CipherSuiteTag (MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519)) import Wire.API.Routes.Named (RenderableSymbol (renderSymbol)) import Wire.Arbitrary (Arbitrary, GenericUniform (..)) @@ -137,17 +139,22 @@ import Wire.Arbitrary (Arbitrary, GenericUniform (..)) -- Galley.Cassandra.TeamFeatures -- -- 4. Add the feature to the config schema of galley in Galley.Types.Teams. --- and extend the Arbitrary instance of FeatureConfigs in the unit tests Test.Galley.Types +-- and extend the Arbitrary instance of FeatureConfigs in the unit tests +-- Test.Galley.Types -- -- 5. Implement 'GetFeatureConfig' and 'SetFeatureConfig' in -- Galley.API.Teams.Features which defines the main business logic for getting --- and setting (with side-effects). Note that we don't have to check the lockstatus inside 'setConfigForTeam' --- because the lockstatus is checked in 'setFeatureStatus' before which is the public API for setting the feature status. +-- and setting (with side-effects). Note that we don't have to check the +-- lockstatus inside 'setConfigForTeam' because the lockstatus is checked in +-- 'setFeatureStatus' before which is the public API for setting the feature +-- status. Also extend FeaturePersistentAllFeatures. -- --- 6. Add public routes to Wire.API.Routes.Public.Galley.Feature: 'FeatureStatusGet', --- 'FeatureStatusPut' (optional). Then implement them in Galley.API.Public.Feature. +-- 6. Add public routes to Wire.API.Routes.Public.Galley.Feature: +-- 'FeatureStatusGet', 'FeatureStatusPut' (optional). Then implement them in +-- Galley.API.Public.Feature. -- --- 7. Add internal routes in Wire.API.Routes.Internal.Galley +-- 7. Add internal routes in Wire.API.Routes.Internal.Galley and implement them +-- in Galley.API.Internal. -- -- 8. If the feature should be configurable via Stern add routes to Stern.API. -- Manually check that the swagger looks okay and works. @@ -195,6 +202,7 @@ data FeatureSingleton cfg where FeatureSingletonExposeInvitationURLsToTeamAdminConfig :: FeatureSingleton ExposeInvitationURLsToTeamAdminConfig FeatureSingletonOutlookCalIntegrationConfig :: FeatureSingleton OutlookCalIntegrationConfig FeatureSingletonMlsE2EIdConfig :: FeatureSingleton MlsE2EIdConfig + FeatureSingletonMlsMigration :: FeatureSingleton MlsMigrationConfig class FeatureTrivialConfig cfg where trivialConfig :: cfg @@ -266,7 +274,7 @@ deriving via (Schema (WithStatus cfg)) instance (ToSchema (WithStatus cfg)) => T deriving via (Schema (WithStatus cfg)) instance (ToSchema (WithStatus cfg)) => FromJSON (WithStatus cfg) -deriving via (Schema (WithStatus cfg)) instance (ToSchema (WithStatus cfg)) => S.ToSchema (WithStatus cfg) +deriving via (Schema (WithStatus cfg)) instance (ToSchema (WithStatus cfg), Typeable cfg) => S.ToSchema (WithStatus cfg) instance (ToSchema cfg, IsFeatureConfig cfg) => ToSchema (WithStatus cfg) where schema = @@ -296,7 +304,7 @@ deriving via (Schema (WithStatusPatch cfg)) instance (ToSchema (WithStatusPatch deriving via (Schema (WithStatusPatch cfg)) instance (ToSchema (WithStatusPatch cfg)) => FromJSON (WithStatusPatch cfg) -deriving via (Schema (WithStatusPatch cfg)) instance (ToSchema (WithStatusPatch cfg)) => S.ToSchema (WithStatusPatch cfg) +deriving via (Schema (WithStatusPatch cfg)) instance (ToSchema (WithStatusPatch cfg), Typeable cfg) => S.ToSchema (WithStatusPatch cfg) wsPatch :: Maybe FeatureStatus -> Maybe LockStatus -> Maybe cfg -> Maybe FeatureTTL -> WithStatusPatch cfg wsPatch = WithStatusBase @@ -904,7 +912,8 @@ data MLSConfig = MLSConfig { mlsProtocolToggleUsers :: [UserId], mlsDefaultProtocol :: ProtocolTag, mlsAllowedCipherSuites :: [CipherSuiteTag], - mlsDefaultCipherSuite :: CipherSuiteTag + mlsDefaultCipherSuite :: CipherSuiteTag, + mlsSupportedProtocols :: [ProtocolTag] } deriving stock (Eq, Show, Generic) deriving (Arbitrary) via (GenericUniform MLSConfig) @@ -920,11 +929,18 @@ instance ToSchema MLSConfig where <*> mlsDefaultProtocol .= field "defaultProtocol" schema <*> mlsAllowedCipherSuites .= field "allowedCipherSuites" (array schema) <*> mlsDefaultCipherSuite .= field "defaultCipherSuite" schema + <*> mlsSupportedProtocols .= field "supportedProtocols" (array schema) instance IsFeatureConfig MLSConfig where type FeatureSymbol MLSConfig = "mls" defFeatureStatus = - let config = MLSConfig [] ProtocolProteusTag [MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519] MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + let config = + MLSConfig + [] + ProtocolProteusTag + [MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519] + MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + [ProtocolProteusTag, ProtocolMLSTag] in withStatus FeatureStatusDisabled LockStatusUnlocked config FeatureTTLUnlimited featureSingleton = FeatureSingletonMLSConfig objectSchema = field "config" schema @@ -1030,6 +1046,43 @@ instance IsFeatureConfig MlsE2EIdConfig where featureSingleton = FeatureSingletonMlsE2EIdConfig objectSchema = field "config" schema +---------------------------------------------------------------------- +-- MlsMigration + +data MlsMigrationConfig = MlsMigrationConfig + { startTime :: Maybe UTCTime, + finaliseRegardlessAfter :: Maybe UTCTime + } + deriving stock (Eq, Show, Generic) + +instance RenderableSymbol MlsMigrationConfig where + renderSymbol = "MlsMigrationConfig" + +instance Arbitrary MlsMigrationConfig where + arbitrary = do + startTime <- fmap fromUTCTimeMillis <$> arbitrary + finaliseRegardlessAfter <- fmap fromUTCTimeMillis <$> arbitrary + pure + MlsMigrationConfig + { startTime = startTime, + finaliseRegardlessAfter = finaliseRegardlessAfter + } + +instance ToSchema MlsMigrationConfig where + schema = + object "MlsMigration" $ + MlsMigrationConfig + <$> startTime .= maybe_ (optField "startTime" utcTimeSchema) + <*> finaliseRegardlessAfter .= maybe_ (optField "finaliseRegardlessAfter" utcTimeSchema) + +instance IsFeatureConfig MlsMigrationConfig where + type FeatureSymbol MlsMigrationConfig = "mlsMigration" + defFeatureStatus = withStatus FeatureStatusDisabled LockStatusLocked defValue FeatureTTLUnlimited + where + defValue = MlsMigrationConfig Nothing Nothing + featureSingleton = FeatureSingletonMlsMigration + objectSchema = field "config" schema + ---------------------------------------------------------------------- -- FeatureStatus @@ -1043,8 +1096,8 @@ data FeatureStatus instance S.ToParamSchema FeatureStatus where toParamSchema _ = mempty - { S._paramSchemaType = Just S.SwaggerString, - S._paramSchemaEnum = Just (A.String . toQueryParam <$> [(minBound :: FeatureStatus) ..]) + { S._schemaType = Just S.OpenApiString, + S._schemaEnum = Just (A.String . toQueryParam <$> [(minBound :: FeatureStatus) ..]) } instance FromHttpApiData FeatureStatus where @@ -1106,7 +1159,8 @@ data AllFeatureConfigs = AllFeatureConfigs afcMLS :: WithStatus MLSConfig, afcExposeInvitationURLsToTeamAdmin :: WithStatus ExposeInvitationURLsToTeamAdminConfig, afcOutlookCalIntegration :: WithStatus OutlookCalIntegrationConfig, - afcMlsE2EId :: WithStatus MlsE2EIdConfig + afcMlsE2EId :: WithStatus MlsE2EIdConfig, + afcMlsMigration :: WithStatus MlsMigrationConfig } deriving stock (Eq, Show) deriving (FromJSON, ToJSON, S.ToSchema) via (Schema AllFeatureConfigs) @@ -1132,6 +1186,7 @@ instance ToSchema AllFeatureConfigs where <*> afcExposeInvitationURLsToTeamAdmin .= featureField <*> afcOutlookCalIntegration .= featureField <*> afcMlsE2EId .= featureField + <*> afcMlsMigration .= featureField where featureField :: forall cfg. @@ -1159,5 +1214,6 @@ instance Arbitrary AllFeatureConfigs where <*> arbitrary <*> arbitrary <*> arbitrary + <*> arbitrary makeLenses ''ImplicitLockStatus diff --git a/libs/wire-api/src/Wire/API/Team/Invitation.hs b/libs/wire-api/src/Wire/API/Team/Invitation.hs index 8593c67ce97..44cc508ab69 100644 --- a/libs/wire-api/src/Wire/API/Team/Invitation.hs +++ b/libs/wire-api/src/Wire/API/Team/Invitation.hs @@ -32,9 +32,9 @@ import Data.Aeson qualified as A import Data.ByteString.Conversion import Data.Id import Data.Json.Util +import Data.OpenApi qualified as S import Data.SOP import Data.Schema -import Data.Swagger qualified as S import Data.Text.Encoding qualified as TE import Imports import Servant (FromHttpApiData (..), ToHttpApiData (..)) @@ -130,7 +130,7 @@ newtype InvitationLocation = InvitationLocation instance S.ToParamSchema InvitationLocation where toParamSchema _ = mempty - & S.type_ ?~ S.SwaggerString + & S.type_ ?~ S.OpenApiString & S.format ?~ "url" instance FromHttpApiData InvitationLocation where diff --git a/libs/wire-api/src/Wire/API/Team/LegalHold.hs b/libs/wire-api/src/Wire/API/Team/LegalHold.hs index d72dafb5da8..40fbb9a7af0 100644 --- a/libs/wire-api/src/Wire/API/Team/LegalHold.hs +++ b/libs/wire-api/src/Wire/API/Team/LegalHold.hs @@ -35,9 +35,9 @@ import Data.Aeson.Types qualified as A import Data.Id import Data.LegalHold import Data.Misc +import Data.OpenApi qualified as S hiding (info) import Data.Proxy import Data.Schema -import Data.Swagger qualified as S hiding (info) import Deriving.Aeson import Imports import Wire.API.Provider @@ -240,11 +240,11 @@ instance ToSchema LegalholdProtectee where pure $ S.NamedSchema (Just "LegalholdProtectee") $ mempty - & S.type_ ?~ S.SwaggerObject + & S.type_ ?~ S.OpenApiObject & S.properties . at "tag" ?~ S.Inline ( mempty - & S.type_ ?~ S.SwaggerString + & S.type_ ?~ S.OpenApiString & S.enum_ ?~ [ A.toJSON ("ProtectedUser" :: String), A.toJSON ("UnprotectedBot" :: String), diff --git a/libs/wire-api/src/Wire/API/Team/LegalHold/External.hs b/libs/wire-api/src/Wire/API/Team/LegalHold/External.hs index ea892087bfe..8dc5fd14366 100644 --- a/libs/wire-api/src/Wire/API/Team/LegalHold/External.hs +++ b/libs/wire-api/src/Wire/API/Team/LegalHold/External.hs @@ -34,7 +34,7 @@ where import Data.Aeson hiding (fieldLabelModifier) import Data.Id import Data.Json.Util ((#)) -import Data.Swagger +import Data.OpenApi import Imports import Wire.API.User.Client.Prekey import Wire.Arbitrary (Arbitrary, GenericUniform (..)) diff --git a/libs/wire-api/src/Wire/API/Team/LegalHold/Internal.hs b/libs/wire-api/src/Wire/API/Team/LegalHold/Internal.hs index cb3915f4a38..e706f472fc6 100644 --- a/libs/wire-api/src/Wire/API/Team/LegalHold/Internal.hs +++ b/libs/wire-api/src/Wire/API/Team/LegalHold/Internal.hs @@ -29,8 +29,8 @@ import Data.Aeson import Data.Id import Data.Json.Util import Data.Misc +import Data.OpenApi qualified as Swagger import Data.Schema qualified as Schema -import Data.Swagger qualified as Swagger import Imports import Wire.API.Provider import Wire.API.Provider.Service diff --git a/libs/wire-api/src/Wire/API/Team/Member.hs b/libs/wire-api/src/Wire/API/Team/Member.hs index d47061389b5..91e790aa66e 100644 --- a/libs/wire-api/src/Wire/API/Team/Member.hs +++ b/libs/wire-api/src/Wire/API/Team/Member.hs @@ -74,10 +74,10 @@ import Data.Json.Util import Data.Kind import Data.LegalHold (UserLegalHoldStatus (..), defUserLegalHoldStatus) import Data.Misc (PlainTextPassword6) +import Data.OpenApi (ToParamSchema (..)) +import Data.OpenApi.Schema qualified as S import Data.Proxy import Data.Schema -import Data.Swagger (ToParamSchema (..)) -import Data.Swagger.Schema qualified as S import GHC.TypeLits import Imports import Wire.API.Routes.MultiTablePaging (MultiTablePage (..)) @@ -132,7 +132,7 @@ deriving via deriving via (Schema (TeamMember' tag)) instance - (ToSchema (TeamMember' tag)) => + (ToSchema (TeamMember' tag), Typeable tag) => S.ToSchema (TeamMember' tag) mkTeamMember :: @@ -256,7 +256,7 @@ deriving via deriving via (Schema (TeamMemberList' tag)) instance - ToSchema (TeamMemberList' tag) => + (ToSchema (TeamMemberList' tag), Typeable tag) => S.ToSchema (TeamMemberList' tag) newTeamMemberList :: [TeamMember] -> ListType -> TeamMemberList @@ -348,7 +348,7 @@ deriving via deriving via (Schema (NewTeamMember' tag)) instance - (ToSchema (NewTeamMember' tag)) => + (ToSchema (NewTeamMember' tag), Typeable tag) => S.ToSchema (NewTeamMember' tag) deriving via (GenericUniform NewTeamMember) instance Arbitrary NewTeamMember diff --git a/libs/wire-api/src/Wire/API/Team/Permission.hs b/libs/wire-api/src/Wire/API/Team/Permission.hs index be29d6b46d1..49a9893b370 100644 --- a/libs/wire-api/src/Wire/API/Team/Permission.hs +++ b/libs/wire-api/src/Wire/API/Team/Permission.hs @@ -48,10 +48,10 @@ import Control.Error.Util qualified as Err import Control.Lens (makeLenses, (?~), (^.)) import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Bits (testBit, (.|.)) +import Data.OpenApi qualified as S import Data.Schema import Data.Set qualified as Set import Data.Singletons.Base.TH -import Data.Swagger qualified as S import Imports import Wire.API.Util.Aeson (CustomEncoded (..)) import Wire.Arbitrary (Arbitrary (arbitrary), GenericUniform (..)) diff --git a/libs/wire-api/src/Wire/API/Team/Role.hs b/libs/wire-api/src/Wire/API/Team/Role.hs index 424065e66c0..d4602394750 100644 --- a/libs/wire-api/src/Wire/API/Team/Role.hs +++ b/libs/wire-api/src/Wire/API/Team/Role.hs @@ -29,8 +29,8 @@ import Control.Lens ((?~)) import Data.Aeson import Data.Attoparsec.ByteString.Char8 (string) import Data.ByteString.Conversion (FromByteString (..), ToByteString (..)) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as T import Imports import Servant.API (FromHttpApiData, parseQueryParam) @@ -93,7 +93,7 @@ instance ToSchema Role where instance S.ToParamSchema Role where toParamSchema _ = mempty - & S.type_ ?~ S.SwaggerString + & S.type_ ?~ S.OpenApiString & S.enum_ ?~ fmap roleName [minBound .. maxBound] instance FromHttpApiData Role where diff --git a/libs/wire-api/src/Wire/API/Team/SearchVisibility.hs b/libs/wire-api/src/Wire/API/Team/SearchVisibility.hs index b41300a8b7a..76d530f6f15 100644 --- a/libs/wire-api/src/Wire/API/Team/SearchVisibility.hs +++ b/libs/wire-api/src/Wire/API/Team/SearchVisibility.hs @@ -24,8 +24,8 @@ module Wire.API.Team.SearchVisibility where import Control.Lens ((?~)) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Deriving.Aeson import Imports import Wire.Arbitrary (Arbitrary, GenericUniform (..)) diff --git a/libs/wire-api/src/Wire/API/Team/Size.hs b/libs/wire-api/src/Wire/API/Team/Size.hs index 811a7a094e6..ce0d8fe6468 100644 --- a/libs/wire-api/src/Wire/API/Team/Size.hs +++ b/libs/wire-api/src/Wire/API/Team/Size.hs @@ -22,8 +22,8 @@ where import Control.Lens ((?~)) import Data.Aeson qualified as A +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports import Numeric.Natural diff --git a/libs/wire-api/src/Wire/API/Unreachable.hs b/libs/wire-api/src/Wire/API/Unreachable.hs index baf37558eff..54055ae6359 100644 --- a/libs/wire-api/src/Wire/API/Unreachable.hs +++ b/libs/wire-api/src/Wire/API/Unreachable.hs @@ -28,9 +28,9 @@ import Data.Aeson qualified as A import Data.Id import Data.List.NonEmpty import Data.List.NonEmpty qualified as NE +import Data.OpenApi qualified as S import Data.Qualified import Data.Schema -import Data.Swagger qualified as S import Imports newtype UnreachableUsers = UnreachableUsers {unreachableUsers :: NonEmpty (Qualified UserId)} diff --git a/libs/wire-api/src/Wire/API/User.hs b/libs/wire-api/src/Wire/API/User.hs index 9404502ca68..c0095855963 100644 --- a/libs/wire-api/src/Wire/API/User.hs +++ b/libs/wire-api/src/Wire/API/User.hs @@ -25,6 +25,7 @@ module Wire.API.User UserIdList (..), UserIds (..), QualifiedUserIdList (..), + qualifiedUserIdListObjectSchema, LimitedQualifiedUserIdList (..), ScimUserInfo (..), ScimUserInfos (..), @@ -146,6 +147,7 @@ module Wire.API.User -- * Protocol preferences BaseProtocolTag (..), + baseProtocolToProtocol, SupportedProtocolUpdate (..), defSupportedProtocols, protocolSetBits, @@ -177,13 +179,13 @@ import Data.Json.Util (UTCTimeMillis, (#)) import Data.LegalHold (UserLegalHoldStatus) import Data.List.NonEmpty (NonEmpty (..)) import Data.Misc (PlainTextPassword6, PlainTextPassword8) +import Data.OpenApi qualified as S import Data.Qualified import Data.Range import Data.SOP import Data.Schema import Data.Schema qualified as Schema import Data.Set qualified as Set -import Data.Swagger qualified as S import Data.Text qualified as T import Data.Text.Ascii import Data.Text.Encoding qualified as T @@ -200,6 +202,7 @@ import Servant (FromHttpApiData (..), ToHttpApiData (..), type (.++)) import Test.QuickCheck qualified as QC import URI.ByteString (serializeURIRef) import Web.Cookie qualified as Web +import Wire.API.Conversation.Protocol import Wire.API.Error import Wire.API.Error.Brig import Wire.API.Error.Brig qualified as E @@ -546,12 +549,15 @@ newtype QualifiedUserIdList = QualifiedUserIdList {qualifiedUserIdList :: [Quali instance ToSchema QualifiedUserIdList where schema = - object "QualifiedUserIdList" $ - QualifiedUserIdList - <$> qualifiedUserIdList - .= field "qualified_user_ids" (array schema) - <* (fmap qUnqualified . qualifiedUserIdList) - .= field "user_ids" (deprecatedSchema "qualified_user_ids" (array schema)) + object "QualifiedUserIdList" qualifiedUserIdListObjectSchema + +qualifiedUserIdListObjectSchema :: ObjectSchema SwaggerDoc QualifiedUserIdList +qualifiedUserIdListObjectSchema = + QualifiedUserIdList + <$> qualifiedUserIdList + .= field "qualified_user_ids" (array schema) + <* (fmap qUnqualified . qualifiedUserIdList) + .= field "user_ids" (deprecatedSchema "qualified_user_ids" (array schema)) -------------------------------------------------------------------------------- -- LimitedQualifiedUserIdList @@ -1819,7 +1825,7 @@ instance S.ToSchema ListUsersQuery where pure $ S.NamedSchema (Just "ListUsersQuery") $ mempty - & S.type_ ?~ S.SwaggerObject + & S.type_ ?~ S.OpenApiObject & S.description ?~ "exactly one of qualified_ids or qualified_handles must be provided." & S.properties .~ InsOrdHashMap.fromList [("qualified_ids", uids), ("qualified_handles", handles)] & S.example ?~ toJSON (ListUsersByIds [Qualified (Id UUID.nil) (Domain "example.com")]) @@ -1896,6 +1902,7 @@ instance Schema.ToSchema UserAccount where -- NewUserScimInvitation data NewUserScimInvitation = NewUserScimInvitation + -- FIXME: the TID should be captured in the route as usual { newUserScimInvTeamId :: TeamId, newUserScimInvLocale :: Maybe Locale, newUserScimInvName :: Name, @@ -1954,8 +1961,8 @@ instance FromByteString VerificationAction where instance S.ToParamSchema VerificationAction where toParamSchema _ = mempty - { S._paramSchemaType = Just S.SwaggerString, - S._paramSchemaEnum = Just (A.String . toQueryParam <$> [(minBound :: VerificationAction) ..]) + { S._schemaType = Just S.OpenApiString, + S._schemaEnum = Just (A.String . toQueryParam <$> [(minBound :: VerificationAction) ..]) } instance FromHttpApiData VerificationAction where @@ -1997,6 +2004,10 @@ baseProtocolMask :: BaseProtocolTag -> Word32 baseProtocolMask BaseProtocolProteusTag = 1 baseProtocolMask BaseProtocolMLSTag = 2 +baseProtocolToProtocol :: BaseProtocolTag -> ProtocolTag +baseProtocolToProtocol BaseProtocolProteusTag = ProtocolProteusTag +baseProtocolToProtocol BaseProtocolMLSTag = ProtocolMLSTag + instance ToSchema BaseProtocolTag where schema = enum @Text "BaseProtocol" $ diff --git a/libs/wire-api/src/Wire/API/User/Activation.hs b/libs/wire-api/src/Wire/API/User/Activation.hs index 7777b2c25b8..e14b30bc326 100644 --- a/libs/wire-api/src/Wire/API/User/Activation.hs +++ b/libs/wire-api/src/Wire/API/User/Activation.hs @@ -40,9 +40,9 @@ import Data.Aeson qualified as A import Data.Aeson.Types (Parser) import Data.ByteString.Conversion import Data.Data (Proxy (Proxy)) +import Data.OpenApi (ToParamSchema) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger (ToParamSchema) -import Data.Swagger qualified as S import Data.Text.Ascii import Data.Tuple.Extra (fst3, snd3, thd3) import Imports diff --git a/libs/wire-api/src/Wire/API/User/Auth.hs b/libs/wire-api/src/Wire/API/User/Auth.hs index 8670a4bc20e..135c1cb89ba 100644 --- a/libs/wire-api/src/Wire/API/User/Auth.hs +++ b/libs/wire-api/src/Wire/API/User/Auth.hs @@ -46,6 +46,8 @@ module Wire.API.User.Auth SomeUserToken (..), SomeAccessToken (..), UserTokenCookie (..), + ProviderToken (..), + ProviderTokenCookie (..), -- * Access AccessWithCookie (..), @@ -58,7 +60,7 @@ module Wire.API.User.Auth where import Control.Applicative -import Control.Lens ((?~)) +import Control.Lens ((?~), (^.)) import Control.Lens.TH import Data.Aeson (FromJSON, ToJSON) import Data.Aeson.Types qualified as A @@ -71,14 +73,16 @@ import Data.Handle (Handle) import Data.Id import Data.Json.Util import Data.Misc (PlainTextPassword6) +import Data.OpenApi qualified as S import Data.SOP import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as T import Data.Text.Encoding qualified as T import Data.Text.Lazy.Encoding qualified as LT import Data.Time.Clock (UTCTime) +import Data.Time.Clock.POSIX (posixSecondsToUTCTime) import Data.Tuple.Extra hiding (first) +import Data.ZAuth.Token (header, time) import Data.ZAuth.Token qualified as ZAuth import Imports import Servant @@ -554,7 +558,7 @@ utcToSetCookie c = } instance S.ToParamSchema UserTokenCookie where - toParamSchema _ = mempty & S.type_ ?~ S.SwaggerString + toParamSchema _ = mempty & S.type_ ?~ S.OpenApiString instance FromHttpApiData UserTokenCookie where parseHeader = utcFromSetCookie . parseSetCookie @@ -568,6 +572,61 @@ instance ToHttpApiData UserTokenCookie where . utcToSetCookie toUrlPiece = T.decodeUtf8 . toHeader +-------------------------------------------------------------------------------- +-- Provider + +data ProviderToken = ProviderToken (ZAuth.Token ZAuth.Provider) + deriving (Show) + +instance FromByteString ProviderToken where + parser = ProviderToken <$> parser + +data ProviderTokenCookie = ProviderTokenCookie + { ptcToken :: ProviderToken, + ptcSecure :: Bool + } + +instance FromHttpApiData ProviderTokenCookie where + parseHeader = ptcFromSetCookie . parseSetCookie + parseUrlPiece = parseHeader . T.encodeUtf8 + +ptcFromSetCookie :: SetCookie -> Either Text ProviderTokenCookie +ptcFromSetCookie c = do + v <- first T.pack $ runParser parser (setCookieValue c) + pure + ProviderTokenCookie + { ptcToken = v, + ptcSecure = setCookieSecure c + } + +instance ToHttpApiData ProviderTokenCookie where + toHeader = + LBS.toStrict + . toLazyByteString + . renderSetCookie + . ptcToSetCookie + toUrlPiece = T.decodeUtf8 . toHeader + +ptcToSetCookie :: ProviderTokenCookie -> SetCookie +ptcToSetCookie c = + def + { setCookieName = "zprovider", + setCookieValue = toByteString' (providerToken (ptcToken c)), + setCookiePath = Just "/provider", + setCookieExpires = Just (tokenExpiresUTC (providerToken (ptcToken c))), + setCookieSecure = ptcSecure c, + setCookieHttpOnly = True + } + where + providerToken :: ProviderToken -> ZAuth.Token ZAuth.Provider + providerToken (ProviderToken t) = t + + tokenExpiresUTC :: ZAuth.Token a -> UTCTime + tokenExpiresUTC t = posixSecondsToUTCTime (fromIntegral (t ^. header . time)) + +instance S.ToParamSchema ProviderTokenCookie where + toParamSchema _ = mempty & S.type_ ?~ S.OpenApiString + -------------------------------------------------------------------------------- -- Servant diff --git a/libs/wire-api/src/Wire/API/User/Auth/LegalHold.hs b/libs/wire-api/src/Wire/API/User/Auth/LegalHold.hs index 951b2c19ab2..b1f20c416a8 100644 --- a/libs/wire-api/src/Wire/API/User/Auth/LegalHold.hs +++ b/libs/wire-api/src/Wire/API/User/Auth/LegalHold.hs @@ -21,8 +21,8 @@ import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Aeson qualified as A import Data.Id import Data.Misc +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports import Wire.API.User.Auth diff --git a/libs/wire-api/src/Wire/API/User/Auth/ReAuth.hs b/libs/wire-api/src/Wire/API/User/Auth/ReAuth.hs index 040698e848a..0892089a90d 100644 --- a/libs/wire-api/src/Wire/API/User/Auth/ReAuth.hs +++ b/libs/wire-api/src/Wire/API/User/Auth/ReAuth.hs @@ -25,8 +25,8 @@ import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Aeson qualified as A import Data.Code import Data.Misc +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports import Wire.API.User diff --git a/libs/wire-api/src/Wire/API/User/Auth/Sso.hs b/libs/wire-api/src/Wire/API/User/Auth/Sso.hs index 6e061536e01..0c9daa86859 100644 --- a/libs/wire-api/src/Wire/API/User/Auth/Sso.hs +++ b/libs/wire-api/src/Wire/API/User/Auth/Sso.hs @@ -20,8 +20,8 @@ module Wire.API.User.Auth.Sso where import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Aeson qualified as A import Data.Id +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports import Wire.API.User.Auth diff --git a/libs/wire-api/src/Wire/API/User/Client.hs b/libs/wire-api/src/Wire/API/User/Client.hs index 69c1f65e7fb..b168d21633e 100644 --- a/libs/wire-api/src/Wire/API/User/Client.hs +++ b/libs/wire-api/src/Wire/API/User/Client.hs @@ -65,6 +65,9 @@ module Wire.API.User.Client longitude, Latitude (..), Longitude (..), + + -- * List of MLS client ids + ClientList (..), ) where @@ -83,11 +86,11 @@ import Data.Id import Data.Json.Util import Data.Map.Strict qualified as Map import Data.Misc (Latitude (..), Location, Longitude (..), PlainTextPassword6, latitude, location, longitude) +import Data.OpenApi hiding (Schema, ToSchema, nullable, schema) +import Data.OpenApi qualified as Swagger hiding (nullable) import Data.Qualified import Data.Schema import Data.Set qualified as Set -import Data.Swagger hiding (Schema, ToSchema, schema) -import Data.Swagger qualified as Swagger import Data.Text.Encoding qualified as Text.E import Data.Time.Clock import Data.UUID (toASCIIBytes) @@ -98,8 +101,8 @@ import Deriving.Swagger StripPrefix, ) import Imports -import Wire.API.MLS.Credential -import Wire.API.User.Auth (CookieLabel) +import Wire.API.MLS.CipherSuite +import Wire.API.User.Auth import Wire.API.User.Client.Prekey as Prekey import Wire.Arbitrary (Arbitrary (arbitrary), GenericUniform (..), generateExample, mapOf', setOf') @@ -368,7 +371,7 @@ instance Swagger.ToSchema UserClientsFull where pure $ NamedSchema (Just "UserClientsFull") $ mempty - & type_ ?~ SwaggerObject + & type_ ?~ OpenApiObject & description ?~ "Dictionary object of `Client` objects indexed by `UserId`." & example ?~ "{\"1355c55a-0ac8-11ee-97ee-db1a6351f093\": , ...}" @@ -519,6 +522,22 @@ instance ToSchema Client where mlsPublicKeysFieldSchema :: ObjectSchema SwaggerDoc MLSPublicKeys mlsPublicKeysFieldSchema = fromMaybe mempty <$> optField "mls_public_keys" mlsPublicKeysSchema +-------------------------------------------------------------------------------- +-- ClientList + +-- | Client list for internal API. +data ClientList = ClientList {clClients :: [ClientId]} + deriving stock (Eq, Show, Generic) + deriving (Arbitrary) via (GenericUniform ClientList) + deriving (FromJSON, ToJSON, Swagger.ToSchema) via Schema ClientList + +instance ToSchema ClientList where + schema = + object "ClientList" $ + ClientList + <$> clClients + .= field "client_ids" (array schema) + -------------------------------------------------------------------------------- -- PubClient @@ -529,19 +548,14 @@ data PubClient = PubClient deriving stock (Eq, Show, Generic, Ord) deriving (Arbitrary) via (GenericUniform PubClient) deriving (Swagger.ToSchema) via (CustomSwagger '[FieldLabelModifier (StripPrefix "pubClient", LowerCase)] PubClient) + deriving (FromJSON, ToJSON) via Schema PubClient -instance ToJSON PubClient where - toJSON c = - A.object $ - "id" A..= pubClientId c - # "class" A..= pubClientClass c - # [] - -instance FromJSON PubClient where - parseJSON = A.withObject "PubClient" $ \o -> - PubClient - <$> o A..: "id" - <*> o A..:? "class" +instance ToSchema PubClient where + schema = + object "PubClient" $ + PubClient + <$> pubClientId .= field "id" schema + <*> pubClientClass .= maybe_ (optField "class" schema) -------------------------------------------------------------------------------- -- Client Type/Class diff --git a/libs/wire-api/src/Wire/API/User/Client/DPoPAccessToken.hs b/libs/wire-api/src/Wire/API/User/Client/DPoPAccessToken.hs index df719886f37..99ed6e13d92 100644 --- a/libs/wire-api/src/Wire/API/User/Client/DPoPAccessToken.hs +++ b/libs/wire-api/src/Wire/API/User/Client/DPoPAccessToken.hs @@ -22,10 +22,10 @@ module Wire.API.User.Client.DPoPAccessToken where import Data.Aeson (FromJSON, ToJSON) import Data.ByteString.Conversion (FromByteString (..), ToByteString (..), fromByteString', toByteString') +import Data.OpenApi qualified as S +import Data.OpenApi.ParamSchema (ToParamSchema (..)) import Data.SOP import Data.Schema -import Data.Swagger qualified as S -import Data.Swagger.ParamSchema (ToParamSchema (..)) import Data.Text as T import Data.Text.Encoding (decodeUtf8, encodeUtf8) import Imports diff --git a/libs/wire-api/src/Wire/API/User/Client/Prekey.hs b/libs/wire-api/src/Wire/API/User/Client/Prekey.hs index 4f03328465a..f58eaa000ed 100644 --- a/libs/wire-api/src/Wire/API/User/Client/Prekey.hs +++ b/libs/wire-api/src/Wire/API/User/Client/Prekey.hs @@ -35,8 +35,8 @@ where import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Hashable (hash) import Data.Id +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Imports import Wire.Arbitrary (Arbitrary (arbitrary), GenericUniform (..)) diff --git a/libs/wire-api/src/Wire/API/User/Handle.hs b/libs/wire-api/src/Wire/API/User/Handle.hs index 08242a6dfe9..3db27ef8c12 100644 --- a/libs/wire-api/src/Wire/API/User/Handle.hs +++ b/libs/wire-api/src/Wire/API/User/Handle.hs @@ -28,10 +28,10 @@ import Control.Applicative import Data.Aeson (FromJSON (..), ToJSON (..)) import Data.Aeson qualified as A import Data.Id (UserId) +import Data.OpenApi qualified as S import Data.Qualified (Qualified (..), deprecatedSchema) import Data.Range import Data.Schema -import Data.Swagger qualified as S import Imports import Wire.Arbitrary (Arbitrary, GenericUniform (..)) diff --git a/libs/wire-api/src/Wire/API/User/Identity.hs b/libs/wire-api/src/Wire/API/User/Identity.hs index c71bec44864..2b88c1d3bd8 100644 --- a/libs/wire-api/src/Wire/API/User/Identity.hs +++ b/libs/wire-api/src/Wire/API/User/Identity.hs @@ -61,9 +61,9 @@ import Data.Attoparsec.Text import Data.Bifunctor (first) import Data.ByteString.Conversion import Data.CaseInsensitive qualified as CI +import Data.OpenApi (ToParamSchema (..)) +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger (ToParamSchema (..)) -import Data.Swagger qualified as S import Data.Text qualified as Text import Data.Text.Encoding (decodeUtf8', encodeUtf8) import Data.Time.Clock @@ -326,7 +326,7 @@ instance S.ToSchema UserSSOId where pure $ S.NamedSchema (Just "UserSSOId") $ mempty - & S.type_ ?~ S.SwaggerObject + & S.type_ ?~ S.OpenApiObject & S.properties .~ [ ("tenant", tenantSchema), ("subject", subjectSchema), diff --git a/libs/wire-api/src/Wire/API/User/IdentityProvider.hs b/libs/wire-api/src/Wire/API/User/IdentityProvider.hs index 0295cab6d37..e954f15c2e6 100644 --- a/libs/wire-api/src/Wire/API/User/IdentityProvider.hs +++ b/libs/wire-api/src/Wire/API/User/IdentityProvider.hs @@ -24,14 +24,15 @@ import Control.Lens (makeLenses, (.~), (?~)) import Control.Monad.Except import Data.Aeson import Data.Aeson.TH +import Data.Aeson.Types (parseMaybe) import Data.Attoparsec.ByteString qualified as AP import Data.Binary.Builder qualified as BSB import Data.ByteString.Conversion qualified as BSC import Data.HashMap.Strict.InsOrd (InsOrdHashMap) import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap import Data.Id (TeamId) +import Data.OpenApi import Data.Proxy (Proxy (Proxy)) -import Data.Swagger import Imports import Network.HTTP.Media ((//)) import SAML2.WebSSO (IdPConfig) @@ -39,6 +40,7 @@ import SAML2.WebSSO qualified as SAML import SAML2.WebSSO.Types.TH (deriveJSONOptions) import Servant.API as Servant hiding (MkLink, URI (..)) import Wire.API.User.Orphans (samlSchemaOptions) +import Wire.API.Util.Aeson (defaultOptsDropChar) import Wire.Arbitrary (Arbitrary, GenericUniform (GenericUniform)) -- | The identity provider type used in Spar. @@ -48,17 +50,17 @@ newtype IdPHandle = IdPHandle {unIdPHandle :: Text} deriving (Eq, Ord, Show, FromJSON, ToJSON, ToSchema, Arbitrary, Generic) data WireIdP = WireIdP - { _wiTeam :: TeamId, + { _team :: TeamId, -- | list of issuer names that this idp has replaced, most recent first. this is used -- for finding users that are still stored under the old issuer, see -- 'findUserWithOldIssuer', 'moveUserToNewIssuer'. - _wiApiVersion :: Maybe WireIdPAPIVersion, - _wiOldIssuers :: [SAML.Issuer], + _apiVersion :: Maybe WireIdPAPIVersion, + _oldIssuers :: [SAML.Issuer], -- | the issuer that has replaced this one. this is set iff a new issuer is created -- with the @"replaces"@ query parameter, and it is used to decide whether users not -- existing on this IdP can be auto-provisioned (if 'isJust', they can't). - _wiReplacedBy :: Maybe SAML.IdPId, - _wiHandle :: IdPHandle + _replacedBy :: Maybe SAML.IdPId, + _handle :: IdPHandle } deriving (Eq, Show, Generic) @@ -80,7 +82,9 @@ defWireIdPAPIVersion = WireIdPAPIV1 makeLenses ''WireIdP deriveJSON deriveJSONOptions ''WireIdPAPIVersion -deriveJSON deriveJSONOptions ''WireIdP + +-- Changing the encoder since we've dropped the field prefixes +deriveJSON (defaultOptsDropChar '_') ''WireIdP instance BSC.ToByteString WireIdPAPIVersion where builder = @@ -104,9 +108,9 @@ instance ToHttpApiData WireIdPAPIVersion where instance ToParamSchema WireIdPAPIVersion where toParamSchema Proxy = mempty - { _paramSchemaDefault = Just "v2", - _paramSchemaType = Just SwaggerString, - _paramSchemaEnum = Just (String . toQueryParam <$> [(minBound :: WireIdPAPIVersion) ..]) + { _schemaDefault = Just "v2", + _schemaType = Just OpenApiString, + _schemaEnum = Just (String . toQueryParam <$> [(minBound :: WireIdPAPIVersion) ..]) } instance Cql.Cql WireIdPAPIVersion where @@ -124,13 +128,14 @@ instance Cql.Cql WireIdPAPIVersion where -- | A list of 'IdP's, returned by some endpoints. Wrapped into an object to -- allow extensibility later on. data IdPList = IdPList - { _idplProviders :: [IdP] + { _providers :: [IdP] } deriving (Eq, Show, Generic) makeLenses ''IdPList -deriveJSON deriveJSONOptions ''IdPList +-- Same as WireIdP, we want the lenses, so we have to drop a prefix +deriveJSON (defaultOptsDropChar '_') ''IdPList -- | JSON-encoded information about metadata: @{"value": }@. (Here we could also -- implement @{"uri": , "cert": }@. check both the certificate we get @@ -165,16 +170,27 @@ instance ToJSON IdPMetadataInfo where toJSON (IdPMetadataValue _ x) = object ["value" .= SAML.encode x] +idPMetadataToInfo :: SAML.IdPMetadata -> IdPMetadataInfo +idPMetadataToInfo = + -- 'undefined' is fine because `instance toJSON IdPMetadataValue` ignores it. 'fromJust' is + -- ok as long as 'parseJSON . toJSON' always yields a value and not 'Nothing'. + fromJust . parseMaybe parseJSON . toJSON . IdPMetadataValue undefined + -- Swagger instances +-- Same as WireIdP, check there for why this has different handling instance ToSchema IdPList where - declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions + declareNamedSchema = genericDeclareNamedSchema $ fromAesonOptions $ defaultOptsDropChar '_' instance ToSchema WireIdPAPIVersion where declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions instance ToSchema WireIdP where - declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions + -- We don't want to use `samlSchemaOptions`, as it pulls from saml2-web-sso json options which + -- as a `dropWhile not . isUpper` modifier. All we need is to drop the underscore prefix and + -- keep the rest of the default processing. This isn't strictly in line with WPB-3798's requirements + -- but it is close, and maintains the lens template haskell. + declareNamedSchema = genericDeclareNamedSchema $ fromAesonOptions $ defaultOptsDropChar '_' -- TODO: would be nice to add an example here, but that only works for json? @@ -189,7 +205,7 @@ instance ToSchema IdPMetadataInfo where & properties .~ properties_ & minProperties ?~ 1 & maxProperties ?~ 1 - & type_ ?~ SwaggerObject + & type_ ?~ OpenApiObject where properties_ :: InsOrdHashMap Text (Referenced Schema) properties_ = diff --git a/libs/wire-api/src/Wire/API/User/Orphans.hs b/libs/wire-api/src/Wire/API/User/Orphans.hs index f2b29ccdf8b..10ec177a3fe 100644 --- a/libs/wire-api/src/Wire/API/User/Orphans.hs +++ b/libs/wire-api/src/Wire/API/User/Orphans.hs @@ -21,11 +21,13 @@ module Wire.API.User.Orphans where import Control.Lens +import Data.Aeson qualified as A +import Data.Char import Data.Currency qualified as Currency import Data.ISO3166_CountryCodes import Data.LanguageCodes +import Data.OpenApi import Data.Proxy -import Data.Swagger import Data.UUID import Data.X509 as X509 import Imports @@ -33,7 +35,7 @@ import SAML2.WebSSO qualified as SAML import SAML2.WebSSO.Types.TH (deriveJSONOptions) import Servant.API ((:>)) import Servant.Multipart qualified as SM -import Servant.Swagger +import Servant.OpenApi import URI.ByteString deriving instance Generic ISO639_1 @@ -51,9 +53,18 @@ instance ToSchema CountryCode -- | The options to use for schema generation. Must match the options used -- for 'ToJSON' instances elsewhere. +-- +-- FUTUREWORK: This should be removed once the saml2-web-sso types are updated to remove their prefixes. +-- FUTUREWORK: Ticket for these changes https://wearezeta.atlassian.net/browse/WPB-3972 +-- Preserve the old prefix semantics for types that are coming from outside of this repo. samlSchemaOptions :: SchemaOptions -samlSchemaOptions = fromAesonOptions deriveJSONOptions +samlSchemaOptions = fromAesonOptions $ deriveJSONOptions {A.fieldLabelModifier = fieldMod . dropPrefix} + where + fieldMod = A.fieldLabelModifier deriveJSONOptions + dropPrefix = dropWhile (not . isUpper) +-- This type comes from a seperate repo, so we're keeping the prefix dropping +-- for the moment. instance ToSchema SAML.XmlText where declareNamedSchema = genericDeclareNamedSchema samlSchemaOptions @@ -83,7 +94,7 @@ instance ToSchema (SAML.FormRedirect SAML.AuthnRequest) where pure $ NamedSchema (Just "FormRedirect") $ mempty - & type_ ?~ SwaggerObject + & type_ ?~ OpenApiObject & properties . at "uri" ?~ Inline (toSchema (Proxy @Text)) & properties . at "xml" ?~ authnReqSchema @@ -99,8 +110,8 @@ instance ToSchema SAML.SPMetadata where instance ToSchema Void where declareNamedSchema _ = declareNamedSchema (Proxy @String) -instance HasSwagger route => HasSwagger (SM.MultipartForm SM.Mem resp :> route) where - toSwagger _proxy = toSwagger (Proxy @route) +instance HasOpenApi route => HasOpenApi (SM.MultipartForm SM.Mem resp :> route) where + toOpenApi _proxy = toOpenApi (Proxy @route) instance ToSchema SAML.IdPId where declareNamedSchema _ = declareNamedSchema (Proxy @UUID) diff --git a/libs/wire-api/src/Wire/API/User/Password.hs b/libs/wire-api/src/Wire/API/User/Password.hs index 2a6b9bf20ed..4f14e4ca7c6 100644 --- a/libs/wire-api/src/Wire/API/User/Password.hs +++ b/libs/wire-api/src/Wire/API/User/Password.hs @@ -36,11 +36,11 @@ import Data.Aeson qualified as A import Data.Aeson.Types (Parser) import Data.ByteString.Conversion import Data.Misc (PlainTextPassword8) +import Data.OpenApi qualified as S +import Data.OpenApi.ParamSchema import Data.Proxy (Proxy (Proxy)) import Data.Range (Ranged (..)) import Data.Schema as Schema -import Data.Swagger qualified as S -import Data.Swagger.ParamSchema import Data.Text.Ascii import Data.Tuple.Extra (fst3, snd3, thd3) import Imports diff --git a/libs/wire-api/src/Wire/API/User/Profile.hs b/libs/wire-api/src/Wire/API/User/Profile.hs index 8f03b39b375..ae018f20b75 100644 --- a/libs/wire-api/src/Wire/API/User/Profile.hs +++ b/libs/wire-api/src/Wire/API/User/Profile.hs @@ -58,9 +58,9 @@ import Data.Attoparsec.Text import Data.ByteString.Conversion import Data.ISO3166_CountryCodes import Data.LanguageCodes +import Data.OpenApi qualified as S import Data.Range import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text import Imports import Wire.API.Asset (AssetKey (..)) diff --git a/libs/wire-api/src/Wire/API/User/RichInfo.hs b/libs/wire-api/src/Wire/API/User/RichInfo.hs index ef0eac713e8..32a3db8fa19 100644 --- a/libs/wire-api/src/Wire/API/User/RichInfo.hs +++ b/libs/wire-api/src/Wire/API/User/RichInfo.hs @@ -52,8 +52,8 @@ import Data.CaseInsensitive (CI) import Data.CaseInsensitive qualified as CI import Data.List.Extra (nubOrdOn) import Data.Map qualified as Map +import Data.OpenApi qualified as S import Data.Schema -import Data.Swagger qualified as S import Data.Text qualified as Text import Imports import Test.QuickCheck qualified as QC diff --git a/libs/wire-api/src/Wire/API/User/Saml.hs b/libs/wire-api/src/Wire/API/User/Saml.hs index e42fbbf8f63..09ad0d24367 100644 --- a/libs/wire-api/src/Wire/API/User/Saml.hs +++ b/libs/wire-api/src/Wire/API/User/Saml.hs @@ -30,8 +30,8 @@ import Data.Aeson hiding (fieldLabelModifier) import Data.Aeson.TH hiding (fieldLabelModifier) import Data.ByteString.Builder qualified as Builder import Data.Id (UserId) +import Data.OpenApi import Data.Proxy (Proxy (Proxy)) -import Data.Swagger import Data.Text qualified as T import Data.Time import GHC.TypeLits (KnownSymbol, symbolVal) @@ -56,7 +56,7 @@ type AssId = ID Assertion -- so that the verdict handler can act on it. data VerdictFormat = VerdictFormatWeb - | VerdictFormatMobile {_verdictFormatGrantedURI :: URI, _verdictFormatDeniedURI :: URI} + | VerdictFormatMobile {_formatGrantedURI :: URI, _formatDeniedURI :: URI} deriving (Eq, Show, Generic) makeLenses ''VerdictFormat diff --git a/libs/wire-api/src/Wire/API/User/Scim.hs b/libs/wire-api/src/Wire/API/User/Scim.hs index f3440beea7b..752c608bd85 100644 --- a/libs/wire-api/src/Wire/API/User/Scim.hs +++ b/libs/wire-api/src/Wire/API/User/Scim.hs @@ -59,8 +59,8 @@ import Data.Id (ScimTokenId, TeamId, UserId) import Data.Json.Util ((#)) import Data.Map qualified as Map import Data.Misc (PlainTextPassword6) +import Data.OpenApi hiding (Operation) import Data.Proxy -import Data.Swagger hiding (Operation) import Data.Text.Encoding (encodeUtf8) import Data.Time.Clock (UTCTime) import Imports @@ -462,7 +462,7 @@ instance ToSchema ScimTokenInfo where pure $ NamedSchema (Just "ScimTokenInfo") $ mempty - & type_ ?~ SwaggerObject + & type_ ?~ OpenApiObject & properties .~ [ ("team", teamSchema), ("id", idSchema), @@ -478,7 +478,7 @@ instance ToSchema CreateScimToken where pure $ NamedSchema (Just "CreateScimToken") $ mempty - & type_ ?~ SwaggerObject + & type_ ?~ OpenApiObject & properties .~ [ ("description", textSchema), ("password", textSchema), @@ -493,7 +493,7 @@ instance ToSchema CreateScimTokenResponse where pure $ NamedSchema (Just "CreateScimTokenResponse") $ mempty - & type_ ?~ SwaggerObject + & type_ ?~ OpenApiObject & properties .~ [ ("token", tokenSchema), ("info", infoSchema) @@ -506,7 +506,7 @@ instance ToSchema ScimTokenList where pure $ NamedSchema (Just "ScimTokenList") $ mempty - & type_ ?~ SwaggerObject + & type_ ?~ OpenApiObject & properties .~ [ ("tokens", infoListSchema) ] diff --git a/libs/wire-api/src/Wire/API/User/Search.hs b/libs/wire-api/src/Wire/API/User/Search.hs index 819f0111ab0..deaf7c08f4d 100644 --- a/libs/wire-api/src/Wire/API/User/Search.hs +++ b/libs/wire-api/src/Wire/API/User/Search.hs @@ -43,11 +43,11 @@ import Data.ByteString.Conversion (FromByteString (..), ToByteString (..)) import Data.Either.Combinators (mapLeft) import Data.Id (TeamId, UserId) import Data.Json.Util (UTCTimeMillis) +import Data.OpenApi (ToParamSchema (..)) +import Data.OpenApi qualified as S import Data.Proxy import Data.Qualified import Data.Schema -import Data.Swagger (ToParamSchema (..)) -import Data.Swagger qualified as S import Data.Text qualified as T import Data.Text.Ascii (AsciiBase64Url, toText, validateBase64Url) import Imports @@ -228,7 +228,7 @@ data TeamUserSearchSortBy instance S.ToParamSchema TeamUserSearchSortBy where toParamSchema _ = mempty - & S.type_ ?~ S.SwaggerString + & S.type_ ?~ S.OpenApiString & S.enum_ ?~ fmap teamUserSearchSortByName [minBound .. maxBound] instance ToByteString TeamUserSearchSortBy where @@ -264,7 +264,7 @@ data TeamUserSearchSortOrder instance S.ToParamSchema TeamUserSearchSortOrder where toParamSchema _ = mempty - & S.type_ ?~ S.SwaggerString + & S.type_ ?~ S.OpenApiString & S.enum_ ?~ fmap teamUserSearchSortOrderName [minBound .. maxBound] instance ToByteString TeamUserSearchSortOrder where diff --git a/libs/wire-api/src/Wire/API/UserMap.hs b/libs/wire-api/src/Wire/API/UserMap.hs index bcf41da1559..31f81392195 100644 --- a/libs/wire-api/src/Wire/API/UserMap.hs +++ b/libs/wire-api/src/Wire/API/UserMap.hs @@ -24,9 +24,9 @@ import Data.Aeson (FromJSON, ToJSON (toJSON)) import Data.Domain (Domain) import Data.Id (UserId) import Data.Map qualified as Map +import Data.OpenApi (HasDescription (description), HasExample (example), NamedSchema (..), ToSchema (..), declareSchema, toSchema) import Data.Proxy (Proxy (..)) import Data.Set qualified as Set -import Data.Swagger (HasDescription (description), HasExample (example), NamedSchema (..), ToSchema (..), declareSchema, toSchema) import Data.Text qualified as Text import Data.Typeable (typeRep) import Imports @@ -56,7 +56,7 @@ instance Functor QualifiedUserMap where instance Arbitrary a => Arbitrary (QualifiedUserMap a) where arbitrary = QualifiedUserMap <$> mapOf' arbitrary arbitrary -instance (Typeable a, ToSchema a, ToJSON a, Arbitrary a) => ToSchema (UserMap (Set a)) where +instance (ToSchema a, ToJSON a, Arbitrary a) => ToSchema (UserMap (Set a)) where declareNamedSchema _ = do mapSch <- declareSchema (Proxy @(Map UserId (Set a))) let valueTypeName = Text.pack $ show $ typeRep $ Proxy @a diff --git a/libs/wire-api/src/Wire/API/Util/Aeson.hs b/libs/wire-api/src/Wire/API/Util/Aeson.hs index b1a28f1fdf1..209d55efb7e 100644 --- a/libs/wire-api/src/Wire/API/Util/Aeson.hs +++ b/libs/wire-api/src/Wire/API/Util/Aeson.hs @@ -17,12 +17,15 @@ module Wire.API.Util.Aeson ( customEncodingOptions, + customEncodingOptionsDropChar, + defaultOptsDropChar, CustomEncoded (..), + CustomEncodedLensable (..), ) where import Data.Aeson -import Data.Char qualified as Char +import Data.Json.Util (toJSONFieldName) import GHC.Generics (Rep) import Imports hiding (All) @@ -31,9 +34,22 @@ import Imports hiding (All) -- -- For example, it converts @_recordFieldLabel@ into @field_label@. customEncodingOptions :: Options -customEncodingOptions = +customEncodingOptions = toJSONFieldName + +-- This is useful for structures that are also creating lenses. +-- If the field name doesn't have a leading underscore then the +-- default `makeLenses` call won't make any lenses. +customEncodingOptionsDropChar :: Char -> Options +customEncodingOptionsDropChar c = + toJSONFieldName + { fieldLabelModifier = fieldLabelModifier toJSONFieldName . dropWhile (c ==) + } + +-- Similar to customEncodingOptionsDropChar, but not doing snake_case +defaultOptsDropChar :: Char -> Options +defaultOptsDropChar c = defaultOptions - { fieldLabelModifier = camelTo2 '_' . dropWhile (not . Char.isUpper) + { fieldLabelModifier = fieldLabelModifier defaultOptions . dropWhile (c ==) } newtype CustomEncoded a = CustomEncoded {unCustomEncoded :: a} @@ -43,3 +59,14 @@ instance (Generic a, GToJSON Zero (Rep a)) => ToJSON (CustomEncoded a) where instance (Generic a, GFromJSON Zero (Rep a)) => FromJSON (CustomEncoded a) where parseJSON = fmap CustomEncoded . genericParseJSON @a customEncodingOptions + +-- Similar to CustomEncoded except that it will first strip off leading '_' characters. +-- This is important for records with field names that would otherwise be keywords, like type or data +-- It is also useful if the record has lenses being generated. +newtype CustomEncodedLensable a = CustomEncodedLensable {unCustomEncodedLensable :: a} + +instance (Generic a, GToJSON Zero (Rep a)) => ToJSON (CustomEncodedLensable a) where + toJSON = genericToJSON @a (customEncodingOptionsDropChar '_') . unCustomEncodedLensable + +instance (Generic a, GFromJSON Zero (Rep a)) => FromJSON (CustomEncodedLensable a) where + parseJSON = fmap CustomEncodedLensable . genericParseJSON @a (customEncodingOptionsDropChar '_') diff --git a/libs/wire-api/src/Wire/API/VersionInfo.hs b/libs/wire-api/src/Wire/API/VersionInfo.hs index 0fa210b01eb..1d05a55e027 100644 --- a/libs/wire-api/src/Wire/API/VersionInfo.hs +++ b/libs/wire-api/src/Wire/API/VersionInfo.hs @@ -42,7 +42,6 @@ import Servant import Servant.Client.Core import Servant.Server.Internal.Delayed import Servant.Server.Internal.DelayedIO -import Servant.Swagger import Wire.API.Routes.ClientAlgebra vinfoObjectSchema :: ValueSchema NamedSwaggerDoc v -> ObjectSchema SwaggerDoc [v] @@ -108,9 +107,6 @@ instance clientWithRoute pm (Proxy @api) req hoistClientMonad pm _ f = hoistClientMonad pm (Proxy @api) f -instance HasSwagger (Until v :> api) where - toSwagger _ = mempty - instance RoutesToPaths api => RoutesToPaths (Until v :> api) where getRoutes = getRoutes @api @@ -159,8 +155,5 @@ instance clientWithRoute pm (Proxy @api) req hoistClientMonad pm _ f = hoistClientMonad pm (Proxy @api) f -instance HasSwagger api => HasSwagger (From v :> api) where - toSwagger _ = toSwagger (Proxy @api) - instance RoutesToPaths api => RoutesToPaths (From v :> api) where getRoutes = getRoutes @api diff --git a/libs/wire-api/src/Wire/API/Wrapped.hs b/libs/wire-api/src/Wire/API/Wrapped.hs index f6d71142a5f..44c41bbddc2 100644 --- a/libs/wire-api/src/Wire/API/Wrapped.hs +++ b/libs/wire-api/src/Wire/API/Wrapped.hs @@ -21,8 +21,8 @@ import Control.Lens ((.~), (?~)) import Data.Aeson import Data.Aeson.Key qualified as Key import Data.HashMap.Strict.InsOrd qualified as InsOrdHashMap +import Data.OpenApi import Data.Proxy (Proxy (..)) -import Data.Swagger import Data.Text qualified as Text import GHC.TypeLits (KnownSymbol, Symbol, symbolVal) import Imports @@ -48,7 +48,7 @@ instance (ToSchema a, KnownSymbol name) => ToSchema (Wrapped name a) where pure $ NamedSchema Nothing $ mempty - & type_ ?~ SwaggerObject + & type_ ?~ OpenApiObject & properties .~ InsOrdHashMap.singleton (Text.pack (symbolVal (Proxy @name))) wrappedSchema instance (Arbitrary a, KnownSymbol name) => Arbitrary (Wrapped name a) where diff --git a/libs/wire-api/test/golden.hs b/libs/wire-api/test/golden.hs new file mode 100644 index 00000000000..0ff7c7e4ca8 --- /dev/null +++ b/libs/wire-api/test/golden.hs @@ -0,0 +1,5 @@ +import Imports +import qualified Test.Wire.API.Golden.Run as Run + +main :: IO () +main = Run.main diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Client_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Client_user.hs index b34d64cd73c..a3b5ebd5c06 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Client_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Client_user.hs @@ -25,7 +25,7 @@ import Data.Map qualified as Map import Data.Misc import Data.Set as Set import Imports -import Wire.API.MLS.Credential +import Wire.API.MLS.CipherSuite import Wire.API.User.Auth (CookieLabel (CookieLabel, cookieLabelText)) import Wire.API.User.Client diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/CompletePasswordReset_provider.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/CompletePasswordReset_provider.hs index 36c405ce542..e96883648e2 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/CompletePasswordReset_provider.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/CompletePasswordReset_provider.hs @@ -27,9 +27,9 @@ import Wire.API.Provider (CompletePasswordReset (..)) testObject_CompletePasswordReset_provider_1 :: CompletePasswordReset testObject_CompletePasswordReset_provider_1 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "Cd9b4n7KaooqOhOciMIf"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "W0CLFxLOL"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "Cd9b4n7KaooqOhOciMIf"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "W0CLFxLOL"))}, + password = plainTextPassword6Unsafe "\1012683\1112273\39028\&5\168679\169133rs\93986\&4wo~\1002561l=\1032023\13042\SI1nt\35892\1050889N\46503>?\"\aT\69782\USgg\\f\SYN\165120#tS\NAK8\DC1C\36700q\r!2d\DC4\189369m\SUB\a\\V'W\\\110825,\r\143398?\ACKx\agVQy9\SI3'h]\78709n0ue\b\1032695?@\ETB1zJ6\NULI\a;DL\ENQ\37006c\92669\US\ETBz\1097017?0\NUL\184657\"A&&\36577E\157691\US7fG\1081322Vpx\DELI'\1102879\DLE\1008567g,\NULH\DC2@+\1085033\1064315\DC4\1091186\STXJ\1103240dPQ\STX|\EOT9^9_\1033902\SO]\a\1022683Of'd\SYN\"^\EOTw\1073515_\1113440\DLE}\95632\DC1s5\161851N1\1078798RkTZ&\150149X\1065364~''v{4MDK\153974\US\SOH|oB\143604'q,HU\1025306\SUB\NUL\1060487+%~v\DEL\97853V|5\127943|\999498\1059223HTFhF\FSdelLB\CAN\SUBbiC\1027783\n\110976u}g!\38540M\141506\1037727Pt$2(W%\149078\&0i-H\SUB@ii\1037533\NAK2\2636hg\50874\28429#{\23697\SO\NUL\146715\f\f\1039241A\GS:\EOT]\99785qf\SOH'\DELx\139534\SYN\f\DLE\nT\149322sK5O\EOT\SYN^&3\SOf!\150976\GS\SYN\f\1112187wy\1052535\1091937\1045148\SYN\ACKijjq\58477&\RS\"\DC2\1063939e\129001\ETX-\\\DC2E\ETX\40256\39310Z\DC3\22084iD7Xv\137008m\SUB>~\CANW\139109\33037YYZE\1022090J|\5247\CAN.\137437p\1011705\ETXS:Y<.YBcP\31609\1107733v4U\f\987772\1070124W!9Z\1035690;\1106506\DLE\132101\SOH(kH\SUB\"\vdX\136713\10837x\154948\&6/b$A\"jH\133538\48869\&9\DC3,\144088\1091851{\DC2\12495&>\1040461" } @@ -37,9 +37,9 @@ testObject_CompletePasswordReset_provider_1 = testObject_CompletePasswordReset_provider_2 :: CompletePasswordReset testObject_CompletePasswordReset_provider_2 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "8XosCtq4Dzhyo=UoMRg_"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "EoNo4PH=cFSyQ-yuHhP"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "8XosCtq4Dzhyo=UoMRg_"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "EoNo4PH=cFSyQ-yuHhP"))}, + password = plainTextPassword6Unsafe "\DC3~d{ \988098\1008471\&7\DLE\NULd\1065586\SOH?NT.\186651\1106270JJ\64065^rd\146603N[\43292\SOHt#dn\142707}u\SO\1022368<\1094323\18349\51616\GS\CANn\n05\983885\&4Z\vIJXz1ia\20698&\SYN'<\162555\v\19677B\ENQ\SI\1049058\DLE1dt\1038032)$\135798\&1b\97041Fvi\36729J\a_T(-`S\NAK\fU\20849dBbTgi\167678\rfp\171973ED=\STX\1086228\SUBXa<*#\1037916<\1106037\191075^%Xx\ESCOM\DEL\994881\1059244X _3\DC4K\GS\a(&6\59167\&8[\1045759\1111435M\681>f]o\ENQ`m\DEL\1112157\1102641\11945\f\161652)Q1\1018093q\1005011\&9\1102348UD]$\41477\f6j\190919\&3jAG\1007534!ys\NAKs?\17249Z\160153cfpz\fGC_\SIf%xb\99796\&1\ESCj\94762\&4K\rQ7\150803:\55009%:\r\"-Zq\DELU|\DLENa>\131324K\131830G\ACK3#\"V\NAK-w\ACK\1081085(\23629\1091792\\H\21182\ENQ\1049732\1036941~M;FHW$X\988437Wy|x5N\CANTrX\US,\n!\51726U==I}\ACK\1067103\1041045\1085401\EOT\983701{ }1\144729yu8_\DC2p\1053610l)S\128946fZ7\ETB>hnRX\458M{U~Hw;\69816\1035492v=J\8990:\1000731\1096086\70367o\ESCs=\NAK\1017016\SOH\NULb\1111472\152433H%f\1040890\EOT" } @@ -47,9 +47,9 @@ testObject_CompletePasswordReset_provider_2 = testObject_CompletePasswordReset_provider_3 :: CompletePasswordReset testObject_CompletePasswordReset_provider_3 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "=aYXtgLJZX77qMIx0Oah"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "RMQ-RtgFDI-b"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "=aYXtgLJZX77qMIx0Oah"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "RMQ-RtgFDI-b"))}, + password = plainTextPassword6Unsafe "\1073786\1022541\1030619|@\DLE\1050256\58722\5028\SOH\25945J\EMkH\986937=\11472\"SP\\\FSw\95016lR[.29\137466\&1W_\64827\96388M\RSU\a!\GS\43687NKv\993525\1097611X\50069;?\157751\&47.\CAN\1103688\137799\186574}\v8\STX3fj\DC1\SI\181630=-3ZmNn10\DC1\997119\1059249\161874CT\NUL:N\"\SYN\\@|q\128174\FSv_u\95666\1080533J-*\1034203;\1068818hC (_u\161608g\43952\33809\NAK\US',m}\a\30792\DC2Dt\171459\152195Him\395|\125271q\161223r\110828\&27A\NAK\EOT\FSgP\1090390\US\993009\62450\1042020O9\EOTEB]\DLE<\156612\127142\133358\1015398rJu\t\1027420\1050082F\bfxm/f\a\rC\152680t~D\ESCO]_i\US\39307\SOH\35670>\SYN\1086602\NAK\STXDz\DC3\1048748ZC\DC1x0bLFjXI\148199\EMZ\GSR2!\ENQ\DC3\\Mffm\986388\1043076\94041F\1096421u\7179*\DLEM.q\33878\a\1106357GdxHmu\DELSTrb`cn\NAK+(@KZ\ENQ]\1034430QEf?fw\ETX\177531.W\STX~k\ENQ\993340\1112261\US\tB\SO-\STX4b\185882o,\CAN}P\SOKD\v\1100259O*\b\1061589\RS\1106367\ACK\NAK=\1048333eh\DLE\EMY\12994\986285\185764\GS\DC1#)v>a\1050729L\DEL\16992&gh1\SO\24688\&18\DC1\1091353(\167196\1031220lc\ACK#\1096547Poe\178761~\ETX[%e\133630{\1020978\&31\99380\45215\SOHI1z\1093633s#y\1048198\FS\8988g\USPE5P\SO/\n\1089996 *Z\DC3\2954\33162p}sh;[Sr\STX\1015744\ESC\tO\152390\STX/_Q^a\157142\1101351\985165y" } @@ -67,9 +67,9 @@ testObject_CompletePasswordReset_provider_4 = testObject_CompletePasswordReset_provider_5 :: CompletePasswordReset testObject_CompletePasswordReset_provider_5 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "OU56F44t-0ybJj7eKUaS"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "rr3lleg-Tu4eJ"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "OU56F44t-0ybJj7eKUaS"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "rr3lleg-Tu4eJ"))}, + password = plainTextPassword6Unsafe "k\1044075Pnu'6Z\NAK\1017783\149108\ENQ\129297l\18438[\1054432TMgddIb\186517mt.TCQW\1025717O\1111819M\ETX\27672\ETX\ETB\1083603\1091383F\RS^\182596C\SOH<\rs\f#\STX?A\n\170555\68821\t 88|;\SUB\1015442&\n\1042330'\1003626\151074 <\63465\v\EOT\1043258w\1012648\DC3l\62396\FS2)\SYN\1003311o4G\161486\&1;0IVKt6t$Y\":\13086\156982\1055032\"\GS\6275$y\ESC\15469)#\1011445H\SUB \SYNLk|\DLE$\GSh;\19798G(?ft*V%|\9608\bC\b,\131877\SYN\7628eI?:T1\ENQ2\1042416B+\STX\\\GS>4\1042921\1015196\DEL\1050654\ENQ\RSdH\NAK\SI\vK\NUL\1020294\a\b:9\163015\&3\53363%^[X\r:\1044970c\n\1035333kk'RA\78616\1054694\24158\1051573c\RS!\167908\28730\ENQ\SI\1068557\r/\SUB\1106472\&1ott&\SOK" } @@ -77,9 +77,9 @@ testObject_CompletePasswordReset_provider_5 = testObject_CompletePasswordReset_provider_6 :: CompletePasswordReset testObject_CompletePasswordReset_provider_6 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "54Yh4fa_ClTVqjEEubnW"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "RcplMOQiGa-JY"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "54Yh4fa_ClTVqjEEubnW"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "RcplMOQiGa-JY"))}, + password = plainTextPassword6Unsafe "\1068965Mz\1112587\\b\988910\33388\1081682\FSSi8:\"\r3\GSc\989625I=8L>uA'\SI&I\94104!W\995368\&7z;r\ENQnj_+3u/8\31470{\32573\170260\EM$vy\rB)\125105l\58284\1022117'iN8\SO}vd\1025869\132023uw\996610\&17\ETBF#\154217:s\1019264\EOT\CAN\12331\127284p$\53580\&2\14658\DLE\13233\SUB\59635Hl\25906\SOHw\1054216\&4[\171724\DC1\RS\SO!lS\EM\1073106\66443\\(\47504\61628N\1029483M\NUL\"\SOHd\1088943 \58859U?\31664d\138217(o\RS'\47111\v\1097785{A\ETBb=\1039402\1096760?o\n\164402*\12095P\SO84,Qf\1065714D\EMZ\SOHux\1096460<\v)\1109779\185595\25160\69876\&8t\136448Ya\GS\ENQ\9575\NUL`\US7\1022950p\1032880\&42\32304h\68036\EOT+W\a\1022685aH+XE\1016645p\SUB\8531\n\DLE\136210\1080841\1069380\119885\t\31849k\1020979\159730\RS\99244\1100479\14782G\nh\168920\SUB\DC4{\1107942\&5,\US\DC2L\DC1(\137496<|\bZ\172359\SIK\EM7\t2V|K\ETX,\SYN)F\50452\20991\100678\1098846\1109927\tJ\SYN)\133930" } @@ -87,9 +87,9 @@ testObject_CompletePasswordReset_provider_6 = testObject_CompletePasswordReset_provider_7 :: CompletePasswordReset testObject_CompletePasswordReset_provider_7 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "muTkNflRkN4ZV2Tsx=ZS"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "X-ySKT"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "muTkNflRkN4ZV2Tsx=ZS"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "X-ySKT"))}, + password = plainTextPassword6Unsafe ")jtk/z\184222F!N~\ETX\990448\1055900{8\73979\153166!D\1043025%\135850\168364u7WynrV\ETB\148520p\1077327Lt\842e^}?\1093891l`.`Y\vZ\STX\1112581P}[~\30935=}L\1095875\a\v!\1028719\ETBH)>5\ETX{\NAKD\ETXUEh^ ~\EOTCC\ETX\SO\16392p\38296z3jt\NAK\984409\bB7 P\CANSu_\183789o\17912\DC2\178168I\v`,\1022887N8\\\DC1^\10311m\CAN\1030400\FSZ_\"$\ETBB/\NUL!\SI[\DC3\vy\f\ENQ\ESC\137923OC\SIt\12293:\EOTl\\\b\EOTrG@\US\45550J\95310\166637-\10023\&8tTT#MD\FS\DC4lJQ9s\64189\25142\DC1jlVF\96794P{\5228\25037\NAKKEC\1098620[kg2*C\991918\NUL[\35874&\74062\188051?\182094\&8\145055\rSYlf\95342q\30892\94613\NULM.\b\t\1102963\1018631;\DC3_\1029835|@\SYNd\1082087)\n$an\SI\RSp\n=\1013045D+\97624\f\1106118\988197\1113\GSb\181818\SI\1091492YQx]\1063062c\18044\993702\148181\1072483\1042478J:\ESC\RS\1052622\186566<>\EOT\DC1\FS,\1076029i4@\ENQu^\178972\1082722Dd\63135\1006290\EOT\66041>Tx\1091471#u\\`\STX\1093786,Kt-\1035926D\1024804\154425,I.\190722:\15722&3n\v!\40042Pm\41694$\n\SOH\183103\75035\1093394\3121>ihpLGl@L\DEL\ENQ\ETB\182031\SOH \21434\SI)D` wC\STX\v\ENQ`\54406}$\39750\DLE[\"\1087944'q\1043619tP\EOT%\ENQeG\r\1058468\1110447C\DC3g\1038268#\FSYrht\164459@\1085349tMo\ACKWM\SUB\v\40317o&}~45\160190\&4K\1104579\CANl!x\167229k\ESC\\h\ENQ/4,\177887Yp\995759d\98258N\1108317vw\ESCK\1098528\FS\ETBRSf0\DLE\148633\93011*Wukxd3>\ACK'gN\1044418\DC38;2FN\747 '\1005699Yt<\1105770\21737\1045228\DC3]\13220\ETX@\f\1101655\42506f9i.\1005751\&5\n\131677\&2%$\1047618N\169552Y~47\986154\SO\1007292\1001379\31676\&3\1056996le\1059155\&4\DLE1Q\FS\986744#5?\73770\1092436\1011458\171368\167096\&4l<\1069261H7]=\DC1a\62925od\1064417A\GS:l\SI4q^b\1057856D\173253\1059916$b _oH'\DC1Kv\\n<-\t\US\1083436\163231\ESC\1098850F\1329\STX-\ENQ,\CANG$\NUL\38340.\1107219;\125009\169728\167O\ENQH\1018301%\ACK\1025545\1011306j\RS\994143\1094533mEB\120644\1031761A\20411\180256YN\STXFRm\US\ETXQ\1072397V`+\95270m\SYN1\1013314\b\1024313\&1}O\1108229\1002097\49175\f\1007287j$t\47188\&4!8%#v\f=\t<\49120\61960\ACKM\1056844\SUB3\"\r\989243\SUBX%~n+:\NULM\134421X\DEL-v\72197\f\ETB\996041\EOT\DLE07\1009115\CANU},,}\141362\bHy\fLa\\\n\64444\983949;jo0\157407\1061450\1041761\EOTMlW\DLE7\45112\1113654\984581B\1087787Z \1067937/\1027501R5F]X\ENQF|`\162826E\128973\r\v\984688c\1100696\1074387T\1041206\SO*(\RS\ACKbNs\1056623ST\139333\170914K\1032627?\SOH\1095798\1006647\13962\"S[TY};\SOH*r55\aT\1006364\SYN\SOH\1111555\1082650\RSZ\a\1020940s\162901t\1055866}\1055756deI\153662\46739\rR\\]'\1084483\1056412\\y\135616\FS)@o\30437Ci\1081016\1042881|[Q}}\1025142\SOH^\1085438|S\EOTWa\nE\DEL,\1014498S\DLEq\DC3s\"h\36770)\1084960\RSB:" } @@ -97,9 +97,9 @@ testObject_CompletePasswordReset_provider_7 = testObject_CompletePasswordReset_provider_8 :: CompletePasswordReset testObject_CompletePasswordReset_provider_8 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "4h1kCFffI4sHePSIIfS1"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "jgfbzV60"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "4h1kCFffI4sHePSIIfS1"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "jgfbzV60"))}, + password = plainTextPassword6Unsafe "[_.VrDh\1015708\1032560\&3\DC2=M\163597rhfOlZuP\65504\DC1\SUB\f\rx\FSJ5\f\DEL\181294si\166877{P\CAN\GSG3%'O\a\f\RSa\1092468x:\1053642\61514\1073484+\39638fMP\1054011B\\nu\\\SI:a4\15010qI\n{\1029779\NAK\1041484(r\44941EI\13466G\141832>\FS\1022348\EM@*y\US4{,\ETB\151574p\ACK\1107549R\1055583H,\DC1\v\US\1009911C!\SYN\1027699i}2\1006393\1013086pu\t?4\ETB\35803\44095\NUL\t7&\f\94064\993295\1068521\1077762\t&\ab\160257'\NULM:\29880oI\DC3\ENQtG\DC3`/0\RS\166279v\b_c}m\UST7;he\155120#\99948\1018238\1062963S4K\EMR;\ETB\US\ENQ\1021792\STX\1003450:\24440\DEL\EOT|p^ZN\30349&WtKz(S&M\SO`\SO\181996#\1011887C:^\ETB\147530f\EM\a3jp/\1058108|p\SYN/9?Wn\13780\RSH\ENQ*\168131\1075215\119182gh\2225\1089941T1\133460\77864\1037953=\986510\1004229&1Z[\1043805\1002639\&4U\DC4\998270K%\DEL2\USp&q\1055724o3QhHE:}\ENQhil\1096277fc\f\SYN1U\ACKTK\DC2\173882!4Ch>f\DEL\SYNV\49106QcXO3\t\SYN1\185658\147541ii5;?\ACK\1023746\994599W\63325\DC2\45506yDu\132949\140075\1007168\"\EOTVsg\1088989`\1042945:\38432'\STXE\992832\SYNJ\ETX\64654\DEL\RS\rV)6K\1001241u\n\1061707\ESCWq4k'xZ\CAN\1004671Pp`\78706\DC3s\vb'\1026286\DLE\51253\49630.v\1078713W2u*\1026823\f\rc;=l2.\135778\1067475\66363'AT\1038064\20692mc\ESC\DC3?Y\EM\1043502erF?lU\177756\SYN2\137736ZW\SYNe}\110678i\r8\1045526%\DLE\1060820Wu\ESCwr\SYNZ\984526\DC1\DC4*F\1025876j\4244\NAK\69844\SI&\24155t?\SYN:\996677\EOT\1096939\\d\ESC\rV\1048902\DLEY\SOH\DELHDi#'#\SO3\DLE\1033528\1066728hP'\SI>,#;B-\DEL\ETX\FS\b\1080220\\O\173118\155899\33548\161628r\DC3v\1036063\NAKwY>@P{&\126581muC\30489\DLE\RSW\DC3bzp#\SINO\ng.f\SOH8\1044888\USM3\STX9M#\31452A,S\144295\DLEiK\ACKi5\DC2\1106504\163392\&9\DEL3~\SUB;z\37537H\SOn\74309\1097966\22046h\SOHH\SO\1014941rSW!\1076838\1019303\ENQ,Texo\1103981\\U\60688\1107601ef~\NAKA\CAN\1095090\b1\FSiW\EM:i\1063110\100555\1028434\f@\45876^20\EMn!\1110881\ETB'\t`\"^" } @@ -107,9 +107,9 @@ testObject_CompletePasswordReset_provider_8 = testObject_CompletePasswordReset_provider_9 :: CompletePasswordReset testObject_CompletePasswordReset_provider_9 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "8QW8mjnVnIisvrtQDzWV"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "qXaaBJ"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "8QW8mjnVnIisvrtQDzWV"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "qXaaBJ"))}, + password = plainTextPassword6Unsafe "7\CAN\995057\1082858>#\149981T\1113543e\thUr\189434/\186737o%\DC4\SI\8198o6n8\20176c\1043600[C\1057789\&72@t;6t\169068\11814\120655\DC1\EOT\1079958\v\aS\SOH\EMey\ACK:\aii>\1079059u-<\1112894\1083324\SYN[#b$<\r\1056477\1033082\1105819\ETB!eWg\991833d\DLE\CAN\ETX @\SI\185824\&7\b(\40642\&3\NUL\1110157!X\FSe,t\ETX\1095428\&3\128629\1025661p\1000552\184281\184297l\25688V]\1068327v\152194MF\v*\1050101\1065061 \ESCT\SUB-\21105\&0>`{|bal\1060553\ESC\US\GS|\ACK\1028192\DEL\DELV\143705dq'\DEL6mCCjv&\1015677\DC4n9\1022140My[ K0p\\`6r\182750\1080218\DC1$|#\137636H\DC2?0{%`\STX\1005371k!\RSIg\SOH\SYNJ\FS{9b\1059876/4@\1060707ldKAH\ETX'8\180338\178999\1013270O\1075685Dko\23121\&04%/N9B\1003052aW*\1070751\1043722w8\SYN\RSr=\EMnX\1071326]\NUL\GS\1082718\139251\1079728\DC2EfW\t\SYN&G\196\&2\1008326b\1023329\1102771\1047159\&5[f\NAK\100090J/7\26364\t4\SOHS\CAN;7\185137R<;`L\1112382\1022626\&1?yCIiS\153111\GS\FS\EM\ESC\156314LH\140232\\:K\1002577MP}q\139293J\ETX\151699\1052232\1108510\NUL+X\1029314\181545D}-!EF\SOHE|\131183\&6\39841\1062330\21504\SOH<*x\179748\1015132k\DC1\DC3\98575\ETB\EOT|\SI~gr\DEL\2694YyEY)Z\155604&\DC4\997375\1004619\36183\151489\143359\29364\DC3P0R(|\1044843(%Y4\1044821?3\ENQ\v\ACKU\988376\30638Y\f0L\b\986153\STX\997297,\ru['" } @@ -117,17 +117,17 @@ testObject_CompletePasswordReset_provider_9 = testObject_CompletePasswordReset_provider_10 :: CompletePasswordReset testObject_CompletePasswordReset_provider_10 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "Myzdj2g7NTl0ppCPXiN1"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "BBwqW3"))}, - cpwrPassword = plainTextPassword6Unsafe "\ESC.\63992\SYN\128619\1086386\&0EI\50894\1058818A\ny\65231\1092012~\CAN;p" + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "Myzdj2g7NTl0ppCPXiN1"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "BBwqW3"))}, + password = plainTextPassword6Unsafe "\ESC.\63992\SYN\128619\1086386\&0EI\50894\1058818A\ny\65231\1092012~\CAN;p" } testObject_CompletePasswordReset_provider_11 :: CompletePasswordReset testObject_CompletePasswordReset_provider_11 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "nQSYG43lVn8kYS-MPtOO"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "5BuwQHalK"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "nQSYG43lVn8kYS-MPtOO"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "5BuwQHalK"))}, + password = plainTextPassword6Unsafe "\1045282k\1026750)t\NUL\15552jgI\ETBP\SO\188738\147525\1066604G\39626jb\f`\nTq+Ut\92361\27743UBpVU \992919f9\21139\1020059\&1hYp9Ja\147132EBN\DC3t@\1079146i;P\1042445\180008\&8a\1091375T\158952V\138448\SI\18953fx\11087d:\SO\\\1054972?b\NAKKtz\GS\1104407\39067\n\1074206\&3\SOH\1025715\r87\ENQ9@\5471Y\ESC\62699\11493O\1045551\ETB\10550\1037708$Fph\US9\ETBe\fC\20273%>\USP@\STXo\34112h*\1042645\1104430\987562E\43000\11020\32229Ft{A=\38646#k\SYN\185887Fi\99911>&oy\98658\f_\1099272YIL\65827\&2\184583\1063350v3\RS\DC4\27853T\141265S\1048343\NAKK\150089\&1Y\1059308\NULk\DLEy\1067797\162645\92680B\78890 of ," } @@ -135,9 +135,9 @@ testObject_CompletePasswordReset_provider_11 = testObject_CompletePasswordReset_provider_12 :: CompletePasswordReset testObject_CompletePasswordReset_provider_12 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "NeuDtdyLCvq11nGkkEal"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "sj64oWB"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "NeuDtdyLCvq11nGkkEal"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "sj64oWB"))}, + password = plainTextPassword6Unsafe "an-<\r\ESCa$\n\SOH\150355@, daTW\1040876\1086641\1008932O\a\173984\1089573\187195E=\1033471\v\142301l\ESC\ACK\ACK\67616g>h(;\983436\ENQN:d\6803R\SYN$<;\18099:@\fWrO\119365\\\SOHE\NULX\SIL&Qc\143803\ACKKx+K\FSF\US\1018415\n:\\\SOH~a\147255\78868\RS\DC3Jr0c\DEL5\1004711\&88\1048447(m\1018537e-cWRQ`N\1091454\127355+i\DEL?\DC1\172339(\1079229\1021542\1023479\1095290\NULzNO\"h\61187q\NAK%\148825|\8495A\171333>\1023153\NUL*\144781\b\1096599\&6\ty\1084884\1106372\&9\991761\&4\54666\993909\&69\188610\78768/\EM\3120\SYNX\160680\1093419e\1101140!(|\EOT\180765C\15108\GSj\73803\t@\rsQ{ZZ-\22170\ETB_\EM&6#\bsD\v\n\td\74406D\37637\&9\72882\1015558\&3\NAKN\1028309Fnk\ENQ&\EOT3Q&\28043Ys\97711H\181981\999099\46018\&5VB\1044294I+\1104448\61690\&7f\12643\133501]\r '\163623\SYNmbE\1015369\n2?HK@\DLE\DC3\DC3\1023424Z(\DC4\SO\DLEk{r6%*\1034286\EOTM=/\STX\1035914&4\1098394\b)TI}\998716[+2\EMV\SOH0h\v\18412@\bQZG\GS_\DEL\999345Cim\DC4)m\1021546\RSP\23785zv\50314\1005770mi\DC1\100847\1042938\USC-zT\SIQY^;f\NUL\ESC\30038N\93068\SOH\DLET\1038908I\DC1\187625n\DC3\CAN\\\r&<\SOH5\71118\1027153X\1092148mF#h/{\DLE!(\16202\&3\ESC\32283\185971[h}I\1071533:\183293R/\1004445\140257#\"\1028937\v\177329\61790_m\138219(\SOH%^\1105873[\1035020RF\CAN\1054790\24076\DC1\FS\NUL\72138\STX\ACKd\ETX>#qn\SYNw=}\1001530\177147&^\NUL$BP%5\3450\179283\DLE\ETB\SYN{y\34999\1114051\NAK:\17208na\133899\1014430c\1106626NDB\160028\15282u\43902Xpr*#\172705\a\SUB\188880\1026535F$\ENQ2\ETBB?Q'asS\ESC\96583\DLE\DLE\1012383e?\f\STXT\1096814Q{\DC3R\CAN\1065288$\1074134j\SI\135241\&3\DEL\1035586\1073529\43493\&9ecd2\ETX\139431@Pvv\123147\157284q\r\1091419\1052105\23426\185829\1098874,[a%\1087411\"RLOU\31476V\1060394K\NAK\EOT\180111S\"Wes\ACKH415\78735-S\SUB\DC4h:d\1036393NZ\t\1043380m\167051i& \1107753`dP4/\DC4Q\ETX\54045B%\186624\&0;Nb1\DC1\EOT9\SO.\1014579\187014q\ESC\1078099f\ETX\64604H\1060225\vY\RS\1045658\DLEC\179470\a\NAKWw\ENQ\1035817[3^3B\154130\"_\nPK\1076894{\ACK\ETXO\DLEr\SIvc=+af\SO\ACK\1101910\167540\STX@\GSQ\1011496s\ETB^c\CANwJY$\1107843s\DC1Gs\1049240\DC4\NAK\171080k\US\ETB|\1065322\EM\1035477tJ(\1075051\1687xc\b\1056830q.\34099\&7\NAKF\1023165\DC3C\a\172318S][\DC1:\ACK\26422qL\1039209\&2\EM\44805\ETX?NG|x\1065136>/Iz\1061649ms\US\SI\1005398\131153\159667\&3\NAK\1048772\997425nd\tv4\DC2\1080172\1101786\v1Iw\1050069\v}?1m^\STX5#V\147028\1063172w\EOT#\1030144\145884\f\DC2\131840\6065\FS8O\NULS\ESC\1033971X8N\142482\1041006\59926.\ETX\163181\ACK\DC2\RS2\GSr\EMV\nK\NUL\DC1\1019014\30036_W\61065\9477\SOH\1094473/\392\20690\159848\181387\EM\vGDR\188046\SOH2G0{\FS\1084240JX)\188982SE\176663B\1089777\US\132402&\SYNg\"\DEL\1902UCP\1054969\1106547\1106033YU\EOTi+,?\147075\1044086\1028895\1110977\1016778\1106548\DC21\186874\1095378L\1030254\997653\998721\SYNc'\ESCp[\STX\EOTN\ETB8^\1021121Bk\b\t[" } @@ -155,9 +155,9 @@ testObject_CompletePasswordReset_provider_13 = testObject_CompletePasswordReset_provider_14 :: CompletePasswordReset testObject_CompletePasswordReset_provider_14 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "4hqO6D9=V3BKXLXcLie2"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "emsaYZVuPvQ1U"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "4hqO6D9=V3BKXLXcLie2"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "emsaYZVuPvQ1U"))}, + password = plainTextPassword6Unsafe "\1028432Q~\7949F+9Dc7\1026106jl\SIC?xdB\ENQx\33993\62067\ETX\DEL\GSj/^#NS}fiO\119558At>\GSh0U\62526`\r\aV\US\1112085_v\33980w\ENQ\184054\&8\11831\1032958}e\ETXi\NULRC-- \37583Xd\ENQ\an/,?\SUB\SUB\1066224\42328gQ`\70388\41959\1012806Q\US\ETB\184603&LR\149821>\1012033tR\DELg" } @@ -165,9 +165,9 @@ testObject_CompletePasswordReset_provider_14 = testObject_CompletePasswordReset_provider_15 :: CompletePasswordReset testObject_CompletePasswordReset_provider_15 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "bbFHebGp6h_3F4QpSrud"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "K_xVBcX5bLpjvL"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "bbFHebGp6h_3F4QpSrud"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "K_xVBcX5bLpjvL"))}, + password = plainTextPassword6Unsafe "\ETB\26894'\fd3E\120233\1029573\1064918Y\r\1104541H~4aF\16111&&\US\1085044\1086081Kt\2880XoS\r\DLE\NUL\1044509\v\983386q\182823\1075779\148618N=\1062701\1004214R.\\\t6!E\164333\GS_$F\STX\DC4l#!\ETB\DC3+\1078110P_\1037691\GS\1003847~HhsY\1008817\CAN'\996168wy\ESCA\1096877\&2\170187\1060412\SOHO\SUB\ETBc\t\1090646ud\1037884\CAN~\173115\1096337\rB7\1049690o\DLE\1095190fL\996695\ETXi_=\a\SYNLE}<\1106966\aEl\49881\v.6H\CANw\995916ZH\176178\49327\19051o1?\61005\1065006!W\DLEY@!\1058199S`mq\15087\161424\167582\a8\127764~rL\41008\171779\DC3[\989714K\SO\ETB\152791$jRxH\SYNm\1076533\DLE\169669'\EM/xd\52526\95412\&8\ESC\38505\&6bYZ%\139602\20809\35764~\72852SG\1075777`0pL.\185639\&4f`7\DC3\1113337*\v\60813/T\180136C\1111167Z\SUBZ\44799\DC1@\\\1060472\&2\EOT\11121K\1039363||\DC1h]3&\DLEY]\GSk \EOT\SOHU\161853.\DLEk_\133547O\1041592h\1083420{\64532\aw\ETX3K\41041l\1069560\a=\EOT\152591\"\fyH\78163\SOH/L09\149680\DLEvw:\ETBO\1066598%#(Js\169845'9s_&9\32941m\149591$\1021728N\984156WE\DC4=y$=3\1083024\21817<\t\th\1087258\NAKx/\999799V)\STX\183098\1073874BI)\\gk2I]#l\NAKPn$\172450\ETX&\DC1;\GSY\EMO\180851\ETB\1095722d\ESC6\151131}$\175277\NAKajf\1093922v\184717 \ACKa!\v\166519\EOTS\1012345'\153953j\1098235\EOT\FSE\1061729\1052832#5Zg=\172012\4883\66029ZU\36791\22747&C\STXZh,\1088719\7021\1087041\ENQxC\987916\39597=l,\fu\998370\ETX\60675 }&\183212\989435\165094\1040277\135097\"\v\SId\US\EMJ\59927'6WK\13266\ACK>\70807\995567y\r\"\98652,\DC4\SUBd\NULne+\64011,&!9Y\15584T\127281<\1077668d\31074e.#w|?\1034255G\1027753S1\24647\\\1090505\bz3\DC4,\988313<\\\1073727\DC3\1032879\997224%-\64532\EOTC\ESC!2\156292\145116DT \f\SIXja\\R\1014521\SYN`\CANJ\n\45882\1023562\SO\13921ab\NAK d0\SYNX{:\51467\CAN?\187194\&1txQ]\1005159?\176303[)\78300\&1O[Xl#\DLE\38014\50691~g\1043081{\132217_g\ESC!\SOH't\1101558I\1003044\1063761?\137915`Nd\182690`*1rD25c\169907owK\20714(\1055173\&6i&j(U6p\1104351zK\73918mE\11375\vjr:\43447`\1094897w\SOH\SYN^3\DC3<**`j\f " } @@ -195,9 +195,9 @@ testObject_CompletePasswordReset_provider_17 = testObject_CompletePasswordReset_provider_18 :: CompletePasswordReset testObject_CompletePasswordReset_provider_18 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "m3ruXwhym9ERHyTAJo1y"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "88xU9QOF1FPXdL6e4"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "m3ruXwhym9ERHyTAJo1y"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "88xU9QOF1FPXdL6e4"))}, + password = plainTextPassword6Unsafe "cW\ac,#\12631\n}\188543\EOT\NUL`v\STX\48639\SYNO^\1061062\1045730\1096180\tUs\EM\SOH[b[uz\DC1\1106362\DC3-\995083\1054859+]\GS90\DC3X\GS\v>8\154400\78507\t!\SYN\24475\&2 \RS\fW?\SO\EM\17276>\\S\ACK@Qt~8`T\1073440\97220a,#\1054560]X\1019169-\1112078]\1005234\SYN}\1112649T\999008\1062688\rH\171818d@9/pab%\52983**pK4q$\984140+e\NAK\USZ\DC3\29392\&3\176130\1073376(\ENQy\\(5$'|\2800!_\154876_\1073420\&2\SYN\996743\187317I\DC4Y\"B\1049376=\1103936\&6\66599v\153333\NUL\137084\119859\147584'\16885GJe\FS\SIPk\NAK\ENQ#\29575\23580\SIa>m.\161669to\SO\1049661_\v\165212\FS\ETB\ESC\trn\1029796\1078206\61317\rM\149956\&2S8\a35\\\ETBo\191128\DC4\58762~?\178576\STX/\nNZSjbWH\r\1098678X\993718\4120/\1046632y}#\63730\ACK.Voi9\50993\f*Y\STX\1001056\43180\DC1\rS\1021396\144641qSj\17576X\149262\1081745\1076445M\1063531\22347\CAN\45875\40887B\NAK)U\"~V\1036888\1007909/S\61542y\SOH<\b\ENQ^\SOY\1013585;\SYN-RKy\ESCO\1033537[;\b\1094937EDjW\997383\182740bKx\128165lZ\DC3J(\1032322\&1mK\DEL\69897z\131148\144121i/RxiQ\1085090}E\23345pA\1065790q\v.\v\ng\20319Gb\46475Kt#\USP@8s0\vg[c\169328\DC3\US}{/\1002448D8\170376\159999\987435\67200\1053165M\1079934\1073683\DC10i\159626\1111106\ESC\"\1019962\SYNg\1025072M\1022474\1059584IsD\b\1086244\70682Z\1015255g\DC2;\SOH\1009422cQ0f\STX]0p<\1065421.j\DC3\r[W^rsM\fU\65479=h\1059093L94\993336oNs\1016719Z;8\30468lw\t;S\GS`V\\f\993287\1001923\49875\1018016\1032042X\ESC\NAK\132703sb\SI\1110714C\ETXi_7\138308\NAK\35645}\12913\100683go\FSVj\vtr\SYN\181280\166083;\137762\161816\EM6\1068253\1058678/\n\71064\fp\1036795O\SUB\45835>S1=\DC4>m=Li]y\1014422\r\US\5961+D\230\54691UWo@\1104594n/\EOT\FSDR\1084131\&4\CAN`-}/\v=<\DC1\1011393\DLE\SYN\1000229oB8\1073774\fT\185994?\DLE5lJ\917988\1051232\993358\&1\\\SYNGx\160450\993275HF\988493\1096467N&\DC3A\1078985\ETB\1085595\71193@\a\SON\ETB7\RS8kT\13512\SOH\128792}!`].7C\ACK\EMa\991996\SOH\ESCR\\Iw/y\1052927\162141;*!\SUB;)\1034215\DC3\GSjQ)\98905\1083130 \aQJf\143466\3112\1088669q\183516D\47434Z\r\1051585\1066298\1011799\DEL\31175\1077158\19157\f}\1074960\CAN{\1026108\ACK\165269\989993\1021383\4839\993646_9\FSYzI=JL0]\45720/W\NAKD\ENQ\143508WJ\CAN\DLE8\EOT2JsPn\1025590\20415\bhB\DC1\1537Xj\ESC\GS%:p\64920@gL\ETB\1087542\145056\1111605Q" } @@ -205,9 +205,9 @@ testObject_CompletePasswordReset_provider_18 = testObject_CompletePasswordReset_provider_19 :: CompletePasswordReset testObject_CompletePasswordReset_provider_19 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "nBmwchpz6q_fDPCPZYQe"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "AHGkmRBXJr="))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "nBmwchpz6q_fDPCPZYQe"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "AHGkmRBXJr="))}, + password = plainTextPassword6Unsafe "o\58515ZHN\83385\a%S\1039235I\1072272\&0\DC4!\1105051\&0\DC4\FSc|\SYNo\r\141433m\DC4\1017077&\ETBX\GS\101008sXzlOS\1086195nZ\"\DC29\30572S\n\1028791\1110650\SUB1>\155164*K\63527\917991\34537\"\152219\174017GW\165251\917540PPO\1099839\983424\&9c\1001124U{fLy\SOH\FS\STX|\35808\&52\SOHP\n \126255/\42693\188778s^_\1012451MW7M\EOTs\DC2M\RS9\ENQg\1084863\61924@%o\DC2\DC16m&B\95458\190903wL\1066100o\r\1082662y\ETX\tk;>\1108088\1053265A\US9\4469\STX%\44556\SUBghb\1046982\DEL\b%0\1011473\16374'kz\61155\126123\NAK\1040333\&3:<\1028617\12709\1085958X[g77\59354_\ETX\1018780\1031200\&7\STX\GS\163106\60867\n\f\129490\993680iG\181984\58073\168758\44094`i\49314r\65104\GSu\1030407b\1002850\1053366O)\1042687YQ\190781\DEL=fZa)T0j\1070016K/\1104693%v\100085qsY\1017025R\33451\997088\&3a\45926\r\DEL\ESC\1031881\NAK\35199\183615'a\11657B\111310\STX=\RS>n\1055557)d\US\FS.F\1111038M\133759\1021129\&3x\ETX\51747\1102182\11790Z\35206\&3\173723P\RSV3GeGN\v3\28136a^k`\29343\22637\rK\SYN\NAKe(\GSQ%b\1080735u\DLE\ACK\NUL\99272\1095099y{\61538\&9IbP\SI% \CAN^\1064103+f\144228\59518r\18266G\DC2?y\DC3\1073829\RS8\34346}r\45176|y,\b\1026988\145851/\DC1R\1017813\&0W\988979N\NUL\35979q\bc\1091121\NUL\1087940\GS,d\CAN{J8[\1031817\CAN\r\138592\EM|\nz\28160G-{\SI(1\47823\GSl1\18854iL\77903j^\DLE4\ETX\159954\1105693\83316\FSoj\ESC2z\STX\1021083t\17703;K\STX\DEL4Yhr\STX\987287fO6h\158330t\1076871\&3Tpef\SI\ETB\1109588jC\150352eh\10328nf" } @@ -215,9 +215,9 @@ testObject_CompletePasswordReset_provider_19 = testObject_CompletePasswordReset_provider_20 :: CompletePasswordReset testObject_CompletePasswordReset_provider_20 = CompletePasswordReset - { cpwrKey = Key {asciiKey = unsafeRange (fromRight undefined (validate "n7oUiCMAvjokyCwCwIZx"))}, - cpwrCode = Value {asciiValue = unsafeRange (fromRight undefined (validate "padtz-lbyFICM8PEzCj"))}, - cpwrPassword = + { key = Key {asciiKey = unsafeRange (fromRight undefined (validate "n7oUiCMAvjokyCwCwIZx"))}, + code = Value {asciiValue = unsafeRange (fromRight undefined (validate "padtz-lbyFICM8PEzCj"))}, + password = plainTextPassword6Unsafe "n.\30576`Ab}1x?dyrWEI\SO\b|\r n\\\174375\163710yzk\CAN\1032873\13076q\1104973h\1078766\1065080\1073163\1024180\SYNzt\1041454oY\SIv\1109814\999708!\DC4b\r\ETB\139978\1037986\&97|\68831Is\78227\48210\984397\1108736R\1076978y*\ESC \1080452\6350;\1002645*F\ETB|l0\\-\49792/d,76W\SOH\1091954a\52507\ESCkY6\EOTe*6\1068076\1010489\STX\1109890%B\"\DC1X\145857Z\1107907\988601\SOH\r\983601\ETB\1027493's\\g\SI_=\187494\52638\139634oOcZ\EM ZWd\EM\STXe\25610Zz\1055806\1023881Me\25012\DC2N\1061919o\154179BmCD\ESC\146744\165530Wsw<\ETXs\SO\992482\11825,\NAK\97960\ACK\1112588je1m\1080113\&5\CAN@\SI)(\39581y%~d\1022649\&0z1||L{1\EOT\1083342Ja\74536\EOThHi\NAK\1033200\SOH\CAN\ACK\175120\183861\DC34&q\GS>\SOH\184139\SOH\ESCQs\41951a\59763\1069217bv[u\bX\1078841\1048633^\1015710;[\STX\DC3\1001312jYw\1003565\1077047\te3\148232+\7427\\\SUBq\1108026r$(zD)\\7p\"\168984\STX\59311?\8657<\NUL\1035836cP\194909[>\USm2Y\1010432\1106430\21518P\DC1.\1074512\35480\ACK\EM\SOH1npVW\SUB2+\ESC\1059649\33997\GSk\v \993759A)*uk\1030453L0\1078688\1044139\DC2\1029875\DLEn\EM$\1054292?\NUL\vji4Y\DC1\EM\1027716=/S\1024040`P.\ENQ\SI\GS\1090161\50097mww\61962\59664e\994460\1030466\f\83226f\"\CAN{X)\v\4796\SYN\STX\119946\DEL\992301+\39597jv^\169149\SUB%C\"]v5?\185720/M\991044\1010224\1027231\984290+\SUB+\186874VG\SOH2\1003544VM\SI\f2'\1009297\1059762?Lx\986666<,Q\1009359t?\1067784\18910\GS\CAN-\1090445C\2603\1004458\10478XZ\STXo\1019324\by\985769,\46054'\a\21265\DLE\SIH\1003281$J\US\1051584\ETB\r6\ACK\FS_1\45810\1013879\998189\167043\DC2>\1082944<0\47209G,T\1055523\12871\1057078:>4\1005909\1060368\GS\FSED\DC2:\rzSMQw)P\50826s\1051230^-~\95981,v\DEL,\SOH1=/GNsO\129350\&6\GS\16013_4\62900\1097318!\SOH'M\139907+$\29092\154621<~E\96994, U8\ETX\986557#\1092210[\1042274.H\DLE\1098681\ACK\1062248\"\133455?I\1005507e\167230\t\28751\1016604\159825\GS'\160639\&9k\EOTZPj\1084498\1039215O\131535L@\171949\r%2>M<\n\120995\1031232\&9/\985482C\SOHav\142062\ENQ-|\ESC)\b$\"\DC26\16379\CANCT|Ut\131524\149842\96725X\64829\v\28384\DEL'yR\1022028\1056329r\25908o\165079\1077144.\185928\&8\NUL;\NAK5\ENQ\ETB8Y\DC4g\1101865Q\1085552\150701\&7!HO;\br\1026135C\24186\37827\SO\ACK\165967E\r\DC2\DC4l3\1090105\127078\DC4\SYN\bUF\15427\DC3.wO\EOThm\164680L]y&\1024985\&0\308;Nwyw\61385#\r8Om@x\1007233\b\ACKo823U\146708C\SIMN6t\DC28\1047608\SOF\ETXRna\a~\r6\fE\US#\tn\1006471wr'B\rnlolj\1017148\144338\1087477tT\119355\1044444\SYN|4G}\SYNEn\1000211\&0D\DLE\SOjn}`0\994578+\1019070\184767" } diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/ConversationList_20Conversation_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/ConversationList_20Conversation_user.hs index 1f54659721d..c5e394080d7 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/ConversationList_20Conversation_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/ConversationList_20Conversation_user.hs @@ -42,7 +42,7 @@ testObject_ConversationList_20Conversation_user_1 = cnvMetadata = ConversationMetadata { cnvmType = RegularConv, - cnvmCreator = Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000000000001")), + cnvmCreator = Just (Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000000000001"))), cnvmAccess = [], cnvmAccessRoles = Set.empty, cnvmName = Just "", diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Conversation_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Conversation_user.hs index 23656db04a4..516f8baae58 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Conversation_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Conversation_user.hs @@ -41,7 +41,7 @@ testObject_Conversation_user_1 = cnvMetadata = ConversationMetadata { cnvmType = One2OneConv, - cnvmCreator = Id (fromJust (UUID.fromString "00000001-0000-0001-0000-000200000001")), + cnvmCreator = Just (Id (fromJust (UUID.fromString "00000001-0000-0001-0000-000200000001"))), cnvmAccess = [], cnvmAccessRoles = Set.empty, cnvmName = Just " 0", @@ -75,7 +75,7 @@ testObject_Conversation_user_2 = cnvMetadata = ConversationMetadata { cnvmType = SelfConv, - cnvmCreator = Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000200000001")), + cnvmCreator = Just (Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000200000001"))), cnvmAccess = [ InviteAccess, InviteAccess, diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/EmailUpdate_provider.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/EmailUpdate_provider.hs index 84ce0c6a946..8673f2ba821 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/EmailUpdate_provider.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/EmailUpdate_provider.hs @@ -21,12 +21,12 @@ import Wire.API.Provider (EmailUpdate (..)) import Wire.API.User.Identity (Email (Email, emailDomain, emailLocal)) testObject_EmailUpdate_provider_1 :: EmailUpdate -testObject_EmailUpdate_provider_1 = EmailUpdate {euEmail = Email {emailLocal = "sL\98765", emailDomain = "%"}} +testObject_EmailUpdate_provider_1 = EmailUpdate {email = Email {emailLocal = "sL\98765", emailDomain = "%"}} testObject_EmailUpdate_provider_2 :: EmailUpdate testObject_EmailUpdate_provider_2 = EmailUpdate - { euEmail = + { email = Email { emailLocal = "7\160957>t\21165\ACK\69619n9\b\USskT.\"\1106936\r\DC4`", emailDomain = "^/>1Rp<\EM\1110261\1087553\STX#\a[E\ETX#\30865\162265\3392eJ " @@ -36,7 +36,7 @@ testObject_EmailUpdate_provider_2 = testObject_EmailUpdate_provider_3 :: EmailUpdate testObject_EmailUpdate_provider_3 = EmailUpdate - { euEmail = + { email = Email { emailLocal = "1[Z\68778\r\35821\&3\1087344|u\996796\167850\GS \1071086" @@ -157,7 +157,7 @@ testObject_EmailUpdate_provider_19 = testObject_EmailUpdate_provider_20 :: EmailUpdate testObject_EmailUpdate_provider_20 = EmailUpdate - { euEmail = + { email = Email { emailLocal = "o\SOH\1002138\aLL$\SO\65490\1099895l*p\984607\SUB", emailDomain = "q\30683\DC3\12589\1001477\1015970q\1002402\145416\1056480&^\176848Z" diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_conversation.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_conversation.hs index 12cc9c7b83e..fbe3ab3e5ae 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_conversation.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_conversation.hs @@ -147,6 +147,7 @@ testObject_Event_conversation_9 = evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, evtData = EdMembersLeave + EdReasonLeft ( QualifiedUserIdList { qualifiedUserIdList = [ Qualified {qUnqualified = Id (fromJust (UUID.fromString "2126ea99-ca79-43ea-ad99-a59616468e8e")), qDomain = Domain {_domainText = "ow8i3fhr.v"}}, diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_user.hs index 45df7103162..b6ffdeea2ef 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/Event_user.hs @@ -148,7 +148,7 @@ testObject_Event_user_8 = cnvMetadata = ConversationMetadata { cnvmType = RegularConv, - cnvmCreator = Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000200000001")), + cnvmCreator = Just (Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000200000001"))), cnvmAccess = [InviteAccess, PrivateAccess, LinkAccess, InviteAccess, InviteAccess, InviteAccess, LinkAccess], cnvmAccessRoles = Set.fromList [TeamMemberAccessRole, GuestAccessRole, ServiceAccessRole], @@ -236,6 +236,7 @@ testObject_Event_user_11 = (Qualified (Id (fromJust (UUID.fromString "000043a6-0000-1627-0000-490300002017"))) (Domain "faraway.example.com")) (read "1864-04-12 01:28:25.705 UTC") ( EdMembersLeave + EdReasonLeft ( QualifiedUserIdList { qualifiedUserIdList = [ Qualified (Id (fromJust (UUID.fromString "00003fab-0000-40b8-0000-3b0c000014ef"))) (Domain "faraway.example.com"), diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/NewClient_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/NewClient_user.hs index 177601cecfa..17b3893bef6 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/NewClient_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/NewClient_user.hs @@ -26,7 +26,7 @@ import Data.Range (unsafeRange) import Data.Set qualified as Set import Data.Text.Ascii (AsciiChars (validate)) import Imports (Maybe (Just, Nothing), fromRight, mempty, undefined) -import Wire.API.MLS.Credential +import Wire.API.MLS.CipherSuite import Wire.API.User.Auth (CookieLabel (CookieLabel, cookieLabelText)) import Wire.API.User.Client import Wire.API.User.Client.Prekey diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/NewConv_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/NewConv_user.hs index 519ec147e7b..654575f9943 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/NewConv_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/NewConv_user.hs @@ -26,8 +26,8 @@ import Data.Set qualified as Set (fromList) import Data.UUID qualified as UUID (fromString) import Imports import Wire.API.Conversation -import Wire.API.Conversation.Protocol import Wire.API.Conversation.Role +import Wire.API.User testDomain :: Domain testDomain = Domain "testdomain.example.com" @@ -52,7 +52,7 @@ testObject_NewConv_user_1 = newConvMessageTimer = Just (Ms {ms = 3320987366258987}), newConvReceiptMode = Just (ReceiptMode {unReceiptMode = 1}), newConvUsersRole = fromJust (parseRoleName "8tp2gs7b6"), - newConvProtocol = ProtocolProteusTag + newConvProtocol = BaseProtocolProteusTag } testObject_NewConv_user_3 :: NewConv @@ -71,5 +71,5 @@ testObject_NewConv_user_3 = ( parseRoleName "y3otpiwu615lvvccxsq0315jj75jquw01flhtuf49t6mzfurvwe3_sh51f4s257e2x47zo85rif_xyiyfldpan3g4r6zr35rbwnzm0k" ), - newConvProtocol = ProtocolMLSTag + newConvProtocol = BaseProtocolMLSTag } diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/PasswordChange_provider.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/PasswordChange_provider.hs index 605e1e76eaf..092515c8c0d 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/PasswordChange_provider.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/PasswordChange_provider.hs @@ -23,10 +23,10 @@ import Wire.API.Provider (PasswordChange (..)) testObject_PasswordChange_provider_1 :: PasswordChange testObject_PasswordChange_provider_1 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe "\RS\148930<7jc~MQ\SOH\US\7333[\1084132Lz\US\1022735?q'p\1099657\ETBy\DLE\EOT\FS\1107730x\ETB$\1060369\DLE\61681\13692\993364\b9\FSiU\NULz\fb\22561bP\60643f\SO^\\\1008115\NUL\ESC\STXd\DLE>\1040220\1103806\SYNOIc\189228l^l]\1031063JY8J\1036381t\70171AcI\SIi]Bh\989297\ESC\140714R\r\NULz\ACK\1088597' A\DEL\\feuBiG\993059SDoWa\DLE\ACKW'\ESC!\"erY7Y`\\I\4948\"`y)\1045668iN)Z\1012930T5Q\1076971\147595\993658g|)\US\1003237\ETXJ\39701\1106744\NAKY_\a\vJL\1027083\CAN P\ETB\1078145+%.BF\153802Ay9M\98346\1008748\1104393s\25042pI\1005219_\1002925\rM\CAN\39386EZ\58119ZXS\1105928\RS5\DC4b\97371\SOH\1106103\184422b\100457\\X\STX\144554;l\49694xQo\NAK\138070\SOHJ\989399XE0^&\167968\n_1\USS\DEL^\SO?W9~\1099319\DC3=\DC2\1068564@*\155081yLc\ACK9\15796\1059875\RS\98699bV\1105827W\1005933o\1072486\FS\1000032Bmr>\1053735\1030072\157751\1056352m\1072516\142673G\STX'\1013038\&3K\"%Hk]\61616f\178900\RS[\ACK\1112290\DELvq7n\146589}F*m\DC2\ETX\1038640w$R?%\1048405\991130T%\1086216o\DC1\139752G\1070667\SYN|\1085639\111068\1068350B\a\ESC\1063604\1088194T\1019860@\SOH,1\1094852?\DC3\ENQ,)ipt\146004NXe\DELd\1015278\DC2 6\984739\1037131\&14\31204p\f\23571\134629\60442q|\EOTh!\DC4z\GS\NAK\1046271\RS0\120694\ACK\b\DC1\1057519\ENQN\22139P\139372?\",\955a\ETB\f\ESC+\"JpO\39005\1043690\54002\ETB\ENQ\1093734\CAN\1005666\SOH\DLE\DEL\2414I\55278'=I\EOT\62705N \SO{Vtdy\DEL\"\163827\37015hC,\1053062i\NAK!o\SUB\1075233(u&\fz\1049825\ETBA\1045850\175742\RS\US~A\40004:D-\ESC~A:p^\1079564\135140:\151246w\1025133m\61429>\6832:.M\1073891\10252'\CAN\1071280\24496R\1003679P\1024693*\SYNq\GSUX\1072603|\r\US.\1062109\NAK\1109389W~5O\1000925\STX\184929s\1006565\150194\DC1r\SUB\b\1057499ZN\SUB\rf\20741\SOH5\GSJe\66771\1067879b:z\SI!\DLE\ESC\t\NAKd\v@'C/6\DC4h\983790@", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "l\992017jfd]\CANOG\1037448\9679E-FG\DELb\DC3c\168198\179584\r\EM\98126~\tW+\ACK\33446,\1082952M\128295s\1000699M'\DEL\STX\SIz\ESC}\EM\154156\CAN~;k\53216t\ETXx\149224\132305y\96580\ESC\DEL\142174#O\r\bJacD\GS}}M\n\aY\1033126\&01C\EM$E/\NAK-B\175854I\1023972\1070217\6129\1013299\184147\&83$E\1009863\22083\&0\SOn[T\GSB\DLE-\1007136cZ\46079\174945\38508\985022\173232\ESC7\1110907'/2\9477B$SL\NUL^\EM2\NAKG\1093469\&2H\ACK\DLE\DEL\43539\58839XR\1080271h,\78748\174767\1054239\1041868Y?\SOH8DF\EOTCh#;/x\DLE-\EM\bA\DLE\159735\bkX\SO\188157g\94522\172555\NUL:2<\166889\998353\1083925zp\SI#\1022316\48223\NUL\ESC\141660\1089351T\27451\&0bA\7868\v\v$&qMQD\994988\12182}\SOZM\ETX\179973.\ENQ\21913\58375\47428\191199f=j{\47820\1111986\1073477\100913\61587\1073940@\ESC!u\1077743R\7637EJ\1016579\1016763\DLE\1058455\FS_@\1076367V\1005325j1Z\NUL\1004454>t6\1079007\ENQ\1084309$\SO\DC3\98064\137557\47918M\ACK\ETB\172727r\b\1100397)\SOw\ETX3\1010263\DC4\1066921w5>\1035509\96260Ga\USz\1047694n\SUB>\t'\92445\r\148219 \1025075;(4!\1073109,\",\"x\EOTyf \f\aRW'z\133849\1048674\24036\ETXYx%\1080586\42394\1045626n\13335[/'\"2-2jR{=\1012515E;\1026542\SO0+\142703H\987291\24296@\1106752%\SUBNnZVt-k1\DC2\155707bTV\DC1k\1003798a\1071366\DEL(%*h#\1030597\SUBL\STXa" } @@ -34,10 +34,10 @@ testObject_PasswordChange_provider_1 = testObject_PasswordChange_provider_2 :: PasswordChange testObject_PasswordChange_provider_2 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe "J6\1063462\\\ETB\1078893\993390\ru.# ?_\DC4=\1064091E\a\1053958-\150000L\1061533\&3\1048772`'\DC2I\SI\31152,0K\NUL\\G}];\187087\RS\176174d\136667\1076870\t\1047442\48335.\152068\15337{\70067a\EM{9W\1063171\SO}'U\SYNB0a\SOHI\EM:^~\DC1\146030]\ETX\1068275E\ENQ\EOT\SUB\GS\38515-,d\1087008\146851:\1009309\DLE3\nn*4U8\f\f/\164529_Jq<\23032j\163500*e0\152734\DC3\58971v{'\57450^3;\EOT&yY\NUL~\194865\94514ct\33770o}\1015771\1008056?Y/<\SOH\1076816iJ\62386V$\\\CANd\CAN?\DC3n}\1001195\t\1017145\139912\1110640\r\EM&C\FS(A!Ke\DLEV]iB\136999\137089]\173378~\995379\&9\NAK/sY\FS5\165215\142850\&3\1074963\ETBX\DC2\92297\nzeKE\\\v3\128136\r\ETX\n\1110227Mr:M:\n-#'L\142407\121103\&7eo\57512\1043763!L$#", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "\STX5{IYj.N\1097942\1034521]}l~\ETB+\DC2XU\51470pB\121102\25755\ACKH)V\ENQ{\DEL}\ACK\SOH\991933\SO~YZ\1000116`vCT\CANU\3236<\NUL\ETB\1097339\SO\SOHgfBz\NUL\DEL\27119V+q\n\53764 \1025256\FSG\145697\184862~+]A\97342\70080N4r3ly$\EOT\1088639Mv\1002336\1050997$\CAN\DC4\23032\ETX\a\SOi+L\1016399Q4\99029c\1019418\EM>z?'\CANO\SOH\77963l2\SI-nZ\\\NAK\187610\&7'VHQ\ACKf\182917\US\1083627H.1\US*\SI\35903\SYNw\1090044\EOTR(Z\165917\1084964t\1085428\ETXV\184675\b\RSbU>\986822\NUL\DLEZ)X\137883\22578*n\1035455a\RS\SIvZ\CAN\ACKe?eB\998053\991381~\CAN(\ESC\a\1007645;\121445q\1042993\1070820{\SYNk\GSA\DLE@8#sG\1095900O\78803oKfrR*\SI)'\22262L\35087I\1030025;LR\988091\STX\917885\93968b\1033563\986558\&3SJ|\61654E\54642(}\985223J+\\ED\187753\182558\987551\&7*6\ETX9V5mm79s\b<5\146014\ETXe\1070748\\=1|>J \DLE\DC1#\1036249\174789\a\119052\DC4\ENQ\7214A\1057521\991526\ETB\1096433\159048=B\DLE\b\EOT\99076mR7\GS\\_{||o\191444)\1077352SJA5" } @@ -45,10 +45,10 @@ testObject_PasswordChange_provider_2 = testObject_PasswordChange_provider_3 :: PasswordChange testObject_PasswordChange_provider_3 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe "\1090639\STX\132492\SI/\183791\DEL|9\\\b_\1111257E\18560\1026473\37179O-9\SYN1L\11112\153612SYM6`XP\185928x\1091969BP\SIV\1094629\1099550\&3Dt\1051594 \185390\127986\STX\993509\1072672\\P\182591\ACK5\v6\SIW;\178969\&1\nE:[\182420\96819a>\1066339\17724rv\12096OvI'-\1062972\166884\ETX\SO\SOu{\999818\164786\"_\984751~_u\1023831L\RS?\1032751L\97379m\1012949\b\189539H\DC3\3277Zd8\CAN\173661j\STX\59973\STXU\b\1058747\EOT\39158a(\ESC\NAK\"\ETBc_?95\1048328\r\SYNpa\NAK\b\SI!\DLE;\GSI\b@\20755D\1058888 \DLE}f\1025872\989393*\\\DLE\DC4l4sG#{\175474O~3!n\1070630\1046460`X>n\r%\vR%\12500\GS\SO\b \NAKJ\FSe6y:\DEL)T!\163863\19105(p\STX\ETXS\1035778g.v7\EOTU\\\ESC{9\1029424\&7\1096509\182402 \11429`3\v'\1011010\6241H\SUB\67713`\1102842\SI\ENQ\36671XSL\5184\74631i6*H[`bk\187634DB|\1082864tF\STX\1095470\40232\24100W$\DEL\SYN|AjF\SYN\1060698\ETX\1002438\151035s)$.U++'\n\DC3C\1092666.k\ESCgAo?,\1098414\SUB(sBt\994422X\51349C!\1079241<\1003009)5\147358[\25065\RS\993654\988949~(8:\1099034\94463\67647M\NAK\STXFs\\\162758u|~\DC22@D\39863?L\SOH\EOT\CAN\78252_\95345+\b\1047730)f\DC3\147188r\SYN+=8\17990+\131480k\n\1004620kv\ENQ%zD\1087067Q\EOT\DEL<\DC4U\EOT\98368$\CANw<+\125229*\171804/\az[q[\DLE\ACK\132467d\bv\DC1\DC1{Q]\155471\n\rokp\CAN\DLE\180903\EOTNn\147253.!\63250\65540z&|\983968W\164923\1015875\47406\r%B\NUL0\62411\58998\989796jn\EOTg1\1030731U|\1069001.Z\147615-\DLEUdT\SOHP\ENQr:\993057@J\172264r+\22908f\189795\1008819\12565\1059459?!rR\184591\1059540d\1010396\153681\1087402\ETBms\157686SQ\SO\1013566\159622\"S8aV\t\ETX\69642\NUL\1018708\GSj>0!f\1048850\172491d<\1090475c-!+\v\157251\USB\CAN0u\f\1109289@n\US=\EOT&;\FS\1094127\147561Sc1\tkL\f\ETB.V.m\1102645NZ\1025114\171053=\9900w\SI=p)\983196\110627\n*\EOT\4264S\142455c`h\1064905\CAN\DC1}U\16412\RS(.\ETXBQ\RS\SI|2\DC2\t\58697\26979J\1059222\t\n:1\1076824\DC4\20071\DEL\\?AkiJ&\ax^0\t\RS\1008082\145465'\vEB*\n\133263C;EZh*\1096287\v\172007\&27nR\1030319'7\1067756\ETBV\1096588\SIC\25035\NUL\ACKcl\1015672a\DC2\1054753_X3\1087036\&3\174910mvT&\v`r>\1008758_.\28801\DELo*\USR@!g\5064\DELo6'\61374~\11251\1055297Rv\96087", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "\ACKb\65690.z\DELS\RS\1043455|@ou6\SI)^#Nv\173721\nK (\49735\"+\180317r\168250hgU_\NAK>.~\42075\EM\DC3rNA\1003405\1064814O\1080126f\DEL\170710@Mj\DC1\rd6\157492)<\NULZN\STX\EOT]\164074%Ki\ETX;\1074300\&1lkk:Mk\1032789{\1085268Hq\1095137\991423[\1046704\1069727Y;(\DEL<<\NAKGs *\1064528\SYNGz+\r_f\162493\EME\179877\SOHo\GSA%'+v\145346:lQ\183770\120729\1008624\54333~\46645a\18566h\RS\nEX=*~\b\38738\DELk\47265\26012Y,r=\SOH2\12321\&9\FS\f\61703\146118\1101857\41261_\177617~\150584\1101236rA\1057119JA\\~\t\67201\GSrIk%3\179007%\EOT=%\13237\1079931b\FS\1080016`}\SI\167993`@\NAK\1037573\f\179386{\984491b\DC4\191263\SUB`\tV\ETX$\34881\DC2FH\ETX4E\128495\1074641\155862\f SL\176565{A&\FS@\DC3q~p78<\1029510t\1072174&&\1064641W\62128T\148445gn^K\1032060\DEL\1073914TK-\1032280t\69863*B\NAK\ACK}G3W\119556aj\135982\&0\35872\48740O1\EOT[o>\SYN5w\GS\tEd\ESC.}\27602\&1{'\1023415\1007968a|\am\181403\bu\DELo5$07\tK\1101735*\t\bv@F\ACK\ENQrQx\RS\52315\r\f\18064\60859\1043018\42814\1082068\16599p,*\RS\DLE6\SUBH_\994350\SIi\145754\17091\1001085\NULUq\176242\\\1081511[j\1020996PN1\DC15+!\1067420.\34561+7mLuRk\1036698)N)dlu.^\1109734\EOTzU\1019326#3\1055275V/\SUB6^\DLEq\60060\153909j1\n\ENQ\143934\DLEC\1094908\174654w)\SI\1095019\17787\155204\\y0/@_\1036276\\;\1044245U+\":|\1008444L\\" } @@ -56,10 +56,10 @@ testObject_PasswordChange_provider_3 = testObject_PasswordChange_provider_4 :: PasswordChange testObject_PasswordChange_provider_4 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe "\1005314~=N\SI\SOH\a alq\ENQ\ESCj\163982U\133814*f\"\72875\SI\1015050\1072188\52409\177675M@0\\\1060741s\b\131914\&4\NAK{\EOT\125006\ESC7\1042522\n\DC3\ACK\41709\CAN\DEL&\RS\984654\DC2\1039379\GS\NAKbHZNbM,\53068\EOT\SOH\1086193Qw>56\NAKF\"Q6\USp0\ACK_\1071681\22914\GS+\1113265\1033881\&1XL\1057692\r\b,l\1090712UrQ3) \DLE\1020313I{\ENQ\168366\v&\STX\182716k\1077895zs\1099425 \120690G(gf\1060305O\38802\n\1054049\1065585j>|Kg{.V\163868\ENQL\131757[\1096643@;=zg\134436\183794\158027biF\132561\&6ZPA\n\135254\t\1032483\NAK0\CAN\SO\SOHaE\1060005\EOT}Ys )\STX5\172564\SOH\1025776\1058126\1057981Tx#\r\b\DC1Lc5#\1035228Y\1093589/B-Sv\159168\161208\n\1031751S\39534\f\r-\167002Gq:;\168477\&4T\96990\917580,ST\FS\1079935&c\ETB@+\180969\DC1\NULd\167999\143044\CANP\1021550\988126\1020951\1108300z\ACKU\SUB?UV\148371p5\161618D\f\USo\165498#x#_\1054438\991493H\1053912\1101113$m\1108341,$\1028517(\1361\EM\FSr{(\DC2\36604:Hr02Z\CANj\EOT_C\RS\SIt\143715{(-\f\184102\&2q\r\r\155913Z\1042726Ko\t\ENQY\1105826}\a7h\40363\&39\DC1\40241}1P L\nj\GS\123621'\94253Q\1094248%n\144018\"$R'S=W\EOT\nr\v%O_\187746Pz(&v\t8V!\150217O\1087987\1109209G\120191'~q\6433b;\b\33127\&6\6978XNr\ENQ~K\DC1\184383O\STX\43136\186449q,~A\STX\995391f=JwT\f-Z0\DC1\STXJ\135448\SYN)\t\28369\989463oI`'s\aG.ggB\SUB\1089552s\7042Iv\995920f\DC4.NGl\167789\SUB6\ESC\SOfJpG2\ESC\151792J\165772\1060235\RS9\164444@pMICAP\a\STX*_\41597^e(M\EOT\a\GSuYl\1027529\\*\ETB\1000487GSnZO\184505\a\151353Do\185571S,\n\ETX[\1024125\12994%\1048335\158640\b$fm\DELJYdZSTOY\1011389\1000717\22006K-~n\101099Us<\63668\42529r\SOH{\1001552}\1035280\143766-\SI\191007\&0\30696\1067137\1100998y\34996\&3\1012120,d\t{\1060327\1023821H\EOTV.\CANu\1087782A\78628r", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "=:kk\1018981\&8-\\HYms@'?v\ESC\133198\147317\t\1008759.\v\1033547R\DEL\1060763\DC1Z1\95927\CANSmD\143932\1024564\ENQ\FS\"Z\1030825@n?zS\119833\DLE\988733bz\189538\1082413\SYNSv1(\132603\rQ\EM\f\149020'.9\SI^|v`\a\1039850+\DLElBv \RS\23734\35305\1109565\DC2\bJm\1085701\1095232?_rXd\1019687T\137292\STX\147778VK0\134410H?.H3H|\1089668\&5\1020896\RStKw1k\1046876e$A\1046587\66888\1106484w\22514\aRYT\1048339T{|!\1076458\50629;*\1111661\169350\1009115\1100512\&6B^\36743Hbz\DLEK.w\STX]m\1105522\3984`3@\1035182/\113800Y#\1048181{X\74883?^aYz)\STX:$\148676\nd:\"Q\FS1\1083955\DC4}s*}%^CWY\SOH5w<\NULla\65202\1015084\STXa1\1073755L]\GS\ETBV8\SUB\133836\1011042rS\152914\1109488}\SYNl}\1018153p?Yi\1111523\&7v}reh\993180w\DC3\DC1k\US`)\f\181331bSd\184792A6hx\1018142#n<~V-\987055h\143466LT\53862q\f7\SYN\\92\137339\ENQ\r\CANU\1071144%lYedK\vGHs?rN\177663\&9\99852\52785FV\22264\127076\154269`.\DELXv,\1085673\&4\r*\191239\135780\152535\&6/\EOT;V\1045100.Z\DC3\1073857\SYN%\ETBTVv\ACK\5241\&40c\STX\\:_T\49351\&0D=\138839\ESCg\SO2\16967\RS0\1009235b\n\SIe\NUL]=Ir\\[,;\ENQ\1109755E]\b3\1057051\100809c\NULya\1062732\NAK/t\US\r\DELS \1052333QQYH\53161\15320\EOT6\1045701pY-\EOT4\7658L\1039028\155730kB\172820\EM$,8\120808[\1087257DGgn@\ACK\16782+@\169718[\teJf$G/B\8949\\&k)bT<\1074663iE2h`\189858&p2\DC3r4g*\1040011M\SUB\1034202.\14977\25151\994175\1080867\r\156624\&6V\163857?Z\7344(\SOHF(z\1085772\EM@r.\1041715\EMBi_\1023342!I\DLEj\1069951\NULHy?\SYN\1024843R\1061663J4kQ\DC4\SYN`1\SOHNnp\167659\ETB\36188\ACK6p[.q\DLEZBF\\\b\NUL|\f,\989077\161119\150164z1\187508M\DC4!\1041102\DC1\1034211c~\1085907\DC3\1085359V\1024822\SUBJ/#'U4t\119160\1101637\ETXW]\EM\1003131V\FS},\1018388z\28107nOj<%7\SIk^\ETXiZ^*Fz\STX\\g.\np\DLE4o\NAK\DEL\DC1,\1048642\&4L\EOT\DC3]\83046[\DC3y\1111455\RS\ETB0L\US;\\\161083\1038455\165809VOG\97134 R/\1068148\136637J\1008468\SOi\31819Qm9\DC3P\178289\1062875m;\33449\151544\1112165g%\119045\ETX\SOH\9817U\ETB9\63207M\68250\EOT\12530\&3:1\8937s\ETB\t\1073799\160211\1013614\99221Ycje\US\1021337\bC:S\EM\DC3\EM\1080393\&0;Y\1006472unM#\ENQs3(\1012482\ETB\SUB$\1108830\SUB\SO\ESC\1011341\&9xk#\"\1107050o\152443`leDo?\EOT\166427'\178290\1083767KWa\SI\983521\&5N\1062943\ETX*\1069873E\95635\49809B0~\rG\146763Pkj\134528C\994565\1112409\FS\77994\DLE\DLE\1083005G\1079213q\1078280\SO)\SO`\1044446\r,\GS;\141525\13757\USO5\184789\1024674\1052970a\SUB5G\DEL\vN)\1061733\SIi\EOT\1053143`\181217\RS]VL\b\1013194\&0\a\EOT\1039988W\51437c\SO\1065070*av\98257'\170757 ;c\996012.\131792~\DEL\4838\182631\1055951a\135094I\ACK\1052557\f{\1072125\&1\27594Jd\1063195|\8005\SOH\1024659\EM\ESC8\120279\986941,HPp\3786\ETX=\135249\SOHD.\DC4\GSwi\1040647\CAN\SOH;\1021350!|^\DC1\ETX\ACK\1063454~y<8\1011154=|\134143q\f\FSE\149852\bnT\25647I\NAK\STXYB\1111567\1092081Gp)\1057864\STXj\EOTBi\1015288\72231\1105732\vzO\EOT)\1021734IRz\1036141Ty[\SOtj\994518q\ESC\DC1\ENQA\78643`\1033140\SUB\1086534\119199pUM\74032U5\SOE>i\DC4\1026198H\r\f\a\ETX\SYN\EM#\STX0m[\DC4Y\ETX\nY\1041053\1032715\&2xROJP_\1069998\r[\139994o\1038593\1022439-\CAN\FS\1053876\162419\GSI\1114021\1099881\DC2\ENQ=\DC1]\14160\178652ee\NUL3\165586CA$\1096608>,Sc\"\DC24$\98460\US\1104391|\148368vh\EM\b\1110174\SOH\51262EC[7Kh\n\26878\1037105qWk\142931\NULQ7i~gM\RSp\r^\nJy-[\41948\v\1088158!\164120\&4\DC4\41370\1083111\1100437\1027623gln%\r7q2\8168\DC2!\EM\NAK\175295Tl6\1071902\STX\38040+mo`VuL!\n7<*\\\157558>\68039\64688\RS\CAN\37133\ENQ#\DC1zi(3OU\ESC\NAK\ESC%\137946\1049584[8.G\1111459-E\1110194\1084255\1058892\v\1064396\1062440\&9M\99681v\SYNl=\US\15311\1047155&\1053601\ESC\SOH\1047114\1071949\172567$H\n1Y\51322\CANLg\47625\ETX(;\GS\61177lZP|E\ACK-8\GS~\ENQ\v\189891\1107362zv\bQ\USbkv\94908*S\DLEs\1075777B`]\1046292\SO\52998I:\65296D\167913\r\37306\182476P-wN\173628\RS\bD4WD?\63663\SO\1113823\1023204\149429\fI6,6h\b\1004711nP9!G\27578-\DLE.\SYNF[\160877%Q\1097530^\STXH\157909}\v\US:hx{\1038469+\1090842|\1014387M\DEL\\;G\28870\1101783\48530\EM\SO\162503zq\r%\SYNCUS!+%{\127862'w\996607q\1104160^!XSCAa[N'Vm\DC1\DC4~\189916P+w\164548\5708#LI-k\118975V!\121316\1113106md:\SUB\t\FS4\1004433z\1078080Zg:^\NUL\995376Qs\184644o\1095386\SOH\158723\EOT\1021483lPb\nBT@}\2545'&e\NUL\1065941,X0\135225c\tu\CAN|`4\1020041t*wK\DC3\f\25439RD\b\SYNGZ\1006639g0F^n\f\1105456R>f\1100409\1100823\\;`\SUBoh9\ACK\DC3\1071927K\v\11722\"\1060736\DEL\99248\GS\1040422\8236h\194957\23896+T{\52879\1008639\ETB\57964\987068\&6\DC4\998395\&99\SO\1098197\1097876\STXR\1090815\EMQb\165117a|c\150904~\FS\NUL\132829\DC3\15008V\DLE7K\167075\DC16=E\ETX\fTv\1034496<;\rLEGuZY\118839iNm\SYNJ\rI\190474\&2\1095050lI\ENQ\GS\1034351\63865\STXaPo\FS=8DtkQe\SO\147814\&0vQm\153309\1071911x\128401\164053\1008099$o-(t\DC2z\"AQ\1020511e;\SI\70124C\ETBH\29202#\1074721nCh'#\1094035'\1064442\35450Nx5=\37407\177998\94806\49674\\Y\35646Bec\1095406\1051005\DC1r|*\EM\38243}.A~\1079182\1042143\ETB\SI{Pt\1011810\ESCS8\160032\ESC\22627\SI\153862h\998542dZLu A\7299\149281+jc\1513<^\157390\DC4:\1083899\f\1031499]\bl\1036256\128520\38650d\1056973\DC2v\1044284\987395r\ESC#R\1022711Xr\27081\20760R|f\1092090?\1013931+\ACK\1107788M\CAN\1020010;\USJ\DC4\1012811\1028415>\1053853\STXj_)cWt B\18936f\1012599p-\vJ8\51800m7\167922R\NUL\171175\1057562\STXk\1020080(9\DC3%x\34431\DC2}(dMC1\ESC[", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "\131784\138514\157899#Jk\r\1106537K\DC4RP~\ESC\150747\1093719\NUL_\1070253F\EOT*a\RS3we+u\163806\SYN[\183120BlK\n\FSF\DC3\SI\1031201\51899\1091000\1006948y\b;\83374$=\EOT$\100771\tJvs\155623\&2H\1097133<3\49632\18894\&7&L'W\169743\&9\1100463\1016241\DC3\EM1\998556V\DC3Spzv\rZ\98169M\rg\60865d\r}\1017655\6434" } @@ -89,10 +89,10 @@ testObject_PasswordChange_provider_6 = testObject_PasswordChange_provider_7 :: PasswordChange testObject_PasswordChange_provider_7 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe "o\194642\1097637\NAK\1096957q;\40241\78060@G7os\ACK&\NUL\rb\1064652\r!\v\EMh\SIf\1009912\FS\1111085\994910/m\1095852zr\21631\&70p\ESCxcNP;>ah\1038533\1088598?s\DC2\161084+l=\SYNtKk\1112851\NAK<%Wy\DC2\rdO\"wl\r\1073877&P[ x\EOT4\1070910\94261k\110789\EOTO\2408CJ\FSh\CAN,j\FS\1051419\&8\145785\EMDM=?\1044107rN'uaGQD\DC3c\DC1\SYN\50022\SYN\SI\191191\23297_\f\f}(lT\1001335-\1102617\1055091an\ETX\",S#\182773\ETXhx\ETX\"S\29957\133313\44208$Gpk\135573\74317\28585]Dx\SOVr]\1106090\15330\EMt][\190740e#B ;#\RSwWZ`'-K\1031198\1079899\149986'G:I<\ESCgN79G5\1076698\134552\1001620\&4k\178721Z\61166\ENQ\DC2A(@ScQ\1036811\RSS.\1083926?&5\59387\&4N/S\162222\1084995\&74w\171315\176694\31631\132086\f0l\vn\1041562\&22.\995582\162439\178300\DLE\CAN\GS?!\SI\19976usZ8sJD\1016223A]ExUW\18012tdEm\160005{7\b{\ETB<\DEL.\187515\26357\1022998!<\\\ESCGPS%b\1101700\13570\EOT_X6\\a~u.E,\145264\151278B,\1110132\178446!\146205}j\DELw\1094539wAOIN\74851suE\1011582LH\50805\1075175.8g,4\b\994127\158463A\STXO\CANYgM_<\134150`2t\5592/\184130\ESC\1025744\a\EOT@\t\n6\SYN\1051619\153044\US\24861\NAKqOwDI;\158935\STX\142163T\153718\EOT#EZVVq\nj*w\1099335*\SI@[5\1010626\n\DC4\\1\DC4\DEL[\CAN}\NULBUTcW\DLE;-D-\GS[\EOT8o/\26515\b\FSU\DC4}\\\140030~\SOH.%r\24914\33259\ESC=aoY\121055z\135293\180565t:\18518NUy\986819o\v%\1031392\EOT_\1056629\990992Vv\96494\1073204>%E@H\t\171158*\1055587W\131453U#p\ACK:\1001700{?.Dv'\US4VZR\140754\138375\14865\ACK=\1010074\a?\DC3\DC1\1104713F\1094183%\NUL\11085\NUL\119900\50952\1091734\1096788Z\131351\1071405K\STX$H\b\25145\SYN\137614}Uu>\1059256QJ}\1092477c\1005961f?\1098417dP\ESC\1112602eA\f3q\EOT\DC3\1085835\SUBP\DEL$\\\1043723\148046x\EM$J^ I[\ACKgl@k9\14259oq8\60943f%\"J\1057317\73689\1041929t\SO\fi\NUL'tD38k\ESC*z\v\DC4'\38081i2}}K\1000483\NAK ]=\GSzA\SI\STX\GS\65784@5\DEL\32084\RSJ'I\1061395\ENQC\132576k\36936Bde\132392S_]e\22951K\STXh\1080765\&9w\1031828\1079907\"\1075875x]\v\"\1004384\1034557\a\13954X+!S\188785k9\1336\STXL*Z\992108E\988071E\157741\1059002\49383l&M\78548\68781\1059405\&1\1024148\98156\&4JY\vb\1009588\CAN\1012372Xqx\DC4\STX\rW\3001RRPX\GS\RS^m\58278\a\1082402\US\990568QD\1066036\EOTAzaVl\NUL0[[\997199\992592SJ\100209Io K\US\SI?'.\100329Zv\26227\183271\&9?C\\\SUB\1055968\&5x{", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "_90\1017274=\175182\\eIq\EM\29319@\US\ESC\USr\GS{\bq\SYNX{k\998765\1113634#>\v\127239\\L4z^\47811+N\1113429\STX?9C!^\1072965\155787\1051243f\n\189595\DLE\a\ENQ\1019894\50928&\rJw\CANZE\178133jc\ETBb%\50684va\11406<\US*\77953\&9?P\f" } @@ -100,10 +100,10 @@ testObject_PasswordChange_provider_7 = testObject_PasswordChange_provider_8 :: PasswordChange testObject_PasswordChange_provider_8 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe "\180818[@\EOTO\EOTr\t0h\154811\1097619Ls,\RS\177254\1001237\US\53799!\174182i\33534gi\12980Ul\DC2c\"Q!h\1078688\ETB\32168j<\143648\SYN8\120997\&3z\1109784\140076\8229\EML\DC4)L\1086339hE\FSYD v\60832\&3:\b\127281~\t\DC2|\NUL\STX>\1037988\&9\52889\ETB%2TZ\1057438\1019124\34595Ba\36978\&5\n\f\66575M\DEL'.HktRZK\US\b\1045482\SUBQfa:NA.(\az\DEL\131940Jviu\SYNt\141599$5b];W\SUBPM\1063367\1063525\135883,\17207W2L~_\DC2\6631@DJ;|\DELa\ESC.h\1052121\1098974\1033911p\1087765\EM^\t1X<15#N\1026411\1084279\&8G!R\147770\&2t;\ETB\ESC\1064735d~\v\NUL\1102025\DC4B1T\"\1109782}x;!no\r\1106009\RSt\18334uj\EM#r.W'}@b)\STX\162952i\SYN\167204Y\NAK g.xl\63576L\1084858\&5\186390\182838\990328\th\DC14\26177Np\ENQRLe\1082057\&5k\SOH\997985\1062349D;KY\189276A|\ETX&t:7$\ESC\NAKX\176629&\ETB,S\SI\18829\29542K;\CAN\184587,RWVP\RS6C\24675\a\187635\992522\154218\41884\b\STX\ENQX\9568$\SYNK", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "x\1083148\ETX+:\1080028wSH\SOH\62978\1102729N6\182762a.$\992539\120747GK\158987%\136043\DC2S\1037479Ym\1008949\NUL_Fe\GS\n4\990156\39344\1010528\&9\t\ESCEZ\"\aE\SIO]\1045645\1079319mZv\14455\&4Ie\166474iF$\n5\EM\ACK\1075904\1113583F\SOHG|\STX4g\1002980XHN\989865?\1099099\173477\&3\50673\83417\11655\1099379B\ETB1\r\1013634Tn[\EOT$&}'}\141322\&3_m\1077163W\3968Pt\ESC\1071158\&6f\1068159=\183495:\1036808\DLE/p\1004364:\r\1085290\ETB9`\1080065\984968h\1027137kOs7\NUL(t\10489\1068176,G@C\63384\1024509\186856\190455B\1113082\SUB\CANQjW\1008166~U(\1009483\&5\1109177\20348!\1077035E\1005&Mj.(\DC3y\152919\172701\ETX8p)'1ep\n~&{\62654\167895\DC2*7\990132\DEL\185308<\DEL\SI\1098650)j-abs\CAN\GSL\25312\DC3Pl\51906\&2`$bh\SYN_\1086252+>h\59671#\1056101RE\"{n.wh\n\64038\154124c\1069890\GS\170451\1076325N#I\1062645\nX\n3S0+\tr\CAN\39868\t9\1031811`/\1019167\1036273(N\57792\ACKD5\1096310a5\DC2Tf=}\ETB\1108347_yHue0\n\1092905\1099428H\b'\1091583T+:\183409${\1057811\GSE0\RS\1043155ly\SIobfRk\ENQ\RS\145619h`\\\95626\NUL>rV1\ETBfXk=cyaI\EOT\ETX!_\CAN\26741xR\DC4\1038343<%\1073241,`TXaz@^\\^s\180706Y\NUL\1081447\156985\DC2c\EOT&\n\DC3S\44890\NULE@\13815\&0`B\t\1039059N\CAN\32617\167086\999897\34753B\149257\USp[\SYN\186181\ETX\1040852=;\ENQ+$\a#\1020966\ACKc<\1106724\NAK\ACK\CAN#\41741\66650\DC2^\f\1089620!\ENQ\ACKC\SUB+\NAK\1070506f}\SOH>\bz\184367-{\37662z\128698\191437G\ENQ\n\1036769O\1112827\ENQPs}T'q\1049540\1059171+\ESCW9U\ETB1m\ETX\1044364\1110248\1011325\1077049\\\1070234}z^f\v8p\58049u\DC1Dn@7\SO\178338y'\t\CAN\DELX\138703\44901\111212Mz\1060998\&5\\\"\128701>\EOTNZdWO*\177619\DC3NV\1105635\44906vyM\45692\145400z\CAN\63310J\DC4\RS\ESC-FY]`k\DLE\DLE\GS\US4l\DC3(Ot\SOH1\156591\DC1Daok\131703\1053478u\1047598\RS=ES?\1105503v\119021\1077338\1108555\1105842!\EOTICQ\64082\167240\1027279\ETBu?%\1093608v/\47051e\DEL!M\bLA\SOHN\STXWi\1013467\176220*\aU+\SOAiO-(\n\7942m_\1015104khe^:\rQ\bZS?\1043829k\n8eh\984956\ETBU\146314k#]\ESC\32013\58442,\ETX\DC3.3\SI}\30711=8\NAK\1023884o`TI(\992144-\ENQUR?\152908\DLE\1110035\1106113?VYfS\EM\SUB\1095315\33553\1096655\ETXC\RS8j0\tKC\190493[_&\46172\1060818\SOl}:\r\SI8(x\135429?8\98588\&7)R\985918Q\ESC\ACK(z\SOH.\1107353\&2Y\US\SOH\1035764|\DEL|3&\DC3\94271Q'D{\NUL??\ETX7HDW\184522]`f\bPO\ENQx?\33111A\DC2|hP@;We\35075;\1057215R\ACK8\"$!A3.[\ag\997090\1017693.\STXI\1107916\1089611\19348U\983048Y\1008717)G\RSY\1107954\DC3+u/\DC2\DLE$\STXN\DLE\185464(>[\SIH\12867'KOC\DC4P\38328.\65734M\ns\ETXl\NAKj\DC4M\1046104=v\US\FS\1033829=p\157189s\SYN\DC4T\113713e\SIXhO\v0\SYN\159832\SOH>!\161626\n\RS\999549\135814\49229\1051757.\EOT'(,f8NT+J\1006984O\1062064\RS\187616\309D\152878\b\EMg\v^\22870\SYN\1026918\62565\ACKf", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "\47540\188511\&0\STX\SOHF!v\ENQ\11373 l[\SYN\137894\&4R\15581\&7\1083947rK\15414+\n\135750\1065844\SUB\afb'|\RS\"\995385\1090151\DC4\132765/\SOH\153829P\33605B=\72999\a1\1017925F\1051495k\ETXmF\1017174#\177930\148698b\168141ZG$\1112470dbB\SOH\983969\72724\EOT!\996099\&7\SI\1066289{\ETB\1024612\DC1\NAKSr$o\63124\&4<\163973q\1060394\NULXX\DC1;#%fM[dR\EM\1044817N\62150\139272\US\SI\1073067\985245d\RS\GS\DC2EbQ\191179l\1028785r`Tf\DEL\191144\&0\ACKHJ\1016730T6X\DC1cypE\ACKy \DC3\DC2\25565\SI7Wv\SI\1046192Z\vY\"\v\156204\DEL\1106419\995387/\DLE+D?\f;B\163188(\FSyI\1060531Zi\1051115~\993288XN\13032M\DLEPzB\US6\38727gj&L_\1061368\EMj9\1018863V8*Hf`?25\154173Y\SO!dS\1033424O'\1099719`\GS_T\1012344\48568\NAK\1052118\b 'ss\179793Ug\62366\ENQ\rQ:NVc\46684\ETX\147041$\33117K\97385]rNq\1088791\14261\&5g\1108158>i\1060212t@=Io/nm\ENQE{\1051318\v\181086Yk:\SYN9o\NUL\v\16507 [K%J\97955\fM-\1066437rvqm$^b9mkM\1039402\&2;\ETX2\1043146\SUBU\18461]\SOHD\ESCF\DC3<\CANu:\EM\174389\n\DLE\24984\142121yXK\1034045\52191\FSA\62973\&4(K,\168483\SO%gE/B1D\1107948\DC2\161658C\SYN\EOT\DLE\CAN9O5De\29644seu*9\DELk~B\ESC\DLE-\rT?t@\1000006\151230;6\ETB$\1003656\FS\1041307\637\1085577\1005683u\194601{>6\DC1E\48817kO\1071212\DEL60\183739\&9wk\177129\DC3\156315VX\1016207C\141727\"\155769N\153799\CANT\SI\STX\1049621\985530Rl1Qr\21745eC\GS\SYN2z\RSi\161367D6s1y\1095652F&\1040517lTt\ENQ$p\GSRi\1048949-\58685\SIu\21111v\19578P\1077429l:IZ\181939\17566V9e.\DELp\v_6)|q\1034902{\ENQ\29955MXK\1056306ax\1003137_\1006056#0\US\r\CAN\"\DC4`O\1049859\CAN~w|f\EM\126607Oi\1023015\SO\FS?h/('\DC1^\39065i\185517J`a3\ENQ\v\1060183\11345g\DC3\48063X\29116Ya\"6\r \132135Bb\1062624\DC1&\13220\EM\ENQy8anV7y\134882T\1047562\SUBcn\SUBk\168542\US_a\EM?\1106016\1088608\DC3I.Z\1069178\ETX\SIrX )!p\1067306Y\183358\&07\GS\1086052?\169845#m\EOTuZegqUxW{\100361\34246\33073\36773L\EOTg\154155\998821]\ETB\a\1059432\ENQz-\97879\187856j4\ACK\STXM\STX%4\EOT{\59661s" } @@ -122,10 +122,10 @@ testObject_PasswordChange_provider_9 = testObject_PasswordChange_provider_10 :: PasswordChange testObject_PasswordChange_provider_10 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe "1\RS\10044\NULv\987768z\1055172|%\1068184n\150620-g\146786\100842a\132317\EOT%?\97207\1068876AA\"Hmj-\EM\29734\&8\39432,\EOTP\58685>`L\STX:\127298\ACKhOh*\54301md\DC3\STX\144021\1098966LJ+\ACK\"\186180+\1079127\1032187\1090115\SYN9\147876`\63187X9(i\48707\163570\&6\1042913dn~\f>\DC2\1059432\1061679\1009814gq>!\1009228\1047046\63767R&|/\996634fC(D\180586\163947p\\0D$G\23465(J\btdn\1057718u\SYN\NAK:[gvX\50684\NULA\DC3T |@A)\f;\10521R-q?fi\142601\34542#\131181\68251\NULF\1056804\RS\1089058?*\1094737iy \1005023\126576EJ3\153530F\SUB\3963\&2\16235\1015286\SUB\1066520X($}aH\118969!M\1077359\SOH3])\\\ESC\1049797h4sn\FS\10735ztNR\STXxt~:Rb\12611\996694\SI\n\1112987\b\154951C\83302+\\K\1035224\STXs\RSa\47166+d\1073064\&1Z9g\RS)\93030&)\1043446,EIg?K\EOTm\1090815\&9\n\175897\US.u\51778\\\DEL\195063^\DC1|\DC2\SO\SO*LJVVT\1033808WO\USWmOS\1066607\"Z.\SO\1113376C-8\f\DC2miZ\ACK\1084935~C\153854sbIc\"\\-x\10336L\162894\NAK\EOT\1011330\DC4\1065068 \DC1I;\50247;\FS\STX9gU]\151272\154324;\131933v\ESC\n9?QY\SUB\176268\137386Y\78635\1037339Y\DC4\1005665@ll\26187\FS-\v\1059041l\1096164\1084819\SIWrw\ACKU:\135072{\GS\SO\1015883*\f@n.\70686f~\1087845\1045524u0y\SUB\1057096fX\SUB\1018748#e~V\"/[\ESC\CAN\152318\&4\27910_6 q\1092940P<8.MdP\CANV\RS\1046864\991518\NUL\ETXy<\CAN!\US,\RSt`\t\CAN~2E\vs6E\28962\1105957J\vo\1034354O?h7\NULQ>\1091553\&6\"V\138663s.<\"\1088335Myg\"\1103252}>O8\\N\FS\EOTu\DC3\SOF/\RS\GSO\27243?t\177484p$<\1089949VrW+\148070\"Ss\CAN``\v\DC3j@\NAKkeV\bE\164215\11921\FS\NAK1\1061345~aQ\f\RS\SO\1083547~\RS\1064714\GS/t:\DC2\tH\1019658\176743'2\77831\ETX\SO\CAND\SUB\19747ux\DC2\1085285\b@b#\US\17662\NULS>\ENQp\DC1\EM2\NUL\145191\"\"Z\133654\&1, Isn\f\160554\EMR-\58719RsaR\FSu\b\1055113>O\1004908\n\59796\136706\DC2F\1085126y\SOZ\93820\ESC\158354\DC2 \46585\n\1106215J!\STXg\ETX\988927\1065461rba\NUL_\42383\STX\STX\ETB#\aAqacXF1b.$\fFvU\173641\ESCAa:\154419\67985", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "\1903\59455\FS2\DELpfHg\1002321@hB\1104441\136229ae\"@\178717\DC1[;F?\1059559{\r.~Q8q^^\1032632p~\54629A\SO8epx\v\1091069\36029\&9Q#`B>\\\STX\ESC]\62056a\aH\GS\1017283=\133882\DLE\US|K\1089241SU\"`TOG)E\1107458\EM\ENQ\1100349V\DC3\SUB\131700\35766\&11eL\136082.\1077925\f\30081\139237\ETX\1018509\&33\3857\1020021@\\56/RC\9618VomQo\189775\1090226\54508|b5\1008980\t\38541\148813W\1052824u\172429\19279\1097359\&2\SIHs\1009437'\\\bt)A\DC4|\vh\141292\US9K\1006653\1113093wY\SO\1070366\40254\17618\&4u\997723'\1071258Wtl,\1028539\180872c\SYN\169621p5<\989014\SOd\1098426(P\SYN41.!\1051130(\DC4L{m\SUB\1109045dYlO\ENQ\180749l\27773r\1015113tS\DC1d\1103375\RS\150389\\U\137134)\1068287\EOTRc\DC4N\ETB\SYNrzEm\763#q39\1045616lY\CANHr\156951\26672:\877w\135480\DC2\r:CA\29971\110984\1082925\n\DC3VE,od{J@]\21278P\29049\FS\139994\1100241\1102005!\136294\1017333H\23052\&5\\\DC1\148824%\181207\165938-p\bN{Ky(N\52156B\a\990465u\119232\")\14788Q\1031053.\183810J\160516a\18817\EM`c\DLEh3\150349\SI>\59607\58218\987987L7II@C%\170472b>\NAKgl*\EOTzI4Vc\78060\22721+)\ETX,\DC3:\42649\&5\1054588\SO[UL\SYNCQ\41627(\12611\ACK-;O|\120383\SOH\25185;VBv[\fj\n$jq\"\NULuYx\EOT\1042364\&9:F\94542\1103197omPG6\fX$\DC1\ETX\SO\EOT-\137576Yk\147970M`\DC4K0\v?\1041183t\ESCT\1068218\30904Z\ta\1045178\SOH\EM0tf\4343U\NULz \98491~jq\1078216\ETXV\174194=\47181vn\143157oly\DC2i\n~_R$8;fbOK\NULz-?CM*\STX5!\1105218\181223\98689i~\189811!\DC3\134655\DEL+\1100972\1088541>\US\1083023\988420\59101'75K\FSf\CANY\SOzC^b2w-uD\1106649p" } @@ -133,10 +133,10 @@ testObject_PasswordChange_provider_10 = testObject_PasswordChange_provider_11 :: PasswordChange testObject_PasswordChange_provider_11 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe "\1020927\1032547/~G!t3\ETBpT(lfd\987373Uv\US\1047331n/\1037165\v\1000590\&4P\SI\1060450gP\1107120\&3\132634\147692\&0o\57446~\1081825\&4.p\155579xg\169376\1084103yGDY\1068206\EMx^\151320\141047u*R\t21=|\\2\7811\ESC\11201W\147769\STX&\USGu\1097263+#&\ACK]m\159187>\94801\186556\&6W\ESC;w\1017208b\\\ENQqz\\|\95815o`\\\184139\&1\ENQ&@\SUBF\DELC]G[G\164490$z\1112723\1032192\ESC\1044057\EM\1048741\NUL>d\1033451\1015039\1073811\n\166720\180329P&s/!qt\162927@\vP\DC1p\n.n_N\1112206\DLEV~\1101479$h\138762Is\1004025`!\22537$\996552\r\RS\1098882@\16250Rd87\16682\69640\1041864Zo_Ps?\58225\ETB\DEL\25967wU\r [t\174359/\GS.\ETB\178764}UyN\DEL{w\NUL\1036907:u\\\US\SYN\179040K\1072719\6945J>%.\147824'\19885\184555Z\DEL?\4231\STXjg\145989G\DC4\SO\37110\ACK\43793\SIv\134991\NUL\174407ei\\]\n\998741f/a y\1002649F\SIlm\995784\1000687\141212\ESC\RS\US$|\1035150\&1\CAN;6\150884\&6rxN\135999RVa\STX(\GS\ENQ*pSX2-\GS\53557\DC1x\b\".\38402\ETBM\"\1082676\178592\DC4g\r\1100889)\DLEk\190056t\1043965^0\1008489\DC36\US\t\52881m\SUB\DEL;OV\1030445p]-p\DLE\152006D\37776\19566>d\DC34@/\994339\141918c\SI+dol\r{\RS\DC1\1017180\1064450\SUB1>\FSy&#\169442>\DEL?x[~^dAc\f\DLEW\SI1\995822\&0S\ETX_\1047048#9\175054wfVhc\1042539\1020713L#J7GBX(\4705\1048737\989333/W5\SOH\1112863@gv\STX\162785Z\ACKw^\CANk\986491HyU@I#}Xc?\1028091\28123\DC3\DC2)5+\1059942h\DC1\NAK[>\58609\ENQ#\RS\997513\nG\135550\&3\CAN\FS2:M\49436\ENQ\1074694\1023446\1073068\1089664>pne\178174s\SOd\1091829\138029\&1\24380_\1036947PM)\EMGb\986632$z\46384\ETXv'\re\CAN2\12453NA\n\1033330H:\148549y\ETB4\ETBm\132498\185138\DC3\1010645c\at\184247^\b\ETB\1097131*\SUB\1075368Q}\1107305r%\984574gdAS\EMX\ETB\ENQ\NUL\ETB3\NUL\DC1\99185k\fJ.\1031366\48850A\DC2\185849i5V\1044560\996851:)*\tO\DLE", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "P7\8027gJ\1025598\1050728\tb\1046936s\179130\FS*\r\163897~yj\67377qm)Y\EM\1082698k]SS\990645~;\rp\ENQ\SUBm\1109081QAT\SYNe:\1037799\3798\a=nU\DC2n2\97679c\1105464|\SOH\NAK\EOTL\STX\1014848>\n\SON\US\990037\SI\GS`JOJ\991087v\USY\43061\NULp(S\1006785<\1009666\DC2j\ETB\1074651\1111117s\49393\167041J.,OGU\1015263\1026361.\1082540\&71U\1093020\&8/\1053449\1043583T\EOT\GS c=\53746t}RiOL\RSUsY*h\120237_| \1025261\991499\63211\27137s2redy\1033031Vg\6578\EOT\155956\62493bdQ\EM\1060795-\1079855\1078796\EM\ETB\18365\170958\5129\1084739(Qw!\SOHh\1045601N\52593\STXb\43616d1\1081703{\1034023\FSBE+\r\a\1042611I\988095Rbat=\DEL\57734\v\1087456\139092\1002353oA$\ETX\EOT{o|\DC1Y6\SIleK\984319c\DC2\NAK\9960H\SI\97430\&5\988979\&8'\142119\SOH/\12561\bKo6+Iw\179708\18608\v\SYNN\1061814\SILuF\164362J\1037455\&1\1050949\&0\168187\12201/i\SO\990270\996257\&6g\DC2eR;\ACK\159365\1095404\83044\1057125\SOH\42192_=\1021400" } @@ -144,10 +144,10 @@ testObject_PasswordChange_provider_11 = testObject_PasswordChange_provider_12 :: PasswordChange testObject_PasswordChange_provider_12 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe "t\176015z\63099\RS\1019165\72134\1094758\1087142Z\NAKB\1067933+\ETXXj`3.Q\1032039\SUBK\1109183r\1017216\141033\EOT\1016663\52892f=\t\b\CAN\SOH\40431\ETB\a1\187032J4n2eUq\165606\STX\1002769\95144\n\DC1\v'P K\ENQC\49957\&6\995828\abB\\\SYN$E\rCp(\ENQ\997577Z\r\5958\1064586\6052\169035(2\DC3\nwz\EM\1057822`[,\a(\168052\ETBn\1024741\170909\&3\166910-\NAKTp26!\EM%\1067691\983629\1105810X\1084137Ww\160494.S\SUB\RSs5\tS/\188321\STX\179825\14815E\143720\5499::Y%\SI\151589iV\139252BU}]*\GSp'\51146\77903\40692\1032384\EM}\14702k\EOT\1048493a\1024981\&8E\26598\f\1016469\"1uT\US\97604\1086313hC$ND\99182\ENQJ\"S\vCyu\SO\DLE9\137054b\44966\DC3Pe\21040l\44235u\1093696\2097VSM\a\n\1107484\&9\DC4(\a\177489\150593>-\NAK\1030177Mr\1050563\&8\DC3\194810\141213PiK9\EMm\ACKW\SOHvIFX\984936\ac4<\SUBU'\ENQ\SYN\155860x\9048Y>@\1041311\STXp5C\r\163993!z$\1015059\GS\ESC?# UW\1052214\&0&w\ETX\1102267H\190128>-[9z\29338\1008713\SUB@\EOTiA\1113779n1S\136784\tG\DC3\DC4RT*", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "\1029941\182208*\50406\144479mqK=\SYN\NULg\3986a^U !\164592\1000979F\1091010J\137728\43445\ENQTB\152909=N[/5\187278\SYNT\SOH\NULp\1045227\&8\155213q\ETB\v\ESCA!w=x\DC1\RS\29768Z\EM\vpx\US\b(\143914mvW6~'\DC2n&\RS\ETB^]\1092638\b|\1066156d\SOHv-\1092522T\b\ETB\158149x\46579 Mo3)\FS_5\182825Mf\156710VFG\STXOEC\142253(\ESC<1bYVMM7h.:n+\ESCXB~juz{<\GS1\23218\1073013v\1085890Ge\SYN9X\STX\1027702<\ENQx\61722>\ACK^\1108099\1049394\140867P\ETXuh\ESC0\15060?R\SO\CANcT\1025381\1026223C\DC2\"8BY*8\121167XO1\v?z\US9RR\139465\NAKdG\160065\DC4/k\1101568\1097562\46923\ENQk\70387HNx\139984#\997549 \apU\24875\161412E~p\DELe\1024027\32616%(\EOT/\165473Oa\1068906:9'b\b\48968\63083bSw\1089284\DC2pQ\DC4\SO\997853G\143790(A+!\SI\SOH9O\1021380\rWZ\ETBHVe\1038354\SO\SYN>\1084362\SOH}Bcj\1040105\SODC8\180947F\t\1078880yrR\126464|\1002350\CAN\9877e\160489\SYN\fo\n#M\140761\1050789u:j\DC4\"\39095\SOH\1091919I\64643`tpdU\SOH@\ETX5\1015281\169940\&8\1084283C\EMI\70725\EM\DC2M\172398\1098890\&20\17785\ENQzq\v=KLK\1026521\160303\191191\FS\1022904&\1044176\EOT;e\DEL\1092828,59~\DC4\1095506s04[C\74207YbmD\f\40061\\\ETX\EM\153974\169857Z\f~:.\r^?#e\1054794du_I\ETBHy\NAK\DC3C@]Q\167956\65170a\v\1065540<\1097822zRr\vA\1005063\1042686%]\US!\99727.dfV\STXR\54637\1083007CJ3\t\1100221d=\DC4b0\96187\ETB\STXc\NUL\32051B(\RSsx\DLE\FSlYr#@\1048685\145684\1032535\995750\NUL7Py\176787aL?7\SOH'\1032303\1026443\166147;\ENQ\1101976\178862\1064385CP\GSy\NAK2@h4D\74062\98439\98790;\991778+%\142285\1032969V\DLE+Af\ETXr*}v\74561\EOT\b\SYN\DLE\SOH\CAN\NUL\DLElI\1103471Kof\59742\DC4\ESC\185307O\1025693fr/\STXq\62224{\SYN@\1075147p\1092437\DELmvx:fk\1096766\r4\1079176\9458\ETX\t\fP\1068111%c:\\4\403\1072385A\SI\1107936\188641\154662?1\US-\EM5j[([\40806\aQWI1\1012550\1013491H3Xf+A#YF\SUB_lIo\169072\997652\998922X\13580X\1093433\SOH\1032578\989439)P\172195\&0\1014194d\a\RS|\174744$Eu \SOH\ETXF8 a\b\1022530\1066904/]\US{3E9Sf\138114\a_:V\ACK\1111019QWU3\1098773\3051OS XdA[\183160\28570.\31939(b\nL#\1107788[\STX7l*C\1033328\984136\GS1.Z\998716\EOT\46839H\n\1071926\1079240\SOH(l\RS\143037\v\38887>\1090554:-\NULj?%F\1084391I[6v\170273\22502\100077\49274~7G\166097\4519\EM!'\ETX\38398Q?,\GSUu@%(H(I\ETX~>\DC4K\DC3>\72246\EM5\1022086 \1090756O\f\1111314\&2\DC3\25931\994780G9\999039\990590*%\1000152\NAK-\983159\SYN\\\US;\152905JP0$\1106049\37822]\DLE`\1036169:0K\997106hkB\144462\RSH\1102093\33454\RS\989572\1107706:A#]R\SIXFW\"8\STXHW0\986050\45557j\37948\995320\1100585A\1035623}\61155l\49913\1091660\ETXg&?\SO\&H\1054389\1089082\NAKL\98924RG\1101738\&76\5639\1081113@\EOT\SOv:\29442*\DC2xph\136453\"F\n3lu\61648\STXuf\DC1A2M\154097\1091702\ACK\DELtXj\DLE\1096523fT\168155\160774\62409Q\ETX\1073552ah:\1045093ctrC\1008972&:\ENQ1\984019\DEL\72254_,}\1059043Y9`:\DLE\DC3d\1103970\1096003\1102898*Z!\187234\148461\v\52269\&7\63614\SOH'\\TOs!%?\SOH\1093845Z>\DC4\1204t^P\v\a\34585`\147989_\SOE*\1011406\SI\162221<\DC4\USW{\83494[\1054677\&1\1049205duWR\25182\1059779\&9l\FS\a\US\ETB\r\1036646J$Ea\1052569\173473\SUBLpR2\27762A\167459\b\SOUJao\1025597@\17412h`\SO\163155\DC4\1066350E\157076o\1110972dZPjbt\54921\985661k{\1102674\&0|\ETB\50568Q\SOH\152060\&5\rfAj\1062496T\983117U N\31082u\1075887\DC1\157116\DC1IP>\995210'\rz\1046533A\1066921\"\181434\&8\164987|\63500wXC\NUL\1064912?,X\1019667m\US\EOT\96637N\185883_:d\USU\167304A\1106870*'`w|6\1045529\&9+\166106mjC:v\1053515\39282\10936388!Acu\a\ETB6k\SYN>\EM-\22513g\149536S\DEL\RS\1023314\1096302jyZ\1066742\1070063U1G\SYN(\180738\US\1006809\SOH\1114037M\172262\a$CT\CAN;iezO\150819!\1105298t<\1055348\149076oogs\SI\ESCO\a~\DC1\a\DC3\1018432\159829t\15910\37325|\DEL\CAN%\1010165\&9i\156087\&5\144925k\1050355\49336\11211\174004\1051581\DC1K:\RSE\ETX\5991:f\1098856\19403\rN\49047:t\SI\1053824\1038465z\149922V^?D\v\vki\SI\ETB\1001377?u\"4L\1021111&\987357\21606B2H\173390\35107\&5F\34214\GS\DC3\\\1059063\&4" } @@ -166,10 +166,10 @@ testObject_PasswordChange_provider_13 = testObject_PasswordChange_provider_14 :: PasswordChange testObject_PasswordChange_provider_14 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe ")\1036280s\137216'`\59330\STX_\CAN\180010D\t\ENQzJ\1063390\1045233`D^\111264>\132440\NAK\DC3\176932\49323l\SOH\99704\1013404\&8\1098260\DC3&>\43426\30743x\173643.o\158967=6\1633\1098022P1M\162604\"RG\SUB\1038025\FS\STX\CAN:\DC4\ETBu\CANx\1089236\1061187@(E\1002850\&4~\ETX\NUL\51238\&4X\NULH\DC2pll\EOT\DC2\177807\1104201\DLE\17185[K|W\DLE;\144266z,C\USD\983816O\NUL\riV\">=^oX#&L\1049388Kq\25975YW\1033425t\1055427\22674\nPF~\1082938;42%b.;t \1040882\1039127\165132\DC1\1064926PV\26969z_xZ\SOH)Bz\t1f\DLE9/\FS7\1093628,J\33998\72145&Q\NULe=\FSK+\SI\25383\1028788\1022136n\97202\SO\173088\&8p\DLE\ETX\111158|,\STX\1033460\1104436d\1050868o`C\SO\132042\f?Hy&\36586A\46227\141006e\NAK\DC1wJ[\fc\b\1070422l\141230\DC4\64151W\DC4R\1045214t$\136334v\61852~r\1060898f\1071586\&3\SI\1019583\\D\ETB>\164308b\EM\133344w)\1053343_(\1058134[ra5U\SYN\178080\72861fC\141152\&6\1011495\STXy\100396Ii\1109445\f\184085z0\164727~\78749D\rhqu'\DLE\SOH\DC4\145824V\\\SOH+Mu\1041477S;w\141810Z\1041792\EOT\NAK5mo\USZ\1079915\172082\1069321\1090200/\ETB]\aD'\NULx+\STX\ENQI\NUL\DEL8\RS\1055834gcU7\1066759\NUL\7502\RS\995972T-", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "\DC2\t!' czxpt\186414}\1036760Z|\1105851\&3\1098742\&9cE9;qlI\128862\&0d\1041894\1071062\ETB\SUB\13291C(q\CAN\DC4\92178r\992045\n\37621\SOH*\26079\&3xLD\97230\SI/AS\CANX\DC40\NULC\134548\DLE^\1113807P\SYN6U\"2N\STXv%\1078605} \17042\4719\ACKB\v\t\989972s\138208\&5\DLE#\53263\1088280\STX13H\173756#@t)\188467BEV\ACKLCX\SYN]\DC2P\181437\FSN\149514\186718?\22604(\1090926\15413\1065637\EMO\27585\\r\FS\\\SI\1035346\18565\1013435vU\at#\1062175\"\EOT\SOH{pE\12478xJ\DC2g]\ETX\DEL\DC3c\SOH\fPX|\1066931wEAe!\993681R\ACKu\1113614..$\1068793\27316\&9(\SYN\58010c\1002603\RSw!\SOi\1030926>qPl\ACK\STXXmG-\v\120608`\1088763B\FS%gW\ENQ\DC4\n\STX(\1074703\b\DC2N\1109769\&7H\"k\EMty\ETB\187962A\1020329.:\ESCj\re\GS\161991\1034321-*q\ETX\1093441Y%A\46791hf\n\141128\DC3J\157117\SUB}4\EOT\161237\v 8$d%\b\28128n\989856K\DELf\t\NUL|;X\ACK\142667*\CAN\SYN\24303\v\4878\1760Yj\vyyk\1026116'i\1080447?U]\ACKb\ETX\48209&\"\29031\1039525}\63031P\13226%\ESC\t\1047966\1098216\ENQ\ACK\NUL@\ENQ\1106062%D\41968\94208C\NAK~p\SI;\GSz&\SOj\DC1\"7\66276\1081689\EOT\DEL\990793\bdiX@[%}\1072552\1098336\&6K]S\ENQ\1012057T\990893\154290\&24g\USe\STXpS\82979E\FS\ACK\71075\1087888\DELo\29366'\t\SIv\995645\&7,\ETX\SOH!i\1031533\1081283\&43XW\t4\FS7\1101016\1108161\31300?\1083887@\1048301g\DLE\DC3\1067632Mn\GS;\1044539*k\147748\&1\DLEW\1112391\1103992*\vB6\158062\f\68308=P!\1112874\45394|9Qv\DC4q\1063868\1105018\1004357Z\DC4\1097524;9J\160053+\fLa\DC4\1010823\&2\1049908<\1055415\EM\1053244M\ACKDO7NUa\34227\1041181[ \181881\a1\US\vc.\1012405\ACKhi)\162677\35949nG\27679;\132913\1026232f\182327#t\1027272B=\152450`\180605\&4\NUL\1006343*\1075473\b\\*\ESC\177216\CAN;\ao%pAH|\n\EM\tC\143291ZrZ\151170IF\ETBO6=bh\STXzV\1013862P\1064457\\\159157:,U)\58595\DC4\1013245\1075630\GSg\SUByv\110788l\\\v\b^\31887Ii\ENQ[\65195y\1088707\&8a\CAN\t\vDeb\1007440i{t;oR]", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "\152852s\1068988Df{\1109851>\DLEN\1096871:,\1104054 \134810\3245;L\133604uN\989402-\"A9t\1089099f\CAN;q\1046261\36961R<&3\ESC\DEL\72254\NULq\n9e\SUB[d\1062509^\v\CAN\61899U\18913BM)\DC1r\SOHevfz\EOT\118837k\152950\&8zn![nE\40274\a\148239\146181\1034404\DLE\NAK#\1021834\\\7383\f)We\135919\ENQ\1003933\1042129Y\1083464W\184760\1043368\&3\DLE\\\b/\1058055<JXk3\78374\987565\151899\147262\27256\ENQLe\138865V3>\142078\32373\&4\1065316\35039F\\$k1\f\DC2f \v\121169\FS_\1066161b-\6727&\995927'X?Jb,\12990\158842\9204-\ACK/\983518\1100707`\ETX*\9732!\DELf2&/\SO\78519\ENQ\1006374\ESCU\1108463\993917\USJq\155123R\133864\&2Q@\ETXq\30171sM\"\DC2{b\v}i=\118851\DC4p98\DELsa\1110153 NiV\1016779f@\162996\1062077F+Z\154196\DC1\1093124Zd\66026gh^\SOK%\142282I" } @@ -188,10 +188,10 @@ testObject_PasswordChange_provider_15 = testObject_PasswordChange_provider_16 :: PasswordChange testObject_PasswordChange_provider_16 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe "gB[\SOa\136597\&4I_\1070522$\US-E\f\bD\184839l\53618j\145196\1063189e\96537*\1053243g(hJ+E\\\STX\100410/\ENQb.\7738\1113147\\\27214\9423\EMu)-\DEL\FSRh\DC1G>\DLE\ENQ#\162187\94536\v&\DLEd[5\DEL\DEL7U+wZ~e\1065056RT\1015376~\47206\1049409\SUBL~\ESC]\175086KpHWu_\1093704uN=\b\1029782\EM\US\1041680\DC3\DLEd47\1016259\1094650\SYNZOiX\61511M\DLE\DC14\1023510\55250d3h>Jw<\SUB\1105863$*)\173139\&5\t~\36451U\v\1007351\&8nb\DLEXH\137571\DC1Q7kP\186382L<\1078705$+\1081663#\ESC\38858*K\180009%\154955\65738\f2\v~A\1011551\f}\1100334}(%\SUB\997989PA\NUL\1081198o\1064382\SYNV\NULv\159271\ETX\54311\1064618os-\1091683\NULE\STXcl0\137068f\1050864\186833\174746\EM\25158sQ\1071799\60428\5196k^=_l\1066392f;O|\1063397YO\"P\NUL\69230\\\35862\"\ACK\"~\141584X\1038172\ETB$\95964\CAN\27381%UQ\NAKy\1029066\1073585-W\1020228z~\GS\22450\1113567J~\DC4-?:\1110536\rf\1065914\a\187988\1098168^.\26197T!*\1037028~\1073514\SOHF\am\27257\158078\175061\\\SI\147288Vk\99196w\1092949\186929_D\DLEq\1086094r\131393\NAKy2\ETX\ACK{Pq)\1037265\99424\993708t\169393e\NUL\US\988887`\159377\EM\1002749\STXY!\50906\fY\ENQ\1078545ERU\990479>\NUL:ZT\1035772.3\CAN\1096695J\SUBN\ETBZ\153481\a\16088\DC2m\ENQ\ACKDzhd(<\SIF9-N^|\983096;3\993521%\164480|\SOH\1080654\32149L\DELs\72704/G\161452\&6\1001045T<\US\SO\176234\b\132812}\1056421\7504k\19220\NAK[t\DC2U\SOHIX(\1069119E?9*|-/)3U\ETX\ESCo\SO\1099860\SO\RS\1009682E\te#\DC4m;\DLEfx\SIm\1046538\a\97848\187828\&1\137971Kd\CAN\1042040%\ENQ\173735;\1027791:4kcX\37202!>j\153067hT\DC4\163467~\1060082\995785\1005593\190617\1113881\199\DEL(6r\DC3\145739\EM\ENQx\GS9E\SOH,6\1064646\984090\&5Zv\US\70154)dssy(\NULco.#\"\NAKXW\119053gS_\31565O*\142194\"\1067622&Xt\DC2\r{Fi\48778 ]ZZq\994122:\177062:\1052139U\ESC$zN\SYN9\"\10761\&10b}X'`\190793C\1043334}\r\1111369\1021511*\r\SIk\1062958`E?=`\46022\78512l\1068151*j\36518M\1020065\37308;\159311j&\DC4?#\191316\ACKs[iF:\n\16090\991139\1059764v!b\1112922\1068230\SO\985232\141755\1112084j\DC4A\SYNT\NULNY1\1078882\a\136436\1088153blf_n\DC3\GSB\ETB\11380\50340q\991037-B/B\16269j\178019\ACK\1079155rr7x.x~\151350\DLEcIIK l~\vt8z!)/\24279~?yP1BY\ESC\1044'#7\\(p\159717Xx}\150971\145409x\15522X}8\SIw$\1067337Dy\68912SR\7036\54589\1086756R'\EOT\1016478 \1086591>\1072777\&65\DLE+\EOTm\1097089*vMO,4\24600R\1049889W\991833\FS\EM\154481\":H\SYN_K\v1(\23407\&5g\189510=\ESCY\v||]A3\1090464\&8fI\f\1089249d\27681dw:\53053:e\FSX\140812&\26383\58555\1020960\153568\&6\ETX7?Z8\DC3i\10727\32848U\987253'D>\ACK\1099263\167302n\1084348\SUBe\5445\DC4=F\SI\GS,!jSM\1037019_\n", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "s/\DC3\a\1019919;\186152\DC1\ACKL\NAK\SUB\123639\&0\DLE5\988934\1085942\ENQ]y\n\1088660\EOT%#o\181283k'N\5859\164247\1012481.U+D\ACK\US7^\1106302\DC4&\73089\US46zmN\1078548EX\1100831&hV\147473\&2#B,EY\1062234`\DLE\986448\25566\EOT\EOTv\1060131\1011335\6586\8606[H89oS\141447\v(Isb\DLE\1009984\999533Dv>S\ENQ8\NAK\1013739\31809CBi9C\f\ENQ<\1060837\EM\1002394\RS,/g\1021216\&6S$\CAN\CANTc\145792+~\CAN\38440;P\EOTh\SOH\990223\GS3}j {p\1006877\169025\158467D`\1014376mN-\\V\STX\1106691{\SI\ETB \NAK1#6p\994323~\992677\1043440\&6\1059978Q\\8\ENQ\STX\1011902]ynrf~>n&\6380\94374 m_`\1080547D@\DC2k1,>\1098067v\132299\&6?\ACK\1012654\DC1\SOHjL\16576\&6\USld\1008037\189738\&5\7878w\62207$l\f57\NUL\64524\1073108sg\"Mf\ETBuKPsa\190219zLTtY\136016\&2\"R\18939\66650\FSw\b\167281\US\1065135\50725W/\126507\94452}S\164388;X\EMe`\94080\NULZ-\995401\43858\n\\\EOTy\187923f?\1090882!\SI3\n\ETX0\1046274S\39868T\189058\ENQ\SOHB_{&\FS:\FS\CAN\988362\9506k\1054019N\GSWR_>J?,(\1101196r\1035425\1088638\1003569\43364)\997661\1029696q4U{\21915uqQA/\1036564\1028269 ]4L\100553\29035\25204m\ETX\179784b\64823\1106448\GS\151115\DC4F\1004969G0@6\1077837\DLE\137744BV\1081634l\1081851C\1065998Q*<\SUB4\ENQ\99901\DC1dh\1085582\1100640V\128022\FS\1012025oZ6>\24971I\1046586\&2@I^\DC4B\ETB\ACKmD.\137741`sB\19558\183636L2\DC32;\GS1L;\DEL\141722p/\bCj\47458G\27338SXe\97073Z\ENQf\43154\&2_\6385\SYNr\DC4/C1R\DC2" } @@ -199,10 +199,10 @@ testObject_PasswordChange_provider_16 = testObject_PasswordChange_provider_17 :: PasswordChange testObject_PasswordChange_provider_17 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe "I{A\1081115\1040244WH\t^\1024680\STX\DEL]~D\n9:\NAK\ESCy\1040393$%7?\1048769>bm\DC26V\1065346\16705\ESCQMle,\1015761\21766\1105355\NUL\1020694\GS\v@K\1043881aC\1094072GI\37368\1075187\1079031@W\1038319\1001363\997424\157615\EMa}\SOH\12756\1081169RP\1087906\SImNi\98040.6\14300\35116\STXe2S:X\\y\1027289R1\163603}\1012190\1110662\119901d\146377Xi_\1058064z \185806\47296\28402f#\ACK\b\1018085\fDK>\1092488AV=6\b\DLE\GSi\DC1\SYN\1108215=W$M\1102069\ESC \DC4u\990358Q\FS\78744u)\1056471Qf\US\SOH-\STX,\DELnCr\\[\25698\CAN\ENQ-G\SO7\9176m\RSs5\CAN\NAK\NAK\1081901`-fY\1028198\ETBL\63765\ETBX2[X$B\FS\ESC|\n\SOc\1093611X\NAKW\1010429q\1046880^`CD\DC3SMJ\STX/wR\NAKmB(\EM-\1034880xI\DC4A\1078737\1103535\1083873yw\fn\1057985E\1101283cP1\189927\141738\1011422j\1037710_\NULb>&^\EOT\58470<\r\b\rr#hu.LY,\tS}\ETX=Jz\1113866\78095B[\f(yR\997282\SUB3\140630\DELl\1016964\SYNIa^\50526*\988502\&3z\11825%<\ESCx\48956=H&u=\FSs5_X/\1021976Q\1059092dJ\172609ghu\44563\ESCE>3\1072325\189630Wls=)'NG\1094331r\SYN\DC1%}8%&\154657\&8:\ETBKQ@?>k\1027270\NULb\9895+Xmu\1011506\n\33550-\f\49393\51542\DEL6\1055009\CANw70*;Q\1072772}y#\ACK}\ENQu+2\163564\171133\vTF\v.Pq\DC2`M\174005\96741\&1\SO\njL^sg7\1081135C*\1102887o\1088885{y\52998\ETX|\1023709\EMF\135291vxdQ\137699:\SYN]e\DC3^\SOrv\SUBf[5\NUL6x1E\1005855\1048944\1080103\v\993780\v\DC1\SO\988187\"p\35477k]^/=+\1067771\n\1074075\SUB}A\DEL_8\RS62\ESC\DC2Bk\29783C~S\EM-`\n\186446\EM\NAK]J!p\SUB!Y\SI8m(cS,\176277<\168390\1057601\1082774'\1092313z\989733\ETBwe\1013727\aa\n\59433Fb>A\1088804|\SYN-\STXXz&/'~(cy)hIF\181057\DLE-/XyR\147207\1035531\99846\ENQr[\r\1052642Q\1054366u/r\995938\NUL\ETX\r\tTj\190394\155541d\1046953\DELm\vb'<\1059546\45701@v\53022T5dlF\f\157714gRJ\1037469\995950\1090231\US\62219j\bF\\\v\f]}4[\1091004\&1J5\1101864fM\1002697P}\1109954]xd\44742rn\USi\127844zuK2\64008qwmc\57449\SYN\SUB\r55w5q|\DEL\1004339\DC4\1000580\NAK\\;\97824(d\STX\1069352\126106#\SIeP\1014578pc\b\1006213\78367C\119203\b\DC4#C\183272M_\DC4 EY \140657\STXg\US\rq.\99689._\44617E\ENQ\STX\21293\994346.\1010642\165378s 7p?I\\\1016448NG\1016271\vH.\1011097\&4Hz\DC3a\1112965\1099391LS\956\NULnx\ESCK]\v\1014996\ESC\1001785\DC1<\1059607M)\1103444M\52771o\DLE\FS)6\52537\78102YfQ@F\140229W\NAK+\158031\f_b\999514_p\1026159N\vF\RS\"f0\FS\DEL?#v{\1062480\GS\ETX_p\STX[\170629Qb\37443\&5\1079682b\b\1113122O\987041\r\30653z\GS\1034134I`\ENQ\1074991\ENQ4\NUL|\GS\rt-\FSY\ETXea\164217'\ETX|\25860qY\137837\EOT9Gyz$ME\1012376HF8\1101936\DC4\1040797\&4\DC3d\38807w\EM\1087666PY6$aKV\RSkHNh\ENQ7\b\154234;I-J\1010947rSDa\51431C\157687\SUB\US\SOHV%\ETXB\DC2N\DELGq#Q\173949>\1049166\ETB&\SUB\1027480\&6c{:\NUL\1026276{\tq3\96886\1014266n\DC2C\993425\17816nLR<2bXS\ESCe[l\1097388\fjjZ\1004264w,a\143819\r\SYNL\1049703K\EOT\FS\v(x\141566\1002452\49875l{\986046cp\\\GS>DA*\186399\189082v1`[I\1087573f\160956\&8j\SO\144181n\60434\EOT7jx \v\DC4\SOH-\"\1051346+`\STXF{y56\186936T\17962D\111297^C:F%5B\1113608\183649eU" } @@ -210,10 +210,10 @@ testObject_PasswordChange_provider_17 = testObject_PasswordChange_provider_18 :: PasswordChange testObject_PasswordChange_provider_18 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe "\ENQ>$;GNS\NAKq\178874\58968$-\68366|\151635\70868GG\1069152+N\994629igAv\68290\n\1040872\DC2Tl\ETX\CAN\173661\ETX\161735N\42419v\149562\98179\&9$v$R/\69765U,\a\21766\CAN\ETB\1040043-O\DC1HY\fK\1067449aS6V\NAK\1040221\996914\850D\54816Ke\ACKj!|!\ETBo]Xg)X8P=\ETX\ENQ\RS.b\"xp\1105843\&3i?kTDu\1098220T'.\1071583x%0\DC1+GA$A\45313\a\ETB\ESCa\1028493\1096329+\1019183\1111460\SO\n\SOH\ESC\65042\155997\STX(]\1046910GL\DC2a\151097F\a\168528\fdB\ETB'\165547+\vTf\992507I\1045958\&4r\175158%\RS\999749Xq_*g\994465q\RS\SYN.\1013841 \SYNzQ\NAK\986554:\181318(d0i\1025168Z\1057887\nn\988266sa\61001\SYNx\DEL\170759A\1027650?LZ\CANO\1039286\57543\&8\RS\EOT\992522M:unvs\ACKco/\1103889\61376:$\1017208O0l:\4771j\1071859\NULB\DC4\165947e|\1104789\119138`BCH\92363\&42\59532\999574%Y6\1036574\b|\119002n\1008926\NAK\FSb\EOTso`\DELy7H\1144\SI0\12299B`!\n\EOT#L\51662\1103006|\ESCa\1038787\DLE\r\1067701\&0Y\135747#NKj\1106283\42365\SO,9\ETXh\DLEw\128979R\"(\EOTV\r\14540\&1\989621\DC1yq-e\f\1035089r1YU2\1087695\171952\DC2\SO\1105513\1062941[\1019605_Za\52679\DC2\ENQ\v\1046141\\#\1076425\a\FS\16658b5C&>dt~y)\66452\ETXg\141548@\DC3\182735\ETB*$\DLE\1313UnKs:vVS\1095798m\997335\15618X5f)}@{ha\DC2\f\1041518\ETB-\1054867\t@\STX,\1099907\990571\STXD\1087623\SOH Cm\1004594sDY#YL\1090422;F\156423\v\GS n\a\v\157412c6+,\1004205\EOTH\1063061\33351\vf\1065194-ZS\ACKB\SIFd\buy)\128544\1074733i\1092468/\SOvZw`JB>\RSu}\1107475\1036872\35763%\185556n5\CAN\60703js\1039874\1078173\135796uvW\"\28047h\1043723P\DLE\1097958S-<\187968\34464\186710/wb(\26583\&3\n*kt+\180850\SUB\1021642\1068226x\983171@\DC3\SI\US\\0\NULRA?\SO\RS\189447LuW:Xh1\SOHT\DLE\94916 VOG\DEL\RSO\189514k\3015\1025016;l`g?q_ \1011679\92993\984171~\ny\US\1045482\52577N\1029913\EOT\SIA/j\STX\1084693\DEL\176033\136608m\GS#z(\127161jW[\1038238\1073630\a\1060787\&6\nnn\145137\188550\DC1\1068174\989085t(l;\1017830Sm`tO\r\57362\NUL\1101579|`x\1071012t\153686\EM\163617/\DC2yuB\1072146s_\183212UrcO\99677\1081043\n\41654b\1797\GS^\132600\142485}Q\1109755\DC3G!\1035773\&9L\195083y\2453*\SIU\vk?T\1064435;\f\\Q\RSk\1085726\172950\188191|\6976\1114033Z\155291\tl\NULb%WA\49679r}u2\1059498I\DC3V'i0\158983/Icz\74116\1054934\DLE5L2K.\ESCKLr\DC4\1046820\1052056\STX\\\SYN+\DC4G>\132569>N%R\DEL\vR\\L\1082431*D\SI4u7\DC3AW\STXG\144748e2%y\bw\US\SUBZ4x,\ESC\67889\f\47421\DC2r\1004074r]%ws\DC4]y0\1014309I\993296n\1010689.l\NAKLAv\EOTJ3z@d\27165\42869\t" } @@ -221,10 +221,10 @@ testObject_PasswordChange_provider_18 = testObject_PasswordChange_provider_19 :: PasswordChange testObject_PasswordChange_provider_19 = PasswordChange - { cpOldPassword = + { oldPassword = plainTextPassword6Unsafe ";\93029\64850k{\EM\ETB\183122'\ACK`{2\16834\ETB\DC17\b\aQ}P%O!_^d Tf\STX\177895sQDd\DC1?}\b\NAK\64801!7T\180836L\ENQ\30743\STX\DC3\GS\a\US\1011186\DC2\DEL1EEV(x'\DC1\SUB\1061407\t\GS\ri\1100649\&4*\1060200Fs<3\FS>}Y:\DC2)8+4+\STX\985320\1012240\134405\vl\NUL$\v]rZ~Dy\1045002MF\CANlw\995758x\1076847-I*bE\1065762A\189306\&5\50057c:R^f\\G)\44960\&9\DC4$|\STX\"y\1020665\39604AW =\20322\1091813\&1\1108618'\1051166x\39102\&6U_\997336\ENQ\50873<\1066165*\ENQ\1029616z\186193U\DC1:4a\148141\32437@\135977\177323\&5p\1110836\NAK$[\US\74077.\987765\&7\NUL_\988982\&8\ETX\r\bg+'R\1027482\1029411~E\rP\1034583\r\181175\46544S\\&.^\60125L\1104199L\a\144365G:Wiws\NAKQc\r\164901\b\141361;\999696|Q\23549\&4\1036417\72875y\29622\f_&fGgH[\1029620u\1052069\23938$\ACK\STX}M2y\99985Z&\189182\f\1110805nzK&\1066016w\n\NUL!-U\SOH\ESC7\ETX6\1026958so$\991139\t\138455\DC1A\27842M1\DC3\SO3.Q[Uy\1006799e\1005623\fl\146202\171029W\1104958C\SIk\71104p@$+\ENQr5\1029753\nB(X)Y\62054>\149953%'\180534H\187026\135153s\11937A2MqW/\18450$#I]\137728\&8sv\49908\ESC\1061880t\1103799sB\988567\&5\"\a\128637{t\23482oJA($\RS;\1067956:\SO\98842\128224\v\141160c\992280\"\1037303\95310oQ>\STXyD\186030\1035343\186166g@&\EOT\95865{x0R6\989091tn\12077x\1106050d}\1016609m\DC4bHo{\f\rM\184517\t\137817\147706<\NAK\179286;dz\EOTC9.\CAN\15836V\SUB;\992386\RS\44724Ho\DC2\93805/\1100285\141534\RS\t\1016167c\STX\1023078\1034365\1046848DavSJ\SOH\STXk\NAK.\FS{Z4\1035470\&0j\1013919{\161435\59533{\148113\EMIW\143598\147178_[", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "4)N`WV1t\171789cC\1064787\SUBi\r\181549K.\1026553\EOTpO\USm\172246\&4JS\119270\NUL)bL>\ENQDBd!\SYNc@\SI,Y\30028`M\47712M\SO\8923{\1025087\ETXk.\30014\EOT\1066825`\120441Zu\169464;>\1079442\CAN\1106555\1111972\ETB\51402%\1109382,Zp+\rM~Q\r)Hv#l\168927\FSDHS\174628\999635j\987945\163692eWG\156827\\7\RS\1052535\&9\\\t\DC1t\1112332\1073530`\143677N\1010456h&\188126\1030547b\6757\1027169\1067336\183902[U\136396\991560>\CAN%\nO\78186\24509\b\1052137\&3Za.u\160812T\US\1033241\CAN6VR>P\188486\1062205\1040066!n(\1023327sWSekN@\24878t\985533V\145005\1095963\r+\1017241B*K\ACK\1074664]Ani&\tsuX/a|,,\74249^\129600\SOHty\ESCf\146951\NAK\150050\DC3E\fS\1032730\SOH\3856\SUB:7=\bgux(Q}s\20372]\DLE\r\44488(\1014999\148549\&2E`yqv;=\DC1\985264#{\EOT=l4PE5\181488D\1035492\138684:_a.{\"F[\n#~\1063106\&50K>\bTp\US\DC4K\1084112/zT\186543\991672\&7\150007\1111071X)\DC1\\,\22596%\SUB1\SIA\STX\RS\10664l\135150\1044470:e\SYN\179418a\17938v\34834\ENQj\NAK[\1095674f\177286W\159061\19064\180331\\]\162954&\EOT,6\GS\DLE\v&g\1039892&36\120707\1051886[IO\1066559\ESC\DC4sS\1010996_a><\f\1020047(.Y}%7z\DC2Q\NUL\CAN\1050803v\1028497\US\DC3J\EM\DC1dEh'Em\133130\999811\SIH\FS\120427E\v\1050653,{\190522iQ\1097210\165473k\FS\1112852\&3}6#\1003388 \94215\1112185\n{\vY\ETB\70801\1019457\v|~W{\1035159\8185\1045959'\DLEO!\SO[\DLEUk\23328)\1101657*\1069854B7#\988898`\992584$C\r\SI\1055353\1075772i\121086\1096003e{N\a\US\SYN\7098\1049584\DC1\SYN\164973\1005568^\US\1074252\1056920\FS\180867^6\SYNWPe\nx\1022949:^\SUB[\EOT~u\26460\&7\RS \44431\DC2\23328O\ENQ}zi\1081683d d\166200t\996444\fk\172000r0\1044826\50464*\994866\&1)$j*<\51631\1008420v|F~\1020301\NAKm#^l\EOT\148762\139353\US\GSW\15496^j\DC3\1080990\aA\\.=3J\995313>\95982}.u\STX\989998:sk\ESC\1107636E\ACKBR2o)\STX\7667S(V\"\n\50026dvp\997353f\DC1\DC3\2034\ACK~KH4K\92412'\58542h\1050852k\1045053i&\a~\20933W\1033711{\1058407mp%\1091729H\1011114i\DLEdZ\1104024W\NAKYb%\bXwgAa\1084701/\1060643\ACK\DC4W+h\1089169S~\EOTQzZ~\SO\1094174E\1061848\t*\100572v\STX#\\K\RS\1045046\111171\EM\149568O'\DC2!4]\DC2\163000\ACK\ESC:\59217\NAK>Y|\31158\t\149865\NULuv\1087835\aV\tb\STX7\1095030~#\184376\165077\41742\1035571\US\DLE\13715\v\1079101\NUL\EOT\1064658\136028\ESC`\1005448V?\1069622{x\SO)\DC24n*\997306nsI L\DC2\CANvo\EM\ap_P\tJ-Pk#ui\1049155\159822b[\1067444Yd\180222\35890\42013V|\49216q\1097565\r\CAN(yW?#+E\SO?Y\29463\63850\DC3\DLE3Q$\a'1\1110820%\r\153831@W\26659-\DC4\52370v\DC1\1013997a\r\SUB\NUL\1009250Jz\983893n\986832\133570\5867\163028O\t\ENQ\999543\&8\DLE\38142A\\$\5442\n)\1058130t\178355\166333CE\163128rpj \DC4O|}\72274\150000&1\1012087)\98960\138897\133873\53513", - cpNewPassword = + newPassword = plainTextPassword6Unsafe "\GSATD~\GS\EMK$\1042815h\993958PO\rPp6}vL}Q\SIO?\SO\SYN2!%\140960\98456\14965IQ\SYN\DC3T\DEL\EMb\\n.\EM\v6G)j\NAKo*G\vn\DC1q\DLE4\1057182\190279\FSG\a\1078359\CAN\STX5\US\r\SO&JH\v\v\GS\SOy\FSZ\1087012*\1054369C36Z\100291I\1006927d\167128\136845X\NAK}\vX\13655\ETBE@\1047002}S\DC1\44983:nS\1033701y\1032307\&6\a1\RSF\151742;\78820|\NAK0\1046714\ACK-\DC3'\1052372\t\DC1J\1031338\t\60374\ACK\1100306&\EOT\USfx\1078008\998032ev\t\17415^p\ESC\41380=1de]\988835zK\ETXt\168935\EM_\133793\SI\128297A\GS" } diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/PasswordReset_provider.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/PasswordReset_provider.hs index afccd106599..a652aed54af 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/PasswordReset_provider.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/PasswordReset_provider.hs @@ -23,7 +23,7 @@ import Wire.API.User.Identity (Email (Email, emailDomain, emailLocal)) testObject_PasswordReset_provider_1 :: PasswordReset testObject_PasswordReset_provider_1 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "\1086444\r\1014286bW\1044115\989541\1013077\r\ETX\SOH\ESCj\150487", emailDomain = "sC.\DC2PW" @@ -33,7 +33,7 @@ testObject_PasswordReset_provider_1 = testObject_PasswordReset_provider_2 :: PasswordReset testObject_PasswordReset_provider_2 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "mh\DC1\1112540\a\1111919#\63011e\994580m\122892\189689\161506D]", emailDomain = "\GSJ-0\123200DU~\7828\1089171\NAKF$" @@ -43,7 +43,7 @@ testObject_PasswordReset_provider_2 = testObject_PasswordReset_provider_3 :: PasswordReset testObject_PasswordReset_provider_3 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "-BP\CAN\1007058F\19503\1100657]W\1039512d\138837\1077790\ACK\GS\138454Dy\ESCx\CAN\158675uOU\987404\CAN\1075830\ACK", @@ -53,12 +53,12 @@ testObject_PasswordReset_provider_3 = testObject_PasswordReset_provider_4 :: PasswordReset testObject_PasswordReset_provider_4 = - PasswordReset {nprEmail = Email {emailLocal = "\ETX!\DC4]$Zp", emailDomain = "R\STX\DLEQ"}} + PasswordReset {email = Email {emailLocal = "\ETX!\DC4]$Zp", emailDomain = "R\STX\DLEQ"}} testObject_PasswordReset_provider_5 :: PasswordReset testObject_PasswordReset_provider_5 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "\1008001Mm\145584\FS9` \146161\994039\DLE\150684\ETX\44961]\1047951\27506\&2\SI\ETB\45081", emailDomain = "B\989792{\n\1049497O\EOT,P" @@ -68,7 +68,7 @@ testObject_PasswordReset_provider_5 = testObject_PasswordReset_provider_6 :: PasswordReset testObject_PasswordReset_provider_6 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "\5297\1059611\152727\ETXLv \1037185\&9", emailDomain = @@ -79,7 +79,7 @@ testObject_PasswordReset_provider_6 = testObject_PasswordReset_provider_7 :: PasswordReset testObject_PasswordReset_provider_7 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "\SOH\ETXrra\SOH4|]c&4%#Al\DC2*U\STX\82983m9\SOH\985551UQ\41944\1046828", emailDomain = "1G*\155832f\CANV\996525\15378\98283lR\51561" @@ -88,12 +88,12 @@ testObject_PasswordReset_provider_7 = testObject_PasswordReset_provider_8 :: PasswordReset testObject_PasswordReset_provider_8 = - PasswordReset {nprEmail = Email {emailLocal = "6\1063459C\37237(|\NUL\RS\133203", emailDomain = "\35140\EM\39282"}} + PasswordReset {email = Email {emailLocal = "6\1063459C\37237(|\NUL\RS\133203", emailDomain = "\35140\EM\39282"}} testObject_PasswordReset_provider_9 :: PasswordReset testObject_PasswordReset_provider_9 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "ui0^p\1017396\ETX\994732\DELu<8\"YgWb\bx[\RS},W\v\1043359\32800\SYN", emailDomain = "\b*\1030521\&0>*N`\134311\DC3 t" @@ -102,12 +102,12 @@ testObject_PasswordReset_provider_9 = testObject_PasswordReset_provider_10 :: PasswordReset testObject_PasswordReset_provider_10 = - PasswordReset {nprEmail = Email {emailLocal = "", emailDomain = "$y0=|\GS\1042508E\1079919!tN:"}} + PasswordReset {email = Email {emailLocal = "", emailDomain = "$y0=|\GS\1042508E\1079919!tN:"}} testObject_PasswordReset_provider_11 :: PasswordReset testObject_PasswordReset_provider_11 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "\57466\DLE\SOH\97075\40644K!z|\135037\&0\9622,1,\1083909\&4\\\38025Q", emailDomain = ">Xi\1078572\SOH\DC1:\1037092\180278\166228\SUB[\CAN.+uOgWp" @@ -117,7 +117,7 @@ testObject_PasswordReset_provider_11 = testObject_PasswordReset_provider_12 :: PasswordReset testObject_PasswordReset_provider_12 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "\1068401\168354\128598>", emailDomain = @@ -128,7 +128,7 @@ testObject_PasswordReset_provider_12 = testObject_PasswordReset_provider_13 :: PasswordReset testObject_PasswordReset_provider_13 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "\994700\&5\ACK\132331!\1085699\nVb\1027357nU&\1037025u\169968", emailDomain = "+I\176471q\1064856\SYN\1069753#A\163779\DLE}.\SOHu\1015059" @@ -136,12 +136,12 @@ testObject_PasswordReset_provider_13 = } testObject_PasswordReset_provider_14 :: PasswordReset -testObject_PasswordReset_provider_14 = PasswordReset {nprEmail = Email {emailLocal = "v", emailDomain = "\1090313"}} +testObject_PasswordReset_provider_14 = PasswordReset {email = Email {emailLocal = "v", emailDomain = "\1090313"}} testObject_PasswordReset_provider_15 :: PasswordReset testObject_PasswordReset_provider_15 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "+\150753~\1073496VFc\RS\1102900R\a\ESC4J_\1087106I\f\1043823Dj\DC1\EOT\62142q", emailDomain = "\1020153\138280n\1062475Gh?\vPXOO\v\1092723\DC2" @@ -151,7 +151,7 @@ testObject_PasswordReset_provider_15 = testObject_PasswordReset_provider_16 :: PasswordReset testObject_PasswordReset_provider_16 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "]\1111436Dn\b\NAK\n\17695\167052\ENQ\1024236\&2\r\1069249\1002489\1038720", emailDomain = "%L(\EM\1109782\STXk\EOTo\170961B\18655O*/+", emailDomain = "\48353"} } testObject_PasswordReset_provider_18 :: PasswordReset testObject_PasswordReset_provider_18 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "\FS\1022850\1012117^3\68431*(\1037814\99655", emailDomain = @@ -179,7 +179,7 @@ testObject_PasswordReset_provider_18 = testObject_PasswordReset_provider_19 :: PasswordReset testObject_PasswordReset_provider_19 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "x|\58643\1101318J8\1007195|%\142798'9\1089195\172026\1085440F\1098543xyP\1054659 4,", emailDomain = "!]w6:\SOHd4t(\1103884\1052833$\SOHrl9\9929\120677t8" @@ -189,7 +189,7 @@ testObject_PasswordReset_provider_19 = testObject_PasswordReset_provider_20 :: PasswordReset testObject_PasswordReset_provider_20 = PasswordReset - { nprEmail = + { email = Email { emailLocal = "\39795\&2\SYN)=Xd\155177}o", emailDomain = "4\SUB\188588\1054317g\NUL\1092307\984568Q`\\\SOU\1017696" diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/RemoveBotResponse_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/RemoveBotResponse_user.hs index 49b47d17aa8..7fd0d92a2f0 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/RemoveBotResponse_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/RemoveBotResponse_user.hs @@ -38,6 +38,7 @@ testObject_RemoveBotResponse_user_1 = (Qualified (Id (fromJust (UUID.fromString "00004166-0000-1e32-0000-52cb0000428d"))) (Domain "faraway.example.com")) (read "1864-05-07 01:13:35.741 UTC") ( EdMembersLeave + EdReasonRemoved ( QualifiedUserIdList { qualifiedUserIdList = [ Qualified (Id (fromJust (UUID.fromString "000038c1-0000-4a9c-0000-511300004c8b"))) (Domain "faraway.example.com"), diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UpdateClient_user.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UpdateClient_user.hs index 77b8879ce54..be89952db7d 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UpdateClient_user.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Generated/UpdateClient_user.hs @@ -21,7 +21,7 @@ module Test.Wire.API.Golden.Generated.UpdateClient_user where import Data.Map qualified as Map import Imports -import Wire.API.MLS.Credential +import Wire.API.MLS.CipherSuite import Wire.API.User.Client import Wire.API.User.Client.Prekey diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs index b6cba26bc13..2760f871daa 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual.hs @@ -37,6 +37,7 @@ import Test.Wire.API.Golden.Manual.ListConversations import Test.Wire.API.Golden.Manual.ListUsersById import Test.Wire.API.Golden.Manual.QualifiedUserClientPrekeyMap import Test.Wire.API.Golden.Manual.SearchResultContact +import Test.Wire.API.Golden.Manual.SubConversation import Test.Wire.API.Golden.Manual.TeamSize import Test.Wire.API.Golden.Manual.Token import Test.Wire.API.Golden.Manual.UserClientPrekeyMap @@ -73,7 +74,8 @@ tests = ], testGroup "ConversationEvent" $ testObjects - [ (testObject_Event_conversation_manual_1, "testObject_Event_conversation_manual_1.json") + [ (testObject_Event_conversation_manual_1, "testObject_Event_conversation_manual_1.json"), + (testObject_Event_conversation_manual_2, "testObject_Event_conversation_manual_2.json") ], testGroup "GetPaginatedConversationIds" $ testObjects @@ -148,6 +150,11 @@ tests = (testObject_TeamSize_2, "testObject_TeamSize_2.json"), (testObject_TeamSize_3, "testObject_TeamSize_3.json") ], + testGroup "PublicSubConversation" $ + testObjects + [ (testObject_PublicSubConversation_1, "testObject_PublicSubConversation_1.json"), + (testObject_PublicSubConversation_2, "testObject_PublicSubConversation_2.json") + ], testGroup "ListUsersById" $ testObjects [ (testObject_ListUsersById_user_1, "testObject_ListUsersById_user_1.json"), diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ConversationEvent.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ConversationEvent.hs index dac7d4534e7..ba1c36fbba2 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ConversationEvent.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ConversationEvent.hs @@ -23,6 +23,7 @@ import Data.Qualified (Qualified (..)) import Data.Time import Data.UUID qualified as UUID import Imports +import Wire.API.Conversation.Protocol qualified as P import Wire.API.Event.Conversation import Wire.API.MLS.SubConversation @@ -35,3 +36,13 @@ testObject_Event_conversation_manual_1 = evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, evtData = EdConvCodeDelete } + +testObject_Event_conversation_manual_2 :: Event +testObject_Event_conversation_manual_2 = + Event + { evtConv = Qualified {qUnqualified = Id (fromJust (UUID.fromString "2126ea99-ca79-43ea-ad99-a59616468e8e")), qDomain = Domain {_domainText = "example.com"}}, + evtSubConv = Nothing, + evtFrom = Qualified {qUnqualified = Id (fromJust (UUID.fromString "a471447c-aa30-4592-81b0-dec6c1c02bca")), qDomain = Domain {_domainText = "example.com"}}, + evtTime = UTCTime {utctDay = ModifiedJulianDay 58119, utctDayTime = 0}, + evtData = EdProtocolUpdate P.ProtocolMixedTag + } diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ConversationsResponse.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ConversationsResponse.hs index d03606a8f81..0a7b0342409 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ConversationsResponse.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/ConversationsResponse.hs @@ -25,6 +25,8 @@ import Data.Id (Id (Id)) import Data.Misc import Data.Qualified import Data.Set qualified as Set +import Data.Time.Calendar +import Data.Time.Clock import Data.UUID qualified as UUID import Imports import Wire.API.Conversation @@ -56,7 +58,7 @@ conv1 = cnvMetadata = ConversationMetadata { cnvmType = One2OneConv, - cnvmCreator = Id (fromJust (UUID.fromString "00000001-0000-0001-0000-000200000001")), + cnvmCreator = Just (Id (fromJust (UUID.fromString "00000001-0000-0001-0000-000200000001"))), cnvmAccess = [], cnvmAccessRoles = Set.empty, cnvmName = Just " 0", @@ -90,7 +92,7 @@ conv2 = cnvMetadata = ConversationMetadata { cnvmType = SelfConv, - cnvmCreator = Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000200000001")), + cnvmCreator = Just (Id (fromJust (UUID.fromString "00000000-0000-0000-0000-000200000001"))), cnvmAccess = [ InviteAccess, InviteAccess, @@ -128,5 +130,15 @@ conv2 = }, cmOthers = [] }, - cnvProtocol = ProtocolMLS (ConversationMLSData (GroupId "test_group") (Epoch 42) MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519) + cnvProtocol = + ProtocolMLS + ( ConversationMLSData + (GroupId "test_group") + (Epoch 42) + (Just timestamp) + MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + ) } + where + timestamp :: UTCTime + timestamp = UTCTime (fromGregorian 2023 1 17) (secondsToDiffTime 42) diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/SubConversation.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/SubConversation.hs new file mode 100644 index 00000000000..f885593fa42 --- /dev/null +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Manual/SubConversation.hs @@ -0,0 +1,86 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Wire.API.Golden.Manual.SubConversation + ( testObject_PublicSubConversation_1, + testObject_PublicSubConversation_2, + ) +where + +import Data.Domain +import Data.Id +import Data.Qualified +import Data.Time.Calendar +import Data.Time.Clock +import Data.UUID qualified as UUID +import Imports +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.Credential +import Wire.API.MLS.Epoch +import Wire.API.MLS.Group +import Wire.API.MLS.SubConversation + +subConvId1 :: SubConvId +subConvId1 = SubConvId "test_group" + +subConvId2 :: SubConvId +subConvId2 = SubConvId "call" + +domain :: Domain +domain = Domain "golden.example.com" + +convId :: Qualified ConvId +convId = + Qualified + ( Id (fromJust (UUID.fromString "00000000-0000-0001-0000-000100000001")) + ) + domain + +testObject_PublicSubConversation_1 :: PublicSubConversation +testObject_PublicSubConversation_1 = + PublicSubConversation + convId + subConvId1 + (GroupId "test_group") + (Epoch 5) + (Just (UTCTime day fromMidnight)) + MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + [] + where + fromMidnight :: DiffTime + fromMidnight = 42 + day :: Day + day = fromGregorian 2023 1 17 + +testObject_PublicSubConversation_2 :: PublicSubConversation +testObject_PublicSubConversation_2 = + PublicSubConversation + convId + subConvId2 + (GroupId "test_group_2") + (Epoch 0) + Nothing + MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + [mkClientIdentity user cid] + where + user :: Qualified UserId + user = + Qualified + ( Id (fromJust (UUID.fromString "00000000-0000-0007-0000-000a00000002")) + ) + domain + cid = ClientId "deadbeef" diff --git a/libs/wire-api/test/golden/Main.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Run.hs similarity index 96% rename from libs/wire-api/test/golden/Main.hs rename to libs/wire-api/test/golden/Test/Wire/API/Golden/Run.hs index 0169e038d8b..3ce3befcd26 100644 --- a/libs/wire-api/test/golden/Main.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Run.hs @@ -15,10 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Main - ( main, - ) -where +module Test.Wire.API.Golden.Run (main) where import Imports import Test.Tasty diff --git a/libs/wire-api/test/golden/Test/Wire/API/Golden/Runner.hs b/libs/wire-api/test/golden/Test/Wire/API/Golden/Runner.hs index 13fdf5510cd..f4c9a736005 100644 --- a/libs/wire-api/test/golden/Test/Wire/API/Golden/Runner.hs +++ b/libs/wire-api/test/golden/Test/Wire/API/Golden/Runner.hs @@ -32,12 +32,11 @@ import Data.ByteString qualified as ByteString import Data.ByteString.Lazy qualified as LBS import Data.ProtoLens.Encoding (decodeMessage, encodeMessage) import Data.ProtoLens.Message (Message) -import Data.ProtoLens.TextFormat (pprintMessage, readMessage) +import Data.ProtoLens.TextFormat (readMessage, showMessage) import Data.Text.Lazy.IO qualified as LText import Imports import Test.Tasty (TestTree) import Test.Tasty.HUnit -import Text.PrettyPrint (render) import Type.Reflection (typeRep) import Wire.API.ServantProto @@ -93,7 +92,7 @@ protoTestObject :: protoTestObject obj path = do let actual = toProto obj msg <- assertRight (decodeMessage @m actual) - let pretty = render (pprintMessage msg) + let pretty = showMessage msg dir = "test/golden" fullPath = dir <> "/" <> path createDirectoryIfMissing True dir diff --git a/libs/wire-api/test/golden/testObject_ConversationsResponse_1.json b/libs/wire-api/test/golden/testObject_ConversationsResponse_1.json index 3bba9904761..1156816764e 100644 --- a/libs/wire-api/test/golden/testObject_ConversationsResponse_1.json +++ b/libs/wire-api/test/golden/testObject_ConversationsResponse_1.json @@ -72,6 +72,7 @@ "cipher_suite": 1, "creator": "00000000-0000-0000-0000-000200000001", "epoch": 42, + "epoch_timestamp": "2023-01-17T00:00:42Z", "group_id": "dGVzdF9ncm91cA==", "id": "00000000-0000-0000-0000-000000000002", "last_event": "0.0", diff --git a/libs/wire-api/test/golden/testObject_ConversationsResponse_v2_1.json b/libs/wire-api/test/golden/testObject_ConversationsResponse_v2_1.json index 0bba3b7e155..cf0cc2893b9 100644 --- a/libs/wire-api/test/golden/testObject_ConversationsResponse_v2_1.json +++ b/libs/wire-api/test/golden/testObject_ConversationsResponse_v2_1.json @@ -74,6 +74,7 @@ "cipher_suite": 1, "creator": "00000000-0000-0000-0000-000200000001", "epoch": 42, + "epoch_timestamp": "2023-01-17T00:00:42Z", "group_id": "dGVzdF9ncm91cA==", "id": "00000000-0000-0000-0000-000000000002", "last_event": "0.0", diff --git a/libs/wire-api/test/golden/testObject_Event_conversation_9.json b/libs/wire-api/test/golden/testObject_Event_conversation_9.json index 028aeb144dc..e83525ac019 100644 --- a/libs/wire-api/test/golden/testObject_Event_conversation_9.json +++ b/libs/wire-api/test/golden/testObject_Event_conversation_9.json @@ -63,6 +63,7 @@ "id": "2126ea99-ca79-43ea-ad99-a59616468e8e" } ], + "reason": "left", "user_ids": [ "2126ea99-ca79-43ea-ad99-a59616468e8e", "2126ea99-ca79-43ea-ad99-a59616468e8e", diff --git a/libs/wire-api/test/golden/testObject_Event_conversation_manual_2.json b/libs/wire-api/test/golden/testObject_Event_conversation_manual_2.json new file mode 100644 index 00000000000..2241f2df415 --- /dev/null +++ b/libs/wire-api/test/golden/testObject_Event_conversation_manual_2.json @@ -0,0 +1,17 @@ +{ + "conversation": "2126ea99-ca79-43ea-ad99-a59616468e8e", + "data": { + "protocol": "mixed" + }, + "from": "a471447c-aa30-4592-81b0-dec6c1c02bca", + "qualified_conversation": { + "domain": "example.com", + "id": "2126ea99-ca79-43ea-ad99-a59616468e8e" + }, + "qualified_from": { + "domain": "example.com", + "id": "a471447c-aa30-4592-81b0-dec6c1c02bca" + }, + "time": "2018-01-01T00:00:00.000Z", + "type": "conversation.protocol-update" +} diff --git a/libs/wire-api/test/golden/testObject_Event_user_11.json b/libs/wire-api/test/golden/testObject_Event_user_11.json index 8acfbce8fae..870249332d7 100644 --- a/libs/wire-api/test/golden/testObject_Event_user_11.json +++ b/libs/wire-api/test/golden/testObject_Event_user_11.json @@ -11,6 +11,7 @@ "id": "00001c48-0000-29ae-0000-62fc00001479" } ], + "reason": "left", "user_ids": [ "00003fab-0000-40b8-0000-3b0c000014ef", "00001c48-0000-29ae-0000-62fc00001479" diff --git a/libs/wire-api/test/golden/testObject_PublicSubConversation_1.json b/libs/wire-api/test/golden/testObject_PublicSubConversation_1.json new file mode 100644 index 00000000000..05ce835507a --- /dev/null +++ b/libs/wire-api/test/golden/testObject_PublicSubConversation_1.json @@ -0,0 +1,12 @@ +{ + "cipher_suite": 1, + "epoch": 5, + "epoch_timestamp": "2023-01-17T00:00:42Z", + "group_id": "dGVzdF9ncm91cA==", + "members": [], + "parent_qualified_id": { + "domain": "golden.example.com", + "id": "00000000-0000-0001-0000-000100000001" + }, + "subconv_id": "test_group" +} diff --git a/libs/wire-api/test/golden/testObject_PublicSubConversation_2.json b/libs/wire-api/test/golden/testObject_PublicSubConversation_2.json new file mode 100644 index 00000000000..a918c3161ba --- /dev/null +++ b/libs/wire-api/test/golden/testObject_PublicSubConversation_2.json @@ -0,0 +1,18 @@ +{ + "cipher_suite": 1, + "epoch": 0, + "epoch_timestamp": null, + "group_id": "dGVzdF9ncm91cF8y", + "members": [ + { + "client_id": "deadbeef", + "domain": "golden.example.com", + "user_id": "00000000-0000-0007-0000-000a00000002" + } + ], + "parent_qualified_id": { + "domain": "golden.example.com", + "id": "00000000-0000-0001-0000-000100000001" + }, + "subconv_id": "call" +} diff --git a/libs/wire-api/test/golden/testObject_RemoveBotResponse_user_1.json b/libs/wire-api/test/golden/testObject_RemoveBotResponse_user_1.json index 2bd38208dc9..d5a64addc4a 100644 --- a/libs/wire-api/test/golden/testObject_RemoveBotResponse_user_1.json +++ b/libs/wire-api/test/golden/testObject_RemoveBotResponse_user_1.json @@ -12,6 +12,7 @@ "id": "00003111-0000-2620-0000-1c8800000ea0" } ], + "reason": "removed", "user_ids": [ "000038c1-0000-4a9c-0000-511300004c8b", "00003111-0000-2620-0000-1c8800000ea0" diff --git a/libs/wire-api/test/resources/key_package1.mls b/libs/wire-api/test/resources/key_package1.mls index 8023c690792..bcb18cb6755 100644 Binary files a/libs/wire-api/test/resources/key_package1.mls and b/libs/wire-api/test/resources/key_package1.mls differ diff --git a/libs/wire-api/test/unit.hs b/libs/wire-api/test/unit.hs new file mode 100644 index 00000000000..dbf3fb9acb9 --- /dev/null +++ b/libs/wire-api/test/unit.hs @@ -0,0 +1,5 @@ +import Imports +import qualified Test.Wire.API.Run as Run + +main :: IO () +main = Run.main diff --git a/libs/wire-api/test/unit/Test/Wire/API/MLS.hs b/libs/wire-api/test/unit/Test/Wire/API/MLS.hs index e7ee89b683d..efd3fa48a58 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/MLS.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/MLS.hs @@ -19,36 +19,36 @@ module Test.Wire.API.MLS where import Control.Concurrent.Async import Crypto.PubKey.Ed25519 qualified as Ed25519 -import Data.ByteArray +import Data.ByteArray hiding (length) import Data.ByteString qualified as BS -import Data.ByteString.Lazy qualified as LBS +import Data.ByteString.Char8 qualified as B8 import Data.Domain -import Data.Either.Combinators -import Data.Hex import Data.Id import Data.Json.Util (toBase64Text) import Data.Qualified import Data.Text qualified as T import Data.Text qualified as Text -import Data.UUID qualified as UUID import Data.UUID.V4 qualified as UUID import Imports import System.Exit import System.FilePath (()) import System.Process +import System.Random import Test.Tasty import Test.Tasty.HUnit import UnliftIO (withSystemTempDirectory) +import Wire.API.MLS.AuthenticatedContent import Wire.API.MLS.CipherSuite import Wire.API.MLS.Commit import Wire.API.MLS.Credential import Wire.API.MLS.Epoch -import Wire.API.MLS.Extension import Wire.API.MLS.Group +import Wire.API.MLS.GroupInfo +import Wire.API.MLS.HPKEPublicKey import Wire.API.MLS.KeyPackage import Wire.API.MLS.Message import Wire.API.MLS.Proposal -import Wire.API.MLS.PublicGroupState +import Wire.API.MLS.ProtocolVersion import Wire.API.MLS.Serialisation import Wire.API.MLS.Welcome @@ -56,119 +56,150 @@ tests :: TestTree tests = testGroup "MLS" $ [ testCase "parse key package" testParseKeyPackage, + testCase "parse capabilities in key package" testParseKeyPackageWithCapabilities, testCase "parse commit message" testParseCommit, testCase "parse application message" testParseApplication, - testCase "parse welcome message" testParseWelcome, + testCase "parse welcome and groupinfo message" testParseWelcomeAndGroupInfo, testCase "key package ref" testKeyPackageRef, - testCase "validate message signature" testVerifyMLSPlainTextWithKey, - testCase "create signed remove proposal" testRemoveProposalMessageSignature, - testCase "parse GroupInfoBundle" testParseGroupInfoBundle -- TODO: remove this also + testCase "create signed remove proposal" testRemoveProposalMessageSignature ] testParseKeyPackage :: IO () testParseKeyPackage = do - kpData <- BS.readFile "test/resources/key_package1.mls" + alice <- randomIdentity + let qcid = B8.unpack (encodeMLS' alice) + kpData <- withSystemTempDirectory "mls" $ \tmp -> do + void $ spawn (cli qcid tmp ["init", qcid]) Nothing + spawn (cli qcid tmp ["key-package", "create"]) Nothing + kp <- case decodeMLS' @KeyPackage kpData of Left err -> assertFailure (T.unpack err) Right x -> pure x - pvTag (kpProtocolVersion kp) @?= Just ProtocolMLS10 - kpCipherSuite kp @?= CipherSuite 1 - BS.length (kpInitKey kp) @?= 32 + pvTag (kp.protocolVersion) @?= Just ProtocolMLS10 + kp.cipherSuite @?= CipherSuite 1 + BS.length (unHPKEPublicKey kp.initKey) @?= 32 - case decodeMLS' @ClientIdentity (bcIdentity (kpCredential kp)) of + case keyPackageIdentity kp of Left err -> assertFailure $ "Failed to parse identity: " <> T.unpack err - Right identity -> - identity - @?= ClientIdentity - { ciDomain = Domain "mls.example.com", - ciUser = Id (fromJust (UUID.fromString "b455a431-9db6-4404-86e7-6a3ebe73fcaf")), - ciClient = newClientId 0x3ae58155 - } - - -- check raw TBS package - let rawTBS = rmRaw (kpTBS kp) - rawTBS @?= BS.take 196 kpData + Right identity -> identity @?= alice + +testParseKeyPackageWithCapabilities :: IO () +testParseKeyPackageWithCapabilities = do + kpData <- BS.readFile "test/resources/key_package1.mls" + case decodeMLS' @KeyPackage kpData of + Left err -> assertFailure (T.unpack err) + Right _ -> pure () testParseCommit :: IO () testParseCommit = do - msgData <- LBS.readFile "test/resources/commit1.mls" - msg :: Message 'MLSPlainText <- case decodeMLS @SomeMessage msgData of + qcid <- B8.unpack . encodeMLS' <$> randomIdentity + commitData <- withSystemTempDirectory "mls" $ \tmp -> do + void $ spawn (cli qcid tmp ["init", qcid]) Nothing + groupJSON <- spawn (cli qcid tmp ["group", "create", "Zm9v"]) Nothing + spawn (cli qcid tmp ["commit", "--group", "-"]) (Just groupJSON) + + msg <- case decodeMLS' @Message commitData of Left err -> assertFailure (T.unpack err) - Right (SomeMessage SMLSCipherText _) -> - assertFailure "Expected plain text message, found encrypted" - Right (SomeMessage SMLSPlainText msg) -> - pure msg + Right x -> pure x + + pvTag (msg.protocolVersion) @?= Just ProtocolMLS10 - msgGroupId msg @?= "test_group" - msgEpoch msg @?= Epoch 0 + pmsg <- case msg.content of + MessagePublic x -> pure x + _ -> assertFailure "expected public message" - case msgSender msg of - MemberSender kp -> kp @?= KeyPackageRef (fromRight' (unhex "24e4b0a802a2b81f00a9af7df5e91da8")) - _ -> assertFailure "Unexpected sender type" + pmsg.content.value.sender @?= SenderMember 0 - let payload = msgPayload msg - commit <- case payload of - CommitMessage c -> pure c - _ -> assertFailure "Unexpected message type" + commit <- case pmsg.content.value.content of + FramedContentCommit c -> pure c + _ -> assertFailure "expected commit" - case cProposals commit of - [Inline (AddProposal _)] -> pure () - _ -> assertFailure "Unexpected proposals" + commit.value.proposals @?= [] testParseApplication :: IO () testParseApplication = do - msgData <- LBS.readFile "test/resources/app_message1.mls" - msg :: Message 'MLSCipherText <- case decodeMLS @SomeMessage msgData of - Left err -> assertFailure (T.unpack err) - Right (SomeMessage SMLSCipherText msg) -> pure msg - Right (SomeMessage SMLSPlainText _) -> - assertFailure "Expected encrypted message, found plain text" - - msgGroupId msg @?= "test_group" - msgEpoch msg @?= Epoch 0 - msgContentType (msgPayload msg) @?= fromMLSEnum ApplicationMessageTag - -testParseWelcome :: IO () -testParseWelcome = do - welData <- LBS.readFile "test/resources/welcome1.mls" - wel <- case decodeMLS welData of + qcid <- B8.unpack . encodeMLS' <$> randomIdentity + msgData <- withSystemTempDirectory "mls" $ \tmp -> do + void $ spawn (cli qcid tmp ["init", qcid]) Nothing + groupJSON <- spawn (cli qcid tmp ["group", "create", "Zm9v"]) Nothing + spawn (cli qcid tmp ["message", "--group", "-", "hello"]) (Just groupJSON) + + msg <- case decodeMLS' @Message msgData of Left err -> assertFailure (T.unpack err) Right x -> pure x - welCipherSuite wel @?= CipherSuite 1 - map gsNewMember (welSecrets wel) @?= [KeyPackageRef (fromRight' (unhex "ab4692703ca6d50ffdeaae3096f885c2"))] + pvTag (msg.protocolVersion) @?= Just ProtocolMLS10 + + pmsg <- case msg.content of + MessagePrivate x -> pure x.value + _ -> assertFailure "expected private message" + + pmsg.groupId @?= GroupId "foo" + pmsg.epoch @?= Epoch 0 + +testParseWelcomeAndGroupInfo :: IO () +testParseWelcomeAndGroupInfo = do + qcid <- B8.unpack . encodeMLS' <$> randomIdentity + qcid2 <- B8.unpack . encodeMLS' <$> randomIdentity + (welData, giData) <- withSystemTempDirectory "mls" $ \tmp -> do + void $ spawn (cli qcid tmp ["init", qcid]) Nothing + void $ spawn (cli qcid2 tmp ["init", qcid2]) Nothing + groupJSON <- spawn (cli qcid tmp ["group", "create", "Zm9v"]) Nothing + kp <- spawn (cli qcid2 tmp ["key-package", "create"]) Nothing + BS.writeFile (tmp "kp") kp + void $ + spawn + ( cli + qcid + tmp + [ "member", + "add", + "--group", + "-", + tmp "kp", + "--welcome-out", + tmp "welcome", + "--group-info-out", + tmp "gi" + ] + ) + (Just groupJSON) + (,) + <$> BS.readFile (tmp "welcome") + <*> BS.readFile (tmp "gi") + + do + welcomeMsg <- case decodeMLS' @Message welData of + Left err -> assertFailure (T.unpack err) + Right x -> pure x + + pvTag (welcomeMsg.protocolVersion) @?= Just ProtocolMLS10 + + wel <- case welcomeMsg.content of + MessageWelcome x -> pure x.value + _ -> assertFailure "expected welcome message" + + length (wel.welSecrets) @?= 1 + + do + gi <- case decodeMLS' @GroupInfo giData of + Left err -> assertFailure (T.unpack err) + Right x -> pure x + + gi.groupContext.groupId @?= GroupId "foo" + gi.groupContext.epoch @?= Epoch 1 testKeyPackageRef :: IO () testKeyPackageRef = do - kpData <- BS.readFile "test/resources/key_package1.mls" - ref <- KeyPackageRef <$> BS.readFile "test/resources/key_package_ref1" - kpRef MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 (KeyPackageData kpData) @?= ref - -testVerifyMLSPlainTextWithKey :: IO () -testVerifyMLSPlainTextWithKey = do - -- this file was created with openmls from the client that is in the add proposal - msgData <- BS.readFile "test/resources/external_proposal.mls" + let qcid = "b455a431-9db6-4404-86e7-6a3ebe73fcaf:3ae58155@mls.example.com" + (kpData, ref) <- withSystemTempDirectory "mls" $ \tmp -> do + void $ spawn (cli qcid tmp ["init", qcid]) Nothing + kpData <- spawn (cli qcid tmp ["key-package", "create"]) Nothing + ref <- spawn (cli qcid tmp ["key-package", "ref", "-"]) (Just kpData) + pure (kpData, KeyPackageRef ref) - msg :: Message 'MLSPlainText <- case decodeMLS' @SomeMessage msgData of - Left err -> assertFailure (T.unpack err) - Right (SomeMessage SMLSCipherText _) -> - assertFailure "Expected SomeMessage SMLSCipherText" - Right (SomeMessage SMLSPlainText msg) -> - pure msg - - kp <- case msgPayload msg of - ProposalMessage prop -> - case rmValue prop of - AddProposal kp -> pure kp - _ -> error "Expected AddProposal" - _ -> error "Expected ProposalMessage" - - let pubkey = bcSignatureKey . kpCredential . rmValue $ kp - liftIO - $ assertBool - "message signature verification failed" - $ verifyMessageSignature MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 msg pubkey + kpRef MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 (KeyPackageData kpData) @?= ref testRemoveProposalMessageSignature :: IO () testRemoveProposalMessageSignature = withSystemTempDirectory "mls" $ \tmp -> do @@ -176,32 +207,42 @@ testRemoveProposalMessageSignature = withSystemTempDirectory "mls" $ \tmp -> do let c = newClientId 0x3ae58155 usr <- flip Qualified (Domain "example.com") <$> (Id <$> UUID.nextRandom) pure (userClientQid usr c) - void . liftIO $ spawn (cli qcid tmp ["init", qcid]) Nothing + void $ spawn (cli qcid tmp ["init", qcid]) Nothing qcid2 <- do let c = newClientId 0x4ae58157 usr <- flip Qualified (Domain "example.com") <$> (Id <$> UUID.nextRandom) pure (userClientQid usr c) - void . liftIO $ spawn (cli qcid2 tmp ["init", qcid2]) Nothing - kp <- liftIO $ decodeMLSError <$> spawn (cli qcid2 tmp ["key-package", "create"]) Nothing - liftIO $ BS.writeFile (tmp qcid2) (rmRaw kp) + void $ spawn (cli qcid2 tmp ["init", qcid2]) Nothing + kp :: RawMLS KeyPackage <- + decodeMLSError <$> spawn (cli qcid2 tmp ["key-package", "create"]) Nothing + BS.writeFile (tmp qcid2) (raw kp) + secretKey <- Ed25519.generateSecretKey let groupFilename = "group" - let gid = GroupId "abcd" - createGroup tmp qcid groupFilename gid + gid = GroupId "abcd" + signerKeyFilename = "signer-key.bin" + publicKey = Ed25519.toPublic secretKey + BS.writeFile (tmp signerKeyFilename) (convert publicKey) + createGroup tmp qcid groupFilename signerKeyFilename gid - void $ liftIO $ spawn (cli qcid tmp ["member", "add", "--group", tmp groupFilename, "--in-place", tmp qcid2]) Nothing + void $ spawn (cli qcid tmp ["member", "add", "--group", tmp groupFilename, "--in-place", tmp qcid2]) Nothing - secretKey <- Ed25519.generateSecretKey - let publicKey = Ed25519.toPublic secretKey - let message = mkSignedMessage secretKey publicKey gid (Epoch 1) (ProposalMessage (mkRemoveProposal (fromJust (kpRef' kp)))) + let proposal = mkRawMLS (RemoveProposal 1) + pmessage = + mkSignedPublicMessage + secretKey + publicKey + gid + (Epoch 1) + (TaggedSenderExternal 0) + (FramedContentProposal proposal) + message = mkMessage $ MessagePublic pmessage + messageFilename = "signed-message.mls" - let messageFilename = "signed-message.mls" - BS.writeFile (tmp messageFilename) (rmRaw (mkRawMLS message)) - let signerKeyFilename = "signer-key.bin" - BS.writeFile (tmp signerKeyFilename) (convert publicKey) + BS.writeFile (tmp messageFilename) (raw (mkRawMLS message)) - void . liftIO $ + void $ spawn ( cli qcid @@ -209,65 +250,25 @@ testRemoveProposalMessageSignature = withSystemTempDirectory "mls" $ \tmp -> do [ "consume", "--group", tmp groupFilename, - "--signer-key", - tmp signerKeyFilename, tmp messageFilename ] ) Nothing -testParseGroupInfoBundle :: IO () -testParseGroupInfoBundle = withSystemTempDirectory "mls" $ \tmp -> do - qcid <- do - let c = newClientId 0x3ae58155 - usr <- flip Qualified (Domain "example.com") <$> (Id <$> UUID.nextRandom) - pure (userClientQid usr c) - void . liftIO $ spawn (cli qcid tmp ["init", qcid]) Nothing - - qcid2 <- do - let c = newClientId 0x4ae58157 - usr <- flip Qualified (Domain "example.com") <$> (Id <$> UUID.nextRandom) - pure (userClientQid usr c) - void . liftIO $ spawn (cli qcid2 tmp ["init", qcid2]) Nothing - kp :: RawMLS KeyPackage <- liftIO $ decodeMLSError <$> spawn (cli qcid2 tmp ["key-package", "create"]) Nothing - liftIO $ BS.writeFile (tmp qcid2) (rmRaw kp) - - let groupFilename = "group" - let gid = GroupId "abcd" - createGroup tmp qcid groupFilename gid - - void $ - liftIO $ - spawn - ( cli - qcid - tmp - [ "member", - "add", - "--group", - tmp groupFilename, - "--in-place", - tmp qcid2, - "--group-state-out", - tmp "group-info-bundle" - ] - ) - Nothing - - bundleBS <- BS.readFile (tmp "group-info-bundle") - case decodeMLS' @PublicGroupState bundleBS of - Left err -> assertFailure ("Failed parsing PublicGroupState: " <> T.unpack err) - Right _ -> pure () - -createGroup :: FilePath -> String -> String -> GroupId -> IO () -createGroup tmp store groupName gid = do +createGroup :: FilePath -> String -> String -> String -> GroupId -> IO () +createGroup tmp store groupName removalKey gid = do groupJSON <- liftIO $ spawn ( cli store tmp - ["group", "create", T.unpack (toBase64Text (unGroupId gid))] + [ "group", + "create", + "--removal-key", + tmp removalKey, + T.unpack (toBase64Text (unGroupId gid)) + ] ) Nothing liftIO $ BS.writeFile (tmp groupName) groupJSON @@ -281,7 +282,7 @@ userClientQid :: Qualified UserId -> ClientId -> String userClientQid usr c = show (qUnqualified usr) <> ":" - <> T.unpack (client c) + <> T.unpack c.client <> "@" <> T.unpack (domainText (qDomain usr)) @@ -306,3 +307,9 @@ cli :: String -> FilePath -> [String] -> CreateProcess cli store tmp args = proc "mls-test-cli" $ ["--store", tmp (store <> ".db")] <> args + +randomIdentity :: IO ClientIdentity +randomIdentity = do + uid <- Id <$> UUID.nextRandom + c <- newClientId <$> randomIO + pure $ ClientIdentity (Domain "mls.example.com") uid c diff --git a/libs/wire-api/test/unit/Test/Wire/API/MLS/Group.hs b/libs/wire-api/test/unit/Test/Wire/API/MLS/Group.hs new file mode 100644 index 00000000000..d731b10f5d9 --- /dev/null +++ b/libs/wire-api/test/unit/Test/Wire/API/MLS/Group.hs @@ -0,0 +1,56 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Wire.API.MLS.Group where + +import Data.Qualified +import Imports +import Test.QuickCheck +import Test.Tasty +import Test.Tasty.QuickCheck +import Wire.API.Conversation +import Wire.API.MLS.Group +import Wire.API.MLS.Group.Serialisation +import Wire.API.MLS.SubConversation + +tests :: TestTree +tests = + testGroup + "Group" + [ testProperty "roundtrip serialise and parse groupId" $ roundtripGroupId + ] + +roundtripGroupId :: ConvType -> Qualified ConvOrSubConvId -> GroupIdGen -> Property +roundtripGroupId ct convId gen = + let gen' = case qUnqualified convId of + (Conv _) -> GroupIdGen 0 + (SubConv _ _) -> gen + in groupIdToConv + ( convToGroupId + GroupIdParts + { convType = ct, + qConvId = convId, + gidGen = gen + } + ) + === Right + ( GroupIdParts + { convType = ct, + qConvId = convId, + gidGen = gen' + } + ) diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs index 79433dbd2d7..aefaa6cb8cd 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/Aeson.hs @@ -20,7 +20,7 @@ module Test.Wire.API.Roundtrip.Aeson (tests) where import Data.Aeson (FromJSON, ToJSON, parseJSON, toJSON) import Data.Aeson.Types (parseEither) import Data.Id (ConvId) -import Data.Swagger (ToSchema, validatePrettyToJSON) +import Data.OpenApi (ToSchema, validatePrettyToJSON) import Imports import Test.Tasty qualified as T import Test.Tasty.QuickCheck (Arbitrary, counterexample, testProperty, (.&&.), (===)) @@ -355,7 +355,7 @@ testRoundTrip = testProperty msg trip testRoundTripWithSwagger :: forall a. - (Arbitrary a, Typeable a, ToJSON a, FromJSON a, ToSchema a, Eq a, Show a) => + (Arbitrary a, ToJSON a, FromJSON a, ToSchema a, Eq a, Show a) => T.TestTree testRoundTripWithSwagger = testProperty msg (trip .&&. scm) where diff --git a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/MLS.hs b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/MLS.hs index c6f14ce1352..d8b6ec7f552 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/MLS.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Roundtrip/MLS.hs @@ -1,4 +1,5 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} + -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -15,24 +16,23 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -{-# OPTIONS_GHC -Wwarn #-} module Test.Wire.API.Roundtrip.MLS (tests) where -import Data.Binary.Put +import Data.Hex import Imports -import Proto.Mls qualified import Test.Tasty qualified as T import Test.Tasty.QuickCheck import Type.Reflection (typeRep) -import Wire.API.ConverProtoLens +import Wire.API.MLS.Commit import Wire.API.MLS.CommitBundle +import Wire.API.MLS.Credential import Wire.API.MLS.Extension -import Wire.API.MLS.GroupInfoBundle +import Wire.API.MLS.GroupInfo import Wire.API.MLS.KeyPackage +import Wire.API.MLS.LeafNode import Wire.API.MLS.Message import Wire.API.MLS.Proposal -import Wire.API.MLS.PublicGroupState import Wire.API.MLS.Serialisation import Wire.API.MLS.Welcome @@ -40,17 +40,21 @@ tests :: T.TestTree tests = T.localOption (T.Timeout (60 * 1000000) "60s") . T.testGroup "MLS roundtrip tests" $ [ testRoundTrip @KeyPackageRef, + testRoundTrip @LeafNode, + testRoundTrip @LeafNodeCore, + testRoundTrip @KeyPackageTBS, + testRoundTrip @Credential, + testRoundTrip @ClientIdentity, testRoundTrip @TestPreconfiguredSender, testRoundTrip @RemoveProposalMessage, testRoundTrip @RemoveProposalPayload, - testRoundTrip @AppAckProposalTest, testRoundTrip @ExtensionVector, - testRoundTrip @PublicGroupStateTBS, - testRoundTrip @PublicGroupState, + testRoundTrip @GroupInfoData, + testRoundTrip @TestCommitBundle, testRoundTrip @Welcome, - testRoundTrip @OpaquePublicGroupState, - testConvertProtoRoundTrip @Proto.Mls.GroupInfoBundle @GroupInfoBundle, - testConvertProtoRoundTrip @Proto.Mls.CommitBundle @TestCommitBundle + testRoundTrip @Proposal, + testRoundTrip @ProposalRef, + testRoundTrip @VarInt ] testRoundTrip :: @@ -61,138 +65,125 @@ testRoundTrip = testProperty msg trip where msg = show (typeRep @a) trip (v :: a) = - counterexample (show (runPut (serialiseMLS v))) $ - Right v === (decodeMLS . runPut . serialiseMLS) v - -testConvertProtoRoundTrip :: - forall p a. - ( Arbitrary a, - Typeable a, - Show a, - Show p, - Eq a, - ConvertProtoLens p a - ) => - T.TestTree -testConvertProtoRoundTrip = testProperty (show (typeRep @a)) trip - where - trip (v :: a) = - counterexample (show (toProtolens @p @a v)) $ - Right v === do - let pa = toProtolens @p @a v - fromProtolens @p @a pa + let serialised = encodeMLS v + parsed = decodeMLS serialised + in counterexample (show $ hex serialised) $ + Right v === parsed -------------------------------------------------------------------------------- -- auxiliary types class ArbitrarySender a where - arbitrarySender :: Gen (Sender 'MLSPlainText) + arbitrarySender :: Gen Sender -class ArbitraryMessagePayload a where - arbitraryMessagePayload :: Gen (MessagePayload 'MLSPlainText) +instance ArbitrarySender Sender where + arbitrarySender = arbitrary -class ArbitraryMessageTBS a where - arbitraryArbitraryMessageTBS :: Gen (MessageTBS 'MLSPlainText) +class ArbitraryFramedContentData a where + arbitraryFramedContentData :: Gen FramedContentData -newtype MessageGenerator tbs = MessageGenerator {unMessageGenerator :: Message 'MLSPlainText} - deriving newtype (ParseMLS, SerialiseMLS, Eq, Show) +class ArbitraryFramedContent a where + arbitraryFramedContent :: Gen FramedContent -instance (ArbitraryMessageTBS tbs) => Arbitrary (MessageGenerator tbs) where - arbitrary = do - tbs <- arbitraryArbitraryMessageTBS @tbs - MessageGenerator - <$> (Message (mkRawMLS tbs) <$> arbitrary) +newtype MessageGenerator fc = MessageGenerator {unMessageGenerator :: Message} + deriving newtype (ParseMLS, SerialiseMLS, Eq, Show) -data MessageTBSGenerator sender payload +instance ArbitraryFramedContent fc => Arbitrary (MessageGenerator fc) where + arbitrary = + fmap MessageGenerator $ do + fc <- arbitraryFramedContent @fc + mt <- case fc.sender of + SenderMember _ -> Just <$> arbitrary + _ -> pure Nothing + confirmationTag <- case fc.content of + FramedContentCommit _ -> Just <$> arbitrary + _ -> pure Nothing + Message + <$> arbitrary + <*> fmap + MessagePublic + ( PublicMessage (mkRawMLS fc) + <$> (mkRawMLS <$> (FramedContentAuthData <$> arbitrary <*> pure confirmationTag)) + <*> pure mt + ) + +data FramedContentGenerator sender payload instance ( ArbitrarySender sender, - ArbitraryMessagePayload payload + ArbitraryFramedContentData payload ) => - ArbitraryMessageTBS (MessageTBSGenerator sender payload) + ArbitraryFramedContent (FramedContentGenerator sender payload) where - arbitraryArbitraryMessageTBS = - MessageTBS KnownFormatTag + arbitraryFramedContent = + FramedContent <$> arbitrary <*> arbitrary - <*> arbitrary <*> arbitrarySender @sender - <*> arbitraryMessagePayload @payload + <*> arbitrary + <*> arbitraryFramedContentData @payload --- -newtype RemoveProposalMessage = RemoveProposalMessage {unRemoveProposalMessage :: Message 'MLSPlainText} +newtype RemoveProposalMessage = RemoveProposalMessage Message deriving newtype (ParseMLS, SerialiseMLS, Eq, Show) instance Arbitrary RemoveProposalMessage where arbitrary = RemoveProposalMessage - <$> (unMessageGenerator <$> arbitrary @(MessageGenerator (MessageTBSGenerator TestPreconfiguredSender RemoveProposalPayload))) + <$> (unMessageGenerator <$> arbitrary @(MessageGenerator (FramedContentGenerator TestPreconfiguredSender RemoveProposalPayload))) --- -newtype RemoveProposalPayload = RemoveProposalPayload {unRemoveProposalPayload :: MessagePayload 'MLSPlainText} +newtype RemoveProposalPayload = RemoveProposalPayload {unRemoveProposalPayload :: FramedContentData} deriving newtype (ParseMLS, SerialiseMLS, Eq, Show) instance Arbitrary RemoveProposalPayload where - arbitrary = RemoveProposalPayload . ProposalMessage . mkRemoveProposal <$> arbitrary + arbitrary = RemoveProposalPayload . FramedContentProposal . mkRawMLS . RemoveProposal <$> arbitrary -instance ArbitraryMessagePayload RemoveProposalPayload where - arbitraryMessagePayload = unRemoveProposalPayload <$> arbitrary +instance ArbitraryFramedContentData RemoveProposalPayload where + arbitraryFramedContentData = unRemoveProposalPayload <$> arbitrary --- newtype TestPreconfiguredSender = TestPreconfiguredSender - {unTestPreconfiguredSender :: Sender 'MLSPlainText} + {unTestPreconfiguredSender :: Sender} deriving newtype (ParseMLS, SerialiseMLS, Eq, Show) instance Arbitrary TestPreconfiguredSender where - arbitrary = TestPreconfiguredSender . PreconfiguredSender <$> arbitrary + arbitrary = TestPreconfiguredSender . SenderExternal <$> arbitrary instance ArbitrarySender TestPreconfiguredSender where arbitrarySender = unTestPreconfiguredSender <$> arbitrary --- -newtype AppAckProposalTest = AppAckProposalTest Proposal - deriving newtype (ParseMLS, Eq, Show) - -instance Arbitrary AppAckProposalTest where - arbitrary = AppAckProposalTest . AppAckProposal <$> arbitrary - -instance SerialiseMLS AppAckProposalTest where - serialiseMLS (AppAckProposalTest (AppAckProposal mrs)) = serialiseAppAckProposal mrs - serialiseMLS _ = serialiseAppAckProposal [] - ---- - newtype ExtensionVector = ExtensionVector [Extension] deriving newtype (Arbitrary, Eq, Show) instance ParseMLS ExtensionVector where - parseMLS = ExtensionVector <$> parseMLSVector @Word32 (parseMLS @Extension) + parseMLS = ExtensionVector <$> parseMLSVector @VarInt (parseMLS @Extension) instance SerialiseMLS ExtensionVector where serialiseMLS (ExtensionVector exts) = do - serialiseMLSVector @Word32 serialiseMLS exts + serialiseMLSVector @VarInt serialiseMLS exts ---- +-- -newtype TestCommitBundle = TestCommitBundle {unTestCommitBundle :: CommitBundle} - deriving (Show, Eq) +newtype TestCommitBundle = TestCommitBundle CommitBundle + deriving newtype (Eq, Show, ParseMLS, SerialiseMLS) --- | The commit bundle should contain a commit message, not a remove proposal --- message. However defining MLS serialization for Commits and all nested types --- seems overkill to test the commit bundle roundtrip instance Arbitrary TestCommitBundle where - arbitrary = do - bundle <- - CommitBundle - <$> (mkRawMLS . unRemoveProposalMessage <$> arbitrary) - <*> oneof [Just <$> (mkRawMLS <$> arbitrary), pure Nothing] - <*> arbitrary - pure (TestCommitBundle bundle) - -instance ConvertProtoLens Proto.Mls.CommitBundle TestCommitBundle where - fromProtolens = fmap TestCommitBundle . fromProtolens @Proto.Mls.CommitBundle @CommitBundle - toProtolens = toProtolens . unTestCommitBundle + arbitrary = + TestCommitBundle <$> do + commitMsg <- + mkRawMLS . unMessageGenerator @(FramedContentGenerator Sender CommitPayload) + <$> arbitrary + welcome <- arbitrary + CommitBundle commitMsg welcome <$> arbitrary + +newtype CommitPayload = CommitPayload {unCommitPayload :: RawMLS Commit} + deriving newtype (Arbitrary) + +instance ArbitraryFramedContentData CommitPayload where + arbitraryFramedContentData = FramedContentCommit . unCommitPayload <$> arbitrary diff --git a/libs/wire-api/test/unit/Main.hs b/libs/wire-api/test/unit/Test/Wire/API/Run.hs similarity index 96% rename from libs/wire-api/test/unit/Main.hs rename to libs/wire-api/test/unit/Test/Wire/API/Run.hs index 2ba0813e90e..0a083cd4fea 100644 --- a/libs/wire-api/test/unit/Main.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Run.hs @@ -15,10 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Main - ( main, - ) -where +module Test.Wire.API.Run (main) where import Imports import System.IO.Unsafe (unsafePerformIO) @@ -26,6 +23,7 @@ import Test.Tasty import Test.Wire.API.Call.Config qualified as Call.Config import Test.Wire.API.Conversation qualified as Conversation import Test.Wire.API.MLS qualified as MLS +import Test.Wire.API.MLS.Group qualified as Group import Test.Wire.API.OAuth qualified as OAuth import Test.Wire.API.RawJson qualified as RawJson import Test.Wire.API.Roundtrip.Aeson qualified as Roundtrip.Aeson @@ -65,6 +63,7 @@ main = Routes.tests, Conversation.tests, MLS.tests, + Group.tests, Routes.Version.tests, unsafePerformIO Routes.Version.Wai.tests, RawJson.tests, diff --git a/libs/wire-api/test/unit/Test/Wire/API/Swagger.hs b/libs/wire-api/test/unit/Test/Wire/API/Swagger.hs index 8de2cb6ad16..bbb37e6e2a4 100644 --- a/libs/wire-api/test/unit/Test/Wire/API/Swagger.hs +++ b/libs/wire-api/test/unit/Test/Wire/API/Swagger.hs @@ -18,7 +18,7 @@ module Test.Wire.API.Swagger (tests) where import Data.Aeson (ToJSON) -import Data.Swagger (ToSchema, validatePrettyToJSON) +import Data.OpenApi (ToSchema, validatePrettyToJSON) import Imports import Test.Tasty qualified as T import Test.Tasty.QuickCheck (Arbitrary, counterexample, testProperty) @@ -56,7 +56,7 @@ tests = testToJSON @(Wrapped.Wrapped "some_user" User.User) ] -testToJSON :: forall a. (Arbitrary a, Typeable a, ToJSON a, ToSchema a, Show a) => T.TestTree +testToJSON :: forall a. (Arbitrary a, ToJSON a, ToSchema a, Show a) => T.TestTree testToJSON = testProperty msg trip where msg = show (typeRep @a) diff --git a/libs/wire-api/wire-api.cabal b/libs/wire-api/wire-api.cabal index 05cd5447b97..0d06a7f9964 100644 --- a/libs/wire-api/wire-api.cabal +++ b/libs/wire-api/wire-api.cabal @@ -1,4 +1,4 @@ -cabal-version: 1.12 +cabal-version: 3.0 name: wire-api version: 0.1.0 description: API types of the Wire collaboration platform @@ -6,11 +6,65 @@ category: Network author: Wire Swiss GmbH maintainer: Wire Swiss GmbH copyright: (c) 2020 Wire Swiss GmbH -license: AGPL-3 +license: AGPL-3.0-only license-file: LICENSE build-type: Simple +common common-all + default-language: Haskell2010 + ghc-options: + -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates + -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path + -Wredundant-constraints + + default-extensions: + NoImplicitPrelude + AllowAmbiguousTypes + BangPatterns + ConstraintKinds + DataKinds + DefaultSignatures + DeriveFunctor + DeriveGeneric + DeriveLift + DeriveTraversable + DerivingStrategies + DerivingVia + DuplicateRecordFields + EmptyCase + FlexibleContexts + FlexibleInstances + FunctionalDependencies + GADTs + GeneralizedNewtypeDeriving + InstanceSigs + KindSignatures + LambdaCase + MultiParamTypeClasses + MultiWayIf + NamedFieldPuns + NumericUnderscores + OverloadedLabels + OverloadedRecordDot + OverloadedStrings + PackageImports + PatternSynonyms + PolyKinds + QuasiQuotes + RankNTypes + ScopedTypeVariables + StandaloneDeriving + TupleSections + TypeApplications + TypeFamilies + TypeFamilyDependencies + TypeOperators + UndecidableInstances + ViewPatterns + library + import: common-all + -- cabal-fmt: expand src exposed-modules: Wire.API.ApplyMods @@ -28,6 +82,7 @@ library Wire.API.Conversation.Role Wire.API.Conversation.Typing Wire.API.CustomBackend + Wire.API.Deprecated Wire.API.Error Wire.API.Error.Brig Wire.API.Error.Cannon @@ -46,6 +101,8 @@ library Wire.API.MakesFederatedCall Wire.API.Message Wire.API.Message.Proto + Wire.API.MLS.AuthenticatedContent + Wire.API.MLS.Capabilities Wire.API.MLS.CipherSuite Wire.API.MLS.Commit Wire.API.MLS.CommitBundle @@ -54,15 +111,22 @@ library Wire.API.MLS.Epoch Wire.API.MLS.Extension Wire.API.MLS.Group - Wire.API.MLS.GroupInfoBundle + Wire.API.MLS.Group.Serialisation + Wire.API.MLS.GroupInfo + Wire.API.MLS.HPKEPublicKey Wire.API.MLS.KeyPackage Wire.API.MLS.Keys + Wire.API.MLS.LeafNode + Wire.API.MLS.Lifetime Wire.API.MLS.Message Wire.API.MLS.Proposal + Wire.API.MLS.ProposalTag + Wire.API.MLS.ProtocolVersion Wire.API.MLS.PublicGroupState Wire.API.MLS.Serialisation Wire.API.MLS.Servant Wire.API.MLS.SubConversation + Wire.API.MLS.Validation Wire.API.MLS.Welcome Wire.API.Notification Wire.API.OAuth @@ -103,7 +167,10 @@ library Wire.API.Routes.Named Wire.API.Routes.Public Wire.API.Routes.Public.Brig + Wire.API.Routes.Public.Brig.Bot Wire.API.Routes.Public.Brig.OAuth + Wire.API.Routes.Public.Brig.Provider + Wire.API.Routes.Public.Brig.Services Wire.API.Routes.Public.Cannon Wire.API.Routes.Public.Cargohold Wire.API.Routes.Public.Galley @@ -169,58 +236,10 @@ library Wire.API.VersionInfo Wire.API.Wrapped - other-modules: Paths_wire_api - hs-source-dirs: src - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - NumericUnderscores - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - - ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -Wredundant-constraints -Wunused-packages - + other-modules: Paths_wire_api + hs-source-dirs: src build-depends: - aeson >=2.0.1.0 + , aeson >=2.0.1.0 , async , attoparsec >=0.10 , base >=4 && <5 @@ -255,7 +274,6 @@ library , hscim , HsOpenSSL , http-api-data - , http-client , http-media , http-types , imports @@ -269,6 +287,7 @@ library , metrics-wai , mime >=0.4 , mtl + , openapi3 , pem >=0.2 , polysemy , proto-lens @@ -277,7 +296,6 @@ library , quickcheck-instances >=0.3.16 , random >=1.2.0 , resourcet - , retry , saml2-web-sso , schema-profunctor , scientific @@ -287,17 +305,15 @@ library , servant-client-core , servant-conduit , servant-multipart + , servant-openapi3 , servant-server - , servant-swagger , singletons , singletons-base , singletons-th , sop-core - , swagger2 , tagged , text >=0.11 , time >=1.4 - , tinylog , transitive-anns , types-common >=0.16 , unordered-containers >=0.2 @@ -314,15 +330,15 @@ library , x509 , zauth - default-language: GHC2021 + default-language: GHC2021 test-suite wire-api-golden-tests - type: exitcode-stdio-1.0 - main-is: Main.hs + import: common-all + type: exitcode-stdio-1.0 + main-is: ../golden.hs -- cabal-fmt: expand test/golden other-modules: - Main Paths_wire_api Test.Wire.API.Golden.FromJSON Test.Wire.API.Golden.Generated @@ -563,64 +579,19 @@ test-suite wire-api-golden-tests Test.Wire.API.Golden.Manual.ListUsersById Test.Wire.API.Golden.Manual.QualifiedUserClientPrekeyMap Test.Wire.API.Golden.Manual.SearchResultContact + Test.Wire.API.Golden.Manual.SubConversation Test.Wire.API.Golden.Manual.TeamSize Test.Wire.API.Golden.Manual.Token Test.Wire.API.Golden.Manual.UserClientPrekeyMap Test.Wire.API.Golden.Manual.UserIdList Test.Wire.API.Golden.Protobuf + Test.Wire.API.Golden.Run Test.Wire.API.Golden.Runner - hs-source-dirs: test/golden - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - - ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -threaded -with-rtsopts=-N -Wredundant-constraints - -Wunused-packages - + ghc-options: -threaded -with-rtsopts=-N + hs-source-dirs: test/golden build-depends: - aeson >=2.0.1.0 + , aeson >=2.0.1.0 , aeson-diff , aeson-pretty , base @@ -634,7 +605,6 @@ test-suite wire-api-golden-tests , iso639 , lens , pem - , pretty , proto-lens , tasty , tasty-hunit @@ -646,19 +616,20 @@ test-suite wire-api-golden-tests , wire-api , wire-message-proto-lens - default-language: GHC2021 + default-language: GHC2021 test-suite wire-api-tests - type: exitcode-stdio-1.0 - main-is: Main.hs + import: common-all + type: exitcode-stdio-1.0 + main-is: ../unit.hs -- cabal-fmt: expand test/unit other-modules: - Main Paths_wire_api Test.Wire.API.Call.Config Test.Wire.API.Conversation Test.Wire.API.MLS + Test.Wire.API.MLS.Group Test.Wire.API.OAuth Test.Wire.API.RawJson Test.Wire.API.Roundtrip.Aeson @@ -669,6 +640,7 @@ test-suite wire-api-tests Test.Wire.API.Routes Test.Wire.API.Routes.Version Test.Wire.API.Routes.Version.Wai + Test.Wire.API.Run Test.Wire.API.Swagger Test.Wire.API.Team.Export Test.Wire.API.Team.Member @@ -677,57 +649,9 @@ test-suite wire-api-tests Test.Wire.API.User.RichInfo Test.Wire.API.User.Search - hs-source-dirs: test/unit - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - - ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -threaded -with-rtsopts=-N -Wredundant-constraints - -Wunused-packages - + hs-source-dirs: test/unit build-depends: - aeson >=2.0.1.0 + , aeson >=2.0.1.0 , aeson-qq , async , base @@ -747,12 +671,14 @@ test-suite wire-api-tests , imports , memory , metrics-wai + , openapi3 , process , QuickCheck + , random + , saml2-web-sso , schema-profunctor , servant , servant-server - , swagger2 , tasty , tasty-hspec , tasty-hunit @@ -766,4 +692,5 @@ test-suite wire-api-tests , wire-api , wire-message-proto-lens - default-language: GHC2021 + ghc-options: -threaded -with-rtsopts=-N + default-language: GHC2021 diff --git a/nix/haskell-pins.nix b/nix/haskell-pins.nix index 291ea06526f..edc5b3a4496 100644 --- a/nix/haskell-pins.nix +++ b/nix/haskell-pins.nix @@ -133,13 +133,6 @@ let sha256 = "sha256-g2lbKt3+hToVFQvaHOa9dg4HqAL7YgReo8fy7wQavmY="; }; }; - swagger2 = { - src = fetchgit { - url = "https://github.com/GetShopTV/swagger2"; - rev = "d79deca03b714cdd4531217831a8305068b2e8f9"; - sha256 = "sha256-R3p0L0TgM0Bspe5z6vauwdPq9TmEWpMC53DBkMtCEoE="; - }; - }; # MR: https://gitlab.com/twittner/cql-io/-/merge_requests/20 cql-io = { src = fetchgit { @@ -180,6 +173,15 @@ let sha256 = "sha256-SKEE9ZqhjBxHYUKQaoB4IpN4/Ui3tS4S98FgZqj7WlY="; }; }; + servant-openapi3 = { + src = fetchgit { + # This is a patched version of the library that sets the required flag for HTTP request bodies. + # A PR for these changes has been made for the upstream library. biocad/servant-openapi3#49 + url = "https://github.com/lepsa/servant-openapi3"; + rev = "5cdb2783f15058f753c41b800415d4ba1149a78b"; + sha256 = "sha256-8FM3IAA3ewCuv9Mar8aWmzbyfKK9eLXIJPMHzmYb1zE="; + }; + }; # This can be removed once postie 0.6.0.3 (or later) is in nixpkgs postie = { src = fetchgit { @@ -204,6 +206,22 @@ let sha256 = "sha256-htEIJY+LmIMACVZrflU60+X42/g14NxUyFM7VJs4E6w="; }; }; + # PR: https://github.com/ocharles/tasty-ant-xml/pull/32 + tasty-ant-xml = { + src = fetchgit { + url = "https://github.com/akshaymankar/tasty-ant-xml"; + rev = "34ff294d805e62e73678dccc0be9d3da13540fbe"; + sha256 = "sha256-+rHcS+BwEFsXqPAHX/KZDIgv9zfk1dZl0LlZJ57Com4="; + }; + }; + # PR: https://github.com/freckle/hspec-junit-formatter/pull/24 + hspec-junit-formatter = { + src = fetchgit { + url = "https://github.com/akshaymankar/hspec-junit-formatter"; + rev = "acec31822cc4f90489d9940bad23b3fd6d1d7c75"; + sha256 = "sha256-4xGW3KHQKbTL+6+Q/gzfaMBP+J0npUe7tP5ZCQCB5+s="; + }; + }; }; hackagePins = { # Major re-write upstream, we should get rid of this dependency rather than diff --git a/nix/local-haskell-packages.nix b/nix/local-haskell-packages.nix index 0770af43b6d..39fe6a23e24 100644 --- a/nix/local-haskell-packages.nix +++ b/nix/local-haskell-packages.nix @@ -44,14 +44,16 @@ spar = hself.callPackage ../services/spar/default.nix { inherit gitignoreSource; }; assets = hself.callPackage ../tools/db/assets/default.nix { inherit gitignoreSource; }; auto-whitelist = hself.callPackage ../tools/db/auto-whitelist/default.nix { inherit gitignoreSource; }; - billing-team-member-backfill = hself.callPackage ../tools/db/billing-team-member-backfill/default.nix { inherit gitignoreSource; }; find-undead = hself.callPackage ../tools/db/find-undead/default.nix { inherit gitignoreSource; }; inconsistencies = hself.callPackage ../tools/db/inconsistencies/default.nix { inherit gitignoreSource; }; migrate-sso-feature-flag = hself.callPackage ../tools/db/migrate-sso-feature-flag/default.nix { inherit gitignoreSource; }; move-team = hself.callPackage ../tools/db/move-team/default.nix { inherit gitignoreSource; }; + repair-brig-clients-table = hself.callPackage ../tools/db/repair-brig-clients-table/default.nix { inherit gitignoreSource; }; repair-handles = hself.callPackage ../tools/db/repair-handles/default.nix { inherit gitignoreSource; }; service-backfill = hself.callPackage ../tools/db/service-backfill/default.nix { inherit gitignoreSource; }; fedcalls = hself.callPackage ../tools/fedcalls/default.nix { inherit gitignoreSource; }; + mlsstats = hself.callPackage ../tools/mlsstats/default.nix { inherit gitignoreSource; }; + rabbitmq-consumer = hself.callPackage ../tools/rabbitmq-consumer/default.nix { inherit gitignoreSource; }; rex = hself.callPackage ../tools/rex/default.nix { inherit gitignoreSource; }; stern = hself.callPackage ../tools/stern/default.nix { inherit gitignoreSource; }; } diff --git a/nix/manual-overrides.nix b/nix/manual-overrides.nix index 74c0da56158..90022986207 100644 --- a/nix/manual-overrides.nix +++ b/nix/manual-overrides.nix @@ -1,11 +1,15 @@ -{ libsodium, protobuf, hlib, mls-test-cli }: +{ libsodium, protobuf, hlib, mls-test-cli, fetchpatch }: # FUTUREWORK: Figure out a way to detect if some of these packages are not # actually marked broken, so we can cleanup this file on every nixpkgs bump. hself: hsuper: { aeson = (hlib.doJailbreak hsuper.aeson_2_1_2_1); binary-parsers = hlib.markUnbroken (hlib.doJailbreak hsuper.binary-parsers); bytestring-arbitrary = hlib.markUnbroken (hlib.doJailbreak hsuper.bytestring-arbitrary); - cql = hlib.markUnbroken hsuper.cql; + openapi3 = hlib.markUnbroken (hlib.dontCheck hsuper.openapi3); + cql = hlib.appendPatch (hlib.markUnbroken hsuper.cql) (fetchpatch { + url = "https://gitlab.com/twittner/cql/-/merge_requests/11.patch"; + sha256 = "sha256-qfcCRkKjSS1TEqPRVBU9Ox2DjsdGsYG/F3DrZ5JGoEI="; + }); hashtables = hsuper.hashtables_1_3; invertible = hlib.markUnbroken hsuper.invertible; lens-datetime = hlib.markUnbroken (hlib.doJailbreak hsuper.lens-datetime); @@ -20,18 +24,21 @@ hself: hsuper: { servant-swagger-ui = hlib.doJailbreak hsuper.servant-swagger-ui; servant-swagger-ui-core = hlib.doJailbreak hsuper.servant-swagger-ui-core; sodium-crypto-sign = hlib.addPkgconfigDepend hsuper.sodium-crypto-sign libsodium.dev; - swagger2 = hlib.doJailbreak hsuper.swagger2; text-icu-translit = hlib.markUnbroken (hlib.dontCheck hsuper.text-icu-translit); text-short = hlib.dontCheck hsuper.text-short; type-errors = hlib.dontCheck hsuper.type-errors; wai-middleware-prometheus = hlib.doJailbreak hsuper.wai-middleware-prometheus; wai-predicates = hlib.markUnbroken hsuper.wai-predicates; + # PR with fix: https://github.com/freckle/hspec-junit-formatter/pull/23 + hspec-junit-formatter = hlib.markUnbroken (hlib.dontCheck hsuper.hspec-junit-formatter); + # Some test seems to be broken hsaml2 = hlib.dontCheck hsuper.hsaml2; saml2-web-sso = hlib.dontCheck hsuper.saml2-web-sso; http2 = hlib.dontCheck hsuper.http2; + # Disable tests because they need network access to a running cassandra # # Explicitly enable haddock because cabal2nix disables it for packages with diff --git a/nix/overlay.nix b/nix/overlay.nix index 3bcd85b5a18..4d533dea9c8 100644 --- a/nix/overlay.nix +++ b/nix/overlay.nix @@ -49,15 +49,20 @@ let ''; }; + sources = import ./sources.nix; + pkgsCargo = import sources.nixpkgs-cargo {}; in self: super: { + cryptobox = self.callPackage ./pkgs/cryptobox { }; zauth = self.callPackage ./pkgs/zauth { }; mls-test-cli = self.callPackage ./pkgs/mls-test-cli { }; # Named like this so cabal2nix can find it - rusty_jwt_tools_ffi = self.callPackage ./pkgs/rusty_jwt_tools_ffi { }; + rusty_jwt_tools_ffi = self.callPackage ./pkgs/rusty_jwt_tools_ffi { + inherit (pkgsCargo) rustPlatform; + }; nginxModules = super.nginxModules // { zauth = { diff --git a/nix/pkgs/cryptobox/default.nix b/nix/pkgs/cryptobox/default.nix index 5945c616f22..98d5adf8aa3 100644 --- a/nix/pkgs/cryptobox/default.nix +++ b/nix/pkgs/cryptobox/default.nix @@ -7,7 +7,7 @@ }: rustPlatform.buildRustPackage rec { - name = "cryptobox-c-${version}"; + pname = "cryptobox-c"; version = "2019-06-17"; nativeBuildInputs = [ pkg-config ]; buildInputs = [ libsodium ]; @@ -17,12 +17,19 @@ rustPlatform.buildRustPackage rec { rev = "4067ad96b125942545dbdec8c1a89f1e1b65d013"; sha256 = "1i9dlhw0xk1viglyhail9fb36v1awrypps8jmhrkz8k1bhx98ci3"; }; - cargoSha256 = "sha256-Afr3ShCXDCwTQNdeCZbA5/aosRt+KFpGfT1mrob6cog="; - patchLibs = lib.optionalString stdenv.isDarwin '' install_name_tool -id $out/lib/libcryptobox.dylib $out/lib/libcryptobox.dylib ''; + cargoLock = { + lockFile = "${src}/Cargo.lock"; + outputHashes = { + "cryptobox-1.0.0" = "sha256-Ewo+FtEGTZ4/U7Ow6mGTQkxS4IQYcEthr5/xG9BRTWk="; + "hkdf-0.2.0" = "sha256-cdgR94c40JFIjBf8NfZPXPGLU60BlAZX/SQnRHAXGOg="; + "proteus-1.0.0" = "sha256-ppMt56RY5K3rOwO7MEdY6d3t96sbHZzDB/nPNNp35DY="; + }; + }; + postInstall = '' ${patchLibs} mkdir -p $out/include diff --git a/nix/pkgs/mls-test-cli/default.nix b/nix/pkgs/mls-test-cli/default.nix index 8562916aca7..2ba2d126575 100644 --- a/nix/pkgs/mls-test-cli/default.nix +++ b/nix/pkgs/mls-test-cli/default.nix @@ -1,27 +1,36 @@ { fetchFromGitHub -, lib , libsodium , perl , pkg-config , rustPlatform -, stdenv , gitMinimal }: -rustPlatform.buildRustPackage rec { - name = "mls-test-cli-${version}"; - version = "0.6.0"; - nativeBuildInputs = [ pkg-config perl gitMinimal ]; - buildInputs = [ libsodium ]; +let + version = "0.7.0"; src = fetchFromGitHub { owner = "wireapp"; repo = "mls-test-cli"; - sha256 = "sha256-/XQ/9oQTPkRqgMzDGRm+Oh9jgkdeDM1vRJ6/wEf2+bY="; - rev = "c6f80be2839ac1ed2894e96044541d1c3cf6ecdf"; + rev = "e6e6ce0c29f0e48e84b4ccef058130aca0625492"; + sha256 = "sha256-J9M8w3GJnULH3spKEuPGCL/t43zb2Wd+YfZ0LY3YITo="; }; - doCheck = false; - cargoSha256 = "sha256-AlZrxa7f5JwxxrzFBgeFSaYU6QttsUpfLYfq1HzsdbE="; - cargoDepsHook = '' - mkdir -p mls-test-cli-${version}-vendor.tar.gz/ring/.git + cargoLockFile = builtins.toFile "cargo.lock" (builtins.readFile "${src}/Cargo.lock"); +in rustPlatform.buildRustPackage rec { + name = "mls-test-cli-${version}"; + inherit version src; + + cargoLock = { + lockFile = cargoLockFile; + outputHashes = { + "hpke-0.10.0" = "sha256-T1+BFwX6allljNZ/8T3mrWhOejnUU27BiWQetqU+0fY="; + "openmls-1.0.0" = "sha256-s1ejM/aicFGvsKY7ajEun1Mc645/k8QVrE8YSbyD3Fg="; + "safe_pqc_kyber-0.6.0" = "sha256-Ch1LA+by+ezf5RV0LDSQGC1o+IWKXk8IPvkwSrAos68="; + "tls_codec-0.3.0" = "sha256-IO6tenXKkC14EoUDp/+DtFNOVzDfOlLu8K1EJI7sOzs="; + }; + }; + + postPatch = '' + cp ${cargoLockFile} Cargo.lock ''; + doCheck = false; } diff --git a/nix/pkgs/rusty_jwt_tools_ffi/default.nix b/nix/pkgs/rusty_jwt_tools_ffi/default.nix index 6fb4b58470e..1f0764c3b7a 100644 --- a/nix/pkgs/rusty_jwt_tools_ffi/default.nix +++ b/nix/pkgs/rusty_jwt_tools_ffi/default.nix @@ -7,12 +7,12 @@ }: let - version = "0.3.4"; + version = "0.5.0"; src = fetchFromGitHub { owner = "wireapp"; repo = "rusty-jwt-tools"; - rev = "fc4569c5b84d00a5cc8fc77b450714a5261cd3d9"; - sha256 = "sha256-cZffVKfH0FzA4Eo7YVxivT3JWTwz9uu1HWhPVlvbYqM="; + rev = "6704e08376bb49168133d8f4ce66155adeb6bfb0"; + sha256 = "sha256-ocmeFXjU3psCO+hpDuEAIzYIm4QzP+jHJR/V8yyw6Lw="; }; cargoLockFile = builtins.toFile "cargo.lock" (builtins.readFile "${src}/ffi/Cargo.lock"); diff --git a/nix/pkgs/zauth/default.nix b/nix/pkgs/zauth/default.nix index 969a76fc333..19ade192f8f 100644 --- a/nix/pkgs/zauth/default.nix +++ b/nix/pkgs/zauth/default.nix @@ -16,7 +16,12 @@ rustPlatform.buildRustPackage rec { src = nix-gitignore.gitignoreSourcePure [ ../../../.gitignore ] ../../../libs/libzauth; sourceRoot = "libzauth/libzauth-c"; - cargoSha256 = "sha256-f/MNUrEQaPzSUHtnZ0jARMwBswS+Sh0Swe+2D+hpHF4="; + cargoLock = { + lockFile = "${src}/libzauth-c/Cargo.lock"; + outputHashes = { + "jwt-simple-0.11.3" = "sha256-H9gCwqxUlffi8feQ4xjiTbeyT1RMrfZAsPsNWapfR9c="; + }; + }; patchLibs = lib.optionalString stdenv.isDarwin '' install_name_tool -id $out/lib/libzauth.dylib $out/lib/libzauth.dylib diff --git a/nix/sources.json b/nix/sources.json index 80e326af497..e1adedd2306 100644 --- a/nix/sources.json +++ b/nix/sources.json @@ -10,5 +10,17 @@ "type": "tarball", "url": "https://github.com/NixOS/nixpkgs/archive/402cc3633cc60dfc50378197305c984518b30773.tar.gz", "url_template": "https://github.com///archive/.tar.gz" + }, + "nixpkgs-cargo": { + "branch": "nixpkgs-unstable", + "description": "Nix Packages collection", + "homepage": "https://github.com/NixOS/nixpkgs", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "efd23a1c9ae8c574e2ca923c2b2dc336797f4cc4", + "sha256": "0pb1dgdgfsnsngw2ci807wln2jnlsha4zkm1y14x497qbw4izir3", + "type": "tarball", + "url": "https://github.com/NixOS/nixpkgs/archive/efd23a1c9ae8c574e2ca923c2b2dc336797f4cc4.tar.gz", + "url_template": "https://github.com///archive/.tar.gz" } } diff --git a/nix/wire-server.nix b/nix/wire-server.nix index 0f4241bb223..652cbcedd4b 100644 --- a/nix/wire-server.nix +++ b/nix/wire-server.nix @@ -80,12 +80,11 @@ let proxy = [ "proxy" ]; spar = [ "spar" "spar-integration" "spar-schema" "spar-migrate-data" ]; stern = [ "stern" "stern-integration" ]; - - billing-team-member-backfill = [ "billing-team-member-backfill" ]; inconsistencies = [ "inconsistencies" ]; zauth = [ "zauth" ]; background-worker = [ "background-worker" ]; integration = [ "integration" ]; + rabbitmq-consumer = [ "rabbitmq-consumer" ]; }; attrsets = lib.attrsets; @@ -106,7 +105,10 @@ let hsuper hself; - werror = _: hlib.failOnAllWarnings; + # append `-Werror` to ghc options for all packages. + # failOnAllWarnings implies `-Wall`, which overrides any `-Wno-*` from the package cabal file. + # https://github.com/NixOS/nixpkgs/blob/1e411c55166539b130b330dafcc4034152f8d4fd/pkgs/development/haskell-modules/lib/compose.nix#L327 + werror = _: (drv: hlib.appendConfigureFlag drv "--ghc-option=-Werror"); opt = _: drv: if enableOptimization then drv @@ -123,6 +125,9 @@ let then drv else hlib.dontHaddock drv; + bench = _: drv: + hlib.doBenchmark drv; + overrideAll = fn: overrides: attrsets.mapAttrs fn (overrides); in @@ -131,9 +136,10 @@ let opt docs tests + bench ]; manualOverrides = import ./manual-overrides.nix (with pkgs; { - inherit hlib libsodium protobuf mls-test-cli; + inherit hlib libsodium protobuf mls-test-cli fetchpatch; }); executables = hself: hsuper: @@ -249,8 +255,13 @@ let # extraContents :: Map Exe Derivation -> Map Text [Derivation] extraContents = exes: { brig = [ brig-templates ]; - brig-integration = [ brig-templates pkgs.mls-test-cli ]; - galley-integration = [ pkgs.mls-test-cli ]; + brig-integration = [brig-templates pkgs.mls-test-cli pkgs.awscli2]; + galley-integration = [pkgs.mls-test-cli pkgs.awscli2]; + stern-integration = [ pkgs.awscli2 ]; + gundeck-integration = [ pkgs.awscli2 ]; + cargohold-integration = [ pkgs.awscli2 ]; + spar-integration = [ pkgs.awscli2 ]; + federator-integration = [ pkgs.awscli2 ]; integration = with exes; [ brig brig-index @@ -269,6 +280,8 @@ let brig-templates background-worker pkgs.nginz + pkgs.mls-test-cli + pkgs.awscli2 integration-dynamic-backends-db-schemas integration-dynamic-backends-brig-index integration-dynamic-backends-sqs @@ -388,6 +401,7 @@ let pkgs.kubectl pkgs.kubelogin-oidc pkgs.nixpkgs-fmt + pkgs.openssl pkgs.ormolu pkgs.shellcheck pkgs.treefmt @@ -418,6 +432,7 @@ let }; shell = (hPkgs localModsOnlyTests).shellFor { + doBenchmark = true; packages = p: builtins.map (e: p.${e}) wireServerPackages; }; ghcWithPackages = shell.nativeBuildInputs ++ shell.buildInputs; @@ -464,6 +479,7 @@ in flake8 ipdb ipython + protobuf pylint pyyaml requests diff --git a/services/background-worker/background-worker.cabal b/services/background-worker/background-worker.cabal index 2dc7c897f45..377e7487ae5 100644 --- a/services/background-worker/background-worker.cabal +++ b/services/background-worker/background-worker.cabal @@ -18,7 +18,6 @@ library Wire.BackgroundWorker.Health Wire.BackgroundWorker.Options Wire.BackgroundWorker.Util - Wire.Defederation hs-source-dirs: src default-language: GHC2021 @@ -30,19 +29,13 @@ library build-depends: aeson , amqp - , async - , base - , bilge - , bytestring-conversion , containers , exceptions , extended , HsOpenSSL , http-client - , http-types , http2-manager , imports - , lens , metrics-core , metrics-wai , monad-control @@ -56,7 +49,6 @@ library , types-common , unliftio , wai-utilities - , wire-api , wire-api-federation default-extensions: @@ -175,7 +167,6 @@ test-suite background-worker-test other-modules: Main Test.Wire.BackendNotificationPusherSpec - Test.Wire.DefederationSpec Test.Wire.Util build-depends: @@ -191,7 +182,6 @@ test-suite background-worker-test , http-client , http-media , http-types - , HUnit , imports , prometheus-client , QuickCheck diff --git a/services/background-worker/background-worker.integration.yaml b/services/background-worker/background-worker.integration.yaml index 03e95748914..32ff94e37ef 100644 --- a/services/background-worker/background-worker.integration.yaml +++ b/services/background-worker/background-worker.integration.yaml @@ -1,4 +1,4 @@ -logLevel: Info +logLevel: Debug backgroundWorker: host: 0.0.0.0 @@ -8,14 +8,6 @@ federatorInternal: host: 127.0.0.1 port: 8097 -galley: - host: 127.0.0.1 - port: 8085 - -brig: - host: 127.0.0.1 - port: 8082 - rabbitmq: host: 127.0.0.1 port: 5672 @@ -23,4 +15,6 @@ rabbitmq: adminPort: 15672 backendNotificationPusher: - remotesRefreshInterval: 1 \ No newline at end of file + pushBackoffMinWait: 1000 # 1ms + pushBackoffMaxWait: 1000000 # 1s + remotesRefreshInterval: 10000 # 10ms \ No newline at end of file diff --git a/services/background-worker/default.nix b/services/background-worker/default.nix index de2217e45b6..910b9a396dd 100644 --- a/services/background-worker/default.nix +++ b/services/background-worker/default.nix @@ -5,11 +5,8 @@ { mkDerivation , aeson , amqp -, async , base -, bilge , bytestring -, bytestring-conversion , containers , exceptions , extended @@ -21,9 +18,7 @@ , http-media , http-types , http2-manager -, HUnit , imports -, lens , lib , metrics-core , metrics-wai @@ -55,19 +50,13 @@ mkDerivation { libraryHaskellDepends = [ aeson amqp - async - base - bilge - bytestring-conversion containers exceptions extended HsOpenSSL http-client - http-types http2-manager imports - lens metrics-core metrics-wai monad-control @@ -81,7 +70,6 @@ mkDerivation { types-common unliftio wai-utilities - wire-api wire-api-federation ]; executableHaskellDepends = [ HsOpenSSL imports types-common ]; @@ -97,7 +85,6 @@ mkDerivation { http-client http-media http-types - HUnit imports prometheus-client QuickCheck diff --git a/services/background-worker/src/Wire/BackendNotificationPusher.hs b/services/background-worker/src/Wire/BackendNotificationPusher.hs index 0b8bd91c807..3bcceafac4c 100644 --- a/services/background-worker/src/Wire/BackendNotificationPusher.hs +++ b/services/background-worker/src/Wire/BackendNotificationPusher.hs @@ -11,7 +11,6 @@ import Data.Map.Strict qualified as Map import Data.Set qualified as Set import Data.Text qualified as Text import Imports -import Network.AMQP (cancelConsumer) import Network.AMQP qualified as Q import Network.AMQP.Extended import Network.AMQP.Lifted qualified as QL @@ -21,8 +20,8 @@ import System.Logger.Class qualified as Log import UnliftIO import Wire.API.Federation.BackendNotifications import Wire.API.Federation.Client -import Wire.API.Routes.FederationDomainConfig import Wire.BackgroundWorker.Env +import Wire.BackgroundWorker.Options import Wire.BackgroundWorker.Util startPushingNotifications :: @@ -36,11 +35,12 @@ startPushingNotifications runningFlag chan domain = do pushNotification :: RabbitMQEnvelope e => MVar () -> Domain -> (Q.Message, e) -> AppT IO (Async ()) pushNotification runningFlag targetDomain (msg, envelope) = do + cfg <- asks (.backendNotificationsConfig) -- Jittered exponential backoff with 10ms as starting delay and 300s as max -- delay. When 300s is reached, every retry will happen after 300s. -- -- FUTUREWORK: Pull these numbers into config.s - let policy = capDelay 300_000_000 $ fullJitterBackoff 10000 + let policy = capDelay cfg.pushBackoffMaxWait $ fullJitterBackoff cfg.pushBackoffMinWait logErrr willRetry (SomeException e) rs = do Log.err $ Log.msg (Log.val "Exception occurred while pushing notification") @@ -112,14 +112,13 @@ startPusher consumersRef chan = do -- delivered in order. markAsWorking BackendNotificationPusher lift $ Q.qos chan 0 1 False - env <- ask -- Make sure threads aren't dangling if/when this async thread is killed let cleanup :: (Exception e, MonadThrow m, MonadIO m) => e -> m () cleanup e = do consumers <- liftIO $ readIORef consumersRef - traverse_ (liftIO . cancelConsumer chan . fst) $ Map.elems consumers + traverse_ (liftIO . Q.cancelConsumer chan . fst) $ Map.elems consumers throwM e - + timeBeforeNextRefresh <- asks (.backendNotificationsConfig.remotesRefreshInterval) -- If this thread is cancelled, catch the exception, kill the consumers, and carry on. -- FUTUREWORK?: -- If this throws an exception on the Chan / in the forever loop, the exception will @@ -129,26 +128,11 @@ startPusher consumersRef chan = do [ Handler $ cleanup @SomeException, Handler $ cleanup @SomeAsyncException ] + $ forever $ do - -- Get an initial set of domains from the sync thread - -- The Chan that we will be waiting on isn't initialised with a - -- value until the domain update loop runs the callback for the - -- first time. - initRemotes <- liftIO $ readIORef env.remoteDomains - -- Get an initial set of consumers for the domains pulled from the IORef - -- so that we aren't just sitting around not doing anything for a bit at - -- the start. - ensureConsumers consumersRef chan $ domain <$> initRemotes.remotes - -- Wait for updates to the domains, this is where the bulk of the action - -- is going to take place - forever $ do - -- Wait for a new set of domains. This is a blocking action - -- so we will only move past here when we get a new set of domains. - -- It is a bit nicer than having another timeout value, as Brig is - -- already providing one in the domain update message. - chanRemotes <- liftIO $ readChan env.remoteDomainsChan - -- Make new consumers for the new domains, clean up old ones from the consumer map. - ensureConsumers consumersRef chan $ domain <$> chanRemotes.remotes + remotes <- getRemoteDomains + ensureConsumers consumersRef chan remotes + threadDelay timeBeforeNextRefresh ensureConsumers :: IORef (Map Domain (Q.ConsumerTag, MVar ())) -> Q.Channel -> [Domain] -> AppT IO () ensureConsumers consumers chan domains = do @@ -159,10 +143,10 @@ ensureConsumers consumers chan domains = do traverse_ (ensureConsumer consumers chan) domains -- Loop over all of the dropped domains. These need to be cancelled as they are no longer -- on the domain list. - traverse_ (cancelConsumer' consumers chan) droppedDomains + traverse_ (cancelConsumer consumers chan) droppedDomains -cancelConsumer' :: IORef (Map Domain (Q.ConsumerTag, MVar ())) -> Q.Channel -> Domain -> AppT IO () -cancelConsumer' consumers chan domain = do +cancelConsumer :: IORef (Map Domain (Q.ConsumerTag, MVar ())) -> Q.Channel -> Domain -> AppT IO () +cancelConsumer consumers chan domain = do Log.info $ Log.msg (Log.val "Stopping consumer") . Log.field "domain" (domainText domain) -- The ' version of atomicModifyIORef is strict in the function update and is useful -- for not leaking memory. diff --git a/services/background-worker/src/Wire/BackgroundWorker.hs b/services/background-worker/src/Wire/BackgroundWorker.hs index f6aec92223b..31a9c769034 100644 --- a/services/background-worker/src/Wire/BackgroundWorker.hs +++ b/services/background-worker/src/Wire/BackgroundWorker.hs @@ -2,7 +2,6 @@ module Wire.BackgroundWorker where -import Control.Concurrent.Async (cancel) import Data.Domain import Data.Map.Strict qualified as Map import Data.Metrics.Servant qualified as Metrics @@ -17,35 +16,19 @@ import Wire.BackendNotificationPusher qualified as BackendNotificationPusher import Wire.BackgroundWorker.Env import Wire.BackgroundWorker.Health qualified as Health import Wire.BackgroundWorker.Options -import Wire.Defederation as Defederation --- FUTUREWORK: Start an http service with status and metrics endpoints run :: Opts -> IO () run opts = do - (env, syncThread) <- mkEnv opts - (defedChanRef, defedConsumerRef) <- runAppT env $ Defederation.startWorker opts.rabbitmq + env <- mkEnv opts (notifChanRef, notifConsumersRef) <- runAppT env $ BackendNotificationPusher.startWorker opts.rabbitmq let -- cleanup will run in a new thread when the signal is caught, so we need to use IORefs and -- specific exception types to message threads to clean up l = logger env cleanup = do - cancel syncThread - -- Cancel the consumers and wait for them to finish their processing step. - -- Defederation thread - Log.info (logger env) $ Log.msg (Log.val "Cancelling the defederation thread") - readIORef defedChanRef >>= traverse_ \chan -> do - Log.info (logger env) $ Log.msg (Log.val "Got channel") - readIORef defedConsumerRef >>= traverse_ \(consumer, runningFlag) -> do - Log.info l $ Log.msg (Log.val "Cancelling consumer") - Q.cancelConsumer chan consumer - Log.info l $ Log.msg $ Log.val "Taking MVar. Waiting for current operation to finish" - takeMVar runningFlag - Log.info l $ Log.msg $ Log.val "Closing RabbitMQ channel" - Q.closeChannel chan -- Notification pusher thread - Log.info (logger env) $ Log.msg (Log.val "Cancelling the notification pusher thread") + Log.info l $ Log.msg (Log.val "Cancelling the notification pusher thread") readIORef notifChanRef >>= traverse_ \chan -> do - Log.info (logger env) $ Log.msg (Log.val "Got channel") + Log.info l $ Log.msg (Log.val "Got channel") readIORef notifConsumersRef >>= \m -> for_ (Map.assocs m) \(domain, (consumer, runningFlag)) -> do Log.info l $ Log.msg (Log.val "Cancelling consumer") . Log.field "Domain" domain._domainText -- Remove the consumer from the channel so it isn't called again @@ -64,7 +47,7 @@ run opts = do -- Close the channel. `extended` will then close the connection, flushing messages to the server. Log.info l $ Log.msg $ Log.val "Closing RabbitMQ channel" Q.closeChannel chan - let server = defaultServer (cs $ opts.backgroundWorker._epHost) opts.backgroundWorker._epPort env.logger env.metrics + let server = defaultServer (cs $ opts.backgroundWorker._host) opts.backgroundWorker._port env.logger env.metrics settings <- newSettings server -- Additional cleanup when shutting down via signals. runSettingsWithCleanup cleanup settings (servantApp env) Nothing diff --git a/services/background-worker/src/Wire/BackgroundWorker/Env.hs b/services/background-worker/src/Wire/BackgroundWorker/Env.hs index e6f9b93edee..0d3080595f6 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Env.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Env.hs @@ -3,8 +3,6 @@ module Wire.BackgroundWorker.Env where -import Control.Concurrent.Async -import Control.Concurrent.Chan import Control.Monad.Base import Control.Monad.Catch import Control.Monad.Trans.Control @@ -23,8 +21,6 @@ import System.Logger qualified as Log import System.Logger.Class (Logger, MonadLogger (..)) import System.Logger.Extended qualified as Log import Util.Options -import Wire.API.FederationUpdate -import Wire.API.Routes.FederationDomainConfig import Wire.BackgroundWorker.Options type IsWorking = Bool @@ -32,7 +28,6 @@ type IsWorking = Bool -- | Eventually this will be a sum type of all the types of workers data Worker = BackendNotificationPusher - | DefederationWorker deriving (Show, Eq, Ord) data Env = Env @@ -43,12 +38,9 @@ data Env = Env metrics :: Metrics.Metrics, federatorInternal :: Endpoint, httpManager :: Manager, - galley :: Endpoint, - brig :: Endpoint, defederationTimeout :: ResponseTimeout, - remoteDomains :: IORef FederationDomainConfigs, - remoteDomainsChan :: Chan FederationDomainConfigs, backendNotificationMetrics :: BackendNotificationMetrics, + backendNotificationsConfig :: BackendNotificationsConfig, statuses :: IORef (Map Worker IsWorking) } @@ -65,37 +57,28 @@ mkBackendNotificationMetrics = <*> register (vector "targetDomain" $ counter $ Prometheus.Info "wire_backend_notifications_errors" "Number of errors that occurred while pushing notifications") <*> register (vector "targetDomain" $ gauge $ Prometheus.Info "wire_backend_notifications_stuck_queues" "Set to 1 when pushing notifications is stuck") -mkEnv :: Opts -> IO (Env, Async ()) +mkEnv :: Opts -> IO Env mkEnv opts = do http2Manager <- initHttp2Manager logger <- Log.mkLogger opts.logLevel Nothing opts.logFormat httpManager <- newManager defaultManagerSettings - remoteDomainsChan <- newChan let federatorInternal = opts.federatorInternal - galley = opts.galley defederationTimeout = maybe responseTimeoutNone (\t -> responseTimeoutMicro $ 1000000 * t) -- seconds to microseconds opts.defederationTimeout - brig = opts.brig rabbitmqVHost = opts.rabbitmq.vHost - callback = - SyncFedDomainConfigsCallback - { fromFedUpdateCallback = \_old new -> do - writeChan remoteDomainsChan new - } - (remoteDomains, syncThread) <- syncFedDomainConfigs brig logger callback rabbitmqAdminClient <- mkRabbitMqAdminClientEnv opts.rabbitmq statuses <- newIORef $ Map.fromList - [ (BackendNotificationPusher, False), - (DefederationWorker, False) + [ (BackendNotificationPusher, False) ] metrics <- Metrics.metrics backendNotificationMetrics <- mkBackendNotificationMetrics - pure (Env {..}, syncThread) + let backendNotificationsConfig = opts.backendNotificationPusher + pure Env {..} initHttp2Manager :: IO Http2Manager initHttp2Manager = do diff --git a/services/background-worker/src/Wire/BackgroundWorker/Health.hs b/services/background-worker/src/Wire/BackgroundWorker/Health.hs index 26c8374654b..dc0cc0a97d7 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Health.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Health.hs @@ -7,12 +7,13 @@ import Servant.Server.Generic import Wire.BackgroundWorker.Env data HealthAPI routes = HealthAPI - { status :: routes :- "i" :> "status" :> Get '[PlainText] NoContent + { status :: routes :- "i" :> "status" :> Get '[PlainText] NoContent, + statusWorkers :: routes :- "i" :> "status" :> "workers" :> Get '[PlainText] NoContent } deriving (Generic) -statusImpl :: AppT Handler NoContent -statusImpl = do +statusWorkersImpl :: AppT Handler NoContent +statusWorkersImpl = do notWorkingWorkers <- Map.keys . Map.filter not <$> (readIORef =<< asks statuses) if null notWorkingWorkers then pure NoContent @@ -22,4 +23,8 @@ api :: Env -> HealthAPI AsServer api env = fromServant $ hoistServer (Proxy @(ToServant HealthAPI AsApi)) (runAppT env) (toServant apiInAppT) where apiInAppT :: HealthAPI (AsServerT (AppT Handler)) - apiInAppT = HealthAPI {status = statusImpl} + apiInAppT = + HealthAPI + { status = pure NoContent, + statusWorkers = statusWorkersImpl + } diff --git a/services/background-worker/src/Wire/BackgroundWorker/Options.hs b/services/background-worker/src/Wire/BackgroundWorker/Options.hs index 1778dcf905f..da31c41255a 100644 --- a/services/background-worker/src/Wire/BackgroundWorker/Options.hs +++ b/services/background-worker/src/Wire/BackgroundWorker/Options.hs @@ -12,10 +12,28 @@ data Opts = Opts backgroundWorker :: !Endpoint, federatorInternal :: !Endpoint, rabbitmq :: !RabbitMqAdminOpts, - galley :: !Endpoint, - brig :: !Endpoint, - defederationTimeout :: Maybe Int -- Seconds, Nothing for no timeout + -- | Seconds, Nothing for no timeout + defederationTimeout :: Maybe Int, + backendNotificationPusher :: BackendNotificationsConfig } deriving (Show, Generic) instance FromJSON Opts + +data BackendNotificationsConfig = BackendNotificationsConfig + { -- | Minimum amount of time (in microseconds) to wait before doing the first + -- retry in pushing a notification. Futher retries are done in a jittered + -- exponential way. + -- https://aws.amazon.com/blogs/architecture/exponential-backoff-and-jitter/ + pushBackoffMinWait :: Int, + -- | Upper limit on amount of time (in microseconds) to wait before retrying + -- any notification. This exists to ensure that exponential back-off doesn't + -- cause wait times to be very big. + pushBackoffMaxWait :: Int, + -- | The list of remotes is refreshed at an interval. This value in + -- microseconds decides the interval for polling. + remotesRefreshInterval :: Int + } + deriving (Show, Generic) + +instance FromJSON BackendNotificationsConfig diff --git a/services/background-worker/src/Wire/Defederation.hs b/services/background-worker/src/Wire/Defederation.hs deleted file mode 100644 index b87e112c267..00000000000 --- a/services/background-worker/src/Wire/Defederation.hs +++ /dev/null @@ -1,113 +0,0 @@ -module Wire.Defederation where - -import Bilge.Retry -import Control.Concurrent.Async -import Control.Lens (to, (^.)) -import Control.Monad.Catch -import Control.Retry -import Data.Aeson qualified as A -import Data.ByteString.Conversion -import Data.Text.Encoding -import Imports -import Network.AMQP qualified as Q -import Network.AMQP.Extended -import Network.AMQP.Lifted qualified as QL -import Network.HTTP.Client -import Network.HTTP.Types -import System.Logger.Class qualified as Log -import Util.Options -import Wire.API.Federation.BackendNotifications -import Wire.BackgroundWorker.Env -import Wire.BackgroundWorker.Util - -deleteFederationDomain :: MVar () -> Q.Channel -> AppT IO Q.ConsumerTag -deleteFederationDomain runningFlag chan = do - lift $ ensureQueue chan defederationQueue - QL.consumeMsgs chan (routingKey defederationQueue) Q.Ack $ deleteFederationDomainInner runningFlag - -x3 :: RetryPolicy -x3 = limitRetries 3 <> exponentialBackoff 100000 - --- Exposed for testing purposes so we can decode without further processing the message. -deleteFederationDomainInner' :: (RabbitMQEnvelope e) => (e -> DefederationDomain -> AppT IO ()) -> (Q.Message, e) -> AppT IO () -deleteFederationDomainInner' go (msg, envelope) = do - either - ( \e -> do - void $ logErr e - -- ensure that the message is _NOT_ requeued - -- This means that we won't process this message again - -- as it is unparsable. - liftIO $ reject envelope False - ) - (go envelope) - $ A.eitherDecode @DefederationDomain (Q.msgBody msg) - where - logErr err = - Log.err $ - Log.msg (Log.val "Failed to delete federation domain") - . Log.field "error" err - --- What should we do with non-recoverable (unparsable) errors/messages? --- should we deadletter, or do something else? --- Deadlettering has a privacy implication -- FUTUREWORK. -deleteFederationDomainInner :: (RabbitMQEnvelope e) => MVar () -> (Q.Message, e) -> AppT IO () -deleteFederationDomainInner runningFlag (msg, envelope) = - deleteFederationDomainInner' (const callGalley) (msg, envelope) - where - callGalley domain = do - env <- ask - -- Jittered exponential backoff with 10ms as starting delay and 60s as max - -- delay. When 60 is reached, every retry will happen after 60s. - let policy = capDelay 60_000_000 $ fullJitterBackoff 10000 - manager = httpManager env - recovering policy httpHandlers $ \_ -> - bracket_ (takeMVar runningFlag) (putMVar runningFlag ()) $ do - -- Non 2xx responses will throw an exception - -- So we are relying on that to be caught by recovering - resp <- liftIO $ httpLbs (req env domain) manager - let code = statusCode $ responseStatus resp - if code >= 200 && code <= 299 - then do - liftIO $ ack envelope - else -- ensure that the message is requeued - -- This message was able to be parsed but something - -- else in our stack failed and we should try again. - liftIO $ reject envelope True - req env dom = - defaultRequest - { method = methodDelete, - secure = False, - host = galley env ^. epHost . to encodeUtf8, - port = galley env ^. epPort . to fromIntegral, - path = "/i/federation/" <> toByteString' dom, - requestHeaders = ("Accept", "application/json") : requestHeaders defaultRequest, - responseTimeout = defederationTimeout env - } - -startDefederator :: IORef (Maybe (Q.ConsumerTag, MVar ())) -> Q.Channel -> AppT IO () -startDefederator consumerRef chan = do - markAsWorking DefederationWorker - lift $ Q.qos chan 0 1 False - runningFlag <- newMVar () - consumer <- deleteFederationDomain runningFlag chan - liftIO $ atomicWriteIORef consumerRef $ pure (consumer, runningFlag) - liftIO $ forever $ threadDelay maxBound - -startWorker :: RabbitMqAdminOpts -> AppT IO (IORef (Maybe Q.Channel), IORef (Maybe (Q.ConsumerTag, MVar ()))) -startWorker rabbitmqOpts = do - env <- ask - chanRef <- newIORef Nothing - consumerRef <- newIORef Nothing - let clearRefs = do - runAppT env $ markAsNotWorking DefederationWorker - atomicWriteIORef chanRef Nothing - atomicWriteIORef consumerRef Nothing - void . liftIO . async . openConnectionWithRetries env.logger (demoteOpts rabbitmqOpts) $ - RabbitMqHooks - { onNewChannel = \chan -> do - atomicWriteIORef chanRef $ pure chan - runAppT env $ startDefederator consumerRef chan, - onChannelException = const clearRefs, - onConnectionClose = clearRefs - } - pure (chanRef, consumerRef) diff --git a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs index daacceeab32..243eb3d864b 100644 --- a/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs +++ b/services/background-worker/test/Test/Wire/BackendNotificationPusherSpec.hs @@ -4,7 +4,6 @@ module Test.Wire.BackendNotificationPusherSpec where -import Control.Concurrent.Chan import Control.Exception import Control.Monad.Trans.Except import Data.Aeson qualified as Aeson @@ -42,9 +41,9 @@ import Wire.API.Federation.API.Brig import Wire.API.Federation.API.Common import Wire.API.Federation.BackendNotifications import Wire.API.RawJson -import Wire.API.Routes.FederationDomainConfig import Wire.BackendNotificationPusher import Wire.BackgroundWorker.Env +import Wire.BackgroundWorker.Options import Wire.BackgroundWorker.Util spec :: Spec @@ -181,8 +180,6 @@ spec = do ] logger <- Logger.new Logger.defSettings httpManager <- newManager defaultManagerSettings - remoteDomains <- newIORef defFederationDomainConfigs - remoteDomainsChan <- newChan let federatorInternal = Endpoint "localhost" 8097 http2Manager = undefined statuses = undefined @@ -190,8 +187,7 @@ spec = do rabbitmqAdminClient = mockRabbitMqAdminClient mockAdmin rabbitmqVHost = "test-vhost" defederationTimeout = responseTimeoutNone - galley = Endpoint "localhost" 8085 - brig = Endpoint "localhost" 8082 + backendNotificationsConfig = BackendNotificationsConfig 1000 500000 1000 backendNotificationMetrics <- mkBackendNotificationMetrics domains <- runAppT Env {..} getRemoteDomains @@ -202,8 +198,6 @@ spec = do mockAdmin <- newMockRabbitMqAdmin True ["backend-notifications.foo.example"] logger <- Logger.new Logger.defSettings httpManager <- newManager defaultManagerSettings - remoteDomains <- newIORef defFederationDomainConfigs - remoteDomainsChan <- newChan let federatorInternal = Endpoint "localhost" 8097 http2Manager = undefined statuses = undefined @@ -211,8 +205,7 @@ spec = do rabbitmqAdminClient = mockRabbitMqAdminClient mockAdmin rabbitmqVHost = "test-vhost" defederationTimeout = responseTimeoutNone - galley = Endpoint "localhost" 8085 - brig = Endpoint "localhost" 8082 + backendNotificationsConfig = BackendNotificationsConfig 1000 500000 1000 backendNotificationMetrics <- mkBackendNotificationMetrics domainsThread <- async $ runAppT Env {..} getRemoteDomains @@ -267,7 +260,8 @@ newMockRabbitMqAdmin isBroken queues = do mockApi :: MockRabbitMqAdmin -> AdminAPI (AsServerT Servant.Handler) mockApi mockAdmin = AdminAPI - { listQueuesByVHost = mockListQueuesByVHost mockAdmin + { listQueuesByVHost = mockListQueuesByVHost mockAdmin, + deleteQueue = mockListDeleteQueue mockAdmin } mockListQueuesByVHost :: MockRabbitMqAdmin -> Text -> Servant.Handler [Queue] @@ -277,6 +271,10 @@ mockListQueuesByVHost MockRabbitMqAdmin {..} vhost = do True -> throwError $ Servant.err500 False -> pure $ map (\n -> Queue n vhost) queues +mockListDeleteQueue :: MockRabbitMqAdmin -> Text -> Text -> Servant.Handler NoContent +mockListDeleteQueue _ _ _ = do + pure NoContent + mockRabbitMqAdminApp :: MockRabbitMqAdmin -> Application mockRabbitMqAdminApp mockAdmin = genericServe (mockApi mockAdmin) diff --git a/services/background-worker/test/Test/Wire/DefederationSpec.hs b/services/background-worker/test/Test/Wire/DefederationSpec.hs deleted file mode 100644 index 8707414d442..00000000000 --- a/services/background-worker/test/Test/Wire/DefederationSpec.hs +++ /dev/null @@ -1,51 +0,0 @@ -module Test.Wire.DefederationSpec where - -import Data.Aeson qualified as Aeson -import Data.Domain -import Federator.MockServer -import Imports -import Network.AMQP qualified as Q -import Test.HUnit.Lang -import Test.Hspec -import Test.Wire.Util -import Wire.API.Federation.API.Common -import Wire.API.Federation.BackendNotifications -import Wire.BackgroundWorker.Util -import Wire.Defederation - -spec :: Spec -spec = do - describe - "Wire.BackendNotificationPusher.deleteFederationDomain" - $ do - it "should fail on message decoding" $ do - envelope <- newFakeEnvelope - let msg = Q.newMsg {Q.msgBody = Aeson.encode @[()] [], Q.msgContentType = Just "application/json"} - respSuccess _ = pure ("application/json", Aeson.encode EmptyResponse) - resps <- - withTempMockFederator [] respSuccess - . runTestAppT - $ deleteFederationDomainInner' (\e _ -> liftIO $ ack e) (msg, envelope) - case resps of - ((), []) -> pure () - _ -> assertFailure "Expected call to federation" - readIORef envelope.acks `shouldReturn` 0 - -- Fail to decode should not be requeued - readIORef envelope.rejections `shouldReturn` [False] - it "should succeed on message decoding" $ do - envelope <- newFakeEnvelope - let msg = - Q.newMsg - { Q.msgBody = Aeson.encode @DefederationDomain (Domain "far-away.example.com"), - Q.msgContentType = Just "application/json" - } - respSuccess _ = pure ("application/json", Aeson.encode EmptyResponse) - resps <- - withTempMockFederator [] respSuccess - . runTestAppT - $ deleteFederationDomainInner' (\e _ -> liftIO $ ack e) (msg, envelope) - case resps of - ((), []) -> pure () - _ -> assertFailure "Expected call to federation" - readIORef envelope.acks `shouldReturn` 1 - readIORef envelope.rejections `shouldReturn` [] diff --git a/services/background-worker/test/Test/Wire/Util.hs b/services/background-worker/test/Test/Wire/Util.hs index 470dd3992a2..ba698cccc2b 100644 --- a/services/background-worker/test/Test/Wire/Util.hs +++ b/services/background-worker/test/Test/Wire/Util.hs @@ -2,14 +2,13 @@ module Test.Wire.Util where -import Control.Concurrent.Chan import Imports import Network.HTTP.Client import System.Logger.Class qualified as Logger -import Util.Options -import Wire.API.Routes.FederationDomainConfig -import Wire.BackgroundWorker.Env hiding (federatorInternal, galley) +import Util.Options (Endpoint (..)) +import Wire.BackgroundWorker.Env hiding (federatorInternal) import Wire.BackgroundWorker.Env qualified as E +import Wire.BackgroundWorker.Options import Wire.BackgroundWorker.Util testEnv :: IO Env @@ -19,15 +18,12 @@ testEnv = do statuses <- newIORef mempty backendNotificationMetrics <- mkBackendNotificationMetrics httpManager <- newManager defaultManagerSettings - remoteDomains <- newIORef defFederationDomainConfigs - remoteDomainsChan <- newChan let federatorInternal = Endpoint "localhost" 0 rabbitmqAdminClient = undefined rabbitmqVHost = undefined metrics = undefined - galley = Endpoint "localhost" 8085 - brig = Endpoint "localhost" 8082 defederationTimeout = responseTimeoutNone + backendNotificationsConfig = BackendNotificationsConfig 1000 500000 1000 pure Env {..} runTestAppT :: AppT IO a -> Int -> IO a diff --git a/services/brig/brig.cabal b/services/brig/brig.cabal index 998ff4edf7b..9351b4f65b9 100644 --- a/services/brig/brig.cabal +++ b/services/brig/brig.cabal @@ -1,4 +1,4 @@ -cabal-version: 1.12 +cabal-version: 3.0 name: brig version: 2.0 synopsis: User Service @@ -6,15 +6,72 @@ category: Network author: Wire Swiss GmbH maintainer: Wire Swiss GmbH copyright: (c) 2017 Wire Swiss GmbH -license: AGPL-3 +license: AGPL-3.0-only license-file: LICENSE build-type: Simple extra-source-files: docs/swagger-v0.json docs/swagger-v1.json + docs/swagger-v2.json + docs/swagger-v3.json + docs/swagger-v4.json docs/swagger.md +common common-all + default-language: GHC2021 + ghc-options: + -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates + -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path + -Wredundant-constraints + + default-extensions: + NoImplicitPrelude + AllowAmbiguousTypes + BangPatterns + ConstraintKinds + DataKinds + DefaultSignatures + DeriveFunctor + DeriveGeneric + DeriveLift + DeriveTraversable + DerivingStrategies + DerivingVia + DuplicateRecordFields + EmptyCase + FlexibleContexts + FlexibleInstances + FunctionalDependencies + GADTs + GeneralizedNewtypeDeriving + InstanceSigs + KindSignatures + LambdaCase + MultiParamTypeClasses + MultiWayIf + NamedFieldPuns + NumericUnderscores + OverloadedLabels + OverloadedRecordDot + OverloadedStrings + PackageImports + PatternSynonyms + PolyKinds + QuasiQuotes + RankNTypes + ScopedTypeVariables + StandaloneDeriving + TupleSections + TypeApplications + TypeFamilies + TypeFamilyDependencies + TypeOperators + UndecidableInstances + ViewPatterns + library + import: common-all + -- cabal-fmt: expand src exposed-modules: Brig.Allowlists @@ -28,6 +85,7 @@ library Brig.API.Federation Brig.API.Handler Brig.API.Internal + Brig.API.MLS.CipherSuite Brig.API.MLS.KeyPackages Brig.API.MLS.KeyPackages.Validation Brig.API.MLS.Util @@ -104,6 +162,46 @@ library Brig.Queue.Types Brig.RPC Brig.Run + Brig.Schema.Run + Brig.Schema.V43 + Brig.Schema.V44 + Brig.Schema.V45 + Brig.Schema.V46 + Brig.Schema.V47 + Brig.Schema.V48 + Brig.Schema.V49 + Brig.Schema.V50 + Brig.Schema.V51 + Brig.Schema.V52 + Brig.Schema.V53 + Brig.Schema.V54 + Brig.Schema.V55 + Brig.Schema.V56 + Brig.Schema.V57 + Brig.Schema.V58 + Brig.Schema.V59 + Brig.Schema.V60_AddFederationIdMapping + Brig.Schema.V61_team_invitation_email + Brig.Schema.V62_RemoveFederationIdMapping + Brig.Schema.V63_AddUsersPendingActivation + Brig.Schema.V64_ClientCapabilities + Brig.Schema.V65_FederatedConnections + Brig.Schema.V66_PersonalFeatureConfCallInit + Brig.Schema.V67_MLSKeyPackages + Brig.Schema.V68_AddMLSPublicKeys + Brig.Schema.V69_MLSKeyPackageRefMapping + Brig.Schema.V70_UserEmailUnvalidated + Brig.Schema.V71_AddTableVCodesThrottle + Brig.Schema.V72_AddNonceTable + Brig.Schema.V73_ReplaceNonceTable + Brig.Schema.V74_AddOAuthTables + Brig.Schema.V75_AddOAuthCodeChallenge + Brig.Schema.V76_AddSupportedProtocols + Brig.Schema.V77_FederationRemotes + Brig.Schema.V78_ClientLastActive + Brig.Schema.V79_ConnectionRemoteIndex + Brig.Schema.V80_KeyPackageCiphersuite + Brig.Schema.V_FUTUREWORK Brig.SMTP Brig.Team.API Brig.Team.DB @@ -134,60 +232,15 @@ library Brig.Version Brig.ZAuth - other-modules: Paths_brig - hs-source-dirs: src - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - NumericUnderscores - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - + other-modules: Paths_brig + hs-source-dirs: src ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path -funbox-strict-fields -fplugin=Polysemy.Plugin -fplugin=TransitiveAnns.Plugin -Wredundant-constraints -Wunused-packages build-depends: - aeson >=2.0.1.0 + , aeson >=2.0.1.0 , amazonka >=2 , amazonka-core >=2 , amazonka-dynamodb >=2 @@ -257,6 +310,7 @@ library , mwc-random , network >=2.4 , network-conduit-tls + , openapi3 , optparse-applicative >=0.11 , polysemy , polysemy-plugin @@ -264,6 +318,7 @@ library , proto-lens >=0.1 , random , random-shuffle >=0.0.3 + , raw-strings-qq , resource-pool >=0.2 , resourcet >=1.1 , retry >=0.7 @@ -273,15 +328,14 @@ library , schema-profunctor , scientific >=0.3.4 , servant + , servant-openapi3 , servant-server - , servant-swagger , servant-swagger-ui , sodium-crypto-sign >=0.1 , split >=0.2 , ssl-util , statistics >=0.13 , stomp-queue >=0.3 - , swagger2 , template >=0.2 , template-haskell , text >=0.11 @@ -311,131 +365,36 @@ library , yaml >=0.8.22 , zauth >=0.10.3 - default-language: GHC2021 - executable brig - main-is: exec/Main.hs - other-modules: Paths_brig - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - NumericUnderscores - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - + import: common-all + main-is: exec/Main.hs + other-modules: Paths_brig ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path -funbox-strict-fields -threaded -with-rtsopts=-N -with-rtsopts=-T -rtsopts -Wredundant-constraints -Wunused-packages build-depends: - base + , base , brig , HsOpenSSL , imports , types-common - default-language: GHC2021 - executable brig-index - main-is: index/src/Main.hs - other-modules: Paths_brig - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - - ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -funbox-strict-fields -threaded -with-rtsopts=-N - -Wredundant-constraints -Wunused-packages - + import: common-all + main-is: index/src/Main.hs + other-modules: Paths_brig + ghc-options: -funbox-strict-fields -threaded -with-rtsopts=-N build-depends: - base + , base , brig , imports , optparse-applicative , tinylog - default-language: GHC2021 - executable brig-integration - main-is: Main.hs + import: common-all + main-is: ../integration.hs -- cabal-fmt: expand test/integration other-modules: @@ -472,68 +431,19 @@ executable brig-integration Federation.End2end Federation.Util Index.Create - Main + Run SMTP Util Util.AWS - hs-source-dirs: test/integration - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - NumericUnderscores - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - - ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -funbox-strict-fields -threaded -with-rtsopts=-N - -Wredundant-constraints -Wunused-packages - + hs-source-dirs: test/integration + ghc-options: -funbox-strict-fields -threaded -with-rtsopts=-N build-depends: - aeson + , aeson , async , attoparsec , base , base16-bytestring - , base64-bytestring , bilge , bloodhound , brig @@ -592,6 +502,7 @@ executable brig-integration , spar , streaming-commons , tasty >=1.0 + , tasty-ant-xml , tasty-cannon >=0.3.4 , tasty-hunit >=0.2 , temporary >=1.2.1 @@ -619,119 +530,27 @@ executable brig-integration , yaml , zauth - default-language: GHC2021 - executable brig-schema + import: common-all main-is: Main.hs - - -- cabal-fmt: expand schema/src - other-modules: - Main - V43 - V44 - V45 - V46 - V47 - V48 - V49 - V50 - V51 - V52 - V53 - V54 - V55 - V56 - V57 - V58 - V59 - V60_AddFederationIdMapping - V61_team_invitation_email - V62_RemoveFederationIdMapping - V63_AddUsersPendingActivation - V64_ClientCapabilities - V65_FederatedConnections - V66_PersonalFeatureConfCallInit - V67_MLSKeyPackages - V68_AddMLSPublicKeys - V69_MLSKeyPackageRefMapping - V70_UserEmailUnvalidated - V71_AddTableVCodesThrottle - V72_AddNonceTable - V73_ReplaceNonceTable - V74_AddOAuthTables - V75_AddOAuthCodeChallenge - V76_AddSupportedProtocols - V77_FederationRemotes - V78_ClientLastActive - V79_ConnectionRemoteIndex - V_FUTUREWORK - - hs-source-dirs: schema/src - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - NumericUnderscores - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - - ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -funbox-strict-fields -Wredundant-constraints -Wunused-packages - + hs-source-dirs: schema + ghc-options: -funbox-strict-fields -Wredundant-constraints + default-extensions: TemplateHaskell build-depends: - base - , cassandra-util >=0.12 + , base + , brig + , cassandra-util , extended , imports - , raw-strings-qq >=1.0 + , raw-strings-qq , types-common - default-language: GHC2021 - test-suite brig-tests - type: exitcode-stdio-1.0 - main-is: Main.hs - - -- cabal-fmt: expand test/unit + import: common-all + type: exitcode-stdio-1.0 + main-is: ../unit.hs other-modules: - Main + Run Test.Brig.Calling Test.Brig.Calling.Internal Test.Brig.Effects.Delay @@ -740,61 +559,14 @@ test-suite brig-tests Test.Brig.Roundtrip Test.Brig.User.Search.Index.Types - hs-source-dirs: test/unit - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - NumericUnderscores - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - - ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -funbox-strict-fields -threaded -with-rtsopts=-N - -Wredundant-constraints -Wunused-packages - + hs-source-dirs: test/unit + ghc-options: -funbox-strict-fields -threaded -with-rtsopts=-N build-depends: - aeson + , aeson , base , binary , brig + , brig-types , bytestring , containers , data-timeout @@ -816,5 +588,3 @@ test-suite brig-tests , uri-bytestring , uuid , wire-api - - default-language: GHC2021 diff --git a/services/brig/default.nix b/services/brig/default.nix index e5859de9282..6887c802f38 100644 --- a/services/brig/default.nix +++ b/services/brig/default.nix @@ -85,6 +85,7 @@ , network , network-conduit-tls , network-uri +, openapi3 , optparse-applicative , pem , pipes @@ -110,8 +111,8 @@ , servant , servant-client , servant-client-core +, servant-openapi3 , servant-server -, servant-swagger , servant-swagger-ui , sodium-crypto-sign , spar @@ -120,8 +121,8 @@ , statistics , stomp-queue , streaming-commons -, swagger2 , tasty +, tasty-ant-xml , tasty-cannon , tasty-hunit , tasty-quickcheck @@ -235,6 +236,7 @@ mkDerivation { mwc-random network network-conduit-tls + openapi3 optparse-applicative polysemy polysemy-plugin @@ -242,6 +244,7 @@ mkDerivation { proto-lens random random-shuffle + raw-strings-qq resource-pool resourcet retry @@ -251,15 +254,14 @@ mkDerivation { schema-profunctor scientific servant + servant-openapi3 servant-server - servant-swagger servant-swagger-ui sodium-crypto-sign split ssl-util statistics stomp-queue - swagger2 template template-haskell text @@ -295,7 +297,6 @@ mkDerivation { attoparsec base base16-bytestring - base64-bytestring bilge bloodhound brig-types @@ -354,6 +355,7 @@ mkDerivation { spar streaming-commons tasty + tasty-ant-xml tasty-cannon tasty-hunit temporary @@ -385,6 +387,7 @@ mkDerivation { aeson base binary + brig-types bytestring containers data-timeout diff --git a/services/brig/docs/swagger-v0.json b/services/brig/docs/swagger-v0.json index c6eaca520c5..39ba49998d3 100644 --- a/services/brig/docs/swagger-v0.json +++ b/services/brig/docs/swagger-v0.json @@ -1,4 +1,5 @@ { + "basePath": "/v0", "definitions": { "ASCII": { "example": "aGVsbG8", diff --git a/services/brig/docs/swagger-v1.json b/services/brig/docs/swagger-v1.json index 82a9e565841..3a81bc59e54 100644 --- a/services/brig/docs/swagger-v1.json +++ b/services/brig/docs/swagger-v1.json @@ -1,4 +1,5 @@ { + "basePath": "/v1", "definitions": { "ASCII": { "example": "aGVsbG8", diff --git a/services/brig/docs/swagger-v2.json b/services/brig/docs/swagger-v2.json index 875aeffc991..437aeb2380a 100644 --- a/services/brig/docs/swagger-v2.json +++ b/services/brig/docs/swagger-v2.json @@ -1,4 +1,5 @@ { + "basePath": "/v2", "definitions": { "ASCII": { "example": "aGVsbG8", diff --git a/services/brig/docs/swagger-v3.json b/services/brig/docs/swagger-v3.json index c366175b56b..e252a739717 100644 --- a/services/brig/docs/swagger-v3.json +++ b/services/brig/docs/swagger-v3.json @@ -1,4 +1,5 @@ { + "basePath": "/v3", "definitions": { "": { "description": "Username to use for authenticating against the given TURN servers", diff --git a/services/brig/docs/swagger-v4.json b/services/brig/docs/swagger-v4.json new file mode 100644 index 00000000000..937aafdefc9 --- /dev/null +++ b/services/brig/docs/swagger-v4.json @@ -0,0 +1,21936 @@ +{ + "basePath": "/v4", + "definitions": { + "": { + "description": "Username to use for authenticating against the given TURN servers", + "type": "string" + }, + "ASCII": { + "description": "Stable conversation identifier", + "maxLength": 20, + "minLength": 20, + "type": "string" + }, + "Access": { + "description": "How users can join conversations", + "enum": [ + "private", + "invite", + "link", + "code" + ], + "type": "string" + }, + "AccessRole": { + "description": "Which users/services can join conversations. This replaces legacy access roles and allows a more fine grained configuration of access roles, and in particular a separation of guest and services access.\n\nThis field is optional. If it is not present, the default will be `[team_member, non_team_member, service]`. Please note that an empty list is not allowed when creating a new conversation.", + "enum": [ + "team_member", + "non_team_member", + "guest", + "service" + ], + "type": "string" + }, + "AccessRoleLegacy": { + "description": "Deprecated, please use access_role_v2", + "enum": [ + "private", + "team", + "activated", + "non_activated" + ], + "type": "string" + }, + "AccessToken": { + "properties": { + "access_token": { + "description": "The opaque access token string", + "type": "string" + }, + "expires_in": { + "description": "The number of seconds this token is valid", + "type": "integer" + }, + "token_type": { + "$ref": "#/definitions/TokenType" + }, + "user": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "user", + "access_token", + "token_type", + "expires_in" + ], + "type": "object" + }, + "AccessTokenType": { + "enum": [ + "DPoP" + ], + "type": "string" + }, + "Action": { + "enum": [ + "add_conversation_member", + "remove_conversation_member", + "modify_conversation_name", + "modify_conversation_message_timer", + "modify_conversation_receipt_mode", + "modify_conversation_access", + "modify_other_conversation_member", + "leave_conversation", + "delete_conversation" + ], + "type": "string" + }, + "Activate": { + "description": "Data for an activation request.", + "properties": { + "code": { + "$ref": "#/definitions/ASCII" + }, + "dryrun": { + "description": "At least one of key, email, or phone has to be present while key takes precedence over email, and email takes precedence over phone. Whether to perform a dryrun, i.e. to only check whether activation would succeed. Dry-runs never issue access cookies or tokens on success but failures still count towards the maximum failure count.", + "type": "boolean" + }, + "email": { + "$ref": "#/definitions/Email" + }, + "key": { + "$ref": "#/definitions/ASCII" + }, + "phone": { + "$ref": "#/definitions/PhoneNumber" + } + }, + "required": [ + "code", + "dryrun" + ], + "type": "object" + }, + "ActivationResponse": { + "description": "Response body of a successful activation request", + "properties": { + "email": { + "$ref": "#/definitions/Email" + }, + "first": { + "description": "Whether this is the first successful activation (i.e. account activation).", + "type": "boolean" + }, + "phone": { + "$ref": "#/definitions/PhoneNumber" + }, + "sso_id": { + "$ref": "#/definitions/UserSSOId" + } + }, + "type": "object" + }, + "AddBot": { + "properties": { + "locale": { + "$ref": "#/definitions/Locale" + }, + "provider": { + "$ref": "#/definitions/UUID" + }, + "service": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "provider", + "service" + ], + "type": "object" + }, + "AddBotResponse": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/definitions/UserAsset" + }, + "type": "array" + }, + "client": { + "$ref": "#/definitions/ClientId" + }, + "event": { + "$ref": "#/definitions/Event" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "id", + "client", + "name", + "accent_id", + "assets", + "event" + ], + "type": "object" + }, + "AllFeatureConfigs": { + "properties": { + "appLock": { + "$ref": "#/definitions/AppLockConfig.WithStatus" + }, + "classifiedDomains": { + "$ref": "#/definitions/ClassifiedDomainsConfig.WithStatus" + }, + "conferenceCalling": { + "$ref": "#/definitions/ConferenceCallingConfig.WithStatus" + }, + "conversationGuestLinks": { + "$ref": "#/definitions/GuestLinksConfig.WithStatus" + }, + "digitalSignatures": { + "$ref": "#/definitions/DigitalSignaturesConfig.WithStatus" + }, + "exposeInvitationURLsToTeamAdmin": { + "$ref": "#/definitions/ExposeInvitationURLsToTeamAdminConfig.WithStatus" + }, + "fileSharing": { + "$ref": "#/definitions/FileSharingConfig.WithStatus" + }, + "legalhold": { + "$ref": "#/definitions/LegalholdConfig.WithStatus" + }, + "mls": { + "$ref": "#/definitions/MLSConfig.WithStatus" + }, + "mlsE2EId": { + "$ref": "#/definitions/MlsE2EIdConfig.WithStatus" + }, + "mlsMigration": { + "$ref": "#/definitions/MlsMigration.WithStatus" + }, + "outlookCalIntegration": { + "$ref": "#/definitions/OutlookCalIntegrationConfig.WithStatus" + }, + "searchVisibility": { + "$ref": "#/definitions/SearchVisibilityAvailableConfig.WithStatus" + }, + "searchVisibilityInbound": { + "$ref": "#/definitions/SearchVisibilityInboundConfig.WithStatus" + }, + "selfDeletingMessages": { + "$ref": "#/definitions/SelfDeletingMessagesConfig.WithStatus" + }, + "sndFactorPasswordChallenge": { + "$ref": "#/definitions/SndFactorPasswordChallengeConfig.WithStatus" + }, + "sso": { + "$ref": "#/definitions/SSOConfig.WithStatus" + }, + "validateSAMLemails": { + "$ref": "#/definitions/ValidateSAMLEmailsConfig.WithStatus" + } + }, + "required": [ + "legalhold", + "sso", + "searchVisibility", + "searchVisibilityInbound", + "validateSAMLemails", + "digitalSignatures", + "appLock", + "fileSharing", + "classifiedDomains", + "conferenceCalling", + "selfDeletingMessages", + "conversationGuestLinks", + "sndFactorPasswordChallenge", + "mls", + "exposeInvitationURLsToTeamAdmin", + "outlookCalIntegration", + "mlsE2EId", + "mlsMigration" + ], + "type": "object" + }, + "Alpha": { + "enum": [ + "AED", + "AFN", + "ALL", + "AMD", + "ANG", + "AOA", + "ARS", + "AUD", + "AWG", + "AZN", + "BAM", + "BBD", + "BDT", + "BGN", + "BHD", + "BIF", + "BMD", + "BND", + "BOB", + "BOV", + "BRL", + "BSD", + "BTN", + "BWP", + "BYN", + "BZD", + "CAD", + "CDF", + "CHE", + "CHF", + "CHW", + "CLF", + "CLP", + "CNY", + "COP", + "COU", + "CRC", + "CUC", + "CUP", + "CVE", + "CZK", + "DJF", + "DKK", + "DOP", + "DZD", + "EGP", + "ERN", + "ETB", + "EUR", + "FJD", + "FKP", + "GBP", + "GEL", + "GHS", + "GIP", + "GMD", + "GNF", + "GTQ", + "GYD", + "HKD", + "HNL", + "HRK", + "HTG", + "HUF", + "IDR", + "ILS", + "INR", + "IQD", + "IRR", + "ISK", + "JMD", + "JOD", + "JPY", + "KES", + "KGS", + "KHR", + "KMF", + "KPW", + "KRW", + "KWD", + "KYD", + "KZT", + "LAK", + "LBP", + "LKR", + "LRD", + "LSL", + "LYD", + "MAD", + "MDL", + "MGA", + "MKD", + "MMK", + "MNT", + "MOP", + "MRO", + "MUR", + "MVR", + "MWK", + "MXN", + "MXV", + "MYR", + "MZN", + "NAD", + "NGN", + "NIO", + "NOK", + "NPR", + "NZD", + "OMR", + "PAB", + "PEN", + "PGK", + "PHP", + "PKR", + "PLN", + "PYG", + "QAR", + "RON", + "RSD", + "RUB", + "RWF", + "SAR", + "SBD", + "SCR", + "SDG", + "SEK", + "SGD", + "SHP", + "SLL", + "SOS", + "SRD", + "SSP", + "STD", + "SVC", + "SYP", + "SZL", + "THB", + "TJS", + "TMT", + "TND", + "TOP", + "TRY", + "TTD", + "TWD", + "TZS", + "UAH", + "UGX", + "USD", + "USN", + "UYI", + "UYU", + "UZS", + "VEF", + "VND", + "VUV", + "WST", + "XAF", + "XAG", + "XAU", + "XBA", + "XBB", + "XBC", + "XBD", + "XCD", + "XDR", + "XOF", + "XPD", + "XPF", + "XPT", + "XSU", + "XTS", + "XUA", + "XXX", + "YER", + "ZAR", + "ZMW", + "ZWL" + ], + "type": "string" + }, + "AppLockConfig": { + "properties": { + "enforceAppLock": { + "type": "boolean" + }, + "inactivityTimeoutSecs": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "enforceAppLock", + "inactivityTimeoutSecs" + ], + "type": "object" + }, + "AppLockConfig.WithStatus": { + "properties": { + "config": { + "$ref": "#/definitions/AppLockConfig" + }, + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "AppLockConfig.WithStatusNoLock": { + "properties": { + "config": { + "$ref": "#/definitions/AppLockConfig" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "ApproveLegalHoldForUserRequest": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "Asset": { + "properties": { + "domain": { + "$ref": "#/definitions/Domain" + }, + "expires": { + "$ref": "#/definitions/UTCTime" + }, + "key": { + "$ref": "#/definitions/AssetKey" + }, + "token": { + "$ref": "#/definitions/ASCII" + } + }, + "required": [ + "key", + "domain" + ], + "type": "object" + }, + "AssetKey": { + "example": "3-1-47de4580-ae51-4650-acbb-d10c028cb0ac", + "type": "string" + }, + "AssetSize": { + "enum": [ + "preview", + "complete" + ], + "type": "string" + }, + "AssetSource": {}, + "AssetType": { + "enum": [ + "image" + ], + "type": "string" + }, + "AuthnRequest": { + "properties": { + "iD": { + "$ref": "#/definitions/ID" + }, + "issueInstant": { + "$ref": "#/definitions/Time" + }, + "issuer": { + "type": "string" + }, + "nameIDPolicy": { + "$ref": "#/definitions/NameIdPolicy" + } + }, + "required": [ + "iD", + "issueInstant", + "issuer" + ], + "type": "object" + }, + "Base64ByteString": { + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "BaseProtocol": { + "enum": [ + "proteus", + "mls" + ], + "type": "string" + }, + "BindingNewTeamUser": { + "properties": { + "currency": { + "$ref": "#/definitions/Alpha" + }, + "icon": { + "$ref": "#/definitions/Icon" + }, + "icon_key": { + "description": "team icon asset key", + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "members": { + "description": "initial team member ids (between 1 and 127)" + }, + "name": { + "description": "team name", + "maxLength": 256, + "minLength": 1, + "type": "string" + } + }, + "required": [ + "name", + "icon" + ], + "type": "object" + }, + "Body": {}, + "BotUserView": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "handle": { + "$ref": "#/definitions/Handle" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "team": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "id", + "name", + "accent_id" + ], + "type": "object" + }, + "CheckHandles": { + "properties": { + "handles": { + "items": { + "type": "string" + }, + "maxItems": 50, + "minItems": 1, + "type": "array" + }, + "return": { + "maximum": 10, + "minimum": 1, + "type": "integer" + } + }, + "required": [ + "handles", + "return" + ], + "type": "object" + }, + "CipherSuiteTag": { + "description": "The cipher suite of the corresponding MLS group", + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "ClassifiedDomainsConfig": { + "properties": { + "domains": { + "items": { + "$ref": "#/definitions/Domain" + }, + "type": "array" + } + }, + "required": [ + "domains" + ], + "type": "object" + }, + "ClassifiedDomainsConfig.WithStatus": { + "properties": { + "config": { + "$ref": "#/definitions/ClassifiedDomainsConfig" + }, + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "Client": { + "properties": { + "capabilities": { + "$ref": "#/definitions/ClientCapabilityList" + }, + "class": { + "$ref": "#/definitions/ClientClass" + }, + "cookie": { + "type": "string" + }, + "id": { + "$ref": "#/definitions/ClientId" + }, + "label": { + "type": "string" + }, + "last_active": { + "$ref": "#/definitions/UTCTime" + }, + "location": { + "$ref": "#/definitions/Location" + }, + "mls_public_keys": { + "$ref": "#/definitions/MLSPublicKeys" + }, + "model": { + "type": "string" + }, + "time": { + "$ref": "#/definitions/UTCTime" + }, + "type": { + "$ref": "#/definitions/ClientType" + } + }, + "required": [ + "id", + "type", + "time" + ], + "type": "object" + }, + "ClientCapability": { + "enum": [ + "legalhold-implicit-consent" + ], + "type": "string" + }, + "ClientCapabilityList": { + "properties": { + "capabilities": { + "description": "Hints provided by the client for the backend so it can behave in a backwards-compatible way.", + "items": { + "$ref": "#/definitions/ClientCapability" + }, + "type": "array" + } + }, + "required": [ + "capabilities" + ], + "type": "object" + }, + "ClientClass": { + "enum": [ + "phone", + "tablet", + "desktop", + "legalhold" + ], + "type": "string" + }, + "ClientId": { + "type": "string" + }, + "ClientMismatch": { + "properties": { + "deleted": { + "$ref": "#/definitions/UserClients" + }, + "missing": { + "$ref": "#/definitions/UserClients" + }, + "redundant": { + "$ref": "#/definitions/UserClients" + }, + "time": { + "$ref": "#/definitions/UTCTime" + } + }, + "required": [ + "time", + "missing", + "redundant", + "deleted" + ], + "type": "object" + }, + "ClientPrekey": { + "properties": { + "client": { + "$ref": "#/definitions/ClientId" + }, + "prekey": { + "$ref": "#/definitions/Prekey" + } + }, + "required": [ + "client", + "prekey" + ], + "type": "object" + }, + "ClientType": { + "enum": [ + "temporary", + "permanent", + "legalhold" + ], + "type": "string" + }, + "CodeChallengeMethod": { + "description": "The method used to encode the code challenge. Only `S256` is supported.", + "enum": [ + "S256" + ], + "type": "string" + }, + "CompletePasswordReset": { + "description": "Data to complete a password reset", + "properties": { + "code": { + "$ref": "#/definitions/ASCII" + }, + "email": { + "$ref": "#/definitions/Email" + }, + "key": { + "$ref": "#/definitions/ASCII" + }, + "password": { + "description": "New password (6 - 1024 characters)", + "maxLength": 1024, + "minLength": 8, + "type": "string" + }, + "phone": { + "$ref": "#/definitions/PhoneNumber" + } + }, + "required": [ + "code", + "password" + ], + "type": "object" + }, + "ConferenceCallingConfig.WithStatus": { + "properties": { + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "Connect": { + "properties": { + "email": { + "type": "string" + }, + "message": { + "type": "string" + }, + "name": { + "type": "string" + }, + "qualified_recipient": { + "$ref": "#/definitions/Qualified_UserId" + }, + "recipient": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "qualified_recipient" + ], + "type": "object" + }, + "ConnectionUpdate": { + "properties": { + "status": { + "$ref": "#/definitions/Relation" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "Connections_Page": { + "properties": { + "connections": { + "items": { + "$ref": "#/definitions/UserConnection" + }, + "type": "array" + }, + "has_more": { + "type": "boolean" + }, + "paging_state": { + "$ref": "#/definitions/Connections_PagingState" + } + }, + "required": [ + "connections", + "has_more", + "paging_state" + ], + "type": "object" + }, + "Connections_PagingState": { + "type": "string" + }, + "Contact": { + "description": "Contact discovered through search", + "properties": { + "accent_id": { + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + }, + "handle": { + "type": "string" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "name": { + "type": "string" + }, + "qualified_id": { + "$ref": "#/definitions/Qualified_UserId" + }, + "team": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "qualified_id", + "name" + ], + "type": "object" + }, + "ConvMembers": { + "description": "Users of a conversation", + "properties": { + "others": { + "description": "All other current users of this conversation", + "items": { + "$ref": "#/definitions/OtherMember" + }, + "type": "array" + }, + "self": { + "$ref": "#/definitions/Member" + } + }, + "required": [ + "self", + "others" + ], + "type": "object" + }, + "ConvTeamInfo": { + "description": "Team information of this conversation", + "properties": { + "managed": { + "description": "This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface." + }, + "teamid": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "teamid", + "managed" + ], + "type": "object" + }, + "ConvType": { + "enum": [ + 0, + 1, + 2, + 3 + ], + "type": "integer" + }, + "Conversation": { + "description": "A conversation object as returned from the server", + "properties": { + "access": { + "items": { + "$ref": "#/definitions/Access" + }, + "type": "array" + }, + "access_role": { + "$ref": "#/definitions/AccessRoleLegacy" + }, + "access_role_v2": { + "items": { + "$ref": "#/definitions/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/definitions/CipherSuiteTag" + }, + "creator": { + "$ref": "#/definitions/UUID" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/definitions/Epoch Timestamp" + }, + "group_id": { + "$ref": "#/definitions/GroupId" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/definitions/ConvMembers" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/Protocol" + }, + "qualified_id": { + "$ref": "#/definitions/Qualified_ConvId" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/definitions/UUID" + }, + "type": { + "$ref": "#/definitions/ConvType" + } + }, + "required": [ + "qualified_id", + "type", + "access", + "members", + "group_id", + "epoch", + "epoch_timestamp", + "cipher_suite" + ], + "type": "object" + }, + "ConversationAccessDatav2": { + "properties": { + "access": { + "items": { + "$ref": "#/definitions/Access" + }, + "type": "array" + }, + "access_role": { + "$ref": "#/definitions/AccessRoleLegacy" + }, + "access_role_v2": { + "items": { + "$ref": "#/definitions/AccessRole" + }, + "type": "array" + } + }, + "required": [ + "access" + ], + "type": "object" + }, + "ConversationAccessDatav3": { + "properties": { + "access": { + "items": { + "$ref": "#/definitions/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/definitions/AccessRole" + }, + "type": "array" + } + }, + "required": [ + "access", + "access_role" + ], + "type": "object" + }, + "ConversationCode": { + "description": "Contains conversation properties to update", + "properties": { + "code": { + "$ref": "#/definitions/ASCII" + }, + "key": { + "$ref": "#/definitions/ASCII" + }, + "uri": { + "$ref": "#/definitions/HttpsUrl" + } + }, + "required": [ + "key", + "code" + ], + "type": "object" + }, + "ConversationCodeInfo": { + "description": "Contains conversation properties to update", + "properties": { + "code": { + "$ref": "#/definitions/ASCII" + }, + "has_password": { + "description": "Whether the conversation has a password", + "type": "boolean" + }, + "key": { + "$ref": "#/definitions/ASCII" + }, + "uri": { + "$ref": "#/definitions/HttpsUrl" + } + }, + "required": [ + "key", + "code", + "has_password" + ], + "type": "object" + }, + "ConversationCoverView": { + "description": "Limited view of Conversation.", + "properties": { + "has_password": { + "type": "boolean" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "name": { + "type": "string" + } + }, + "required": [ + "id", + "has_password" + ], + "type": "object" + }, + "ConversationIds_Page": { + "properties": { + "has_more": { + "type": "boolean" + }, + "paging_state": { + "$ref": "#/definitions/ConversationIds_PagingState" + }, + "qualified_conversations": { + "items": { + "$ref": "#/definitions/Qualified_ConvId" + }, + "type": "array" + } + }, + "required": [ + "qualified_conversations", + "has_more", + "paging_state" + ], + "type": "object" + }, + "ConversationIds_PagingState": { + "type": "string" + }, + "ConversationMessageTimerUpdate": { + "description": "Contains conversation properties to update", + "properties": { + "message_timer": { + "format": "int64", + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + } + }, + "type": "object" + }, + "ConversationReceiptModeUpdate": { + "description": "Contains conversation receipt mode to update to. Receipt mode tells clients whether certain types of receipts should be sent in the given conversation or not. How this value is interpreted is up to clients.", + "properties": { + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "receipt_mode" + ], + "type": "object" + }, + "ConversationRename": { + "properties": { + "name": { + "description": "The new conversation name", + "type": "string" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "ConversationRole": { + "properties": { + "actions": { + "description": "The set of actions allowed for this role", + "items": { + "$ref": "#/definitions/Action" + }, + "type": "array" + }, + "conversation_role": { + "$ref": "#/definitions/RoleName" + } + } + }, + "ConversationRolesList": { + "properties": { + "conversation_roles": { + "items": { + "$ref": "#/definitions/ConversationRole" + }, + "type": "array" + } + }, + "required": [ + "conversation_roles" + ], + "type": "object" + }, + "ConversationsResponse": { + "description": "Response object for getting metadata of a list of conversations", + "properties": { + "failed": { + "description": "The server failed to fetch these conversations, most likely due to network issues while contacting a remote server", + "items": { + "$ref": "#/definitions/Qualified_ConvId" + }, + "type": "array" + }, + "found": { + "items": { + "$ref": "#/definitions/Conversation" + }, + "type": "array" + }, + "not_found": { + "description": "These conversations either don't exist or are deleted.", + "items": { + "$ref": "#/definitions/Qualified_ConvId" + }, + "type": "array" + } + }, + "required": [ + "found", + "not_found", + "failed" + ], + "type": "object" + }, + "Cookie": { + "properties": { + "created": { + "$ref": "#/definitions/UTCTime" + }, + "expires": { + "$ref": "#/definitions/UTCTime" + }, + "id": { + "format": "int32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "label": { + "type": "string" + }, + "successor": { + "format": "int32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "type": { + "$ref": "#/definitions/CookieType" + } + }, + "required": [ + "id", + "type", + "created", + "expires" + ], + "type": "object" + }, + "CookieList": { + "description": "List of cookie information", + "properties": { + "cookies": { + "items": { + "$ref": "#/definitions/Cookie" + }, + "type": "array" + } + }, + "required": [ + "cookies" + ], + "type": "object" + }, + "CookieType": { + "enum": [ + "session", + "persistent" + ], + "type": "string" + }, + "CreateConversationCodeRequest": { + "description": "Request body for creating a conversation code", + "properties": { + "password": { + "description": "Password for accessing the conversation via guest link. Set to null or omit for no password.", + "maxLength": 1024, + "minLength": 8, + "type": "string" + } + }, + "type": "object" + }, + "CreateGroupConversation": { + "description": "A created group-conversation object extended with a list of failed-to-add users", + "properties": { + "access": { + "items": { + "$ref": "#/definitions/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/definitions/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/definitions/CipherSuiteTag" + }, + "creator": { + "$ref": "#/definitions/UUID" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/definitions/Epoch Timestamp" + }, + "failed_to_add": { + "items": { + "$ref": "#/definitions/Qualified_UserId" + }, + "type": "array" + }, + "group_id": { + "$ref": "#/definitions/GroupId" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/definitions/ConvMembers" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + }, + "name": { + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/Protocol" + }, + "qualified_id": { + "$ref": "#/definitions/Qualified_ConvId" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/definitions/UUID" + }, + "type": { + "$ref": "#/definitions/ConvType" + } + }, + "required": [ + "qualified_id", + "type", + "access", + "access_role", + "members", + "group_id", + "epoch", + "epoch_timestamp", + "cipher_suite", + "failed_to_add" + ], + "type": "object" + }, + "CreateOAuthAuthorizationCodeRequest": { + "properties": { + "client_id": { + "$ref": "#/definitions/UUID" + }, + "code_challenge": { + "$ref": "#/definitions/OAuthCodeChallenge" + }, + "code_challenge_method": { + "$ref": "#/definitions/CodeChallengeMethod" + }, + "redirect_uri": { + "$ref": "#/definitions/RedirectUrl" + }, + "response_type": { + "$ref": "#/definitions/OAuthResponseType" + }, + "scope": { + "description": "The scopes which are requested to get authorization for, separated by a space", + "type": "string" + }, + "state": { + "description": "An opaque value used by the client to maintain state between the request and callback. The authorization server includes this value when redirecting the user-agent back to the client. The parameter SHOULD be used for preventing cross-site request forgery", + "type": "string" + } + }, + "required": [ + "client_id", + "scope", + "response_type", + "redirect_uri", + "state", + "code_challenge_method", + "code_challenge" + ], + "type": "object" + }, + "CreateScimToken": { + "properties": { + "description": { + "type": "string" + }, + "password": { + "type": "string" + }, + "verification_code": { + "type": "string" + } + }, + "required": [ + "description" + ], + "type": "object" + }, + "CreateScimTokenResponse": { + "properties": { + "info": { + "$ref": "#/definitions/ScimTokenInfo" + }, + "token": { + "description": "Authentication token", + "type": "string" + } + }, + "required": [ + "token", + "info" + ], + "type": "object" + }, + "CustomBackend": { + "description": "Description of a custom backend", + "properties": { + "config_json_url": { + "$ref": "#/definitions/HttpsUrl" + }, + "webapp_welcome_url": { + "$ref": "#/definitions/HttpsUrl" + } + }, + "required": [ + "config_json_url", + "webapp_welcome_url" + ], + "type": "object" + }, + "DPoPAccessToken": { + "type": "string" + }, + "DPoPAccessTokenResponse": { + "properties": { + "expires_in": { + "format": "int64", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + }, + "token": { + "$ref": "#/definitions/DPoPAccessToken" + }, + "type": { + "$ref": "#/definitions/AccessTokenType" + } + }, + "required": [ + "token", + "type", + "expires_in" + ], + "type": "object" + }, + "DeleteClient": { + "properties": { + "password": { + "description": "The password of the authenticated user for verification. The password is not required for deleting temporary clients.", + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "DeleteUser": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "DeletionCodeTimeout": { + "properties": { + "expires_in": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "expires_in" + ], + "type": "object" + }, + "DeprecatedMatchingResult": { + "properties": { + "auto-connects": { + "items": {}, + "type": "array" + }, + "results": { + "items": {}, + "type": "array" + } + }, + "required": [ + "results", + "auto-connects" + ], + "type": "object" + }, + "DigitalSignaturesConfig.WithStatus": { + "properties": { + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "DisableLegalHoldForUserRequest": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "Domain": { + "example": "example.com", + "type": "string" + }, + "Either": { + "maxProperties": 1, + "minProperties": 1, + "properties": { + "Left": { + "$ref": "#/definitions/OAuthAccessTokenRequest" + }, + "Right": { + "$ref": "#/definitions/OAuthRefreshAccessTokenRequest" + } + }, + "type": "object" + }, + "Email": { + "type": "string" + }, + "EmailUpdate": { + "properties": { + "email": { + "$ref": "#/definitions/Email" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "Epoch Timestamp": { + "description": "The timestamp of the epoch number", + "example": "2021-05-12T10:52:02.671Z", + "format": "yyyy-mm-ddThh:MM:ss.qqq", + "type": "string" + }, + "Event": { + "properties": { + "conversation": { + "$ref": "#/definitions/UUID" + }, + "data": { + "description": "Encrypted message of a conversation", + "example": "ZXhhbXBsZQo=", + "properties": { + "access": { + "items": { + "$ref": "#/definitions/Access" + }, + "type": "array" + }, + "access_role": { + "$ref": "#/definitions/AccessRoleLegacy" + }, + "access_role_v2": { + "items": { + "$ref": "#/definitions/AccessRole" + }, + "type": "array" + }, + "cipher_suite": { + "$ref": "#/definitions/CipherSuiteTag" + }, + "code": { + "$ref": "#/definitions/ASCII" + }, + "conversation_role": { + "$ref": "#/definitions/RoleName" + }, + "creator": { + "$ref": "#/definitions/UUID" + }, + "data": { + "description": "Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.", + "type": "string" + }, + "email": { + "type": "string" + }, + "epoch": { + "description": "The epoch number of the corresponding MLS group", + "format": "int64", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + }, + "epoch_timestamp": { + "$ref": "#/definitions/Epoch Timestamp" + }, + "group_id": { + "$ref": "#/definitions/GroupId" + }, + "has_password": { + "description": "Whether the conversation has a password", + "type": "boolean" + }, + "hidden": { + "type": "boolean" + }, + "hidden_ref": { + "type": "string" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "key": { + "$ref": "#/definitions/ASCII" + }, + "last_event": { + "type": "string" + }, + "last_event_time": { + "type": "string" + }, + "members": { + "$ref": "#/definitions/ConvMembers" + }, + "message": { + "type": "string" + }, + "message_timer": { + "description": "Per-conversation message timer (can be null)", + "format": "int64", + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + }, + "name": { + "type": "string" + }, + "otr_archived": { + "type": "boolean" + }, + "otr_archived_ref": { + "type": "string" + }, + "otr_muted_ref": { + "type": "string" + }, + "otr_muted_status": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "protocol": { + "$ref": "#/definitions/Protocol" + }, + "qualified_id": { + "$ref": "#/definitions/Qualified_ConvId" + }, + "qualified_recipient": { + "$ref": "#/definitions/Qualified_UserId" + }, + "qualified_target": { + "$ref": "#/definitions/Qualified_UserId" + }, + "qualified_user_ids": { + "items": { + "$ref": "#/definitions/Qualified_UserId" + }, + "type": "array" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "recipient": { + "$ref": "#/definitions/ClientId" + }, + "sender": { + "$ref": "#/definitions/ClientId" + }, + "status": { + "$ref": "#/definitions/TypingStatus" + }, + "target": { + "$ref": "#/definitions/UUID" + }, + "team": { + "$ref": "#/definitions/UUID" + }, + "text": { + "description": "The ciphertext for the recipient (Base64 in JSON)", + "type": "string" + }, + "type": { + "$ref": "#/definitions/ConvType" + }, + "uri": { + "$ref": "#/definitions/HttpsUrl" + }, + "user_ids": { + "description": "Deprecated, use qualified_user_ids", + "items": { + "$ref": "#/definitions/UUID" + }, + "type": "array" + }, + "users": { + "items": { + "$ref": "#/definitions/SimpleMember" + }, + "type": "array" + } + }, + "required": [ + "users", + "qualified_user_ids", + "user_ids", + "qualified_target", + "name", + "access", + "key", + "code", + "has_password", + "qualified_id", + "type", + "members", + "group_id", + "epoch", + "epoch_timestamp", + "cipher_suite", + "qualified_recipient", + "receipt_mode", + "sender", + "recipient", + "text", + "status" + ], + "type": "object" + }, + "from": { + "$ref": "#/definitions/UUID" + }, + "qualified_conversation": { + "$ref": "#/definitions/Qualified_ConvId" + }, + "qualified_from": { + "$ref": "#/definitions/Qualified_UserId" + }, + "subconv": { + "type": "string" + }, + "time": { + "$ref": "#/definitions/UTCTime" + }, + "type": { + "$ref": "#/definitions/EventType" + } + }, + "required": [ + "type", + "data", + "qualified_conversation", + "qualified_from", + "time" + ], + "type": "object" + }, + "EventType": { + "enum": [ + "conversation.member-join", + "conversation.member-leave", + "conversation.member-update", + "conversation.rename", + "conversation.access-update", + "conversation.receipt-mode-update", + "conversation.message-timer-update", + "conversation.code-update", + "conversation.code-delete", + "conversation.create", + "conversation.delete", + "conversation.connect-request", + "conversation.typing", + "conversation.otr-message-add", + "conversation.mls-message-add", + "conversation.mls-welcome", + "conversation.protocol-update" + ], + "type": "string" + }, + "ExposeInvitationURLsToTeamAdminConfig.WithStatus": { + "properties": { + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "ExposeInvitationURLsToTeamAdminConfig.WithStatusNoLock": { + "properties": { + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "FeatureStatus": { + "enum": [ + "enabled", + "disabled" + ], + "type": "string" + }, + "FederatedUserSearchPolicy": { + "description": "Search policy that was applied when searching for users", + "enum": [ + "no_search", + "exact_handle_search", + "full_search" + ], + "type": "string" + }, + "FileSharingConfig.WithStatus": { + "properties": { + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "FileSharingConfig.WithStatusNoLock": { + "properties": { + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "Fingerprint": { + "example": "ioy3GeIjgQRsobf2EKGO3O8mq/FofFxHRqy0T4ERIZ8=", + "type": "string" + }, + "FormRedirect": { + "properties": { + "uri": { + "type": "string" + }, + "xml": { + "$ref": "#/definitions/AuthnRequest" + } + }, + "type": "object" + }, + "GetPaginated_Connections": { + "description": "A request to list some or all of a user's Connections, including remote ones", + "properties": { + "paging_state": { + "$ref": "#/definitions/Connections_PagingState" + }, + "size": { + "description": "optional, must be <= 500, defaults to 100.", + "format": "int32", + "maximum": 500, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "GetPaginated_ConversationIds": { + "description": "A request to list some or all of a user's ConversationIds, including remote ones", + "properties": { + "paging_state": { + "$ref": "#/definitions/ConversationIds_PagingState" + }, + "size": { + "description": "optional, must be <= 1000, defaults to 1000.", + "format": "int32", + "maximum": 1000, + "minimum": 1, + "type": "integer" + } + }, + "type": "object" + }, + "GroupId": { + "description": "An MLS group identifier (at most 256 bytes long)", + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "GuestLinksConfig.WithStatus": { + "properties": { + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "GuestLinksConfig.WithStatusNoLock": { + "properties": { + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "Handle": { + "type": "string" + }, + "HandleUpdate": { + "properties": { + "handle": { + "type": "string" + } + }, + "required": [ + "handle" + ], + "type": "object" + }, + "HttpsUrl": { + "description": "Full URI (containing key/code) to join a conversation", + "example": "https://example.com", + "type": "string" + }, + "ID": { + "properties": { + "iD": { + "$ref": "#/definitions/XmlText" + } + }, + "required": [ + "iD" + ], + "type": "object" + }, + "Icon": { + "type": "string" + }, + "Id": { + "properties": { + "id": { + "$ref": "#/definitions/ClientId" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "IdPConfig": { + "properties": { + "extraInfo": { + "$ref": "#/definitions/WireIdP" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "metadata": { + "$ref": "#/definitions/IdPMetadata" + } + }, + "required": [ + "id", + "metadata", + "extraInfo" + ], + "type": "object" + }, + "IdPList": { + "properties": { + "providers": { + "items": { + "$ref": "#/definitions/IdPConfig" + }, + "type": "array" + } + }, + "required": [ + "providers" + ], + "type": "object" + }, + "IdPMetadata": { + "properties": { + "certAuthnResponse": { + "items": { + "type": "string" + }, + "minItems": 1, + "type": "array" + }, + "issuer": { + "type": "string" + }, + "requestURI": { + "type": "string" + } + }, + "required": [ + "issuer", + "requestURI", + "certAuthnResponse" + ], + "type": "object" + }, + "IdPMetadataInfo": { + "maxProperties": 1, + "minProperties": 1, + "properties": { + "value": { + "type": "string" + } + }, + "type": "object" + }, + "Invitation": { + "description": "An invitation to join a team on Wire", + "properties": { + "created_at": { + "$ref": "#/definitions/UTCTime" + }, + "created_by": { + "$ref": "#/definitions/UUID" + }, + "email": { + "$ref": "#/definitions/Email" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "name": { + "description": "Name of the invitee (1 - 128 characters)", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "phone": { + "$ref": "#/definitions/PhoneNumber" + }, + "role": { + "$ref": "#/definitions/Role" + }, + "team": { + "$ref": "#/definitions/UUID" + }, + "url": { + "$ref": "#/definitions/URIRef Absolute" + } + }, + "required": [ + "team", + "id", + "created_at", + "email" + ], + "type": "object" + }, + "InvitationList": { + "description": "A list of sent team invitations.", + "properties": { + "has_more": { + "description": "Indicator that the server has more invitations than returned.", + "type": "boolean" + }, + "invitations": { + "items": { + "$ref": "#/definitions/Invitation" + }, + "type": "array" + } + }, + "required": [ + "invitations", + "has_more" + ], + "type": "object" + }, + "InvitationRequest": { + "description": "A request to join a team on Wire.", + "properties": { + "email": { + "$ref": "#/definitions/Email" + }, + "locale": { + "$ref": "#/definitions/Locale" + }, + "name": { + "description": "Name of the invitee (1 - 128 characters).", + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "phone": { + "$ref": "#/definitions/PhoneNumber" + }, + "role": { + "$ref": "#/definitions/Role" + } + }, + "required": [ + "email" + ], + "type": "object" + }, + "InviteQualified": { + "properties": { + "conversation_role": { + "$ref": "#/definitions/RoleName" + }, + "qualified_users": { + "items": { + "$ref": "#/definitions/Qualified_UserId" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "qualified_users" + ], + "type": "object" + }, + "JoinConversationByCode": { + "description": "Request body for joining a conversation by code", + "properties": { + "code": { + "$ref": "#/definitions/ASCII" + }, + "key": { + "$ref": "#/definitions/ASCII" + }, + "password": { + "maxLength": 1024, + "minLength": 8, + "type": "string" + }, + "uri": { + "$ref": "#/definitions/HttpsUrl" + } + }, + "required": [ + "key", + "code" + ], + "type": "object" + }, + "LHServiceStatus": { + "enum": [ + "configured", + "not_configured", + "disabled" + ], + "type": "string" + }, + "LegalholdConfig.WithStatus": { + "properties": { + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "LegalholdConfig.WithStatusNoLock": { + "properties": { + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "LimitedQualifiedUserIdList": { + "properties": { + "qualified_users": { + "items": { + "$ref": "#/definitions/Qualified_UserId" + }, + "type": "array" + } + }, + "required": [ + "qualified_users" + ], + "type": "object" + }, + "ListConversations": { + "description": "A request to list some of a user's conversations, including remote ones. Maximum 1000 qualified conversation IDs", + "properties": { + "qualified_ids": { + "items": { + "$ref": "#/definitions/Qualified_ConvId" + }, + "maxItems": 1000, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "qualified_ids" + ], + "type": "object" + }, + "ListType": { + "description": "true if 'members' doesn't contain all team members", + "enum": [ + true, + false + ], + "type": "boolean" + }, + "ListUsersById": { + "properties": { + "failed": { + "items": { + "$ref": "#/definitions/Qualified_UserId" + }, + "minItems": 1, + "type": "array" + }, + "found": { + "items": { + "$ref": "#/definitions/UserProfile" + }, + "type": "array" + } + }, + "required": [ + "found" + ], + "type": "object" + }, + "ListUsersQuery": { + "description": "exactly one of qualified_ids or qualified_handles must be provided.", + "example": { + "qualified_ids": [ + { + "domain": "example.com", + "id": "00000000-0000-0000-0000-000000000000" + } + ] + }, + "properties": { + "qualified_handles": { + "items": { + "$ref": "#/definitions/Qualified_Handle" + }, + "type": "array" + }, + "qualified_ids": { + "items": { + "$ref": "#/definitions/Qualified_UserId" + }, + "type": "array" + } + }, + "type": "object" + }, + "Locale": { + "type": "string" + }, + "LocaleUpdate": { + "properties": { + "locale": { + "$ref": "#/definitions/Locale" + } + }, + "required": [ + "locale" + ], + "type": "object" + }, + "Location": { + "properties": { + "lat": { + "format": "double", + "type": "number" + }, + "lon": { + "format": "double", + "type": "number" + } + }, + "required": [ + "lat", + "lon" + ], + "type": "object" + }, + "LockStatus": { + "enum": [ + "locked", + "unlocked" + ], + "type": "string" + }, + "Login": { + "properties": { + "code": { + "$ref": "#/definitions/LoginCode" + }, + "email": { + "$ref": "#/definitions/Email" + }, + "handle": { + "$ref": "#/definitions/Handle" + }, + "label": { + "description": "This label can be used to delete all cookies matching it (cf. /cookies/remove)", + "type": "string" + }, + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "phone": { + "$ref": "#/definitions/PhoneNumber" + }, + "verification_code": { + "$ref": "#/definitions/ASCII" + } + }, + "required": [ + "password", + "phone", + "code" + ], + "type": "object" + }, + "LoginCode": { + "type": "string" + }, + "LoginCodeTimeout": { + "description": "A response for a successfully sent login code", + "properties": { + "expires_in": { + "description": "Number of seconds before the login code expires", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "expires_in" + ], + "type": "object" + }, + "MLSConfig": { + "properties": { + "allowedCipherSuites": { + "items": { + "$ref": "#/definitions/CipherSuiteTag" + }, + "type": "array" + }, + "defaultCipherSuite": { + "$ref": "#/definitions/CipherSuiteTag" + }, + "defaultProtocol": { + "$ref": "#/definitions/Protocol" + }, + "protocolToggleUsers": { + "description": "allowlist of users that may change protocols", + "items": { + "$ref": "#/definitions/UUID" + }, + "type": "array" + }, + "supportedProtocols": { + "items": { + "$ref": "#/definitions/Protocol" + }, + "type": "array" + } + }, + "required": [ + "protocolToggleUsers", + "defaultProtocol", + "allowedCipherSuites", + "defaultCipherSuite", + "supportedProtocols" + ], + "type": "object" + }, + "MLSConfig.WithStatus": { + "properties": { + "config": { + "$ref": "#/definitions/MLSConfig" + }, + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "MLSPublicKeys": { + "additionalProperties": { + "example": "ZXhhbXBsZQo=", + "type": "string" + }, + "description": "Mapping from signature scheme (tags) to public key data", + "example": { + "ed25519": "ZXhhbXBsZQo=" + }, + "type": "object" + }, + "ManagedBy": { + "enum": [ + "wire", + "scim" + ], + "type": "string" + }, + "Member": { + "description": "The user ID of the requestor", + "properties": { + "conversation_role": { + "$ref": "#/definitions/RoleName" + }, + "hidden": { + "type": "boolean" + }, + "hidden_ref": { + "type": "string" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "otr_archived": { + "type": "boolean" + }, + "otr_archived_ref": { + "type": "string" + }, + "otr_muted_ref": { + "type": "string" + }, + "otr_muted_status": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "qualified_id": { + "$ref": "#/definitions/Qualified_UserId" + }, + "service": { + "$ref": "#/definitions/ServiceRef" + }, + "status": {}, + "status_ref": {}, + "status_time": {} + }, + "required": [ + "qualified_id" + ], + "type": "object" + }, + "MemberUpdate": { + "properties": { + "hidden": { + "type": "boolean" + }, + "hidden_ref": { + "type": "string" + }, + "otr_archived": { + "type": "boolean" + }, + "otr_archived_ref": { + "type": "string" + }, + "otr_muted_ref": { + "type": "string" + }, + "otr_muted_status": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "type": "object" + }, + "MemberUpdateData": { + "properties": { + "conversation_role": { + "$ref": "#/definitions/RoleName" + }, + "hidden": { + "type": "boolean" + }, + "hidden_ref": { + "type": "string" + }, + "otr_archived": { + "type": "boolean" + }, + "otr_archived_ref": { + "type": "string" + }, + "otr_muted_ref": { + "type": "string" + }, + "otr_muted_status": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "qualified_target": { + "$ref": "#/definitions/Qualified_UserId" + }, + "target": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "qualified_target" + ], + "type": "object" + }, + "MessageSendingStatus": { + "description": "The Proteus message sending status. It has these fields:\n- `time`: Time of sending message.\n- `missing`: Clients that the message /should/ have been encrypted for, but wasn't.\n- `redundant`: Clients that the message /should not/ have been encrypted for, but was.\n- `deleted`: Clients that were deleted.\n- `failed_to_send`: When message sending fails for some clients but succeeds for others, e.g., because a remote backend is unreachable, this field will contain the list of clients for which the message sending failed. This list should be empty when message sending is not even tried, like when some clients are missing.", + "properties": { + "deleted": { + "$ref": "#/definitions/QualifiedUserClients" + }, + "failed_to_confirm_clients": { + "$ref": "#/definitions/QualifiedUserClients" + }, + "failed_to_send": { + "$ref": "#/definitions/QualifiedUserClients" + }, + "missing": { + "$ref": "#/definitions/QualifiedUserClients" + }, + "redundant": { + "$ref": "#/definitions/QualifiedUserClients" + }, + "time": { + "$ref": "#/definitions/UTCTime" + } + }, + "required": [ + "time", + "missing", + "redundant", + "deleted", + "failed_to_send", + "failed_to_confirm_clients" + ], + "type": "object" + }, + "MlsE2EIdConfig": { + "properties": { + "acmeDiscoveryUrl": { + "$ref": "#/definitions/HttpsUrl" + }, + "verificationExpiration": { + "description": "When a client first tries to fetch or renew a certificate, they may need to login to an identity provider (IdP) depending on their IdP domain authentication policy. The user may have a grace period during which they can “snooze” this login. The duration of this grace period (in seconds) is set in the `verificationDuration` parameter, which is enforced separately by each client. After the grace period has expired, the client will not allow the user to use the application until they have logged to refresh the certificate. The default value is 1 day (86400s). The client enrolls using the Automatic Certificate Management Environment (ACME) protocol. The `acmeDiscoveryUrl` parameter must be set to the HTTPS URL of the ACME server discovery endpoint for this team. It is of the form \"https://acme.{backendDomain}/acme/{provisionerName}/discovery\". For example: `https://acme.example.com/acme/provisioner1/discovery`.", + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + } + }, + "required": [ + "verificationExpiration" + ], + "type": "object" + }, + "MlsE2EIdConfig.WithStatus": { + "properties": { + "config": { + "$ref": "#/definitions/MlsE2EIdConfig" + }, + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "MlsMigration": { + "properties": { + "finaliseRegardlessAfter": { + "$ref": "#/definitions/UTCTime" + }, + "startTime": { + "$ref": "#/definitions/UTCTime" + } + }, + "type": "object" + }, + "MlsMigration.WithStatus": { + "properties": { + "config": { + "$ref": "#/definitions/MlsMigration" + }, + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "NameIDFormat": { + "enum": [ + "NameIDFUnspecified", + "NameIDFEmail", + "NameIDFX509", + "NameIDFWindows", + "NameIDFKerberos", + "NameIDFEntity", + "NameIDFPersistent", + "NameIDFTransient" + ], + "type": "string" + }, + "NameIdPolicy": { + "properties": { + "allowCreate": { + "type": "boolean" + }, + "format": { + "$ref": "#/definitions/NameIDFormat" + }, + "spNameQualifier": { + "$ref": "#/definitions/XmlText" + } + }, + "required": [ + "format", + "allowCreate" + ], + "type": "object" + }, + "NewAssetToken": { + "properties": { + "token": { + "$ref": "#/definitions/ASCII" + } + }, + "required": [ + "token" + ], + "type": "object" + }, + "NewClient": { + "properties": { + "capabilities": { + "description": "Hints provided by the client for the backend so it can behave in a backwards-compatible way.", + "items": { + "$ref": "#/definitions/ClientCapability" + }, + "type": "array" + }, + "class": { + "$ref": "#/definitions/ClientClass" + }, + "cookie": { + "description": "The cookie label, i.e. the label used when logging in.", + "type": "string" + }, + "label": { + "type": "string" + }, + "lastkey": { + "$ref": "#/definitions/Prekey" + }, + "mls_public_keys": { + "$ref": "#/definitions/MLSPublicKeys" + }, + "model": { + "type": "string" + }, + "password": { + "description": "The password of the authenticated user for verification. Note: Required for registration of the 2nd, 3rd, ... client.", + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "prekeys": { + "description": "Prekeys for other clients to establish OTR sessions.", + "items": { + "$ref": "#/definitions/Prekey" + }, + "type": "array" + }, + "type": { + "$ref": "#/definitions/ClientType" + }, + "verification_code": { + "$ref": "#/definitions/ASCII" + } + }, + "required": [ + "prekeys", + "lastkey", + "type" + ], + "type": "object" + }, + "NewConv": { + "description": "JSON object to create a new conversation. When using 'qualified_users' (preferred), you can omit 'users'", + "properties": { + "access": { + "items": { + "$ref": "#/definitions/Access" + }, + "type": "array" + }, + "access_role": { + "items": { + "$ref": "#/definitions/AccessRole" + }, + "type": "array" + }, + "conversation_role": { + "$ref": "#/definitions/RoleName" + }, + "message_timer": { + "description": "Per-conversation message timer", + "format": "int64", + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + }, + "name": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "protocol": { + "$ref": "#/definitions/BaseProtocol" + }, + "qualified_users": { + "description": "List of qualified user IDs (excluding the requestor) to be part of this conversation", + "items": { + "$ref": "#/definitions/Qualified_UserId" + }, + "type": "array" + }, + "receipt_mode": { + "description": "Conversation receipt mode", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "team": { + "$ref": "#/definitions/ConvTeamInfo" + }, + "users": { + "description": "List of user IDs (excluding the requestor) to be part of this conversation (deprecated)", + "items": { + "$ref": "#/definitions/UUID" + }, + "type": "array" + } + }, + "type": "object" + }, + "NewLegalHoldService": { + "properties": { + "auth_token": { + "$ref": "#/definitions/ASCII" + }, + "base_url": { + "$ref": "#/definitions/HttpsUrl" + }, + "public_key": { + "$ref": "#/definitions/ServiceKeyPEM" + } + }, + "required": [ + "base_url", + "public_key", + "auth_token" + ], + "type": "object" + }, + "NewPasswordReset": { + "description": "Data to initiate a password reset", + "properties": { + "email": { + "$ref": "#/definitions/Email" + }, + "phone": { + "$ref": "#/definitions/PhoneNumber" + } + }, + "type": "object" + }, + "NewTeamMember": { + "description": "Required data when creating new team members", + "properties": { + "member": { + "description": "the team member to add (the legalhold_status field must be null or missing!)", + "properties": { + "created_at": { + "$ref": "#/definitions/UTCTime" + }, + "created_by": { + "$ref": "#/definitions/UUID" + }, + "permissions": { + "$ref": "#/definitions/Permissions" + }, + "user": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "user", + "permissions" + ], + "type": "object" + } + }, + "required": [ + "member" + ], + "type": "object" + }, + "NewUser": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/definitions/UserAsset" + }, + "type": "array" + }, + "email": { + "$ref": "#/definitions/Email" + }, + "email_code": { + "$ref": "#/definitions/ASCII" + }, + "expires_in": { + "maximum": 604800, + "minimum": 1, + "type": "integer" + }, + "invitation_code": { + "$ref": "#/definitions/ASCII" + }, + "label": { + "type": "string" + }, + "locale": { + "$ref": "#/definitions/Locale" + }, + "managed_by": { + "$ref": "#/definitions/ManagedBy" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "password": { + "maxLength": 1024, + "minLength": 8, + "type": "string" + }, + "phone": { + "$ref": "#/definitions/PhoneNumber" + }, + "phone_code": { + "$ref": "#/definitions/ASCII" + }, + "picture": { + "$ref": "#/definitions/Pict" + }, + "sso_id": { + "$ref": "#/definitions/UserSSOId" + }, + "supported_protocols": { + "items": { + "$ref": "#/definitions/BaseProtocol" + }, + "type": "array" + }, + "team": { + "$ref": "#/definitions/BindingNewTeamUser" + }, + "team_code": { + "$ref": "#/definitions/ASCII" + }, + "team_id": { + "$ref": "#/definitions/UUID" + }, + "uuid": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "name" + ], + "type": "object" + }, + "OAuthAccessTokenRequest": { + "properties": { + "client_id": { + "$ref": "#/definitions/UUID" + }, + "code": { + "$ref": "#/definitions/OAuthAuthorizationCode" + }, + "code_verifier": { + "description": "The code verifier to complete the code challenge", + "maxLength": 128, + "minLength": 43, + "type": "string" + }, + "grant_type": { + "$ref": "#/definitions/OAuthGrantType" + }, + "redirect_uri": { + "$ref": "#/definitions/RedirectUrl" + } + }, + "required": [ + "grant_type", + "client_id", + "code_verifier", + "code", + "redirect_uri" + ], + "type": "object" + }, + "OAuthAccessTokenResponse": { + "properties": { + "access_token": { + "description": "The access token, which has a relatively short lifetime", + "type": "string" + }, + "expires_in": { + "description": "The lifetime of the access token in seconds", + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "refresh_token": { + "description": "The refresh token, which has a relatively long lifetime, and can be used to obtain a new access token", + "type": "string" + }, + "token_type": { + "$ref": "#/definitions/OAuthAccessTokenType" + } + }, + "required": [ + "access_token", + "token_type", + "expires_in", + "refresh_token" + ], + "type": "object" + }, + "OAuthAccessTokenType": { + "description": "The type of the access token. Currently only `Bearer` is supported.", + "enum": [ + "Bearer" + ], + "type": "string" + }, + "OAuthApplication": { + "properties": { + "id": { + "$ref": "#/definitions/UUID" + }, + "name": { + "description": "The OAuth client's name", + "maxLength": 256, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "OAuthAuthorizationCode": { + "description": "The authorization code", + "type": "string" + }, + "OAuthClient": { + "properties": { + "application_name": { + "maxLength": 256, + "minLength": 6, + "type": "string" + }, + "client_id": { + "$ref": "#/definitions/UUID" + }, + "redirect_url": { + "$ref": "#/definitions/RedirectUrl" + } + }, + "required": [ + "client_id", + "application_name", + "redirect_url" + ], + "type": "object" + }, + "OAuthCodeChallenge": { + "description": "Generated by the client from the code verifier (unpadded base64url-encoded SHA256 hash of the code verifier)", + "type": "string" + }, + "OAuthGrantType": { + "description": "Indicates which authorization flow to use. Use `authorization_code` for authorization code flow.", + "enum": [ + "authorization_code", + "refresh_token" + ], + "type": "string" + }, + "OAuthRefreshAccessTokenRequest": { + "properties": { + "client_id": { + "$ref": "#/definitions/UUID" + }, + "grant_type": { + "$ref": "#/definitions/OAuthGrantType" + }, + "refresh_token": { + "description": "The refresh token", + "type": "string" + } + }, + "required": [ + "grant_type", + "client_id", + "refresh_token" + ], + "type": "object" + }, + "OAuthResponseType": { + "description": "Indicates which authorization flow to use. Use `code` for authorization code flow.", + "enum": [ + "code" + ], + "type": "string" + }, + "OAuthRevokeRefreshTokenRequest": { + "properties": { + "client_id": { + "$ref": "#/definitions/UUID" + }, + "refresh_token": { + "description": "The refresh token", + "type": "string" + } + }, + "required": [ + "client_id", + "refresh_token" + ], + "type": "object" + }, + "Object": {}, + "OtherMember": { + "properties": { + "conversation_role": { + "$ref": "#/definitions/RoleName" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "qualified_id": { + "$ref": "#/definitions/Qualified_UserId" + }, + "service": { + "$ref": "#/definitions/ServiceRef" + }, + "status": { + "description": "deprecated", + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + } + }, + "required": [ + "qualified_id" + ], + "type": "object" + }, + "OtherMemberUpdate": { + "description": "Update user properties of other members relative to a conversation", + "properties": { + "conversation_role": { + "$ref": "#/definitions/RoleName" + } + }, + "type": "object" + }, + "OtrMessage": { + "description": "Encrypted message of a conversation", + "properties": { + "data": { + "description": "Extra (symmetric) data (i.e. ciphertext, Base64 in JSON) that is common with all other recipients.", + "type": "string" + }, + "recipient": { + "$ref": "#/definitions/ClientId" + }, + "sender": { + "$ref": "#/definitions/ClientId" + }, + "text": { + "description": "The ciphertext for the recipient (Base64 in JSON)", + "type": "string" + } + }, + "required": [ + "sender", + "recipient", + "text" + ], + "type": "object" + }, + "OutlookCalIntegrationConfig.WithStatus": { + "properties": { + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "OutlookCalIntegrationConfig.WithStatusNoLock": { + "properties": { + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "PagingState": { + "description": "Paging state that should be supplied to retrieve the next page of results", + "type": "string" + }, + "PasswordChange": { + "description": "Data to change a password. The old password is required if a password already exists.", + "properties": { + "new_password": { + "maxLength": 1024, + "minLength": 8, + "type": "string" + }, + "old_password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "new_password" + ], + "type": "object" + }, + "PasswordReset": { + "description": "Data to complete a password reset", + "properties": { + "code": { + "$ref": "#/definitions/ASCII" + }, + "password": { + "description": "New password (6 - 1024 characters)", + "maxLength": 1024, + "minLength": 8, + "type": "string" + } + }, + "required": [ + "code", + "password" + ], + "type": "object" + }, + "Permissions": { + "description": "This is just a complicated way of representing a team role. self and copy always have to contain the same integer, and only the following integers are allowed: 1025 (partner), 1587 (member), 5951 (admin), 8191 (owner). Unit tests of the galley-types package in wire-server contain an authoritative list.", + "properties": { + "copy": { + "format": "int64", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + }, + "self": { + "format": "int64", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "self", + "copy" + ], + "type": "object" + }, + "PhoneNumber": { + "description": "Phone number of the invitee, in the E.164 format", + "type": "string" + }, + "PhoneUpdate": { + "properties": { + "phone": { + "$ref": "#/definitions/PhoneNumber" + } + }, + "required": [ + "phone" + ], + "type": "object" + }, + "Pict": { + "items": {}, + "maxItems": 10, + "minItems": 0, + "type": "array" + }, + "Prekey": { + "properties": { + "id": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "key": { + "type": "string" + } + }, + "required": [ + "id", + "key" + ], + "type": "object" + }, + "PrekeyBundle": { + "properties": { + "clients": { + "items": { + "$ref": "#/definitions/ClientPrekey" + }, + "type": "array" + }, + "user": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "user", + "clients" + ], + "type": "object" + }, + "Priority": { + "enum": [ + "low", + "high" + ], + "type": "string" + }, + "PropertyKeysAndValues": { + "type": "object" + }, + "PropertyValue": { + "description": "An arbitrary JSON value for a property" + }, + "Protocol": { + "enum": [ + "proteus", + "mls", + "mixed" + ], + "type": "string" + }, + "ProtocolUpdate": { + "properties": { + "protocol": { + "$ref": "#/definitions/Protocol" + } + }, + "type": "object" + }, + "PubClient": { + "properties": { + "class": { + "$ref": "#/definitions/ClientClass" + }, + "id": { + "$ref": "#/definitions/ClientId" + } + }, + "required": [ + "id" + ], + "type": "object" + }, + "PushToken": { + "description": "Native Push Token", + "properties": { + "app": { + "description": "Application", + "type": "string" + }, + "client": { + "$ref": "#/definitions/ClientId" + }, + "token": { + "description": "Access Token", + "type": "string" + }, + "transport": { + "$ref": "#/definitions/Transport" + } + }, + "required": [ + "transport", + "app", + "token", + "client" + ], + "type": "object" + }, + "PushTokenList": { + "description": "List of Native Push Tokens", + "properties": { + "tokens": { + "description": "Push tokens", + "items": { + "$ref": "#/definitions/PushToken" + }, + "type": "array" + } + }, + "required": [ + "tokens" + ], + "type": "object" + }, + "QualifiedNewOtrMessage": { + "description": "This object can only be parsed from Protobuf.\nThe specification for the protobuf types is here: \nhttps://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto." + }, + "QualifiedUserClientPrekeyMapV4": { + "properties": { + "failed_to_list": { + "items": { + "$ref": "#/definitions/Qualified_UserId" + }, + "type": "array" + }, + "qualified_user_client_prekeys": { + "additionalProperties": { + "$ref": "#/definitions/UserClientPrekeyMap" + }, + "type": "object" + } + }, + "required": [ + "qualified_user_client_prekeys" + ], + "type": "object" + }, + "QualifiedUserClients": { + "additionalProperties": { + "additionalProperties": { + "items": { + "$ref": "#/definitions/ClientId" + }, + "type": "array" + }, + "type": "object" + }, + "description": "Map of Domain to UserClients", + "example": { + "domain1.example.com": { + "000600d0-000b-9c1a-000d-a4130002c221": [ + "60f85e4b15ad3786", + "6e323ab31554353b" + ] + } + }, + "type": "object" + }, + "QualifiedUserIdList": { + "properties": { + "qualified_user_ids": { + "items": { + "$ref": "#/definitions/Qualified_UserId" + }, + "type": "array" + }, + "user_ids": { + "description": "Deprecated, use qualified_user_ids", + "items": { + "$ref": "#/definitions/UUID" + }, + "type": "array" + } + }, + "required": [ + "qualified_user_ids", + "user_ids" + ], + "type": "object" + }, + "QualifiedUserMap_Set_PubClient": { + "additionalProperties": { + "$ref": "#/definitions/UserMap_Set_PubClient" + }, + "description": "Map of Domain to (UserMap (Set_PubClient)).", + "example": { + "domain1.example.com": { + "000600d0-000b-9c1a-000d-a4130002c221": [ + { + "class": "legalhold", + "id": "d0" + } + ] + } + }, + "type": "object" + }, + "Qualified_ConvId": { + "properties": { + "domain": { + "$ref": "#/definitions/Domain" + }, + "id": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "domain", + "id" + ], + "type": "object" + }, + "Qualified_Handle": { + "properties": { + "domain": { + "$ref": "#/definitions/Domain" + }, + "handle": { + "$ref": "#/definitions/Handle" + } + }, + "required": [ + "domain", + "handle" + ], + "type": "object" + }, + "Qualified_UserId": { + "properties": { + "domain": { + "$ref": "#/definitions/Domain" + }, + "id": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "domain", + "id" + ], + "type": "object" + }, + "QueuedNotification": { + "description": "A single notification", + "properties": { + "id": { + "$ref": "#/definitions/UUID" + }, + "payload": { + "description": "List of events", + "items": { + "$ref": "#/definitions/Object" + }, + "minItems": 1, + "type": "array" + } + }, + "required": [ + "id", + "payload" + ], + "type": "object" + }, + "QueuedNotificationList": { + "description": "Zero or more notifications", + "properties": { + "has_more": { + "description": "Whether there are still more notifications.", + "type": "boolean" + }, + "notifications": { + "description": "Notifications", + "items": { + "$ref": "#/definitions/QueuedNotification" + }, + "type": "array" + }, + "time": { + "$ref": "#/definitions/UTCTime" + } + }, + "required": [ + "notifications" + ], + "type": "object" + }, + "RTCConfiguration": { + "description": "A subset of the WebRTC 'RTCConfiguration' dictionary", + "properties": { + "ice_servers": { + "description": "Array of 'RTCIceServer' objects", + "items": { + "$ref": "#/definitions/RTCIceServer" + }, + "minItems": 1, + "type": "array" + }, + "sft_servers": { + "description": "Array of 'SFTServer' objects (optional)", + "items": { + "$ref": "#/definitions/SftServer" + }, + "minItems": 1, + "type": "array" + }, + "sft_servers_all": { + "description": "Array of all SFT servers", + "items": { + "$ref": "#/definitions/SftServer" + }, + "type": "array" + }, + "ttl": { + "description": "Number of seconds after which the configuration should be refreshed (advisory)", + "format": "int32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "ice_servers", + "ttl" + ], + "type": "object" + }, + "RTCIceServer": { + "description": "A subset of the WebRTC 'RTCIceServer' object", + "properties": { + "credential": { + "$ref": "#/definitions/ASCII" + }, + "urls": { + "description": "Array of TURN server addresses of the form 'turn::'", + "items": { + "$ref": "#/definitions/TurnURI" + }, + "minItems": 1, + "type": "array" + }, + "username": { + "$ref": "#/definitions/" + } + }, + "required": [ + "urls", + "username", + "credential" + ], + "type": "object" + }, + "RedirectUrl": { + "description": "The URL must match the URL that was used to generate the authorization code.", + "type": "string" + }, + "Relation": { + "enum": [ + "accepted", + "blocked", + "pending", + "ignored", + "sent", + "cancelled", + "missing-legalhold-consent" + ], + "type": "string" + }, + "RemoveBotResponse": { + "properties": { + "event": { + "$ref": "#/definitions/Event" + } + }, + "required": [ + "event" + ], + "type": "object" + }, + "RemoveCookies": { + "description": "Data required to remove cookies", + "properties": { + "ids": { + "description": "A list of cookie IDs to revoke", + "items": { + "format": "int32", + "maximum": 4294967295, + "minimum": 0, + "type": "integer" + }, + "type": "array" + }, + "labels": { + "description": "A list of cookie labels for which to revoke the cookies", + "items": { + "type": "string" + }, + "type": "array" + }, + "password": { + "description": "The user's password", + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "required": [ + "password" + ], + "type": "object" + }, + "RemoveLegalHoldSettingsRequest": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "RichField": { + "properties": { + "type": { + "type": "string" + }, + "value": { + "type": "string" + } + }, + "required": [ + "type", + "value" + ], + "type": "object" + }, + "RichInfoAssocList": { + "description": "json object with case-insensitive fields.", + "properties": { + "fields": { + "items": { + "$ref": "#/definitions/RichField" + }, + "type": "array" + }, + "version": { + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + } + }, + "required": [ + "version", + "fields" + ], + "type": "object" + }, + "Role": { + "description": "Role of the invited user", + "enum": [ + "owner", + "admin", + "member", + "partner" + ], + "type": "string" + }, + "RoleName": { + "description": "Role name, between 2 and 128 chars, 'wire_' prefix is reserved for roles designed by Wire (i.e., no custom roles can have the same prefix)", + "type": "string" + }, + "SSOConfig.WithStatus": { + "properties": { + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "ScimTokenInfo": { + "properties": { + "created_at": { + "$ref": "#/definitions/UTCTime" + }, + "description": { + "type": "string" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "idp": { + "$ref": "#/definitions/UUID" + }, + "team": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "team", + "id", + "created_at", + "description" + ], + "type": "object" + }, + "ScimTokenList": { + "properties": { + "tokens": { + "items": { + "$ref": "#/definitions/ScimTokenInfo" + }, + "type": "array" + } + }, + "required": [ + "tokens" + ], + "type": "object" + }, + "SearchResult": { + "properties": { + "documents": { + "description": "List of contacts found", + "items": { + "$ref": "#/definitions/TeamContact" + }, + "type": "array" + }, + "found": { + "description": "Total number of hits", + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + }, + "has_more": { + "description": "Indicates whether there are more results to be fetched", + "type": "boolean" + }, + "paging_state": { + "$ref": "#/definitions/PagingState" + }, + "returned": { + "description": "Total number of hits returned", + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + }, + "search_policy": { + "$ref": "#/definitions/FederatedUserSearchPolicy" + }, + "took": { + "description": "Search time in ms", + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + } + }, + "required": [ + "found", + "returned", + "took", + "documents", + "search_policy" + ], + "type": "object" + }, + "SearchVisibilityAvailableConfig.WithStatus": { + "properties": { + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "SearchVisibilityAvailableConfig.WithStatusNoLock": { + "properties": { + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "SearchVisibilityInboundConfig.WithStatus": { + "properties": { + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "SearchVisibilityInboundConfig.WithStatusNoLock": { + "properties": { + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "SelfDeletingMessagesConfig": { + "properties": { + "enforcedTimeoutSeconds": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + } + }, + "required": [ + "enforcedTimeoutSeconds" + ], + "type": "object" + }, + "SelfDeletingMessagesConfig.WithStatus": { + "properties": { + "config": { + "$ref": "#/definitions/SelfDeletingMessagesConfig" + }, + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus", + "config" + ], + "type": "object" + }, + "SelfDeletingMessagesConfig.WithStatusNoLock": { + "properties": { + "config": { + "$ref": "#/definitions/SelfDeletingMessagesConfig" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "config" + ], + "type": "object" + }, + "SendActivationCode": { + "description": "Data for requesting an email or phone activation code to be sent. One of 'email' or 'phone' must be present.", + "properties": { + "email": { + "$ref": "#/definitions/Email" + }, + "locale": { + "$ref": "#/definitions/Locale" + }, + "phone": { + "$ref": "#/definitions/PhoneNumber" + }, + "voice_call": { + "description": "Request the code with a call instead (default is SMS).", + "type": "boolean" + } + }, + "type": "object" + }, + "SendLoginCode": { + "description": "Payload for requesting a login code to be sent", + "properties": { + "force": { + "type": "boolean" + }, + "phone": { + "description": "E.164 phone number to send the code to", + "type": "string" + }, + "voice_call": { + "description": "Request the code with a call instead (default is SMS)", + "type": "boolean" + } + }, + "required": [ + "phone" + ], + "type": "object" + }, + "SendVerificationCode": { + "properties": { + "action": { + "$ref": "#/definitions/VerificationAction" + }, + "email": { + "$ref": "#/definitions/Email" + } + }, + "required": [ + "action", + "email" + ], + "type": "object" + }, + "ServiceKeyPEM": { + "example": "-----BEGIN PUBLIC KEY-----\nMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAu+Kg/PHHU3atXrUbKnw0\nG06FliXcNt3lMwl2os5twEDcPPFw/feGiAKymxp+7JqZDrseS5D9THGrW+OQRIPH\nWvUBdiLfGrZqJO223DB6D8K2Su/odmnjZJ2z23rhXoEArTplu+Dg9K+c2LVeXTKV\nVPOaOzgtAB21XKRiQ4ermqgi3/njr03rXyq/qNkuNd6tNcg+HAfGxfGvvCSYBfiS\nbUKr/BeArYRcjzr/h5m1In6fG/if9GEI6m8dxHT9JbY53wiksowy6ajCuqskIFg8\n7X883H+LA/d6X5CTiPv1VMxXdBUiGPuC9IT/6CNQ1/LFt0P37ax58+LGYlaFo7la\nnQIDAQAB\n-----END PUBLIC KEY-----\n", + "type": "string" + }, + "ServiceRef": { + "properties": { + "id": { + "$ref": "#/definitions/UUID" + }, + "provider": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "id", + "provider" + ], + "type": "object" + }, + "SftServer": { + "description": "Inspired by WebRTC 'RTCIceServer' object, contains details of SFT servers", + "properties": { + "urls": { + "description": "Array containing exactly one SFT server address of the form 'https://:'", + "items": { + "$ref": "#/definitions/HttpsUrl" + }, + "type": "array" + } + }, + "required": [ + "urls" + ], + "type": "object" + }, + "SimpleMember": { + "properties": { + "conversation_role": { + "$ref": "#/definitions/RoleName" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "qualified_id": { + "$ref": "#/definitions/Qualified_UserId" + } + }, + "required": [ + "qualified_id" + ], + "type": "object" + }, + "SimpleMembers": { + "properties": { + "user_ids": { + "description": "deprecated", + "items": { + "$ref": "#/definitions/UUID" + }, + "type": "array" + }, + "users": { + "items": { + "$ref": "#/definitions/SimpleMember" + }, + "type": "array" + } + }, + "required": [ + "users" + ], + "type": "object" + }, + "SndFactorPasswordChallengeConfig.WithStatus": { + "properties": { + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "SndFactorPasswordChallengeConfig.WithStatusNoLock": { + "properties": { + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "Sso": { + "properties": { + "issuer": { + "type": "string" + }, + "nameid": { + "type": "string" + } + }, + "required": [ + "issuer", + "nameid" + ], + "type": "object" + }, + "SsoSettings": { + "properties": { + "default_sso_code": { + "$ref": "#/definitions/UUID" + } + }, + "type": "object" + }, + "SystemSettings": { + "properties": { + "setEnableMls": { + "description": "Whether MLS is enabled or not", + "type": "boolean" + }, + "setRestrictUserCreation": { + "description": "Do not allow certain user creation flows", + "type": "boolean" + } + }, + "required": [ + "setRestrictUserCreation", + "setEnableMls" + ], + "type": "object" + }, + "SystemSettingsPublic": { + "properties": { + "setRestrictUserCreation": { + "description": "Do not allow certain user creation flows", + "type": "boolean" + } + }, + "required": [ + "setRestrictUserCreation" + ], + "type": "object" + }, + "Team": { + "description": "`binding` is deprecated, and should be ignored. The non-binding teams API is not used (and will not be supported from API version V4 onwards), and `binding` will always be `true`.", + "properties": { + "binding": { + "$ref": "#/definitions/TeamBinding" + }, + "creator": { + "$ref": "#/definitions/UUID" + }, + "icon": { + "$ref": "#/definitions/Icon" + }, + "icon_key": { + "type": "string" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "name": { + "type": "string" + }, + "splash_screen": { + "$ref": "#/definitions/Icon" + } + }, + "required": [ + "id", + "creator", + "name", + "icon" + ], + "type": "object" + }, + "TeamBinding": { + "description": "Deprecated, please ignore.", + "enum": [ + true, + false + ], + "type": "boolean" + }, + "TeamContact": { + "properties": { + "accent_id": { + "maximum": 9223372036854776000, + "minimum": -9223372036854776000, + "type": "integer" + }, + "created_at": { + "$ref": "#/definitions/UTCTime" + }, + "email": { + "$ref": "#/definitions/Email" + }, + "email_unvalidated": { + "$ref": "#/definitions/Email" + }, + "handle": { + "type": "string" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "managed_by": { + "$ref": "#/definitions/ManagedBy" + }, + "name": { + "type": "string" + }, + "role": { + "$ref": "#/definitions/Role" + }, + "saml_idp": { + "type": "string" + }, + "scim_external_id": { + "type": "string" + }, + "sso": { + "$ref": "#/definitions/Sso" + }, + "team": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "id", + "name" + ], + "type": "object" + }, + "TeamConversation": { + "description": "Team conversation data", + "properties": { + "conversation": { + "$ref": "#/definitions/UUID" + }, + "managed": { + "description": "This field MUST NOT be used by clients. It is here only for backwards compatibility of the interface." + } + }, + "required": [ + "conversation", + "managed" + ], + "type": "object" + }, + "TeamConversationList": { + "description": "Team conversation list", + "properties": { + "conversations": { + "items": { + "$ref": "#/definitions/TeamConversation" + }, + "type": "array" + } + }, + "required": [ + "conversations" + ], + "type": "object" + }, + "TeamDeleteData": { + "properties": { + "password": { + "maxLength": 1024, + "minLength": 6, + "type": "string" + }, + "verification_code": { + "$ref": "#/definitions/ASCII" + } + }, + "type": "object" + }, + "TeamMember": { + "description": "team member data", + "properties": { + "created_at": { + "$ref": "#/definitions/UTCTime" + }, + "created_by": { + "$ref": "#/definitions/UUID" + }, + "legalhold_status": { + "$ref": "#/definitions/UserLegalHoldStatus" + }, + "permissions": { + "$ref": "#/definitions/Permissions" + }, + "user": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "user" + ], + "type": "object" + }, + "TeamMemberDeleteData": { + "description": "Data for a team member deletion request in case of binding teams.", + "properties": { + "password": { + "description": "The account password to authorise the deletion.", + "maxLength": 1024, + "minLength": 6, + "type": "string" + } + }, + "type": "object" + }, + "TeamMemberList": { + "description": "list of team member", + "properties": { + "hasMore": { + "$ref": "#/definitions/ListType" + }, + "members": { + "description": "the array of team members", + "items": { + "$ref": "#/definitions/TeamMember" + }, + "type": "array" + } + }, + "required": [ + "members", + "hasMore" + ], + "type": "object" + }, + "TeamMembersPage": { + "properties": { + "hasMore": { + "type": "boolean" + }, + "members": { + "items": { + "$ref": "#/definitions/TeamMember" + }, + "type": "array" + }, + "pagingState": { + "$ref": "#/definitions/TeamMembers_PagingState" + } + }, + "required": [ + "members", + "hasMore", + "pagingState" + ], + "type": "object" + }, + "TeamMembers_PagingState": { + "type": "string" + }, + "TeamSearchVisibility": { + "description": "value of visibility", + "enum": [ + "standard", + "no-name-outside-team" + ], + "type": "string" + }, + "TeamSearchVisibilityView": { + "description": "Search visibility value for the team", + "properties": { + "search_visibility": { + "$ref": "#/definitions/TeamSearchVisibility" + } + }, + "required": [ + "search_visibility" + ], + "type": "object" + }, + "TeamSize": { + "description": "A simple object with a total number of team members.", + "properties": { + "teamSize": { + "description": "Team size.", + "exclusiveMinimum": false, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "teamSize" + ], + "type": "object" + }, + "TeamUpdateData": { + "properties": { + "icon": { + "$ref": "#/definitions/Icon" + }, + "icon_key": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "name": { + "maxLength": 256, + "minLength": 1, + "type": "string" + }, + "splash_screen": { + "$ref": "#/definitions/Icon" + } + }, + "type": "object" + }, + "Time": { + "properties": { + "time": { + "$ref": "#/definitions/UTCTime" + } + }, + "required": [ + "time" + ], + "type": "object" + }, + "TokenType": { + "enum": [ + "Bearer" + ], + "type": "string" + }, + "Transport": { + "description": "Transport", + "enum": [ + "GCM", + "APNS", + "APNS_SANDBOX", + "APNS_VOIP", + "APNS_VOIP_SANDBOX" + ], + "type": "string" + }, + "TurnURI": { + "type": "string" + }, + "TypingData": { + "properties": { + "status": { + "$ref": "#/definitions/TypingStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "TypingStatus": { + "enum": [ + "started", + "stopped" + ], + "type": "string" + }, + "URIRef Absolute": { + "description": "URL of the invitation link to be sent to the invitee", + "type": "string" + }, + "UTCTime": { + "example": "2021-05-12T10:52:02.671Z", + "format": "yyyy-mm-ddThh:MM:ss.qqq", + "type": "string" + }, + "UUID": { + "example": "99db9768-04e3-4b5d-9268-831b6a25c4ab", + "format": "uuid", + "type": "string" + }, + "Unnamed": { + "properties": { + "created_at": { + "$ref": "#/definitions/UTCTime" + }, + "created_by": { + "$ref": "#/definitions/UUID" + }, + "permissions": { + "$ref": "#/definitions/Permissions" + }, + "user": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "user", + "permissions" + ], + "type": "object" + }, + "UpdateBotPrekeys": { + "properties": { + "prekeys": { + "items": { + "$ref": "#/definitions/Prekey" + }, + "type": "array" + } + }, + "required": [ + "prekeys" + ], + "type": "object" + }, + "UpdateClient": { + "properties": { + "capabilities": { + "description": "Hints provided by the client for the backend so it can behave in a backwards-compatible way.", + "items": { + "$ref": "#/definitions/ClientCapability" + }, + "type": "array" + }, + "label": { + "description": "A new name for this client.", + "type": "string" + }, + "lastkey": { + "$ref": "#/definitions/Prekey" + }, + "mls_public_keys": { + "$ref": "#/definitions/MLSPublicKeys" + }, + "prekeys": { + "description": "New prekeys for other clients to establish OTR sessions.", + "items": { + "$ref": "#/definitions/Prekey" + }, + "type": "array" + } + }, + "type": "object" + }, + "User": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/definitions/UserAsset" + }, + "type": "array" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "$ref": "#/definitions/Email" + }, + "expires_at": { + "$ref": "#/definitions/UTCTime" + }, + "handle": { + "$ref": "#/definitions/Handle" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "locale": { + "$ref": "#/definitions/Locale" + }, + "managed_by": { + "$ref": "#/definitions/ManagedBy" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "phone": { + "$ref": "#/definitions/PhoneNumber" + }, + "picture": { + "$ref": "#/definitions/Pict" + }, + "qualified_id": { + "$ref": "#/definitions/Qualified_UserId" + }, + "service": { + "$ref": "#/definitions/ServiceRef" + }, + "sso_id": { + "$ref": "#/definitions/UserSSOId" + }, + "supported_protocols": { + "items": { + "$ref": "#/definitions/BaseProtocol" + }, + "type": "array" + }, + "team": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "id", + "qualified_id", + "name", + "accent_id", + "locale" + ], + "type": "object" + }, + "UserAsset": { + "properties": { + "key": { + "$ref": "#/definitions/AssetKey" + }, + "size": { + "$ref": "#/definitions/AssetSize" + }, + "type": { + "$ref": "#/definitions/AssetType" + } + }, + "required": [ + "key", + "type" + ], + "type": "object" + }, + "UserClientMap": { + "additionalProperties": { + "additionalProperties": { + "type": "string" + }, + "type": "object" + }, + "type": "object" + }, + "UserClientPrekeyMap": { + "additionalProperties": { + "additionalProperties": { + "properties": { + "id": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "key": { + "type": "string" + } + }, + "required": [ + "id", + "key" + ], + "type": "object" + }, + "type": "object" + }, + "example": { + "000600d0-000b-9c1a-000d-a4130002c221": { + "44901fb0712e588f": { + "id": 1, + "key": "pQABAQECoQBYIOjl7hw0D8YRNq..." + } + } + }, + "type": "object" + }, + "UserClients": { + "additionalProperties": { + "items": { + "$ref": "#/definitions/ClientId" + }, + "type": "array" + }, + "description": "Map of user id to list of client ids.", + "example": { + "000600d0-000b-9c1a-000d-a4130002c221": [ + "60f85e4b15ad3786", + "6e323ab31554353b" + ] + }, + "type": "object" + }, + "UserConnection": { + "properties": { + "conversation": { + "$ref": "#/definitions/UUID" + }, + "from": { + "$ref": "#/definitions/UUID" + }, + "last_update": { + "$ref": "#/definitions/UTCTime" + }, + "qualified_conversation": { + "$ref": "#/definitions/Qualified_ConvId" + }, + "qualified_to": { + "$ref": "#/definitions/Qualified_UserId" + }, + "status": { + "$ref": "#/definitions/Relation" + }, + "to": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "from", + "qualified_to", + "status", + "last_update" + ], + "type": "object" + }, + "UserIdList": { + "properties": { + "user_ids": { + "items": { + "$ref": "#/definitions/UUID" + }, + "type": "array" + } + }, + "required": [ + "user_ids" + ], + "type": "object" + }, + "UserLegalHoldStatus": { + "description": "states whether a user is under legal hold, or whether legal hold is pending approval.", + "enum": [ + "enabled", + "pending", + "disabled", + "no_consent" + ], + "type": "string" + }, + "UserLegalHoldStatusResponse": { + "properties": { + "client": { + "$ref": "#/definitions/Id" + }, + "last_prekey": { + "$ref": "#/definitions/Prekey" + }, + "status": { + "$ref": "#/definitions/UserLegalHoldStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "UserMap_Set_PubClient": { + "additionalProperties": { + "items": { + "$ref": "#/definitions/PubClient" + }, + "type": "array", + "uniqueItems": true + }, + "description": "Map of UserId to (Set PubClient)", + "example": { + "000600d0-000b-9c1a-000d-a4130002c221": [ + { + "class": "legalhold", + "id": "d0" + } + ] + }, + "type": "object" + }, + "UserProfile": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/definitions/UserAsset" + }, + "type": "array" + }, + "deleted": { + "type": "boolean" + }, + "email": { + "$ref": "#/definitions/Email" + }, + "expires_at": { + "$ref": "#/definitions/UTCTime" + }, + "handle": { + "$ref": "#/definitions/Handle" + }, + "id": { + "$ref": "#/definitions/UUID" + }, + "legalhold_status": { + "$ref": "#/definitions/UserLegalHoldStatus" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "picture": { + "$ref": "#/definitions/Pict" + }, + "qualified_id": { + "$ref": "#/definitions/Qualified_UserId" + }, + "service": { + "$ref": "#/definitions/ServiceRef" + }, + "supported_protocols": { + "items": { + "$ref": "#/definitions/BaseProtocol" + }, + "type": "array" + }, + "team": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "qualified_id", + "name", + "accent_id", + "legalhold_status" + ], + "type": "object" + }, + "UserSSOId": { + "properties": { + "scim_external_id": { + "type": "string" + }, + "subject": { + "type": "string" + }, + "tenant": { + "type": "string" + } + }, + "type": "object" + }, + "UserUpdate": { + "properties": { + "accent_id": { + "format": "int32", + "maximum": 2147483647, + "minimum": -2147483648, + "type": "integer" + }, + "assets": { + "items": { + "$ref": "#/definitions/UserAsset" + }, + "type": "array" + }, + "name": { + "maxLength": 128, + "minLength": 1, + "type": "string" + }, + "picture": { + "$ref": "#/definitions/Pict" + } + }, + "type": "object" + }, + "ValidateSAMLEmailsConfig.WithStatus": { + "properties": { + "lockStatus": { + "$ref": "#/definitions/LockStatus" + }, + "status": { + "$ref": "#/definitions/FeatureStatus" + }, + "ttl": { + "example": "unlimited", + "maximum": 18446744073709552000, + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "status", + "lockStatus" + ], + "type": "object" + }, + "VerificationAction": { + "enum": [ + "create_scim_token", + "login", + "delete_team" + ], + "type": "string" + }, + "VerifyDeleteUser": { + "description": "Data for verifying an account deletion.", + "properties": { + "code": { + "$ref": "#/definitions/ASCII" + }, + "key": { + "$ref": "#/definitions/ASCII" + } + }, + "required": [ + "key", + "code" + ], + "type": "object" + }, + "VersionInfo": { + "example": { + "development": [ + 5 + ], + "domain": "example.com", + "federation": false, + "supported": [ + 0, + 1, + 2, + 3, + 4, + 5 + ] + }, + "properties": { + "development": { + "items": { + "$ref": "#/definitions/VersionNumber" + }, + "type": "array" + }, + "domain": { + "$ref": "#/definitions/Domain" + }, + "federation": { + "type": "boolean" + }, + "supported": { + "items": { + "$ref": "#/definitions/VersionNumber" + }, + "type": "array" + } + }, + "required": [ + "supported", + "development", + "federation", + "domain" + ], + "type": "object" + }, + "VersionNumber": { + "enum": [ + 0, + 1, + 2, + 3, + 4, + 5 + ], + "type": "integer" + }, + "ViewLegalHoldService": { + "properties": { + "settings": { + "$ref": "#/definitions/ViewLegalHoldServiceInfo" + }, + "status": { + "$ref": "#/definitions/LHServiceStatus" + } + }, + "required": [ + "status" + ], + "type": "object" + }, + "ViewLegalHoldServiceInfo": { + "properties": { + "auth_token": { + "$ref": "#/definitions/ASCII" + }, + "base_url": { + "$ref": "#/definitions/HttpsUrl" + }, + "fingerprint": { + "$ref": "#/definitions/Fingerprint" + }, + "public_key": { + "$ref": "#/definitions/ServiceKeyPEM" + }, + "team_id": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "team_id", + "base_url", + "fingerprint", + "auth_token", + "public_key" + ], + "type": "object" + }, + "WireIdP": { + "properties": { + "apiVersion": { + "$ref": "#/definitions/WireIdPAPIVersion" + }, + "handle": { + "type": "string" + }, + "oldIssuers": { + "items": { + "type": "string" + }, + "type": "array" + }, + "replacedBy": { + "$ref": "#/definitions/UUID" + }, + "team": { + "$ref": "#/definitions/UUID" + } + }, + "required": [ + "team", + "oldIssuers", + "handle" + ], + "type": "object" + }, + "WireIdPAPIVersion": { + "enum": [ + "WireIdPAPIV1", + "WireIdPAPIV2" + ], + "type": "string" + }, + "XmlText": { + "properties": { + "fromXmlText": { + "type": "string" + } + }, + "required": [ + "fromXmlText" + ], + "type": "object" + }, + "new-otr-message": { + "properties": { + "data": { + "type": "string" + }, + "native_priority": { + "$ref": "#/definitions/Priority" + }, + "native_push": { + "type": "boolean" + }, + "recipients": { + "$ref": "#/definitions/UserClientMap" + }, + "report_missing": { + "items": { + "$ref": "#/definitions/UUID" + }, + "type": "array" + }, + "sender": { + "$ref": "#/definitions/ClientId" + }, + "transient": { + "type": "boolean" + } + }, + "required": [ + "sender", + "recipients" + ], + "type": "object" + } + }, + "info": { + "description": "## Authentication / Authorization\n\nThe end-points in this API support differing authorization protocols:\nsome are unauthenticated (`/api-version`, `/login`), some require\n[zauth](), and some support both [zauth]() and [oauth]().\n\nThe end-points that require zauth are labelled so in the description\nbelow. The end-points that support oauth as an alternative to zauth\nhave the required oauth scopes listed in the same description.\n\nFuther reading:\n- https://docs.wire.com/developer/reference/oauth.html\n- https://github.com/wireapp/wire-server/blob/develop/libs/wire-api/src/Wire/API/Routes/Public.hs (search for HasSwagger instances)\n- `curl https://staging-nginz-https.zinfra.io/v4/api/swagger.json | jq '.security, .securityDefinitions`\n\n### SSO Endpoints\n\n#### Overview\n\n`/sso/metadata` will be requested by the IdPs to learn how to talk to wire.\n\n`/sso/initiate-login`, `/sso/finalize-login` are for the SAML authentication handshake performed by a user in order to log into wire. They are not exactly standard in their details: they may return HTML or XML; redirect to error URLs instead of throwing errors, etc.\n\n`/identity-providers` end-points are for use in the team settings page when IdPs are registered. They talk json.\n\n\n#### Configuring IdPs\n\nIdPs usually allow you to copy the metadata into your clipboard. That should contain all the details you need to post the idp in your team under `/identity-providers`. (Team id is derived from the authorization credentials of the request.)\n\n##### okta.com\n\nOkta will ask you to provide two URLs when you set it up for talking to wireapp:\n\n1. The `Single sign on URL`. This is the end-point that accepts the user's credentials after successful authentication against the IdP. Choose `/sso/finalize-login` with schema and hostname of the wire server you are configuring.\n\n2. The `Audience URI`. You can find this in the metadata returned by the `/sso/metadata` end-point. It is the contents of the `md:OrganizationURL` element.\n\n##### centrify.com\n\nCentrify allows you to upload the metadata xml document that you get from the `/sso/metadata` end-point. You can also enter the metadata url and have centrify retrieve the xml, but to guarantee integrity of the setup, the metadata should be copied from the team settings page and pasted into the centrify setup page without any URL indirections.\n\n## Federation errors\n\nEndpoints involving federated calls to other domains can return some extra failure responses, common to all endpoints. Instead of listing them as possible responses for each endpoint, we document them here.\n\nFor errors that are more likely to be transient, we suggest clients to retry whatever request resulted in the error. Transient errors are indicated explicitly below.\n\n**Note**: when a failure occurs as a result of making a federated RPC to another backend, the error response contains the following extra fields:\n\n - `type`: \"federation\" (just the literal string in quotes, which can be used as an error type identifier when parsing errors)\n - `domain`: the target backend of the RPC that failed;\n - `path`: the path of the RPC that failed.\n\n### Domain errors\n\nErrors in this category result from trying to communicate with a backend that is considered non-existent or invalid. They can result from invalid user input or client issues, but they can also be a symptom of misconfiguration in one or multiple backends. These errors have a 4xx status code.\n\n - **Remote backend not found** (status: 422, label: `invalid-domain`): This backend attempted to contact a backend which does not exist or is not properly configured. For the most part, clients can consider this error equivalent to a domain not existing, although it should be noted that certain mistakes in the DNS configuration on a remote backend can lead to the backend not being recognized, and hence to this error. It is therefore not advisable to take any destructive action upon encountering this error, such as deleting remote users from conversations.\n - **Federation denied locally** (status: 400, label: `federation-denied`): This backend attempted an RPC to a non-whitelisted backend. Similar considerations as for the previous error apply.\n - **Federation not enabled** (status: 400, label: `federation-not-enabled`): Federation has not been configured for this backend. This will happen if a federation-aware client tries to talk to a backend for which federation is disabled, or if federation was disabled on the backend after reaching a federation-specific state (e.g. conversations with remote users). There is no way to cleanly recover from these errors at this point.\n\n### Local federation errors\n\nAn error in this category likely indicates an issue with the configuration of federation on the local backend. Possibly transient errors are indicated explicitly below. All these errors have a 500 status code.\n\n - **Federation unavailable** (status: 500, label: `federation-not-available`): Federation is configured for this backend, but the local federator cannot be reached. This can be transient, so clients should retry the request.\n - **Federation not implemented** (status: 500, label: `federation-not-implemented`): Federated behaviour for a certain endpoint is not yet implemented.\n - **Federator discovery failed** (status: 400, label: `discovery-failure`): A DNS error occurred during discovery of a remote backend. This can be transient, so clients should retry the request.\n - **Local federation error** (status: 500, label: `federation-local-error`): An error occurred in the communication between this backend and its local federator. These errors are most likely caused by bugs in the backend, and should be reported as such.\n\n### Remote federation errors\n\nErrors in this category are returned in case of communication issues between the local backend and a remote one, or if the remote side encountered an error while processing an RPC. Some errors in this category might be caused by incorrect client behaviour, wrong user input, or incorrect certificate configuration. Possibly transient errors are indicated explicitly. We use non-standard 5xx status codes for these errors.\n\n - **HTTP2 error** (status: 533, label: `federation-http2-error`): The current federator encountered an error when making an HTTP2 request to a remote one. Check the error message for more details.\n - **Connection refused** (status: 521, label: `federation-connection-refused`): The local federator could not connect to a remote one. This could be transient, so clients should retry the request.\n - **TLS failure**: (status: 525, label: `federation-tls-error`): An error occurred during the TLS handshake between the local federator and a remote one. This is most likely due to an issue with the certificate on the remote end.\n - **Remote federation error** (status: 533, label: `federation-remote-error`): The remote backend could not process a request coming from this backend. Check the error message for more details.\n - **Version negotiation error** (status: 533, label: `federation-version-error`): The remote backend returned invalid version information.\n\n### Backend compatibility errors\n\nAn error in this category will be returned when this backend makes an invalid or unsupported RPC to another backend. This can indicate some incompatibility between backends or a backend bug. These errors are unlikely to be transient, so retrying requests is *not* advised.\n\n - **Version mismatch** (status: 531, label: `federation-version-mismatch`): A remote backend is running an unsupported version of the federator.\n - **Invalid content type** (status: 533, label: `federation-invalid-content-type`): An RPC to another backend returned with an invalid content type.\n - **Unsupported content type** (status: 533, label: `federation-unsupported-content-type`): An RPC to another backend returned with an unsupported content type.\n", + "title": "Wire-Server API", + "version": "" + }, + "paths": { + "/access": { + "post": { + "description": " [internal route ID: \"access\"]\n\nYou can provide only a cookie or a cookie and token. Every other combination is invalid. Access tokens can be given as query parameter or authorisation header, with the latter being preferred.", + "parameters": [ + { + "in": "query", + "name": "client_id", + "required": false, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "Set-Cookie": { + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/AccessToken" + } + }, + "400": { + "description": "Invalid `client_id`" + }, + "403": { + "description": "Authentication failed (label: `invalid-credentials`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Obtain an access tokens for a cookie" + } + }, + "/access/logout": { + "post": { + "description": " [internal route ID: \"logout\"]\n\nCalling this endpoint will effectively revoke the given cookie and subsequent calls to /access with the same cookie will result in a 403.", + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Logout" + }, + "403": { + "description": "Authentication failed (label: `invalid-credentials`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Log out in order to remove a cookie from the server" + } + }, + "/access/self/email": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"change-self-email\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/EmailUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "202": { + "description": "Update accepted and pending activation of the new email", + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "204": { + "description": "No update, current and new email address are the same", + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "400": { + "description": "Invalid e-mail address. (label: `invalid-email`) or `body`", + "schema": { + "example": { + "code": 400, + "label": "invalid-email", + "message": "Invalid e-mail address." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "403": { + "description": "Authentication failed (label: `invalid-credentials`)\n\nThe given phone number has been blacklisted due to suspected abuse or a complaint (label: `blacklisted-phone`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "blacklisted-phone", + "blacklisted-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "The given e-mail address or phone number is in use. (label: `key-exists`)", + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address or phone number is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Change your email address" + } + }, + "/activate": { + "get": { + "description": " [internal route ID: \"get-activate\"]\n\nSee also 'POST /activate' which has a larger feature set.", + "parameters": [ + { + "description": "Activation key", + "in": "query", + "name": "key", + "required": true, + "type": "string" + }, + { + "description": "Activation code", + "in": "query", + "name": "code", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful.", + "schema": { + "$ref": "#/definitions/ActivationResponse" + } + }, + "204": { + "description": "A recent activation was already successful." + }, + "400": { + "description": "Invalid `code` or `key`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "404": { + "description": "Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)", + "schema": { + "example": { + "code": 404, + "label": "invalid-code", + "message": "Invalid activation code" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "The given e-mail address or phone number is in use. (label: `key-exists`)", + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address or phone number is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Activate (i.e. confirm) an email address or phone number." + }, + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"post-activate\"]\n\nActivation only succeeds once and the number of failed attempts for a valid key is limited.", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Activate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Activation successful.\n\nActivation successful. (Dry run)\n\nActivation successful.", + "schema": { + "$ref": "#/definitions/ActivationResponse" + } + }, + "204": { + "description": "A recent activation was already successful." + }, + "400": { + "description": "Invalid `body`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "404": { + "description": "Invalid activation code (label: `invalid-code`)\n\nUser does not exist (label: `invalid-code`)", + "schema": { + "example": { + "code": 404, + "label": "invalid-code", + "message": "Invalid activation code" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "The given e-mail address or phone number is in use. (label: `key-exists`)", + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address or phone number is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Activate (i.e. confirm) an email address or phone number." + } + }, + "/activate/send": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"post-activate-send\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SendActivationCode" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Activation code sent." + }, + "400": { + "description": "Invalid `body`\n\nInvalid mobile phone number (label: `invalid-phone`)\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "403": { + "description": "The given phone number has been blacklisted due to suspected abuse or a complaint (label: `blacklisted-phone`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)", + "schema": { + "example": { + "code": 403, + "label": "blacklisted-phone", + "message": "The given phone number has been blacklisted due to suspected abuse or a complaint" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "blacklisted-phone", + "blacklisted-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "The given e-mail address or phone number is in use. (label: `key-exists`)", + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address or phone number is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "451": { + "description": "[Customer extension] the email domain example.com that you are attempting to register a user with has been blocked for creating wire users. Please contact your IT department. (label: `domain-blocked-for-registration`)", + "schema": { + "example": { + "code": 451, + "label": "domain-blocked-for-registration", + "message": "[Customer extension] the email domain example.com that you are attempting to register a user with has been blocked for creating wire users. Please contact your IT department." + }, + "properties": { + "code": { + "enum": [ + 451 + ], + "type": "integer" + }, + "label": { + "enum": [ + "domain-blocked-for-registration" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Send (or resend) an email or phone activation code." + } + }, + "/api-version": { + "get": { + "description": " [internal route ID: \"get-version\"]\n\n", + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/VersionInfo" + } + } + } + } + }, + "/assets": { + "post": { + "consumes": [ + "multipart/mixed" + ], + "parameters": [ + { + "description": "A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server.", + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AssetSource" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Asset posted", + "headers": { + "Location": { + "description": "Asset location", + "format": "url", + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/Asset" + } + }, + "400": { + "description": "Invalid `body`\n\nInvalid content length (label: `invalid-length`)" + }, + "413": { + "description": "Asset too large (label: `client-error`)", + "schema": { + "example": { + "code": 413, + "label": "client-error", + "message": "Asset too large" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Upload an asset" + } + }, + "/assets/{key_domain}/{key}": { + "delete": { + "description": "**Note**: only local assets can be deleted.", + "parameters": [ + { + "in": "path", + "name": "key_domain", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "key", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Asset deleted" + }, + "400": { + "description": "Invalid `key` or `key_domain`" + }, + "403": { + "description": "Unauthorised operation (label: `unauthorised`)", + "schema": { + "example": { + "code": 403, + "label": "unauthorised", + "message": "Unauthorised operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorised" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Asset not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Delete an asset" + }, + "get": { + "description": "**Note**: local assets result in a redirect, while remote assets are streamed directly.", + "parameters": [ + { + "in": "path", + "name": "key_domain", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "key", + "required": true, + "type": "string" + }, + { + "in": "header", + "name": "Asset-Token", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "asset_token", + "required": false, + "type": "string" + } + ], + "responses": { + "200": { + "description": "Asset returned directly with content type `application/octet-stream`" + }, + "302": { + "description": "Asset found", + "headers": { + "Location": { + "description": "Asset location", + "format": "url", + "type": "string" + } + } + }, + "400": { + "description": "Invalid `asset_token` or `Asset-Token` or `key` or `key_domain`" + }, + "404": { + "description": "Asset not found (label: `not-found`)\n\nAsset not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Download an asset", + "x-wire-makes-federated-call-to": [ + [ + "cargohold", + "get-asset" + ], + [ + "cargohold", + "stream-asset" + ] + ] + } + }, + "/assets/{key}/token": { + "delete": { + "description": "**Note**: deleting the token makes the asset public.", + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Asset token deleted" + }, + "400": { + "description": "Invalid `key`" + } + }, + "summary": "Delete an asset token" + }, + "post": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/NewAssetToken" + } + }, + "400": { + "description": "Invalid `key`" + }, + "403": { + "description": "Unauthorised operation (label: `unauthorised`)", + "schema": { + "example": { + "code": 403, + "label": "unauthorised", + "message": "Unauthorised operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorised" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Asset not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Renew an asset token" + } + }, + "/await": { + "get": { + "description": " [internal route ID: \"await-notifications\"]\n\n", + "externalDocs": { + "description": "RFC 6455", + "url": "https://datatracker.ietf.org/doc/html/rfc6455" + }, + "parameters": [ + { + "description": "Client ID", + "in": "query", + "name": "client", + "required": false, + "type": "string" + } + ], + "responses": { + "101": { + "description": "Connection upgraded." + }, + "400": { + "description": "Invalid `client`" + }, + "426": { + "description": "Upgrade required." + } + }, + "summary": "Establish websocket connection" + } + }, + "/bot/assets": { + "post": { + "consumes": [ + "multipart/mixed" + ], + "parameters": [ + { + "description": "A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server.", + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AssetSource" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Asset posted", + "headers": { + "Location": { + "description": "Asset location", + "format": "url", + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/Asset" + } + }, + "400": { + "description": "Invalid `body`\n\nInvalid content length (label: `invalid-length`)" + }, + "413": { + "description": "Asset too large (label: `client-error`)", + "schema": { + "example": { + "code": 413, + "label": "client-error", + "message": "Asset too large" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Upload an asset" + } + }, + "/bot/assets/{key}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Asset deleted" + }, + "400": { + "description": "Invalid `key`" + }, + "403": { + "description": "Unauthorised operation (label: `unauthorised`)", + "schema": { + "example": { + "code": 403, + "label": "unauthorised", + "message": "Unauthorised operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorised" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Asset not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Delete an asset" + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "type": "string" + }, + { + "in": "header", + "name": "Asset-Token", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "asset_token", + "required": false, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "302": { + "description": "Asset found", + "headers": { + "Location": { + "description": "Asset location", + "format": "url", + "type": "string" + } + } + }, + "400": { + "description": "Invalid `asset_token` or `Asset-Token` or `key`" + }, + "404": { + "description": "Asset not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Download an asset" + } + }, + "/bot/client": { + "get": { + "description": " [internal route ID: \"bot-get-client\"]\n\n", + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Client found", + "schema": { + "$ref": "#/definitions/Client" + } + }, + "403": { + "description": "Access denied. (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Client not found (label: `client-not-found`)\n\nClient not found (label: `client-not-found`)", + "schema": { + "example": { + "code": 404, + "label": "client-not-found", + "message": "Client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get client for bot" + } + }, + "/bot/client/prekeys": { + "get": { + "description": " [internal route ID: \"bot-list-prekeys\"]\n\n", + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "items": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "type": "array" + } + }, + "403": { + "description": "Access denied. (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "List prekeys for bot" + }, + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"bot-update-prekeys\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateBotPrekeys" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "" + }, + "400": { + "description": "Invalid `body`" + }, + "403": { + "description": "Access denied. (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Client not found (label: `client-not-found`)", + "schema": { + "example": { + "code": 404, + "label": "client-not-found", + "message": "Client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update prekeys for bot" + } + }, + "/bot/messages": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"post-bot-message-unqualified\"]\n\n", + "parameters": [ + { + "in": "query", + "name": "ignore_missing", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "report_missing", + "required": false, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/new-otr-message" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Message sent", + "schema": { + "$ref": "#/definitions/ClientMismatch" + } + }, + "400": { + "description": "Invalid `body` or `report_missing` or `ignore_missing`" + }, + "403": { + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)", + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)\n\nConversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "412": { + "description": "Missing clients", + "schema": { + "$ref": "#/definitions/ClientMismatch" + } + } + }, + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-message-sent" + ], + [ + "brig", + "get-user-clients" + ] + ] + } + }, + "/bot/self": { + "delete": { + "description": " [internal route ID: \"bot-delete-self\"]\n\n", + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "" + }, + "403": { + "description": "The targeted user is not a bot. (label: `invalid-bot`)\n\nAccess denied. (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-bot", + "message": "The targeted user is not a bot." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-bot", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Delete self" + }, + "get": { + "description": " [internal route ID: \"bot-get-self\"]\n\n", + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/UserProfile" + } + }, + "403": { + "description": "Access denied. (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "User not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "User not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get self" + } + }, + "/bot/users": { + "get": { + "description": " [internal route ID: \"bot-list-users\"]\n\n", + "parameters": [ + { + "in": "query", + "name": "ids", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/BotUserView" + }, + "type": "array" + } + }, + "400": { + "description": "Invalid `ids`" + }, + "403": { + "description": "Access denied. (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "List users" + } + }, + "/bot/users/prekeys": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"bot-claim-users-prekeys\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UserClients" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/UserClientPrekeyMap" + } + }, + "400": { + "description": "Invalid `body`" + }, + "403": { + "description": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nToo many clients (label: `too-many-clients`)\n\nAccess denied. (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "missing-legalhold-consent", + "message": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "missing-legalhold-consent", + "too-many-clients", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Claim users prekeys" + } + }, + "/bot/users/{User ID}/clients": { + "get": { + "description": " [internal route ID: \"bot-get-user-clients\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "User ID", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/PubClient" + }, + "type": "array" + } + }, + "400": { + "description": "Invalid `User ID`" + }, + "403": { + "description": "Access denied. (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Access denied." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get user clients" + } + }, + "/broadcast/otr/messages": { + "post": { + "consumes": [ + "application/json;charset=utf-8", + "application/x-protobuf" + ], + "description": " [internal route ID: \"post-otr-broadcast-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.", + "parameters": [ + { + "in": "query", + "name": "ignore_missing", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "report_missing", + "required": false, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/new-otr-message" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Message sent", + "schema": { + "$ref": "#/definitions/ClientMismatch" + } + }, + "400": { + "description": "Invalid `body` or `report_missing` or `ignore_missing`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)" + }, + "403": { + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)", + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "non-binding-team", + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "412": { + "description": "Missing clients", + "schema": { + "$ref": "#/definitions/ClientMismatch" + } + } + }, + "summary": "Broadcast an encrypted message to all team members and all contacts (accepts JSON or Protobuf)" + } + }, + "/broadcast/proteus/messages": { + "post": { + "consumes": [ + "application/x-protobuf" + ], + "description": " [internal route ID: \"post-proteus-broadcast\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/QualifiedNewOtrMessage" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Message sent", + "schema": { + "$ref": "#/definitions/MessageSendingStatus" + } + }, + "400": { + "description": "Invalid `body`\n\nToo many users to fan out the broadcast event to (label: `too-many-users-to-broadcast`)" + }, + "403": { + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)", + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)\n\nNot a member of a binding team (label: `non-binding-team`)\n\nTeam not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "non-binding-team", + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "412": { + "description": "Missing clients", + "schema": { + "$ref": "#/definitions/MessageSendingStatus" + } + } + }, + "summary": "Post an encrypted message to all team members and all contacts (accepts only Protobuf)" + } + }, + "/calls/config": { + "get": { + "description": " [internal route ID: \"get-calls-config\"]\n\n", + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/RTCConfiguration" + } + } + }, + "summary": "[deprecated] Retrieve TURN server addresses and credentials for IP addresses, scheme `turn` and transport `udp` only" + } + }, + "/calls/config/v2": { + "get": { + "description": " [internal route ID: \"get-calls-config-v2\"]\n\n", + "parameters": [ + { + "description": "Limit resulting list. Allowed values [1..10]", + "in": "query", + "maximum": 10, + "minimum": 1, + "name": "limit", + "required": false, + "type": "integer" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/RTCConfiguration" + } + }, + "400": { + "description": "Invalid `limit`" + } + }, + "summary": "Retrieve all TURN server addresses and credentials. Clients are expected to do a DNS lookup to resolve the IP addresses of the given hostnames " + } + }, + "/clients": { + "get": { + "description": " [internal route ID: \"list-clients\"]\n\n", + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/Client" + }, + "type": "array" + } + } + }, + "summary": "List the registered clients" + }, + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"add-client\"]\n\n", + "parameters": [ + { + "in": "header", + "name": "X-Forwarded-For", + "required": false, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/NewClient" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "201": { + "description": "", + "headers": { + "Location": { + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/Client" + } + }, + "400": { + "description": "Invalid `body` or `X-Forwarded-For`\n\nMalformed prekeys uploaded (label: `bad-request`)" + }, + "403": { + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nToo many clients (label: `too-many-clients`)", + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "missing-auth", + "too-many-clients" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Register a new client" + } + }, + "/clients/{cid}/access-token": { + "post": { + "description": " [internal route ID: \"create-access-token\"]\n\nCreate an JWT DPoP access token for the client CSR, given a JWT DPoP proof, specified in the `DPoP` header. The access token will be returned as JWT DPoP token in the `DPoP` header.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "cid", + "required": true, + "type": "string" + }, + { + "in": "header", + "name": "DPoP", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Access token created", + "headers": { + "Cache-Control": { + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/DPoPAccessTokenResponse" + } + }, + "400": { + "description": "Invalid `DPoP` or `cid`" + } + }, + "summary": "Create a JWT DPoP access token" + } + }, + "/clients/{client}": { + "delete": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"delete-client\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/DeleteClient" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Client deleted" + }, + "400": { + "description": "Invalid `body` or `client`" + } + }, + "summary": "Delete an existing client" + }, + "get": { + "description": " [internal route ID: \"get-client\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Client found", + "schema": { + "$ref": "#/definitions/Client" + } + }, + "400": { + "description": "Invalid `client`" + }, + "404": { + "description": "Client not found(**Note**: This error has an empty body for legacy reasons)" + } + }, + "summary": "Get a registered client by ID" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-client\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UpdateClient" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Client updated" + }, + "400": { + "description": "Invalid `body` or `client`\n\nMalformed prekeys uploaded (label: `bad-request`)" + } + }, + "summary": "Update a registered client" + } + }, + "/clients/{client}/capabilities": { + "get": { + "description": " [internal route ID: \"get-client-capabilities\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ClientCapabilityList" + } + }, + "400": { + "description": "Invalid `client`" + } + }, + "summary": "Read back what the client has been posting about itself" + } + }, + "/clients/{client}/nonce": { + "get": { + "description": " [internal route ID: \"get-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "204": { + "description": "No Content", + "headers": { + "Cache-Control": { + "type": "string" + }, + "Replay-Nonce": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid `client`" + } + }, + "summary": "Get a new nonce for a client CSR" + }, + "head": { + "description": " [internal route ID: \"head-nonce\"]\n\nGet a new nonce for a client CSR, specified in the response header `Replay-Nonce` as a uuidv4 in base64url encoding.", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "No Content", + "headers": { + "Cache-Control": { + "type": "string" + }, + "Replay-Nonce": { + "type": "string" + } + } + }, + "400": { + "description": "Invalid `client`" + } + }, + "summary": "Get a new nonce for a client CSR" + } + }, + "/clients/{client}/prekeys": { + "get": { + "description": " [internal route ID: \"get-client-prekeys\"]\n\n", + "parameters": [ + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "items": { + "maximum": 65535, + "minimum": 0, + "type": "integer" + }, + "type": "array" + } + }, + "400": { + "description": "Invalid `client`" + } + }, + "summary": "List the remaining prekey IDs of a client" + } + }, + "/connections/{uid_domain}/{uid}": { + "get": { + "description": " [internal route ID: \"get-connection\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "type": "string" + }, + { + "description": "User Id", + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Connection found", + "schema": { + "$ref": "#/definitions/UserConnection" + } + }, + "400": { + "description": "Invalid `uid` or `uid_domain`" + }, + "404": { + "description": "Connection not found(**Note**: This error has an empty body for legacy reasons)" + } + }, + "summary": "Get an existing connection to another user (local or remote)" + }, + "post": { + "description": " [internal route ID: \"create-connection\"]\n\nYou can have no more than 1000 connections in accepted or sent state", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "type": "string" + }, + { + "description": "User Id", + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Connection existed", + "schema": { + "$ref": "#/definitions/UserConnection" + } + }, + "201": { + "description": "Connection was created", + "schema": { + "$ref": "#/definitions/UserConnection" + } + }, + "400": { + "description": "Invalid `uid` or `uid_domain`\n\nInvalid user (label: `invalid-user`)" + }, + "403": { + "description": "The user has no verified identity (email or phone number) (label: `no-identity`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)", + "schema": { + "example": { + "code": 403, + "label": "no-identity", + "message": "The user has no verified identity (email or phone number)" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-identity", + "connection-limit", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Create a connection to another user", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "send-connection-action" + ] + ] + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-connection\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "type": "string" + }, + { + "description": "User Id", + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ConnectionUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Connection updated", + "schema": { + "$ref": "#/definitions/UserConnection" + } + }, + "204": { + "description": "Connection unchanged" + }, + "400": { + "description": "Invalid `body` or `uid` or `uid_domain`\n\nInvalid user (label: `invalid-user`)" + }, + "403": { + "description": "The user has no verified identity (email or phone number) (label: `no-identity`)\n\nInvalid status transition (label: `bad-conn-update`)\n\nUsers are not connected (label: `not-connected`)\n\nToo many sent/accepted connections (label: `connection-limit`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)", + "schema": { + "example": { + "code": 403, + "label": "no-identity", + "message": "The user has no verified identity (email or phone number)" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-identity", + "bad-conn-update", + "not-connected", + "connection-limit", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update a connection to another user", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "send-connection-action" + ] + ] + } + }, + "/conversations": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"create-group-conversation\"]\n\nThis returns 201 when a new conversation is created, and 200 when the conversation already existed", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/NewConv" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Conversation existed", + "headers": { + "Location": { + "description": "Conversation ID", + "format": "uuid", + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/Conversation" + } + }, + "201": { + "description": "Conversation created", + "headers": { + "Location": { + "description": "Conversation ID", + "format": "uuid", + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/CreateGroupConversation" + } + }, + "400": { + "description": "Invalid `body`\n\nMLS is not configured on this backend. See docs.wire.com for instructions on how to enable it (label: `mls-not-enabled`)\n\nAttempting to add group members outside MLS (label: `non-empty-member-list`)" + }, + "403": { + "description": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nUsers are not connected (label: `not-connected`)\n\nConversation access denied (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "missing-legalhold-consent", + "message": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "missing-legalhold-consent", + "operation-denied", + "no-team-member", + "not-connected", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "Adding members to the conversation is not possible because the backends involved do not form a fully connected graph", + "schema": { + "properties": { + "non_federating_backends": { + "items": { + "$ref": "#/definitions/Domain" + }, + "type": "array" + } + }, + "required": [ + "non_federating_backends" + ], + "type": "object" + } + }, + "533": { + "description": "Some domains are unreachable", + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/definitions/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "summary": "Create a new conversation", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "api-version" + ], + [ + "brig", + "get-not-fully-connected-backends" + ], + [ + "galley", + "on-conversation-created" + ], + [ + "galley", + "on-conversation-updated" + ] + ] + } + }, + "/conversations/code-check": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"code-check\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ConversationCode" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Valid" + }, + "400": { + "description": "Invalid `body`" + }, + "403": { + "description": "Invalid conversation password (label: `invalid-conversation-password`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-conversation-password", + "message": "Invalid conversation password" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-conversation-password" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "no-conversation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Check validity of a conversation code.If the guest links team feature is disabled, this will fail with 404 CodeNotFound.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/join` which responds with 409 GuestLinksDisabled if guest links are disabled." + } + }, + "/conversations/join": { + "get": { + "description": " [internal route ID: \"get-conversation-by-reusable-code\"]\n\n", + "parameters": [ + { + "in": "query", + "name": "key", + "required": true, + "type": "string" + }, + { + "in": "query", + "name": "code", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ConversationCoverView" + } + }, + "400": { + "description": "Invalid `code` or `key`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "access-denied", + "invalid-conversation-password" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "no-conversation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)", + "schema": { + "example": { + "code": 409, + "label": "guest-links-disabled", + "message": "The guest link feature is disabled and all guest links have been revoked" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "guest-links-disabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get limited conversation information by key/code pair" + }, + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"join-conversation-by-code-unqualified\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/JoinConversationByCode" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Conversation joined", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "204": { + "description": "Conversation unchanged" + }, + "400": { + "description": "Invalid `body`" + }, + "403": { + "description": "Maximum number of members per conversation reached (label: `too-many-members`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInvalid conversation password (label: `invalid-conversation-password`)", + "schema": { + "example": { + "code": 403, + "label": "too-many-members", + "message": "Maximum number of members per conversation reached" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-members", + "no-team-member", + "invalid-op", + "access-denied", + "invalid-conversation-password" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "no-conversation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)", + "schema": { + "example": { + "code": 409, + "label": "guest-links-disabled", + "message": "The guest link feature is disabled and all guest links have been revoked" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "guest-links-disabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Join a conversation using a reusable code.If the guest links team feature is disabled, this will fail with 409 GuestLinksDisabled.Note that this is currently inconsistent (for backwards compatibility reasons) with `POST /conversations/code-check` which responds with 404 CodeNotFound if guest links are disabled.", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ] + ] + } + }, + "/conversations/list": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"list-conversations\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ListConversations" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ConversationsResponse" + } + }, + "400": { + "description": "Invalid `body`" + } + }, + "summary": "Get conversation metadata for a list of conversation ids", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "get-conversations" + ] + ] + } + }, + "/conversations/list-ids": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"list-conversation-ids\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/GetPaginated_ConversationIds" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ConversationIds_Page" + } + }, + "400": { + "description": "Invalid `body`" + } + }, + "summary": "Get all conversation IDs." + } + }, + "/conversations/one2one": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"create-one-to-one-conversation\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/NewConv" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Conversation existed", + "headers": { + "Location": { + "description": "Conversation ID", + "format": "uuid", + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/Conversation" + } + }, + "201": { + "description": "Conversation created", + "headers": { + "Location": { + "description": "Conversation ID", + "format": "uuid", + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/Conversation" + } + }, + "400": { + "description": "Invalid `body`" + }, + "403": { + "description": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nInsufficient permissions (label: `operation-denied`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nBoth users must be members of the same binding team (label: `non-binding-team-members`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "missing-legalhold-consent", + "message": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "missing-legalhold-consent", + "operation-denied", + "not-connected", + "no-team-member", + "non-binding-team-members", + "invalid-op", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)\n\nNot a member of a binding team (label: `non-binding-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team", + "non-binding-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "533": { + "description": "Some domains are unreachable", + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/definitions/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "summary": "Create a 1:1 conversation", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-created" + ] + ] + } + }, + "/conversations/self": { + "post": { + "description": " [internal route ID: \"create-self-conversation\"]\n\n", + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Conversation existed", + "headers": { + "Location": { + "description": "Conversation ID", + "format": "uuid", + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/Conversation" + } + }, + "201": { + "description": "Conversation created", + "headers": { + "Location": { + "description": "Conversation ID", + "format": "uuid", + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/Conversation" + } + } + }, + "summary": "Create a self-conversation" + } + }, + "/conversations/{Conversation ID}/bots": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"add-bot\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "Conversation ID", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AddBot" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "", + "schema": { + "$ref": "#/definitions/AddBotResponse" + } + }, + "400": { + "description": "Invalid `body` or `Conversation ID`" + }, + "403": { + "description": "The desired service is currently disabled. (label: `service-disabled`)\n\nMaximum number of members per conversation reached. (label: `too-many-members`)\n\nThe operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "service-disabled", + "message": "The desired service is currently disabled." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "service-disabled", + "too-many-members", + "invalid-conversation", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Add bot" + } + }, + "/conversations/{Conversation ID}/bots/{Bot ID}": { + "delete": { + "description": " [internal route ID: \"remove-bot\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "Conversation ID", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "Bot ID", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "User found", + "schema": { + "$ref": "#/definitions/RemoveBotResponse" + } + }, + "204": { + "description": "" + }, + "400": { + "description": "Invalid `Bot ID` or `Conversation ID`" + }, + "403": { + "description": "The operation is not allowed in this conversation. (label: `invalid-conversation`)\n\nAccess denied. (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-conversation", + "message": "The operation is not allowed in this conversation." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-conversation", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Remove bot" + } + }, + "/conversations/{cnv_domain}/{cnv}": { + "get": { + "description": " [internal route ID: \"get-conversation\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/Conversation" + } + }, + "400": { + "description": "Invalid `cnv` or `cnv_domain`" + }, + "403": { + "description": "Conversation access denied (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get a conversation by ID", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "get-conversations" + ] + ] + } + }, + "/conversations/{cnv_domain}/{cnv}/access": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-conversation-access\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "type": "string" + }, + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ConversationAccessDatav3" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Access updated", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "204": { + "description": "Access unchanged" + }, + "400": { + "description": "Invalid `body` or `cnv` or `cnv_domain`" + }, + "403": { + "description": "Invalid target access (label: `invalid-op`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nInsufficient authorization (missing modify_conversation_access) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid target access" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update access modes for a conversation", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/conversations/{cnv_domain}/{cnv}/members": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"add-members-to-conversation\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/InviteQualified" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Conversation updated", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "204": { + "description": "Conversation unchanged" + }, + "400": { + "description": "Invalid `body` or `cnv` or `cnv_domain`" + }, + "403": { + "description": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)\n\nUsers are not connected (label: `not-connected`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nConversation access denied (label: `access-denied`)\n\nMaximum number of members per conversation reached (label: `too-many-members`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing leave_conversation) (label: `action-denied`)\n\nInsufficient authorization (missing add_conversation_member) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "missing-legalhold-consent", + "message": "Failed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "missing-legalhold-consent", + "not-connected", + "no-team-member", + "access-denied", + "too-many-members", + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "Adding members to the conversation is not possible because the backends involved do not form a fully connected graph", + "schema": { + "properties": { + "non_federating_backends": { + "items": { + "$ref": "#/definitions/Domain" + }, + "type": "array" + } + }, + "required": [ + "non_federating_backends" + ], + "type": "object" + } + }, + "533": { + "description": "Some domains are unreachable", + "schema": { + "properties": { + "unreachable_backends": { + "items": { + "$ref": "#/definitions/Domain" + }, + "type": "array" + } + }, + "required": [ + "unreachable_backends" + ], + "type": "object" + } + } + }, + "summary": "Add qualified members to an existing conversation.", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ] + ] + } + }, + "/conversations/{cnv_domain}/{cnv}/members/{usr_domain}/{usr}": { + "delete": { + "description": " [internal route ID: \"remove-member\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "type": "string" + }, + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "usr_domain", + "required": true, + "type": "string" + }, + { + "description": "Target User ID", + "format": "uuid", + "in": "path", + "name": "usr", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Member removed", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "204": { + "description": "No change" + }, + "400": { + "description": "Invalid `usr` or `usr_domain` or `cnv` or `cnv_domain`" + }, + "403": { + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Remove a member from a conversation", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "leave-conversation" + ], + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-other-member\"]\n\n**Note**: at least one field has to be provided.", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "type": "string" + }, + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "path", + "name": "usr_domain", + "required": true, + "type": "string" + }, + { + "description": "Target User ID", + "format": "uuid", + "in": "path", + "name": "usr", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/OtherMemberUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Membership updated" + }, + "400": { + "description": "Invalid `body` or `usr` or `usr_domain` or `cnv` or `cnv_domain`" + }, + "403": { + "description": "Invalid operation (label: `invalid-op`)\n\nInvalid target (label: `invalid-op`)\n\nInsufficient authorization (missing modify_other_conversation_member) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation member not found (label: `no-conversation-member`)\n\nConversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation-member", + "message": "Conversation member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation-member", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update membership of the specified user", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/conversations/{cnv_domain}/{cnv}/message-timer": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-conversation-message-timer\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "type": "string" + }, + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ConversationMessageTimerUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Message timer updated", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "204": { + "description": "Message timer unchanged" + }, + "400": { + "description": "Invalid `body` or `cnv` or `cnv_domain`" + }, + "403": { + "description": "Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_message_timer) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update the message timer for a conversation", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/conversations/{cnv_domain}/{cnv}/name": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-conversation-name\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "type": "string" + }, + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ConversationRename" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Name unchanged", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "204": { + "description": "Name updated" + }, + "400": { + "description": "Invalid `body` or `cnv` or `cnv_domain`" + }, + "403": { + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update conversation name", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/conversations/{cnv_domain}/{cnv}/proteus/messages": { + "post": { + "consumes": [ + "application/x-protobuf" + ], + "description": " [internal route ID: \"post-proteus-message\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts `client_mismatch_strategy` in the body. It can have these values:\n- `report_all`: When set, the message is not sent if any clients are missing. The missing clients are reported in the response.\n- `ignore_all`: When set, no checks about missing clients are carried out.\n- `report_only`: Takes a list of qualified UserIDs. If any clients of the listed users are missing, the message is not sent. The missing clients are reported in the response.\n- `ignore_only`: Takes a list of qualified UserIDs. If any clients of the non-listed users are missing, the message is not sent. The missing clients are reported in the response.\n\nThe sending of messages in a federated conversation could theoretically fail partially. To make this case unlikely, the backend first gets a list of clients from all the involved backends and then tries to send a message. So, if any backend is down, the message is not propagated to anyone. But the actual message fan out to multiple backends could still fail partially. This type of failure is reported as a 201, the clients for which the message sending failed are part of the response body.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/QualifiedNewOtrMessage" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Message sent", + "schema": { + "$ref": "#/definitions/MessageSendingStatus" + } + }, + "400": { + "description": "Invalid `body` or `cnv` or `cnv_domain`" + }, + "403": { + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)", + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "412": { + "description": "Missing clients", + "schema": { + "$ref": "#/definitions/MessageSendingStatus" + } + } + }, + "summary": "Post an encrypted message to a conversation (accepts only Protobuf)", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "get-user-clients" + ], + [ + "galley", + "on-message-sent" + ], + [ + "galley", + "send-message" + ] + ] + } + }, + "/conversations/{cnv_domain}/{cnv}/receipt-mode": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-conversation-receipt-mode\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "type": "string" + }, + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ConversationReceiptModeUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Receipt mode updated", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "204": { + "description": "Receipt mode unchanged" + }, + "400": { + "description": "Invalid `body` or `cnv` or `cnv_domain`" + }, + "403": { + "description": "Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_receipt_mode) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update receipt mode for a conversation", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "galley", + "update-conversation" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/conversations/{cnv_domain}/{cnv}/self": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-conversation-self\"]\n\n**Note**: at least one field has to be provided.", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "type": "string" + }, + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/MemberUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Update successful" + }, + "400": { + "description": "Invalid `body` or `cnv` or `cnv_domain`" + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update self membership properties" + } + }, + "/conversations/{cnv_domain}/{cnv}/typing": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"member-typing-qualified\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "cnv_domain", + "required": true, + "type": "string" + }, + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/TypingData" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Notification sent" + }, + "400": { + "description": "Invalid `body` or `cnv` or `cnv_domain`" + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Sending typing notifications", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "update-typing-indicator" + ], + [ + "galley", + "on-typing-indicator-updated" + ] + ] + } + }, + "/conversations/{cnv}": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-conversation-name-deprecated\"]\n\nUse `/conversations/:domain/:conv/name` instead.", + "parameters": [ + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ConversationRename" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Name updated", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "204": { + "description": "Name unchanged" + }, + "400": { + "description": "Invalid `body` or `cnv`" + }, + "403": { + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update conversation name (deprecated)", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/conversations/{cnv}/code": { + "delete": { + "description": " [internal route ID: \"remove-code-unqualified\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Conversation code deleted.", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "400": { + "description": "Invalid `cnv`" + }, + "403": { + "description": "Conversation access denied (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Delete conversation code" + }, + "get": { + "description": " [internal route ID: \"get-code\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Conversation Code", + "schema": { + "$ref": "#/definitions/ConversationCodeInfo" + } + }, + "400": { + "description": "Invalid `cnv`" + }, + "403": { + "description": "Conversation access denied (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)\n\nConversation code not found (label: `no-conversation-code`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation", + "no-conversation-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "The guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)", + "schema": { + "example": { + "code": 409, + "label": "guest-links-disabled", + "message": "The guest link feature is disabled and all guest links have been revoked" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "guest-links-disabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get existing conversation code" + }, + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"create-conversation-code-unqualified\"]\n\n\nOAuth scope: `write:conversations_code`", + "parameters": [ + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateConversationCodeRequest" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Conversation code already exists.", + "schema": { + "$ref": "#/definitions/ConversationCodeInfo" + } + }, + "201": { + "description": "Conversation code created.", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "400": { + "description": "Invalid `body` or `cnv`" + }, + "403": { + "description": "Conversation access denied (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "Conversation code already exists with a different password setting than the requested one. (label: `create-conv-code-conflict`)\n\nThe guest link feature is disabled and all guest links have been revoked (label: `guest-links-disabled`)", + "schema": { + "example": { + "code": 409, + "label": "create-conv-code-conflict", + "message": "Conversation code already exists with a different password setting than the requested one." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "create-conv-code-conflict", + "guest-links-disabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Create or recreate a conversation code" + } + }, + "/conversations/{cnv}/features/conversationGuestLinks": { + "get": { + "description": " [internal route ID: \"get-conversation-guest-links-status\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/GuestLinksConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `cnv`" + }, + "403": { + "description": "Conversation access denied (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get the status of the guest links feature for a conversation that potentially has been created by someone from another team." + } + }, + "/conversations/{cnv}/join": { + "post": { + "description": " [internal route ID: \"join-conversation-by-id-unqualified\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Conversation joined", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "204": { + "description": "Conversation unchanged" + }, + "400": { + "description": "Invalid `cnv`" + }, + "403": { + "description": "Maximum number of members per conversation reached (label: `too-many-members`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "too-many-members", + "message": "Maximum number of members per conversation reached" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "too-many-members", + "no-team-member", + "invalid-op", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Join a conversation by its ID (if link access enabled)", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ] + ] + } + }, + "/conversations/{cnv}/members/{usr}": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-other-member-unqualified\"]\n\nUse `PUT /conversations/:cnv_domain/:cnv/members/:usr_domain/:usr` instead", + "parameters": [ + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "description": "Target User ID", + "format": "uuid", + "in": "path", + "name": "usr", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/OtherMemberUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Membership updated" + }, + "400": { + "description": "Invalid `body` or `usr` or `cnv`" + }, + "403": { + "description": "Invalid operation (label: `invalid-op`)\n\nInvalid target (label: `invalid-op`)\n\nInsufficient authorization (missing modify_other_conversation_member) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation member not found (label: `no-conversation-member`)\n\nConversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation-member", + "message": "Conversation member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation-member", + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update membership of the specified user (deprecated)", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/conversations/{cnv}/message-timer": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-conversation-message-timer-unqualified\"]\n\nUse `/conversations/:domain/:cnv/message-timer` instead.", + "parameters": [ + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ConversationMessageTimerUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Message timer updated", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "204": { + "description": "Message timer unchanged" + }, + "400": { + "description": "Invalid `body` or `cnv`" + }, + "403": { + "description": "Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_message_timer) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update the message timer for a conversation (deprecated)", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/conversations/{cnv}/name": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-conversation-name-unqualified\"]\n\nUse `/conversations/:domain/:conv/name` instead.", + "parameters": [ + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ConversationRename" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Name updated", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "204": { + "description": "Name unchanged" + }, + "400": { + "description": "Invalid `body` or `cnv`" + }, + "403": { + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing modify_conversation_name) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update conversation name (deprecated)", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/conversations/{cnv}/otr/messages": { + "post": { + "consumes": [ + "application/json;charset=utf-8", + "application/x-protobuf" + ], + "description": " [internal route ID: \"post-otr-message-unqualified\"]\n\nThis endpoint ensures that the list of clients is correct and only sends the message if the list is correct.\nTo override this, the endpoint accepts two query params:\n- `ignore_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are ignored.\n - When 'false' all missing clients are reported.\n - When comma separated list of user-ids, only clients for listed users are ignored.\n- `report_missing`: Can be 'true' 'false' or a comma separated list of user IDs.\n - When 'true' all missing clients are reported.\n - When 'false' all missing clients are ignored.\n - When comma separated list of user-ids, only clients for listed users are reported.\n\nApart from these, the request body also accepts `report_missing` which can only be a list of user ids and behaves the same way as the query parameter.\n\nAll three of these should be considered mutually exclusive. The server however does not error if more than one is specified, it reads them in this order of precedence:\n- `report_missing` in the request body has highest precedence.\n- `ignore_missing` in the query param is the next.\n- `report_missing` in the query param has the lowest precedence.\n\nThis endpoint can lead to OtrMessageAdd event being sent to the recipients.\n\n**NOTE:** The protobuf definitions of the request body can be found at https://github.com/wireapp/generic-message-proto/blob/master/proto/otr.proto.", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "query", + "name": "ignore_missing", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "report_missing", + "required": false, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/new-otr-message" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Message sent", + "schema": { + "$ref": "#/definitions/ClientMismatch" + } + }, + "400": { + "description": "Invalid `body` or `report_missing` or `ignore_missing` or `cnv`" + }, + "403": { + "description": "Unknown Client (label: `unknown-client`)\n\nFailed to connect to a user or to invite a user to a group because somebody is under legalhold and somebody else has not granted consent (label: `missing-legalhold-consent`)", + "schema": { + "example": { + "code": 403, + "label": "unknown-client", + "message": "Unknown Client" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unknown-client", + "missing-legalhold-consent" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "412": { + "description": "Missing clients", + "schema": { + "$ref": "#/definitions/ClientMismatch" + } + } + }, + "summary": "Post an encrypted message to a conversation (accepts JSON or Protobuf)", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-message-sent" + ], + [ + "brig", + "get-user-clients" + ] + ] + } + }, + "/conversations/{cnv}/receipt-mode": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-conversation-receipt-mode-unqualified\"]\n\nUse `PUT /conversations/:domain/:cnv/receipt-mode` instead.", + "parameters": [ + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ConversationReceiptModeUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Receipt mode updated", + "schema": { + "$ref": "#/definitions/Event" + } + }, + "204": { + "description": "Receipt mode unchanged" + }, + "400": { + "description": "Invalid `body` or `cnv`" + }, + "403": { + "description": "Invalid operation (label: `invalid-op`)\n\nConversation access denied (label: `access-denied`)\n\nInsufficient authorization (missing modify_conversation_receipt_mode) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "access-denied", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update receipt mode for a conversation (deprecated)", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "galley", + "update-conversation" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/conversations/{cnv}/roles": { + "get": { + "description": " [internal route ID: \"get-conversation-roles\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ConversationRolesList" + } + }, + "400": { + "description": "Invalid `cnv`" + }, + "403": { + "description": "Conversation access denied (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "Conversation access denied" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get existing roles available for the given conversation" + } + }, + "/conversations/{cnv}/self": { + "get": { + "description": " [internal route ID: \"get-conversation-self-unqualified\"]\n\n", + "parameters": [ + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/Member" + } + }, + "400": { + "description": "Invalid `cnv`" + } + }, + "summary": "Get self membership properties (deprecated)" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-conversation-self-unqualified\"]\n\nUse `/conversations/:domain/:conv/self` instead.", + "parameters": [ + { + "description": "Conversation ID", + "format": "uuid", + "in": "path", + "name": "cnv", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/MemberUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Update successful" + }, + "400": { + "description": "Invalid `body` or `cnv`" + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update self membership properties (deprecated)" + } + }, + "/cookies": { + "get": { + "description": " [internal route ID: \"list-cookies\"]\n\n", + "parameters": [ + { + "description": "Filter by label (comma-separated list)", + "in": "query", + "name": "labels", + "required": false, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "List of cookies", + "schema": { + "$ref": "#/definitions/CookieList" + } + }, + "400": { + "description": "Invalid `labels`" + } + }, + "summary": "Retrieve the list of cookies currently stored for the user" + } + }, + "/cookies/remove": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"remove-cookies\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RemoveCookies" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Cookies revoked" + }, + "400": { + "description": "Invalid `body`" + }, + "403": { + "description": "Authentication failed (label: `invalid-credentials`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Revoke stored cookies" + } + }, + "/custom-backend/by-domain/{domain}": { + "get": { + "description": " [internal route ID: \"get-custom-backend-by-domain\"]\n\n", + "parameters": [ + { + "description": "URL-encoded email domain", + "in": "path", + "name": "domain", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/CustomBackend" + } + }, + "400": { + "description": "Invalid `domain`" + }, + "404": { + "description": "Custom backend not found (label: `custom-backend-not-found`)", + "schema": { + "example": { + "code": 404, + "label": "custom-backend-not-found", + "message": "Custom backend not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "custom-backend-not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Shows information about custom backends related to a given email domain" + } + }, + "/delete": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"verify-delete\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/VerifyDeleteUser" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Deletion is initiated." + }, + "400": { + "description": "Invalid `body`" + }, + "403": { + "description": "Invalid verification code (label: `invalid-code`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-code", + "message": "Invalid verification code" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Verify account deletion with a code." + } + }, + "/feature-configs": { + "get": { + "description": " [internal route ID: \"get-all-feature-configs-for-user\"]\n\nGets feature configs for a user. If the user is a member of a team and has the required permissions, this will return the team's feature configs.If the user is not a member of a team, this will return the personal feature configs (the server defaults).\nOAuth scope: `read:feature_configs`", + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/AllFeatureConfigs" + } + }, + "403": { + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)", + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Gets feature configs for a user" + } + }, + "/identity-providers": { + "get": { + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/IdPList" + } + } + } + }, + "post": { + "consumes": [ + "application/xml", + "application/json;charset=utf-8" + ], + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/IdPMetadataInfo" + } + }, + { + "format": "uuid", + "in": "query", + "name": "replaces", + "required": false, + "type": "string" + }, + { + "default": "v2", + "enum": [ + "v1", + "v2" + ], + "in": "query", + "name": "api_version", + "required": false, + "type": "string" + }, + { + "in": "query", + "maxLength": 1, + "minLength": 32, + "name": "handle", + "required": false, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "201": { + "description": "", + "schema": { + "$ref": "#/definitions/IdPConfig" + } + }, + "400": { + "description": "Invalid `handle` or `api_version` or `replaces` or `body`" + } + } + } + }, + "/identity-providers/{id}": { + "delete": { + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "id", + "required": true, + "type": "string" + }, + { + "in": "query", + "name": "purge", + "required": false, + "type": "boolean" + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "Invalid `purge` or `id`" + } + } + }, + "get": { + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/IdPConfig" + } + }, + "400": { + "description": "Invalid `id`" + } + } + }, + "put": { + "consumes": [ + "application/xml", + "application/json;charset=utf-8" + ], + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/IdPMetadataInfo" + } + }, + { + "format": "uuid", + "in": "path", + "name": "id", + "required": true, + "type": "string" + }, + { + "in": "query", + "maxLength": 1, + "minLength": 32, + "name": "handle", + "required": false, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/IdPConfig" + } + }, + "400": { + "description": "Invalid `handle` or `id` or `body`" + } + } + } + }, + "/identity-providers/{id}/raw": { + "get": { + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "id", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/xml" + ], + "responses": { + "200": { + "description": "", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid `id`" + } + } + } + }, + "/list-connections": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"list-connections\"]\n\nThe IDs returned by this endpoint are paginated. To get the first page, make a call with the `paging_state` field set to `null` (or omitted). Whenever the `has_more` field of the response is set to `true`, more results are available, and they can be obtained by calling the endpoint again, but this time passing the value of `paging_state` returned by the previous call. One can continue in this fashion until all results are returned, which is indicated by `has_more` being `false`. Note that `paging_state` should be considered an opaque token. It should not be inspected, or stored, or reused across multiple unrelated invocations of the endpoint.", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/GetPaginated_Connections" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/Connections_Page" + } + }, + "400": { + "description": "Invalid `body`" + } + }, + "summary": "List the connections to other users, including remote users" + } + }, + "/list-users": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"list-users-by-ids-or-handles\"]\n\nThe 'qualified_ids' and 'qualified_handles' parameters are mutually exclusive.", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ListUsersQuery" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ListUsersById" + } + }, + "400": { + "description": "Invalid `body`" + } + }, + "summary": "List users", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/login": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"login\"]\n\nLogins are throttled at the server's discretion", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Login" + } + }, + { + "description": "Request a persistent cookie instead of a session cookie", + "in": "query", + "name": "persist", + "required": false, + "type": "boolean" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "headers": { + "Set-Cookie": { + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/AccessToken" + } + }, + "400": { + "description": "Invalid `persist` or `body`" + }, + "403": { + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nAccount pending activation (label: `pending-activation`)\n\nAccount suspended (label: `suspended`)\n\nAuthentication failed (label: `invalid-credentials`)", + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "pending-activation", + "suspended", + "invalid-credentials" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Authenticate a user to obtain a cookie and first access token" + } + }, + "/login/send": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"send-login-code\"]\n\nThis operation generates and sends a login code via sms for phone login. A login code can be used only once and times out after 10 minutes. Only one login code may be pending at a time. For 2nd factor authentication login with email and password, use the `/verification-code/send` endpoint.", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SendLoginCode" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "OK", + "schema": { + "$ref": "#/definitions/LoginCodeTimeout" + } + }, + "400": { + "description": "Invalid mobile phone number (label: `invalid-phone`) or `body`", + "schema": { + "example": { + "code": 400, + "label": "invalid-phone", + "message": "Invalid mobile phone number" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-phone" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "403": { + "description": "The operation is not permitted because the user has a password set (label: `password-exists`)", + "schema": { + "example": { + "code": 403, + "label": "password-exists", + "message": "The operation is not permitted because the user has a password set" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Send a login code to a verified phone number" + } + }, + "/notifications": { + "get": { + "description": " [internal route ID: \"get-notifications\"]\n\n", + "parameters": [ + { + "description": "Only return notifications more recent than this", + "format": "uuid", + "in": "query", + "name": "since", + "required": false, + "type": "string" + }, + { + "description": "Only return notifications targeted at the given client", + "in": "query", + "name": "client", + "required": false, + "type": "string" + }, + { + "description": "Maximum number of notifications to return", + "format": "int32", + "in": "query", + "maximum": 10000, + "minimum": 100, + "name": "size", + "required": false, + "type": "integer" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Notification list", + "schema": { + "$ref": "#/definitions/QueuedNotificationList" + } + }, + "400": { + "description": "Invalid `size` or `client` or `since`" + }, + "404": { + "description": "Some notifications not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Fetch notifications" + } + }, + "/notifications/last": { + "get": { + "description": " [internal route ID: \"get-last-notification\"]\n\n", + "parameters": [ + { + "description": "Only return notifications targeted at the given client", + "in": "query", + "name": "client", + "required": false, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Notification found", + "schema": { + "$ref": "#/definitions/QueuedNotification" + } + }, + "400": { + "description": "Invalid `client`" + }, + "404": { + "description": "Some notifications not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Fetch the last notification" + } + }, + "/notifications/{id}": { + "get": { + "description": " [internal route ID: \"get-notification-by-id\"]\n\n", + "parameters": [ + { + "description": "Notification ID", + "format": "uuid", + "in": "path", + "name": "id", + "required": true, + "type": "string" + }, + { + "description": "Only return notifications targeted at the given client", + "in": "query", + "name": "client", + "required": false, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Notification found", + "schema": { + "$ref": "#/definitions/QueuedNotification" + } + }, + "400": { + "description": "Invalid `client` or `id`" + }, + "404": { + "description": "Some notifications not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Some notifications not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Fetch a notification by ID" + } + }, + "/oauth/applications": { + "get": { + "description": " [internal route ID: \"get-oauth-applications\"]\n\nGet all OAuth applications with active account access for a user.", + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "OAuth applications found", + "schema": { + "items": { + "$ref": "#/definitions/OAuthApplication" + }, + "type": "array" + } + } + }, + "summary": "Get OAuth applications with account access" + } + }, + "/oauth/applications/{OAuthClientId}": { + "delete": { + "description": " [internal route ID: \"revoke-oauth-account-access\"]\n\n", + "parameters": [ + { + "description": "The ID of the OAuth client", + "format": "uuid", + "in": "path", + "name": "OAuthClientId", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "204": { + "description": "OAuth application access revoked" + }, + "400": { + "description": "Invalid `OAuthClientId`" + } + }, + "summary": "Revoke account access from an OAuth application" + } + }, + "/oauth/authorization/codes": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"create-oauth-auth-code\"]\n\nCurrently only supports the 'code' response type, which corresponds to the authorization code flow.", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateOAuthAuthorizationCodeRequest" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Created", + "headers": { + "Location": { + "type": "string" + } + } + }, + "400": { + "description": "Bad Request\n\nThe redirect URL does not match the one registered with the client (label: `redirect-url-miss-match`) or `body`", + "headers": { + "Location": { + "type": "string" + } + } + }, + "403": { + "description": "Forbidden", + "headers": { + "Location": { + "type": "string" + } + } + }, + "404": { + "description": "Not Found", + "headers": { + "Location": { + "type": "string" + } + } + } + }, + "summary": "Create an OAuth authorization code" + } + }, + "/oauth/clients/{OAuthClientId}": { + "get": { + "description": " [internal route ID: \"get-oauth-client\"]\n\n", + "parameters": [ + { + "description": "The ID of the OAuth client", + "format": "uuid", + "in": "path", + "name": "OAuthClientId", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "OAuth client found", + "schema": { + "$ref": "#/definitions/OAuthClient" + } + }, + "400": { + "description": "Invalid `OAuthClientId`" + }, + "403": { + "description": "OAuth is disabled (label: `forbidden`)", + "schema": { + "example": { + "code": 403, + "label": "forbidden", + "message": "OAuth is disabled" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "forbidden" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "OAuth client not found (label: `not-found`)\n\nOAuth client not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get OAuth client information" + } + }, + "/oauth/revoke": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"revoke-oauth-refresh-token\"]\n\nRevoke an access token.", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/OAuthRevokeRefreshTokenRequest" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "400": { + "description": "Invalid `body`" + }, + "403": { + "description": "Invalid refresh token (label: `forbidden`)", + "schema": { + "example": { + "code": 403, + "label": "forbidden", + "message": "Invalid refresh token" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "forbidden" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "OAuth client not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "500": { + "description": "Internal error while handling JWT token (label: `jwt-error`)", + "schema": { + "example": { + "code": 500, + "label": "jwt-error", + "message": "Internal error while handling JWT token" + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "jwt-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Revoke an OAuth refresh token" + } + }, + "/oauth/token": { + "post": { + "consumes": [ + "application/x-www-form-urlencoded" + ], + "description": " [internal route ID: \"create-oauth-access-token\"]\n\nObtain a new access token from an authorization code or a refresh token.", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Either" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/OAuthAccessTokenResponse" + } + }, + "400": { + "description": "Invalid `body`" + }, + "403": { + "description": "Invalid grant (label: `invalid_grant`)\n\nInvalid client credentials (label: `forbidden`)\n\nInvalid grant type (label: `forbidden`)\n\nInvalid refresh token (label: `forbidden`)\n\nOAuth is disabled (label: `forbidden`)", + "schema": { + "example": { + "code": 403, + "label": "invalid_grant", + "message": "Invalid grant" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid_grant", + "forbidden" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "OAuth client not found (label: `not-found`)\n\nOAuth authorization code not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "OAuth client not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "500": { + "description": "Internal error while handling JWT token (label: `jwt-error`)", + "schema": { + "example": { + "code": 500, + "label": "jwt-error", + "message": "Internal error while handling JWT token" + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "jwt-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Create an OAuth access token" + } + }, + "/onboarding/v3": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"onboarding\"]\n\nDEPRECATED: the feature has been turned off, the end-point does nothing and always returns '{\"results\":[],\"auto-connects\":[]}'.", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/Body" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/DeprecatedMatchingResult" + } + }, + "400": { + "description": "Invalid `body`" + } + }, + "summary": "Upload contacts and invoke matching." + } + }, + "/password-reset": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"post-password-reset\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/NewPasswordReset" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Password reset code created and sent by email." + }, + "400": { + "description": "Invalid `body`\n\nInvalid email or mobile number for password reset. (label: `invalid-key`)" + }, + "409": { + "description": "A password reset is already in progress. (label: `code-exists`)", + "schema": { + "example": { + "code": 409, + "label": "code-exists", + "message": "A password reset is already in progress." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Initiate a password reset." + } + }, + "/password-reset/complete": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"post-password-reset-complete\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CompletePasswordReset" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Password reset successful." + }, + "400": { + "description": "Invalid `body`\n\nInvalid password reset code. (label: `invalid-code`)" + } + }, + "summary": "Complete a password reset." + } + }, + "/password-reset/{key}": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"post-password-reset-key-deprecated\"]\n\nDEPRECATED: Use 'POST /password-reset/complete'.", + "parameters": [ + { + "description": "An opaque key for a pending password reset.", + "in": "path", + "name": "key", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PasswordReset" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Password reset successful." + }, + "400": { + "description": "Invalid `body` or `key`\n\nInvalid password reset code. (label: `invalid-code`)\n\nInvalid email or mobile number for password reset. (label: `invalid-key`)" + }, + "409": { + "description": "For password reset, new and old password must be different. (label: `password-must-differ`)\n\nA password reset is already in progress. (label: `code-exists`)", + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password reset, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ", + "code-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Complete a password reset." + } + }, + "/properties": { + "delete": { + "description": " [internal route ID: \"clear-properties\"]\n\n", + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Properties cleared" + } + }, + "summary": "Clear all properties" + }, + "get": { + "description": " [internal route ID: \"list-property-keys\"]\n\n", + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "List of property keys", + "schema": { + "items": { + "$ref": "#/definitions/ASCII" + }, + "type": "array" + } + } + }, + "summary": "List all property keys" + } + }, + "/properties-values": { + "get": { + "description": " [internal route ID: \"list-properties\"]\n\n", + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/PropertyKeysAndValues" + } + } + }, + "summary": "List all properties with key and value" + } + }, + "/properties/{key}": { + "delete": { + "description": " [internal route ID: \"delete-property\"]\n\n", + "parameters": [ + { + "format": "printable", + "in": "path", + "name": "key", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Property deleted" + }, + "400": { + "description": "Invalid `key`" + } + }, + "summary": "Delete a property" + }, + "get": { + "description": " [internal route ID: \"get-property\"]\n\n", + "parameters": [ + { + "format": "printable", + "in": "path", + "name": "key", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "The property value", + "schema": { + "$ref": "#/definitions/PropertyValue" + } + }, + "400": { + "description": "Invalid `key`" + }, + "404": { + "description": "Property not found(**Note**: This error has an empty body for legacy reasons)" + } + }, + "summary": "Get a property value" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"set-property\"]\n\n", + "parameters": [ + { + "format": "printable", + "in": "path", + "name": "key", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PropertyValue" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Property set" + }, + "400": { + "description": "Invalid `body` or `key`" + } + }, + "summary": "Set a user property" + } + }, + "/provider/assets": { + "post": { + "consumes": [ + "multipart/mixed" + ], + "parameters": [ + { + "description": "A body with content type `multipart/mixed body`. The first section's content type should be `application/json`. The second section's content type should be always be `application/octet-stream`. Other content types will be ignored by the server.", + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AssetSource" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Asset posted", + "headers": { + "Location": { + "description": "Asset location", + "format": "url", + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/Asset" + } + }, + "400": { + "description": "Invalid `body`\n\nInvalid content length (label: `invalid-length`)" + }, + "413": { + "description": "Asset too large (label: `client-error`)", + "schema": { + "example": { + "code": 413, + "label": "client-error", + "message": "Asset too large" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "client-error" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Upload an asset" + } + }, + "/provider/assets/{key}": { + "delete": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Asset deleted" + }, + "400": { + "description": "Invalid `key`" + }, + "403": { + "description": "Unauthorised operation (label: `unauthorised`)", + "schema": { + "example": { + "code": 403, + "label": "unauthorised", + "message": "Unauthorised operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorised" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Asset not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Delete an asset" + }, + "get": { + "parameters": [ + { + "in": "path", + "name": "key", + "required": true, + "type": "string" + }, + { + "in": "header", + "name": "Asset-Token", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "asset_token", + "required": false, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "302": { + "description": "Asset found", + "headers": { + "Location": { + "description": "Asset location", + "format": "url", + "type": "string" + } + } + }, + "400": { + "description": "Invalid `asset_token` or `Asset-Token` or `key`" + }, + "404": { + "description": "Asset not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Asset not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Download an asset" + } + }, + "/proxy/giphy/v1/gifs": {}, + "/proxy/googlemaps/api/staticmap": {}, + "/proxy/googlemaps/maps/api/geocode": {}, + "/proxy/youtube/v3": {}, + "/push/tokens": { + "get": { + "description": " [internal route ID: \"get-push-tokens\"]\n\n", + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/PushTokenList" + } + } + }, + "summary": "List the user's registered push tokens" + }, + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"register-push-token\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PushToken" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Push token registered", + "headers": { + "Location": { + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/PushToken" + } + }, + "400": { + "description": "Invalid `body`" + }, + "404": { + "description": "App does not exist (label: `app-not-found`)\n\nInvalid push token (label: `invalid-token`)", + "schema": { + "example": { + "code": 404, + "label": "app-not-found", + "message": "App does not exist" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "app-not-found", + "invalid-token" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "413": { + "description": "Too many concurrent calls to SNS; is SNS down? (label: `sns-thread-budget-reached`)\n\nPush token length must be < 8192 for GCM or 400 for APNS (label: `token-too-long`)\n\nTried to add token to endpoint resulting in metadata length > 2048 (label: `metadata-too-long`)", + "schema": { + "example": { + "code": 413, + "label": "sns-thread-budget-reached", + "message": "Too many concurrent calls to SNS; is SNS down?" + }, + "properties": { + "code": { + "enum": [ + 413 + ], + "type": "integer" + }, + "label": { + "enum": [ + "sns-thread-budget-reached", + "token-too-long", + "metadata-too-long" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Register a native push token" + } + }, + "/push/tokens/{pid}": { + "delete": { + "description": " [internal route ID: \"delete-push-token\"]\n\n", + "parameters": [ + { + "description": "The push token to delete", + "in": "path", + "name": "pid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "204": { + "description": "Push token unregistered" + }, + "400": { + "description": "Invalid `pid`" + }, + "404": { + "description": "Push token not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Push token not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Unregister a native push token" + } + }, + "/register": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"register\"]\n\nIf the environment where the registration takes place is private and a registered email address or phone number is not whitelisted, a 403 error is returned.", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/NewUser" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "User created and pending activation", + "headers": { + "Location": { + "description": "UserId", + "format": "uuid", + "type": "string" + }, + "Set-Cookie": { + "description": "Cookie", + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/User" + } + }, + "400": { + "description": "Invalid invitation code. (label: `invalid-invitation-code`)\n\nInvalid e-mail address. (label: `invalid-email`)\n\nInvalid mobile phone number (label: `invalid-phone`) or `body`", + "schema": { + "example": { + "code": 400, + "label": "invalid-invitation-code", + "message": "Invalid invitation code." + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-invitation-code", + "invalid-email", + "invalid-phone" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "403": { + "description": "Unauthorized e-mail address or phone number. (label: `unauthorized`)\n\nUsing an invitation code requires registering the given email and/or phone. (label: `missing-identity`)\n\nThe given phone number has been blacklisted due to suspected abuse or a complaint (label: `blacklisted-phone`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nToo many members in this team. (label: `too-many-team-members`)\n\nThis instance does not allow creation of personal users or teams. (label: `user-creation-restricted`)", + "schema": { + "example": { + "code": 403, + "label": "unauthorized", + "message": "Unauthorized e-mail address or phone number." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "unauthorized", + "missing-identity", + "blacklisted-phone", + "blacklisted-email", + "too-many-team-members", + "user-creation-restricted" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "User does not exist (label: `invalid-code`)\n\nInvalid activation code (label: `invalid-code`)", + "schema": { + "example": { + "code": 404, + "label": "invalid-code", + "message": "User does not exist" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "The given e-mail address or phone number is in use. (label: `key-exists`)", + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address or phone number is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Register a new user." + } + }, + "/scim/auth-tokens": { + "delete": { + "parameters": [ + { + "format": "uuid", + "in": "query", + "name": "id", + "required": true, + "type": "string" + } + ], + "responses": { + "204": { + "description": "" + }, + "400": { + "description": "Invalid `id`" + }, + "403": { + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nPassword authentication failed. (label: `password-authentication-failed`)", + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "password-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + } + }, + "get": { + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ScimTokenList" + } + }, + "403": { + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nPassword authentication failed. (label: `password-authentication-failed`)", + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "password-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + } + }, + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CreateScimToken" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/CreateScimTokenResponse" + } + }, + "400": { + "description": "Invalid `body`" + }, + "403": { + "description": "Code authentication is required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nPassword authentication failed. (label: `password-authentication-failed`)", + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Code authentication is required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "password-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + } + } + }, + "/search/contacts": { + "get": { + "description": " [internal route ID: \"search-contacts\"]\n\n", + "parameters": [ + { + "description": "Search query", + "in": "query", + "name": "q", + "required": true, + "type": "string" + }, + { + "description": "Searched domain. Note: This is optional only for backwards compatibility, future versions will mandate this.", + "in": "query", + "name": "domain", + "required": false, + "type": "string" + }, + { + "description": "Number of results to return (min: 1, max: 500, default 15)", + "format": "int32", + "in": "query", + "maximum": 500, + "minimum": 1, + "name": "size", + "required": false, + "type": "integer" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SearchResult" + } + }, + "400": { + "description": "Invalid `size` or `domain` or `q`" + } + }, + "summary": "Search for users", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "get-users-by-ids" + ], + [ + "brig", + "search-users" + ] + ] + } + }, + "/self": { + "delete": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"delete-self\"]\n\nif the account has a verified identity, a verification code is sent and needs to be confirmed to authorise the deletion. if the account has no verified identity but a password, it must be provided. if password is correct, or if neither a verified identity nor a password exists, account deletion is scheduled immediately.", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/DeleteUser" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Deletion is initiated." + }, + "202": { + "description": "Deletion is pending verification with a code.", + "schema": { + "$ref": "#/definitions/DeletionCodeTimeout" + } + }, + "400": { + "description": "Invalid `body`\n\nInvalid user (label: `invalid-user`)" + }, + "403": { + "description": "Team owners are not allowed to delete themselves; ask a fellow owner (label: `no-self-delete-for-team-owner`)\n\nA verification code for account deletion is still pending (label: `pending-delete`)\n\nRe-authentication via password required (label: `missing-auth`)\n\nAuthentication failed (label: `invalid-credentials`)\n\nInvalid verification code (label: `invalid-code`)", + "schema": { + "example": { + "code": 403, + "label": "no-self-delete-for-team-owner", + "message": "Team owners are not allowed to delete themselves; ask a fellow owner" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-self-delete-for-team-owner", + "pending-delete", + "missing-auth", + "invalid-credentials", + "invalid-code" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Initiate account deletion." + }, + "get": { + "description": " [internal route ID: \"get-self\"]\n\n\nOAuth scope: `read:self`", + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/User" + } + } + }, + "summary": "Get your own profile" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"put-self\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UserUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "User updated" + }, + "400": { + "description": "Invalid `body`" + }, + "403": { + "description": "Updating name is not allowed, because it is managed by SCIM (label: `managed-by-scim`)", + "schema": { + "example": { + "code": 403, + "label": "managed-by-scim", + "message": "Updating name is not allowed, because it is managed by SCIM" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "managed-by-scim" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "User not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "User not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update your profile." + } + }, + "/self/email": { + "delete": { + "description": " [internal route ID: \"remove-email\"]\n\nYour email address can only be removed if you also have a phone number.", + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Identity Removed" + }, + "403": { + "description": "The last user identity (email or phone number) cannot be removed. (label: `last-identity`)\n\nThe user has no password. (label: `no-password`)\n\nThe user has no verified identity (email or phone number) (label: `no-identity`)", + "schema": { + "example": { + "code": 403, + "label": "last-identity", + "message": "The last user identity (email or phone number) cannot be removed." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "last-identity", + "no-password", + "no-identity" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Remove your email address." + } + }, + "/self/handle": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"change-handle\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/HandleUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Handle Changed" + }, + "400": { + "description": "The given handle is invalid (label: `invalid-handle`) or `body`", + "schema": { + "example": { + "code": 400, + "label": "invalid-handle", + "message": "The given handle is invalid" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-handle" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "403": { + "description": "The user has no verified identity (email or phone number) (label: `no-identity`)\n\nUpdating handle is not allowed, because it is managed by SCIM (label: `managed-by-scim`)", + "schema": { + "example": { + "code": 403, + "label": "no-identity", + "message": "The user has no verified identity (email or phone number)" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-identity", + "managed-by-scim" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "The given handle is already taken (label: `handle-exists`)", + "schema": { + "example": { + "code": 409, + "label": "handle-exists", + "message": "The given handle is already taken" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "handle-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Change your handle." + } + }, + "/self/locale": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"change-locale\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/LocaleUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Local Changed" + }, + "400": { + "description": "Invalid `body`" + } + }, + "summary": "Change your locale." + } + }, + "/self/password": { + "head": { + "description": " [internal route ID: \"check-password-exists\"]\n\n", + "responses": { + "200": { + "description": "Password is set" + }, + "404": { + "description": "Password is not set" + } + }, + "summary": "Check that your password is set." + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"change-password\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PasswordChange" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Password Changed" + }, + "400": { + "description": "Invalid `body`" + }, + "403": { + "description": "Authentication failed (label: `invalid-credentials`)\n\nThe user has no verified identity (email or phone number) (label: `no-identity`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-credentials", + "message": "Authentication failed" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-credentials", + "no-identity" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "For password change, new and old password must be different. (label: `password-must-differ`)", + "schema": { + "example": { + "code": 409, + "label": "password-must-differ", + "message": "For password change, new and old password must be different." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "password-must-differ" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Change your password." + } + }, + "/self/phone": { + "delete": { + "description": " [internal route ID: \"remove-phone\"]\n\nYour phone number can only be removed if you also have an email address and a password.", + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Identity Removed" + }, + "403": { + "description": "The last user identity (email or phone number) cannot be removed. (label: `last-identity`)\n\nThe user has no password. (label: `no-password`)\n\nThe user has no verified identity (email or phone number) (label: `no-identity`)", + "schema": { + "example": { + "code": 403, + "label": "last-identity", + "message": "The last user identity (email or phone number) cannot be removed." + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "last-identity", + "no-password", + "no-identity" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Remove your phone number." + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"change-phone\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/PhoneUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "202": { + "description": "Phone updated" + }, + "400": { + "description": "Invalid mobile phone number (label: `invalid-phone`) or `body`", + "schema": { + "example": { + "code": 400, + "label": "invalid-phone", + "message": "Invalid mobile phone number" + }, + "properties": { + "code": { + "enum": [ + 400 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-phone" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "403": { + "description": "The given phone number has been blacklisted due to suspected abuse or a complaint (label: `blacklisted-phone`)", + "schema": { + "example": { + "code": 403, + "label": "blacklisted-phone", + "message": "The given phone number has been blacklisted due to suspected abuse or a complaint" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "blacklisted-phone" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "The given e-mail address or phone number is in use. (label: `key-exists`)", + "schema": { + "example": { + "code": 409, + "label": "key-exists", + "message": "The given e-mail address or phone number is in use." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "key-exists" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Change your phone number." + } + }, + "/sso/finalize-login": { + "post": { + "description": "DEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams", + "produces": [ + "text/plain;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "type": "string" + } + } + } + } + }, + "/sso/finalize-login/{team}": { + "post": { + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "team", + "required": true, + "type": "string" + } + ], + "produces": [ + "text/plain;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid `team`" + } + } + } + }, + "/sso/initiate-login/{idp}": { + "get": { + "parameters": [ + { + "in": "query", + "name": "success_redirect", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "error_redirect", + "required": false, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "idp", + "required": true, + "type": "string" + } + ], + "produces": [ + "text/html" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/FormRedirect" + } + }, + "400": { + "description": "Invalid `idp` or `error_redirect` or `success_redirect`" + } + } + }, + "head": { + "parameters": [ + { + "in": "query", + "name": "success_redirect", + "required": false, + "type": "string" + }, + { + "in": "query", + "name": "error_redirect", + "required": false, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "idp", + "required": true, + "type": "string" + } + ], + "produces": [ + "text/plain;charset=utf-8" + ], + "responses": { + "200": { + "description": "" + }, + "400": { + "description": "Invalid `idp` or `error_redirect` or `success_redirect`" + } + } + } + }, + "/sso/metadata": { + "get": { + "description": "DEPRECATED! use /sso/metadata/:tid instead! Details: https://docs.wire.com/understand/single-sign-on/trouble-shooting.html#can-i-use-the-same-sso-login-code-for-multiple-teams", + "produces": [ + "application/xml" + ], + "responses": { + "200": { + "description": "", + "schema": { + "type": "string" + } + } + } + } + }, + "/sso/metadata/{team}": { + "get": { + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "team", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/xml" + ], + "responses": { + "200": { + "description": "", + "schema": { + "type": "string" + } + }, + "400": { + "description": "Invalid `team`" + } + } + } + }, + "/sso/settings": { + "get": { + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SsoSettings" + } + } + } + } + }, + "/system/settings": { + "get": { + "description": " [internal route ID: \"get-system-settings\"]\n\n", + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SystemSettings" + } + } + }, + "summary": "Returns a curated set of system configuration settings for authorized users." + } + }, + "/system/settings/unauthorized": { + "get": { + "description": " [internal route ID: \"get-system-settings-unauthorized\"]\n\n", + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SystemSettingsPublic" + } + } + }, + "summary": "Returns a curated set of system configuration settings." + } + }, + "/teams/invitations/by-email": { + "head": { + "description": " [internal route ID: \"head-team-invitations\"]\n\n", + "parameters": [ + { + "description": "Email address", + "in": "query", + "name": "email", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Pending invitation exists." + }, + "400": { + "description": "Invalid `email`" + }, + "404": { + "description": "No pending invitations exists. (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "No pending invitations exists." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "Multiple conflicting invitations to different teams exists. (label: `conflicting-invitations`)", + "schema": { + "example": { + "code": 409, + "label": "conflicting-invitations", + "message": "Multiple conflicting invitations to different teams exists." + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "conflicting-invitations" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Check if there is an invitation pending given an email address." + } + }, + "/teams/invitations/info": { + "get": { + "description": " [internal route ID: \"get-team-invitation-info\"]\n\n", + "parameters": [ + { + "description": "Invitation code", + "in": "query", + "name": "code", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Invitation info", + "schema": { + "$ref": "#/definitions/Invitation" + } + }, + "400": { + "description": "Invalid `code`\n\nInvalid invitation code. (label: `invalid-invitation-code`)" + } + }, + "summary": "Get invitation info given a code." + } + }, + "/teams/notifications": { + "get": { + "description": " [internal route ID: \"get-team-notifications\"]\n\nThis is a work-around for scalability issues with gundeck user event fan-out. It does not track all team-wide events, but only `member-join`.\nNote that `/teams/notifications` behaves differently from `/notifications`:\n- If there is a gap between the notification id requested with `since` and the available data, team queues respond with 200 and the data that could be found. They do NOT respond with status 404, but valid data in the body.\n- The notification with the id given via `since` is included in the response if it exists. You should remove this and only use it to decide whether there was a gap between your last request and this one.\n- If the notification id does *not* exist, you get the more recent events from the queue (instead of all of them). This can be done because a notification id is a UUIDv1, which is essentially a time stamp.\n- There is no corresponding `/last` end-point to get only the most recent event. That end-point was only useful to avoid having to pull the entire queue. In team queues, if you have never requested the queue before and have no prior notification id, just pull with timestamp 'now'.", + "parameters": [ + { + "description": "Notification id to start with in the response (UUIDv1)", + "format": "uuid", + "in": "query", + "name": "since", + "required": false, + "type": "string" + }, + { + "description": "Maximum number of events to return (1..10000; default: 1000)", + "format": "int32", + "in": "query", + "maximum": 10000, + "minimum": 1, + "name": "size", + "required": false, + "type": "integer" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/QueuedNotificationList" + } + }, + "400": { + "description": "Invalid `size` or `since`\n\nCould not parse notification id (must be UUIDv1). (label: `invalid-notification-id`)" + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Read recently added team members from team queue" + } + }, + "/teams/{tid}": { + "delete": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"delete-team\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/TeamDeleteData" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "202": { + "description": "Team is scheduled for removal" + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Verification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (missing DeleteTeam) (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "code-authentication-required", + "message": "Verification code required" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "code-authentication-required", + "code-authentication-failed", + "access-denied", + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "503": { + "description": "The delete queue is full; no further delete requests can be processed at the moment (label: `queue-full`)", + "schema": { + "example": { + "code": 503, + "label": "queue-full", + "message": "The delete queue is full; no further delete requests can be processed at the moment" + }, + "properties": { + "code": { + "enum": [ + 503 + ], + "type": "integer" + }, + "label": { + "enum": [ + "queue-full" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Delete a team" + }, + "get": { + "description": " [internal route ID: \"get-team\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/Team" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get a team by ID" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-team\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/TeamUpdateData" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Team updated" + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Insufficient permissions (missing SetTeamData) (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)", + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions (missing SetTeamData)" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update team properties" + } + }, + "/teams/{tid}/conversations": { + "get": { + "description": " [internal route ID: \"get-team-conversations\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/TeamConversationList" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get team conversations" + } + }, + "/teams/{tid}/conversations/roles": { + "get": { + "description": " [internal route ID: \"get-team-conversation-roles\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ConversationRolesList" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get existing roles available for the given team" + } + }, + "/teams/{tid}/conversations/{cid}": { + "delete": { + "description": " [internal route ID: \"delete-team-conversation\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "cid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Conversation deleted" + }, + "400": { + "description": "Invalid `cid` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing delete_conversation) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Remove a team conversation", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + }, + "get": { + "description": " [internal route ID: \"get-team-conversation\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "cid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/TeamConversation" + } + }, + "400": { + "description": "Invalid `cid` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Conversation not found (label: `no-conversation`)", + "schema": { + "example": { + "code": 404, + "label": "no-conversation", + "message": "Conversation not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-conversation" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get one team conversation" + } + }, + "/teams/{tid}/features": { + "get": { + "description": " [internal route ID: \"get-all-feature-configs-for-team\"]\n\nGets feature configs for a team. User must be a member of the team and have permission to view team features.", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/AllFeatureConfigs" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)", + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Gets feature configs for a team" + } + }, + "/teams/{tid}/features/appLock": { + "get": { + "description": " [internal route ID: (\"get\", AppLockConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/AppLockConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for appLock" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: (\"put\", AppLockConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/AppLockConfig.WithStatusNoLock" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/AppLockConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Put config for appLock" + } + }, + "/teams/{tid}/features/classifiedDomains": { + "get": { + "description": " [internal route ID: (\"get\", ClassifiedDomainsConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ClassifiedDomainsConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for classifiedDomains" + } + }, + "/teams/{tid}/features/conferenceCalling": { + "get": { + "description": " [internal route ID: (\"get\", ConferenceCallingConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ConferenceCallingConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for conferenceCalling" + } + }, + "/teams/{tid}/features/conversationGuestLinks": { + "get": { + "description": " [internal route ID: (\"get\", GuestLinksConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/GuestLinksConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for conversationGuestLinks" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: (\"put\", GuestLinksConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/GuestLinksConfig.WithStatusNoLock" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/GuestLinksConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Put config for conversationGuestLinks" + } + }, + "/teams/{tid}/features/digitalSignatures": { + "get": { + "description": " [internal route ID: (\"get\", DigitalSignaturesConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/DigitalSignaturesConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for digitalSignatures" + } + }, + "/teams/{tid}/features/exposeInvitationURLsToTeamAdmin": { + "get": { + "description": " [internal route ID: (\"get\", ExposeInvitationURLsToTeamAdminConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ExposeInvitationURLsToTeamAdminConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for exposeInvitationURLsToTeamAdmin" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: (\"put\", ExposeInvitationURLsToTeamAdminConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ExposeInvitationURLsToTeamAdminConfig.WithStatusNoLock" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ExposeInvitationURLsToTeamAdminConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Put config for exposeInvitationURLsToTeamAdmin" + } + }, + "/teams/{tid}/features/fileSharing": { + "get": { + "description": " [internal route ID: (\"get\", FileSharingConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/FileSharingConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for fileSharing" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: (\"put\", FileSharingConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/FileSharingConfig.WithStatusNoLock" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/FileSharingConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Put config for fileSharing" + } + }, + "/teams/{tid}/features/legalhold": { + "get": { + "description": " [internal route ID: (\"get\", LegalholdConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/LegalholdConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for legalhold" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: (\"put\", LegalholdConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/LegalholdConfig.WithStatusNoLock" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/LegalholdConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `body` or `tid`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)" + }, + "403": { + "description": "legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nCannot enable legalhold on large teams (reason: for removing LH from team, we need to iterate over all members, which is only supported for teams with less than 2k members) (label: `too-large-team-for-legalhold`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "legalhold-disable-unimplemented", + "message": "legal hold cannot be disabled for whitelisted teams" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-disable-unimplemented", + "legalhold-not-enabled", + "too-large-team-for-legalhold", + "code-authentication-required", + "code-authentication-failed", + "access-denied", + "action-denied", + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "500": { + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)", + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Put config for legalhold", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ] + ] + } + }, + "/teams/{tid}/features/outlookCalIntegration": { + "get": { + "description": " [internal route ID: (\"get\", OutlookCalIntegrationConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/OutlookCalIntegrationConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for outlookCalIntegration" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: (\"put\", OutlookCalIntegrationConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/OutlookCalIntegrationConfig.WithStatusNoLock" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/OutlookCalIntegrationConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Put config for outlookCalIntegration" + } + }, + "/teams/{tid}/features/searchVisibility": { + "get": { + "description": " [internal route ID: (\"get\", SearchVisibilityAvailableConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SearchVisibilityAvailableConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for searchVisibility" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: (\"put\", SearchVisibilityAvailableConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SearchVisibilityAvailableConfig.WithStatusNoLock" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SearchVisibilityAvailableConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Put config for searchVisibility" + } + }, + "/teams/{tid}/features/searchVisibilityInbound": { + "get": { + "description": " [internal route ID: (\"get\", SearchVisibilityInboundConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SearchVisibilityInboundConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for searchVisibilityInbound" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: (\"put\", SearchVisibilityInboundConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SearchVisibilityInboundConfig.WithStatusNoLock" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SearchVisibilityInboundConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Put config for searchVisibilityInbound" + } + }, + "/teams/{tid}/features/selfDeletingMessages": { + "get": { + "description": " [internal route ID: (\"get\", SelfDeletingMessagesConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SelfDeletingMessagesConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for selfDeletingMessages" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: (\"put\", SelfDeletingMessagesConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SelfDeletingMessagesConfig.WithStatusNoLock" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SelfDeletingMessagesConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Put config for selfDeletingMessages" + } + }, + "/teams/{tid}/features/sndFactorPasswordChallenge": { + "get": { + "description": " [internal route ID: (\"get\", SndFactorPasswordChallengeConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SndFactorPasswordChallengeConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for sndFactorPasswordChallenge" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: (\"put\", SndFactorPasswordChallengeConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SndFactorPasswordChallengeConfig.WithStatusNoLock" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SndFactorPasswordChallengeConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Put config for sndFactorPasswordChallenge" + } + }, + "/teams/{tid}/features/sso": { + "get": { + "description": " [internal route ID: (\"get\", SSOConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/SSOConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for sso" + } + }, + "/teams/{tid}/features/validateSAMLemails": { + "get": { + "description": " [internal route ID: (\"get\", ValidateSAMLEmailsConfig)]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ValidateSAMLEmailsConfig.WithStatus" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "operation-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get config for validateSAMLemails" + } + }, + "/teams/{tid}/get-members-by-ids-using-post": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"get-team-members-by-ids\"]\n\nThe `has_more` field in the response body is always `false`.", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "description": "Maximum results to be returned", + "format": "int32", + "in": "query", + "maximum": 2000, + "minimum": 1, + "name": "maxResults", + "required": false, + "type": "integer" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/UserIdList" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/TeamMemberList" + } + }, + "400": { + "description": "Invalid `body` or `maxResults` or `tid`\n\nCan only process 2000 user ids per request. (label: `too-many-uids`)" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get team members by user id list" + } + }, + "/teams/{tid}/invitations": { + "get": { + "description": " [internal route ID: \"get-team-invitations\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "description": "Invitation id to start from (ascending).", + "format": "uuid", + "in": "query", + "name": "start", + "required": false, + "type": "string" + }, + { + "description": "Number of results to return (default 100, max 500).", + "format": "int32", + "in": "query", + "maximum": 500, + "minimum": 1, + "name": "size", + "required": false, + "type": "integer" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "List of sent invitations", + "schema": { + "$ref": "#/definitions/InvitationList" + } + }, + "400": { + "description": "Invalid `size` or `start` or `tid`" + }, + "403": { + "description": "Insufficient team permissions (label: `insufficient-permissions`)", + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "List the sent team invitations" + }, + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"send-team-invitation\"]\n\nInvitations are sent by email. The maximum allowed number of pending team invitations is equal to the team size.", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/InvitationRequest" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Invitation was created and sent.", + "headers": { + "Location": { + "format": "url", + "type": "string" + } + }, + "schema": { + "$ref": "#/definitions/Invitation" + } + }, + "400": { + "description": "Invalid `body` or `tid`\n\nInvalid e-mail address. (label: `invalid-email`)" + }, + "403": { + "description": "Insufficient team permissions (label: `insufficient-permissions`)\n\nToo many team invitations for this team (label: `too-many-team-invitations`)\n\nThe given e-mail address has been blacklisted due to a permanent bounce or a complaint. (label: `blacklisted-email`)\n\nThe user has no verified identity (email or phone number) (label: `no-identity`)\n\nThis operation requires the user to have a verified email address. (label: `no-email`)", + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions", + "too-many-team-invitations", + "blacklisted-email", + "no-identity", + "no-email" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Create and send a new team invitation." + } + }, + "/teams/{tid}/invitations/{iid}": { + "delete": { + "description": " [internal route ID: \"delete-team-invitation\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "iid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Invitation deleted" + }, + "400": { + "description": "Invalid `iid` or `tid`" + }, + "403": { + "description": "Insufficient team permissions (label: `insufficient-permissions`)", + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Delete a pending team invitation by ID." + }, + "get": { + "description": " [internal route ID: \"get-team-invitation\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "iid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Invitation", + "schema": { + "$ref": "#/definitions/Invitation" + } + }, + "400": { + "description": "Invalid `iid` or `tid`" + }, + "403": { + "description": "Insufficient team permissions (label: `insufficient-permissions`)", + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Notification not found. (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Notification not found." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get a pending team invitation by ID." + } + }, + "/teams/{tid}/legalhold/consent": { + "post": { + "description": " [internal route ID: \"consent-to-legal-hold\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Grant consent successful" + }, + "204": { + "description": "Consent already granted" + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Invalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "invalid-op", + "message": "Invalid operation" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "invalid-op", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team member not found (label: `no-team-member`)", + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "500": { + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)", + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Consent to legal hold", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/teams/{tid}/legalhold/settings": { + "delete": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"delete-legal-hold-settings\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to members with a legalhold client (via brig)\n- UserLegalHoldDisabled event to contacts of members with a legalhold client (via brig)", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/RemoveLegalHoldSettingsRequest" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "204": { + "description": "Legal hold service settings deleted" + }, + "400": { + "description": "Invalid `body` or `tid`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)" + }, + "403": { + "description": "legal hold cannot be disabled for whitelisted teams (label: `legalhold-disable-unimplemented`)\n\nlegal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInvalid operation (label: `invalid-op`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient permissions (label: `operation-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "legalhold-disable-unimplemented", + "message": "legal hold cannot be disabled for whitelisted teams" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-disable-unimplemented", + "legalhold-not-enabled", + "invalid-op", + "action-denied", + "no-team-member", + "operation-denied", + "code-authentication-required", + "code-authentication-failed", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "500": { + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)", + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Delete legal hold service settings", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + }, + "get": { + "description": " [internal route ID: \"get-legal-hold-settings\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ViewLegalHoldService" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)", + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get legal hold service settings" + }, + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"create-legal-hold-settings\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/NewLegalHoldService" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Legal hold service settings created", + "schema": { + "$ref": "#/definitions/ViewLegalHoldService" + } + }, + "400": { + "description": "Invalid `body` or `tid`\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)\n\nlegal hold service pubkey is invalid (label: `legalhold-invalid-key`)" + }, + "403": { + "description": "legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)", + "schema": { + "example": { + "code": 403, + "label": "legalhold-not-enabled", + "message": "legal hold is not enabled for this team" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-enabled", + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Create legal hold service settings" + } + }, + "/teams/{tid}/legalhold/{uid}": { + "delete": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"disable-legal-hold-for-user\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientRemoved event to the user owning the client (via brig)\n- UserLegalHoldDisabled event to contacts of the user owning the client (via brig)", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/DisableLegalHoldForUserRequest" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Disable legal hold successful" + }, + "204": { + "description": "Legal hold was not enabled" + }, + "400": { + "description": "Invalid `body` or `uid` or `tid`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)" + }, + "403": { + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member", + "action-denied", + "code-authentication-required", + "code-authentication-failed", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "500": { + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)", + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Disable legal hold for user", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + }, + "get": { + "description": " [internal route ID: \"get-legal-hold\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/UserLegalHoldStatusResponse" + } + }, + "400": { + "description": "Invalid `uid` or `tid`" + }, + "404": { + "description": "Team member not found (label: `no-team-member`)", + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get legal hold status" + }, + "post": { + "description": " [internal route ID: \"request-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- LegalHoldClientRequested event to contacts of the user the device is requested for, if they didn't already have a legalhold client (via brig)", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "201": { + "description": "Request device successful" + }, + "204": { + "description": "Request device already pending" + }, + "400": { + "description": "Invalid `uid` or `tid`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)\n\nlegal hold service: invalid response (label: `legalhold-status-bad`)" + }, + "403": { + "description": "legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)", + "schema": { + "example": { + "code": 403, + "label": "legalhold-not-enabled", + "message": "legal hold is not enabled for this team" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-enabled", + "operation-denied", + "no-team-member", + "action-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team member not found (label: `no-team-member`)", + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "user has not given consent to using legal hold (label: `legalhold-no-consent`)\n\nlegal hold is already enabled for this user (label: `legalhold-already-enabled`)", + "schema": { + "example": { + "code": 409, + "label": "legalhold-no-consent", + "message": "user has not given consent to using legal hold" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-no-consent", + "legalhold-already-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "500": { + "description": "internal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)\n\nlegal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)", + "schema": { + "example": { + "code": 500, + "label": "legalhold-illegal-op", + "message": "internal server error: inconsistent change of user's legalhold state" + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-illegal-op", + "legalhold-internal" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Request legal hold device", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/teams/{tid}/legalhold/{uid}/approve": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"approve-legal-hold-device\"]\n\nThis endpoint can lead to the following events being sent:\n- ClientAdded event to the user owning the client (via brig)\n- UserLegalHoldEnabled event to contacts of the user owning the client (via brig)\n- ClientRemoved event to the user, if removing old client due to max number (via brig)", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/ApproveLegalHoldForUserRequest" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Legal hold approved" + }, + "400": { + "description": "Invalid `body` or `uid` or `tid`\n\nlegal hold service has not been registered for this team (label: `legalhold-not-registered`)" + }, + "403": { + "description": "legal hold is not enabled for this team (label: `legalhold-not-enabled`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nInsufficient authorization (missing remove_conversation_member) (label: `action-denied`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "legalhold-not-enabled", + "message": "legal hold is not enabled for this team" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-enabled", + "no-team-member", + "action-denied", + "access-denied", + "code-authentication-required", + "code-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "no legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow. (label: `legalhold-no-device-allocated`)", + "schema": { + "example": { + "code": 404, + "label": "legalhold-no-device-allocated", + "message": "no legal hold device is registered for this user. POST /teams/:tid/legalhold/:uid/ to start the flow." + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-no-device-allocated" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "409": { + "description": "legal hold is already enabled for this user (label: `legalhold-already-enabled`)", + "schema": { + "example": { + "code": 409, + "label": "legalhold-already-enabled", + "message": "legal hold is already enabled for this user" + }, + "properties": { + "code": { + "enum": [ + 409 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-already-enabled" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "412": { + "description": "legal hold cannot be approved without being in a pending state (label: `legalhold-not-pending`)", + "schema": { + "example": { + "code": 412, + "label": "legalhold-not-pending", + "message": "legal hold cannot be approved without being in a pending state" + }, + "properties": { + "code": { + "enum": [ + 412 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-not-pending" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "500": { + "description": "legal hold service: could not block connections when resolving policy conflicts. (label: `legalhold-internal`)\n\ninternal server error: inconsistent change of user's legalhold state (label: `legalhold-illegal-op`)", + "schema": { + "example": { + "code": 500, + "label": "legalhold-internal", + "message": "legal hold service: could not block connections when resolving policy conflicts." + }, + "properties": { + "code": { + "enum": [ + 500 + ], + "type": "integer" + }, + "label": { + "enum": [ + "legalhold-internal", + "legalhold-illegal-op" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Approve legal hold device", + "x-wire-makes-federated-call-to": [ + [ + "galley", + "on-conversation-updated" + ], + [ + "galley", + "on-mls-message-sent" + ], + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/teams/{tid}/members": { + "get": { + "description": " [internal route ID: \"get-team-members\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "description": "Maximum results to be returned", + "format": "int32", + "in": "query", + "maximum": 2000, + "minimum": 1, + "name": "maxResults", + "required": false, + "type": "integer" + }, + { + "description": "Optional, when not specified, the first page will be returned.Every returned page contains a `pagingState`, this should be supplied to retrieve the next page.", + "in": "query", + "name": "pagingState", + "required": false, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/TeamMembersPage" + } + }, + "400": { + "description": "Invalid `pagingState` or `maxResults` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get team members" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-team-member\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/NewTeamMember" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "" + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nMaximum number of admins per team reached (label: `too-many-team-admins`)\n\nThe specified permissions are invalid (label: `invalid-permissions`)\n\nYou do not have permission to access this resource (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member", + "too-many-team-admins", + "invalid-permissions", + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team member not found (label: `no-team-member`)\n\nTeam not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member", + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Update an existing team member" + } + }, + "/teams/{tid}/members/csv": { + "get": { + "description": " [internal route ID: \"get-team-members-csv\"]\n\nThe endpoint returns data in chunked transfer encoding. Internal server errors might result in a failed transfer instead of a 500 response.", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "text/csv" + ], + "responses": { + "200": { + "description": "CSV of team members" + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "You do not have permission to access this resource (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "access-denied", + "message": "You do not have permission to access this resource" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "access-denied" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get all members of the team as a CSV file" + } + }, + "/teams/{tid}/members/{uid}": { + "delete": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"delete-team-member\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/TeamMemberDeleteData" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "" + }, + "202": { + "description": "Team member scheduled for deletion" + }, + "400": { + "description": "Invalid `body` or `uid` or `tid`" + }, + "403": { + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)\n\nYou do not have permission to access this resource (label: `access-denied`)\n\nVerification code required (label: `code-authentication-required`)\n\nCode authentication failed (label: `code-authentication-failed`)\n\nThis operation requires reauthentication (label: `access-denied`)", + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member", + "access-denied", + "code-authentication-required", + "code-authentication-failed" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)\n\nTeam member not found (label: `no-team-member`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Remove an existing team member" + }, + "get": { + "description": " [internal route ID: \"get-team-member\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/TeamMember" + } + }, + "400": { + "description": "Invalid `uid` or `tid`" + }, + "403": { + "description": "Requesting user is not a team member (label: `no-team-member`)", + "schema": { + "example": { + "code": 403, + "label": "no-team-member", + "message": "Requesting user is not a team member" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team member not found (label: `no-team-member`)", + "schema": { + "example": { + "code": 404, + "label": "no-team-member", + "message": "Team member not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get single team member" + } + }, + "/teams/{tid}/search": { + "get": { + "description": " [internal route ID: \"browse-team\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "description": "Search expression", + "in": "query", + "name": "q", + "required": false, + "type": "string" + }, + { + "collectionFormat": null, + "description": "Role filter, eg. `member,partner`. Empty list means do not filter.", + "in": "query", + "items": { + "enum": [ + "owner", + "admin", + "member", + "partner" + ], + "type": "string" + }, + "name": "frole", + "required": false, + "type": "array" + }, + { + "description": "Can be one of name, handle, email, saml_idp, managed_by, role, created_at.", + "enum": [ + "name", + "handle", + "email", + "saml_idp", + "managed_by", + "role", + "created_at" + ], + "in": "query", + "name": "sortby", + "required": false, + "type": "string" + }, + { + "description": "Can be one of asc, desc.", + "enum": [ + "asc", + "desc" + ], + "in": "query", + "name": "sortorder", + "required": false, + "type": "string" + }, + { + "description": "Number of results to return (min: 1, max: 500, default: 15)", + "format": "int32", + "in": "query", + "maximum": 500, + "minimum": 1, + "name": "size", + "required": false, + "type": "integer" + }, + { + "description": "Optional, when not specified, the first page will be returned. Every returned page contains a `paging_state`, this should be supplied to retrieve the next page.", + "in": "query", + "name": "pagingState", + "required": false, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Search results", + "schema": { + "$ref": "#/definitions/SearchResult" + } + }, + "400": { + "description": "Invalid `pagingState` or `size` or `sortorder` or `sortby` or `frole` or `q` or `tid`" + } + }, + "summary": "Browse team for members (requires add-user permission)" + } + }, + "/teams/{tid}/search-visibility": { + "get": { + "description": " [internal route ID: \"get-search-visibility\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/TeamSearchVisibilityView" + } + }, + "400": { + "description": "Invalid `tid`" + }, + "403": { + "description": "Insufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)", + "schema": { + "example": { + "code": 403, + "label": "operation-denied", + "message": "Insufficient permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Shows the value for search visibility" + }, + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"set-search-visibility\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/TeamSearchVisibilityView" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "204": { + "description": "Search visibility set" + }, + "400": { + "description": "Invalid `body` or `tid`" + }, + "403": { + "description": "Custom search is not available for this team (label: `team-search-visibility-not-enabled`)\n\nInsufficient permissions (label: `operation-denied`)\n\nRequesting user is not a team member (label: `no-team-member`)", + "schema": { + "example": { + "code": 403, + "label": "team-search-visibility-not-enabled", + "message": "Custom search is not available for this team" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "team-search-visibility-not-enabled", + "operation-denied", + "no-team-member" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + }, + "404": { + "description": "Team not found (label: `no-team`)", + "schema": { + "example": { + "code": 404, + "label": "no-team", + "message": "Team not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "no-team" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Sets the search visibility for the whole team" + } + }, + "/teams/{tid}/size": { + "get": { + "description": " [internal route ID: \"get-team-size\"]\n\n", + "parameters": [ + { + "format": "uuid", + "in": "path", + "name": "tid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Number of team members", + "schema": { + "$ref": "#/definitions/TeamSize" + } + }, + "400": { + "description": "Invalid `tid`\n\nInvalid invitation code. (label: `invalid-invitation-code`)" + } + }, + "summary": "Returns the number of team members as an integer. Can be out of sync by roughly the `refresh_interval` of the ES index." + } + }, + "/users/handles": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"check-user-handles\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/CheckHandles" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "List of free handles", + "schema": { + "items": { + "$ref": "#/definitions/Handle" + }, + "type": "array" + } + }, + "400": { + "description": "Invalid `body`" + } + }, + "summary": "Check availability of user handles" + } + }, + "/users/handles/{handle}": { + "head": { + "description": " [internal route ID: \"check-user-handle\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "handle", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Handle is taken", + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "400": { + "description": "Invalid `handle`\n\nThe given handle is invalid (label: `invalid-handle`)" + }, + "404": { + "description": "Handle not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "Handle not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Check whether a user handle can be taken" + } + }, + "/users/list-clients": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"list-clients-bulk@v2\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/LimitedQualifiedUserIdList" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "properties": { + "qualified_user_map": { + "$ref": "#/definitions/QualifiedUserMap_Set_PubClient" + } + }, + "type": "object" + } + }, + "400": { + "description": "Invalid `body`" + } + }, + "summary": "List all clients for a set of user ids", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "get-user-clients" + ] + ] + } + }, + "/users/list-prekeys": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"get-multi-user-prekey-bundle-qualified\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/QualifiedUserClients" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/QualifiedUserClientPrekeyMapV4" + } + }, + "400": { + "description": "Invalid `body`" + } + }, + "summary": "Given a map of domain to (map of user IDs to client IDs) return a prekey for each one. You can't request information for more users than maximum conversation size.", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "claim-multi-prekey-bundle" + ] + ] + } + }, + "/users/{uid_domain}/{uid}": { + "get": { + "description": " [internal route ID: \"get-user-qualified\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "type": "string" + }, + { + "description": "User Id", + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "User found", + "schema": { + "$ref": "#/definitions/UserProfile" + } + }, + "400": { + "description": "Invalid `uid` or `uid_domain`" + }, + "404": { + "description": "User not found (label: `not-found`)", + "schema": { + "example": { + "code": 404, + "label": "not-found", + "message": "User not found" + }, + "properties": { + "code": { + "enum": [ + 404 + ], + "type": "integer" + }, + "label": { + "enum": [ + "not-found" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get a user by Domain and UserId", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "get-users-by-ids" + ] + ] + } + }, + "/users/{uid_domain}/{uid}/clients": { + "get": { + "description": " [internal route ID: \"get-user-clients-qualified\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "type": "string" + }, + { + "description": "User Id", + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "items": { + "$ref": "#/definitions/PubClient" + }, + "type": "array" + } + }, + "400": { + "description": "Invalid `uid` or `uid_domain`" + } + }, + "summary": "Get all of a user's clients", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "get-user-clients" + ] + ] + } + }, + "/users/{uid_domain}/{uid}/clients/{client}": { + "get": { + "description": " [internal route ID: \"get-user-client-qualified\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "type": "string" + }, + { + "description": "User Id", + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/PubClient" + } + }, + "400": { + "description": "Invalid `client` or `uid` or `uid_domain`" + } + }, + "summary": "Get a specific client of a user", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "get-user-clients" + ] + ] + } + }, + "/users/{uid_domain}/{uid}/prekeys": { + "get": { + "description": " [internal route ID: \"get-users-prekey-bundle-qualified\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "type": "string" + }, + { + "description": "User Id", + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/PrekeyBundle" + } + }, + "400": { + "description": "Invalid `uid` or `uid_domain`" + } + }, + "summary": "Get a prekey for each client of a user.", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "claim-prekey-bundle" + ] + ] + } + }, + "/users/{uid_domain}/{uid}/prekeys/{client}": { + "get": { + "description": " [internal route ID: \"get-users-prekeys-client-qualified\"]\n\n", + "parameters": [ + { + "in": "path", + "name": "uid_domain", + "required": true, + "type": "string" + }, + { + "description": "User Id", + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "description": "ClientId", + "in": "path", + "name": "client", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "$ref": "#/definitions/ClientPrekey" + } + }, + "400": { + "description": "Invalid `client` or `uid` or `uid_domain`" + } + }, + "summary": "Get a prekey for a specific client of a user.", + "x-wire-makes-federated-call-to": [ + [ + "brig", + "claim-prekey" + ] + ] + } + }, + "/users/{uid}/email": { + "put": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"update-user-email\"]\n\nIf the user has a pending email validation, the validation email will be resent.", + "parameters": [ + { + "description": "User Id", + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + }, + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/EmailUpdate" + } + } + ], + "produces": [ + "application/json;charset=utf-8" + ], + "responses": { + "200": { + "description": "", + "schema": { + "example": [], + "items": {}, + "maxItems": 0, + "type": "array" + } + }, + "400": { + "description": "Invalid `body` or `uid`" + } + }, + "summary": "Resend email address validation email." + } + }, + "/users/{uid}/rich-info": { + "get": { + "description": " [internal route ID: \"get-rich-info\"]\n\n", + "parameters": [ + { + "description": "User Id", + "format": "uuid", + "in": "path", + "name": "uid", + "required": true, + "type": "string" + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Rich info about the user", + "schema": { + "$ref": "#/definitions/RichInfoAssocList" + } + }, + "400": { + "description": "Invalid `uid`" + }, + "403": { + "description": "Insufficient team permissions (label: `insufficient-permissions`)", + "schema": { + "example": { + "code": 403, + "label": "insufficient-permissions", + "message": "Insufficient team permissions" + }, + "properties": { + "code": { + "enum": [ + 403 + ], + "type": "integer" + }, + "label": { + "enum": [ + "insufficient-permissions" + ], + "type": "string" + }, + "message": { + "type": "string" + } + }, + "required": [ + "code", + "label", + "message" + ], + "type": "object" + } + } + }, + "summary": "Get a user's rich info" + } + }, + "/verification-code/send": { + "post": { + "consumes": [ + "application/json;charset=utf-8" + ], + "description": " [internal route ID: \"send-verification-code\"]\n\n", + "parameters": [ + { + "in": "body", + "name": "body", + "required": true, + "schema": { + "$ref": "#/definitions/SendVerificationCode" + } + } + ], + "produces": [ + "application/json;charset=utf-8", + "application/json" + ], + "responses": { + "200": { + "description": "Verification code sent." + }, + "400": { + "description": "Invalid `body`" + } + }, + "summary": "Send a verification code to a given email address." + } + } + }, + "security": [ + { + "ZAuth": [] + } + ], + "securityDefinitions": { + "ZAuth": { + "description": "Must be a token retrieved by calling 'POST /login' or 'POST /access'. It must be presented in this format: 'Bearer \\'.", + "in": "header", + "name": "Authorization", + "type": "apiKey" + } + }, + "swagger": "2.0" +} diff --git a/services/brig/schema/Main.hs b/services/brig/schema/Main.hs new file mode 100644 index 00000000000..11d6e144194 --- /dev/null +++ b/services/brig/schema/Main.hs @@ -0,0 +1,5 @@ +import Brig.Schema.Run qualified as Run +import Imports + +main :: IO () +main = Run.main diff --git a/services/brig/schema/src/Main.hs b/services/brig/schema/src/Main.hs deleted file mode 100644 index 70e82a13415..00000000000 --- a/services/brig/schema/src/Main.hs +++ /dev/null @@ -1,117 +0,0 @@ --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Main where - -import Cassandra.Schema -import Control.Exception (finally) -import Imports -import System.Logger.Extended qualified as Log -import Util.Options -import V43 qualified -import V44 qualified -import V45 qualified -import V46 qualified -import V47 qualified -import V48 qualified -import V49 qualified -import V50 qualified -import V51 qualified -import V52 qualified -import V53 qualified -import V54 qualified -import V55 qualified -import V56 qualified -import V57 qualified -import V58 qualified -import V59 qualified -import V60_AddFederationIdMapping qualified -import V61_team_invitation_email qualified -import V62_RemoveFederationIdMapping qualified -import V63_AddUsersPendingActivation qualified -import V64_ClientCapabilities qualified -import V65_FederatedConnections qualified -import V66_PersonalFeatureConfCallInit qualified -import V67_MLSKeyPackages qualified -import V68_AddMLSPublicKeys qualified -import V69_MLSKeyPackageRefMapping qualified -import V70_UserEmailUnvalidated qualified -import V71_AddTableVCodesThrottle qualified -import V72_AddNonceTable qualified -import V73_ReplaceNonceTable qualified -import V74_AddOAuthTables qualified -import V75_AddOAuthCodeChallenge qualified -import V76_AddSupportedProtocols qualified -import V77_FederationRemotes qualified -import V78_ClientLastActive qualified -import V79_ConnectionRemoteIndex qualified - -main :: IO () -main = do - let desc = "Brig Cassandra Schema Migrations" - defaultPath = "/etc/wire/brig/conf/brig-schema.yaml" - o <- getOptions desc (Just migrationOptsParser) defaultPath - l <- Log.mkLogger' - migrateSchema - l - o - [ V43.migration, - V44.migration, - V45.migration, - V46.migration, - V47.migration, - V48.migration, - V49.migration, - V50.migration, - V51.migration, - V52.migration, - V53.migration, - V54.migration, - V55.migration, - V56.migration, - V57.migration, - V58.migration, - V59.migration, - V60_AddFederationIdMapping.migration, - V61_team_invitation_email.migration, - V62_RemoveFederationIdMapping.migration, - V63_AddUsersPendingActivation.migration, - V64_ClientCapabilities.migration, - V65_FederatedConnections.migration, - V66_PersonalFeatureConfCallInit.migration, - V67_MLSKeyPackages.migration, - V68_AddMLSPublicKeys.migration, - V69_MLSKeyPackageRefMapping.migration, - V70_UserEmailUnvalidated.migration, - V71_AddTableVCodesThrottle.migration, - V72_AddNonceTable.migration, - V73_ReplaceNonceTable.migration, - V74_AddOAuthTables.migration, - V75_AddOAuthCodeChallenge.migration, - V76_AddSupportedProtocols.migration, - V77_FederationRemotes.migration, - V78_ClientLastActive.migration, - V79_ConnectionRemoteIndex.migration - -- When adding migrations here, don't forget to update - -- 'schemaVersion' in Brig.App - - -- FUTUREWORK: undo V41 (searchable flag); we stopped using it in - -- https://github.com/wireapp/wire-server/pull/964 - -- - -- FUTUREWORK after July 2023: integrate V_FUTUREWORK here. - ] - `finally` Log.close l diff --git a/services/brig/src/Brig/API.hs b/services/brig/src/Brig/API.hs index 3d4ff3ebb98..ba318c3f2b5 100644 --- a/services/brig/src/Brig/API.hs +++ b/services/brig/src/Brig/API.hs @@ -22,22 +22,10 @@ where import Brig.API.Handler (Handler) import Brig.API.Internal qualified as Internal -import Brig.API.Public qualified as Public -import Brig.Effects.BlacklistStore (BlacklistStore) import Brig.Effects.GalleyProvider (GalleyProvider) -import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) import Network.Wai.Routing (Routes) import Polysemy -import Wire.Sem.Concurrency -sitemap :: - forall r p. - ( Member BlacklistStore r, - Member GalleyProvider r, - Member (Concurrency 'Unsafe) r, - Member (UserPendingActivationStore p) r - ) => - Routes () (Handler r) () +sitemap :: forall r. (Member GalleyProvider r) => Routes () (Handler r) () sitemap = do - Public.sitemap Internal.sitemap diff --git a/services/brig/src/Brig/API/Client.hs b/services/brig/src/Brig/API/Client.hs index 0d91a678f08..a3317223af0 100644 --- a/services/brig/src/Brig/API/Client.hs +++ b/services/brig/src/Brig/API/Client.hs @@ -78,10 +78,10 @@ import Control.Lens (view) import Data.ByteString.Conversion import Data.Code as Code import Data.Domain +import Data.Either.Extra (mapLeft) import Data.IP (IP) import Data.Id (ClientId, ConnId, UserId) import Data.List.Split (chunksOf) -import Data.Map.Strict (traverseWithKey) import Data.Map.Strict qualified as Map import Data.Misc (PlainTextPassword6) import Data.Qualified @@ -133,13 +133,21 @@ lookupPubClientsBulk :: [Qualified UserId] -> ExceptT ClientError (AppT r) (Qual lookupPubClientsBulk qualifiedUids = do loc <- qualifyLocal () let (localUsers, remoteUsers) = partitionQualified loc qualifiedUids - remoteUserClientMap <- - traverseWithKey - (\domain' uids -> getUserClients domain' (GetUserClients uids)) - (indexQualified (fmap tUntagged remoteUsers)) - !>> ClientFederationError + remoteUserClientMap <- lift $ getRemoteClients $ indexQualified (fmap tUntagged remoteUsers) localUserClientMap <- Map.singleton (tDomain loc) <$> lookupLocalPubClientsBulk localUsers pure $ QualifiedUserMap (Map.union localUserClientMap remoteUserClientMap) + where + getRemoteClients :: Map Domain [UserId] -> AppT r (Map Domain (UserMap (Set PubClient))) + getRemoteClients uids = do + results <- + traverse + (\(d, ids) -> mapLeft (const d) . fmap (d,) <$> runExceptT (getUserClients d (GetUserClients ids))) + (Map.toList uids) + forM_ (lefts results) $ \d -> + Log.warn $ + field "remote_domain" (domainText d) + ~~ msg (val "Failed to fetch clients for domain") + pure $ Map.fromList (rights results) lookupLocalPubClientsBulk :: [UserId] -> ExceptT ClientError (AppT r) (UserMap (Set PubClient)) lookupLocalPubClientsBulk = lift . wrapClient . Data.lookupPubClientsBulk diff --git a/services/brig/src/Brig/API/Error.hs b/services/brig/src/Brig/API/Error.hs index cf2489f5f2f..376047ba874 100644 --- a/services/brig/src/Brig/API/Error.hs +++ b/services/brig/src/Brig/API/Error.hs @@ -297,9 +297,6 @@ loginCodePending = Wai.mkError status403 "pending-login" "A login code is still loginCodeNotFound :: Wai.Error loginCodeNotFound = Wai.mkError status404 "no-pending-login" "No login code was found." -newPasswordMustDiffer :: Wai.Error -newPasswordMustDiffer = Wai.mkError status409 "password-must-differ" "For provider password change or reset, new and old password must be different." - notFound :: LText -> Wai.Error notFound = Wai.mkError status404 "not-found" diff --git a/services/brig/src/Brig/API/Federation.hs b/services/brig/src/Brig/API/Federation.hs index bb9ea753595..90ddd22a281 100644 --- a/services/brig/src/Brig/API/Federation.hs +++ b/services/brig/src/Brig/API/Federation.hs @@ -25,6 +25,7 @@ import Brig.API.Error import Brig.API.Handler (Handler) import Brig.API.Internal hiding (getMLSClients) import Brig.API.Internal qualified as Internal +import Brig.API.MLS.CipherSuite import Brig.API.MLS.KeyPackages import Brig.API.MLS.Util import Brig.API.User qualified as API @@ -33,10 +34,12 @@ import Brig.Data.Connection qualified as Data import Brig.Data.User qualified as Data import Brig.Effects.GalleyProvider (GalleyProvider) import Brig.IO.Intra (notify) +import Brig.Options import Brig.Types.User.Event import Brig.User.API.Handle import Brig.User.Search.SearchIndex qualified as Q import Control.Error.Util +import Control.Lens ((^.)) import Control.Monad.Trans.Except import Data.Domain import Data.Handle (Handle (..), parseHandle) @@ -87,26 +90,30 @@ federationSitemap = :<|> Named @"get-user-clients" getUserClients :<|> Named @"get-mls-clients" getMLSClients :<|> Named @"send-connection-action" sendConnectionAction - :<|> Named @"on-user-deleted-connections" onUserDeleted :<|> Named @"claim-key-packages" fedClaimKeyPackages :<|> Named @"get-not-fully-connected-backends" getFederationStatus + :<|> Named @"on-user-deleted-connections" onUserDeleted -- Allow remote domains to send their known remote federation instances, and respond -- with the subset of those we aren't connected to. getFederationStatus :: Domain -> DomainSet -> Handler r NonConnectedBackends getFederationStatus _ request = do - fedDomains <- fromList . fmap (.domain) . (.remotes) <$> getFederationRemotes - pure $ NonConnectedBackends (request.dsDomains \\ fedDomains) + cfg <- ask + case setFederationStrategy (cfg ^. settings) of + Just AllowAll -> pure $ NonConnectedBackends mempty + _ -> do + fedDomains <- fromList . fmap (.domain) . (.remotes) <$> getFederationRemotes + pure $ NonConnectedBackends (request.domains \\ fedDomains) sendConnectionAction :: Domain -> NewConnectionRequest -> Handler r NewConnectionResponse sendConnectionAction originDomain NewConnectionRequest {..} = do - active <- lift $ wrapClient $ Data.isActivated ncrTo + active <- lift $ wrapClient $ Data.isActivated to if active then do - self <- qualifyLocal ncrTo - let other = toRemoteUnsafe originDomain ncrFrom + self <- qualifyLocal to + let other = toRemoteUnsafe originDomain from mconnection <- lift . wrapClient $ Data.lookupConnection self (tUntagged other) - maction <- lift $ performRemoteAction self other mconnection ncrAction + maction <- lift $ performRemoteAction self other mconnection action pure $ NewConnectionResponseOk maction else pure NewConnectionResponseUserNotActivated @@ -160,10 +167,11 @@ fedClaimKeyPackages :: Domain -> ClaimKeyPackageRequest -> Handler r (Maybe KeyP fedClaimKeyPackages domain ckpr = isMLSEnabled >>= \case True -> do - ltarget <- qualifyLocal (ckprTarget ckpr) - let rusr = toRemoteUnsafe domain (ckprClaimant ckpr) + suite <- getCipherSuite (Just ckpr.cipherSuite) + ltarget <- qualifyLocal ckpr.target + let rusr = toRemoteUnsafe domain ckpr.claimant lift . fmap hush . runExceptT $ - claimLocalKeyPackages (tUntagged rusr) Nothing ltarget + claimLocalKeyPackages (tUntagged rusr) Nothing suite ltarget False -> pure Nothing -- | Searching for federated users on a remote backend should @@ -214,12 +222,12 @@ getUserClients _ (GetUserClients uids) = API.lookupLocalPubClientsBulk uids !>> getMLSClients :: Domain -> MLSClientsRequest -> Handler r (Set ClientInfo) getMLSClients _domain mcr = do - Internal.getMLSClients (mcrUserId mcr) (mcrSignatureScheme mcr) + Internal.getMLSClients mcr.userId mcr.cipherSuite onUserDeleted :: Domain -> UserDeletedConnectionsNotification -> (Handler r) EmptyResponse onUserDeleted origDomain udcn = lift $ do - let deletedUser = toRemoteUnsafe origDomain (udcnUser udcn) - connections = udcnConnections udcn + let deletedUser = toRemoteUnsafe origDomain udcn.user + connections = udcn.connections event = pure . UserEvent $ UserDeleted (tUntagged deletedUser) acceptedLocals <- map csv2From diff --git a/services/brig/src/Brig/API/Internal.hs b/services/brig/src/Brig/API/Internal.hs index 321b68c60d6..8081fa302fc 100644 --- a/services/brig/src/Brig/API/Internal.hs +++ b/services/brig/src/Brig/API/Internal.hs @@ -63,9 +63,8 @@ import Brig.User.EJPD qualified import Brig.User.Search.Index qualified as Index import Control.Error hiding (bool) import Control.Lens (view, (^.)) -import Data.Aeson hiding (json) import Data.CommaSeparatedList -import Data.Domain (Domain, domainText) +import Data.Domain (Domain) import Data.Handle import Data.Id as Id import Data.Map.Strict qualified as Map @@ -73,13 +72,11 @@ import Data.Qualified import Data.Set qualified as Set import Data.Time.Clock.System import Imports hiding (head) -import Network.AMQP qualified as Q import Network.Wai.Routing hiding (toList) import Network.Wai.Utilities as Utilities import Polysemy import Servant hiding (Handler, JSON, addHeader, respond) -import Servant.Swagger.Internal.Orphans () -import System.Logger qualified as Lg +import Servant.OpenApi.Internal.Orphans () import System.Logger.Class qualified as Log import System.Random (randomRIO) import UnliftIO.Async @@ -87,13 +84,9 @@ import Wire.API.Connection import Wire.API.Error import Wire.API.Error.Brig qualified as E import Wire.API.Federation.API -import Wire.API.Federation.BackendNotifications import Wire.API.Federation.Error (FederationError (..)) -import Wire.API.MLS.Credential -import Wire.API.MLS.KeyPackage -import Wire.API.MLS.Serialisation +import Wire.API.MLS.CipherSuite import Wire.API.Routes.FederationDomainConfig -import Wire.API.Routes.Internal.Brig import Wire.API.Routes.Internal.Brig qualified as BrigIRoutes import Wire.API.Routes.Internal.Brig.Connection import Wire.API.Routes.Named @@ -145,18 +138,7 @@ ejpdAPI = :<|> getConnectionsStatus mlsAPI :: ServerT BrigIRoutes.MLSAPI (Handler r) -mlsAPI = - ( \ref -> - Named @"get-client-by-key-package-ref" (getClientByKeyPackageRef ref) - :<|> ( Named @"put-conversation-by-key-package-ref" (putConvIdByKeyPackageRef ref) - :<|> Named @"get-conversation-by-key-package-ref" (getConvIdByKeyPackageRef ref) - ) - :<|> Named @"put-key-package-ref" (putKeyPackageRef ref) - :<|> Named @"post-key-package-ref" (postKeyPackageRef ref) - ) - :<|> getMLSClients - :<|> mapKeyPackageRefsInternal - :<|> Named @"put-key-package-add" upsertKeyPackage +mlsAPI = getMLSClients accountAPI :: ( Member BlacklistStore r, @@ -201,8 +183,20 @@ accountAPI = :<|> Named @"iLegalholdAddClient" legalHoldClientRequestedH :<|> Named @"iLegalholdDeleteClient" removeLegalHoldClientH -teamsAPI :: ServerT BrigIRoutes.TeamsAPI (Handler r) -teamsAPI = Named @"updateSearchVisibilityInbound" Index.updateSearchVisibilityInbound +teamsAPI :: + ( Member GalleyProvider r, + Member (UserPendingActivationStore p) r, + Member BlacklistStore r + ) => + ServerT BrigIRoutes.TeamsAPI (Handler r) +teamsAPI = + Named @"updateSearchVisibilityInbound" Index.updateSearchVisibilityInbound + :<|> Named @"get-invitation-by-email" Team.getInvitationByEmail + :<|> Named @"get-invitation-code" Team.getInvitationCode + :<|> Named @"suspend-team" Team.suspendTeam + :<|> Named @"unsuspend-team" Team.unsuspendTeam + :<|> Named @"team-size" Team.teamSize + :<|> Named @"create-invitations-via-scim" Team.createInvitationViaScim userAPI :: ServerT BrigIRoutes.UserAPI (Handler r) userAPI = @@ -225,8 +219,6 @@ federationRemotesAPI = Named @"add-federation-remotes" addFederationRemote :<|> Named @"get-federation-remotes" getFederationRemotes :<|> Named @"update-federation-remotes" updateFederationRemote - :<|> Named @"delete-federation-remotes" deleteFederationRemote - :<|> Named @"delete-federation-remote-from-galley" deleteFederationRemoteGalley addFederationRemote :: FederationDomainConfig -> ExceptT Brig.API.Error.Error (AppT r) () addFederationRemote fedDomConf = do @@ -332,47 +324,6 @@ assertNoDomainsFromConfigFiles dom = do "keeping track of remote domains in the brig config file is deprecated, but as long as we \ \do that, removing or updating items listed in the config file is not allowed." --- | Remove the entry from the database if present (or do nothing if not). This responds with --- 533 if the entry was also present in the config file, but only *after* it has removed the --- entry from cassandra. --- --- The ordering on this delete then check seems weird, but allows us to default all the --- way back to config file state for a federation domain. -deleteFederationRemote :: Domain -> ExceptT Brig.API.Error.Error (AppT r) () -deleteFederationRemote dom = do - lift . wrapClient . Data.deleteFederationRemote $ dom - assertNoDomainsFromConfigFiles dom - env <- ask - for_ (env ^. rabbitmqChannel) $ \chan -> liftIO . withMVar chan $ \chan' -> do - -- ensureQueue uses routingKey internally - ensureQueue chan' defederationQueue - void $ - Q.publishMsg chan' "" queue $ - Q.newMsg - { -- Check that this message type is compatible with what - -- background worker is expecting - Q.msgBody = encode @DefederationDomain dom, - Q.msgDeliveryMode = pure Q.Persistent, - Q.msgContentType = pure "application/json" - } - -- Drop the notification queue for the domain. - -- This will also drop all of the messages in the queue - -- as we will no longer be able to communicate with this - -- domain. - num <- Q.deleteQueue chan' . routingKey $ domainText dom - Lg.info (env ^. applog) $ Log.msg @String "Dropped Notifications" . Log.field "domain" (domainText dom) . Log.field "count" (show num) - where - -- Ensure that this is kept in sync with background worker - queue = routingKey defederationQueue - --- | Remove one-on-one conversations for the given remote domain. This is called from Galley as --- part of the defederation process, and should not be called during the initial domain removal --- call to brig. This is so we can ensure that domains are correctly cleaned up if a service --- falls over for whatever reason. -deleteFederationRemoteGalley :: Domain -> ExceptT Brig.API.Error.Error (AppT r) () -deleteFederationRemoteGalley dom = do - lift . wrapClient . Data.deleteRemoteConnectionsDomain $ dom - -- | Responds with 'Nothing' if field is NULL in existing user or user does not exist. getAccountConferenceCallingConfig :: UserId -> (Handler r) (ApiFt.WithStatusNoLock ApiFt.ConferenceCallingConfig) getAccountConferenceCallingConfig uid = @@ -387,64 +338,12 @@ deleteAccountConferenceCallingConfig :: UserId -> (Handler r) NoContent deleteAccountConferenceCallingConfig uid = lift $ wrapClient $ Data.updateFeatureConferenceCalling uid Nothing $> NoContent -getClientByKeyPackageRef :: KeyPackageRef -> Handler r (Maybe ClientIdentity) -getClientByKeyPackageRef = runMaybeT . mapMaybeT wrapClientE . Data.derefKeyPackage - --- Used by galley to update conversation id in mls_key_package_ref -putConvIdByKeyPackageRef :: KeyPackageRef -> Qualified ConvId -> Handler r Bool -putConvIdByKeyPackageRef ref = lift . wrapClient . Data.keyPackageRefSetConvId ref - --- Used by galley to create a new record in mls_key_package_ref -putKeyPackageRef :: KeyPackageRef -> NewKeyPackageRef -> Handler r () -putKeyPackageRef ref = lift . wrapClient . Data.addKeyPackageRef ref - --- Used by galley to retrieve conversation id from mls_key_package_ref -getConvIdByKeyPackageRef :: KeyPackageRef -> Handler r (Maybe (Qualified ConvId)) -getConvIdByKeyPackageRef = runMaybeT . mapMaybeT wrapClientE . Data.keyPackageRefConvId - --- Used by galley to update key packages in mls_key_package_ref on commits with update_path -postKeyPackageRef :: KeyPackageRef -> KeyPackageRef -> Handler r () -postKeyPackageRef ref = lift . wrapClient . Data.updateKeyPackageRef ref - --- Used by galley to update key package refs and also validate -upsertKeyPackage :: NewKeyPackage -> Handler r NewKeyPackageResult -upsertKeyPackage nkp = do - kp <- - either - (const $ mlsProtocolError "upsertKeyPackage: Cannot decocode KeyPackage") - pure - $ decodeMLS' @(RawMLS KeyPackage) (kpData . nkpKeyPackage $ nkp) - ref <- kpRef' kp & noteH "upsertKeyPackage: Unsupported CipherSuite" - - identity <- - either - (const $ mlsProtocolError "upsertKeyPackage: Cannot decode ClientIdentity") - pure - $ kpIdentity (rmValue kp) - mp <- lift . wrapClient . runMaybeT $ Data.derefKeyPackage ref - when (isNothing mp) $ do - void $ validateKeyPackage identity kp - lift . wrapClient $ - Data.addKeyPackageRef - ref - ( NewKeyPackageRef - (fst <$> cidQualifiedClient identity) - (ciClient identity) - (nkpConversation nkp) - ) - - pure $ NewKeyPackageResult identity ref - where - noteH :: Text -> Maybe a -> Handler r a - noteH errMsg Nothing = mlsProtocolError errMsg - noteH _ (Just y) = pure y - -getMLSClients :: UserId -> SignatureSchemeTag -> Handler r (Set ClientInfo) -getMLSClients usr _ss = do - -- FUTUREWORK: check existence of key packages with a given ciphersuite +getMLSClients :: UserId -> CipherSuite -> Handler r (Set ClientInfo) +getMLSClients usr suite = do lusr <- qualifyLocal usr + suiteTag <- maybe (mlsProtocolError "Unknown ciphersuite") pure (cipherSuiteTag suite) allClients <- lift (wrapClient (API.lookupUsersClientIds (pure usr))) >>= getResult - clientInfo <- lift . wrapClient $ pooledMapConcurrentlyN 16 (getValidity lusr) (toList allClients) + clientInfo <- lift . wrapClient $ pooledMapConcurrentlyN 16 (\c -> getValidity lusr c suiteTag) (toList allClients) pure . Set.fromList . map (uncurry ClientInfo) $ clientInfo where getResult [] = pure mempty @@ -452,15 +351,9 @@ getMLSClients usr _ss = do | u == usr = pure cs' | otherwise = getResult rs - getValidity lusr cid = + getValidity lusr cid suiteTag = (cid,) . (> 0) - <$> Data.countKeyPackages lusr cid - -mapKeyPackageRefsInternal :: KeyPackageBundle -> Handler r () -mapKeyPackageRefsInternal bundle = do - wrapClientE $ - for_ (kpbEntries bundle) $ \e -> - Data.mapKeyPackageRef (kpbeRef e) (kpbeUser e) (kpbeClient e) + <$> Data.countKeyPackages lusr cid suiteTag getVerificationCode :: UserId -> VerificationAction -> Handler r (Maybe Code.Value) getVerificationCode uid action = do @@ -483,14 +376,11 @@ internalSearchIndexAPI = -- Sitemap (wai-route) sitemap :: - ( Member BlacklistStore r, - Member GalleyProvider r, - Member (UserPendingActivationStore p) r + ( Member GalleyProvider r ) => Routes a (Handler r) () sitemap = unsafeCallsFed @'Brig @"on-user-deleted-connections" $ do Provider.routesInternal - Team.routesInternal --------------------------------------------------------------------------- -- Handlers diff --git a/services/brig/src/Brig/API/MLS/CipherSuite.hs b/services/brig/src/Brig/API/MLS/CipherSuite.hs new file mode 100644 index 00000000000..ec6b9756787 --- /dev/null +++ b/services/brig/src/Brig/API/MLS/CipherSuite.hs @@ -0,0 +1,29 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Brig.API.MLS.CipherSuite (getCipherSuite) where + +import Brig.API.Handler +import Brig.API.MLS.KeyPackages.Validation +import Imports +import Wire.API.MLS.CipherSuite + +getCipherSuite :: Maybe CipherSuite -> Handler r CipherSuiteTag +getCipherSuite mSuite = case mSuite of + Nothing -> pure defCipherSuite + Just x -> + maybe (mlsProtocolError "Unknown ciphersuite") pure (cipherSuiteTag x) diff --git a/services/brig/src/Brig/API/MLS/KeyPackages.hs b/services/brig/src/Brig/API/MLS/KeyPackages.hs index 1b99d97975b..4a3c244b356 100644 --- a/services/brig/src/Brig/API/MLS/KeyPackages.hs +++ b/services/brig/src/Brig/API/MLS/KeyPackages.hs @@ -20,11 +20,13 @@ module Brig.API.MLS.KeyPackages claimKeyPackages, claimLocalKeyPackages, countKeyPackages, + deleteKeyPackages, ) where import Brig.API.Error import Brig.API.Handler +import Brig.API.MLS.CipherSuite import Brig.API.MLS.KeyPackages.Validation import Brig.API.MLS.Util import Brig.API.Types @@ -41,6 +43,7 @@ import Data.Set qualified as Set import Imports import Wire.API.Federation.API import Wire.API.Federation.API.Brig +import Wire.API.MLS.CipherSuite import Wire.API.MLS.Credential import Wire.API.MLS.KeyPackage import Wire.API.MLS.Serialisation @@ -48,31 +51,34 @@ import Wire.API.Team.LegalHold import Wire.API.User.Client uploadKeyPackages :: Local UserId -> ClientId -> KeyPackageUpload -> Handler r () -uploadKeyPackages lusr cid (kpuKeyPackages -> kps) = do +uploadKeyPackages lusr cid kps = do assertMLSEnabled let identity = mkClientIdentity (tUntagged lusr) cid - kps' <- traverse (validateKeyPackage identity) kps + kps' <- traverse (validateUploadedKeyPackage identity) kps.keyPackages lift . wrapClient $ Data.insertKeyPackages (tUnqualified lusr) cid kps' claimKeyPackages :: Local UserId -> - Qualified UserId -> Maybe ClientId -> + Qualified UserId -> + Maybe CipherSuite -> Handler r KeyPackageBundle -claimKeyPackages lusr target skipOwn = do +claimKeyPackages lusr mClient target mSuite = do assertMLSEnabled + suite <- getCipherSuite mSuite foldQualified lusr - (withExceptT clientError . claimLocalKeyPackages (tUntagged lusr) skipOwn) - (claimRemoteKeyPackages lusr) + (withExceptT clientError . claimLocalKeyPackages (tUntagged lusr) mClient suite) + (claimRemoteKeyPackages lusr (tagCipherSuite suite)) target claimLocalKeyPackages :: Qualified UserId -> Maybe ClientId -> + CipherSuiteTag -> Local UserId -> ExceptT ClientError (AppT r) KeyPackageBundle -claimLocalKeyPackages qusr skipOwn target = do +claimLocalKeyPackages qusr skipOwn suite target = do -- skip own client when the target is the requesting user itself let own = guard (qusr == tUntagged target) *> skipOwn clients <- map clientId <$> wrapClientE (Data.lookupClients (tUnqualified target)) @@ -93,13 +99,14 @@ claimLocalKeyPackages qusr skipOwn target = do runMaybeT $ do guard $ Just c /= own uncurry (KeyPackageBundleEntry (tUntagged target) c) - <$> wrapClientM (Data.claimKeyPackage target c) + <$> wrapClientM (Data.claimKeyPackage target c suite) claimRemoteKeyPackages :: Local UserId -> + CipherSuite -> Remote UserId -> Handler r KeyPackageBundle -claimRemoteKeyPackages lusr target = do +claimRemoteKeyPackages lusr suite target = do bundle <- withExceptT clientError . (handleFailure =<<) @@ -107,35 +114,46 @@ claimRemoteKeyPackages lusr target = do $ runBrigFederatorClient (tDomain target) $ fedClient @'Brig @"claim-key-packages" $ ClaimKeyPackageRequest - { ckprClaimant = tUnqualified lusr, - ckprTarget = tUnqualified target + { claimant = tUnqualified lusr, + target = tUnqualified target, + cipherSuite = suite } - -- validate and set up mappings for all claimed key packages - for_ (kpbEntries bundle) $ \e -> do - let cid = mkClientIdentity (kpbeUser e) (kpbeClient e) + -- validate all claimed key packages + for_ bundle.entries $ \e -> do + let cid = mkClientIdentity e.user e.client kpRaw <- withExceptT (const . clientDataError $ KeyPackageDecodingError) . except . decodeMLS' . kpData - . kpbeKeyPackage - $ e - (refVal, _) <- validateKeyPackage cid kpRaw - unless (refVal == kpbeRef e) + $ e.keyPackage + (refVal, _, _) <- validateUploadedKeyPackage cid kpRaw + unless (refVal == e.ref) . throwE . clientDataError $ InvalidKeyPackageRef - wrapClientE $ Data.mapKeyPackageRef (kpbeRef e) (kpbeUser e) (kpbeClient e) pure bundle where handleFailure :: Monad m => Maybe x -> ExceptT ClientError m x handleFailure = maybe (throwE (ClientUserNotFound (tUnqualified target))) pure -countKeyPackages :: Local UserId -> ClientId -> Handler r KeyPackageCount -countKeyPackages lusr c = do +countKeyPackages :: Local UserId -> ClientId -> Maybe CipherSuite -> Handler r KeyPackageCount +countKeyPackages lusr c mSuite = do assertMLSEnabled + suite <- getCipherSuite mSuite lift $ KeyPackageCount . fromIntegral - <$> wrapClient (Data.countKeyPackages lusr c) + <$> wrapClient (Data.countKeyPackages lusr c suite) + +deleteKeyPackages :: + Local UserId -> + ClientId -> + Maybe CipherSuite -> + DeleteKeyPackages -> + Handler r () +deleteKeyPackages lusr c mSuite (unDeleteKeyPackages -> refs) = do + assertMLSEnabled + suite <- getCipherSuite mSuite + lift $ wrapClient (Data.deleteKeyPackages (tUnqualified lusr) c suite refs) diff --git a/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs b/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs index bdb7d63dd94..0783663e807 100644 --- a/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs +++ b/services/brig/src/Brig/API/MLS/KeyPackages/Validation.hs @@ -17,13 +17,9 @@ module Brig.API.MLS.KeyPackages.Validation ( -- * Main key package validation function - validateKeyPackage, - reLifetime, - mlsProtocolError, - - -- * Exported for unit tests - findExtensions, + validateUploadedKeyPackage, validateLifetime', + mlsProtocolError, ) where @@ -33,8 +29,8 @@ import Brig.App import Brig.Data.Client qualified as Data import Brig.Options import Control.Applicative -import Control.Lens (view) -import Data.ByteString.Lazy qualified as LBS +import Control.Lens +import Data.ByteString qualified as LBS import Data.Qualified import Data.Time.Clock import Data.Time.Clock.POSIX @@ -43,109 +39,45 @@ import Wire.API.Error import Wire.API.Error.Brig import Wire.API.MLS.CipherSuite import Wire.API.MLS.Credential -import Wire.API.MLS.Extension import Wire.API.MLS.KeyPackage +import Wire.API.MLS.Lifetime import Wire.API.MLS.Serialisation +import Wire.API.MLS.Validation -validateKeyPackage :: +validateUploadedKeyPackage :: ClientIdentity -> RawMLS KeyPackage -> - Handler r (KeyPackageRef, KeyPackageData) -validateKeyPackage identity (RawMLS (KeyPackageData -> kpd) kp) = do - loc <- qualifyLocal () - -- get ciphersuite - cs <- - maybe - (mlsProtocolError "Unsupported ciphersuite") - pure - $ cipherSuiteTag (kpCipherSuite kp) + Handler r (KeyPackageRef, CipherSuiteTag, KeyPackageData) +validateUploadedKeyPackage identity kp = do + (cs, lt) <- either mlsProtocolError pure $ validateKeyPackage (Just identity) kp.value - -- validate signature scheme - let ss = csSignatureScheme cs - when (signatureScheme ss /= bcSignatureScheme (kpCredential kp)) $ - mlsProtocolError "Signature scheme incompatible with ciphersuite" + validateLifetime lt -- Authenticate signature key. This is performed only upon uploading a key -- package for a local client. + loc <- qualifyLocal () foldQualified loc ( \_ -> do - key <- - fmap LBS.toStrict $ - maybe - (mlsProtocolError "No key associated to the given identity and signature scheme") - pure - =<< lift (wrapClient (Data.lookupMLSPublicKey (ciUser identity) (ciClient identity) ss)) - when (key /= bcSignatureKey (kpCredential kp)) $ + mkey :: Maybe LByteString <- + lift . wrapClient $ + Data.lookupMLSPublicKey + (ciUser identity) + (ciClient identity) + (csSignatureScheme cs) + key :: LByteString <- + maybe + (mlsProtocolError "No key associated to the given identity and signature scheme") + pure + mkey + when (key /= LBS.fromStrict kp.value.leafNode.signatureKey) $ mlsProtocolError "Unrecognised signature key" ) - (pure . const ()) + (\_ -> pure ()) (cidQualifiedClient identity) - -- validate signature - unless - ( csVerifySignature - cs - (bcSignatureKey (kpCredential kp)) - (rmRaw (kpTBS kp)) - (kpSignature kp) - ) - $ mlsProtocolError "Invalid signature" - -- validate protocol version - maybe - (mlsProtocolError "Unsupported protocol version") - pure - (pvTag (kpProtocolVersion kp) >>= guard . (== ProtocolMLS10)) - -- validate credential - validateCredential identity (kpCredential kp) - -- validate extensions - validateExtensions (kpExtensions kp) - pure (kpRef cs kpd, kpd) - -validateCredential :: ClientIdentity -> Credential -> Handler r () -validateCredential identity cred = do - identity' <- - either credentialError pure $ - decodeMLS' (bcIdentity cred) - when (identity /= identity') $ - throwStd (errorToWai @'MLSIdentityMismatch) - where - credentialError e = - mlsProtocolError $ - "Failed to parse identity: " <> e - -data RequiredExtensions f = RequiredExtensions - { reLifetime :: f Lifetime, - reCapabilities :: f () - } - -deriving instance (Show (f Lifetime), Show (f ())) => Show (RequiredExtensions f) - -instance Alternative f => Semigroup (RequiredExtensions f) where - RequiredExtensions lt1 cap1 <> RequiredExtensions lt2 cap2 = - RequiredExtensions (lt1 <|> lt2) (cap1 <|> cap2) - -instance Alternative f => Monoid (RequiredExtensions f) where - mempty = RequiredExtensions empty empty - -checkRequiredExtensions :: RequiredExtensions Maybe -> Either Text (RequiredExtensions Identity) -checkRequiredExtensions re = - RequiredExtensions - <$> maybe (Left "Missing lifetime extension") (pure . Identity) (reLifetime re) - <*> maybe (Left "Missing capability extension") (pure . Identity) (reCapabilities re) - -findExtensions :: [Extension] -> Either Text (RequiredExtensions Identity) -findExtensions = checkRequiredExtensions <=< (getAp . foldMap findExtension) - -findExtension :: Extension -> Ap (Either Text) (RequiredExtensions Maybe) -findExtension ext = (Ap (decodeExtension ext) >>=) . foldMap $ \case - (SomeExtension SLifetimeExtensionTag lt) -> pure $ RequiredExtensions (Just lt) Nothing - (SomeExtension SCapabilitiesExtensionTag _) -> pure $ RequiredExtensions Nothing (Just ()) - -validateExtensions :: [Extension] -> Handler r () -validateExtensions exts = do - re <- either mlsProtocolError pure $ findExtensions exts - validateLifetime . runIdentity . reLifetime $ re + let kpd = KeyPackageData kp.raw + pure (kpRef cs kpd, cs, kpd) validateLifetime :: Lifetime -> Handler r () validateLifetime lt = do diff --git a/services/brig/src/Brig/API/OAuth.hs b/services/brig/src/Brig/API/OAuth.hs index c7ff94be6ab..b204fadd065 100644 --- a/services/brig/src/Brig/API/OAuth.hs +++ b/services/brig/src/Brig/API/OAuth.hs @@ -48,7 +48,7 @@ import Wire.API.Error import Wire.API.OAuth as OAuth import Wire.API.Password (Password, mkSafePassword) import Wire.API.Routes.Internal.Brig.OAuth qualified as I -import Wire.API.Routes.Named (Named (..)) +import Wire.API.Routes.Named (UntypedNamed (Named)) import Wire.API.Routes.Public.Brig.OAuth import Wire.Sem.Jwk import Wire.Sem.Jwk qualified as Jwk @@ -313,7 +313,7 @@ updateOAuthClient' :: (MonadClient m) => OAuthClientId -> OAuthApplicationName - updateOAuthClient' cid name uri = retry x5 . write q $ params LocalQuorum (name, uri, cid) where q :: PrepQuery W (OAuthApplicationName, RedirectUrl, OAuthClientId) () - q = "UPDATE oauth_client SET name = ?, redirect_uri = ? WHERE id = ?" + q = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE oauth_client SET name = ?, redirect_uri = ? WHERE id = ?" insertOAuthClient :: (MonadClient m) => OAuthClientId -> OAuthApplicationName -> RedirectUrl -> Password -> m () insertOAuthClient cid name uri pw = retry x5 . write q $ params LocalQuorum (cid, name, uri, pw) diff --git a/services/brig/src/Brig/API/Public.hs b/services/brig/src/Brig/API/Public.hs index 188db1b0ff7..2ce4307aecc 100644 --- a/services/brig/src/Brig/API/Public.hs +++ b/services/brig/src/Brig/API/Public.hs @@ -19,8 +19,7 @@ -- with this program. If not, see . module Brig.API.Public - ( sitemap, - servantSitemap, + ( servantSitemap, docsAPI, DocsAPI, ) @@ -56,7 +55,7 @@ import Brig.Effects.PasswordResetStore (PasswordResetStore) import Brig.Effects.PublicKeyBundle (PublicKeyBundle) import Brig.Effects.UserPendingActivationStore (UserPendingActivationStore) import Brig.Options hiding (internalEvents, sesQueue) -import Brig.Provider.API qualified as Provider +import Brig.Provider.API import Brig.Team.API qualified as Team import Brig.Team.Email qualified as Team import Brig.Types.Activation (ActivationPair) @@ -87,9 +86,10 @@ import Data.List.NonEmpty (nonEmpty) import Data.Map.Strict qualified as Map import Data.Misc (IpAddr (..)) import Data.Nonce (Nonce, randomNonce) +import Data.OpenApi qualified as S import Data.Qualified import Data.Range -import Data.Swagger qualified as S +import Data.Schema () import Data.Text qualified as Text import Data.Text.Ascii qualified as Ascii import Data.Text.Lazy (pack) @@ -98,12 +98,11 @@ import FileEmbedLzma import Galley.Types.Teams (HiddenPerm (..), hasPermission) import Imports hiding (head) import Network.Socket (PortNumber) -import Network.Wai.Routing import Network.Wai.Utilities as Utilities import Polysemy import Servant hiding (Handler, JSON, addHeader, respond) import Servant qualified -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Servant.Swagger.UI import System.Logger.Class qualified as Log import Util.Logging (logFunction, logHandle, logTeam, logUser) @@ -113,21 +112,22 @@ import Wire.API.Error.Brig qualified as E import Wire.API.Federation.API import Wire.API.Federation.Error import Wire.API.Properties qualified as Public +import Wire.API.Routes.API import Wire.API.Routes.Internal.Brig qualified as BrigInternalAPI import Wire.API.Routes.Internal.Cannon qualified as CannonInternalAPI import Wire.API.Routes.Internal.Cargohold qualified as CargoholdInternalAPI import Wire.API.Routes.Internal.Galley qualified as GalleyInternalAPI import Wire.API.Routes.Internal.Spar qualified as SparInternalAPI import Wire.API.Routes.MultiTablePaging qualified as Public -import Wire.API.Routes.Named (Named (Named)) +import Wire.API.Routes.Named (UntypedNamed (Named)) import Wire.API.Routes.Public.Brig -import Wire.API.Routes.Public.Brig.OAuth qualified as OAuth -import Wire.API.Routes.Public.Cannon qualified as CannonAPI -import Wire.API.Routes.Public.Cargohold qualified as CargoholdAPI -import Wire.API.Routes.Public.Galley qualified as GalleyAPI -import Wire.API.Routes.Public.Gundeck qualified as GundeckAPI -import Wire.API.Routes.Public.Proxy qualified as ProxyAPI -import Wire.API.Routes.Public.Spar qualified as SparAPI +import Wire.API.Routes.Public.Brig.OAuth +import Wire.API.Routes.Public.Cannon +import Wire.API.Routes.Public.Cargohold +import Wire.API.Routes.Public.Galley +import Wire.API.Routes.Public.Gundeck +import Wire.API.Routes.Public.Proxy +import Wire.API.Routes.Public.Spar import Wire.API.Routes.Public.Util qualified as Public import Wire.API.Routes.Version import Wire.API.SwaggerHelper (cleanupSwagger) @@ -166,25 +166,27 @@ docsAPI = -- -- Dual to `internalEndpointsSwaggerDocsAPI`. versionedSwaggerDocsAPI :: Servant.Server VersionedSwaggerDocsAPI -versionedSwaggerDocsAPI (Just (VersionNumber V4)) = +versionedSwaggerDocsAPI (Just (VersionNumber V5)) = swaggerSchemaUIServer $ - ( brigSwagger - <> versionSwagger - <> GalleyAPI.swaggerDoc - <> SparAPI.swaggerDoc - <> CargoholdAPI.swaggerDoc - <> CannonAPI.swaggerDoc - <> GundeckAPI.swaggerDoc - <> ProxyAPI.swaggerDoc - <> OAuth.swaggerDoc + ( serviceSwagger @VersionAPITag @'V5 + <> serviceSwagger @BrigAPITag @'V5 + <> serviceSwagger @GalleyAPITag @'V5 + <> serviceSwagger @SparAPITag @'V5 + <> serviceSwagger @CargoholdAPITag @'V5 + <> serviceSwagger @CannonAPITag @'V5 + <> serviceSwagger @GundeckAPITag @'V5 + <> serviceSwagger @ProxyAPITag @'V5 + <> serviceSwagger @OAuthAPITag @'V5 ) & S.info . S.title .~ "Wire-Server API" & S.info . S.description ?~ $(embedText =<< makeRelativeToProject "docs/swagger.md") + & S.servers .~ [S.Server ("/" <> toUrlPiece V5) Nothing mempty] & cleanupSwagger versionedSwaggerDocsAPI (Just (VersionNumber V0)) = swaggerPregenUIServer $(pregenSwagger V0) versionedSwaggerDocsAPI (Just (VersionNumber V1)) = swaggerPregenUIServer $(pregenSwagger V1) versionedSwaggerDocsAPI (Just (VersionNumber V2)) = swaggerPregenUIServer $(pregenSwagger V2) versionedSwaggerDocsAPI (Just (VersionNumber V3)) = swaggerPregenUIServer $(pregenSwagger V3) +versionedSwaggerDocsAPI (Just (VersionNumber V4)) = swaggerPregenUIServer $(pregenSwagger V4) versionedSwaggerDocsAPI Nothing = allroutes (throwError listAllVersionsResp) where allroutes :: @@ -216,8 +218,13 @@ versionedSwaggerDocsAPI Nothing = allroutes (throwError listAllVersionsResp) internalEndpointsSwaggerDocsAPI :: String -> PortNumber -> - S.Swagger -> + S.OpenApi -> Servant.Server (VersionedSwaggerDocsAPIBase service) +internalEndpointsSwaggerDocsAPI service examplePort swagger (Just (VersionNumber V5)) = + swaggerSchemaUIServer $ + swagger + & adjustSwaggerForInternalEndpoint service examplePort + & cleanupSwagger internalEndpointsSwaggerDocsAPI service examplePort swagger (Just (VersionNumber V4)) = swaggerSchemaUIServer $ swagger @@ -262,6 +269,9 @@ servantSitemap = :<|> Team.servantAPI :<|> systemSettingsAPI :<|> oauthAPI + :<|> botAPI + :<|> servicesAPI + :<|> providerAPI where userAPI :: ServerT UserAPI (Handler r) userAPI = @@ -363,8 +373,9 @@ servantSitemap = mlsAPI :: ServerT MLSAPI (Handler r) mlsAPI = Named @"mls-key-packages-upload" uploadKeyPackages - :<|> Named @"mls-key-packages-claim" (callsFed (exposeAnnotations claimKeyPackages)) + :<|> Named @"mls-key-packages-claim" claimKeyPackages :<|> Named @"mls-key-packages-count" countKeyPackages + :<|> Named @"mls-key-packages-delete" deleteKeyPackages userHandleAPI :: ServerT UserHandleAPI (Handler r) userHandleAPI = @@ -402,14 +413,6 @@ servantSitemap = -- - UserDeleted event to contacts of the user -- - MemberLeave event to members for all conversations the user was in (via galley) -sitemap :: - ( Member (Concurrency 'Unsafe) r, - Member GalleyProvider r - ) => - Routes () (Handler r) () -sitemap = do - Provider.routesPublic - --------------------------------------------------------------------------- -- Handlers diff --git a/services/brig/src/Brig/API/Public/Swagger.hs b/services/brig/src/Brig/API/Public/Swagger.hs index 0f81a74a4d4..e17607cf8f7 100644 --- a/services/brig/src/Brig/API/Public/Swagger.hs +++ b/services/brig/src/Brig/API/Public/Swagger.hs @@ -18,8 +18,8 @@ import Data.Aeson qualified as A import Data.FileEmbed import Data.HashMap.Strict.InsOrd qualified as HM import Data.HashSet.InsOrd qualified as InsOrdSet -import Data.Swagger qualified as S -import Data.Swagger.Declare qualified as S +import Data.OpenApi qualified as S +import Data.OpenApi.Declare qualified as S import Data.Text qualified as T import FileEmbedLzma import GHC.TypeLits @@ -27,7 +27,7 @@ import Imports hiding (head) import Language.Haskell.TH import Network.Socket import Servant -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Servant.Swagger.UI import Wire.API.Event.Conversation qualified import Wire.API.Event.FeatureConfig qualified @@ -68,16 +68,14 @@ swaggerPregenUIServer = . fromMaybe A.Null . A.decode -adjustSwaggerForInternalEndpoint :: String -> PortNumber -> S.Swagger -> S.Swagger +adjustSwaggerForInternalEndpoint :: String -> PortNumber -> S.OpenApi -> S.OpenApi adjustSwaggerForInternalEndpoint service examplePort swagger = swagger & S.info . S.title .~ T.pack ("Wire-Server internal API (" ++ service ++ ")") & S.info . S.description ?~ renderedDescription - & S.host ?~ S.Host "localhost" (Just examplePort) & S.allOperations . S.tags <>~ tag -- Enforce HTTP as the services themselves don't understand HTTPS - & S.schemes ?~ [S.Http] - & S.allOperations . S.schemes ?~ [S.Http] + & S.servers .~ [S.Server ("http://localhost:" <> T.pack (show examplePort)) Nothing mempty] where tag :: InsOrdSet.InsOrdHashSet S.TagName tag = InsOrdSet.singleton @S.TagName (T.pack service) @@ -102,7 +100,7 @@ adjustSwaggerForInternalEndpoint service examplePort swagger = emptySwagger :: Servant.Server (ServiceSwaggerDocsAPIBase a) emptySwagger = swaggerSchemaUIServer $ - mempty @S.Swagger + mempty @S.OpenApi & S.info . S.description ?~ "There is no Swagger documentation for this version. Please refer to v3 or later." diff --git a/services/brig/src/Brig/App.hs b/services/brig/src/Brig/App.hs index b5bc4eeed1f..f084f528a89 100644 --- a/services/brig/src/Brig/App.hs +++ b/services/brig/src/Brig/App.hs @@ -99,6 +99,7 @@ import Brig.Provider.Template import Brig.Queue.Stomp qualified as Stomp import Brig.Queue.Types (Queue (..)) import Brig.SMTP qualified as SMTP +import Brig.Schema.Run qualified as Migrations import Brig.Team.Template import Brig.Template (Localised, TemplateBranding, forLocale, genTemplateBranding) import Brig.User.Search.Index (IndexEnv (..), MonadIndexIO (..), runIndexIO) @@ -157,7 +158,7 @@ import Wire.API.User.Identity (Email) import Wire.API.User.Profile (Locale) schemaVersion :: Int32 -schemaVersion = 79 +schemaVersion = Migrations.lastSchemaVersion ------------------------------------------------------------------------------- -- Environment @@ -300,16 +301,15 @@ newEnv o = do where emailConn _ (Opt.EmailAWS aws) = pure (Just aws, Nothing) emailConn lgr (Opt.EmailSMTP s) = do - let host = Opt.smtpEndpoint s ^. epHost - port = Just $ fromInteger $ toInteger $ Opt.smtpEndpoint s ^. epPort + let h = Opt.smtpEndpoint s ^. host + p = Just $ fromInteger $ toInteger $ Opt.smtpEndpoint s ^. port smtpCredentials <- case Opt.smtpCredentials s of - Just (Opt.EmailSMTPCredentials u p) -> do - pass <- initCredentials p - pure $ Just (SMTP.Username u, SMTP.Password pass) + Just (Opt.EmailSMTPCredentials u p') -> do + Just . (SMTP.Username u,) . SMTP.Password <$> initCredentials p' _ -> pure Nothing - smtp <- SMTP.initSMTP lgr host port smtpCredentials (Opt.smtpConnType s) + smtp <- SMTP.initSMTP lgr h p smtpCredentials (Opt.smtpConnType s) pure (Nothing, Just smtp) - mkEndpoint service = RPC.host (encodeUtf8 (service ^. epHost)) . RPC.port (service ^. epPort) $ RPC.empty + mkEndpoint service = RPC.host (encodeUtf8 (service ^. host)) . RPC.port (service ^. port) $ RPC.empty mkIndexEnv :: Opts -> Logger -> Manager -> Metrics -> Endpoint -> IndexEnv mkIndexEnv o lgr mgr mtr galleyEndpoint = @@ -425,21 +425,21 @@ initCassandra :: Opts -> Logger -> IO Cas.ClientState initCassandra o g = do c <- maybe - (Cas.initialContactsPlain (Opt.cassandra o ^. casEndpoint . epHost)) + (Cas.initialContactsPlain (Opt.cassandra o ^. endpoint . host)) (Cas.initialContactsDisco "cassandra_brig" . unpack) (Opt.discoUrl o) p <- Cas.init $ Cas.setLogger (Cas.mkLogger (Log.clone (Just "cassandra.brig") g)) . Cas.setContacts (NE.head c) (NE.tail c) - . Cas.setPortNumber (fromIntegral (Opt.cassandra o ^. casEndpoint . epPort)) - . Cas.setKeyspace (Keyspace (Opt.cassandra o ^. casKeyspace)) + . Cas.setPortNumber (fromIntegral (Opt.cassandra o ^. endpoint . port)) + . Cas.setKeyspace (Keyspace (Opt.cassandra o ^. keyspace)) . Cas.setMaxConnections 4 . Cas.setPoolStripes 4 . Cas.setSendTimeout 3 . Cas.setResponseTimeout 10 . Cas.setProtocolVersion Cas.V4 - . Cas.setPolicy (Cas.dcFilterPolicyIfConfigured g (Opt.cassandra o ^. casFilterNodesByDatacentre)) + . Cas.setPolicy (Cas.dcFilterPolicyIfConfigured g (Opt.cassandra o ^. filterNodesByDatacentre)) $ Cas.defSettings runClient p $ versionCheck schemaVersion pure p diff --git a/services/brig/src/Brig/Data/Client.hs b/services/brig/src/Brig/Data/Client.hs index 2a8eebeafcb..cc0c7eb58cf 100644 --- a/services/brig/src/Brig/Data/Client.hs +++ b/services/brig/src/Brig/Data/Client.hs @@ -88,7 +88,7 @@ import System.CryptoBox qualified as CryptoBox import System.Logger.Class (field, msg, val) import System.Logger.Class qualified as Log import UnliftIO (pooledMapConcurrentlyN) -import Wire.API.MLS.Credential +import Wire.API.MLS.CipherSuite import Wire.API.User.Auth import Wire.API.User.Client hiding (UpdateClient (..)) import Wire.API.User.Client.Prekey @@ -382,10 +382,10 @@ insertClient :: PrepQuery W (UserId, ClientId, UTCTimeMillis, ClientType, Maybe insertClient = "INSERT INTO clients (user, client, tstamp, type, label, class, cookie, lat, lon, model, capabilities) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" updateClientLabelQuery :: PrepQuery W (Maybe Text, UserId, ClientId) () -updateClientLabelQuery = "UPDATE clients SET label = ? WHERE user = ? AND client = ?" +updateClientLabelQuery = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE clients SET label = ? WHERE user = ? AND client = ?" updateClientCapabilitiesQuery :: PrepQuery W (Maybe (C.Set ClientCapability), UserId, ClientId) () -updateClientCapabilitiesQuery = "UPDATE clients SET capabilities = ? WHERE user = ? AND client = ?" +updateClientCapabilitiesQuery = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE clients SET capabilities = ? WHERE user = ? AND client = ?" updateClientLastActiveQuery :: PrepQuery W (UTCTime, UserId, ClientId) Row updateClientLastActiveQuery = "UPDATE clients SET last_active = ? WHERE user = ? AND client = ? IF EXISTS" diff --git a/services/brig/src/Brig/Data/Connection.hs b/services/brig/src/Brig/Data/Connection.hs index f4d8b56e3ed..16031d654eb 100644 --- a/services/brig/src/Brig/Data/Connection.hs +++ b/services/brig/src/Brig/Data/Connection.hs @@ -340,7 +340,7 @@ connectionInsert :: PrepQuery W (UserId, UserId, RelationWithHistory, UTCTimeMil connectionInsert = "INSERT INTO connection (left, right, status, last_update, conv) VALUES (?, ?, ?, ?, ?)" connectionUpdate :: PrepQuery W (RelationWithHistory, UTCTimeMillis, UserId, UserId) () -connectionUpdate = "UPDATE connection SET status = ?, last_update = ? WHERE left = ? AND right = ?" +connectionUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE connection SET status = ?, last_update = ? WHERE left = ? AND right = ?" connectionSelect :: PrepQuery R (UserId, UserId) (UserId, UserId, RelationWithHistory, UTCTimeMillis, Maybe ConvId) connectionSelect = "SELECT left, right, status, last_update, conv FROM connection WHERE left = ? AND right = ?" @@ -391,7 +391,7 @@ remoteConnectionSelectFrom :: PrepQuery R (UserId, Domain, UserId) (RelationWith remoteConnectionSelectFrom = "SELECT status, last_update, conv_domain, conv_id FROM connection_remote where left = ? AND right_domain = ? AND right_user = ?" remoteConnectionUpdate :: PrepQuery W (RelationWithHistory, UTCTimeMillis, UserId, Domain, UserId) () -remoteConnectionUpdate = "UPDATE connection_remote set status = ?, last_update = ? WHERE left = ? and right_domain = ? and right_user = ?" +remoteConnectionUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE connection_remote set status = ?, last_update = ? WHERE left = ? and right_domain = ? and right_user = ?" remoteConnectionDelete :: PrepQuery W (UserId, Domain, UserId) () remoteConnectionDelete = "DELETE FROM connection_remote where left = ? AND right_domain = ? AND right_user = ?" diff --git a/services/brig/src/Brig/Data/Instances.hs b/services/brig/src/Brig/Data/Instances.hs index dfbba99f65e..34309315771 100644 --- a/services/brig/src/Brig/Data/Instances.hs +++ b/services/brig/src/Brig/Data/Instances.hs @@ -39,6 +39,7 @@ import Data.Text.Encoding (encodeUtf8) import Imports import Wire.API.Asset (AssetKey, assetKeyToText, nilAssetKey) import Wire.API.Connection (RelationWithHistory (..)) +import Wire.API.MLS.CipherSuite import Wire.API.Properties import Wire.API.User import Wire.API.User.Activation @@ -306,3 +307,13 @@ instance Cql (Imports.Set BaseProtocolTag) where toCql = CqlInt . fromIntegral . protocolSetBits fromCql (CqlInt bits) = pure $ protocolSetFromBits (fromIntegral bits) fromCql _ = Left "Protocol set: Int expected" + +instance Cql CipherSuiteTag where + ctype = Tagged IntColumn + toCql = CqlInt . fromIntegral . cipherSuiteNumber . tagCipherSuite + + fromCql (CqlInt index) = + case cipherSuiteTag (CipherSuite (fromIntegral index)) of + Just tag -> Right tag + Nothing -> Left "CipherSuiteTag: unexpected index" + fromCql _ = Left "CipherSuiteTag: int expected" diff --git a/services/brig/src/Brig/Data/MLS/KeyPackage.hs b/services/brig/src/Brig/Data/MLS/KeyPackage.hs index dce1b7cfc58..a03192f32e6 100644 --- a/services/brig/src/Brig/Data/MLS/KeyPackage.hs +++ b/services/brig/src/Brig/Data/MLS/KeyPackage.hs @@ -18,13 +18,8 @@ module Brig.Data.MLS.KeyPackage ( insertKeyPackages, claimKeyPackage, - mapKeyPackageRef, countKeyPackages, - derefKeyPackage, - keyPackageRefConvId, - keyPackageRefSetConvId, - addKeyPackageRef, - updateKeyPackageRef, + deleteKeyPackages, ) where @@ -32,34 +27,35 @@ import Brig.API.MLS.KeyPackages.Validation import Brig.App import Brig.Options hiding (Timeout) import Cassandra -import Cassandra.Settings import Control.Arrow import Control.Error -import Control.Exception import Control.Lens -import Control.Monad.Catch import Control.Monad.Random (randomRIO) -import Data.Domain import Data.Functor import Data.Id import Data.Qualified import Data.Time.Clock import Data.Time.Clock.POSIX import Imports -import Wire.API.MLS.Credential +import Wire.API.MLS.CipherSuite import Wire.API.MLS.KeyPackage +import Wire.API.MLS.LeafNode import Wire.API.MLS.Serialisation -import Wire.API.Routes.Internal.Brig -insertKeyPackages :: MonadClient m => UserId -> ClientId -> [(KeyPackageRef, KeyPackageData)] -> m () +insertKeyPackages :: + MonadClient m => + UserId -> + ClientId -> + [(KeyPackageRef, CipherSuiteTag, KeyPackageData)] -> + m () insertKeyPackages uid cid kps = retry x5 . batch $ do setType BatchLogged setConsistency LocalQuorum - for_ kps $ \(ref, kp) -> do - addPrepQuery q (uid, cid, kp, ref) + for_ kps $ \(ref, suite, kp) -> do + addPrepQuery q (uid, cid, suite, kp, ref) where - q :: PrepQuery W (UserId, ClientId, KeyPackageData, KeyPackageRef) () - q = "INSERT INTO mls_key_packages (user, client, data, ref) VALUES (?, ?, ?, ?)" + q :: PrepQuery W (UserId, ClientId, CipherSuiteTag, KeyPackageData, KeyPackageRef) () + q = "INSERT INTO mls_key_packages (user, client, cipher_suite, data, ref) VALUES (?, ?, ?, ?, ?)" claimKeyPackage :: ( MonadReader Env m, @@ -68,22 +64,22 @@ claimKeyPackage :: ) => Local UserId -> ClientId -> + CipherSuiteTag -> MaybeT m (KeyPackageRef, KeyPackageData) -claimKeyPackage u c = do +claimKeyPackage u c suite = do -- FUTUREWORK: investigate better locking strategies lock <- lift $ view keyPackageLocalLock -- get a random key package and delete it (ref, kpd) <- MaybeT . withMVar lock . const $ do - kps <- getNonClaimedKeyPackages u c + kps <- getNonClaimedKeyPackages u c suite mk <- liftIO (pick kps) for mk $ \(ref, kpd) -> do - retry x5 $ write deleteByRef (params LocalQuorum (tUnqualified u, c, ref)) + retry x5 $ write delete1Query (params LocalQuorum (tUnqualified u, c, suite, ref)) pure (ref, kpd) - lift $ mapKeyPackageRef ref (tUntagged u) c pure (ref, kpd) where - deleteByRef :: PrepQuery W (UserId, ClientId, KeyPackageRef) () - deleteByRef = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND ref = ?" + delete1Query :: PrepQuery W (UserId, ClientId, CipherSuiteTag, KeyPackageRef) () + delete1Query = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref = ?" -- | Fetch all unclaimed non-expired key packages for a given client and delete -- from the database those that have expired. @@ -93,9 +89,10 @@ getNonClaimedKeyPackages :: ) => Local UserId -> ClientId -> + CipherSuiteTag -> m [(KeyPackageRef, KeyPackageData)] -getNonClaimedKeyPackages u c = do - kps <- retry x1 $ query lookupQuery (params LocalQuorum (tUnqualified u, c)) +getNonClaimedKeyPackages u c suite = do + kps <- retry x1 $ query lookupQuery (params LocalQuorum (tUnqualified u, c, suite)) let decodedKps = foldMap (keepDecoded . (decodeKp &&& id)) kps now <- liftIO getPOSIXTime @@ -104,18 +101,11 @@ getNonClaimedKeyPackages u c = do let (kpsExpired, kpsNonExpired) = partition (hasExpired now mMaxLifetime) decodedKps -- delete expired key packages - let kpsExpired' = fmap (\(_, (ref, _)) -> ref) kpsExpired - in retry x5 $ - write - deleteByRefs - (params LocalQuorum (tUnqualified u, c, kpsExpired')) + deleteKeyPackages (tUnqualified u) c suite (map (\(_, (ref, _)) -> ref) kpsExpired) pure $ fmap snd kpsNonExpired where - lookupQuery :: PrepQuery R (UserId, ClientId) (KeyPackageRef, KeyPackageData) - lookupQuery = "SELECT ref, data FROM mls_key_packages WHERE user = ? AND client = ?" - - deleteByRefs :: PrepQuery W (UserId, ClientId, [KeyPackageRef]) () - deleteByRefs = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND ref in ?" + lookupQuery :: PrepQuery R (UserId, ClientId, CipherSuiteTag) (KeyPackageRef, KeyPackageData) + lookupQuery = "SELECT ref, data FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ?" decodeKp :: (a, KeyPackageData) -> Maybe KeyPackage decodeKp = hush . decodeMLS' . kpData . snd @@ -126,19 +116,11 @@ getNonClaimedKeyPackages u c = do hasExpired :: POSIXTime -> Maybe NominalDiffTime -> (KeyPackage, a) -> Bool hasExpired now mMaxLifetime (kp, _) = - case findExtensions (kpExtensions kp) of - Left _ -> True -- the assumption is the key package is valid and has the - -- required extensions so we return 'True' - Right (runIdentity . reLifetime -> lt) -> + case kp.leafNode.source of + LeafNodeSourceKeyPackage lt -> either (const True) (const False) . validateLifetime' now mMaxLifetime $ lt - --- | Add key package ref to mapping table. -mapKeyPackageRef :: MonadClient m => KeyPackageRef -> Qualified UserId -> ClientId -> m () -mapKeyPackageRef ref u c = - write insertQuery (params LocalQuorum (ref, qDomain u, qUnqualified u, c)) - where - insertQuery :: PrepQuery W (KeyPackageRef, Domain, UserId, ClientId) () - insertQuery = "INSERT INTO mls_key_package_refs (ref, domain, user, client) VALUES (?, ?, ?, ?)" + _ -> True -- the assumption is the key package is valid and has the + -- required extensions so we return 'True' countKeyPackages :: ( MonadReader Env m, @@ -146,97 +128,23 @@ countKeyPackages :: ) => Local UserId -> ClientId -> + CipherSuiteTag -> m Int64 -countKeyPackages u c = fromIntegral . length <$> getNonClaimedKeyPackages u c - -derefKeyPackage :: MonadClient m => KeyPackageRef -> MaybeT m ClientIdentity -derefKeyPackage ref = do - (d, u, c) <- MaybeT . retry x1 $ query1 q (params LocalQuorum (Identity ref)) - pure $ ClientIdentity d u c - where - q :: PrepQuery R (Identity KeyPackageRef) (Domain, UserId, ClientId) - q = "SELECT domain, user, client from mls_key_package_refs WHERE ref = ?" - -keyPackageRefConvId :: MonadClient m => KeyPackageRef -> MaybeT m (Qualified ConvId) -keyPackageRefConvId ref = MaybeT $ do - qr <- retry x1 $ query1 q (params LocalSerial (Identity ref)) - pure $ do - (domain, cid) <- qr - Qualified <$> cid <*> domain - where - q :: PrepQuery R (Identity KeyPackageRef) (Maybe Domain, Maybe ConvId) - q = "SELECT conv_domain, conv FROM mls_key_package_refs WHERE ref = ?" - --- We want to proper update, not an upsert, to avoid "ghost" refs without user+client -keyPackageRefSetConvId :: MonadClient m => KeyPackageRef -> Qualified ConvId -> m Bool -keyPackageRefSetConvId ref convId = do - updated <- - retry x5 $ - trans - q - (params LocalQuorum (qDomain convId, qUnqualified convId, ref)) - { serialConsistency = Just LocalSerialConsistency - } - case updated of - [] -> pure False - [_] -> pure True - _ -> throwM $ ErrorCall "Primary key violation detected mls_key_package_refs.ref" - where - q :: PrepQuery W (Domain, ConvId, KeyPackageRef) Row - q = "UPDATE mls_key_package_refs SET conv_domain = ?, conv = ? WHERE ref = ? IF EXISTS" +countKeyPackages u c suite = fromIntegral . length <$> getNonClaimedKeyPackages u c suite -addKeyPackageRef :: MonadClient m => KeyPackageRef -> NewKeyPackageRef -> m () -addKeyPackageRef ref nkpr = do +deleteKeyPackages :: MonadClient m => UserId -> ClientId -> CipherSuiteTag -> [KeyPackageRef] -> m () +deleteKeyPackages u c suite refs = retry x5 $ write - q - (params LocalQuorum (nkprClientId nkpr, qUnqualified (nkprConversation nkpr), qDomain (nkprConversation nkpr), qDomain (nkprUserId nkpr), qUnqualified (nkprUserId nkpr), ref)) + deleteQuery + (params LocalQuorum (u, c, suite, refs)) where - q :: PrepQuery W (ClientId, ConvId, Domain, Domain, UserId, KeyPackageRef) x - q = "UPDATE mls_key_package_refs SET client = ?, conv = ?, conv_domain = ?, domain = ?, user = ? WHERE ref = ?" - --- | Update key package ref, used in Galley when commit reveals key package ref update for the sender. --- Nothing is changed if the previous key package ref is not found in the table. --- Updating amounts to INSERT the new key package ref, followed by DELETE the --- previous one. --- --- FUTUREWORK: this function has to be extended if a table mapping (client, --- conversation) to key package ref is added, for instance, when implementing --- external delete proposals. -updateKeyPackageRef :: MonadClient m => KeyPackageRef -> KeyPackageRef -> m () -updateKeyPackageRef prevRef newRef = - void . runMaybeT $ do - backup <- backupKeyPackageMeta prevRef - lift $ restoreKeyPackageMeta newRef backup >> deleteKeyPackage prevRef + deleteQuery :: PrepQuery W (UserId, ClientId, CipherSuiteTag, [KeyPackageRef]) () + deleteQuery = "DELETE FROM mls_key_packages WHERE user = ? AND client = ? AND cipher_suite = ? AND ref in ?" -------------------------------------------------------------------------------- -- Utilities -backupKeyPackageMeta :: MonadClient m => KeyPackageRef -> MaybeT m (ClientId, Maybe (Qualified ConvId), Qualified UserId) -backupKeyPackageMeta ref = do - (clientId, convId, convDomain, userDomain, userId) <- MaybeT . retry x1 $ query1 q (params LocalQuorum (Identity ref)) - pure (clientId, Qualified <$> convId <*> convDomain, Qualified userId userDomain) - where - q :: PrepQuery R (Identity KeyPackageRef) (ClientId, Maybe ConvId, Maybe Domain, Domain, UserId) - q = "SELECT client, conv, conv_domain, domain, user FROM mls_key_package_refs WHERE ref = ?" - -restoreKeyPackageMeta :: MonadClient m => KeyPackageRef -> (ClientId, Maybe (Qualified ConvId), Qualified UserId) -> m () -restoreKeyPackageMeta ref (clientId, convId, userId) = do - write q (params LocalQuorum (ref, clientId, qUnqualified <$> convId, qDomain <$> convId, qDomain userId, qUnqualified userId)) - where - q :: PrepQuery W (KeyPackageRef, ClientId, Maybe ConvId, Maybe Domain, Domain, UserId) () - q = "INSERT INTO mls_key_package_refs (ref, client, conv, conv_domain, domain, user) VALUES (?, ?, ?, ?, ?, ?)" - -deleteKeyPackage :: MonadClient m => KeyPackageRef -> m () -deleteKeyPackage ref = - retry x5 $ - write - q - (params LocalQuorum (Identity ref)) - where - q :: PrepQuery W (Identity KeyPackageRef) x - q = "DELETE FROM mls_key_package_refs WHERE ref = ?" - pick :: [a] -> IO (Maybe a) pick [] = pure Nothing pick xs = do diff --git a/services/brig/src/Brig/Data/User.hs b/services/brig/src/Brig/Data/User.hs index 1891e135f43..d170ed4e427 100644 --- a/services/brig/src/Brig/Data/User.hs +++ b/services/brig/src/Brig/Data/User.hs @@ -622,64 +622,64 @@ userInsert = \VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" userDisplayNameUpdate :: PrepQuery W (Name, UserId) () -userDisplayNameUpdate = "UPDATE user SET name = ? WHERE id = ?" +userDisplayNameUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET name = ? WHERE id = ?" userPictUpdate :: PrepQuery W (Pict, UserId) () -userPictUpdate = "UPDATE user SET picture = ? WHERE id = ?" +userPictUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET picture = ? WHERE id = ?" userAssetsUpdate :: PrepQuery W ([Asset], UserId) () -userAssetsUpdate = "UPDATE user SET assets = ? WHERE id = ?" +userAssetsUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET assets = ? WHERE id = ?" userAccentIdUpdate :: PrepQuery W (ColourId, UserId) () -userAccentIdUpdate = "UPDATE user SET accent_id = ? WHERE id = ?" +userAccentIdUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET accent_id = ? WHERE id = ?" userEmailUpdate :: PrepQuery W (Email, UserId) () -userEmailUpdate = "UPDATE user SET email = ? WHERE id = ?" +userEmailUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET email = ? WHERE id = ?" userEmailUnvalidatedUpdate :: PrepQuery W (Email, UserId) () -userEmailUnvalidatedUpdate = "UPDATE user SET email_unvalidated = ? WHERE id = ?" +userEmailUnvalidatedUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET email_unvalidated = ? WHERE id = ?" userEmailUnvalidatedDelete :: PrepQuery W (Identity UserId) () -userEmailUnvalidatedDelete = "UPDATE user SET email_unvalidated = null WHERE id = ?" +userEmailUnvalidatedDelete = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET email_unvalidated = null WHERE id = ?" userPhoneUpdate :: PrepQuery W (Phone, UserId) () -userPhoneUpdate = "UPDATE user SET phone = ? WHERE id = ?" +userPhoneUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET phone = ? WHERE id = ?" userSSOIdUpdate :: PrepQuery W (Maybe UserSSOId, UserId) () -userSSOIdUpdate = "UPDATE user SET sso_id = ? WHERE id = ?" +userSSOIdUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET sso_id = ? WHERE id = ?" userManagedByUpdate :: PrepQuery W (ManagedBy, UserId) () -userManagedByUpdate = "UPDATE user SET managed_by = ? WHERE id = ?" +userManagedByUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET managed_by = ? WHERE id = ?" userHandleUpdate :: PrepQuery W (Handle, UserId) () -userHandleUpdate = "UPDATE user SET handle = ? WHERE id = ?" +userHandleUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET handle = ? WHERE id = ?" userSupportedProtocolUpdate :: PrepQuery W (Set BaseProtocolTag, UserId) () -userSupportedProtocolUpdate = "UPDATE user SET supported_protocols = ? WHERE id = ?" +userSupportedProtocolUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET supported_protocols = ? WHERE id = ?" userPasswordUpdate :: PrepQuery W (Password, UserId) () -userPasswordUpdate = "UPDATE user SET password = ? WHERE id = ?" +userPasswordUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET password = ? WHERE id = ?" userStatusUpdate :: PrepQuery W (AccountStatus, UserId) () -userStatusUpdate = "UPDATE user SET status = ? WHERE id = ?" +userStatusUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET status = ? WHERE id = ?" userDeactivatedUpdate :: PrepQuery W (Identity UserId) () -userDeactivatedUpdate = "UPDATE user SET activated = false WHERE id = ?" +userDeactivatedUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET activated = false WHERE id = ?" userActivatedUpdate :: PrepQuery W (Maybe Email, Maybe Phone, UserId) () -userActivatedUpdate = "UPDATE user SET activated = true, email = ?, phone = ? WHERE id = ?" +userActivatedUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET activated = true, email = ?, phone = ? WHERE id = ?" userLocaleUpdate :: PrepQuery W (Language, Maybe Country, UserId) () -userLocaleUpdate = "UPDATE user SET language = ?, country = ? WHERE id = ?" +userLocaleUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET language = ?, country = ? WHERE id = ?" userEmailDelete :: PrepQuery W (Identity UserId) () -userEmailDelete = "UPDATE user SET email = null WHERE id = ?" +userEmailDelete = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET email = null WHERE id = ?" userPhoneDelete :: PrepQuery W (Identity UserId) () -userPhoneDelete = "UPDATE user SET phone = null WHERE id = ?" +userPhoneDelete = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET phone = null WHERE id = ?" userRichInfoUpdate :: PrepQuery W (RichInfoAssocList, UserId) () -userRichInfoUpdate = "UPDATE rich_info SET json = ? WHERE user = ?" +userRichInfoUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE rich_info SET json = ? WHERE user = ?" ------------------------------------------------------------------------------- -- Conversions diff --git a/services/brig/src/Brig/Federation/Client.hs b/services/brig/src/Brig/Federation/Client.hs index e824b4f53e9..87c44ec4465 100644 --- a/services/brig/src/Brig/Federation/Client.hs +++ b/services/brig/src/Brig/Federation/Client.hs @@ -1,7 +1,3 @@ -{-# LANGUAGE AllowAmbiguousTypes #-} -{-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TypeApplications #-} - -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -154,7 +150,9 @@ notifyUserDeleted self remotes = do remoteDomain = tDomain remotes view rabbitmqChannel >>= \case Just chanVar -> do - enqueueNotification (tDomain self) remoteDomain Q.Persistent chanVar $ void $ fedQueueClient @'Brig @"on-user-deleted-connections" notif + enqueueNotification (tDomain self) remoteDomain Q.Persistent chanVar $ + void $ + fedQueueClient @'OnUserDeletedConnectionsTag notif Nothing -> Log.err $ Log.msg ("Federation error while notifying remote backends of a user deletion." :: ByteString) @@ -163,7 +161,7 @@ notifyUserDeleted self remotes = do . Log.field "error" (show FederationNotConfigured) -- | Enqueues notifications in RabbitMQ. Retries 3 times with a delay of 1s. -enqueueNotification :: (MonadReader Env m, MonadIO m, MonadMask m, Log.MonadLogger m) => Domain -> Domain -> Q.DeliveryMode -> MVar Q.Channel -> FedQueueClient c () -> m () +enqueueNotification :: (MonadIO m, MonadMask m, Log.MonadLogger m) => Domain -> Domain -> Q.DeliveryMode -> MVar Q.Channel -> FedQueueClient c () -> m () enqueueNotification ownDomain remoteDomain deliveryMode chanVar action = do let policy = limitRetries 3 <> constantDelay 1_000_000 recovering policy [logRetries (const $ pure True) logError] (const go) diff --git a/services/brig/src/Brig/Index/Migrations.hs b/services/brig/src/Brig/Index/Migrations.hs index c6101df53a7..27d7559fed2 100644 --- a/services/brig/src/Brig/Index/Migrations.hs +++ b/services/brig/src/Brig/Index/Migrations.hs @@ -120,21 +120,21 @@ failIfIndexAbsent targetIndex = -- | Runs only the migrations which need to run runMigration :: MigrationVersion -> MigrationActionT IO () -runMigration ver = do - vmax <- latestMigrationVersion - if ver > vmax +runMigration expectedVersion = do + foundVersion <- latestMigrationVersion + if expectedVersion > foundVersion then do Log.info $ Log.msg (Log.val "Migration necessary.") - . Log.field "expectedVersion" vmax - . Log.field "foundVersion" ver + . Log.field "expectedVersion" expectedVersion + . Log.field "foundVersion" foundVersion Search.reindexAllIfSameOrNewer - persistVersion ver + persistVersion expectedVersion else do Log.info $ Log.msg (Log.val "No migration necessary.") - . Log.field "expectedVersion" vmax - . Log.field "foundVersion" ver + . Log.field "expectedVersion" expectedVersion + . Log.field "foundVersion" foundVersion persistVersion :: (MonadThrow m, MonadIO m) => MigrationVersion -> MigrationActionT m () persistVersion v = @@ -148,6 +148,7 @@ persistVersion v = . Log.field "migrationVersion" v else throwM $ PersistVersionFailed v $ show persistResponse +-- | Which version is the table space currently running on? latestMigrationVersion :: (MonadThrow m, MonadIO m) => MigrationActionT m MigrationVersion latestMigrationVersion = do resp <- ES.parseEsResponse =<< ES.searchByIndex indexName (ES.mkSearch Nothing Nothing) diff --git a/services/brig/src/Brig/Provider/API.hs b/services/brig/src/Brig/Provider/API.hs index 9c5cccfce1d..6291aa84f61 100644 --- a/services/brig/src/Brig/Provider/API.hs +++ b/services/brig/src/Brig/Provider/API.hs @@ -17,8 +17,10 @@ module Brig.Provider.API ( -- * Main stuff - routesPublic, routesInternal, + botAPI, + servicesAPI, + providerAPI, -- * Event handlers finishDeleteService, @@ -59,6 +61,7 @@ import Control.Monad.Except import Data.Aeson hiding (json) import Data.ByteString.Conversion import Data.ByteString.Lazy.Char8 qualified as LC8 +import Data.CommaSeparatedList (CommaSeparatedList (fromCommaSeparatedList)) import Data.Conduit (runConduit, (.|)) import Data.Conduit.List qualified as C import Data.Hashable (hash) @@ -68,23 +71,20 @@ import Data.List qualified as List import Data.List1 (maybeList1) import Data.Map.Strict qualified as Map import Data.Misc (Fingerprint (..), FutureWork (FutureWork), Rsa) -import Data.Predicate import Data.Qualified import Data.Range import Data.Set qualified as Set import Data.Text.Ascii qualified as Ascii import Data.Text.Encoding qualified as Text -import Data.ZAuth.Token qualified as ZAuth import GHC.TypeNats import Imports import Network.HTTP.Types.Status import Network.Wai (Response) -import Network.Wai.Predicate (accept, contentType, def, opt, query) +import Network.Wai.Predicate (accept) import Network.Wai.Routing import Network.Wai.Utilities.Error ((!>>)) import Network.Wai.Utilities.Error qualified as Wai -import Network.Wai.Utilities.Request (JsonRequest, jsonRequest) -import Network.Wai.Utilities.Response (addHeader, empty, json, setStatus) +import Network.Wai.Utilities.Response (json) import Network.Wai.Utilities.ZAuth import OpenSSL.EVP.Digest qualified as SSL import OpenSSL.EVP.PKey qualified as SSL @@ -92,10 +92,10 @@ import OpenSSL.PEM qualified as SSL import OpenSSL.RSA qualified as SSL import OpenSSL.Random (randBytes) import Polysemy +import Servant (NoContent (..), ServerT, (:<|>) (..)) import Ssl.Util qualified as SSL import System.Logger.Class (MonadLogger) import UnliftIO.Async (pooledMapConcurrentlyN_) -import Web.Cookie qualified as Cookie import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Bot import Wire.API.Conversation.Bot qualified as Public @@ -113,219 +113,67 @@ import Wire.API.Provider.External qualified as Ext import Wire.API.Provider.Service import Wire.API.Provider.Service qualified as Public import Wire.API.Provider.Service.Tag qualified as Public +import Wire.API.Routes.Named (UntypedNamed (Named)) +import Wire.API.Routes.Public.Brig.Bot (BotAPI) +import Wire.API.Routes.Public.Brig.Provider (ProviderAPI) +import Wire.API.Routes.Public.Brig.Services (ServicesAPI) import Wire.API.Team.Feature qualified as Feature import Wire.API.Team.LegalHold (LegalholdProtectee (UnprotectedBot)) import Wire.API.Team.Permission import Wire.API.User hiding (cpNewPassword, cpOldPassword) import Wire.API.User qualified as Public (UserProfile, publicProfile) +import Wire.API.User.Auth import Wire.API.User.Client import Wire.API.User.Client qualified as Public (Client, ClientCapability (ClientSupportsLegalholdImplicitConsent), PubClient (..), UserClientPrekeyMap, UserClients, userClients) import Wire.API.User.Client.Prekey qualified as Public (PrekeyId) import Wire.API.User.Identity qualified as Public (Email) import Wire.Sem.Concurrency (Concurrency, ConcurrencySafety (Unsafe)) -routesPublic :: +botAPI :: ( Member GalleyProvider r, Member (Concurrency 'Unsafe) r ) => - Routes () (Handler r) () -routesPublic = do - -- Public API (Unauthenticated) -------------------------------------------- - - post "/provider/register" (continue newAccountH) $ - accept "application" "json" - .&> jsonRequest @Public.NewProvider - - get "/provider/activate" (continue activateAccountKeyH) $ - accept "application" "json" - .&> query "key" - .&. query "code" - - get "/provider/approve" (continue approveAccountKeyH) $ - accept "application" "json" - .&> query "key" - .&. query "code" - - post "/provider/login" (continue loginH) $ - jsonRequest @Public.ProviderLogin - - post "/provider/password-reset" (continue beginPasswordResetH) $ - accept "application" "json" - .&> jsonRequest @Public.PasswordReset - - post "/provider/password-reset/complete" (continue completePasswordResetH) $ - accept "application" "json" - .&> jsonRequest @Public.CompletePasswordReset - - -- Provider API ------------------------------------------------------------ - - delete "/provider" (continue deleteAccountH) $ - zauth ZAuthProvider - .&> zauthProviderId - .&. jsonRequest @Public.DeleteProvider - - put "/provider" (continue updateAccountProfileH) $ - accept "application" "json" - .&> zauth ZAuthProvider - .&> zauthProviderId - .&. jsonRequest @Public.UpdateProvider - - put "/provider/email" (continue updateAccountEmailH) $ - zauth ZAuthProvider - .&> zauthProviderId - .&. jsonRequest @Public.EmailUpdate - - put "/provider/password" (continue updateAccountPasswordH) $ - zauth ZAuthProvider - .&> zauthProviderId - .&. jsonRequest @Public.PasswordChange - - get "/provider" (continue getAccountH) $ - accept "application" "json" - .&> zauth ZAuthProvider - .&> zauthProviderId - - post "/provider/services" (continue addServiceH) $ - accept "application" "json" - .&> zauth ZAuthProvider - .&> zauthProviderId - .&. jsonRequest @Public.NewService - - get "/provider/services" (continue listServicesH) $ - accept "application" "json" - .&> zauth ZAuthProvider - .&> zauthProviderId - - get "/provider/services/:sid" (continue getServiceH) $ - accept "application" "json" - .&> zauth ZAuthProvider - .&> zauthProviderId - .&. capture "sid" - - put "/provider/services/:sid" (continue updateServiceH) $ - zauth ZAuthProvider - .&> zauthProviderId - .&. capture "sid" - .&. jsonRequest @Public.UpdateService - - put "/provider/services/:sid/connection" (continue updateServiceConnH) $ - zauth ZAuthProvider - .&> zauthProviderId - .&. capture "sid" - .&. jsonRequest @Public.UpdateServiceConn - - -- TODO - -- post "/provider/services/:sid/token" (continue genServiceTokenH) $ - -- accept "application" "json" - -- .&. zauthProvider - - delete "/provider/services/:sid" (continue deleteServiceH) $ - zauth ZAuthProvider - .&> zauthProviderId - .&. capture "sid" - .&. jsonRequest @Public.DeleteService - - -- User API ---------------------------------------------------------------- - - get "/providers/:pid" (continue getProviderProfileH) $ - accept "application" "json" - .&> zauth ZAuthAccess - .&> capture "pid" - - get "/providers/:pid/services" (continue listServiceProfilesH) $ - accept "application" "json" - .&> zauth ZAuthAccess - .&> capture "pid" - - get "/providers/:pid/services/:sid" (continue getServiceProfileH) $ - accept "application" "json" - .&> zauth ZAuthAccess - .&> capture "pid" - .&. capture "sid" - - get "/services" (continue searchServiceProfilesH) $ - accept "application" "json" - .&> zauth ZAuthAccess - .&> opt (query "tags") - .&. opt (query "start") - .&. def (unsafeRange 20) (query "size") - - get "/services/tags" (continue getServiceTagListH) $ - accept "application" "json" - .&> zauth ZAuthAccess - - get "/teams/:tid/services/whitelisted" (continue searchTeamServiceProfilesH) $ - accept "application" "json" - .&> zauthUserId - .&. capture "tid" - .&. opt (query "prefix") - .&. def True (query "filter_disabled") - .&. def (unsafeRange 20) (query "size") - - post "/teams/:tid/services/whitelist" (continue updateServiceWhitelistH) $ - accept "application" "json" - .&> zauth ZAuthAccess - .&> zauthUserId - .&. zauthConnId - .&. capture "tid" - .&. jsonRequest @Public.UpdateServiceWhitelist - - post "/conversations/:cnv/bots" (continue addBotH) $ - accept "application" "json" - .&> zauth ZAuthAccess - .&> zauthUserId - .&. zauthConnId - .&. capture "cnv" - .&. jsonRequest @Public.AddBot - - delete "/conversations/:cnv/bots/:bot" (continue removeBotH) $ - zauth ZAuthAccess - .&> zauthUserId - .&. zauthConnId - .&. capture "cnv" - .&. capture "bot" - - -- Bot API ----------------------------------------------------------------- - - get "/bot/self" (continue botGetSelfH) $ - accept "application" "json" - .&> zauth ZAuthBot - .&> zauthBotId - - delete "/bot/self" (continue botDeleteSelfH) $ - zauth ZAuthBot - .&> zauthBotId - .&. zauthConvId - - get "/bot/client/prekeys" (continue botListPrekeysH) $ - accept "application" "json" - .&> zauth ZAuthBot - .&> zauthBotId - - post "/bot/client/prekeys" (continue botUpdatePrekeysH) $ - zauth ZAuthBot - .&> zauthBotId - .&. jsonRequest @Public.UpdateBotPrekeys - - get "/bot/client" (continue botGetClientH) $ - contentType "application" "json" - .&> zauth ZAuthBot - .&> zauthBotId - - post "/bot/users/prekeys" (continue botClaimUsersPrekeysH) $ - accept "application" "json" - .&> zauth ZAuthBot - .&> jsonRequest @Public.UserClients - - get "/bot/users" (continue botListUserProfilesH) $ - accept "application" "json" - .&> zauth ZAuthBot - .&> query "ids" - - get "/bot/users/:uid/clients" (continue botGetUserClientsH) $ - accept "application" "json" - .&> zauth ZAuthBot - .&> capture "uid" + ServerT BotAPI (Handler r) +botAPI = + Named @"add-bot" addBot + :<|> Named @"remove-bot" removeBot + :<|> Named @"bot-get-self" botGetSelf + :<|> Named @"bot-delete-self" botDeleteSelf + :<|> Named @"bot-list-prekeys" botListPrekeys + :<|> Named @"bot-update-prekeys" botUpdatePrekeys + :<|> Named @"bot-get-client" botGetClient + :<|> Named @"bot-claim-users-prekeys" botClaimUsersPrekeys + :<|> Named @"bot-list-users" botListUserProfiles + :<|> Named @"bot-get-user-clients" botGetUserClients + +servicesAPI :: (Member GalleyProvider r) => ServerT ServicesAPI (Handler r) +servicesAPI = + Named @"post-provider-services" addService + :<|> Named @"get-provider-services" listServices + :<|> Named @"get-provider-services-by-service-id" getService + :<|> Named @"put-provider-services-by-service-id" updateService + :<|> Named @"put-provider-services-connection-by-service-id" updateServiceConn + :<|> Named @"delete-provider-services-by-service-id" deleteService + :<|> Named @"get-provider-services-by-provider-id" listServiceProfiles + :<|> Named @"get-services" searchServiceProfiles + :<|> Named @"get-services-tags" getServiceTagList + :<|> Named @"get-provider-services-by-provider-id-and-service-id" getServiceProfile + :<|> Named @"get-whitelisted-services-by-team-id" searchTeamServiceProfiles + :<|> Named @"post-team-whitelist-by-team-id" updateServiceWhitelist + +providerAPI :: Member GalleyProvider r => ServerT ProviderAPI (Handler r) +providerAPI = + Named @"provider-register" newAccount + :<|> Named @"provider-activate" activateAccountKey + :<|> Named @"provider-login" login + :<|> Named @"provider-password-reset" beginPasswordReset + :<|> Named @"provider-password-reset-complete" completePasswordReset + :<|> Named @"provider-delete" deleteAccount + :<|> Named @"provider-update" updateAccountProfile + :<|> Named @"provider-update-email" updateAccountEmail + :<|> Named @"provider-update-password" updateAccountPassword + :<|> Named @"provider-get-account" getAccount + :<|> Named @"provider-get-profile" getProviderProfile routesInternal :: Member GalleyProvider r => Routes a (Handler r) () routesInternal = do @@ -336,13 +184,9 @@ routesInternal = do -------------------------------------------------------------------------------- -- Public API (Unauthenticated) -newAccountH :: Member GalleyProvider r => JsonRequest Public.NewProvider -> (Handler r) Response -newAccountH req = do - guardSecondFactorDisabled Nothing - setStatus status201 . json <$> (newAccount =<< parseJsonBody req) - -newAccount :: Public.NewProvider -> (Handler r) Public.NewProviderResponse +newAccount :: Member GalleyProvider r => Public.NewProvider -> (Handler r) Public.NewProviderResponse newAccount new = do + guardSecondFactorDisabled Nothing email <- case validateEmail (Public.newProviderEmail new) of Right em -> pure em Left _ -> throwStd (errorToWai @'E.InvalidEmail) @@ -373,13 +217,9 @@ newAccount new = do lift $ sendActivationMail name email key val False pure $ Public.NewProviderResponse pid newPass -activateAccountKeyH :: Member GalleyProvider r => Code.Key ::: Code.Value -> (Handler r) Response -activateAccountKeyH (key ::: val) = do - guardSecondFactorDisabled Nothing - maybe (setStatus status204 empty) json <$> activateAccountKey key val - -activateAccountKey :: Code.Key -> Code.Value -> (Handler r) (Maybe Public.ProviderActivationResponse) +activateAccountKey :: Member GalleyProvider r => Code.Key -> Code.Value -> (Handler r) (Maybe Public.ProviderActivationResponse) activateAccountKey key val = do + guardSecondFactorDisabled Nothing c <- wrapClientE (Code.verify key Code.IdentityVerification val) >>= maybeInvalidCode (pid, email) <- case (Code.codeAccount c, Code.codeForEmail c) of (Just p, Just e) -> pure (Id p, e) @@ -421,42 +261,20 @@ instance ToJSON FoundActivationCode where toJSON $ Code.KeyValuePair (Code.codeKey vcode) (Code.codeValue vcode) -approveAccountKeyH :: Member GalleyProvider r => Code.Key ::: Code.Value -> (Handler r) Response -approveAccountKeyH (key ::: val) = do - guardSecondFactorDisabled Nothing - empty <$ approveAccountKey key val - -approveAccountKey :: Code.Key -> Code.Value -> (Handler r) () -approveAccountKey key val = do - c <- wrapClientE (Code.verify key Code.AccountApproval val) >>= maybeInvalidCode - case (Code.codeAccount c, Code.codeForEmail c) of - (Just pid, Just email) -> do - (name, _, _, _) <- wrapClientE (DB.lookupAccountData (Id pid)) >>= maybeInvalidCode - activate (Id pid) Nothing email - lift $ sendApprovalConfirmMail name email - _ -> throwStd (errorToWai @'E.InvalidCode) - -loginH :: Member GalleyProvider r => JsonRequest Public.ProviderLogin -> (Handler r) Response -loginH req = do - guardSecondFactorDisabled Nothing - tok <- login =<< parseJsonBody req - setProviderCookie tok empty - -login :: Public.ProviderLogin -> Handler r (ZAuth.Token ZAuth.Provider) +login :: Member GalleyProvider r => ProviderLogin -> Handler r ProviderTokenCookie login l = do + guardSecondFactorDisabled Nothing pid <- wrapClientE (DB.lookupKey (mkEmailKey (providerLoginEmail l))) >>= maybeBadCredentials pass <- wrapClientE (DB.lookupPassword pid) >>= maybeBadCredentials unless (verifyPassword (providerLoginPassword l) pass) $ throwStd (errorToWai @'E.BadCredentials) - ZAuth.newProviderToken pid - -beginPasswordResetH :: Member GalleyProvider r => JsonRequest Public.PasswordReset -> (Handler r) Response -beginPasswordResetH req = do - guardSecondFactorDisabled Nothing - setStatus status201 empty <$ (beginPasswordReset =<< parseJsonBody req) + token <- ZAuth.newProviderToken pid + s <- view settings + pure $ ProviderTokenCookie (ProviderToken token) (not (setCookieInsecure s)) -beginPasswordReset :: Public.PasswordReset -> (Handler r) () +beginPasswordReset :: Member GalleyProvider r => Public.PasswordReset -> (Handler r) () beginPasswordReset (Public.PasswordReset target) = do + guardSecondFactorDisabled Nothing pid <- wrapClientE (DB.lookupKey (mkEmailKey target)) >>= maybeBadCredentials gen <- Code.mkGen (Code.ForEmail target) pending <- lift . wrapClient $ Code.lookup (Code.genKey gen) Code.PasswordReset @@ -472,20 +290,16 @@ beginPasswordReset (Public.PasswordReset target) = do tryInsertVerificationCode code $ verificationCodeThrottledError . VerificationCodeThrottled lift $ sendPasswordResetMail target (Code.codeKey code) (Code.codeValue code) -completePasswordResetH :: Member GalleyProvider r => JsonRequest Public.CompletePasswordReset -> (Handler r) Response -completePasswordResetH req = do - guardSecondFactorDisabled Nothing - empty <$ (completePasswordReset =<< parseJsonBody req) - -completePasswordReset :: Public.CompletePasswordReset -> (Handler r) () +completePasswordReset :: Member GalleyProvider r => Public.CompletePasswordReset -> (Handler r) () completePasswordReset (Public.CompletePasswordReset key val newpwd) = do + guardSecondFactorDisabled Nothing code <- wrapClientE (Code.verify key Code.PasswordReset val) >>= maybeInvalidCode case Id <$> Code.codeAccount code of - Nothing -> throwE $ pwResetError InvalidPasswordResetCode + Nothing -> throwStd (errorToWai @'E.InvalidPasswordResetCode) Just pid -> do oldpass <- wrapClientE (DB.lookupPassword pid) >>= maybeBadCredentials when (verifyPassword newpwd oldpass) $ do - throwStd newPasswordMustDiffer + throwStd (errorToWai @'E.ResetPasswordMustDiffer) wrapClientE $ do DB.updateAccountPassword pid newpwd Code.delete key Code.PasswordReset @@ -493,23 +307,14 @@ completePasswordReset (Public.CompletePasswordReset key val newpwd) = do -------------------------------------------------------------------------------- -- Provider API -getAccountH :: Member GalleyProvider r => ProviderId -> (Handler r) Response -getAccountH pid = do - guardSecondFactorDisabled Nothing - getAccount pid <&> \case - Just p -> json p - Nothing -> setStatus status404 empty - -getAccount :: ProviderId -> (Handler r) (Maybe Public.Provider) -getAccount = wrapClientE . DB.lookupAccount - -updateAccountProfileH :: Member GalleyProvider r => ProviderId ::: JsonRequest Public.UpdateProvider -> (Handler r) Response -updateAccountProfileH (pid ::: req) = do +getAccount :: Member GalleyProvider r => ProviderId -> (Handler r) (Maybe Public.Provider) +getAccount pid = do guardSecondFactorDisabled Nothing - empty <$ (updateAccountProfile pid =<< parseJsonBody req) + wrapClientE $ DB.lookupAccount pid -updateAccountProfile :: ProviderId -> Public.UpdateProvider -> (Handler r) () +updateAccountProfile :: Member GalleyProvider r => ProviderId -> Public.UpdateProvider -> (Handler r) () updateAccountProfile pid upd = do + guardSecondFactorDisabled Nothing _ <- wrapClientE (DB.lookupAccount pid) >>= maybeInvalidProvider wrapClientE $ DB.updateAccountProfile @@ -518,13 +323,9 @@ updateAccountProfile pid upd = do (updateProviderUrl upd) (updateProviderDescr upd) -updateAccountEmailH :: Member GalleyProvider r => ProviderId ::: JsonRequest Public.EmailUpdate -> (Handler r) Response -updateAccountEmailH (pid ::: req) = do - guardSecondFactorDisabled Nothing - setStatus status202 empty <$ (updateAccountEmail pid =<< parseJsonBody req) - -updateAccountEmail :: ProviderId -> Public.EmailUpdate -> (Handler r) () +updateAccountEmail :: Member GalleyProvider r => ProviderId -> Public.EmailUpdate -> (Handler r) () updateAccountEmail pid (Public.EmailUpdate new) = do + guardSecondFactorDisabled Nothing email <- case validateEmail new of Right em -> pure em Left _ -> throwStd (errorToWai @'E.InvalidEmail) @@ -541,27 +342,23 @@ updateAccountEmail pid (Public.EmailUpdate new) = do tryInsertVerificationCode code $ verificationCodeThrottledError . VerificationCodeThrottled lift $ sendActivationMail (Name "name") email (Code.codeKey code) (Code.codeValue code) True -updateAccountPasswordH :: Member GalleyProvider r => ProviderId ::: JsonRequest Public.PasswordChange -> (Handler r) Response -updateAccountPasswordH (pid ::: req) = do - guardSecondFactorDisabled Nothing - empty <$ (updateAccountPassword pid =<< parseJsonBody req) - -updateAccountPassword :: ProviderId -> Public.PasswordChange -> (Handler r) () +updateAccountPassword :: Member GalleyProvider r => ProviderId -> Public.PasswordChange -> (Handler r) () updateAccountPassword pid upd = do + guardSecondFactorDisabled Nothing pass <- wrapClientE (DB.lookupPassword pid) >>= maybeBadCredentials - unless (verifyPassword (cpOldPassword upd) pass) $ + unless (verifyPassword (oldPassword upd) pass) $ throwStd (errorToWai @'E.BadCredentials) - when (verifyPassword (cpNewPassword upd) pass) $ - throwStd newPasswordMustDiffer - wrapClientE $ DB.updateAccountPassword pid (cpNewPassword upd) - -addServiceH :: Member GalleyProvider r => ProviderId ::: JsonRequest Public.NewService -> (Handler r) Response -addServiceH (pid ::: req) = do - guardSecondFactorDisabled Nothing - setStatus status201 . json <$> (addService pid =<< parseJsonBody req) + when (verifyPassword (newPassword upd) pass) $ + throwStd (errorToWai @'E.ResetPasswordMustDiffer) + wrapClientE $ DB.updateAccountPassword pid (newPassword upd) -addService :: ProviderId -> Public.NewService -> (Handler r) Public.NewServiceResponse +addService :: + Member GalleyProvider r => + ProviderId -> + Public.NewService -> + (Handler r) Public.NewServiceResponse addService pid new = do + guardSecondFactorDisabled Nothing _ <- wrapClientE (DB.lookupAccount pid) >>= maybeInvalidProvider let name = newServiceName new let summary = fromRange (newServiceSummary new) @@ -576,30 +373,28 @@ addService pid new = do let rstoken = maybe (Just token) (const Nothing) (newServiceToken new) pure $ Public.NewServiceResponse sid rstoken -listServicesH :: Member GalleyProvider r => ProviderId -> (Handler r) Response -listServicesH pid = do +listServices :: Member GalleyProvider r => ProviderId -> (Handler r) [Public.Service] +listServices pid = do guardSecondFactorDisabled Nothing - json <$> listServices pid + wrapClientE $ DB.listServices pid -listServices :: ProviderId -> (Handler r) [Public.Service] -listServices = wrapClientE . DB.listServices - -getServiceH :: Member GalleyProvider r => ProviderId ::: ServiceId -> (Handler r) Response -getServiceH (pid ::: sid) = do +getService :: + Member GalleyProvider r => + ProviderId -> + ServiceId -> + (Handler r) Public.Service +getService pid sid = do guardSecondFactorDisabled Nothing - json <$> getService pid sid - -getService :: ProviderId -> ServiceId -> (Handler r) Public.Service -getService pid sid = wrapClientE (DB.lookupService pid sid) >>= maybeServiceNotFound -updateServiceH :: Member GalleyProvider r => ProviderId ::: ServiceId ::: JsonRequest Public.UpdateService -> (Handler r) Response -updateServiceH (pid ::: sid ::: req) = do - guardSecondFactorDisabled Nothing - empty <$ (updateService pid sid =<< parseJsonBody req) - -updateService :: ProviderId -> ServiceId -> Public.UpdateService -> (Handler r) () +updateService :: + Member GalleyProvider r => + ProviderId -> + ServiceId -> + Public.UpdateService -> + (Handler r) NoContent updateService pid sid upd = do + guardSecondFactorDisabled Nothing _ <- wrapClientE (DB.lookupAccount pid) >>= maybeInvalidProvider -- Update service profile svc <- wrapClientE (DB.lookupService pid sid) >>= maybeServiceNotFound @@ -625,14 +420,16 @@ updateService pid sid upd = do newAssets tagsChange (serviceEnabled svc) + $> NoContent -updateServiceConnH :: Member GalleyProvider r => ProviderId ::: ServiceId ::: JsonRequest Public.UpdateServiceConn -> (Handler r) Response -updateServiceConnH (pid ::: sid ::: req) = do - guardSecondFactorDisabled Nothing - empty <$ (updateServiceConn pid sid =<< parseJsonBody req) - -updateServiceConn :: ProviderId -> ServiceId -> Public.UpdateServiceConn -> (Handler r) () +updateServiceConn :: + Member GalleyProvider r => + ProviderId -> + ServiceId -> + Public.UpdateServiceConn -> + (Handler r) NoContent updateServiceConn pid sid upd = do + guardSecondFactorDisabled Nothing pass <- wrapClientE (DB.lookupPassword pid) >>= maybeBadCredentials unless (verifyPassword (updateServiceConnPassword upd) pass) $ throwStd (errorToWai @'E.BadCredentials) @@ -664,26 +461,23 @@ updateServiceConn pid sid upd = do if sconEnabled scon then DB.deleteServiceIndexes pid sid name tags else DB.insertServiceIndexes pid sid name tags + pure NoContent -- TODO: Send informational email to provider. --- | Member GalleyProvider r => The endpoint that is called to delete a service. --- --- Since deleting a service can be costly, it just marks the service as --- disabled and then creates an event that will, when processed, actually --- delete the service. See 'finishDeleteService'. -deleteServiceH :: Member GalleyProvider r => ProviderId ::: ServiceId ::: JsonRequest Public.DeleteService -> (Handler r) Response -deleteServiceH (pid ::: sid ::: req) = do - guardSecondFactorDisabled Nothing - setStatus status202 empty <$ (deleteService pid sid =<< parseJsonBody req) - -- | The endpoint that is called to delete a service. -- -- Since deleting a service can be costly, it just marks the service as -- disabled and then creates an event that will, when processed, actually -- delete the service. See 'finishDeleteService'. -deleteService :: ProviderId -> ServiceId -> Public.DeleteService -> (Handler r) () +deleteService :: + Member GalleyProvider r => + ProviderId -> + ServiceId -> + Public.DeleteService -> + (Handler r) () deleteService pid sid del = do + guardSecondFactorDisabled Nothing pass <- wrapClientE (DB.lookupPassword pid) >>= maybeBadCredentials unless (verifyPassword (deleteServicePassword del) pass) $ throwStd (errorToWai @'E.BadCredentials) @@ -719,143 +513,94 @@ finishDeleteService pid sid = do where kick (bid, cid, _) = deleteBot (botUserId bid) Nothing bid cid -deleteAccountH :: - Member GalleyProvider r => - ProviderId ::: JsonRequest Public.DeleteProvider -> - ExceptT Error (AppT r) Response -deleteAccountH (pid ::: req) = do - guardSecondFactorDisabled Nothing - empty - <$ mapExceptT - wrapHttpClient - ( deleteAccount pid - =<< parseJsonBody req - ) - deleteAccount :: - ( MonadReader Env m, - MonadMask m, - MonadHttp m, - MonadClient m, - HasRequestId m, - MonadLogger m + ( Member GalleyProvider r ) => ProviderId -> Public.DeleteProvider -> - ExceptT Error m () + (Handler r) () deleteAccount pid del = do - prov <- DB.lookupAccount pid >>= maybeInvalidProvider - pass <- DB.lookupPassword pid >>= maybeBadCredentials + guardSecondFactorDisabled Nothing + prov <- wrapClientE (DB.lookupAccount pid) >>= maybeInvalidProvider + pass <- wrapClientE (DB.lookupPassword pid) >>= maybeBadCredentials unless (verifyPassword (deleteProviderPassword del) pass) $ throwStd (errorToWai @'E.BadCredentials) - svcs <- DB.listServices pid + svcs <- wrapClientE $ DB.listServices pid forM_ svcs $ \svc -> do let sid = serviceId svc let tags = unsafeRange (serviceTags svc) name = serviceName svc - lift $ RPC.removeServiceConn pid sid - DB.deleteService pid sid name tags - DB.deleteKey (mkEmailKey (providerEmail prov)) - DB.deleteAccount pid + lift $ wrapHttpClient $ RPC.removeServiceConn pid sid + wrapClientE $ DB.deleteService pid sid name tags + wrapClientE $ DB.deleteKey (mkEmailKey (providerEmail prov)) + wrapClientE $ DB.deleteAccount pid -------------------------------------------------------------------------------- -- User API -getProviderProfileH :: Member GalleyProvider r => ProviderId -> (Handler r) Response -getProviderProfileH pid = do +getProviderProfile :: Member GalleyProvider r => UserId -> ProviderId -> (Handler r) (Maybe Public.ProviderProfile) +getProviderProfile _ pid = do guardSecondFactorDisabled Nothing - json <$> getProviderProfile pid - -getProviderProfile :: ProviderId -> (Handler r) Public.ProviderProfile -getProviderProfile pid = - wrapClientE (DB.lookupAccountProfile pid) >>= maybeProviderNotFound + wrapClientE (DB.lookupAccountProfile pid) -listServiceProfilesH :: Member GalleyProvider r => ProviderId -> (Handler r) Response -listServiceProfilesH pid = do +listServiceProfiles :: Member GalleyProvider r => UserId -> ProviderId -> (Handler r) [Public.ServiceProfile] +listServiceProfiles _ pid = do guardSecondFactorDisabled Nothing - json <$> listServiceProfiles pid - -listServiceProfiles :: ProviderId -> (Handler r) [Public.ServiceProfile] -listServiceProfiles = wrapClientE . DB.listServiceProfiles + wrapClientE $ DB.listServiceProfiles pid -getServiceProfileH :: Member GalleyProvider r => ProviderId ::: ServiceId -> (Handler r) Response -getServiceProfileH (pid ::: sid) = do +getServiceProfile :: Member GalleyProvider r => UserId -> ProviderId -> ServiceId -> (Handler r) Public.ServiceProfile +getServiceProfile _ pid sid = do guardSecondFactorDisabled Nothing - json <$> getServiceProfile pid sid - -getServiceProfile :: ProviderId -> ServiceId -> (Handler r) Public.ServiceProfile -getServiceProfile pid sid = wrapClientE (DB.lookupServiceProfile pid sid) >>= maybeServiceNotFound -searchServiceProfilesH :: Member GalleyProvider r => Maybe (Public.QueryAnyTags 1 3) ::: Maybe Text ::: Range 10 100 Int32 -> (Handler r) Response -searchServiceProfilesH (qt ::: start ::: size) = do - guardSecondFactorDisabled Nothing - json <$> searchServiceProfiles qt start size - -- TODO: in order to actually make it possible for clients to implement -- pagination here, we need both 'start' and 'prefix'. -- -- Also see Note [buggy pagination]. -searchServiceProfiles :: Maybe (Public.QueryAnyTags 1 3) -> Maybe Text -> Range 10 100 Int32 -> (Handler r) Public.ServiceProfilePage -searchServiceProfiles Nothing (Just start) size = do +searchServiceProfiles :: Member GalleyProvider r => UserId -> Maybe (Public.QueryAnyTags 1 3) -> Maybe Text -> Maybe (Range 10 100 Int32) -> (Handler r) Public.ServiceProfilePage +searchServiceProfiles _ Nothing (Just start) mSize = do + guardSecondFactorDisabled Nothing prefix :: Range 1 128 Text <- rangeChecked start + let size = fromMaybe (unsafeRange 20) mSize wrapClientE . DB.paginateServiceNames (Just prefix) (fromRange size) . setProviderSearchFilter =<< view settings -searchServiceProfiles (Just tags) start size = do +searchServiceProfiles _ (Just tags) start mSize = do + guardSecondFactorDisabled Nothing + let size = fromMaybe (unsafeRange 20) mSize (wrapClientE . DB.paginateServiceTags tags start (fromRange size)) . setProviderSearchFilter =<< view settings -searchServiceProfiles Nothing Nothing _ = do +searchServiceProfiles _ Nothing Nothing _ = do + guardSecondFactorDisabled Nothing throwStd $ badRequest "At least `tags` or `start` must be provided." -searchTeamServiceProfilesH :: - Member GalleyProvider r => - UserId ::: TeamId ::: Maybe (Range 1 128 Text) ::: Bool ::: Range 10 100 Int32 -> - (Handler r) Response -searchTeamServiceProfilesH (uid ::: tid ::: prefix ::: filterDisabled ::: size) = do - guardSecondFactorDisabled (Just uid) - json <$> searchTeamServiceProfiles uid tid prefix filterDisabled size - -- NB: unlike 'searchServiceProfiles', we don't filter by service provider here searchTeamServiceProfiles :: UserId -> TeamId -> Maybe (Range 1 128 Text) -> - Bool -> - Range 10 100 Int32 -> + Maybe Bool -> + Maybe (Range 10 100 Int32) -> (Handler r) Public.ServiceProfilePage -searchTeamServiceProfiles uid tid prefix filterDisabled size = do +searchTeamServiceProfiles uid tid prefix mFilterDisabled mSize = do -- Check that the user actually belong to the team they claim they -- belong to. (Note: the 'tid' team might not even exist but we'll throw -- 'insufficientTeamPermissions' anyway) + let filterDisabled = fromMaybe True mFilterDisabled + let size = fromMaybe (unsafeRange 20) mSize teamId <- lift $ wrapClient $ User.lookupUserTeam uid unless (Just tid == teamId) $ throwStd insufficientTeamPermissions -- Get search results wrapClientE $ DB.paginateServiceWhitelist tid prefix filterDisabled (fromRange size) -getServiceTagListH :: Member GalleyProvider r => () -> (Handler r) Response -getServiceTagListH () = do +getServiceTagList :: Member GalleyProvider r => UserId -> (Handler r) Public.ServiceTagList +getServiceTagList _ = do guardSecondFactorDisabled Nothing - json <$> getServiceTagList () - -getServiceTagList :: () -> Monad m => m Public.ServiceTagList -getServiceTagList () = pure (Public.ServiceTagList allTags) + pure (Public.ServiceTagList allTags) where allTags = [(minBound :: Public.ServiceTag) ..] -updateServiceWhitelistH :: Member GalleyProvider r => UserId ::: ConnId ::: TeamId ::: JsonRequest Public.UpdateServiceWhitelist -> (Handler r) Response -updateServiceWhitelistH (uid ::: con ::: tid ::: req) = do - guardSecondFactorDisabled (Just uid) - resp <- updateServiceWhitelist uid con tid =<< parseJsonBody req - let status = case resp of - UpdateServiceWhitelistRespChanged -> status200 - UpdateServiceWhitelistRespUnchanged -> status204 - pure $ setStatus status empty - -data UpdateServiceWhitelistResp - = UpdateServiceWhitelistRespChanged - | UpdateServiceWhitelistRespUnchanged - updateServiceWhitelist :: Member GalleyProvider r => UserId -> ConnId -> TeamId -> Public.UpdateServiceWhitelist -> (Handler r) UpdateServiceWhitelistResp updateServiceWhitelist uid con tid upd = do + guardSecondFactorDisabled (Just uid) let pid = updateServiceWhitelistProvider upd sid = updateServiceWhitelistService upd newWhitelisted = updateServiceWhitelistStatus upd @@ -887,13 +632,12 @@ updateServiceWhitelist uid con tid upd = do wrapClientE $ DB.deleteServiceWhitelist (Just tid) pid sid pure UpdateServiceWhitelistRespChanged -addBotH :: Member GalleyProvider r => UserId ::: ConnId ::: ConvId ::: JsonRequest Public.AddBot -> (Handler r) Response -addBotH (zuid ::: zcon ::: cid ::: req) = do - guardSecondFactorDisabled (Just zuid) - setStatus status201 . json <$> (addBot zuid zcon cid =<< parseJsonBody req) +-------------------------------------------------------------------------------- +-- Bot API addBot :: Member GalleyProvider r => UserId -> ConnId -> ConvId -> Public.AddBot -> (Handler r) Public.AddBotResponse addBot zuid zcon cid add = do + guardSecondFactorDisabled (Just zuid) zusr <- lift (wrapClient $ User.lookupUser NoPendingInvitations zuid) >>= maybeInvalidUser let pid = addBotProvider add let sid = addBotService add @@ -910,18 +654,18 @@ addBot zuid zcon cid add = do guardConvAdmin cnv let mems = cnvMembers cnv unless (cnvType cnv == RegularConv) $ - throwStd invalidConv + throwStd (errorToWai @'E.InvalidConversation) maxSize <- fromIntegral . setMaxConvSize <$> view settings unless (length (cmOthers mems) < maxSize - 1) $ - throwStd tooManyMembers + throwStd (errorToWai @'E.TooManyConversationMembers) -- For team conversations: bots are not allowed in -- team-only conversations unless (Set.member ServiceAccessRole (cnvAccessRoles cnv)) $ - throwStd invalidConv + throwStd (errorToWai @'E.InvalidConversation) -- Lookup the relevant service data scon <- wrapClientE (DB.lookupServiceConn pid sid) >>= maybeServiceNotFound unless (sconEnabled scon) $ - throwStd serviceDisabled + throwStd (errorToWai @'E.ServiceDisabled) svp <- wrapClientE (DB.lookupServiceProfile pid sid) >>= maybeServiceNotFound for_ (cnvTeam cnv) $ \tid -> do whitelisted <- wrapClientE $ DB.getServiceWhitelistStatus tid pid sid @@ -975,13 +719,9 @@ addBot zuid zcon cid add = do Public.rsAddBotEvent = ev } -removeBotH :: Member GalleyProvider r => UserId ::: ConnId ::: ConvId ::: BotId -> (Handler r) Response -removeBotH (zusr ::: zcon ::: cid ::: bid) = do - guardSecondFactorDisabled (Just zusr) - maybe (setStatus status204 empty) json <$> removeBot zusr zcon cid bid - removeBot :: Member GalleyProvider r => UserId -> ConnId -> ConvId -> BotId -> (Handler r) (Maybe Public.RemoveBotResponse) removeBot zusr zcon cid bid = do + guardSecondFactorDisabled (Just zusr) -- Get the conversation and check preconditions lcid <- qualifyLocal cid cnv <- lift (liftSem $ GalleyProvider.getConv zusr lcid) >>= maybeConvNotFound @@ -993,7 +733,7 @@ removeBot zusr zcon cid bid = do guardConvAdmin cnv let mems = cnvMembers cnv unless (cnvType cnv == RegularConv) $ - throwStd invalidConv + (throwStd (errorToWai @'E.InvalidConversation)) -- Find the bot in the member list and delete it let busr = botUserId bid let bot = List.find ((== busr) . qUnqualified . omQualifiedId) (cmOthers mems) @@ -1005,49 +745,29 @@ removeBot zusr zcon cid bid = do guardConvAdmin :: Conversation -> ExceptT Error (AppT r) () guardConvAdmin conv = do let selfMember = cmSelf . cnvMembers $ conv - unless (memConvRoleName selfMember == roleNameWireAdmin) $ throwStd accessDenied - --------------------------------------------------------------------------------- --- Bot API - -botGetSelfH :: Member GalleyProvider r => BotId -> (Handler r) Response -botGetSelfH bot = do - guardSecondFactorDisabled (Just (botUserId bot)) - json <$> botGetSelf bot + unless (memConvRoleName selfMember == roleNameWireAdmin) $ (throwStd (errorToWai @'E.AccessDenied)) botGetSelf :: BotId -> (Handler r) Public.UserProfile botGetSelf bot = do p <- lift $ wrapClient $ User.lookupUser NoPendingInvitations (botUserId bot) maybe (throwStd (errorToWai @'E.UserNotFound)) (pure . (`Public.publicProfile` UserLegalHoldNoConsent)) p -botGetClientH :: Member GalleyProvider r => BotId -> (Handler r) Response -botGetClientH bot = do - guardSecondFactorDisabled (Just (botUserId bot)) - maybe (throwStd (errorToWai @'E.ClientNotFound)) (pure . json) =<< lift (botGetClient bot) - -botGetClient :: BotId -> (AppT r) (Maybe Public.Client) -botGetClient bot = - listToMaybe <$> wrapClient (User.lookupClients (botUserId bot)) - -botListPrekeysH :: Member GalleyProvider r => BotId -> (Handler r) Response -botListPrekeysH bot = do +botGetClient :: Member GalleyProvider r => BotId -> (Handler r) (Maybe Public.Client) +botGetClient bot = do guardSecondFactorDisabled (Just (botUserId bot)) - json <$> botListPrekeys bot + lift $ listToMaybe <$> wrapClient (User.lookupClients (botUserId bot)) -botListPrekeys :: BotId -> (Handler r) [Public.PrekeyId] +botListPrekeys :: Member GalleyProvider r => BotId -> (Handler r) [Public.PrekeyId] botListPrekeys bot = do + guardSecondFactorDisabled (Just (botUserId bot)) clt <- lift $ listToMaybe <$> wrapClient (User.lookupClients (botUserId bot)) case clientId <$> clt of Nothing -> pure [] Just ci -> lift (wrapClient $ User.lookupPrekeyIds (botUserId bot) ci) -botUpdatePrekeysH :: Member GalleyProvider r => BotId ::: JsonRequest Public.UpdateBotPrekeys -> (Handler r) Response -botUpdatePrekeysH (bot ::: req) = do - guardSecondFactorDisabled (Just (botUserId bot)) - empty <$ (botUpdatePrekeys bot =<< parseJsonBody req) - -botUpdatePrekeys :: BotId -> Public.UpdateBotPrekeys -> (Handler r) () +botUpdatePrekeys :: Member GalleyProvider r => BotId -> Public.UpdateBotPrekeys -> (Handler r) () botUpdatePrekeys bot upd = do + guardSecondFactorDisabled (Just (botUserId bot)) clt <- lift $ listToMaybe <$> wrapClient (User.lookupClients (botUserId bot)) case clt of Nothing -> throwStd (errorToWai @'E.ClientNotFound) @@ -1055,57 +775,36 @@ botUpdatePrekeys bot upd = do let pks = updateBotPrekeyList upd wrapClientE (User.updatePrekeys (botUserId bot) (clientId c) pks) !>> clientDataError -botClaimUsersPrekeysH :: - ( Member GalleyProvider r, - Member (Concurrency 'Unsafe) r - ) => - JsonRequest Public.UserClients -> - Handler r Response -botClaimUsersPrekeysH req = do - guardSecondFactorDisabled Nothing - json <$> (botClaimUsersPrekeys =<< parseJsonBody req) - botClaimUsersPrekeys :: - Member (Concurrency 'Unsafe) r => + (Member (Concurrency 'Unsafe) r, Member GalleyProvider r) => + BotId -> Public.UserClients -> Handler r Public.UserClientPrekeyMap -botClaimUsersPrekeys body = do +botClaimUsersPrekeys _ body = do + guardSecondFactorDisabled Nothing maxSize <- fromIntegral . setMaxConvSize <$> view settings when (Map.size (Public.userClients body) > maxSize) $ throwStd (errorToWai @'E.TooManyClients) Client.claimLocalMultiPrekeyBundles UnprotectedBot body !>> clientError -botListUserProfilesH :: Member GalleyProvider r => List UserId -> (Handler r) Response -botListUserProfilesH uids = do +botListUserProfiles :: Member GalleyProvider r => BotId -> (CommaSeparatedList UserId) -> (Handler r) [Public.BotUserView] +botListUserProfiles _ uids = do guardSecondFactorDisabled Nothing -- should we check all user ids? - json <$> botListUserProfiles uids - -botListUserProfiles :: List UserId -> (Handler r) [Public.BotUserView] -botListUserProfiles uids = do - us <- lift . wrapClient $ User.lookupUsers NoPendingInvitations (fromList uids) + us <- lift . wrapClient $ User.lookupUsers NoPendingInvitations (fromCommaSeparatedList uids) pure (map mkBotUserView us) -botGetUserClientsH :: Member GalleyProvider r => UserId -> (Handler r) Response -botGetUserClientsH uid = do +botGetUserClients :: Member GalleyProvider r => BotId -> UserId -> (Handler r) [Public.PubClient] +botGetUserClients _ uid = do guardSecondFactorDisabled (Just uid) - json <$> lift (botGetUserClients uid) - -botGetUserClients :: UserId -> (AppT r) [Public.PubClient] -botGetUserClients uid = - pubClient <$$> wrapClient (User.lookupClients uid) + lift $ pubClient <$$> wrapClient (User.lookupClients uid) where pubClient c = Public.PubClient (clientId c) (clientClass c) -botDeleteSelfH :: Member GalleyProvider r => BotId ::: ConvId -> (Handler r) Response -botDeleteSelfH (bid ::: cid) = do - guardSecondFactorDisabled (Just (botUserId bid)) - empty <$ botDeleteSelf bid cid - botDeleteSelf :: Member GalleyProvider r => BotId -> ConvId -> (Handler r) () botDeleteSelf bid cid = do guardSecondFactorDisabled (Just (botUserId bid)) bot <- lift . wrapClient $ User.lookupUser NoPendingInvitations (botUserId bid) - _ <- maybeInvalidBot (userService =<< bot) + _ <- maybe (throwStd (errorToWai @'E.InvalidBot)) pure $ (userService =<< bot) _ <- lift $ wrapHttpClient $ deleteBot (botUserId bid) Nothing bid cid pure () @@ -1120,7 +819,7 @@ guardSecondFactorDisabled :: ExceptT Error (AppT r) () guardSecondFactorDisabled mbUserId = do enabled <- lift $ liftSem $ (==) Feature.FeatureStatusEnabled . Feature.wsStatus . Feature.afcSndFactorPasswordChallenge <$> GalleyProvider.getAllFeatureConfigsForUser mbUserId - when enabled $ throwStd accessDenied + when enabled $ (throwStd (errorToWai @'E.AccessDenied)) minRsaKeySize :: Int minRsaKeySize = 256 -- Bytes (= 2048 bits) @@ -1194,33 +893,14 @@ mkBotUserView u = Ext.botUserViewTeam = userTeam u } -setProviderCookie :: ZAuth.Token ZAuth.Provider -> Response -> (Handler r) Response -setProviderCookie t r = do - s <- view settings - let hdr = toByteString' (Cookie.renderSetCookie (cookie s)) - pure (addHeader "Set-Cookie" hdr r) - where - cookie s = - Cookie.def - { Cookie.setCookieName = "zprovider", - Cookie.setCookieValue = toByteString' t, - Cookie.setCookiePath = Just "/provider", - Cookie.setCookieExpires = Just (ZAuth.tokenExpiresUTC t), - Cookie.setCookieSecure = not (setCookieInsecure s), - Cookie.setCookieHttpOnly = True - } - maybeInvalidProvider :: Monad m => Maybe a -> (ExceptT Error m) a -maybeInvalidProvider = maybe (throwStd invalidProvider) pure +maybeInvalidProvider = maybe (throwStd (errorToWai @'E.ProviderNotFound)) pure maybeInvalidCode :: Monad m => Maybe a -> (ExceptT Error m) a maybeInvalidCode = maybe (throwStd (errorToWai @'E.InvalidCode)) pure maybeServiceNotFound :: Monad m => Maybe a -> (ExceptT Error m) a -maybeServiceNotFound = maybe (throwStd (notFound "Service not found")) pure - -maybeProviderNotFound :: Monad m => Maybe a -> (ExceptT Error m) a -maybeProviderNotFound = maybe (throwStd (notFound "Provider not found")) pure +maybeServiceNotFound = maybe (throwStd (errorToWai @'E.ServiceNotFound)) pure maybeConvNotFound :: Monad m => Maybe a -> (ExceptT Error m) a maybeConvNotFound = maybe (throwStd (notFound "Conversation not found")) pure @@ -1229,10 +909,7 @@ maybeBadCredentials :: Monad m => Maybe a -> (ExceptT Error m) a maybeBadCredentials = maybe (throwStd (errorToWai @'E.BadCredentials)) pure maybeInvalidServiceKey :: Monad m => Maybe a -> (ExceptT Error m) a -maybeInvalidServiceKey = maybe (throwStd invalidServiceKey) pure - -maybeInvalidBot :: Monad m => Maybe a -> (ExceptT Error m) a -maybeInvalidBot = maybe (throwStd invalidBot) pure +maybeInvalidServiceKey = maybe (throwStd (errorToWai @'E.InvalidServiceKey)) pure maybeInvalidUser :: Monad m => Maybe a -> (ExceptT Error m) a maybeInvalidUser = maybe (throwStd (errorToWai @'E.InvalidUser)) pure @@ -1240,30 +917,12 @@ maybeInvalidUser = maybe (throwStd (errorToWai @'E.InvalidUser)) pure rangeChecked :: (KnownNat n, KnownNat m, Within a n m, Monad monad) => a -> (ExceptT Error monad) (Range n m a) rangeChecked = either (throwStd . invalidRange . fromString) pure . checkedEither -invalidServiceKey :: Wai.Error -invalidServiceKey = Wai.mkError status400 "invalid-service-key" "Invalid service key." - -invalidProvider :: Wai.Error -invalidProvider = Wai.mkError status403 "invalid-provider" "The provider does not exist." - -invalidBot :: Wai.Error -invalidBot = Wai.mkError status403 "invalid-bot" "The targeted user is not a bot." - -invalidConv :: Wai.Error -invalidConv = Wai.mkError status403 "invalid-conversation" "The operation is not allowed in this conversation." - badGateway :: Wai.Error badGateway = Wai.mkError status502 "bad-gateway" "The upstream service returned an invalid response." -tooManyMembers :: Wai.Error -tooManyMembers = Wai.mkError status403 "too-many-members" "Maximum number of members per conversation reached." - tooManyBots :: Wai.Error tooManyBots = Wai.mkError status409 "too-many-bots" "Maximum number of bots for the service reached." -serviceDisabled :: Wai.Error -serviceDisabled = Wai.mkError status403 "service-disabled" "The desired service is currently disabled." - serviceNotWhitelisted :: Wai.Error serviceNotWhitelisted = Wai.mkError status403 "service-not-whitelisted" "The desired service is not on the whitelist of allowed services for this team." @@ -1271,8 +930,5 @@ serviceError :: RPC.ServiceError -> Wai.Error serviceError RPC.ServiceUnavailable = badGateway serviceError RPC.ServiceBotConflict = tooManyBots -accessDenied :: Wai.Error -accessDenied = Wai.mkError status403 "access-denied" "Access denied." - randServiceToken :: MonadIO m => m Public.ServiceToken randServiceToken = ServiceToken . Ascii.encodeBase64Url <$> liftIO (randBytes 18) diff --git a/services/brig/src/Brig/Provider/DB.hs b/services/brig/src/Brig/Provider/DB.hs index ab7f85df1b9..e83919f5bb0 100644 --- a/services/brig/src/Brig/Provider/DB.hs +++ b/services/brig/src/Brig/Provider/DB.hs @@ -72,11 +72,11 @@ updateAccountProfile p name url descr = retry x5 . batch $ do for_ descr $ \x -> addPrepQuery cqlDescr (x, p) where cqlName :: PrepQuery W (Name, ProviderId) () - cqlName = "UPDATE provider SET name = ? WHERE id = ?" + cqlName = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE provider SET name = ? WHERE id = ?" cqlUrl :: PrepQuery W (HttpsUrl, ProviderId) () - cqlUrl = "UPDATE provider SET url = ? WHERE id = ?" + cqlUrl = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE provider SET url = ? WHERE id = ?" cqlDescr :: PrepQuery W (Text, ProviderId) () - cqlDescr = "UPDATE provider SET descr = ? WHERE id = ?" + cqlDescr = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE provider SET descr = ? WHERE id = ?" -- | Lookup the raw account data of a (possibly unverified) provider. lookupAccountData :: @@ -136,7 +136,7 @@ updateAccountPassword pid pwd = do retry x5 $ write cql $ params LocalQuorum (p, pid) where cql :: PrepQuery W (Password, ProviderId) () - cql = "UPDATE provider SET password = ? where id = ?" + cql = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE provider SET password = ? where id = ?" -------------------------------------------------------------------------------- -- Unique (Natural) Keys @@ -156,10 +156,12 @@ insertKey p old new = retry x5 . batch $ do where cqlKeyInsert :: PrepQuery W (Text, ProviderId) () cqlKeyInsert = "INSERT INTO provider_keys (key, provider) VALUES (?, ?)" + cqlKeyDelete :: PrepQuery W (Identity Text) () cqlKeyDelete = "DELETE FROM provider_keys WHERE key = ?" + cqlEmail :: PrepQuery W (Email, ProviderId) () - cqlEmail = "UPDATE provider SET email = ? WHERE id = ?" + cqlEmail = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE provider SET email = ? WHERE id = ?" lookupKey :: MonadClient m => @@ -306,15 +308,15 @@ updateService pid sid svcName svcTags nameChange summary descr assets tagsChange for_ assets $ \x -> addPrepQuery cqlAssets (x, pid, sid) where cqlName :: PrepQuery W (Name, ProviderId, ServiceId) () - cqlName = "UPDATE service SET name = ? WHERE provider = ? AND id = ?" + cqlName = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE service SET name = ? WHERE provider = ? AND id = ?" cqlSummary :: PrepQuery W (Text, ProviderId, ServiceId) () - cqlSummary = "UPDATE service SET summary = ? WHERE provider = ? AND id = ?" + cqlSummary = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE service SET summary = ? WHERE provider = ? AND id = ?" cqlDescr :: PrepQuery W (Text, ProviderId, ServiceId) () - cqlDescr = "UPDATE service SET descr = ? WHERE provider = ? AND id = ?" + cqlDescr = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE service SET descr = ? WHERE provider = ? AND id = ?" cqlAssets :: PrepQuery W ([Asset], ProviderId, ServiceId) () - cqlAssets = "UPDATE service SET assets = ? WHERE provider = ? AND id = ?" + cqlAssets = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE service SET assets = ? WHERE provider = ? AND id = ?" cqlTags :: PrepQuery W (C.Set ServiceTag, ProviderId, ServiceId) () - cqlTags = "UPDATE service SET tags = ? WHERE provider = ? AND id = ?" + cqlTags = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE service SET tags = ? WHERE provider = ? AND id = ?" -- NB: can take a significant amount of time if many teams were using the service deleteService :: @@ -436,16 +438,17 @@ updateServiceConn pid sid url tokens keys enabled = retry x5 . batch $ do for_ enabled $ \x -> addPrepQuery cqlEnabled (x, pid, sid) where (pks, fps) = (fmap fst &&& fmap snd) (unzip . toList <$> keys) + cqlBaseUrl :: PrepQuery W (HttpsUrl, ProviderId, ServiceId) () - cqlBaseUrl = "UPDATE service SET base_url = ? WHERE provider = ? AND id = ?" + cqlBaseUrl = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE service SET base_url = ? WHERE provider = ? AND id = ?" cqlTokens :: PrepQuery W (List1 ServiceToken, ProviderId, ServiceId) () - cqlTokens = "UPDATE service SET auth_tokens = ? WHERE provider = ? AND id = ?" + cqlTokens = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE service SET auth_tokens = ? WHERE provider = ? AND id = ?" cqlKeys :: PrepQuery W ([ServiceKey], ProviderId, ServiceId) () - cqlKeys = "UPDATE service SET pubkeys = ? WHERE provider = ? AND id = ?" + cqlKeys = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE service SET pubkeys = ? WHERE provider = ? AND id = ?" cqlFps :: PrepQuery W ([Fingerprint Rsa], ProviderId, ServiceId) () - cqlFps = "UPDATE service SET fingerprints = ? WHERE provider = ? AND id = ?" + cqlFps = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE service SET fingerprints = ? WHERE provider = ? AND id = ?" cqlEnabled :: PrepQuery W (Bool, ProviderId, ServiceId) () - cqlEnabled = "UPDATE service SET enabled = ? WHERE provider = ? AND id = ?" + cqlEnabled = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE service SET enabled = ? WHERE provider = ? AND id = ?" -------------------------------------------------------------------------------- -- Service "Indexes" (tag and prefix); contain only enabled services diff --git a/services/brig/src/Brig/Run.hs b/services/brig/src/Brig/Run.hs index 305a43911cc..f5ecdff2ae2 100644 --- a/services/brig/src/Brig/Run.hs +++ b/services/brig/src/Brig/Run.hs @@ -110,8 +110,8 @@ run o = do Async.cancel authMetrics closeEnv e where - endpoint = brig o - server e = defaultServer (unpack $ endpoint ^. epHost) (endpoint ^. epPort) (e ^. applog) (e ^. metrics) + endpoint' = brig o + server e = defaultServer (unpack $ endpoint' ^. host) (endpoint' ^. port) (e ^. applog) (e ^. metrics) mkApp :: Opts -> IO (Wai.Application, Env) mkApp o = do diff --git a/services/brig/src/Brig/Schema/Run.hs b/services/brig/src/Brig/Schema/Run.hs new file mode 100644 index 00000000000..f18e340bc6b --- /dev/null +++ b/services/brig/src/Brig/Schema/Run.hs @@ -0,0 +1,123 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Brig.Schema.Run where + +import Brig.Schema.V43 qualified as V43 +import Brig.Schema.V44 qualified as V44 +import Brig.Schema.V45 qualified as V45 +import Brig.Schema.V46 qualified as V46 +import Brig.Schema.V47 qualified as V47 +import Brig.Schema.V48 qualified as V48 +import Brig.Schema.V49 qualified as V49 +import Brig.Schema.V50 qualified as V50 +import Brig.Schema.V51 qualified as V51 +import Brig.Schema.V52 qualified as V52 +import Brig.Schema.V53 qualified as V53 +import Brig.Schema.V54 qualified as V54 +import Brig.Schema.V55 qualified as V55 +import Brig.Schema.V56 qualified as V56 +import Brig.Schema.V57 qualified as V57 +import Brig.Schema.V58 qualified as V58 +import Brig.Schema.V59 qualified as V59 +import Brig.Schema.V60_AddFederationIdMapping qualified as V60_AddFederationIdMapping +import Brig.Schema.V61_team_invitation_email qualified as V61_team_invitation_email +import Brig.Schema.V62_RemoveFederationIdMapping qualified as V62_RemoveFederationIdMapping +import Brig.Schema.V63_AddUsersPendingActivation qualified as V63_AddUsersPendingActivation +import Brig.Schema.V64_ClientCapabilities qualified as V64_ClientCapabilities +import Brig.Schema.V65_FederatedConnections qualified as V65_FederatedConnections +import Brig.Schema.V66_PersonalFeatureConfCallInit qualified as V66_PersonalFeatureConfCallInit +import Brig.Schema.V67_MLSKeyPackages qualified as V67_MLSKeyPackages +import Brig.Schema.V68_AddMLSPublicKeys qualified as V68_AddMLSPublicKeys +import Brig.Schema.V69_MLSKeyPackageRefMapping qualified as V69_MLSKeyPackageRefMapping +import Brig.Schema.V70_UserEmailUnvalidated qualified as V70_UserEmailUnvalidated +import Brig.Schema.V71_AddTableVCodesThrottle qualified as V71_AddTableVCodesThrottle +import Brig.Schema.V72_AddNonceTable qualified as V72_AddNonceTable +import Brig.Schema.V73_ReplaceNonceTable qualified as V73_ReplaceNonceTable +import Brig.Schema.V74_AddOAuthTables qualified as V74_AddOAuthTables +import Brig.Schema.V75_AddOAuthCodeChallenge qualified as V75_AddOAuthCodeChallenge +import Brig.Schema.V76_AddSupportedProtocols qualified as V76_AddSupportedProtocols +import Brig.Schema.V77_FederationRemotes qualified as V77_FederationRemotes +import Brig.Schema.V78_ClientLastActive qualified as V78_ClientLastActive +import Brig.Schema.V79_ConnectionRemoteIndex qualified as V79_ConnectionRemoteIndex +import Brig.Schema.V80_KeyPackageCiphersuite qualified as V80_KeyPackageCiphersuite +import Cassandra.Schema +import Control.Exception (finally) +import Imports +import System.Logger.Extended qualified as Log +import Util.Options + +main :: IO () +main = do + let desc = "Brig Cassandra Schema Migrations" + defaultPath = "/etc/wire/brig/conf/brig-schema.yaml" + o <- getOptions desc (Just migrationOptsParser) defaultPath + l <- Log.mkLogger' + migrateSchema + l + o + migrations + `finally` Log.close l + +lastSchemaVersion :: Int32 +lastSchemaVersion = migVersion $ last migrations + +migrations :: [Migration] +migrations = + [ V43.migration, + V44.migration, + V45.migration, + V46.migration, + V47.migration, + V48.migration, + V49.migration, + V50.migration, + V51.migration, + V52.migration, + V53.migration, + V54.migration, + V55.migration, + V56.migration, + V57.migration, + V58.migration, + V59.migration, + V60_AddFederationIdMapping.migration, + V61_team_invitation_email.migration, + V62_RemoveFederationIdMapping.migration, + V63_AddUsersPendingActivation.migration, + V64_ClientCapabilities.migration, + V65_FederatedConnections.migration, + V66_PersonalFeatureConfCallInit.migration, + V67_MLSKeyPackages.migration, + V68_AddMLSPublicKeys.migration, + V69_MLSKeyPackageRefMapping.migration, + V70_UserEmailUnvalidated.migration, + V71_AddTableVCodesThrottle.migration, + V72_AddNonceTable.migration, + V73_ReplaceNonceTable.migration, + V74_AddOAuthTables.migration, + V75_AddOAuthCodeChallenge.migration, + V76_AddSupportedProtocols.migration, + V77_FederationRemotes.migration, + V78_ClientLastActive.migration, + V79_ConnectionRemoteIndex.migration, + V80_KeyPackageCiphersuite.migration + -- FUTUREWORK: undo V41 (searchable flag); we stopped using it in + -- https://github.com/wireapp/wire-server/pull/964 + -- + -- FUTUREWORK after July 2023: integrate V_FUTUREWORK here. + ] diff --git a/services/brig/schema/src/V43.hs b/services/brig/src/Brig/Schema/V43.hs similarity index 99% rename from services/brig/schema/src/V43.hs rename to services/brig/src/Brig/Schema/V43.hs index 288ea41b2e1..42038f4d0a0 100644 --- a/services/brig/schema/src/V43.hs +++ b/services/brig/src/Brig/Schema/V43.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V43 +module Brig.Schema.V43 ( migration, ) where diff --git a/services/brig/schema/src/V44.hs b/services/brig/src/Brig/Schema/V44.hs similarity index 97% rename from services/brig/schema/src/V44.hs rename to services/brig/src/Brig/Schema/V44.hs index f441fa08e45..b1222e53ff1 100644 --- a/services/brig/schema/src/V44.hs +++ b/services/brig/src/Brig/Schema/V44.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V44 +module Brig.Schema.V44 ( migration, ) where diff --git a/services/brig/schema/src/V45.hs b/services/brig/src/Brig/Schema/V45.hs similarity index 97% rename from services/brig/schema/src/V45.hs rename to services/brig/src/Brig/Schema/V45.hs index bd6317f5192..070801bd535 100644 --- a/services/brig/schema/src/V45.hs +++ b/services/brig/src/Brig/Schema/V45.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V45 +module Brig.Schema.V45 ( migration, ) where diff --git a/services/brig/schema/src/V46.hs b/services/brig/src/Brig/Schema/V46.hs similarity index 97% rename from services/brig/schema/src/V46.hs rename to services/brig/src/Brig/Schema/V46.hs index bbb06e2a9d2..c4d223582cf 100644 --- a/services/brig/schema/src/V46.hs +++ b/services/brig/src/Brig/Schema/V46.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V46 +module Brig.Schema.V46 ( migration, ) where diff --git a/services/brig/schema/src/V47.hs b/services/brig/src/Brig/Schema/V47.hs similarity index 98% rename from services/brig/schema/src/V47.hs rename to services/brig/src/Brig/Schema/V47.hs index 98a924ce891..e6f94b5e3b3 100644 --- a/services/brig/schema/src/V47.hs +++ b/services/brig/src/Brig/Schema/V47.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V47 +module Brig.Schema.V47 ( migration, ) where diff --git a/services/brig/schema/src/V48.hs b/services/brig/src/Brig/Schema/V48.hs similarity index 97% rename from services/brig/schema/src/V48.hs rename to services/brig/src/Brig/Schema/V48.hs index c8b984b1f6c..44927c581eb 100644 --- a/services/brig/schema/src/V48.hs +++ b/services/brig/src/Brig/Schema/V48.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V48 +module Brig.Schema.V48 ( migration, ) where diff --git a/services/brig/schema/src/V49.hs b/services/brig/src/Brig/Schema/V49.hs similarity index 97% rename from services/brig/schema/src/V49.hs rename to services/brig/src/Brig/Schema/V49.hs index 0d20e87f620..b319ce45295 100644 --- a/services/brig/schema/src/V49.hs +++ b/services/brig/src/Brig/Schema/V49.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V49 +module Brig.Schema.V49 ( migration, ) where diff --git a/services/brig/schema/src/V50.hs b/services/brig/src/Brig/Schema/V50.hs similarity index 97% rename from services/brig/schema/src/V50.hs rename to services/brig/src/Brig/Schema/V50.hs index f0c3914c454..ccfda0525e7 100644 --- a/services/brig/schema/src/V50.hs +++ b/services/brig/src/Brig/Schema/V50.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V50 +module Brig.Schema.V50 ( migration, ) where diff --git a/services/brig/schema/src/V51.hs b/services/brig/src/Brig/Schema/V51.hs similarity index 98% rename from services/brig/schema/src/V51.hs rename to services/brig/src/Brig/Schema/V51.hs index e8f4d1846c3..e0e2b3b7489 100644 --- a/services/brig/schema/src/V51.hs +++ b/services/brig/src/Brig/Schema/V51.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V51 +module Brig.Schema.V51 ( migration, ) where diff --git a/services/brig/schema/src/V52.hs b/services/brig/src/Brig/Schema/V52.hs similarity index 98% rename from services/brig/schema/src/V52.hs rename to services/brig/src/Brig/Schema/V52.hs index 6ec1beccf8c..e102ffceac4 100644 --- a/services/brig/schema/src/V52.hs +++ b/services/brig/src/Brig/Schema/V52.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V52 +module Brig.Schema.V52 ( migration, ) where diff --git a/services/brig/schema/src/V53.hs b/services/brig/src/Brig/Schema/V53.hs similarity index 98% rename from services/brig/schema/src/V53.hs rename to services/brig/src/Brig/Schema/V53.hs index a76f3d10936..475eb68c6f5 100644 --- a/services/brig/schema/src/V53.hs +++ b/services/brig/src/Brig/Schema/V53.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V53 +module Brig.Schema.V53 ( migration, ) where diff --git a/services/brig/schema/src/V54.hs b/services/brig/src/Brig/Schema/V54.hs similarity index 97% rename from services/brig/schema/src/V54.hs rename to services/brig/src/Brig/Schema/V54.hs index 245dd9efb99..e7a50904518 100644 --- a/services/brig/schema/src/V54.hs +++ b/services/brig/src/Brig/Schema/V54.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V54 +module Brig.Schema.V54 ( migration, ) where diff --git a/services/brig/schema/src/V55.hs b/services/brig/src/Brig/Schema/V55.hs similarity index 97% rename from services/brig/schema/src/V55.hs rename to services/brig/src/Brig/Schema/V55.hs index fb92b32cf3c..436b1548efd 100644 --- a/services/brig/schema/src/V55.hs +++ b/services/brig/src/Brig/Schema/V55.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V55 +module Brig.Schema.V55 ( migration, ) where diff --git a/services/brig/schema/src/V56.hs b/services/brig/src/Brig/Schema/V56.hs similarity index 98% rename from services/brig/schema/src/V56.hs rename to services/brig/src/Brig/Schema/V56.hs index f6b629cc144..052d86089ee 100644 --- a/services/brig/schema/src/V56.hs +++ b/services/brig/src/Brig/Schema/V56.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V56 +module Brig.Schema.V56 ( migration, ) where diff --git a/services/brig/schema/src/V57.hs b/services/brig/src/Brig/Schema/V57.hs similarity index 97% rename from services/brig/schema/src/V57.hs rename to services/brig/src/Brig/Schema/V57.hs index d3a58275925..a08e75e416c 100644 --- a/services/brig/schema/src/V57.hs +++ b/services/brig/src/Brig/Schema/V57.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V57 +module Brig.Schema.V57 ( migration, ) where diff --git a/services/brig/schema/src/V58.hs b/services/brig/src/Brig/Schema/V58.hs similarity index 98% rename from services/brig/schema/src/V58.hs rename to services/brig/src/Brig/Schema/V58.hs index 57b9c750434..a5e074efe80 100644 --- a/services/brig/schema/src/V58.hs +++ b/services/brig/src/Brig/Schema/V58.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V58 +module Brig.Schema.V58 ( migration, ) where diff --git a/services/brig/schema/src/V59.hs b/services/brig/src/Brig/Schema/V59.hs similarity index 97% rename from services/brig/schema/src/V59.hs rename to services/brig/src/Brig/Schema/V59.hs index b84a2f7ef26..533bfb8fd8b 100644 --- a/services/brig/schema/src/V59.hs +++ b/services/brig/src/Brig/Schema/V59.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V59 +module Brig.Schema.V59 ( migration, ) where diff --git a/services/brig/schema/src/V60_AddFederationIdMapping.hs b/services/brig/src/Brig/Schema/V60_AddFederationIdMapping.hs similarity index 96% rename from services/brig/schema/src/V60_AddFederationIdMapping.hs rename to services/brig/src/Brig/Schema/V60_AddFederationIdMapping.hs index 5fb5e21a358..c529d835d9f 100644 --- a/services/brig/schema/src/V60_AddFederationIdMapping.hs +++ b/services/brig/src/Brig/Schema/V60_AddFederationIdMapping.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V60_AddFederationIdMapping +module Brig.Schema.V60_AddFederationIdMapping ( migration, ) where diff --git a/services/brig/schema/src/V61_team_invitation_email.hs b/services/brig/src/Brig/Schema/V61_team_invitation_email.hs similarity index 96% rename from services/brig/schema/src/V61_team_invitation_email.hs rename to services/brig/src/Brig/Schema/V61_team_invitation_email.hs index 81b0b911de6..1f1a863942b 100644 --- a/services/brig/schema/src/V61_team_invitation_email.hs +++ b/services/brig/src/Brig/Schema/V61_team_invitation_email.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V61_team_invitation_email +module Brig.Schema.V61_team_invitation_email ( migration, ) where diff --git a/services/brig/schema/src/V62_RemoveFederationIdMapping.hs b/services/brig/src/Brig/Schema/V62_RemoveFederationIdMapping.hs similarity index 95% rename from services/brig/schema/src/V62_RemoveFederationIdMapping.hs rename to services/brig/src/Brig/Schema/V62_RemoveFederationIdMapping.hs index 99670d3fed6..d28f09939ac 100644 --- a/services/brig/schema/src/V62_RemoveFederationIdMapping.hs +++ b/services/brig/src/Brig/Schema/V62_RemoveFederationIdMapping.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V62_RemoveFederationIdMapping +module Brig.Schema.V62_RemoveFederationIdMapping ( migration, ) where diff --git a/services/brig/schema/src/V63_AddUsersPendingActivation.hs b/services/brig/src/Brig/Schema/V63_AddUsersPendingActivation.hs similarity index 95% rename from services/brig/schema/src/V63_AddUsersPendingActivation.hs rename to services/brig/src/Brig/Schema/V63_AddUsersPendingActivation.hs index a4d1752a63f..540d6603ca6 100644 --- a/services/brig/schema/src/V63_AddUsersPendingActivation.hs +++ b/services/brig/src/Brig/Schema/V63_AddUsersPendingActivation.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V63_AddUsersPendingActivation (migration) where +module Brig.Schema.V63_AddUsersPendingActivation (migration) where import Cassandra.Schema import Imports diff --git a/services/brig/schema/src/V64_ClientCapabilities.hs b/services/brig/src/Brig/Schema/V64_ClientCapabilities.hs similarity index 96% rename from services/brig/schema/src/V64_ClientCapabilities.hs rename to services/brig/src/Brig/Schema/V64_ClientCapabilities.hs index eda30ee4e82..d6a8bcfc952 100644 --- a/services/brig/schema/src/V64_ClientCapabilities.hs +++ b/services/brig/src/Brig/Schema/V64_ClientCapabilities.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V64_ClientCapabilities +module Brig.Schema.V64_ClientCapabilities ( migration, ) where diff --git a/services/brig/schema/src/V65_FederatedConnections.hs b/services/brig/src/Brig/Schema/V65_FederatedConnections.hs similarity index 97% rename from services/brig/schema/src/V65_FederatedConnections.hs rename to services/brig/src/Brig/Schema/V65_FederatedConnections.hs index 6d92a633170..ac8608b644a 100644 --- a/services/brig/schema/src/V65_FederatedConnections.hs +++ b/services/brig/src/Brig/Schema/V65_FederatedConnections.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V65_FederatedConnections +module Brig.Schema.V65_FederatedConnections ( migration, ) where diff --git a/services/brig/schema/src/V66_PersonalFeatureConfCallInit.hs b/services/brig/src/Brig/Schema/V66_PersonalFeatureConfCallInit.hs similarity index 95% rename from services/brig/schema/src/V66_PersonalFeatureConfCallInit.hs rename to services/brig/src/Brig/Schema/V66_PersonalFeatureConfCallInit.hs index 53cb70ef706..edf40b2ed09 100644 --- a/services/brig/schema/src/V66_PersonalFeatureConfCallInit.hs +++ b/services/brig/src/Brig/Schema/V66_PersonalFeatureConfCallInit.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V66_PersonalFeatureConfCallInit +module Brig.Schema.V66_PersonalFeatureConfCallInit ( migration, ) where diff --git a/services/brig/schema/src/V67_MLSKeyPackages.hs b/services/brig/src/Brig/Schema/V67_MLSKeyPackages.hs similarity index 97% rename from services/brig/schema/src/V67_MLSKeyPackages.hs rename to services/brig/src/Brig/Schema/V67_MLSKeyPackages.hs index 71a933f553c..21d14d97338 100644 --- a/services/brig/schema/src/V67_MLSKeyPackages.hs +++ b/services/brig/src/Brig/Schema/V67_MLSKeyPackages.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V67_MLSKeyPackages +module Brig.Schema.V67_MLSKeyPackages ( migration, ) where diff --git a/services/brig/schema/src/V68_AddMLSPublicKeys.hs b/services/brig/src/Brig/Schema/V68_AddMLSPublicKeys.hs similarity index 96% rename from services/brig/schema/src/V68_AddMLSPublicKeys.hs rename to services/brig/src/Brig/Schema/V68_AddMLSPublicKeys.hs index 599ea5163dc..cff6c189fc6 100644 --- a/services/brig/schema/src/V68_AddMLSPublicKeys.hs +++ b/services/brig/src/Brig/Schema/V68_AddMLSPublicKeys.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V68_AddMLSPublicKeys +module Brig.Schema.V68_AddMLSPublicKeys ( migration, ) where diff --git a/services/brig/schema/src/V69_MLSKeyPackageRefMapping.hs b/services/brig/src/Brig/Schema/V69_MLSKeyPackageRefMapping.hs similarity index 94% rename from services/brig/schema/src/V69_MLSKeyPackageRefMapping.hs rename to services/brig/src/Brig/Schema/V69_MLSKeyPackageRefMapping.hs index 34c95d70e14..a1a5933b107 100644 --- a/services/brig/schema/src/V69_MLSKeyPackageRefMapping.hs +++ b/services/brig/src/Brig/Schema/V69_MLSKeyPackageRefMapping.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V69_MLSKeyPackageRefMapping +module Brig.Schema.V69_MLSKeyPackageRefMapping ( migration, ) where @@ -26,6 +26,7 @@ import Cassandra.Schema import Imports import Text.RawString.QQ +-- FUTUREWORK: remove this table migration :: Migration migration = Migration 69 "Add key package ref mapping" $ diff --git a/services/brig/schema/src/V70_UserEmailUnvalidated.hs b/services/brig/src/Brig/Schema/V70_UserEmailUnvalidated.hs similarity index 96% rename from services/brig/schema/src/V70_UserEmailUnvalidated.hs rename to services/brig/src/Brig/Schema/V70_UserEmailUnvalidated.hs index 8a408dddee1..8373d8931fc 100644 --- a/services/brig/schema/src/V70_UserEmailUnvalidated.hs +++ b/services/brig/src/Brig/Schema/V70_UserEmailUnvalidated.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V70_UserEmailUnvalidated +module Brig.Schema.V70_UserEmailUnvalidated ( migration, ) where diff --git a/services/brig/schema/src/V71_AddTableVCodesThrottle.hs b/services/brig/src/Brig/Schema/V71_AddTableVCodesThrottle.hs similarity index 96% rename from services/brig/schema/src/V71_AddTableVCodesThrottle.hs rename to services/brig/src/Brig/Schema/V71_AddTableVCodesThrottle.hs index 7684653ec02..53b6d6baa86 100644 --- a/services/brig/schema/src/V71_AddTableVCodesThrottle.hs +++ b/services/brig/src/Brig/Schema/V71_AddTableVCodesThrottle.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V71_AddTableVCodesThrottle +module Brig.Schema.V71_AddTableVCodesThrottle ( migration, ) where diff --git a/services/brig/schema/src/V72_AddNonceTable.hs b/services/brig/src/Brig/Schema/V72_AddNonceTable.hs similarity index 96% rename from services/brig/schema/src/V72_AddNonceTable.hs rename to services/brig/src/Brig/Schema/V72_AddNonceTable.hs index 32c7b71012a..6d3f1d2fd34 100644 --- a/services/brig/schema/src/V72_AddNonceTable.hs +++ b/services/brig/src/Brig/Schema/V72_AddNonceTable.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V72_AddNonceTable +module Brig.Schema.V72_AddNonceTable ( migration, ) where diff --git a/services/brig/schema/src/V73_ReplaceNonceTable.hs b/services/brig/src/Brig/Schema/V73_ReplaceNonceTable.hs similarity index 96% rename from services/brig/schema/src/V73_ReplaceNonceTable.hs rename to services/brig/src/Brig/Schema/V73_ReplaceNonceTable.hs index 94b47f817a2..ad96cae9658 100644 --- a/services/brig/schema/src/V73_ReplaceNonceTable.hs +++ b/services/brig/src/Brig/Schema/V73_ReplaceNonceTable.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V73_ReplaceNonceTable +module Brig.Schema.V73_ReplaceNonceTable ( migration, ) where diff --git a/services/brig/schema/src/V74_AddOAuthTables.hs b/services/brig/src/Brig/Schema/V74_AddOAuthTables.hs similarity index 98% rename from services/brig/schema/src/V74_AddOAuthTables.hs rename to services/brig/src/Brig/Schema/V74_AddOAuthTables.hs index 45cdc018be7..5298b199f29 100644 --- a/services/brig/schema/src/V74_AddOAuthTables.hs +++ b/services/brig/src/Brig/Schema/V74_AddOAuthTables.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V74_AddOAuthTables +module Brig.Schema.V74_AddOAuthTables ( migration, ) where diff --git a/services/brig/schema/src/V75_AddOAuthCodeChallenge.hs b/services/brig/src/Brig/Schema/V75_AddOAuthCodeChallenge.hs similarity index 96% rename from services/brig/schema/src/V75_AddOAuthCodeChallenge.hs rename to services/brig/src/Brig/Schema/V75_AddOAuthCodeChallenge.hs index ebead11e8cd..62cb91e5dbe 100644 --- a/services/brig/schema/src/V75_AddOAuthCodeChallenge.hs +++ b/services/brig/src/Brig/Schema/V75_AddOAuthCodeChallenge.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V75_AddOAuthCodeChallenge +module Brig.Schema.V75_AddOAuthCodeChallenge ( migration, ) where diff --git a/services/brig/schema/src/V76_AddSupportedProtocols.hs b/services/brig/src/Brig/Schema/V76_AddSupportedProtocols.hs similarity index 94% rename from services/brig/schema/src/V76_AddSupportedProtocols.hs rename to services/brig/src/Brig/Schema/V76_AddSupportedProtocols.hs index 72365ff28ed..ddf5baa6361 100644 --- a/services/brig/schema/src/V76_AddSupportedProtocols.hs +++ b/services/brig/src/Brig/Schema/V76_AddSupportedProtocols.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V76_AddSupportedProtocols (migration) where +module Brig.Schema.V76_AddSupportedProtocols (migration) where import Cassandra.Schema import Imports diff --git a/services/brig/schema/src/V77_FederationRemotes.hs b/services/brig/src/Brig/Schema/V77_FederationRemotes.hs similarity index 96% rename from services/brig/schema/src/V77_FederationRemotes.hs rename to services/brig/src/Brig/Schema/V77_FederationRemotes.hs index 250164ceb81..1ab4ca90c6b 100644 --- a/services/brig/schema/src/V77_FederationRemotes.hs +++ b/services/brig/src/Brig/Schema/V77_FederationRemotes.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V77_FederationRemotes +module Brig.Schema.V77_FederationRemotes ( migration, ) where diff --git a/services/brig/schema/src/V78_ClientLastActive.hs b/services/brig/src/Brig/Schema/V78_ClientLastActive.hs similarity index 96% rename from services/brig/schema/src/V78_ClientLastActive.hs rename to services/brig/src/Brig/Schema/V78_ClientLastActive.hs index f0c7dec2bd0..2c80519700b 100644 --- a/services/brig/schema/src/V78_ClientLastActive.hs +++ b/services/brig/src/Brig/Schema/V78_ClientLastActive.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V78_ClientLastActive +module Brig.Schema.V78_ClientLastActive ( migration, ) where diff --git a/services/brig/schema/src/V79_ConnectionRemoteIndex.hs b/services/brig/src/Brig/Schema/V79_ConnectionRemoteIndex.hs similarity index 88% rename from services/brig/schema/src/V79_ConnectionRemoteIndex.hs rename to services/brig/src/Brig/Schema/V79_ConnectionRemoteIndex.hs index 729e81f72b6..cea8f360c1d 100644 --- a/services/brig/schema/src/V79_ConnectionRemoteIndex.hs +++ b/services/brig/src/Brig/Schema/V79_ConnectionRemoteIndex.hs @@ -1,7 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -module V79_ConnectionRemoteIndex +module Brig.Schema.V79_ConnectionRemoteIndex ( migration, ) where diff --git a/services/brig/src/Brig/Schema/V80_KeyPackageCiphersuite.hs b/services/brig/src/Brig/Schema/V80_KeyPackageCiphersuite.hs new file mode 100644 index 00000000000..43c5c5804fe --- /dev/null +++ b/services/brig/src/Brig/Schema/V80_KeyPackageCiphersuite.hs @@ -0,0 +1,50 @@ +{-# LANGUAGE QuasiQuotes #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Brig.Schema.V80_KeyPackageCiphersuite + ( migration, + ) +where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +-- Index key packages by ciphersuite as well as user and client. + +-- Note: this migration recreates the mls_key_packages table from scratch, and +-- therefore loses all the data it contains. That means clients will need to +-- re-upload key packages after this migration is run. + +migration :: Migration +migration = + Migration 80 "Recreate mls_key_packages table" $ do + schema' [r| DROP TABLE IF EXISTS mls_key_packages; |] + schema' + [r| + CREATE TABLE mls_key_packages + ( user uuid + , client text + , cipher_suite int + , ref blob + , data blob + , PRIMARY KEY ((user, client, cipher_suite), ref) + ) WITH compaction = {'class': 'org.apache.cassandra.db.compaction.LeveledCompactionStrategy'} + AND gc_grace_seconds = 864000; + |] diff --git a/services/brig/schema/src/V_FUTUREWORK.hs b/services/brig/src/Brig/Schema/V_FUTUREWORK.hs similarity index 98% rename from services/brig/schema/src/V_FUTUREWORK.hs rename to services/brig/src/Brig/Schema/V_FUTUREWORK.hs index 1a9786fbb3f..d4e00c4ec19 100644 --- a/services/brig/schema/src/V_FUTUREWORK.hs +++ b/services/brig/src/Brig/Schema/V_FUTUREWORK.hs @@ -17,7 +17,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V_FUTUREWORK +module Brig.Schema.V_FUTUREWORK ( migration, ) where diff --git a/services/brig/src/Brig/Team/API.hs b/services/brig/src/Brig/Team/API.hs index 18a60a9e797..14987469fff 100644 --- a/services/brig/src/Brig/Team/API.hs +++ b/services/brig/src/Brig/Team/API.hs @@ -17,7 +17,12 @@ module Brig.Team.API ( servantAPI, - routesInternal, + getInvitationByEmail, + getInvitationCode, + suspendTeam, + unsuspendTeam, + teamSize, + createInvitationViaScim, ) where @@ -45,17 +50,12 @@ import Brig.Types.Team (TeamSize) import Brig.User.Search.TeamSize qualified as TeamSize import Control.Lens (view, (^.)) import Control.Monad.Trans.Except (mapExceptT) -import Data.Aeson hiding (json) import Data.ByteString.Conversion (toByteString') import Data.Id import Data.List1 qualified as List1 import Data.Range import Galley.Types.Teams qualified as Team import Imports hiding (head) -import Network.HTTP.Types.Status -import Network.Wai (Response) -import Network.Wai.Predicate hiding (and, result, setStatus) -import Network.Wai.Routing import Network.Wai.Utilities hiding (code, message) import Polysemy (Member) import Servant hiding (Handler, JSON, addHeader) @@ -64,9 +64,10 @@ import System.Logger.Class qualified as Log import Util.Logging (logFunction, logTeam) import Wire.API.Error import Wire.API.Error.Brig qualified as E +import Wire.API.Routes.Internal.Brig (FoundInvitationCode (FoundInvitationCode)) import Wire.API.Routes.Internal.Galley.TeamsIntra qualified as Team import Wire.API.Routes.Named -import Wire.API.Routes.Public.Brig +import Wire.API.Routes.Public.Brig (TeamsAPI) import Wire.API.Team import Wire.API.Team.Invitation import Wire.API.Team.Invitation qualified as Public @@ -92,64 +93,19 @@ servantAPI = :<|> Named @"head-team-invitations" headInvitationByEmail :<|> Named @"get-team-size" teamSizePublic -routesInternal :: - ( Member BlacklistStore r, - Member GalleyProvider r, - Member (UserPendingActivationStore p) r - ) => - Routes a (Handler r) () -routesInternal = do - get "/i/teams/invitations/by-email" (continue getInvitationByEmailH) $ - accept "application" "json" - .&. query "email" - - get "/i/teams/invitation-code" (continue getInvitationCodeH) $ - accept "application" "json" - .&. param "team" - .&. param "invitation_id" - - post "/i/teams/:tid/suspend" (continue suspendTeamH) $ - accept "application" "json" - .&. capture "tid" - - post "/i/teams/:tid/unsuspend" (continue unsuspendTeamH) $ - accept "application" "json" - .&. capture "tid" - - get "/i/teams/:tid/size" (continue teamSizeH) $ - accept "application" "json" - .&. capture "tid" - - post "/i/teams/:tid/invitations" (continue createInvitationViaScimH) $ - accept "application" "json" - .&. jsonRequest @NewUserScimInvitation - teamSizePublic :: Member GalleyProvider r => UserId -> TeamId -> (Handler r) TeamSize teamSizePublic uid tid = do ensurePermissions uid tid [AddTeamMember] -- limit this to team admins to reduce risk of involuntary DOS attacks teamSize tid -teamSizeH :: JSON ::: TeamId -> (Handler r) Response -teamSizeH (_ ::: t) = json <$> teamSize t - teamSize :: TeamId -> (Handler r) TeamSize teamSize t = lift $ TeamSize.teamSize t -getInvitationCodeH :: JSON ::: TeamId ::: InvitationId -> (Handler r) Response -getInvitationCodeH (_ ::: t ::: r) = do - json <$> getInvitationCode t r - getInvitationCode :: TeamId -> InvitationId -> (Handler r) FoundInvitationCode getInvitationCode t r = do code <- lift . wrapClient $ DB.lookupInvitationCode t r maybe (throwStd $ errorToWai @'E.InvalidInvitationCode) (pure . FoundInvitationCode) code -newtype FoundInvitationCode = FoundInvitationCode InvitationCode - deriving (Eq, Show, Generic) - -instance ToJSON FoundInvitationCode where - toJSON (FoundInvitationCode c) = object ["code" .= c] - createInvitationPublicH :: ( Member BlacklistStore r, Member GalleyProvider r @@ -199,25 +155,15 @@ createInvitationPublic uid tid body = do context (createInvitation' tid inviteeRole (Just (inviterUid inviter)) (inviterEmail inviter) body) -createInvitationViaScimH :: - ( Member BlacklistStore r, - Member GalleyProvider r, - Member (UserPendingActivationStore p) r - ) => - JSON ::: JsonRequest NewUserScimInvitation -> - (Handler r) Response -createInvitationViaScimH (_ ::: req) = do - body <- parseJsonBody req - setStatus status201 . json <$> createInvitationViaScim body - createInvitationViaScim :: ( Member BlacklistStore r, Member GalleyProvider r, Member (UserPendingActivationStore p) r ) => + TeamId -> NewUserScimInvitation -> (Handler r) UserAccount -createInvitationViaScim newUser@(NewUserScimInvitation tid loc name email role) = do +createInvitationViaScim tid newUser@(NewUserScimInvitation _tid loc name email role) = do env <- ask let inviteeRole = role fromEmail = env ^. emailSender @@ -352,39 +298,26 @@ headInvitationByEmail e = do -- | FUTUREWORK: This should also respond with status 409 in case of -- @DB.InvitationByEmailMoreThanOne@. Refactor so that 'headInvitationByEmailH' and -- 'getInvitationByEmailH' are almost the same thing. -getInvitationByEmailH :: JSON ::: Email -> (Handler r) Response -getInvitationByEmailH (_ ::: email) = - json <$> getInvitationByEmail email - getInvitationByEmail :: Email -> (Handler r) Public.Invitation getInvitationByEmail email = do inv <- lift $ wrapClient $ DB.lookupInvitationByEmail HideInvitationUrl email maybe (throwStd (notFound "Invitation not found")) pure inv -suspendTeamH :: (Member GalleyProvider r) => JSON ::: TeamId -> (Handler r) Response -suspendTeamH (_ ::: tid) = do - empty <$ suspendTeam tid - -suspendTeam :: (Member GalleyProvider r) => TeamId -> (Handler r) () +suspendTeam :: (Member GalleyProvider r) => TeamId -> (Handler r) NoContent suspendTeam tid = do changeTeamAccountStatuses tid Suspended lift $ wrapClient $ DB.deleteInvitations tid lift $ liftSem $ GalleyProvider.changeTeamStatus tid Team.Suspended Nothing - -unsuspendTeamH :: - (Member GalleyProvider r) => - JSON ::: TeamId -> - (Handler r) Response -unsuspendTeamH (_ ::: tid) = do - empty <$ unsuspendTeam tid + pure NoContent unsuspendTeam :: (Member GalleyProvider r) => TeamId -> - (Handler r) () + (Handler r) NoContent unsuspendTeam tid = do changeTeamAccountStatuses tid Active lift $ liftSem $ GalleyProvider.changeTeamStatus tid Team.Active Nothing + pure NoContent ------------------------------------------------------------------------------- -- Internal diff --git a/services/brig/src/Brig/Unique.hs b/services/brig/src/Brig/Unique.hs index 88e325e8c4d..6c5d5f0a2a8 100644 --- a/services/brig/src/Brig/Unique.hs +++ b/services/brig/src/Brig/Unique.hs @@ -66,13 +66,13 @@ withClaim u v t io = do -- [Note: Guarantees] claim = do let ttl = max minTtl (fromIntegral (t #> Second)) - retry x5 $ write cql $ params LocalQuorum (ttl * 2, C.Set [u], v) + retry x5 $ write upsertQuery $ params LocalQuorum (ttl * 2, C.Set [u], v) claimed <- (== [u]) <$> lookupClaims v if claimed then liftIO $ timeout (fromIntegral ttl # Second) io else pure Nothing - cql :: PrepQuery W (Int32, C.Set (Id a), Text) () - cql = "UPDATE unique_claims USING TTL ? SET claims = claims + ? WHERE value = ?" + upsertQuery :: PrepQuery W (Int32, C.Set (Id a), Text) () + upsertQuery = "UPDATE unique_claims USING TTL ? SET claims = claims + ? WHERE value = ?" deleteClaim :: MonadClient m => @@ -91,7 +91,7 @@ deleteClaim u v t = do retry x5 $ write cql $ params LocalQuorum (ttl * 2, C.Set [u], v) where cql :: PrepQuery W (Int32, C.Set (Id a), Text) () - cql = "UPDATE unique_claims USING TTL ? SET claims = claims - ? WHERE value = ?" + cql = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE unique_claims USING TTL ? SET claims = claims - ? WHERE value = ?" -- | Lookup the current claims on a value. lookupClaims :: MonadClient m => Text -> m [Id a] diff --git a/services/brig/src/Brig/User/EJPD.hs b/services/brig/src/Brig/User/EJPD.hs index 07116e81207..fea8e51a37a 100644 --- a/services/brig/src/Brig/User/EJPD.hs +++ b/services/brig/src/Brig/User/EJPD.hs @@ -36,7 +36,7 @@ import Data.Id (UserId) import Data.Set qualified as Set import Imports hiding (head) import Polysemy (Member) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi.Internal.Orphans () import Wire.API.Connection (Relation, RelationWithHistory (..), relationDropHistory) import Wire.API.Push.Token qualified as PushTok import Wire.API.Routes.Internal.Brig.EJPD (EJPDRequestBody (EJPDRequestBody), EJPDResponseBody (EJPDResponseBody), EJPDResponseItem (EJPDResponseItem)) diff --git a/services/brig/src/Brig/User/Search/Index.hs b/services/brig/src/Brig/User/Search/Index.hs index ea06246298a..812842aef82 100644 --- a/services/brig/src/Brig/User/Search/Index.hs +++ b/services/brig/src/Brig/User/Search/Index.hs @@ -49,17 +49,19 @@ module Brig.User.Search.Index ) where -import Bilge (MonadHttp, expect2xx, header, lbytes) -import Bilge qualified as RPC +import Bilge (expect2xx, header, lbytes, paths) +import Bilge.IO (MonadHttp) +import Bilge.IO qualified as RPC import Bilge.RPC (RPCException (RPCException)) -import Bilge.Request (paths) +import Bilge.Request qualified as RPC (empty, host, method, port) import Bilge.Response (responseJsonThrow) import Bilge.Retry (rpcHandlers) import Brig.Data.Instances () import Brig.Index.Types (CreateIndexSettings (..)) import Brig.Types.Search (SearchVisibilityInbound, defaultSearchVisibilityInbound, searchVisibilityInboundFromFeatureStatus) import Brig.User.Search.Index.Types as Types -import Cassandra qualified as C +import Cassandra.CQL qualified as C +import Cassandra.Exec qualified as C import Cassandra.Util import Control.Lens hiding ((#), (.=)) import Control.Monad.Catch (MonadCatch, MonadMask, MonadThrow, throwM, try) @@ -85,13 +87,13 @@ import Data.Text.Lens hiding (text) import Data.UUID qualified as UUID import Database.Bloodhound qualified as ES import Imports hiding (log, searchable) -import Network.HTTP.Client hiding (path) +import Network.HTTP.Client hiding (host, path, port) import Network.HTTP.Types (StdMethod (POST), hContentType, statusCode) import SAML2.WebSSO.Types qualified as SAML import System.Logger qualified as Log import System.Logger.Class (Logger, MonadLogger (..), field, info, msg, val, (+++), (~~)) import URI.ByteString (URI, serializeURIRef) -import Util.Options (Endpoint, epHost, epPort) +import Util.Options (Endpoint, host, port) import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti qualified as Multi import Wire.API.Team.Feature (SearchVisibilityInboundConfig, featureNameBS) import Wire.API.User @@ -933,7 +935,7 @@ getTeamSearchVisibilityInboundMulti tids = do Left x -> throwM $ RPCException nm rq x Right x -> pure x where - mkEndpoint service = RPC.host (encodeUtf8 (service ^. epHost)) . RPC.port (service ^. epPort) $ RPC.empty + mkEndpoint service = RPC.host (encodeUtf8 (service ^. host)) . RPC.port (service ^. port) $ RPC.empty x3 :: RetryPolicy x3 = limitRetries 3 <> exponentialBackoff 100000 diff --git a/services/spar/schema/exe/spar-schema.hs b/services/brig/test/integration.hs similarity index 77% rename from services/spar/schema/exe/spar-schema.hs rename to services/brig/test/integration.hs index d862e71bcdc..d4037ab9cfa 100644 --- a/services/spar/schema/exe/spar-schema.hs +++ b/services/brig/test/integration.hs @@ -1,5 +1,3 @@ -module Main where - import Imports import qualified Run diff --git a/services/brig/test/integration/API/Federation.hs b/services/brig/test/integration/API/Federation.hs index 0a539e44555..0cc5eaf9be1 100644 --- a/services/brig/test/integration/API/Federation.hs +++ b/services/brig/test/integration/API/Federation.hs @@ -1,4 +1,3 @@ -{-# OPTIONS_GHC -Wno-deferred-out-of-scope-variables #-} -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -68,7 +67,7 @@ tests m opts brig cannon fedBrigClient = test m "POST /federation/search-users : Found (multiple users)" (testFulltextSearchMultipleUsers opts brig), test m "POST /federation/search-users : NotFound" (testSearchNotFound opts), test m "POST /federation/search-users : Empty Input - NotFound" (testSearchNotFoundEmpty opts), - flakyTest m "POST /federation/search-users : configured restrictions" (testSearchRestrictions opts brig), + test m "POST /federation/search-users : configured restrictions" (testSearchRestrictions opts brig), test m "POST /federation/get-user-by-handle : configured restrictions" (testGetUserByHandleRestrictions opts brig), test m "POST /federation/get-user-by-handle : Found" (testGetUserByHandleSuccess opts brig), test m "POST /federation/get-user-by-handle : NotFound" (testGetUserByHandleNotFound opts), @@ -80,7 +79,7 @@ tests m opts brig cannon fedBrigClient = test m "POST /federation/claim-multi-prekey-bundle : 200" (testClaimMultiPrekeyBundleSuccess brig fedBrigClient), test m "POST /federation/get-user-clients : 200" (testGetUserClients brig fedBrigClient), test m "POST /federation/get-user-clients : Not Found" (testGetUserClientsNotFound fedBrigClient), - flakyTest m "POST /federation/on-user-deleted-connections : 200" (testRemoteUserGetsDeleted opts brig cannon fedBrigClient), + test m "POST /federation/on-user-deleted-connections : 200" (testRemoteUserGetsDeleted opts brig cannon fedBrigClient), test m "POST /federation/api-version : 200" (testAPIVersion brig fedBrigClient) ] @@ -116,11 +115,11 @@ testFulltextSearchSuccess opts brig = do searchResponse <- withSettingsOverrides (allowFullSearch domain opts) $ do runWaiTestFedClient domain $ createWaiTestFedClient @"search-users" @'Brig $ - SearchRequest ((fromName . userDisplayName) user) + SearchRequest (fromName $ userDisplayName user) liftIO $ do let contacts = contactQualifiedId <$> S.contacts searchResponse - assertEqual "should return the user id" [quid] contacts + assertElem "should return the user id" quid contacts testFulltextSearchMultipleUsers :: Opt.Opts -> Brig -> Http () testFulltextSearchMultipleUsers opts brig = do @@ -134,7 +133,7 @@ testFulltextSearchMultipleUsers opts brig = do update'' :: UserUpdate <- liftIO $ generate arbitrary let update' = update'' {uupName = Just (Name (fromHandle handle))} update = RequestBodyLBS . encode $ update' - put (brig . path "/self" . contentJson . zUser (userId identityThief) . zConn "c" . body update) !!! const 200 === statusCode + put (brig . path "/self" . contentJson . zUser identityThief.userId . zConn "c" . body update) !!! const 200 === statusCode refreshIndex brig @@ -190,22 +189,30 @@ testSearchRestrictions opts brig = do FD.FederationDomainConfig domainFullSearch FullSearch ] - let expectSearch :: HasCallStack => Domain -> Text -> [Qualified UserId] -> FederatedUserSearchPolicy -> WaiTest.Session () - expectSearch domain squery expectedUsers expectedSearchPolicy = do + let expectSearch :: HasCallStack => Domain -> Either Handle Name -> Maybe (Qualified UserId) -> FederatedUserSearchPolicy -> WaiTest.Session () + expectSearch domain handleOrName mExpectedUser expectedSearchPolicy = do + let squery = either fromHandle fromName handleOrName searchResponse <- runWaiTestFedClient domain $ createWaiTestFedClient @"search-users" @'Brig (SearchRequest squery) - liftIO $ assertEqual "Unexpected search result" expectedUsers (contactQualifiedId <$> S.contacts searchResponse) - liftIO $ assertEqual "Unexpected search result" expectedSearchPolicy (S.searchPolicy searchResponse) + liftIO $ do + case (mExpectedUser, handleOrName) of + (Just expectedUser, Right _) -> + assertElem "Unexpected search result" expectedUser (contactQualifiedId <$> S.contacts searchResponse) + (Nothing, Right _) -> + assertEqual "Unexpected search result" [] (contactQualifiedId <$> S.contacts searchResponse) + _ -> + assertEqual "Unexpected search result" (maybeToList mExpectedUser) (contactQualifiedId <$> S.contacts searchResponse) + assertEqual "Unexpected search result" expectedSearchPolicy (S.searchPolicy searchResponse) withSettingsOverrides opts' $ do - expectSearch domainNoSearch (fromHandle handle) [] NoSearch - expectSearch domainExactHandle (fromHandle handle) [quid] ExactHandleSearch - expectSearch domainExactHandle (fromName (userDisplayName user)) [] ExactHandleSearch - expectSearch domainFullSearch (fromHandle handle) [quid] FullSearch - expectSearch domainFullSearch (fromName (userDisplayName user)) [quid] FullSearch - expectSearch domainUnconfigured (fromHandle handle) [] NoSearch - expectSearch domainUnconfigured (fromName (userDisplayName user)) [] NoSearch + expectSearch domainNoSearch (Left handle) Nothing NoSearch + expectSearch domainExactHandle (Left handle) (Just quid) ExactHandleSearch + expectSearch domainExactHandle (Right (userDisplayName user)) Nothing ExactHandleSearch + expectSearch domainFullSearch (Left handle) (Just quid) FullSearch + expectSearch domainFullSearch (Right (userDisplayName user)) (Just quid) FullSearch + expectSearch domainUnconfigured (Left handle) Nothing NoSearch + expectSearch domainUnconfigured (Right (userDisplayName user)) Nothing NoSearch testGetUserByHandleRestrictions :: Opt.Opts -> Brig -> Http () testGetUserByHandleRestrictions opts brig = do @@ -272,9 +279,9 @@ testGetUsersByIdsSuccess :: Brig -> FedClient 'Brig -> Http () testGetUsersByIdsSuccess brig fedBrigClient = do user1 <- randomUser brig user2 <- randomUser brig - let uid1 = userId user1 + let uid1 = user1.userId quid1 = userQualifiedId user1 - uid2 = userId user2 + uid2 = user2.userId quid2 = userQualifiedId user2 profiles <- runFedClient @"get-users-by-ids" fedBrigClient (Domain "example.com") [uid1, uid2] liftIO $ do @@ -287,7 +294,7 @@ testGetUsersByIdsPartial brig fedBrigClient = do absentUserId :: UserId <- Id <$> lift UUIDv4.nextRandom profiles <- runFedClient @"get-users-by-ids" fedBrigClient (Domain "example.com") $ - [userId presentUser, absentUserId] + [presentUser.userId, absentUserId] liftIO $ assertEqual "should return the present user and skip the absent ones" [userQualifiedId presentUser] (profileQualifiedId <$> profiles) @@ -302,7 +309,7 @@ testGetUsersByIdsNoneFound fedBrigClient = do testClaimPrekeySuccess :: Brig -> FedClient 'Brig -> Http () testClaimPrekeySuccess brig fedBrigClient = do user <- randomUser brig - let uid = userId user + let uid = user.userId let new = defNewClient PermanentClientType [head somePrekeys] (head someLastPrekeys) c <- responseJsonError =<< addClient brig uid new mkey <- runFedClient @"claim-prekey" fedBrigClient (Domain "example.com") (uid, clientId c) @@ -351,7 +358,7 @@ addTestClients brig uid idxs = testGetUserClients :: Brig -> FedClient 'Brig -> Http () testGetUserClients brig fedBrigClient = do - uid1 <- userId <$> randomUser brig + uid1 <- (.userId) <$> randomUser brig clients :: [Client] <- addTestClients brig uid1 [0, 1, 2] UserMap userClients <- runFedClient @"get-user-clients" fedBrigClient (Domain "example.com") (GetUserClients [uid1]) liftIO $ @@ -372,10 +379,10 @@ testGetUserClientsNotFound fedBrigClient = do testRemoteUserGetsDeleted :: Opt.Opts -> Brig -> Cannon -> FedClient 'Brig -> Http () testRemoteUserGetsDeleted opts brig cannon fedBrigClient = do - connectedUser <- userId <$> randomUser brig - pendingUser <- userId <$> randomUser brig - blockedUser <- userId <$> randomUser brig - unconnectedUser <- userId <$> randomUser brig + connectedUser <- (.userId) <$> randomUser brig + pendingUser <- (.userId) <$> randomUser brig + blockedUser <- (.userId) <$> randomUser brig + unconnectedUser <- (.userId) <$> randomUser brig remoteUser <- fakeRemoteUser sendConnectionAction brig opts connectedUser remoteUser (Just FedBrig.RemoteConnect) Accepted @@ -400,16 +407,3 @@ testAPIVersion :: Brig -> FedClient 'Brig -> Http () testAPIVersion _brig fedBrigClient = do vinfo <- runFedClient @"api-version" fedBrigClient (Domain "far-away.example.com") () liftIO $ vinfoSupported vinfo @?= toList supportedVersions - -testClaimKeyPackagesMLSDisabled :: HasCallStack => Opt.Opts -> Brig -> Http () -testClaimKeyPackagesMLSDisabled opts brig = do - alice <- fakeRemoteUser - bob <- userQualifiedId <$> randomUser brig - - mbundle <- - withSettingsOverrides (opts & Opt.optionSettings . Opt.enableMLS ?~ False) $ - runWaiTestFedClient (qDomain alice) $ - createWaiTestFedClient @"claim-key-packages" @'Brig $ - ClaimKeyPackageRequest (qUnqualified alice) (qUnqualified bob) - - liftIO $ mbundle @?= Nothing diff --git a/services/brig/test/integration/API/Internal.hs b/services/brig/test/integration/API/Internal.hs index 5e35b4caed8..d69b7c55617 100644 --- a/services/brig/test/integration/API/Internal.hs +++ b/services/brig/test/integration/API/Internal.hs @@ -24,42 +24,32 @@ module API.Internal where import API.Internal.Util -import API.MLS (createClient) +import API.MLS hiding (tests) import API.MLS.Util import Bilge import Bilge.Assert -import Brig.Data.Connection import Brig.Data.User (lookupFeatureConferenceCalling, lookupStatus, userExists) import Brig.Options qualified as Opt import Cassandra qualified as C import Cassandra qualified as Cass -import Cassandra.Exec (x1) import Cassandra.Util import Control.Exception (ErrorCall (ErrorCall), throwIO) import Control.Lens ((^.), (^?!)) -import Data.Aeson (decode) import Data.Aeson.Lens qualified as Aeson import Data.Aeson.Types qualified as Aeson import Data.ByteString.Conversion (toByteString') import Data.Default -import Data.Domain import Data.Id -import Data.Json.Util (toUTCTimeMillis) -import Data.Qualified (Qualified (qDomain, qUnqualified)) +import Data.Qualified import Data.Set qualified as Set -import Data.Time import GHC.TypeLits (KnownSymbol) import Imports -import Servant.API (ToHttpApiData (toUrlPiece)) -import Test.QuickCheck (Arbitrary (arbitrary), generate) +import System.IO.Temp import Test.Tasty import Test.Tasty.HUnit -import UnliftIO (withSystemTempDirectory) import Util import Util.Options (Endpoint) import Wire.API.Connection qualified as Conn -import Wire.API.MLS.Credential -import Wire.API.MLS.KeyPackage import Wire.API.Routes.Internal.Brig import Wire.API.Team.Feature import Wire.API.Team.Feature qualified as ApiFt @@ -78,82 +68,9 @@ tests opts mgr db brig brigep gundeck galley = do test mgr "suspend non existing user and verify no db entry" $ testSuspendNonExistingUser db brig, test mgr "mls/clients" $ testGetMlsClients brig, - testGroup - "mls/key-packages" - $ [ test mgr "fresh get" $ testKpcFreshGet brig, - test mgr "put,get" $ testKpcPutGet brig, - test mgr "get,get" $ testKpcGetGet brig, - test mgr "put,put" $ testKpcPutPut brig, - test mgr "add key package ref" $ testAddKeyPackageRef brig - ], - test mgr "writetimeToInt64" $ testWritetimeRepresentation opts mgr db brig brigep galley, - test mgr "delete-federation-remote-galley" $ testDeleteFederationRemoteGalley db brig + test mgr "writetimeToInt64" $ testWritetimeRepresentation opts mgr db brig brigep galley ] -testDeleteFederationRemoteGalley :: forall m. (TestConstraints m) => Cass.ClientState -> Brig -> m () -testDeleteFederationRemoteGalley db brig = do - let remoteDomain1 = Domain "far-away.example.com" - remoteDomain2 = Domain "far-away-two.example.com" - isRemote1 = (== remoteDomain1) - isRemote2 = (== remoteDomain2) - localUser <- randomUser brig - let localUserId = userId localUser - remoteUserId1 <- randomId - remoteUserId2 <- randomId - now <- liftIO $ getCurrentTime - convId <- randomId - - -- Write the local and remote users into 'connection_remote' - let params1 = (localUserId, remoteDomain1, remoteUserId1, Conn.AcceptedWithHistory, toUTCTimeMillis now, remoteDomain1, convId) - liftIO $ - Cass.runClient db $ - Cass.retry x1 $ - Cass.write remoteConnectionInsert (Cass.params Cass.LocalQuorum params1) - let params2 = (localUserId, remoteDomain2, remoteUserId2, Conn.AcceptedWithHistory, toUTCTimeMillis now, remoteDomain1, convId) - liftIO $ - Cass.runClient db $ - Cass.retry x1 $ - Cass.write remoteConnectionInsert (Cass.params Cass.LocalQuorum params2) - - -- Check that the value exists in the DB as expected. - -- Remote 1 - liftIO - ( Cass.runClient db $ - Cass.retry x1 $ - Cass.query remoteConnectionsSelectUsers (Cass.params Cass.LocalQuorum $ pure localUserId) - ) - >>= liftIO . assertBool "connection_remote entry should exist for the user" . any (isRemote1 . fst) - -- Remote 2 - liftIO - ( Cass.runClient db $ - Cass.retry x1 $ - Cass.query remoteConnectionsSelectUsers (Cass.params Cass.LocalQuorum $ pure localUserId) - ) - >>= liftIO . assertBool "connection_remote entry should exist for the user" . any (isRemote2 . fst) - - -- Make the API call to delete remote domain 1 - delete - ( brig - . paths ["i", "federation", "remote", toByteString' $ domainText remoteDomain1, "galley"] - ) - !!! const 200 === statusCode - - -- Check 'connection_remote' for the local user and ensure - -- that there are no conversations for the remote domain. - liftIO - ( Cass.runClient db $ - Cass.retry x1 $ - Cass.query remoteConnectionsSelectUsers (Cass.params Cass.LocalQuorum $ pure localUserId) - ) - >>= liftIO . assertBool "connection_remote entry should NOT exist for the user" . not . any (isRemote1 . fst) - -- But remote domain 2 is still listed - liftIO - ( Cass.runClient db $ - Cass.retry x1 $ - Cass.query remoteConnectionsSelectUsers (Cass.params Cass.LocalQuorum $ pure localUserId) - ) - >>= liftIO . assertBool "connection_remote entry should exist for the user" . any (isRemote2 . fst) - testSuspendUser :: forall m. (TestConstraints m) => Cass.ClientState -> Brig -> m () testSuspendUser db brig = do user <- randomUser brig @@ -304,139 +221,26 @@ testGetMlsClients :: Brig -> Http () testGetMlsClients brig = do qusr <- userQualifiedId <$> randomUser brig c <- createClient brig qusr 0 - (cs0 :: Set ClientInfo) <- - responseJsonError - =<< get - ( brig - . paths ["i", "mls", "clients", toByteString' (qUnqualified qusr)] - . queryItem "sig_scheme" "ed25519" - ) + + let getClients :: Http (Set ClientInfo) + getClients = + responseJsonError + =<< get + ( brig + . paths ["i", "mls", "clients", toByteString' (qUnqualified qusr)] + . queryItem "ciphersuite" "0x0001" + ) + uploadKeyPackages brig tmp def qusr c 2 - (cs1 :: Set ClientInfo) <- - responseJsonError - =<< get - ( brig - . paths ["i", "mls", "clients", toByteString' (qUnqualified qusr)] - . queryItem "sig_scheme" "ed25519" - ) + cs1 <- getClients liftIO $ toList cs1 @?= [ClientInfo c True] -keyPackageCreate :: (HasCallStack) => Brig -> Http KeyPackageRef -keyPackageCreate brig = do - uid <- userQualifiedId <$> randomUser brig - clid <- createClient brig uid 0 - withSystemTempDirectory "mls" $ \tmp -> - uploadKeyPackages brig tmp def uid clid 2 - - uid2 <- userQualifiedId <$> randomUser brig - claimResp <- - post - ( brig - . paths - [ "mls", - "key-packages", - "claim", - toByteString' (qDomain uid), - toByteString' (qUnqualified uid) - ] - . zUser (qUnqualified uid2) - . contentJson - ) - liftIO $ - assertEqual "POST mls/key-packages/claim/:domain/:user failed" 200 (statusCode claimResp) - case responseBody claimResp >>= decode of - Nothing -> liftIO $ assertFailure "Claim response empty" - Just bundle -> case toList $ kpbEntries bundle of - [] -> liftIO $ assertFailure "Claim response held no bundles" - (h : _) -> pure $ kpbeRef h - -kpcPut :: (HasCallStack) => Brig -> KeyPackageRef -> Qualified ConvId -> Http () -kpcPut brig ref qConv = do - resp <- - put - ( brig - . paths ["i", "mls", "key-packages", toByteString' $ toUrlPiece ref, "conversation"] - . contentJson - . json qConv - ) - liftIO $ assertEqual "PUT i/mls/key-packages/:ref/conversation failed" 204 (statusCode resp) - -kpcGet :: (HasCallStack) => Brig -> KeyPackageRef -> Http (Maybe (Qualified ConvId)) -kpcGet brig ref = do - resp <- - get (brig . paths ["i", "mls", "key-packages", toByteString' $ toUrlPiece ref, "conversation"]) - liftIO $ case statusCode resp of - 404 -> pure Nothing - 200 -> pure $ responseBody resp >>= decode - _ -> assertFailure "GET i/mls/key-packages/:ref/conversation failed" - -testKpcFreshGet :: Brig -> Http () -testKpcFreshGet brig = do - ref <- keyPackageCreate brig - mqConv <- kpcGet brig ref - liftIO $ assertEqual "(fresh) Get ~= Nothing" Nothing mqConv - -testKpcPutGet :: Brig -> Http () -testKpcPutGet brig = do - ref <- keyPackageCreate brig - qConv <- liftIO $ generate arbitrary - kpcPut brig ref qConv - mqConv <- kpcGet brig ref - liftIO $ assertEqual "Put x; Get ~= x" (Just qConv) mqConv - -testKpcGetGet :: Brig -> Http () -testKpcGetGet brig = do - ref <- keyPackageCreate brig - liftIO (generate arbitrary) >>= kpcPut brig ref - mqConv1 <- kpcGet brig ref - mqConv2 <- kpcGet brig ref - liftIO $ assertEqual "Get; Get ~= Get" mqConv1 mqConv2 - -testKpcPutPut :: Brig -> Http () -testKpcPutPut brig = do - ref <- keyPackageCreate brig - qConv <- liftIO $ generate arbitrary - qConv2 <- liftIO $ generate arbitrary - kpcPut brig ref qConv - kpcPut brig ref qConv2 - mqConv <- kpcGet brig ref - liftIO $ assertEqual "Put x; Put y ~= Put y" (Just qConv2) mqConv - -testAddKeyPackageRef :: Brig -> Http () -testAddKeyPackageRef brig = do - ref <- keyPackageCreate brig - qcnv <- liftIO $ generate arbitrary - qusr <- liftIO $ generate arbitrary - c <- liftIO $ generate arbitrary - put - ( brig - . paths ["i", "mls", "key-packages", toByteString' $ toUrlPiece ref] - . json - NewKeyPackageRef - { nkprUserId = qusr, - nkprClientId = c, - nkprConversation = qcnv - } - ) - !!! const 201 === statusCode - ci <- - responseJsonError - =<< get (brig . paths ["i", "mls", "key-packages", toByteString' $ toUrlPiece ref]) - (Request -> Request) -> UserId -> m ResponseLBS getFeatureConfig galley uid = do get $ apiVersion "v1" . galley . paths ["feature-configs", featureNameBS @cfg] . zUser uid diff --git a/services/brig/test/integration/API/Internal/Util.hs b/services/brig/test/integration/API/Internal/Util.hs index 4837e7a1019..ab6824f4d76 100644 --- a/services/brig/test/integration/API/Internal/Util.hs +++ b/services/brig/test/integration/API/Internal/Util.hs @@ -30,7 +30,7 @@ module API.Internal.Util where import API.Team.Util (createPopulatedBindingTeamWithNamesAndHandles) -import Bilge +import Bilge hiding (host, port) import Control.Lens (view, (^.)) import Control.Monad.Catch (MonadCatch, MonadThrow, throwM) import Data.ByteString.Base16 qualified as B16 @@ -46,7 +46,7 @@ import Servant.API.ContentTypes (NoContent) import Servant.Client qualified as Client import System.Random (randomIO) import Util -import Util.Options (Endpoint, epHost, epPort) +import Util.Options (Endpoint, host, port) import Wire.API.Connection import Wire.API.Push.V2.Token qualified as PushToken import Wire.API.Routes.Internal.Brig as IAPI @@ -146,5 +146,5 @@ deleteAccountConferenceCallingConfigClient brigep mgr uid = runHereClientM brige runHereClientM :: (HasCallStack, MonadIO m) => Endpoint -> Manager -> Client.ClientM a -> m (Either Client.ClientError a) runHereClientM brigep mgr action = do let env = Client.mkClientEnv mgr baseurl - baseurl = Client.BaseUrl Client.Http (cs $ brigep ^. epHost) (fromIntegral $ brigep ^. epPort) "" + baseurl = Client.BaseUrl Client.Http (cs $ brigep ^. host) (fromIntegral $ brigep ^. port) "" liftIO $ Client.runClientM action env diff --git a/services/brig/test/integration/API/MLS.hs b/services/brig/test/integration/API/MLS.hs index 7fc652e8119..6c93560b0a9 100644 --- a/services/brig/test/integration/API/MLS.hs +++ b/services/brig/test/integration/API/MLS.hs @@ -19,7 +19,7 @@ module API.MLS where import API.MLS.Util import Bilge -import Bilge.Assert +import Bilge.Assert (( Brig -> Opts -> TestTree tests m b opts = testGroup "MLS" - [ test m "POST /mls/key-packages/self/:client" (testKeyPackageUpload b), - test m "POST /mls/key-packages/self/:client (no public keys)" (testKeyPackageUploadNoKey b), - test m "GET /mls/key-packages/self/:client/count" (testKeyPackageZeroCount b), - test m "GET /mls/key-packages/self/:client/count (expired package)" (testKeyPackageExpired b), + [ test m "POST /mls/key-packages/self/:client (no public keys)" (testKeyPackageUploadNoKey b), + -- FUTUREWORK test m "GET /mls/key-packages/self/:client/count (expired package)" (testKeyPackageExpired b), test m "GET /mls/key-packages/claim/local/:user" (testKeyPackageClaim b), test m "GET /mls/key-packages/claim/local/:user - self claim" (testKeyPackageSelfClaim b), test m "GET /mls/key-packages/claim/remote/:user" (testKeyPackageRemoteClaim opts b) ] -testKeyPackageUpload :: Brig -> Http () -testKeyPackageUpload brig = do - u <- userQualifiedId <$> randomUser brig - c <- createClient brig u 0 - withSystemTempDirectory "mls" $ \tmp -> - uploadKeyPackages brig tmp def u c 5 - - count <- getKeyPackageCount brig u c - liftIO $ count @?= 5 - testKeyPackageUploadNoKey :: Brig -> Http () testKeyPackageUploadNoKey brig = do u <- userQualifiedId <$> randomUser brig @@ -74,13 +62,6 @@ testKeyPackageUploadNoKey brig = do count <- getKeyPackageCount brig u c liftIO $ count @?= 0 -testKeyPackageZeroCount :: Brig -> Http () -testKeyPackageZeroCount brig = do - u <- userQualifiedId <$> randomUser brig - c <- randomClient - count <- getKeyPackageCount brig u c - liftIO $ count @?= 0 - testKeyPackageExpired :: Brig -> Http () testKeyPackageExpired brig = do u <- userQualifiedId <$> randomUser brig @@ -115,7 +96,7 @@ testKeyPackageClaim brig = do -- claim packages for both clients of u u' <- userQualifiedId <$> randomUser brig - bundle <- + bundle :: KeyPackageBundle <- responseJsonError =<< post ( brig @@ -124,8 +105,7 @@ testKeyPackageClaim brig = do ) (kpbeUser e, kpbeClient e)) (kpbEntries bundle) @?= Set.fromList [(u, c1), (u, c2)] - checkMapping brig u bundle + liftIO $ Set.map (\e -> (e.user, e.client)) bundle.entries @?= Set.fromList [(u, c1), (u, c2)] -- check that we have one fewer key package now for_ [c1, c2] $ \c -> do @@ -145,16 +125,16 @@ testKeyPackageSelfClaim brig = do -- claim own packages but skip the first do - bundle <- + bundle :: KeyPackageBundle <- responseJsonError =<< post ( brig . paths ["mls", "key-packages", "claim", toByteString' (qDomain u), toByteString' (qUnqualified u)] - . queryItem "skip_own" (toByteString' c1) . zUser (qUnqualified u) + . zClient c1 ) (kpbeUser e, kpbeClient e)) (kpbEntries bundle) @?= Set.fromList [(u, c2)] + liftIO $ Set.map (\e -> (e.user, e.client)) bundle.entries @?= Set.fromList [(u, c2)] -- check that we still have all keypackages for client c1 count <- getKeyPackageCount brig u c1 @@ -163,7 +143,7 @@ testKeyPackageSelfClaim brig = do -- if another user sets skip_own, nothing is skipped do u' <- userQualifiedId <$> randomUser brig - bundle <- + bundle :: KeyPackageBundle <- responseJsonError =<< post ( brig @@ -172,7 +152,7 @@ testKeyPackageSelfClaim brig = do . zUser (qUnqualified u') ) (kpbeUser e, kpbeClient e)) (kpbEntries bundle) @?= Set.fromList [(u, c1), (u, c2)] + liftIO $ Set.map (\e -> (e.user, e.client)) bundle.entries @?= Set.fromList [(u, c1), (u, c2)] -- check package counts again for_ [(c1, 2), (c2, 1)] $ \(c, n) -> do @@ -181,6 +161,7 @@ testKeyPackageSelfClaim brig = do testKeyPackageRemoteClaim :: Opts -> Brig -> Http () testKeyPackageRemoteClaim opts brig = do + traceM "sun" u <- fakeRemoteUser u' <- userQualifiedId <$> randomUser brig @@ -192,12 +173,13 @@ testKeyPackageRemoteClaim opts brig = do (r, kp) <- generateKeyPackage tmp qcid Nothing pure $ KeyPackageBundleEntry - { kpbeUser = u, - kpbeClient = ciClient qcid, - kpbeRef = kp, - kpbeKeyPackage = KeyPackageData . rmRaw $ r + { user = u, + client = ciClient qcid, + ref = kp, + keyPackage = KeyPackageData . raw $ r } let mockBundle = KeyPackageBundle (Set.fromList entries) + traceM "gun" (bundle :: KeyPackageBundle, _reqs) <- liftIO . withTempMockFederator opts (Aeson.encode mockBundle) $ responseJsonError @@ -209,23 +191,10 @@ testKeyPackageRemoteClaim opts brig = do Qualified UserId -> KeyPackageBundle -> Http () -checkMapping brig u bundle = - for_ (kpbEntries bundle) $ \e -> do - cid <- - responseJsonError - =<< get (brig . paths ["i", "mls", "key-packages", toHeader (kpbeRef e)]) - Qualified UserId -> Int -> Http ClientId createClient brig u i = fmap clientId $ diff --git a/services/brig/test/integration/API/MLS/Util.hs b/services/brig/test/integration/API/MLS/Util.hs index e80402ea75f..9b103e63f86 100644 --- a/services/brig/test/integration/API/MLS/Util.hs +++ b/services/brig/test/integration/API/MLS/Util.hs @@ -34,6 +34,7 @@ import System.FilePath import System.Process import Test.Tasty.HUnit import Util +import Wire.API.MLS.CipherSuite import Wire.API.MLS.Credential import Wire.API.MLS.KeyPackage import Wire.API.MLS.Serialisation @@ -109,7 +110,7 @@ uploadKeyPackages brig tmp KeyingInfo {..} u c n = do . json defUpdateClient {updateClientMLSPublicKeys = Map.fromList [(Ed25519, pk)]} ) !!! const 200 === statusCode - let upload = object ["key_packages" .= toJSON (map (Base64ByteString . rmRaw) kps)] + let upload = object ["key_packages" .= toJSON (map (Base64ByteString . raw) kps)] post ( brig . paths ["mls", "key-packages", "self", toByteString' c] diff --git a/services/brig/test/integration/API/OAuth.hs b/services/brig/test/integration/API/OAuth.hs index 1be8e09ea98..757815af2c0 100644 --- a/services/brig/test/integration/API/OAuth.hs +++ b/services/brig/test/integration/API/OAuth.hs @@ -52,10 +52,9 @@ import Text.RawString.QQ import URI.ByteString import Util import Web.FormUrlEncoded -import Wire.API.Conversation (Access (..), Conversation (cnvQualifiedId)) +import Wire.API.Conversation import Wire.API.Conversation qualified as Conv import Wire.API.Conversation.Code (CreateConversationCodeRequest (CreateConversationCodeRequest)) -import Wire.API.Conversation.Protocol (ProtocolTag (ProtocolProteusTag)) import Wire.API.Conversation.Role qualified as Role import Wire.API.OAuth import Wire.API.Routes.Bearer (Bearer (Bearer, unBearer)) @@ -702,7 +701,7 @@ createTeamConv :: Http ResponseLBS createTeamConv svc mkHeader token tid name = do let tinfo = Conv.ConvTeamInfo tid - let conv = Conv.NewConv [] [] (checked name) (Set.fromList [CodeAccess]) Nothing (Just tinfo) Nothing Nothing Role.roleNameWireAdmin ProtocolProteusTag + let conv = Conv.NewConv [] [] (checked name) (Set.fromList [CodeAccess]) Nothing (Just tinfo) Nothing Nothing Role.roleNameWireAdmin BaseProtocolProteusTag post $ svc . path "conversations" diff --git a/services/brig/test/integration/API/Provider.hs b/services/brig/test/integration/API/Provider.hs index 815bf4025aa..12749a533a3 100644 --- a/services/brig/test/integration/API/Provider.hs +++ b/services/brig/test/integration/API/Provider.hs @@ -47,6 +47,7 @@ import Data.Id hiding (client) import Data.Json.Util (toBase64Text) import Data.List1 (List1) import Data.List1 qualified as List1 +import Data.Map qualified as Map import Data.Misc import Data.PEM import Data.Qualified @@ -83,7 +84,6 @@ import Wire.API.Asset hiding (Asset) import Wire.API.Connection import Wire.API.Conversation import Wire.API.Conversation.Bot -import Wire.API.Conversation.Protocol import Wire.API.Conversation.Role import Wire.API.Event.Conversation import Wire.API.Internal.Notification @@ -96,11 +96,12 @@ import Wire.API.Team.Feature (featureNameBS) import Wire.API.Team.Feature qualified as Public import Wire.API.Team.Permission import Wire.API.User as User hiding (EmailUpdate, PasswordChange, mkName) +import Wire.API.User.Auth (CookieType (..)) import Wire.API.User.Client import Wire.API.User.Client.Prekey -tests :: Domain -> Config -> Manager -> DB.ClientState -> Brig -> Cannon -> Galley -> IO TestTree -tests dom conf p db b c g = do +tests :: Domain -> Config -> Manager -> DB.ClientState -> Brig -> Cannon -> Galley -> Nginz -> IO TestTree +tests dom conf p db b c g n = do pure $ testGroup "provider" @@ -138,14 +139,18 @@ tests dom conf p db b c g = do test p "de-whitelisted bots are removed" $ testWhitelistKickout dom conf db b g c, test p "de-whitelisting works with deleted conversations" $ - testDeWhitelistDeletedConv conf db b g c + testDeWhitelistDeletedConv conf db b g c, + test p "whitelist via nginz" $ testWhitelistNginz conf db b n ], testGroup "bot" [ test p "add-remove" $ testAddRemoveBot conf db b g c, test p "message" $ testMessageBot conf db b g c, test p "bad fingerprint" $ testBadFingerprint conf db b g c, - test p "add bot forbidden" $ testAddBotForbidden conf db b g + test p "add bot forbidden" $ testAddBotForbidden conf db b g, + test p "claim user prekeys" $ testClaimUserPrekeys conf db b g, + test p "list user profiles" $ testListUserProfiles conf db b g, + test p "get user clients" $ testGetUserClients conf db b g ], testGroup "bot-teams" @@ -354,6 +359,12 @@ testAddGetService config db brig = do assertEqual "assets" (serviceAssets svc) (serviceProfileAssets svp) assertEqual "tags" (serviceTags svc) (serviceProfileTags svp) assertBool "enabled" (not (serviceProfileEnabled svp)) + services :: [Service] <- responseJsonError =<< getServices brig pid DB.ClientState -> Brig -> Galley -> Http () +testClaimUserPrekeys config db brig galley = withTestService config db brig defServiceApp $ \sref _ -> do + (pid, sid, u1, _u2, _h) <- prepareUsers sref brig + cid <- do + rs <- createConv galley u1.userId [] DB.ClientState -> Brig -> Galley -> Http () +testListUserProfiles config db brig galley = withTestService config db brig defServiceApp $ \sref _ -> do + (pid, sid, u1, u2, _h) <- prepareUsers sref brig + cid <- do + rs <- createConv galley u1.userId [] DB.ClientState -> Brig -> Galley -> Http () +testGetUserClients config db brig galley = withTestService config db brig defServiceApp $ \sref _ -> do + (pid, sid, u1, _u2, _h) <- prepareUsers sref brig + cid <- do + rs <- createConv galley u1.userId [] DB.ClientState -> Brig -> Galley -> Http () testAddBotBlocked config db brig galley = withTestService config db brig defServiceApp $ \sref _buf -> do (userId -> u1, _, _, tid, cid, pid, sid) <- prepareBotUsersTeam brig galley sref @@ -727,7 +789,7 @@ testBotTeamOnlyConv config db brig galley cannon = withTestService config db bri let msg = QualifiedUserIdList gone assertEqual "conv" cnv (evtConv e) assertEqual "user" leaveFrom (evtFrom e) - assertEqual "event data" (EdMembersLeave msg) (evtData e) + assertEqual "event data" (EdMembersLeave EdReasonRemoved msg) (evtData e) _ -> assertFailure $ "expected event of type: ConvAccessUpdate or MemberLeave, got: " <> show e setAccessRole uid qcid role = @@ -1204,6 +1266,22 @@ getService brig pid sid = . header "Z-Type" "provider" . header "Z-Provider" (toByteString' pid) +getServices :: Brig -> ProviderId -> Http ResponseLBS +getServices brig pid = + get $ + brig + . path "/provider/services" + . header "Z-Type" "provider" + . header "Z-Provider" (toByteString' pid) + +getProviderServices :: Brig -> UserId -> ProviderId -> Http ResponseLBS +getProviderServices brig uid pid = + get $ + brig + . paths ["providers", toByteString' pid, "services"] + . header "Z-Type" "access" + . header "Z-User" (toByteString' uid) + getServiceProfile :: Brig -> UserId -> @@ -1414,7 +1492,7 @@ createConvWithAccessRoles ars g u us = . contentJson . body (RequestBodyLBS (encode conv)) where - conv = NewConv us [] Nothing Set.empty ars Nothing Nothing Nothing roleNameWireAdmin ProtocolProteusTag + conv = NewConv us [] Nothing Set.empty ars Nothing Nothing Nothing roleNameWireAdmin BaseProtocolProteusTag postMessage :: Galley -> @@ -1504,6 +1582,68 @@ enabled2ndFaForTeamInternal galley tid = do ) !!! const 200 === statusCode +getBotSelf :: Brig -> BotId -> Http ResponseLBS +getBotSelf brig bid = + get $ + brig + . path "/bot/self" + . header "Z-Type" "bot" + . header "Z-Bot" (toByteString' bid) + +getBotClient :: Brig -> BotId -> Http ResponseLBS +getBotClient brig bid = + get $ + brig + . path "/bot/client" + . header "Z-Type" "bot" + . header "Z-Bot" (toByteString' bid) + . contentJson + +getBotPreKeyIds :: Brig -> BotId -> Http ResponseLBS +getBotPreKeyIds brig bid = + get $ + brig + . path "/bot/client/prekeys" + . header "Z-Type" "bot" + . header "Z-Bot" (toByteString' bid) + +updateBotPrekeys :: Brig -> BotId -> [Prekey] -> Http ResponseLBS +updateBotPrekeys brig bid prekeys = + post $ + brig + . path "/bot/client/prekeys" + . header "Z-Type" "bot" + . header "Z-Bot" (toByteString' bid) + . contentJson + . body (RequestBodyLBS (encode (UpdateBotPrekeys prekeys))) + +claimUsersPrekeys :: Brig -> BotId -> UserClients -> Http ResponseLBS +claimUsersPrekeys brig bid ucs = + post $ + brig + . path "/bot/users/prekeys" + . header "Z-Type" "bot" + . header "Z-Bot" (toByteString' bid) + . contentJson + . body (RequestBodyLBS (encode ucs)) + +listUserProfiles :: Brig -> BotId -> [UserId] -> Http ResponseLBS +listUserProfiles brig bid uids = + get $ + brig + . path "/bot/users" + . header "Z-Type" "bot" + . header "Z-Bot" (toByteString' bid) + . queryItem "ids" (C8.intercalate "," $ toByteString' <$> uids) + +getUserClients :: Brig -> BotId -> UserId -> Http ResponseLBS +getUserClients brig bid uid = + get $ + brig + . paths ["bot", "users", toByteString' uid, "clients"] + . header "Z-Type" "bot" + . header "Z-Bot" (toByteString' bid) + -------------------------------------------------------------------------------- -- DB Operations @@ -1545,7 +1685,7 @@ testRegisterProvider db' brig = do getProviderActivationCodeInternal brig email + Nginz -> + -- | Team owner + User -> + -- | Team + TeamId -> + ProviderId -> + ServiceId -> + Http () +whitelistServiceNginz nginz user tid pid sid = + updateServiceWhitelistNginz nginz user tid (UpdateServiceWhitelist pid sid True) !!! const 200 === statusCode + +updateServiceWhitelistNginz :: + Nginz -> + User -> + TeamId -> + UpdateServiceWhitelist -> + Http ResponseLBS +updateServiceWhitelistNginz nginz user tid upd = do + let Just email = userEmail user + rs <- login nginz (defEmailLogin email) PersistentCookie toByteString' t) + . contentJson + . body (RequestBodyLBS (encode upd)) + whitelistService :: HasCallStack => Brig -> @@ -1866,7 +2036,7 @@ wsAssertMemberLeave ws conv usr old = void $ evtConv e @?= conv evtType e @?= MemberLeave evtFrom e @?= usr - evtData e @?= EdMembersLeave (QualifiedUserIdList old) + evtData e @?= EdMembersLeave EdReasonRemoved (QualifiedUserIdList old) wsAssertConvDelete :: (HasCallStack, MonadIO m) => WS.WebSocket -> Qualified ConvId -> Qualified UserId -> m () wsAssertConvDelete ws conv from = void $ @@ -1913,7 +2083,7 @@ svcAssertMemberLeave buf usr gone cnv = liftIO $ do assertEqual "event type" MemberLeave (evtType e) assertEqual "conv" cnv (evtConv e) assertEqual "user" usr (evtFrom e) - assertEqual "event data" (EdMembersLeave msg) (evtData e) + assertEqual "event data" (EdMembersLeave EdReasonRemoved msg) (evtData e) _ -> assertFailure "Event timeout (TestBotMessage: member-leave)" svcAssertConvDelete :: (HasCallStack, MonadIO m) => Chan TestBotEvent -> Qualified UserId -> Qualified ConvId -> m () @@ -2036,9 +2206,17 @@ testAddRemoveBotUtil localDomain pid sid cid u1 u2 h sref buf brig galley cannon let Just rs = responseJsonMaybe _rs bid = rsAddBotId rs qbuid = Qualified (botUserId bid) localDomain + getBotSelf brig bid !!! const 200 === statusCode + (randomId >>= getBotSelf brig . BotId) !!! const 404 === statusCode + botClient :: Client <- responseJsonError =<< getBotClient brig bid >= getBotClient brig . BotId) !!! const 404 === statusCode bot <- svcAssertBotCreated buf bid cid - liftIO $ assertEqual "bot client" (rsAddBotClient rs) (testBotClient bot) + liftIO $ assertEqual "bot client" rs.rsAddBotClient bot.testBotClient liftIO $ assertEqual "bot event" MemberJoin (evtType (rsAddBotEvent rs)) + -- just check that these endpoints works + getBotPreKeyIds brig bid !!! const 200 === statusCode + updateBotPrekeys brig bid bot.testBotPrekeys !!! const 200 === statusCode -- Member join event for both users forM_ [ws1, ws2] $ \ws -> wsAssertMemberJoin ws qcid quid1 [qbuid] -- Member join event for the bot @@ -2068,7 +2246,7 @@ testAddRemoveBotUtil localDomain pid sid cid u1 u2 h sref buf brig galley cannon assertEqual "colour" defaultAccentId (profileAccentId bp) assertEqual "assets" defServiceAssets (profileAssets bp) -- Check that the bot client exists and has prekeys - let isBotPrekey = (`elem` testBotPrekeys bot) . prekeyData + let isBotPrekey = (`elem` bot.testBotPrekeys) . prekeyData getPreKey brig buid buid (rsAddBotClient rs) !!! do const 200 === statusCode const (Just True) === fmap isBotPrekey . responseJsonMaybe @@ -2166,6 +2344,14 @@ prepareBotUsersTeam brig galley sref = do cid <- Team.createTeamConv galley tid uid1 [uid2] Nothing pure (u1, u2, h, tid, cid, pid, sid) +testWhitelistNginz :: Config -> DB.ClientState -> Brig -> Nginz -> Http () +testWhitelistNginz config db brig nginz = withTestService config db brig defServiceApp $ \sref _ -> do + let pid = sref ^. serviceRefProvider + let sid = sref ^. serviceRefId + (admin, tid) <- Team.createUserWithTeam brig + adminUser <- selfUser <$> getSelfProfile brig admin + whitelistServiceNginz nginz adminUser tid pid sid + addBotConv :: HasCallStack => Domain -> diff --git a/services/brig/test/integration/API/Swagger.hs b/services/brig/test/integration/API/Swagger.hs index 2d2525dcdc6..ec5ec230e96 100644 --- a/services/brig/test/integration/API/Swagger.hs +++ b/services/brig/test/integration/API/Swagger.hs @@ -48,7 +48,7 @@ tests p _opts brigNoImplicitVersion = [ test p "toc" $ do forM_ ["/api/swagger-ui", "/api/swagger-ui/index.html", "/api/swagger.json"] $ \pth -> do r <- get (brigNoImplicitVersion . path pth . expect2xx) - liftIO $ assertEqual "toc is intact" (responseBody r) (Just "

please pick an api version

/v0/api/swagger-ui/
/v1/api/swagger-ui/
/v2/api/swagger-ui/
/v3/api/swagger-ui/
/v4/api/swagger-ui/
") + liftIO $ assertEqual "toc is intact" (Just "

please pick an api version

/v0/api/swagger-ui/
/v1/api/swagger-ui/
/v2/api/swagger-ui/
/v3/api/swagger-ui/
/v4/api/swagger-ui/
/v5/api/swagger-ui/
") (responseBody r) -- are all versions listed? forM_ [minBound :: Version ..] $ \v -> liftIO $ assertBool (show v) ((cs (toQueryParam v) :: String) `isInfixOf` (cs . fromJust . responseBody $ r)) -- FUTUREWORK: maybe test that no invalid versions are listed? (that wouldn't diff --git a/services/brig/test/integration/API/Team/Util.hs b/services/brig/test/integration/API/Team/Util.hs index f99e691b286..161902c4bc7 100644 --- a/services/brig/test/integration/API/Team/Util.hs +++ b/services/brig/test/integration/API/Team/Util.hs @@ -40,7 +40,6 @@ import Test.Tasty.HUnit import Util import Web.Cookie (parseSetCookie, setCookieName) import Wire.API.Conversation -import Wire.API.Conversation.Protocol import Wire.API.Conversation.Role import Wire.API.Routes.Internal.Galley.TeamsIntra qualified as Team import Wire.API.Team hiding (newTeam) @@ -235,7 +234,7 @@ createTeamConvWithRole role g tid u us mtimer = do mtimer Nothing role - ProtocolProteusTag + BaseProtocolProteusTag r <- post ( g diff --git a/services/brig/test/integration/API/User/Auth.hs b/services/brig/test/integration/API/User/Auth.hs index 8979a1b9415..06a10761028 100644 --- a/services/brig/test/integration/API/User/Auth.hs +++ b/services/brig/test/integration/API/User/Auth.hs @@ -202,17 +202,20 @@ testLoginWith6CharPassword brig db = do (PasswordLogin (PasswordLoginData (LoginByEmail email) pw Nothing Nothing)) PersistentCookie !!! const expectedStatusCode === statusCode + -- Since 8 char passwords are required, when setting a password via the API, -- we need to write this directly to the db, to be able to test this writeDirectlyToDB :: UserId -> PlainTextPassword6 -> Http () writeDirectlyToDB uid pw = liftIO (runClient db (updatePassword uid pw >> revokeAllCookies uid)) + updatePassword :: MonadClient m => UserId -> PlainTextPassword6 -> m () updatePassword u t = do p <- liftIO $ mkSafePassword t retry x5 $ write userPasswordUpdate (params LocalQuorum (p, u)) + userPasswordUpdate :: PrepQuery W (Password, UserId) () - userPasswordUpdate = "UPDATE user SET password = ? WHERE id = ?" + userPasswordUpdate = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET password = ? WHERE id = ?" -------------------------------------------------------------------------------- -- ZAuth test environment for generating arbitrary tokens. @@ -387,7 +390,7 @@ testPhoneLogin brig = do testHandleLogin :: Brig -> Http () testHandleLogin brig = do - usr <- userId <$> randomUser brig + usr <- (.userId) <$> randomUser brig hdl <- randomHandle let update = RequestBodyLBS . encode $ HandleUpdate hdl put (brig . path "/self/handle" . contentJson . zUser usr . zConn "c" . Http.body update) @@ -700,7 +703,7 @@ testLimitRetries conf brig = do testRegularUserLegalHoldLogin :: Brig -> Http () testRegularUserLegalHoldLogin brig = do -- Create a regular user - uid <- userId <$> randomUser brig + uid <- (.userId) <$> randomUser brig -- fail if user is not a team user legalHoldLogin brig (LegalHoldLogin uid (Just defPassword) Nothing) PersistentCookie !!! do const 403 === statusCode @@ -785,7 +788,7 @@ testLegalHoldLogout brig galley = do testEmailSsoLogin :: Brig -> Http () testEmailSsoLogin brig = do -- Create a user - uid <- userId <$> randomUser brig + uid <- (.userId) <$> randomUser brig now <- liftIO getCurrentTime -- Login and do some checks _rs <- @@ -800,7 +803,7 @@ testEmailSsoLogin brig = do testSuspendedSsoLogin :: Brig -> Http () testSuspendedSsoLogin brig = do -- Create a user and immediately suspend them - uid <- userId <$> randomUser brig + uid <- (.userId) <$> randomUser brig setStatus brig uid Suspended -- Try to login and see if we fail ssoLogin brig (SsoLogin uid Nothing) PersistentCookie !!! do @@ -830,7 +833,7 @@ testInvalidCookie z b = do const 403 === statusCode const (Just "Invalid user token") =~= responseBody -- Expired - user <- userId <$> randomUser b + user <- (.userId) <$> randomUser b let f = set (ZAuth.userTTL (Proxy @u)) 0 t <- toByteString' <$> runZAuth z (ZAuth.localSettings f (ZAuth.newUserToken @u user Nothing)) liftIO $ threadDelay 1000000 @@ -842,7 +845,7 @@ testInvalidCookie z b = do testInvalidToken :: ZAuth.Env -> Brig -> Http () testInvalidToken z b = do - user <- userId <$> randomUser b + user <- (.userId) <$> randomUser b t <- toByteString' <$> runZAuth z (ZAuth.newUserToken @ZAuth.User user Nothing) -- Syntactically invalid @@ -1418,7 +1421,7 @@ testLogout b = do testReauthentication :: Brig -> Http () testReauthentication b = do - u <- userId <$> randomUser b + u <- (.userId) <$> randomUser b let js = Http.body . RequestBodyLBS . encode $ object ["foo" .= ("bar" :: Text)] get (b . paths ["/i/users", toByteString' u, "reauthenticate"] . contentJson . js) !!! do const 403 === statusCode diff --git a/services/brig/test/integration/API/User/Client.hs b/services/brig/test/integration/API/User/Client.hs index 55510064b3b..f474f65cd76 100644 --- a/services/brig/test/integration/API/User/Client.hs +++ b/services/brig/test/integration/API/User/Client.hs @@ -40,7 +40,6 @@ import Data.Aeson hiding (json) import Data.Aeson qualified as A import Data.Aeson.KeyMap qualified as M import Data.Aeson.Lens -import Data.ByteString.Base64.URL qualified as B64 import Data.ByteString.Conversion import Data.Coerce (coerce) import Data.Default @@ -52,10 +51,10 @@ import Data.Nonce (isValidBase64UrlEncodedUUID) import Data.Qualified (Qualified (..)) import Data.Range (unsafeRange) import Data.Set qualified as Set -import Data.Text (replace) -import Data.Text.Ascii (AsciiChars (validate)) +import Data.Text.Ascii (AsciiChars (validate), encodeBase64UrlUnpadded, toText) import Data.Time (addUTCTime) import Data.Time.Clock.POSIX +import Data.UUID (toByteString) import Data.Vector qualified as Vec import Imports import Network.Wai.Utilities.Error qualified as Error @@ -68,7 +67,7 @@ import Test.Tasty.HUnit import UnliftIO (mapConcurrently) import Util import Wire.API.Internal.Notification -import Wire.API.MLS.Credential +import Wire.API.MLS.CipherSuite import Wire.API.Team.Feature qualified as Public import Wire.API.User import Wire.API.User qualified as Public @@ -1424,22 +1423,25 @@ testCreateAccessToken opts n brig = do let localDomain = opts ^. Opt.optionSettings & Opt.setFederationDomain u <- randomUser brig let uid = userId u + -- convert the user Id into 16 octets of binary and then base64url + let uidBS = Data.UUID.toByteString (toUUID uid) + let uidB64 = encodeBase64UrlUnpadded (cs uidBS) let email = fromMaybe (error "invalid email") $ userEmail u rs <- login n (defEmailLogin email) PersistentCookie (floor <$> getPOSIXTime) - let clientIdentity = cs $ "im:wireapp=" <> uidb64 <> "/" <> toByteString' cid <> "@" <> toByteString' localDomain + let clientIdentity = cs $ "im:wireapp=" <> cs (toText uidB64) <> "/" <> toByteString' cid <> "@" <> toByteString' localDomain let httpsUrl = cs $ "https://" <> toByteString' localDomain <> "/clients/" <> toByteString' cid <> "/access-token" + let expClaim = NumericDate (addUTCTime 10 now) let claimsSet' = emptyClaimsSet & claimIat ?~ NumericDate now - & claimExp ?~ NumericDate (addUTCTime 10 now) + & claimExp ?~ expClaim & claimNbf ?~ NumericDate now & claimSub ?~ fromMaybe (error "invalid sub claim") ((clientIdentity :: Text) ^? stringOrUri) & claimJti ?~ "6fc59e7f-b666-4ffc-b738-4f4760c884ca" diff --git a/services/brig/test/integration/API/User/Connection.hs b/services/brig/test/integration/API/User/Connection.hs index 85260c9d002..972d4ddeda0 100644 --- a/services/brig/test/integration/API/User/Connection.hs +++ b/services/brig/test/integration/API/User/Connection.hs @@ -44,7 +44,7 @@ import Util import Wire.API.Connection import Wire.API.Conversation import Wire.API.Federation.API.Brig -import Wire.API.Federation.API.Galley (GetConversationsRequest (..), GetConversationsResponse (gcresConvs), RemoteConvMembers (rcmOthers), RemoteConversation (rcnvMembers)) +import Wire.API.Federation.API.Galley (GetConversationsRequest (..), GetConversationsResponse (convs), RemoteConvMembers (others), RemoteConversation (members)) import Wire.API.Federation.Component import Wire.API.Routes.Internal.Brig.Connection import Wire.API.Routes.MultiTablePaging @@ -113,7 +113,7 @@ tests cl _at opts p b _c g fedBrigClient fedGalleyClient db = testCreateConnectionInvalidUser :: Brig -> Http () testCreateConnectionInvalidUser brig = do - uid1 <- userId <$> randomUser brig + uid1 <- (.userId) <$> randomUser brig -- user does not exist uid2 <- Id <$> liftIO UUID.nextRandom postConnection brig uid1 uid2 !!! do @@ -142,14 +142,14 @@ testCreateConnectionInvalidUserQualified brig = do testCreateManualConnections :: Brig -> Http () testCreateManualConnections brig = do - uid1 <- userId <$> randomUser brig - uid2 <- userId <$> randomUser brig + uid1 <- (.userId) <$> randomUser brig + uid2 <- (.userId) <$> randomUser brig postConnection brig uid1 uid2 !!! const 201 === statusCode assertConnections brig uid1 [ConnectionStatus uid1 uid2 Sent] assertConnections brig uid2 [ConnectionStatus uid2 uid1 Pending] -- Test that no connections to anonymous users can be created, -- as well as that anonymous users cannot create connections. - uid3 <- userId <$> createAnonUser "foo3" brig + uid3 <- (.userId) <$> createAnonUser "foo3" brig postConnection brig uid1 uid3 !!! const 400 === statusCode postConnection brig uid3 uid1 !!! const 403 === statusCode @@ -168,8 +168,8 @@ testCreateManualConnectionsQualified brig = do testCreateMutualConnections :: Brig -> Galley -> Http () testCreateMutualConnections brig galley = do - uid1 <- userId <$> randomUser brig - uid2 <- userId <$> randomUser brig + uid1 <- (.userId) <$> randomUser brig + uid2 <- (.userId) <$> randomUser brig postConnection brig uid1 uid2 !!! const 201 === statusCode assertConnections brig uid1 [ConnectionStatus uid1 uid2 Sent] assertConnections brig uid2 [ConnectionStatus uid2 uid1 Pending] @@ -210,8 +210,8 @@ testCreateMutualConnectionsQualified brig galley = do testAcceptConnection :: Brig -> Http () testAcceptConnection brig = do - uid1 <- userId <$> randomUser brig - uid2 <- userId <$> randomUser brig + uid1 <- (.userId) <$> randomUser brig + uid2 <- (.userId) <$> randomUser brig -- Initiate a new connection (A -> B) postConnection brig uid1 uid2 !!! const 201 === statusCode -- B accepts @@ -219,7 +219,7 @@ testAcceptConnection brig = do assertConnections brig uid1 [ConnectionStatus uid1 uid2 Accepted] assertConnections brig uid2 [ConnectionStatus uid2 uid1 Accepted] -- Mutual connection request with a user C - uid3 <- userId <$> randomUser brig + uid3 <- (.userId) <$> randomUser brig postConnection brig uid1 uid3 !!! const 201 === statusCode postConnection brig uid3 uid1 !!! const 200 === statusCode assertConnections brig uid1 [ConnectionStatus uid1 uid3 Accepted] @@ -238,8 +238,8 @@ testAcceptConnectionQualified brig = do testIgnoreConnection :: Brig -> Http () testIgnoreConnection brig = do - uid1 <- userId <$> randomUser brig - uid2 <- userId <$> randomUser brig + uid1 <- (.userId) <$> randomUser brig + uid2 <- (.userId) <$> randomUser brig -- Initiate a new connection (A -> B) postConnection brig uid1 uid2 !!! const 201 === statusCode -- B ignores A @@ -267,8 +267,8 @@ testIgnoreConnectionQualified brig = do testCancelConnection :: Brig -> Http () testCancelConnection brig = do - uid1 <- userId <$> randomUser brig - uid2 <- userId <$> randomUser brig + uid1 <- (.userId) <$> randomUser brig + uid2 <- (.userId) <$> randomUser brig -- Initiate a new connection (A -> B) postConnection brig uid1 uid2 !!! const 201 === statusCode -- A cancels the request @@ -296,8 +296,8 @@ testCancelConnectionQualified brig = do testCancelConnection2 :: Brig -> Galley -> Http () testCancelConnection2 brig galley = do - uid1 <- userId <$> randomUser brig - uid2 <- userId <$> randomUser brig + uid1 <- (.userId) <$> randomUser brig + uid2 <- (.userId) <$> randomUser brig -- Initiate a new connection (A -> B) postConnection brig uid1 uid2 !!! const 201 === statusCode -- A cancels the request @@ -373,8 +373,8 @@ testBlockConnection :: Brig -> Http () testBlockConnection brig = do u1 <- randomUser brig u2 <- randomUser brig - let uid1 = userId u1 - let uid2 = userId u2 + let uid1 = u1.userId + let uid2 = u2.userId -- Initiate a new connection (A -> B) postConnection brig uid1 uid2 !!! const 201 === statusCode -- Even connected users cannot see each other's email @@ -418,8 +418,8 @@ testBlockConnectionQualified :: Brig -> Http () testBlockConnectionQualified brig = do u1 <- randomUser brig u2 <- randomUser brig - let uid1 = userId u1 - uid2 = userId u2 + let uid1 = u1.userId + uid2 = u2.userId quid1 = userQualifiedId u1 quid2 = userQualifiedId u2 -- Initiate a new connection (A -> B) @@ -465,8 +465,8 @@ testBlockAndResendConnection :: Brig -> Galley -> Http () testBlockAndResendConnection brig galley = do u1 <- randomUser brig u2 <- randomUser brig - let uid1 = userId u1 - let uid2 = userId u2 + let uid1 = u1.userId + let uid2 = u2.userId -- Initiate a new connection (A -> B) postConnection brig uid1 uid2 !!! const 201 === statusCode -- B blocks A @@ -516,8 +516,8 @@ testBlockAndResendConnectionQualified brig galley = do testUnblockPendingConnection :: Brig -> Http () testUnblockPendingConnection brig = do - u1 <- userId <$> randomUser brig - u2 <- userId <$> randomUser brig + u1 <- (.userId) <$> randomUser brig + u2 <- (.userId) <$> randomUser brig postConnection brig u1 u2 !!! const 201 === statusCode putConnection brig u1 u2 Blocked !!! const 200 === statusCode assertConnections brig u1 [ConnectionStatus u1 u2 Blocked] @@ -539,8 +539,8 @@ testUnblockPendingConnectionQualified brig = do testAcceptWhileBlocked :: Brig -> Http () testAcceptWhileBlocked brig = do - u1 <- userId <$> randomUser brig - u2 <- userId <$> randomUser brig + u1 <- (.userId) <$> randomUser brig + u2 <- (.userId) <$> randomUser brig postConnection brig u1 u2 !!! const 201 === statusCode putConnection brig u1 u2 Blocked !!! const 200 === statusCode assertConnections brig u1 [ConnectionStatus u1 u2 Blocked] @@ -576,8 +576,8 @@ testUpdateConnectionNoopQualified brig = do testBadUpdateConnection :: Brig -> Http () testBadUpdateConnection brig = do - uid1 <- userId <$> randomUser brig - uid2 <- userId <$> randomUser brig + uid1 <- (.userId) <$> randomUser brig + uid2 <- (.userId) <$> randomUser brig postConnection brig uid1 uid2 !!! const 201 === statusCode assertBadUpdate uid1 uid2 Pending assertBadUpdate uid1 uid2 Ignored @@ -606,9 +606,9 @@ testBadUpdateConnectionQualified brig = do testLocalConnectionsPaging :: Brig -> Http () testLocalConnectionsPaging b = do - u <- userId <$> randomUser b + u <- (.userId) <$> randomUser b replicateM_ total $ do - u2 <- userId <$> randomUser b + u2 <- (.userId) <$> randomUser b postConnection b u u2 !!! const 201 === statusCode foldM_ (next u 2) (0, Nothing) [2, 2, 1, 0] foldM_ (next u total) (0, Nothing) [total, 0] @@ -672,21 +672,21 @@ testAllConnectionsPaging b db = do testConnectionLimit :: Brig -> ConnectionLimit -> Http () testConnectionLimit brig (ConnectionLimit l) = do - uid1 <- userId <$> randomUser brig + uid1 <- (.userId) <$> randomUser brig (uid2 : _) <- replicateM (fromIntegral l) (newConn uid1) - uidX <- userId <$> randomUser brig + uidX <- (.userId) <$> randomUser brig postConnection brig uid1 uidX !!! assertLimited -- blocked connections do not count towards the limit putConnection brig uid1 uid2 Blocked !!! const 200 === statusCode postConnection brig uid1 uidX !!! const 201 === statusCode -- the next send/accept hits the limit again - uidY <- userId <$> randomUser brig + uidY <- (.userId) <$> randomUser brig postConnection brig uid1 uidY !!! assertLimited -- (re-)sending an already accepted connection does not affect the limit postConnection brig uid1 uidX !!! const 200 === statusCode where newConn from = do - to <- userId <$> randomUser brig + to <- (.userId) <$> randomUser brig postConnection brig from to !!! const 201 === statusCode pure to assertLimited = do @@ -737,7 +737,7 @@ testConnectOK brig galley fedBrigClient = do testConnectWithAnon :: Brig -> FedClient 'Brig -> Http () testConnectWithAnon brig fedBrigClient = do fromUser <- randomId - toUser <- userId <$> createAnonUser "anon1234" brig + toUser <- (.userId) <$> createAnonUser "anon1234" brig res <- runFedClient @"send-connection-action" fedBrigClient (Domain "far-away.example.com") $ NewConnectionRequest fromUser toUser RemoteConnect @@ -746,7 +746,7 @@ testConnectWithAnon brig fedBrigClient = do testConnectFromAnon :: Brig -> Http () testConnectFromAnon brig = do - anonUser <- userId <$> createAnonUser "anon1234" brig + anonUser <- (.userId) <$> createAnonUser "anon1234" brig remoteUser <- fakeRemoteUser postConnectionQualified brig anonUser remoteUser !!! const 403 === statusCode @@ -790,13 +790,13 @@ testConnectMutualRemoteActionThenLocalAction opts brig fedBrigClient fedGalleyCl let request = GetConversationsRequest - { gcrUserId = qUnqualified quid2, - gcrConvIds = [qUnqualified convId] + { userId = qUnqualified quid2, + convIds = [qUnqualified convId] } res <- runFedClient @"get-conversations" fedGalleyClient (qDomain quid2) request liftIO $ - fmap (fmap omQualifiedId . rcmOthers . rcnvMembers) (gcresConvs res) @?= [[]] + fmap (fmap omQualifiedId . others . members) res.convs @?= [[]] -- The mock response has 'RemoteConnect' as action, because the remote backend -- cannot be sure if the local backend was previously in Ignored state or not diff --git a/services/brig/test/integration/API/User/Util.hs b/services/brig/test/integration/API/User/Util.hs index 96acc83396e..49dbacd7a90 100644 --- a/services/brig/test/integration/API/User/Util.hs +++ b/services/brig/test/integration/API/User/Util.hs @@ -60,6 +60,7 @@ import Test.Tasty.HUnit import Util import Wire.API.Asset import Wire.API.Connection +import Wire.API.Event.Conversation (EdMemberLeftReason) import Wire.API.Event.Conversation qualified as Conv import Wire.API.Federation.API.Brig qualified as F import Wire.API.Federation.Component @@ -512,16 +513,16 @@ matchDeleteUserNotification quid n = do eUnqualifiedId @?= Just (qUnqualified quid) eQualifiedId @?= Just quid -matchConvLeaveNotification :: Qualified ConvId -> Qualified UserId -> [Qualified UserId] -> Notification -> IO () -matchConvLeaveNotification conv remover removeds n = do +matchConvLeaveNotification :: Qualified ConvId -> Qualified UserId -> [Qualified UserId] -> EdMemberLeftReason -> Notification -> IO () +matchConvLeaveNotification conv remover removeds reason n = do let e = List1.head (WS.unpackPayload n) ntfTransient n @?= False Conv.evtConv e @?= conv Conv.evtType e @?= Conv.MemberLeave Conv.evtFrom e @?= remover - sorted (Conv.evtData e) @?= sorted (Conv.EdMembersLeave (Conv.QualifiedUserIdList removeds)) + sorted (Conv.evtData e) @?= sorted (Conv.EdMembersLeave reason (Conv.QualifiedUserIdList removeds)) where - sorted (Conv.EdMembersLeave (Conv.QualifiedUserIdList m)) = Conv.EdMembersLeave (Conv.QualifiedUserIdList (sort m)) + sorted (Conv.EdMembersLeave r (Conv.QualifiedUserIdList m)) = Conv.EdMembersLeave r (Conv.QualifiedUserIdList (sort m)) sorted x = x generateVerificationCode :: (MonadCatch m, MonadIO m, MonadHttp m, HasCallStack) => Brig -> Public.SendVerificationCode -> m () diff --git a/services/brig/test/integration/Federation/End2end.hs b/services/brig/test/integration/Federation/End2end.hs index 4f9a117016c..7fe253c785a 100644 --- a/services/brig/test/integration/Federation/End2end.hs +++ b/services/brig/test/integration/Federation/End2end.hs @@ -18,7 +18,6 @@ module Federation.End2end where import API.MLS.Util -import API.Search.Util import API.User.Util import Bilge import Bilge.Assert ((!!!), (+ +------->+ +--------->+ | -- +------+ +-+-------+ +---------+ +------+ -testHandleLookup :: Brig -> Brig -> Http () -testHandleLookup brig brigTwo = do - -- Create a user on the "other side" using an internal brig endpoint from a - -- second brig instance in backendTwo (in another namespace in kubernetes) - (handle, userBrigTwo) <- createUserWithHandle brigTwo - -- Get result from brig two for comparison - let domain = qDomain $ userQualifiedId userBrigTwo - resultViaBrigTwo <- getUserInfoFromHandle brigTwo domain handle - - -- query the local-namespace brig for a user sitting on the other backend - -- (which will exercise the network traffic via two federators to the remote brig) - resultViaBrigOne <- getUserInfoFromHandle brig domain handle - - liftIO $ assertEqual "remote handle lookup via federator should work in the happy case" (profileQualifiedId resultViaBrigOne) (userQualifiedId userBrigTwo) - liftIO $ assertEqual "querying brig1 or brig2 about the same user should give same result" resultViaBrigTwo resultViaBrigOne - -testSearchUsers :: Brig -> Brig -> Http () -testSearchUsers brig brigTwo = do - -- Create a user on the "other side" using an internal brig endpoint from a - -- second brig instance in backendTwo (in another namespace in kubernetes) - (handle, userBrigTwo) <- createUserWithHandle brigTwo - - searcher <- userId <$> randomUser brig - let expectedUserId = userQualifiedId userBrigTwo - searchTerm = fromHandle handle - domain = qDomain expectedUserId - liftIO $ putStrLn "search for user on brigTwo (directly)..." - assertCanFindWithDomain brigTwo searcher expectedUserId searchTerm domain - - -- exercises multi-backend network traffic - liftIO $ putStrLn "search for user on brigOne via federators to remote brig..." - assertCanFindWithDomain brig searcher expectedUserId searchTerm domain - testGetUsersById :: Brig -> Brig -> Http () testGetUsersById brig1 brig2 = do users <- traverse randomUser [brig1, brig2] @@ -254,63 +214,6 @@ testClaimMultiPrekeyBundleSuccess brig1 brig2 = do const 200 === statusCode const (Just ucm) === responseJsonMaybe -testAddRemoteUsersToLocalConv :: Brig -> Galley -> Brig -> Galley -> Http () -testAddRemoteUsersToLocalConv brig1 galley1 brig2 galley2 = do - alice <- randomUser brig1 - bob <- randomUser brig2 - - let newConv = - NewConv - [] - [] - (checked "gossip") - mempty - Nothing - Nothing - Nothing - Nothing - roleNameWireAdmin - ProtocolProteusTag - convId <- - fmap cnvQualifiedId . responseJsonError - =<< post - ( galley1 - . path "/conversations" - . zUser (userId alice) - . zConn "conn" - . header "Z-Type" "access" - . json newConv - ) - - connectUsersEnd2End brig1 brig2 (userQualifiedId alice) (userQualifiedId bob) - - let invite = InviteQualified (userQualifiedId bob :| []) roleNameWireAdmin - post - ( apiVersion "v1" - . galley1 - . paths ["conversations", (toByteString' . qUnqualified) convId, "members", "v2"] - . zUser (userId alice) - . zConn "conn" - . header "Z-Type" "access" - . json invite - ) - !!! (const 200 === statusCode) - - -- test GET /conversations/:domain/:cnv -- Alice's domain is used here - liftIO $ putStrLn "search for conversation on backend 1..." - res <- getConvQualified galley1 (userId alice) convId Galley -> Brig -> Galley -> Http () testRemoveRemoteUserFromLocalConv brig1 galley1 brig2 galley2 = do alice <- randomUser brig1 @@ -645,8 +548,8 @@ testDeleteUser brig1 brig2 galley1 galley2 cannon1 = do WS.bracketR cannon1 (qUnqualified alice) $ \wsAlice -> do deleteUser (qUnqualified bobDel) (Just defPassword) brig2 !!! const 200 === statusCode WS.assertMatch_ (5 # Second) wsAlice $ matchDeleteUserNotification bobDel - WS.assertMatch_ (5 # Second) wsAlice $ matchConvLeaveNotification conv1 bobDel [bobDel] - WS.assertMatch_ (5 # Second) wsAlice $ matchConvLeaveNotification conv2 bobDel [bobDel] + WS.assertMatch_ (5 # Second) wsAlice $ matchConvLeaveNotification conv1 bobDel [bobDel] EdReasonLeft + WS.assertMatch_ (5 # Second) wsAlice $ matchConvLeaveNotification conv2 bobDel [bobDel] EdReasonLeft testRemoteAsset :: Brig -> Brig -> CargoHold -> CargoHold -> Http () testRemoteAsset brig1 brig2 ch1 ch2 = do @@ -675,7 +578,7 @@ claimRemoteKeyPackages brig1 brig2 = do for_ bobClients $ \c -> uploadKeyPackages brig2 tmp def bob c 5 - bundle <- + bundle :: KeyPackageBundle <- responseJsonError =<< post ( brig1 @@ -685,7 +588,7 @@ claimRemoteKeyPackages brig1 brig2 = do (kpbeUser e, kpbeClient e)) (kpbEntries bundle) + Set.map (\e -> (e.user, e.client)) bundle.entries @?= Set.fromList [(bob, c) | c <- bobClients] testRemoteTypingIndicator :: Brig -> Brig -> Galley -> Galley -> Cannon -> Cannon -> Http () diff --git a/services/brig/test/integration/Federation/Util.hs b/services/brig/test/integration/Federation/Util.hs index 7f3eea40afa..cb0eb3e35bb 100644 --- a/services/brig/test/integration/Federation/Util.hs +++ b/services/brig/test/integration/Federation/Util.hs @@ -33,12 +33,14 @@ import Control.Monad.Trans.Except import Control.Retry import Data.Aeson (FromJSON, Value, decode, (.=)) import Data.Aeson qualified as Aeson +import Data.ByteString qualified as BS import Data.ByteString.Conversion (toByteString') import Data.Domain (Domain (Domain)) import Data.Handle (fromHandle) import Data.Id import Data.Map.Strict qualified as Map import Data.Qualified (Qualified (..)) +import Data.Text qualified as T import Data.Text qualified as Text import Database.Bloodhound qualified as ES import Federator.MockServer qualified as Mock @@ -51,6 +53,7 @@ import Network.Socket import Network.Wai.Handler.Warp (Port) import Network.Wai.Test (Session) import Network.Wai.Test qualified as WaiTest +import System.FilePath import Test.QuickCheck (Arbitrary (arbitrary), generate) import Test.Tasty import Test.Tasty.HUnit @@ -62,6 +65,9 @@ import Wire.API.Connection import Wire.API.Conversation (Conversation (cnvMembers)) import Wire.API.Conversation.Member (OtherMember (OtherMember), cmOthers) import Wire.API.Conversation.Role (roleNameWireAdmin) +import Wire.API.MLS.CommitBundle +import Wire.API.MLS.Message +import Wire.API.MLS.Serialisation import Wire.API.Team.Feature (FeatureStatus (..)) import Wire.API.User import Wire.API.User.Client @@ -110,3 +116,32 @@ connectUsersEnd2End brig1 brig2 quid1 quid2 = do !!! const 201 === statusCode putConnectionQualified brig2 (qUnqualified quid2) quid1 Accepted !!! const 200 === statusCode + +sendCommitBundle :: HasCallStack => FilePath -> FilePath -> Maybe FilePath -> Galley -> UserId -> ClientId -> ByteString -> Http () +sendCommitBundle tmp subGroupStateFn welcomeFn galley uid cid commit = do + subGroupStateRaw <- liftIO $ BS.readFile $ tmp subGroupStateFn + subGroupState <- either (liftIO . assertFailure . T.unpack) pure . decodeMLS' $ subGroupStateRaw + subCommit <- either (liftIO . assertFailure . T.unpack) pure . decodeMLS' $ commit + mbWelcome <- + for + welcomeFn + $ \fn -> do + bs <- liftIO $ BS.readFile $ tmp fn + msg :: Message <- either (liftIO . assertFailure . T.unpack) pure . decodeMLS' $ bs + case msg.content of + MessageWelcome welcome -> pure welcome + _ -> liftIO . assertFailure $ "Expected a welcome" + + let subGroupBundle = CommitBundle subCommit mbWelcome subGroupState + post + ( galley + . paths + ["mls", "commit-bundles"] + . zUser uid + . zClient cid + . zConn "conn" + . header "Z-Type" "access" + . Bilge.content "message/mls" + . lbytes (encodeMLS subGroupBundle) + ) + !!! const 201 === statusCode diff --git a/services/brig/test/integration/Main.hs b/services/brig/test/integration/Run.hs similarity index 93% rename from services/brig/test/integration/Main.hs rename to services/brig/test/integration/Run.hs index aafb086f845..a987096b8c5 100644 --- a/services/brig/test/integration/Main.hs +++ b/services/brig/test/integration/Run.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Main +module Run ( main, ) where @@ -36,7 +36,8 @@ import API.TeamUserSearch qualified as TeamUserSearch import API.User qualified as User import API.UserPendingActivation qualified as UserPendingActivation import API.Version qualified -import Bilge hiding (header) +import Bilge hiding (header, host, port) +import Bilge qualified import Brig.API (sitemap) import Brig.AWS qualified as AWS import Brig.CanonicalInterpreter @@ -63,6 +64,9 @@ import System.Environment (withArgs) import System.Logger qualified as Logger import Test.Tasty import Test.Tasty.HUnit +import Test.Tasty.Ingredients +import Test.Tasty.Runners +import Test.Tasty.Runners.AntXML import Util import Util.Options import Util.Test @@ -70,7 +74,6 @@ import Util.Test.SQS qualified as SQS import Web.HttpApiData import Wire.API.Federation.API import Wire.API.Routes.Version -import Wire.Sem.Paging.Cassandra (InternalPaging) data BackendConf = BackendConf { remoteBrig :: Endpoint, @@ -131,9 +134,9 @@ runTests iConf brigOpts otherArgs = do Opts.TurnSourceFiles files -> files Opts.TurnSourceDNS _ -> error "The integration tests can only be run when TurnServers are sourced from files" localDomain = brigOpts ^. Opts.optionSettings . Opts.federationDomain - casHost = (\v -> Opts.cassandra v ^. casEndpoint . epHost) brigOpts - casPort = (\v -> Opts.cassandra v ^. casEndpoint . epPort) brigOpts - casKey = (\v -> Opts.cassandra v ^. casKeyspace) brigOpts + casHost = (\v -> Opts.cassandra v ^. endpoint . host) brigOpts + casPort = (\v -> Opts.cassandra v ^. endpoint . port) brigOpts + casKey = (\v -> Opts.cassandra v ^. keyspace) brigOpts awsOpts = Opts.aws brigOpts lg <- Logger.new Logger.defSettings -- TODO: use mkLogger'? db <- defInitCassandra casKey casHost casPort lg @@ -144,7 +147,7 @@ runTests iConf brigOpts otherArgs = do awsEnv <- AWS.mkEnv lg awsOpts emailAWSOpts mg mUserJournalWatcher <- for (view AWS.userJournalQueue awsEnv) $ SQS.watchSQSQueue (view AWS.amazonkaEnv awsEnv) userApi <- User.tests brigOpts fedBrigClient fedGalleyClient mg b c ch g n awsEnv db mUserJournalWatcher - providerApi <- Provider.tests localDomain (provider iConf) mg db b c g + providerApi <- Provider.tests localDomain (provider iConf) mg db b c g n searchApis <- Search.tests brigOpts mg g b teamApis <- Team.tests brigOpts mg n b c g mUserJournalWatcher turnApi <- Calling.tests mg b brigOpts turnFile turnFileV2 @@ -164,14 +167,14 @@ runTests iConf brigOpts otherArgs = do mlsApi = MLS.tests mg b brigOpts oauthAPI = API.OAuth.tests mg db b n brigOpts - withArgs otherArgs . defaultMain + withArgs otherArgs . defaultMainWithIngredients (listingTests : (composeReporters antXMLRunner consoleTestReporter) : defaultIngredients) $ testGroup "Brig API Integration" $ [ testCase "sitemap" $ assertEqual "inconcistent sitemap" mempty - (pathsConsistencyCheck . treeToPaths . compile $ Brig.API.sitemap @BrigCanonicalEffects @InternalPaging), + (pathsConsistencyCheck . treeToPaths . compile $ Brig.API.sitemap @BrigCanonicalEffects), userApi, providerApi, searchApis, @@ -193,9 +196,9 @@ runTests iConf brigOpts otherArgs = do federationEnd2End ] where - mkRequest (Endpoint h p) = host (encodeUtf8 h) . port p + mkRequest (Endpoint h p) = Bilge.host (encodeUtf8 h) . Bilge.port p - mkVersionedRequest endpoint = maybeAddPrefix . mkRequest endpoint + mkVersionedRequest ep = maybeAddPrefix . mkRequest ep maybeAddPrefix :: Request -> Request maybeAddPrefix r = case pathSegments $ getUri r of diff --git a/services/brig/test/integration/Util.hs b/services/brig/test/integration/Util.hs index d9a5bf5e976..f907597cccc 100644 --- a/services/brig/test/integration/Util.hs +++ b/services/brig/test/integration/Util.hs @@ -22,7 +22,7 @@ module Util where -import Bilge +import Bilge hiding (host, port) import Bilge.Assert import Brig.AWS.Types import Brig.App (applog, fsWatcher, sftEnv, turnEnv) @@ -101,13 +101,14 @@ import Test.Tasty.Pending (flakyTestCase) import Text.Printf (printf) import UnliftIO.Async qualified as Async import Util.Options +import Web.Internal.HttpApiData import Wire.API.Connection import Wire.API.Conversation -import Wire.API.Conversation.Protocol import Wire.API.Conversation.Role (roleNameWireAdmin) import Wire.API.Federation.API import Wire.API.Federation.Domain import Wire.API.Internal.Notification +import Wire.API.MLS.SubConversation import Wire.API.Routes.MultiTablePaging import Wire.API.Team.Member hiding (userId) import Wire.API.User hiding (AccountStatus (..)) @@ -176,14 +177,14 @@ runFedClient :: FedClient comp -> Domain -> Servant.Client Http api -runFedClient (FedClient mgr endpoint) domain = +runFedClient (FedClient mgr ep) domain = Servant.hoistClient (Proxy @api) (servantClientMToHttp domain) $ Servant.clientIn (Proxy @api) (Proxy @Servant.ClientM) where servantClientMToHttp :: Domain -> Servant.ClientM a -> Http a servantClientMToHttp originDomain action = liftIO $ do - let brigHost = Text.unpack $ endpoint ^. epHost - brigPort = fromInteger . toInteger $ endpoint ^. epPort + let brigHost = Text.unpack $ ep ^. host + brigPort = fromInteger . toInteger $ ep ^. port baseUrl = Servant.BaseUrl Servant.Http brigHost brigPort "/federation" clientEnv = Servant.ClientEnv mgr baseUrl Nothing (makeClientRequest originDomain) eitherRes <- Servant.runClientM action clientEnv @@ -255,7 +256,7 @@ localAndRemoteUserWithConvId brig shouldBeLocal = do quid <- userQualifiedId <$> randomUser brig let go = do other <- Qualified <$> randomId <*> pure (Domain "far-away.example.com") - let convId = one2OneConvId quid other + let convId = one2OneConvId BaseProtocolProteusTag quid other isLocal = qDomain quid == qDomain convId if shouldBeLocal == isLocal then pure (qUnqualified quid, other, convId) @@ -734,7 +735,7 @@ createMLSConversation galley zusr c = do Nothing Nothing roleNameWireAdmin - ProtocolMLSTag + BaseProtocolMLSTag post $ galley . path "/conversations" @@ -743,6 +744,25 @@ createMLSConversation galley zusr c = do . zClient c . json conv +createMLSSubConversation :: + (MonadIO m, MonadHttp m) => + Galley -> + UserId -> + Qualified ConvId -> + SubConvId -> + m ResponseLBS +createMLSSubConversation galley zusr qcnv sconv = + get $ + galley + . paths + [ "conversations", + toByteString' (qDomain qcnv), + toByteString' (qUnqualified qcnv), + "subconversations", + toHeader sconv + ] + . zUser zusr + createConversation :: MonadHttp m => Galley -> UserId -> [Qualified UserId] -> m ResponseLBS createConversation galley zusr usersToAdd = do let conv = @@ -756,7 +776,7 @@ createConversation galley zusr usersToAdd = do Nothing Nothing roleNameWireAdmin - ProtocolProteusTag + BaseProtocolProteusTag post $ galley . path "/conversations" @@ -1333,3 +1353,8 @@ spawn cp minput = do assertJust :: (HasCallStack, MonadIO m) => Maybe a -> m a assertJust (Just a) = pure a assertJust Nothing = liftIO $ error "Expected Just, got Nothing" + +assertElem :: (HasCallStack, Eq a, Show a) => String -> a -> [a] -> Assertion +assertElem msg x xs = + unless (x `elem` xs) $ + assertFailure (msg <> "\nExpected to find: \n" <> show x <> "\nin:\n" <> show xs) diff --git a/services/brig/test/unit.hs b/services/brig/test/unit.hs new file mode 100644 index 00000000000..a26473d24ee --- /dev/null +++ b/services/brig/test/unit.hs @@ -0,0 +1 @@ +import Run diff --git a/services/brig/test/unit/Main.hs b/services/brig/test/unit/Run.hs similarity index 99% rename from services/brig/test/unit/Main.hs rename to services/brig/test/unit/Run.hs index dba791cb86b..64092fef3b5 100644 --- a/services/brig/test/unit/Main.hs +++ b/services/brig/test/unit/Run.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Main +module Run ( main, ) where diff --git a/services/brig/test/unit/Test/Brig/MLS.hs b/services/brig/test/unit/Test/Brig/MLS.hs index d63833f1451..92e2b5eb526 100644 --- a/services/brig/test/unit/Test/Brig/MLS.hs +++ b/services/brig/test/unit/Test/Brig/MLS.hs @@ -19,16 +19,12 @@ module Test.Brig.MLS where import Brig.API.MLS.KeyPackages.Validation import Data.Binary -import Data.Binary.Put -import Data.ByteString.Lazy qualified as LBS import Data.Either import Data.Time.Clock import Imports import Test.Tasty import Test.Tasty.QuickCheck -import Wire.API.MLS.CipherSuite -import Wire.API.MLS.Extension -import Wire.API.MLS.Serialisation +import Wire.API.MLS.Lifetime -- | A lifetime with a length of at least 1 day. newtype ValidLifetime = ValidLifetime Lifetime @@ -57,69 +53,6 @@ midpoint lt = ) ) -newtype ValidExtensions = ValidExtensions [Extension] - -instance Show ValidExtensions where - show (ValidExtensions exts) = "ValidExtensions (length " <> show (length exts) <> ")" - -unknownExt :: Gen Extension -unknownExt = do - Positive t0 <- arbitrary - let t = t0 + fromEnum (maxBound :: ExtensionTag) + 1 - Extension (fromIntegral t) <$> arbitrary - --- | Generate a list of extensions containing all the required ones. -instance Arbitrary ValidExtensions where - arbitrary = do - exts0 <- listOf unknownExt - LifetimeAndExtension ext1 _ <- arbitrary - exts2 <- listOf unknownExt - CapabilitiesAndExtension ext3 _ <- arbitrary - exts4 <- listOf unknownExt - pure . ValidExtensions $ exts0 <> [ext1] <> exts2 <> [ext3] <> exts4 - -newtype InvalidExtensions = InvalidExtensions [Extension] - --- | Generate a list of extensions which does not contain one of the required extensions. -instance Show InvalidExtensions where - show (InvalidExtensions exts) = "InvalidExtensions (length " <> show (length exts) <> ")" - -instance Arbitrary InvalidExtensions where - arbitrary = do - req <- fromMLSEnum <$> elements [LifetimeExtensionTag, CapabilitiesExtensionTag] - InvalidExtensions <$> listOf (arbitrary `suchThat` ((/= req) . extType)) - -data LifetimeAndExtension = LifetimeAndExtension Extension Lifetime - deriving (Show) - -instance Arbitrary LifetimeAndExtension where - arbitrary = do - lt <- arbitrary - let ext = Extension (fromIntegral (fromEnum LifetimeExtensionTag + 1)) . LBS.toStrict . runPut $ do - put (timestampSeconds (ltNotBefore lt)) - put (timestampSeconds (ltNotAfter lt)) - pure $ LifetimeAndExtension ext lt - -data CapabilitiesAndExtension = CapabilitiesAndExtension Extension Capabilities - deriving (Show) - -instance Arbitrary CapabilitiesAndExtension where - arbitrary = do - caps <- arbitrary - let ext = Extension (fromIntegral (fromEnum CapabilitiesExtensionTag + 1)) . LBS.toStrict . runPut $ do - putWord8 (fromIntegral (length (capVersions caps))) - traverse_ (putWord8 . pvNumber) (capVersions caps) - - putWord8 (fromIntegral (length (capCiphersuites caps) * 2)) - traverse_ (put . cipherSuiteNumber) (capCiphersuites caps) - - putWord8 (fromIntegral (length (capExtensions caps) * 2)) - traverse_ put (capExtensions caps) - - putWord8 (fromIntegral (length (capProposals caps) * 2)) - traverse_ put (capProposals caps) - pure $ CapabilitiesAndExtension ext caps - tests :: TestTree tests = testGroup @@ -142,16 +75,5 @@ tests = isRight $ validateLifetime' (midpoint lt) Nothing lt, testProperty "expiration too far" $ \(ValidLifetime lt) -> isLeft $ validateLifetime' (midpoint lt) (Just 10) lt - ], - testGroup - "Extensions" - [ testProperty "required extensions are found" $ \(ValidExtensions exts) -> - isRight (findExtensions exts), - testProperty "missing required extensions" $ \(InvalidExtensions exts) -> - isLeft (findExtensions exts), - testProperty "lifetime extension" $ \(LifetimeAndExtension ext lt) -> - decodeExtension ext == Right (Just (SomeExtension SLifetimeExtensionTag lt)), - testProperty "capabilities extension" $ \(CapabilitiesAndExtension ext caps) -> - decodeExtension ext == Right (Just (SomeExtension SCapabilitiesExtensionTag caps)) ] ] diff --git a/services/cannon/cannon.cabal b/services/cannon/cannon.cabal index 89f6159ffba..27429695a74 100644 --- a/services/cannon/cannon.cabal +++ b/services/cannon/cannon.cabal @@ -249,7 +249,6 @@ test-suite cannon-tests , tasty >=0.8 , tasty-hunit >=0.8 , tasty-quickcheck >=0.8 - , types-common , uuid , wire-api diff --git a/services/cannon/default.nix b/services/cannon/default.nix index 1032b92eb15..b1ff1ab1d2c 100644 --- a/services/cannon/default.nix +++ b/services/cannon/default.nix @@ -109,7 +109,6 @@ mkDerivation { tasty tasty-hunit tasty-quickcheck - types-common uuid wire-api ]; diff --git a/services/cannon/src/Cannon/API/Public.hs b/services/cannon/src/Cannon/API/Public.hs index 0eb81bf5fb1..4a559f9f17c 100644 --- a/services/cannon/src/Cannon/API/Public.hs +++ b/services/cannon/src/Cannon/API/Public.hs @@ -31,7 +31,7 @@ import Servant import Wire.API.Routes.Named import Wire.API.Routes.Public.Cannon -publicAPIServer :: ServerT PublicAPI Cannon +publicAPIServer :: ServerT CannonAPI Cannon publicAPIServer = Named @"await-notifications" streamData streamData :: UserId -> ConnId -> Maybe ClientId -> PendingConnection -> Cannon () diff --git a/services/cannon/src/Cannon/Run.hs b/services/cannon/src/Cannon/Run.hs index 49b3d4edb69..b0657c59f8f 100644 --- a/services/cannon/src/Cannon/Run.hs +++ b/services/cannon/src/Cannon/Run.hs @@ -15,8 +15,6 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -{-# OPTIONS -Wwarn #-} - module Cannon.Run ( run, CombinedAPI, @@ -60,7 +58,7 @@ import Wire.API.Routes.Internal.Cannon qualified as Internal import Wire.API.Routes.Public.Cannon import Wire.API.Routes.Version.Wai -type CombinedAPI = PublicAPI :<|> Internal.API +type CombinedAPI = CannonAPI :<|> Internal.API run :: Opts -> IO () run o = do @@ -90,7 +88,7 @@ run o = do app = middleware (serve (Proxy @CombinedAPI) server) server :: Servant.Server CombinedAPI server = - hoistServer (Proxy @PublicAPI) (runCannonToServant e) publicAPIServer + hoistServer (Proxy @CannonAPI) (runCannonToServant e) publicAPIServer :<|> hoistServer (Proxy @Internal.API) (runCannonToServant e) internalServer tid <- myThreadId E.handle uncaughtExceptionHandler $ do diff --git a/services/cannon/test/Test/Cannon/Dict.hs b/services/cannon/test/Test/Cannon/Dict.hs index e3fe085c66b..114edea4a9b 100644 --- a/services/cannon/test/Test/Cannon/Dict.hs +++ b/services/cannon/test/Test/Cannon/Dict.hs @@ -24,7 +24,6 @@ import Cannon.Dict qualified as D import Cannon.WS (Key, mkKey) import Control.Concurrent.Async import Data.ByteString.Lazy qualified as Lazy -import Data.Id import Data.List qualified as List import Data.UUID hiding (fromString) import Data.UUID.V4 @@ -122,6 +121,3 @@ runProp = monadicIO . forAllM arbitrary instance Arbitrary Key where arbitrary = mkKey <$> arbitrary <*> arbitrary - -instance Arbitrary ConnId where - arbitrary = ConnId <$> arbitrary diff --git a/services/cargohold/cargohold.cabal b/services/cargohold/cargohold.cabal index 725699a95c9..cbe5965a748 100644 --- a/services/cargohold/cargohold.cabal +++ b/services/cargohold/cargohold.cabal @@ -289,6 +289,7 @@ executable cargohold-integration , servant-client , tagged >=0.8 , tasty >=1.0 + , tasty-ant-xml , tasty-hunit >=0.9 , text >=1.1 , time >=1.5 diff --git a/services/cargohold/default.nix b/services/cargohold/default.nix index 38aed9f0757..144a55f1943 100644 --- a/services/cargohold/default.nix +++ b/services/cargohold/default.nix @@ -50,6 +50,7 @@ , servant-server , tagged , tasty +, tasty-ant-xml , tasty-hunit , text , time @@ -154,6 +155,7 @@ mkDerivation { servant-client tagged tasty + tasty-ant-xml tasty-hunit text time diff --git a/services/cargohold/src/CargoHold/API/Federation.hs b/services/cargohold/src/CargoHold/API/Federation.hs index 934aedca3f4..f5cbf4b3f62 100644 --- a/services/cargohold/src/CargoHold/API/Federation.hs +++ b/services/cargohold/src/CargoHold/API/Federation.hs @@ -45,13 +45,13 @@ federationSitemap = checkAsset :: F.GetAsset -> Handler Bool checkAsset ga = fmap isJust . runMaybeT $ - checkMetadata Nothing (F.gaKey ga) (F.gaToken ga) + checkMetadata Nothing (F.key ga) (F.token ga) streamAsset :: Domain -> F.GetAsset -> Handler AssetSource streamAsset _ ga = do available <- checkAsset ga unless available (throwE assetNotFound) - AssetSource <$> S3.downloadV3 (F.gaKey ga) + AssetSource <$> S3.downloadV3 (F.key ga) getAsset :: Domain -> F.GetAsset -> Handler F.GetAssetResponse getAsset _ = fmap F.GetAssetResponse . checkAsset diff --git a/services/cargohold/src/CargoHold/API/Public.hs b/services/cargohold/src/CargoHold/API/Public.hs index 280e00b02b8..a896025bdc6 100644 --- a/services/cargohold/src/CargoHold/API/Public.hs +++ b/services/cargohold/src/CargoHold/API/Public.hs @@ -41,7 +41,7 @@ import Wire.API.Routes.AssetBody import Wire.API.Routes.Internal.Cargohold import Wire.API.Routes.Public.Cargohold -servantSitemap :: ServerT ServantAPI Handler +servantSitemap :: ServerT CargoholdAPI Handler servantSitemap = renewTokenV3 :<|> deleteTokenV3 diff --git a/services/cargohold/src/CargoHold/API/V3.hs b/services/cargohold/src/CargoHold/API/V3.hs index 8491f39db24..d96d772d5ce 100644 --- a/services/cargohold/src/CargoHold/API/V3.hs +++ b/services/cargohold/src/CargoHold/API/V3.hs @@ -69,8 +69,8 @@ upload own bdy = do let cl = fromIntegral $ hdrLength hdrs when (cl <= 0) $ throwE invalidLength - maxTotalBytes <- view (settings . setMaxTotalBytes) - when (cl > maxTotalBytes) $ + maxBytes <- view (CargoHold.App.settings . maxTotalBytes) + when (cl > maxBytes) $ throwE assetTooLarge ast <- liftIO $ Id <$> nextRandom tok <- if sets ^. V3.setAssetPublic then pure Nothing else Just <$> randToken @@ -172,7 +172,7 @@ metadataHeaders = cl <- contentLength hdrs pure (ct, cl) -assetHeaders :: Parser AssetHeaders +assetHeaders :: Parser CargoHold.Types.V3.AssetHeaders assetHeaders = eol *> boundary @@ -180,7 +180,7 @@ assetHeaders = <* eol where go hdrs = - AssetHeaders + CargoHold.Types.V3.AssetHeaders <$> contentType hdrs <*> contentLength hdrs diff --git a/services/cargohold/src/CargoHold/AWS.hs b/services/cargohold/src/CargoHold/AWS.hs index 39301e7fee1..4593fa6d7d3 100644 --- a/services/cargohold/src/CargoHold/AWS.hs +++ b/services/cargohold/src/CargoHold/AWS.hs @@ -44,7 +44,7 @@ import Amazonka (AWSRequest, AWSResponse) import qualified Amazonka as AWS import qualified Amazonka.S3 as S3 import CargoHold.CloudFront -import CargoHold.Options +import CargoHold.Options hiding (cloudFront, s3Bucket) import Conduit import Control.Lens hiding ((.=)) import Control.Monad.Catch @@ -116,7 +116,7 @@ mkEnv lgr s3End s3AddrStyle s3Download bucket cfOpts mgr = do cf <- mkCfEnv cfOpts pure (Env g bucket e s3Download cf) where - mkCfEnv (Just o) = Just <$> initCloudFront (o ^. cfPrivateKey) (o ^. cfKeyPairId) 300 (o ^. cfDomain) + mkCfEnv (Just o) = Just <$> initCloudFront (o ^. privateKey) (o ^. keyPairId) 300 (o ^. domain) mkCfEnv Nothing = pure Nothing mkAwsEnv g s3 = do baseEnv <- @@ -172,7 +172,7 @@ exec :: (Text -> r) -> m (AWSResponse r) exec env request = do - let req = request (_s3Bucket env) + let req = request env._s3Bucket resp <- execute env (sendCatch (env ^. amazonkaEnv) req) case resp of Left err -> do @@ -191,7 +191,7 @@ execStream :: (Text -> r) -> ResourceT IO (AWSResponse r) execStream env request = do - let req = request (_s3Bucket env) + let req = request env._s3Bucket resp <- sendCatch (env ^. amazonkaEnv) req case resp of Left err -> do @@ -210,7 +210,7 @@ execCatch :: (Text -> r) -> m (Maybe (AWSResponse r)) execCatch env request = do - let req = request (_s3Bucket env) + let req = request env._s3Bucket resp <- execute env (retrying retry5x (const canRetry) (const (sendCatch (env ^. amazonkaEnv) req))) case resp of Left err -> do diff --git a/services/cargohold/src/CargoHold/App.hs b/services/cargohold/src/CargoHold/App.hs index de2a6777a08..b8d4b9c6e8d 100644 --- a/services/cargohold/src/CargoHold/App.hs +++ b/services/cargohold/src/CargoHold/App.hs @@ -53,7 +53,8 @@ import Bilge (Manager, MonadHttp, RequestId (..), newManager, withResponse) import qualified Bilge import Bilge.RPC (HasRequestId (..)) import qualified CargoHold.AWS as AWS -import CargoHold.Options as Opt +import CargoHold.Options (AWSOpts, Opts, S3Compatibility (..)) +import qualified CargoHold.Options as Opt import Control.Error (ExceptT, exceptT) import Control.Exception (throw) import Control.Lens (Lens', makeLenses, non, view, (?~), (^.)) @@ -93,18 +94,18 @@ data Env = Env makeLenses ''Env settings :: Lens' Env Opt.Settings -settings = options . optSettings +settings = options . Opt.settings newEnv :: Opts -> IO Env newEnv o = do met <- Metrics.metrics - lgr <- Log.mkLogger (o ^. optLogLevel) (o ^. optLogNetStrings) (o ^. optLogFormat) + lgr <- Log.mkLogger (o ^. Opt.logLevel) (o ^. Opt.logNetStrings) (o ^. Opt.logFormat) checkOpts o lgr - mgr <- initHttpManager (o ^. optAws . awsS3Compatibility) + mgr <- initHttpManager (o ^. Opt.aws . Opt.s3Compatibility) h2mgr <- initHttp2Manager - ama <- initAws (o ^. optAws) lgr mgr + ama <- initAws (o ^. Opt.aws) lgr mgr multiIngressAWS <- initMultiIngressAWS lgr mgr - let loc = toLocalUnsafe (o ^. optSettings . Opt.setFederationDomain) () + let loc = toLocalUnsafe (o ^. Opt.settings . Opt.federationDomain) () pure $ Env ama met lgr mgr h2mgr def o loc multiIngressAWS where initMultiIngressAWS :: Logger -> Manager -> IO (Map String AWS.Env) @@ -114,10 +115,10 @@ newEnv o = do ( \(k, v) -> initAws (patchS3DownloadEndpoint v) lgr mgr >>= \v' -> pure (k, v') ) - (Map.assocs (o ^. optAws . Opt.optMultiIngress . non Map.empty)) + (Map.assocs (o ^. Opt.aws . Opt.multiIngress . non Map.empty)) patchS3DownloadEndpoint :: AWSEndpoint -> AWSOpts - patchS3DownloadEndpoint endpoint = (o ^. optAws) & awsS3DownloadEndpoint ?~ endpoint + patchS3DownloadEndpoint e = (o ^. Opt.aws) & Opt.s3DownloadEndpoint ?~ e -- | Validate (some) options (`Opts`) -- @@ -134,19 +135,19 @@ checkOpts opts lgr = do error errorMsg where multiIngressConfigured :: Bool - multiIngressConfigured = (not . null) (opts ^. (optAws . Opt.optMultiIngress . non Map.empty)) + multiIngressConfigured = (not . null) (opts ^. (Opt.aws . Opt.multiIngress . non Map.empty)) cloudFrontConfigured :: Bool - cloudFrontConfigured = isJust (opts ^. (optAws . Opt.awsCloudFront)) + cloudFrontConfigured = isJust (opts ^. (Opt.aws . Opt.cloudFront)) singleAwsDownloadEndpointConfigured :: Bool - singleAwsDownloadEndpointConfigured = isJust (opts ^. (optAws . Opt.awsS3DownloadEndpoint)) + singleAwsDownloadEndpointConfigured = isJust (opts ^. (Opt.aws . Opt.s3DownloadEndpoint)) initAws :: AWSOpts -> Logger -> Manager -> IO AWS.Env -initAws o l = AWS.mkEnv l (o ^. awsS3Endpoint) addrStyle downloadEndpoint (o ^. awsS3Bucket) (o ^. awsCloudFront) +initAws o l = AWS.mkEnv l (o ^. Opt.s3Endpoint) addrStyle downloadEndpoint (o ^. Opt.s3Bucket) (o ^. Opt.cloudFront) where - downloadEndpoint = fromMaybe (o ^. awsS3Endpoint) (o ^. awsS3DownloadEndpoint) - addrStyle = maybe S3AddressingStylePath unwrapS3AddressingStyle (o ^. awsS3AddressingStyle) + downloadEndpoint = fromMaybe (o ^. Opt.s3Endpoint) (o ^. Opt.s3DownloadEndpoint) + addrStyle = maybe S3AddressingStylePath Opt.unwrapS3AddressingStyle (o ^. Opt.s3AddressingStyle) initHttpManager :: Maybe S3Compatibility -> IO Manager initHttpManager s3Compat = diff --git a/services/cargohold/src/CargoHold/Federation.hs b/services/cargohold/src/CargoHold/Federation.hs index 5517e0dce0f..d1d57d0a5bf 100644 --- a/services/cargohold/src/CargoHold/Federation.hs +++ b/services/cargohold/src/CargoHold/Federation.hs @@ -56,12 +56,12 @@ downloadRemoteAsset :: downloadRemoteAsset usr rkey tok = do let ga = GetAsset - { gaKey = tUnqualified rkey, - gaUser = tUnqualified usr, - gaToken = tok + { key = tUnqualified rkey, + user = tUnqualified usr, + token = tok } exists <- - fmap gaAvailable . executeFederated rkey $ + fmap available . executeFederated rkey $ fedClient @'Cargohold @"get-asset" ga if exists then @@ -77,7 +77,7 @@ mkFederatorClientEnv :: Remote x -> Handler FederatorClientEnv mkFederatorClientEnv remote = do loc <- view localUnit endpoint <- - view (options . optFederator) + view (options . federator) >>= maybe (throwE federationNotConfigured) pure mgr <- view http2Manager pure diff --git a/services/cargohold/src/CargoHold/Options.hs b/services/cargohold/src/CargoHold/Options.hs index 9741c3b9e85..aa515729a1f 100644 --- a/services/cargohold/src/CargoHold/Options.hs +++ b/services/cargohold/src/CargoHold/Options.hs @@ -35,11 +35,11 @@ import Wire.API.Routes.Version -- | AWS CloudFront settings. data CloudFrontOpts = CloudFrontOpts { -- | Domain - _cfDomain :: CF.Domain, + _domain :: CF.Domain, -- | Keypair ID - _cfKeyPairId :: CF.KeyPairId, + _keyPairId :: CF.KeyPairId, -- | Path to private key - _cfPrivateKey :: FilePath + _privateKey :: FilePath } deriving (Show, Generic) @@ -62,7 +62,7 @@ instance FromJSON OptS3AddressingStyle where other -> fail $ "invalid S3AddressingStyle: " <> show other data AWSOpts = AWSOpts - { _awsS3Endpoint :: !AWSEndpoint, + { _s3Endpoint :: !AWSEndpoint, -- | S3 can either by addressed in path style, i.e. -- https:////, or vhost style, i.e. -- https://./. AWS's S3 offering has @@ -88,26 +88,26 @@ data AWSOpts = AWSOpts -- -- When this option is unspecified, we default to path style addressing to -- ensure smooth transition for older deployments. - _awsS3AddressingStyle :: !(Maybe OptS3AddressingStyle), + _s3AddressingStyle :: !(Maybe OptS3AddressingStyle), -- | S3 endpoint for generating download links. Useful if Cargohold is configured to use -- an S3 replacement running inside the internal network (in which case internally we -- would use one hostname for S3, and when generating an asset link for a client app, we -- would use another hostname). - _awsS3DownloadEndpoint :: !(Maybe AWSEndpoint), + _s3DownloadEndpoint :: !(Maybe AWSEndpoint), -- | S3 bucket name - _awsS3Bucket :: !Text, + _s3Bucket :: !Text, -- | Enable this option for compatibility with specific S3 backends. - _awsS3Compatibility :: !(Maybe S3Compatibility), + _s3Compatibility :: !(Maybe S3Compatibility), -- | AWS CloudFront options - _awsCloudFront :: !(Maybe CloudFrontOpts), + _cloudFront :: !(Maybe CloudFrontOpts), -- | @Z-Host@ header to s3 download endpoint `Map` -- -- This logic is: If the @Z-Host@ header is provided and found in this map, -- the map's values is taken as s3 download endpoint to redirect to; - -- otherwise, `_awsS3DownloadEndpoint` is used. This option is only useful + -- otherwise a 404 is retuned. This option is only useful -- in the context of multi-ingress setups where one backend / deployment is -- reachable under several domains. - _optMultiIngress :: !(Maybe (Map String AWSEndpoint)) + _multiIngress :: !(Maybe (Map String AWSEndpoint)) } deriving (Show, Generic) @@ -128,9 +128,9 @@ makeLenses ''AWSOpts data Settings = Settings { -- | Maximum allowed size for uploads, in bytes - _setMaxTotalBytes :: !Int, + _maxTotalBytes :: !Int, -- | TTL for download links, in seconds - _setDownloadLinkTTL :: !Word, + _downloadLinkTTL :: !Word, -- | FederationDomain is required, even when not wanting to federate with other backends -- (in that case the 'allowedDomains' can be set to empty in Federator) -- Federation domain is used to qualify local IDs and handles, @@ -141,8 +141,8 @@ data Settings = Settings -- Remember to keep it the same in all services. -- This is referred to as the 'backend domain' in the public documentation; See -- https://docs.wire.com/how-to/install/configure-federation.html#choose-a-backend-domain-name - _setFederationDomain :: !Domain, - _setDisabledAPIVersions :: !(Maybe (Set Version)) + _federationDomain :: !Domain, + _disabledAPIVersions :: !(Maybe (Set Version)) } deriving (Show, Generic) @@ -154,19 +154,19 @@ makeLenses ''Settings -- modify the behavior. data Opts = Opts { -- | Hostname and port to bind to - _optCargohold :: !Endpoint, - _optAws :: !AWSOpts, - _optSettings :: !Settings, + _cargohold :: !Endpoint, + _aws :: !AWSOpts, + _settings :: !Settings, -- | Federator endpoint - _optFederator :: Maybe Endpoint, + _federator :: Maybe Endpoint, -- Logging -- | Log level (Debug, Info, etc) - _optLogLevel :: !Level, + _logLevel :: !Level, -- | Use netstrings encoding: -- - _optLogNetStrings :: !(Maybe (Last Bool)), - _optLogFormat :: !(Maybe (Last LogFormat)) --- ^ Log format + _logNetStrings :: !(Maybe (Last Bool)), + _logFormat :: !(Maybe (Last LogFormat)) --- ^ Log format } deriving (Show, Generic) diff --git a/services/cargohold/src/CargoHold/Run.hs b/services/cargohold/src/CargoHold/Run.hs index ae393ced1ca..ae43e1e96ae 100644 --- a/services/cargohold/src/CargoHold/Run.hs +++ b/services/cargohold/src/CargoHold/Run.hs @@ -27,8 +27,8 @@ import qualified Amazonka as AWS import CargoHold.API.Federation import CargoHold.API.Public import CargoHold.AWS (amazonkaEnv) -import CargoHold.App -import CargoHold.Options +import CargoHold.App hiding (settings) +import CargoHold.Options hiding (aws) import Control.Exception (bracket) import Control.Lens (set, (^.)) import Control.Monad.Codensity @@ -55,7 +55,7 @@ import Wire.API.Routes.Internal.Cargohold import Wire.API.Routes.Public.Cargohold import Wire.API.Routes.Version.Wai -type CombinedAPI = FederationAPI :<|> ServantAPI :<|> InternalAPI +type CombinedAPI = FederationAPI :<|> CargoholdAPI :<|> InternalAPI run :: Opts -> IO () run o = lowerCodensity $ do @@ -65,8 +65,8 @@ run o = lowerCodensity $ do s <- Server.newSettings $ defaultServer - (unpack $ o ^. optCargohold . epHost) - (o ^. optCargohold . epPort) + (unpack $ o ^. cargohold . host) + (o ^. cargohold . port) (e ^. appLogger) (e ^. metrics) runSettingsWithShutdown s app Nothing @@ -78,7 +78,7 @@ mkApp o = Codensity $ \k -> where middleware :: Env -> Wai.Middleware middleware e = - versionMiddleware (fold (o ^. optSettings . setDisabledAPIVersions)) + versionMiddleware (fold (o ^. settings . disabledAPIVersions)) . servantPrometheusMiddleware (Proxy @CombinedAPI) . GZip.gzip GZip.def . catchErrors (e ^. appLogger) [Right $ e ^. metrics] @@ -87,9 +87,9 @@ mkApp o = Codensity $ \k -> let e = set requestId (maybe def RequestId (lookupRequestId r)) e0 in Servant.serveWithContext (Proxy @CombinedAPI) - ((o ^. optSettings . setFederationDomain) :. Servant.EmptyContext) + ((o ^. settings . federationDomain) :. Servant.EmptyContext) ( hoistServerWithDomain @FederationAPI (toServantHandler e) federationSitemap - :<|> hoistServerWithDomain @ServantAPI (toServantHandler e) servantSitemap + :<|> hoistServerWithDomain @CargoholdAPI (toServantHandler e) servantSitemap :<|> hoistServerWithDomain @InternalAPI (toServantHandler e) internalSitemap ) r diff --git a/services/cargohold/src/CargoHold/S3.hs b/services/cargohold/src/CargoHold/S3.hs index 44ccb280ed0..72695f0f477 100644 --- a/services/cargohold/src/CargoHold/S3.hs +++ b/services/cargohold/src/CargoHold/S3.hs @@ -43,7 +43,7 @@ import CargoHold.API.Error import CargoHold.AWS (amazonkaEnvWithDownloadEndpoint) import qualified CargoHold.AWS as AWS import CargoHold.App hiding (Env, Handler) -import CargoHold.Options +import CargoHold.Options (downloadLinkTTL) import qualified CargoHold.Types.V3 as V3 import qualified Codec.MIME.Parse as MIME import qualified Codec.MIME.Type as MIME @@ -218,7 +218,7 @@ signedURL path mbHost = do e <- awsEnvForHost let b = view AWS.s3Bucket e now <- liftIO getCurrentTime - ttl <- view (settings . setDownloadLinkTTL) + ttl <- view (settings . downloadLinkTTL) let req = newGetObject (BucketName b) (ObjectKey . Text.decodeLatin1 $ toByteString' path) signed <- presignURL (amazonkaEnvWithDownloadEndpoint e) now (Seconds (fromIntegral ttl)) req diff --git a/services/cargohold/test/integration/API.hs b/services/cargohold/test/integration/API.hs index 88f733e763e..781cbe125e1 100644 --- a/services/cargohold/test/integration/API.hs +++ b/services/cargohold/test/integration/API.hs @@ -23,7 +23,7 @@ import API.Util import Bilge hiding (body) import Bilge.Assert import CargoHold.API.Error -import CargoHold.Options (awsS3DownloadEndpoint, optAws) +import CargoHold.Options (aws, s3DownloadEndpoint) import CargoHold.Types import qualified CargoHold.Types.V3 as V3 import qualified Codec.MIME.Type as MIME @@ -228,7 +228,7 @@ testDownloadURLOverride = do -- This is a .example domain, it shouldn't resolve. But it is also not -- supposed to be used by cargohold to make connections. let downloadEndpoint = "external-s3-url.example" - withSettingsOverrides (optAws . awsS3DownloadEndpoint ?~ AWSEndpoint downloadEndpoint True 443) $ do + withSettingsOverrides (aws . s3DownloadEndpoint ?~ AWSEndpoint downloadEndpoint True 443) $ do uid <- liftIO $ Id <$> nextRandom -- Upload, should work, shouldn't try to use the S3DownloadEndpoint diff --git a/services/cargohold/test/integration/API/Federation.hs b/services/cargohold/test/integration/API/Federation.hs index 6e25283ea02..29c2d63992b 100644 --- a/services/cargohold/test/integration/API/Federation.hs +++ b/services/cargohold/test/integration/API/Federation.hs @@ -77,13 +77,13 @@ testGetAssetAvailable isPublicAsset = do let key = view assetKey ast let ga = GetAsset - { gaUser = uid, - gaToken = tok, - gaKey = qUnqualified key + { user = uid, + token = tok, + key = qUnqualified key } ok <- withFederationClient $ - gaAvailable <$> runFederationClient (unsafeFedClientIn @'Cargohold @"get-asset" ga) + available <$> runFederationClient (unsafeFedClientIn @'Cargohold @"get-asset" ga) -- check that asset is available liftIO $ ok @?= True @@ -97,13 +97,13 @@ testGetAssetNotAvailable = do let key = AssetKeyV3 assetId AssetPersistent let ga = GetAsset - { gaUser = uid, - gaToken = Just token, - gaKey = key + { user = uid, + token = Just token, + key = key } ok <- withFederationClient $ - gaAvailable <$> runFederationClient (unsafeFedClientIn @'Cargohold @"get-asset" ga) + available <$> runFederationClient (unsafeFedClientIn @'Cargohold @"get-asset" ga) -- check that asset is not available liftIO $ ok @?= False @@ -124,13 +124,13 @@ testGetAssetWrongToken = do let key = view assetKey ast let ga = GetAsset - { gaUser = uid, - gaToken = Just tok, - gaKey = qUnqualified key + { user = uid, + token = Just tok, + key = qUnqualified key } ok <- withFederationClient $ - gaAvailable <$> runFederationClient (unsafeFedClientIn @'Cargohold @"get-asset" ga) + available <$> runFederationClient (unsafeFedClientIn @'Cargohold @"get-asset" ga) -- check that asset is not available liftIO $ ok @?= False @@ -156,9 +156,9 @@ testLargeAsset = do let key = view assetKey ast let ga = GetAsset - { gaUser = uid, - gaToken = tok, - gaKey = qUnqualified key + { user = uid, + token = tok, + key = qUnqualified key } chunks <- withFederationClient $ do source <- getAssetSource <$> runFederationClient (unsafeFedClientIn @'Cargohold @"stream-asset" ga) @@ -188,9 +188,9 @@ testStreamAsset = do let key = view assetKey ast let ga = GetAsset - { gaUser = uid, - gaToken = tok, - gaKey = qUnqualified key + { user = uid, + token = tok, + key = qUnqualified key } respBody <- withFederationClient $ do source <- getAssetSource <$> runFederationClient (unsafeFedClientIn @'Cargohold @"stream-asset" ga) @@ -206,9 +206,9 @@ testStreamAssetNotAvailable = do let key = AssetKeyV3 assetId AssetPersistent let ga = GetAsset - { gaUser = uid, - gaToken = Just token, - gaKey = key + { user = uid, + token = Just token, + key = key } err <- withFederationError $ do runFederationClient (unsafeFedClientIn @'Cargohold @"stream-asset" ga) @@ -232,9 +232,9 @@ testStreamAssetWrongToken = do let key = view assetKey ast let ga = GetAsset - { gaUser = uid, - gaToken = Just tok, - gaKey = qUnqualified key + { user = uid, + token = Just tok, + key = qUnqualified key } err <- withFederationError $ do runFederationClient (unsafeFedClientIn @'Cargohold @"stream-asset" ga) diff --git a/services/cargohold/test/integration/API/Util.hs b/services/cargohold/test/integration/API/Util.hs index 3c1994a8af8..4f0b2c1c746 100644 --- a/services/cargohold/test/integration/API/Util.hs +++ b/services/cargohold/test/integration/API/Util.hs @@ -17,7 +17,7 @@ module API.Util where -import Bilge hiding (body) +import Bilge hiding (body, host, port) import CargoHold.Options import CargoHold.Run import qualified Codec.MIME.Parse as MIME @@ -64,11 +64,11 @@ uploadRaw :: Lazy.ByteString -> TestM (Response (Maybe Lazy.ByteString)) uploadRaw c usr bs = do - cargohold <- viewUnversionedCargohold + cargohold' <- viewUnversionedCargohold post $ apiVersion "v1" . c - . cargohold + . cargohold' . method POST . zUser usr . zConn "conn" @@ -177,7 +177,7 @@ deleteToken uid key = do . paths ["assets", toByteString' key, "token"] viewFederationDomain :: TestM Domain -viewFederationDomain = view (tsOpts . optSettings . setFederationDomain) +viewFederationDomain = view (tsOpts . settings . federationDomain) -------------------------------------------------------------------------------- -- Mocking utilities @@ -192,7 +192,7 @@ withSettingsOverrides f action = do liftIO $ runTestM (ts & tsEndpoint %~ setLocalEndpoint p) action setLocalEndpoint :: Word16 -> Endpoint -> Endpoint -setLocalEndpoint p = (epPort .~ p) . (epHost .~ "127.0.0.1") +setLocalEndpoint p = (port .~ p) . (host .~ "127.0.0.1") withMockFederator :: (FederatedRequest -> IO (HTTP.MediaType, LByteString)) -> @@ -201,5 +201,5 @@ withMockFederator :: withMockFederator respond action = do withTempMockFederator [] respond $ \p -> withSettingsOverrides - (optFederator . _Just %~ setLocalEndpoint (fromIntegral p)) + (federator . _Just %~ setLocalEndpoint (fromIntegral p)) action diff --git a/services/cargohold/test/integration/App.hs b/services/cargohold/test/integration/App.hs index f277a71d3c9..016de02496b 100644 --- a/services/cargohold/test/integration/App.hs +++ b/services/cargohold/test/integration/App.hs @@ -29,8 +29,8 @@ testMultiIngressCloudFrontFails = do ts <- ask let opts = view tsOpts ts - & (Opts.optAws . Opts.awsCloudFront) ?~ cloudFrontOptions - & (Opts.optAws . Opts.optMultiIngress) ?~ multiIngressMap + & (Opts.aws . Opts.cloudFront) ?~ cloudFrontOptions + & (Opts.aws . Opts.multiIngress) ?~ multiIngressMap msg <- liftIO $ catch @@ -44,9 +44,9 @@ testMultiIngressCloudFrontFails = do cloudFrontOptions :: CloudFrontOpts cloudFrontOptions = CloudFrontOpts - { _cfDomain = Domain (T.pack "example.com"), - _cfKeyPairId = KeyPairId (T.pack "anyId"), - _cfPrivateKey = "any/path" + { _domain = Domain (T.pack "example.com"), + _keyPairId = KeyPairId (T.pack "anyId"), + _privateKey = "any/path" } multiIngressMap :: Map String AWSEndpoint @@ -63,8 +63,8 @@ testMultiIngressS3DownloadEndpointFails = do ts <- ask let opts = view tsOpts ts - & (Opts.optAws . Opts.awsS3DownloadEndpoint) ?~ toAWSEndpoint "http://fake-s3:4570" - & (Opts.optAws . Opts.optMultiIngress) ?~ multiIngressMap + & (Opts.aws . Opts.s3DownloadEndpoint) ?~ toAWSEndpoint "http://fake-s3:4570" + & (Opts.aws . Opts.multiIngress) ?~ multiIngressMap msg <- liftIO $ catch diff --git a/services/cargohold/test/integration/Main.hs b/services/cargohold/test/integration/Main.hs index 86da6b26449..4615fa52bfd 100644 --- a/services/cargohold/test/integration/Main.hs +++ b/services/cargohold/test/integration/Main.hs @@ -30,7 +30,10 @@ import Imports hiding (local) import qualified Metrics import Options.Applicative import Test.Tasty +import Test.Tasty.Ingredients import Test.Tasty.Options +import Test.Tasty.Runners +import Test.Tasty.Runners.AntXML import TestSetup import Util.Test @@ -75,4 +78,6 @@ main = do [ Option (Proxy :: Proxy ServiceConfigFile), Option (Proxy :: Proxy IntegrationConfigFile) ] + : listingTests + : composeReporters antXMLRunner consoleTestReporter : defaultIngredients diff --git a/services/cargohold/test/integration/TestSetup.hs b/services/cargohold/test/integration/TestSetup.hs index 759c760a698..03cc3ccd330 100644 --- a/services/cargohold/test/integration/TestSetup.hs +++ b/services/cargohold/test/integration/TestSetup.hs @@ -38,7 +38,7 @@ module TestSetup where import Bilge hiding (body, responseBody) -import CargoHold.Options +import CargoHold.Options hiding (domain) import Control.Exception (catch) import Control.Lens import Control.Monad.Codensity @@ -58,7 +58,7 @@ import qualified Network.Wai.Utilities.Error as Wai import Servant.Client.Streaming import Test.Tasty import Test.Tasty.HUnit -import Util.Options +import Util.Options (Endpoint (..)) import Util.Options.Common import Util.Test import Web.HttpApiData @@ -151,10 +151,10 @@ createTestSetup optsPath configPath = do tlsManagerSettings { managerResponseTimeout = responseTimeoutMicro 300000000 } - let localEndpoint p = Endpoint {_epHost = "127.0.0.1", _epPort = p} + let localEndpoint p = Endpoint {_host = "127.0.0.1", _port = p} iConf <- handleParseError =<< decodeFileEither configPath opts <- decodeFileThrow optsPath - endpoint <- optOrEnv cargohold iConf (localEndpoint . read) "CARGOHOLD_WEB_PORT" + endpoint <- optOrEnv @IntegrationConfig (.cargohold) iConf (localEndpoint . read) "CARGOHOLD_WEB_PORT" pure $ TestSetup { _tsManager = m, @@ -166,7 +166,7 @@ runFederationClient :: ClientM a -> ReaderT TestSetup (ExceptT ClientError (Code runFederationClient action = do man <- view tsManager Endpoint cHost cPort <- view tsEndpoint - domain <- view (tsOpts . optSettings . setFederationDomain) + domain <- view (tsOpts . settings . federationDomain) let base = BaseUrl Http (T.unpack cHost) (fromIntegral cPort) "/federation" let env = (mkClientEnv man base) diff --git a/services/federator/default.nix b/services/federator/default.nix index 5ed00b42be0..44acd863cc6 100644 --- a/services/federator/default.nix +++ b/services/federator/default.nix @@ -23,6 +23,8 @@ , hinotify , HsOpenSSL , hspec +, hspec-core +, hspec-junit-formatter , http-client , http-client-tls , http-media @@ -41,6 +43,7 @@ , pem , polysemy , polysemy-wire-zoo +, prometheus-client , QuickCheck , random , servant @@ -105,7 +108,9 @@ mkDerivation { pem polysemy polysemy-wire-zoo + prometheus-client servant + servant-client servant-client-core servant-server text @@ -123,6 +128,7 @@ mkDerivation { ]; executableHaskellDepends = [ aeson + async base bilge binary @@ -134,13 +140,14 @@ mkDerivation { exceptions HsOpenSSL hspec + hspec-core + hspec-junit-formatter http-client-tls http-types http2-manager imports kan-extensions lens - mtl optparse-applicative polysemy QuickCheck diff --git a/services/federator/federator.cabal b/services/federator/federator.cabal index 9b6dc292c8a..0d52c231756 100644 --- a/services/federator/federator.cabal +++ b/services/federator/federator.cabal @@ -43,6 +43,7 @@ library Federator.ExternalServer Federator.Health Federator.InternalServer + Federator.Metrics Federator.MockServer Federator.Monitor Federator.Monitor.Internal @@ -134,7 +135,9 @@ library , pem , polysemy , polysemy-wire-zoo + , prometheus-client , servant + , servant-client , servant-client-core , servant-server , text @@ -273,6 +276,7 @@ executable federator-integration build-depends: aeson + , async , base , bilge , binary @@ -285,13 +289,14 @@ executable federator-integration , federator , HsOpenSSL , hspec + , hspec-core + , hspec-junit-formatter , http-client-tls , http-types , http2-manager , imports , kan-extensions , lens - , mtl , optparse-applicative , polysemy , QuickCheck diff --git a/services/federator/federator.integration.yaml b/services/federator/federator.integration.yaml index e0d8ec2355f..9f9d2fa2001 100644 --- a/services/federator/federator.integration.yaml +++ b/services/federator/federator.integration.yaml @@ -24,5 +24,6 @@ optSettings: useSystemCAStore: false clientCertificate: "test/resources/integration-leaf.pem" clientPrivateKey: "test/resources/integration-leaf-key.pem" + tcpConnectionTimeout: 5000000 dnsHost: "127.0.0.1" dnsPort: 9053 diff --git a/services/federator/src/Federator/Env.hs b/services/federator/src/Federator/Env.hs index 52a581891a4..12f3670ef18 100644 --- a/services/federator/src/Federator/Env.hs +++ b/services/federator/src/Federator/Env.hs @@ -30,10 +30,15 @@ import Imports import Network.DNS.Resolver (Resolver) import Network.HTTP.Client qualified as HTTP import OpenSSL.Session (SSLContext) +import Prometheus import System.Logger.Class qualified as LC import Util.Options import Wire.API.Federation.Component -import Wire.API.Routes.FederationDomainConfig (FederationDomainConfigs) + +data FederatorMetrics = FederatorMetrics + { outgoingRequests :: Vector Text Counter, + incomingRequests :: Vector Text Counter + } data Env = Env { _metrics :: Metrics, @@ -41,12 +46,12 @@ data Env = Env _requestId :: RequestId, _dnsResolver :: Resolver, _runSettings :: RunSettings, - _domainConfigs :: IORef FederationDomainConfigs, _service :: Component -> Endpoint, _externalPort :: Word16, _internalPort :: Word16, _httpManager :: HTTP.Manager, - _http2Manager :: IORef Http2Manager + _http2Manager :: IORef Http2Manager, + _federatorMetrics :: FederatorMetrics } makeLenses ''Env @@ -55,6 +60,8 @@ onNewSSLContext :: Env -> SSLContext -> IO () onNewSSLContext env ctx = atomicModifyIORef' (_http2Manager env) $ \mgr -> (setSSLContext ctx mgr, ()) -mkHttp2Manager :: SSLContext -> IO Http2Manager -mkHttp2Manager sslContext = - setSSLRemoveTrailingDot True <$> http2ManagerWithSSLCtx sslContext +mkHttp2Manager :: Int -> SSLContext -> IO Http2Manager +mkHttp2Manager tcpConnectionTimeout sslContext = + setTCPConnectionTimeout tcpConnectionTimeout + . setSSLRemoveTrailingDot True + <$> http2ManagerWithSSLCtx sslContext diff --git a/services/federator/src/Federator/ExternalServer.hs b/services/federator/src/Federator/ExternalServer.hs index 6ec2178efb7..733a838b8be 100644 --- a/services/federator/src/Federator/ExternalServer.hs +++ b/services/federator/src/Federator/ExternalServer.hs @@ -40,6 +40,7 @@ import Federator.Discovery import Federator.Env import Federator.Error.ServerError import Federator.Health qualified as Health +import Federator.Metrics import Federator.RPC import Federator.Response import Federator.Service @@ -103,7 +104,8 @@ server :: Member (Error ValidationError) r, Member (Error DiscoveryFailure) r, Member (Error ServerError) r, - Member (Input FederationDomainConfigs) r + Member (Input FederationDomainConfigs) r, + Member Metrics r ) => Manager -> Word16 -> @@ -125,7 +127,8 @@ callInward :: Member (Error ValidationError) r, Member (Error DiscoveryFailure) r, Member (Error ServerError) r, - Member (Input FederationDomainConfigs) r + Member (Input FederationDomainConfigs) r, + Member Metrics r ) => Component -> RPC -> @@ -134,6 +137,7 @@ callInward :: Wai.Request -> Sem r Wai.Response callInward component (RPC rpc) originDomain (CertHeader cert) wreq = do + incomingCounterIncr originDomain -- only POST is supported when (Wai.requestMethod wreq /= HTTP.methodPost) $ throw InvalidRoute diff --git a/services/federator/src/Federator/InternalServer.hs b/services/federator/src/Federator/InternalServer.hs index 5be50a2bde3..b9e3d903a36 100644 --- a/services/federator/src/Federator/InternalServer.hs +++ b/services/federator/src/Federator/InternalServer.hs @@ -29,6 +29,7 @@ import Data.Proxy import Federator.Env import Federator.Error.ServerError import Federator.Health qualified as Health +import Federator.Metrics (Metrics, outgoingCounterIncr) import Federator.RPC import Federator.Remote import Federator.Response @@ -44,8 +45,10 @@ import Servant.API import Servant.API.Extended.Endpath import Servant.Server (Tagged (..)) import Servant.Server.Generic +import System.Logger.Class qualified as Log import Wire.API.Federation.Component import Wire.API.Routes.FederationDomainConfig +import Wire.Sem.Logger (Logger, debug) data API mode = API { status :: @@ -75,7 +78,9 @@ server :: Member (Embed IO) r, Member (Error ValidationError) r, Member (Error ServerError) r, - Member (Input FederationDomainConfigs) r + Member (Input FederationDomainConfigs) r, + Member Metrics r, + Member (Logger (Log.Msg -> Log.Msg)) r ) => Manager -> Word16 -> @@ -93,7 +98,9 @@ callOutward :: Member (Embed IO) r, Member (Error ValidationError) r, Member (Error ServerError) r, - Member (Input FederationDomainConfigs) r + Member (Input FederationDomainConfigs) r, + Member Metrics r, + Member (Logger (Log.Msg -> Log.Msg)) r ) => Domain -> Component -> @@ -107,9 +114,15 @@ callOutward targetDomain component (RPC path) req = do -- No query parameters are allowed unless (BS.null . Wai.rawQueryString $ req) $ throw InvalidRoute - ensureCanFederateWith targetDomain + outgoingCounterIncr targetDomain body <- embed $ Wai.lazyRequestBody req + debug $ + Log.msg (Log.val "Federator outward call") + . Log.field "domain" targetDomain._domainText + . Log.field "component" (show component) + . Log.field "path" path + . Log.field "body" body resp <- discoverAndCall targetDomain diff --git a/services/federator/src/Federator/Metrics.hs b/services/federator/src/Federator/Metrics.hs new file mode 100644 index 00000000000..b2f01b6ebd1 --- /dev/null +++ b/services/federator/src/Federator/Metrics.hs @@ -0,0 +1,55 @@ +{-# LANGUAGE RecordWildCards #-} +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Federator.Metrics + ( Metrics (..), + interpretMetrics, + outgoingCounterIncr, + incomingCounterIncr, + ) +where + +import Control.Lens (view) +import Data.Domain (Domain, domainText) +import Federator.Env +import Imports +import Polysemy +import Polysemy.Input (Input, inputs) +import Prometheus + +data Metrics m a where + OutgoingCounterIncr :: Domain -> Metrics m () + IncomingCounterIncr :: Domain -> Metrics m () + +makeSem ''Metrics + +interpretMetrics :: + ( Member (Input Env) r, + Member (Embed IO) r + ) => + Sem (Metrics ': r) a -> + Sem r a +interpretMetrics = interpret $ \case + OutgoingCounterIncr targetDomain -> do + m <- inputs (view federatorMetrics) + liftIO $ withLabel m.outgoingRequests (domainText targetDomain) incCounter + IncomingCounterIncr originDomain -> do + m <- inputs (view federatorMetrics) + liftIO $ withLabel m.incomingRequests (domainText originDomain) incCounter diff --git a/services/federator/src/Federator/Options.hs b/services/federator/src/Federator/Options.hs index d5bc71da53d..a4052d31652 100644 --- a/services/federator/src/Federator/Options.hs +++ b/services/federator/src/Federator/Options.hs @@ -31,6 +31,9 @@ data RunSettings = RunSettings remoteCAStore :: Maybe FilePath, clientCertificate :: FilePath, clientPrivateKey :: FilePath, + -- | Timeout for making TCP connections (for http2) with remote federators + -- and local components. In microseconds. + tcpConnectionTimeout :: Int, dnsHost :: Maybe String, dnsPort :: Maybe Word16 } diff --git a/services/federator/src/Federator/Remote.hs b/services/federator/src/Federator/Remote.hs index e72682144b9..3741bad1bf9 100644 --- a/services/federator/src/Federator/Remote.hs +++ b/services/federator/src/Federator/Remote.hs @@ -32,6 +32,7 @@ import Data.Binary.Builder import Data.ByteString.Lazy qualified as LBS import Data.Domain import Data.Text qualified as Text +import Data.Text.Encoding (decodeUtf8) import Data.Text.Encoding qualified as Text import Data.Text.Encoding.Error qualified as Text import Federator.Discovery @@ -55,26 +56,33 @@ import Wire.Network.DNS.SRV data RemoteError = -- | This means that an error occurred while trying to make a request to a -- remote federator. - RemoteError SrvTarget FederatorClientHTTP2Error + RemoteError SrvTarget Text FederatorClientHTTP2Error | -- | This means that a request to a remote federator returned an error -- response. The error response could be due to an error in the remote -- federator itself, or in the services it proxied to. - RemoteErrorResponse SrvTarget HTTP.Status LByteString + RemoteErrorResponse SrvTarget Text HTTP.Status LByteString deriving (Show) instance AsWai RemoteError where - toWai (RemoteError _ e) = federationRemoteHTTP2Error e - toWai (RemoteErrorResponse _ status _) = - federationRemoteResponseError status + toWai (RemoteError target path e) = + let domain = Domain . decodeUtf8 $ target.srvTargetDomain + in federationRemoteHTTP2Error domain path e + toWai (RemoteErrorResponse target path status resp) = + let domain = Domain . decodeUtf8 $ target.srvTargetDomain + in federationRemoteResponseError domain path status resp - waiErrorDescription (RemoteError tgt e) = + waiErrorDescription (RemoteError tgt path e) = "Error while connecting to " <> displayTarget tgt + <> " on path " + <> path <> ": " <> Text.pack (displayException e) - waiErrorDescription (RemoteErrorResponse tgt status body) = + waiErrorDescription (RemoteErrorResponse tgt path status body) = "Federator at " <> displayTarget tgt + <> " on path " + <> path <> " failed with status code " <> Text.pack (show (HTTP.statusCode status)) <> ": " @@ -112,12 +120,13 @@ interpretRemote = interpret $ \case let path = LBS.toStrict . toLazyByteString $ HTTP.encodePathSegments ["federation", componentName component, rpc] + pathT = decodeUtf8 path -- filter out Host header, because the HTTP2 client adds it back headers' = filter ((/= "Host") . fst) headers req' = HTTP2.requestBuilder HTTP.methodPost path headers' body mgr <- input - resp <- mapError (RemoteError target) . (fromEither @FederatorClientHTTP2Error =<<) . embed $ + resp <- mapError (RemoteError target pathT) . (fromEither @FederatorClientHTTP2Error =<<) . embed $ Codensity $ \k -> E.catches (H2Manager.withHTTP2Request mgr (True, hostname, fromIntegral port) req' (consumeStreamingResponseWith $ k . Right)) @@ -132,6 +141,7 @@ interpretRemote = interpret $ \case throw $ RemoteErrorResponse target + pathT (responseStatusCode resp) (toLazyByteString bdy) pure resp diff --git a/services/federator/src/Federator/Response.hs b/services/federator/src/Federator/Response.hs index 8c447cbc2fe..ae248f01e18 100644 --- a/services/federator/src/Federator/Response.hs +++ b/services/federator/src/Federator/Response.hs @@ -34,6 +34,7 @@ import Federator.Discovery import Federator.Env import Federator.Error import Federator.Error.ServerError +import Federator.Metrics (Metrics, interpretMetrics) import Federator.Options import Federator.Remote import Federator.Service @@ -53,10 +54,14 @@ import Polysemy.Input import Polysemy.Internal import Polysemy.TinyLog import Servant hiding (ServerError, respond, serve) +import Servant.Client (mkClientEnv) import Servant.Client.Core import Servant.Server.Generic import Servant.Types.SourceT -import Wire.API.Routes.FederationDomainConfig +import Util.Options (Endpoint (..)) +import Wire.API.FederationUpdate qualified as FedUp (getFederationDomainConfigs) +import Wire.API.MakesFederatedCall (Component (Brig)) +import Wire.API.Routes.FederationDomainConfig qualified as FedUp (FederationDomainConfigs) import Wire.Network.DNS.Effect import Wire.Sem.Logger.TinyLog @@ -137,13 +142,14 @@ serveServant middleware server env port = genericServe server type AllEffects = - '[ Remote, + '[ Metrics, + Remote, DiscoverFederator, DNSLookup, -- needed by DiscoverFederator ServiceStreaming, Input RunSettings, Input Http2Manager, -- needed by Remote - Input FederationDomainConfigs, -- needed for the domain list. + Input FedUp.FederationDomainConfigs, -- needed for the domain list and federation policy. Input Env, -- needed by Service Error ValidationError, Error RemoteError, @@ -168,13 +174,24 @@ runFederator env = DiscoveryFailure ] . runInputConst env - . runInputSem (embed @IO (readIORef (view domainConfigs env))) + . runInputSem (embed @IO (getFederationDomainConfigs env)) . runInputSem (embed @IO (readIORef (view http2Manager env))) . runInputConst (view runSettings env) . interpretServiceHTTP . runDNSLookupWithResolver (view dnsResolver env) . runFederatorDiscovery . interpretRemote + . interpretMetrics + +getFederationDomainConfigs :: Env -> IO FedUp.FederationDomainConfigs +getFederationDomainConfigs env = do + let mgr = env ^. httpManager + Endpoint h p = env ^. service $ Brig + baseurl = BaseUrl Http (cs h) (fromIntegral p) "" + clientEnv = mkClientEnv mgr baseurl + FedUp.getFederationDomainConfigs clientEnv >>= \case + Right v -> pure v + Left e -> error $ show e streamingResponseToWai :: StreamingResponse -> Wai.Response streamingResponseToWai resp = diff --git a/services/federator/src/Federator/Run.hs b/services/federator/src/Federator/Run.hs index cd2f82f9ba2..ebdf4cb295e 100644 --- a/services/federator/src/Federator/Run.hs +++ b/services/federator/src/Federator/Run.hs @@ -47,12 +47,11 @@ import Federator.Options as Opt import Imports import Network.DNS qualified as DNS import Network.HTTP.Client qualified as HTTP +import Prometheus import System.Logger qualified as Log import System.Logger.Extended qualified as LogExt import Util.Options import Wire.API.Federation.Component -import Wire.API.FederationUpdate -import Wire.API.Routes.FederationDomainConfig import Wire.Network.DNS.Helper qualified as DNS ------------------------------------------------------------------------------ @@ -64,48 +63,66 @@ run opts = do let resolvConf = mkResolvConf (optSettings opts) DNS.defaultResolvConf DNS.withCachingResolver resolvConf $ \res -> do logger <- LogExt.mkLogger (Opt.logLevel opts) (Opt.logNetStrings opts) (Opt.logFormat opts) - (ioref, updateFedDomainsThread) <- syncFedDomainConfigs (brig opts) logger emptySyncFedDomainConfigsCallback - bracket (newEnv opts res logger ioref) closeEnv $ \env -> do + bracket (newEnv opts res logger) closeEnv $ \env -> do let externalServer = serveInward env portExternal internalServer = serveOutward env portInternal withMonitor logger (onNewSSLContext env) (optSettings opts) $ do internalServerThread <- async internalServer externalServerThread <- async externalServer - void $ waitAnyCancel [updateFedDomainsThread, internalServerThread, externalServerThread] + void $ waitAnyCancel [internalServerThread, externalServerThread] where endpointInternal = federatorInternal opts - portInternal = fromIntegral $ endpointInternal ^. epPort + portInternal = fromIntegral $ endpointInternal ^. port endpointExternal = federatorExternal opts - portExternal = fromIntegral $ endpointExternal ^. epPort + portExternal = fromIntegral $ endpointExternal ^. port mkResolvConf :: RunSettings -> DNS.ResolvConf -> DNS.ResolvConf mkResolvConf settings conf = case (dnsHost settings, dnsPort settings) of - (Just host, Nothing) -> - conf {DNS.resolvInfo = DNS.RCHostName host} - (Just host, Just port) -> - conf {DNS.resolvInfo = DNS.RCHostPort host (fromIntegral port)} + (Just h, Nothing) -> + conf {DNS.resolvInfo = DNS.RCHostName h} + (Just h, Just p) -> + conf {DNS.resolvInfo = DNS.RCHostPort h (fromIntegral p)} (_, _) -> conf ------------------------------------------------------------------------------- -- Environment -newEnv :: Opts -> DNS.Resolver -> Log.Logger -> IORef FederationDomainConfigs -> IO Env -newEnv o _dnsResolver _applog _domainConfigs = do +newEnv :: Opts -> DNS.Resolver -> Log.Logger -> IO Env +newEnv o _dnsResolver _applog = do _metrics <- Metrics.metrics let _requestId = def _runSettings = Opt.optSettings o _service Brig = Opt.brig o _service Galley = Opt.galley o _service Cargohold = Opt.cargohold o - _externalPort = o.federatorExternal._epPort - _internalPort = o.federatorInternal._epPort + _externalPort = o.federatorExternal._port + _internalPort = o.federatorInternal._port _httpManager <- initHttpManager sslContext <- mkTLSSettingsOrThrow _runSettings - _http2Manager <- newIORef =<< mkHttp2Manager sslContext + _http2Manager <- newIORef =<< mkHttp2Manager o.optSettings.tcpConnectionTimeout sslContext + _federatorMetrics <- mkFederatorMetrics pure Env {..} +mkFederatorMetrics :: IO FederatorMetrics +mkFederatorMetrics = + FederatorMetrics + <$> register + ( vector "target_domain" $ + counter $ + Prometheus.Info + "com_wire_federator_outgoing_requests" + "Number of outgoing requests" + ) + <*> register + ( vector "origin_domain" $ + counter $ + Prometheus.Info + "com_wire_federator_incoming_requests" + "Number of incoming requests" + ) + closeEnv :: Env -> IO () closeEnv e = do Log.flush $ e ^. applog diff --git a/services/federator/src/Federator/Validation.hs b/services/federator/src/Federator/Validation.hs index da3f78320d7..5f9fd7cf64a 100644 --- a/services/federator/src/Federator/Validation.hs +++ b/services/federator/src/Federator/Validation.hs @@ -87,7 +87,7 @@ validationErrorStatus :: ValidationError -> HTTP.Status -- the FederationDenied case is handled differently, because it may be caused -- by wrong input in the original request, so we let this error propagate to the -- client -validationErrorStatus (FederationDenied _) = HTTP.status422 +validationErrorStatus (FederationDenied _) = HTTP.status400 validationErrorStatus _ = HTTP.status403 -- | Validates an already-parsed domain against the allow list (stored in diff --git a/services/federator/test/integration/Main.hs b/services/federator/test/integration/Main.hs index fb1183a5926..d63572adf78 100644 --- a/services/federator/test/integration/Main.hs +++ b/services/federator/test/integration/Main.hs @@ -20,6 +20,7 @@ module Main ) where +import Control.Concurrent.Async import Imports import OpenSSL (withOpenSSL) import System.Environment (withArgs) @@ -27,6 +28,10 @@ import Test.Federator.IngressSpec qualified import Test.Federator.InwardSpec qualified import Test.Federator.Util (TestEnv, mkEnvFromOptions) import Test.Hspec +import Test.Hspec.Core.Format +import Test.Hspec.JUnit +import Test.Hspec.JUnit.Config.Env +import Test.Hspec.Runner main :: IO () main = withOpenSSL $ do @@ -34,7 +39,26 @@ main = withOpenSSL $ do env <- withArgs wireArgs mkEnvFromOptions -- withArgs hspecArgs . hspec $ do -- beforeAll (pure env) . afterAll destroyEnv $ Hspec.mkspec - withArgs hspecArgs . hspec $ mkspec env + cfg <- hspecConfig + withArgs hspecArgs . hspecWith cfg $ mkspec env + +hspecConfig :: IO Config +hspecConfig = do + junitConfig <- envJUnitConfig + pure $ + defaultConfig + { configAvailableFormatters = + ("junit", checksAndJUnitFormatter junitConfig) + : configAvailableFormatters defaultConfig + } + where + checksAndJUnitFormatter :: JUnitConfig -> FormatConfig -> IO Format + checksAndJUnitFormatter junitConfig config = do + junit <- junitFormat junitConfig config + let checksFormatter = fromJust (lookup "checks" $ configAvailableFormatters defaultConfig) + checks <- checksFormatter config + pure $ \event -> do + concurrently_ (junit event) (checks event) partitionArgs :: [String] -> ([String], [String]) partitionArgs = go [] [] diff --git a/services/federator/test/integration/Test/Federator/IngressSpec.hs b/services/federator/test/integration/Test/Federator/IngressSpec.hs index fb24eac5796..0d322238276 100644 --- a/services/federator/test/integration/Test/Federator/IngressSpec.hs +++ b/services/federator/test/integration/Test/Federator/IngressSpec.hs @@ -23,7 +23,6 @@ import Control.Monad.Codensity import Data.Aeson qualified as Aeson import Data.Binary.Builder import Data.Domain -import Data.Handle import Data.LegalHold (UserLegalHoldStatus (UserLegalHoldNoConsent)) import Data.Text.Encoding qualified as Text import Federator.Discovery @@ -57,22 +56,20 @@ spec env = do runTestFederator env $ do brig <- view teBrig <$> ask user <- randomUser brig - hdl <- randomHandle - _ <- putHandle brig (userId user) hdl - let expectedProfile = (publicProfile user UserLegalHoldNoConsent) {profileHandle = Just (Handle hdl)} + let expectedProfile = publicProfile user UserLegalHoldNoConsent runTestSem $ do resp <- liftToCodensity . assertNoError @RemoteError $ inwardBrigCallViaIngress - "get-user-by-handle" - (Aeson.fromEncoding (Aeson.toEncoding hdl)) + "get-users-by-ids" + (Aeson.fromEncoding (Aeson.toEncoding [userId user])) embed . lift @Codensity $ do bdy <- streamingResponseStrictBody resp let actualProfile = Aeson.decode (toLazyByteString bdy) responseStatusCode resp `shouldBe` HTTP.status200 - actualProfile `shouldBe` Just expectedProfile + actualProfile `shouldBe` Just [expectedProfile] -- @SF.Federation @TSFI.RESTfulAPI @S2 @S3 @S7 -- @@ -106,9 +103,9 @@ spec env = do (Aeson.fromEncoding (Aeson.toEncoding hdl)) liftToCodensity . embed $ case r of Right _ -> expectationFailure "Expected client certificate error, got response" - Left (RemoteError _ _) -> + Left (RemoteError {}) -> expectationFailure "Expected client certificate error, got remote error" - Left (RemoteErrorResponse _ status _) -> status `shouldBe` HTTP.status400 + Left (RemoteErrorResponse _ _ status _) -> status `shouldBe` HTTP.status400 -- FUTUREWORK: ORMOLU_DISABLE -- @END @@ -144,8 +141,8 @@ inwardBrigCallViaIngressWithSettings :: Sem r StreamingResponse inwardBrigCallViaIngressWithSettings sslCtx requestPath payload = do - Endpoint ingressHost ingressPort <- cfgNginxIngress . view teTstOpts <$> input - originDomain <- cfgOriginDomain . view teTstOpts <$> input + Endpoint ingressHost ingressPort <- nginxIngress . view teTstOpts <$> input + originDomain <- originDomain . view teTstOpts <$> input let target = SrvTarget (cs ingressHost) ingressPort headers = [(originDomainHeaderName, Text.encodeUtf8 originDomain)] mgr <- liftToCodensity . liftIO $ http2ManagerWithSSLCtx sslCtx diff --git a/services/federator/test/integration/Test/Federator/InwardSpec.hs b/services/federator/test/integration/Test/Federator/InwardSpec.hs index 066e9a50583..3b4cc55bd9b 100644 --- a/services/federator/test/integration/Test/Federator/InwardSpec.hs +++ b/services/federator/test/integration/Test/Federator/InwardSpec.hs @@ -28,10 +28,9 @@ import Data.Aeson.Types qualified as Aeson import Data.ByteString qualified as BS import Data.ByteString.Conversion (toByteString') import Data.ByteString.Lazy qualified as LBS -import Data.Handle import Data.LegalHold (UserLegalHoldStatus (UserLegalHoldNoConsent)) import Data.Text.Encoding -import Federator.Options +import Federator.Options hiding (federatorExternal) import Imports import Network.HTTP.Types qualified as HTTP import Network.Wai.Utilities.Error qualified as E @@ -48,7 +47,7 @@ import Wire.API.User -- they don't spread out over the different sevices. -- | This module contains tests for the interface between federator and brig. The tests call --- federator directly, circumnventing ingress: +-- federator directly, circumventing ingress: -- -- +----------+ -- |federator-| +------+--+ @@ -71,15 +70,15 @@ spec env = runTestFederator env $ do brig <- view teBrig <$> ask user <- randomUser brig - hdl <- randomHandle - _ <- putHandle brig (userId user) hdl - let expectedProfile = (publicProfile user UserLegalHoldNoConsent) {profileHandle = Just (Handle hdl)} + let expectedProfile = publicProfile user UserLegalHoldNoConsent bdy <- responseJsonError - =<< inwardCall "/federation/brig/get-user-by-handle" (encode hdl) - view teTstOpts + originDomain <- originDomain <$> view teTstOpts hdl <- randomHandle inwardCallWithHeaders "federation/brig/get-user-by-handle" @@ -145,7 +144,7 @@ inwardCallWithHeaders :: LBS.ByteString -> m (Response (Maybe LByteString)) inwardCallWithHeaders requestPath hh payload = do - Endpoint fedHost fedPort <- cfgFederatorExternal <$> view teTstOpts + Endpoint fedHost fedPort <- federatorExternal <$> view teTstOpts post ( host (encodeUtf8 fedHost) . port fedPort @@ -160,7 +159,7 @@ inwardCall :: LBS.ByteString -> m (Response (Maybe LByteString)) inwardCall requestPath payload = do - originDomain :: Text <- cfgOriginDomain <$> view teTstOpts + originDomain :: Text <- originDomain <$> view teTstOpts inwardCallWithOriginDomain (toByteString' originDomain) requestPath payload inwardCallWithOriginDomain :: @@ -170,7 +169,7 @@ inwardCallWithOriginDomain :: LBS.ByteString -> m (Response (Maybe LByteString)) inwardCallWithOriginDomain originDomain requestPath payload = do - Endpoint fedHost fedPort <- cfgFederatorExternal <$> view teTstOpts + Endpoint fedHost fedPort <- federatorExternal <$> view teTstOpts clientCertFilename <- clientCertificate . optSettings . view teOpts <$> ask clientCert <- liftIO $ BS.readFile clientCertFilename post diff --git a/services/federator/test/integration/Test/Federator/JSON.hs b/services/federator/test/integration/Test/Federator/JSON.hs index d585fd3f9d9..e69be554afb 100644 --- a/services/federator/test/integration/Test/Federator/JSON.hs +++ b/services/federator/test/integration/Test/Federator/JSON.hs @@ -26,4 +26,4 @@ deriveJSONOptions :: Options deriveJSONOptions = defaultOptions {fieldLabelModifier = labelmod} labelmod :: String -> String -labelmod = (ix 0 %~ toLower) . dropWhile (not . isUpper) +labelmod = (ix 0 %~ toLower) diff --git a/services/federator/test/integration/Test/Federator/Util.hs b/services/federator/test/integration/Test/Federator/Util.hs index 883e93bc7b0..6d8a61f0093 100644 --- a/services/federator/test/integration/Test/Federator/Util.hs +++ b/services/federator/test/integration/Test/Federator/Util.hs @@ -30,8 +30,7 @@ import Bilge.Assert import Control.Exception import Control.Lens hiding ((.=)) import Control.Monad.Catch -import Control.Monad.Except -import Crypto.Random.Types (MonadRandom, getRandomBytes) +import Crypto.Random.Types import Data.Aeson import Data.Aeson.TH import Data.Aeson.Types qualified as Aeson @@ -54,7 +53,8 @@ import Polysemy.Error import System.Random import Test.Federator.JSON import Test.Tasty.HUnit -import Util.Options +import Util.Options (Endpoint) +import Util.Options qualified as O import Wire.API.User import Wire.API.User.Auth @@ -104,11 +104,11 @@ data TestEnv = TestEnv type Select = TestEnv -> (Request -> Request) data IntegrationConfig = IntegrationConfig - { cfgBrig :: Endpoint, - cfgCargohold :: Endpoint, - cfgFederatorExternal :: Endpoint, - cfgNginxIngress :: Endpoint, - cfgOriginDomain :: Text + { brig :: Endpoint, + cargohold :: Endpoint, + federatorExternal :: Endpoint, + nginxIngress :: Endpoint, + originDomain :: Text } deriving (Show, Generic) @@ -152,8 +152,8 @@ mkEnv :: HasCallStack => IntegrationConfig -> Opts -> IO TestEnv mkEnv _teTstOpts _teOpts = do let managerSettings = mkManagerSettings (Network.Connection.TLSSettingsSimple True False False) Nothing _teMgr :: Manager <- newManager managerSettings - let _teBrig = endpointToReq (cfgBrig _teTstOpts) - _teCargohold = endpointToReq (cfgCargohold _teTstOpts) + let _teBrig = endpointToReq _teTstOpts.brig + _teCargohold = endpointToReq _teTstOpts.cargohold -- _teTLSSettings <- mkTLSSettingsOrThrow (optSettings _teOpts) _teSSLContext <- mkTLSSettingsOrThrow (optSettings _teOpts) let _teSettings = optSettings _teOpts @@ -163,7 +163,7 @@ destroyEnv :: HasCallStack => TestEnv -> IO () destroyEnv _ = pure () endpointToReq :: Endpoint -> (Bilge.Request -> Bilge.Request) -endpointToReq ep = Bilge.host (ep ^. epHost . to cs) . Bilge.port (ep ^. epPort) +endpointToReq ep = Bilge.host (ep ^. O.host . to cs) . Bilge.port (ep ^. O.port) -- All the code below is copied from brig-integration tests -- FUTUREWORK: This should live in another package and shared by all the integration tests diff --git a/services/federator/test/unit/Test/Federator/Client.hs b/services/federator/test/unit/Test/Federator/Client.hs index afe72d009e5..d5db3ae77f6 100644 --- a/services/federator/test/unit/Test/Federator/Client.hs +++ b/services/federator/test/unit/Test/Federator/Client.hs @@ -45,7 +45,7 @@ import Servant.Types.SourceT import Test.QuickCheck (arbitrary, generate) import Test.Tasty import Test.Tasty.HUnit -import Util.Options +import Util.Options (Endpoint (Endpoint)) import Wire.API.Federation.API import Wire.API.Federation.Client import Wire.API.Federation.Error diff --git a/services/federator/test/unit/Test/Federator/ExternalServer.hs b/services/federator/test/unit/Test/Federator/ExternalServer.hs index 2f95e96aa24..66961412305 100644 --- a/services/federator/test/unit/Test/Federator/ExternalServer.hs +++ b/services/federator/test/unit/Test/Federator/ExternalServer.hs @@ -27,6 +27,7 @@ import Data.Text.Encoding qualified as Text import Federator.Discovery import Federator.Error.ServerError (ServerError (..)) import Federator.ExternalServer +import Federator.Metrics import Federator.Options import Federator.Response import Federator.Service (Service (..), ServiceStreaming) @@ -70,6 +71,11 @@ tests = testMethod ] +interpretMetricsEmpty :: Sem (Metrics ': r) a -> Sem r a +interpretMetricsEmpty = interpret $ \case + OutgoingCounterIncr _ -> pure () + IncomingCounterIncr _ -> pure () + exampleRequest :: FilePath -> ByteString -> IO Wai.Request exampleRequest certFile path = do cert <- BS.readFile certFile @@ -113,8 +119,15 @@ requestBrigSuccess = "test/resources/unit/localhost.example.com.pem" "/federation/brig/get-user-by-handle" Right cert <- decodeCertificate <$> BS.readFile "test/resources/unit/localhost.example.com.pem" + + let assertMetrics :: Member (Embed IO) r => Sem (Metrics ': r) a -> Sem r a + assertMetrics = interpret $ \case + OutgoingCounterIncr _ -> embed @IO $ assertFailure "Should not increment outgoing counter" + IncomingCounterIncr od -> embed @IO $ od @?= aValidDomain + (actualCalls, res) <- runM + . assertMetrics . runOutputList . mockService HTTP.ok200 . assertNoError @ValidationError @@ -142,6 +155,7 @@ requestBrigFailure = (actualCalls, res) <- runM + . interpretMetricsEmpty . runOutputList . mockService HTTP.notFound404 . assertNoError @ValidationError @@ -172,6 +186,7 @@ requestGalleySuccess = runM $ do (actualCalls, res) <- runOutputList + . interpretMetricsEmpty . mockService HTTP.ok200 . assertNoError @ValidationError . assertNoError @DiscoveryFailure @@ -318,7 +333,8 @@ testMethod = testInterpretter :: IORef [Call] -> Sem - '[ Input FederationDomainConfigs, + '[ Metrics, + Input FederationDomainConfigs, Input RunSettings, DiscoverFederator, Error DiscoveryFailure, @@ -341,6 +357,7 @@ testInterpretter serviceCallsRef = . mockDiscoveryTrivial . runInputConst noClientCertSettings . runInputConst scaffoldingFederationDomainConfigs + . interpretMetricsEmpty exampleDomain :: Text exampleDomain = "localhost.example.com" diff --git a/services/federator/test/unit/Test/Federator/InternalServer.hs b/services/federator/test/unit/Test/Federator/InternalServer.hs index 6d0e61cd393..9a433081f94 100644 --- a/services/federator/test/unit/Test/Federator/InternalServer.hs +++ b/services/federator/test/unit/Test/Federator/InternalServer.hs @@ -25,6 +25,7 @@ import Data.Default import Data.Domain import Federator.Error.ServerError import Federator.InternalServer (callOutward) +import Federator.Metrics import Federator.RPC import Federator.Remote import Federator.Validation @@ -86,6 +87,12 @@ federatedRequestSuccess = responseHttpVersion = HTTP.http20, responseBody = source ["\"bar\""] } + + let assertMetrics :: Member (Embed IO) r => Sem (Metrics ': r) a -> Sem r a + assertMetrics = interpret $ \case + OutgoingCounterIncr td -> embed @IO $ td @?= targetDomain + IncomingCounterIncr _ -> embed @IO $ assertFailure "Should not increment incoming counter" + res <- runM . interpretCall @@ -94,6 +101,7 @@ federatedRequestSuccess = . discardTinyLogs . runInputConst settings . runInputConst (FederationDomainConfigs AllowDynamic [FederationDomainConfig (Domain "target.example.com") FullSearch] 10) + . assertMetrics $ callOutward targetDomain Brig (RPC "get-user-by-handle") request Wai.responseStatus res @?= HTTP.status200 body <- Wai.lazyResponseBody res @@ -126,6 +134,9 @@ federatedRequestFailureAllowList = responseHttpVersion = HTTP.http20, responseBody = source ["\"bar\""] } + let interpretMetricsEmpty = interpret $ \case + OutgoingCounterIncr _ -> pure () + IncomingCounterIncr _ -> pure () eith <- runM @@ -136,6 +147,7 @@ federatedRequestFailureAllowList = . discardTinyLogs . runInputConst settings . runInputConst (FederationDomainConfigs AllowDynamic [FederationDomainConfig (Domain "hello.world") FullSearch] 10) + . interpretMetricsEmpty $ callOutward targetDomain Brig (RPC "get-user-by-handle") request eith @?= Left (FederationDenied targetDomain) diff --git a/services/federator/test/unit/Test/Federator/Options.hs b/services/federator/test/unit/Test/Federator/Options.hs index 973d410fa0b..1530489e0f5 100644 --- a/services/federator/test/unit/Test/Federator/Options.hs +++ b/services/federator/test/unit/Test/Federator/Options.hs @@ -39,6 +39,7 @@ defRunSettings client key = remoteCAStore = Nothing, clientCertificate = client, clientPrivateKey = key, + tcpConnectionTimeout = 1000, dnsHost = Nothing, dnsPort = Nothing } @@ -66,6 +67,7 @@ testSettings = allowAll: clientCertificate: client.pem clientPrivateKey: client-key.pem + tcpConnectionTimeout: 1000 useSystemCAStore: true|] ), testCase "parse configuration example (closed federation)" $ do @@ -79,6 +81,7 @@ testSettings = allowedDomains: - server2.example.com useSystemCAStore: false + tcpConnectionTimeout: 1000 clientCertificate: client.pem clientPrivateKey: client-key.pem|], testCase "succefully read client credentials" $ do @@ -89,6 +92,7 @@ testSettings = assertParsesAs settings . B8.pack $ [QQ.i| useSystemCAStore: true + tcpConnectionTimeout: 1000 federationStrategy: allowAll: null clientCertificate: test/resources/unit/localhost.pem @@ -98,12 +102,14 @@ testSettings = assertParseFailure @RunSettings . B8.pack $ [QQ.i| useSystemCAStore: true + tcpConnectionTimeout: 1000 federationStrategy: allowAll: null|], testCase "fail on missing client private key" $ do assertParseFailure @RunSettings . B8.pack $ [QQ.i| useSystemCAStore: true + tcpConnectionTimeout: 1000 federationStrategy: allowAll: null clientCertificate: test/resources/unit/localhost.pem|], @@ -111,6 +117,7 @@ testSettings = assertParseFailure @RunSettings . B8.pack $ [QQ.i| useSystemCAStore: true + tcpConnectionTimeout: 1000 federationStrategy: allowAll: null clientPrivateKey: test/resources/unit/localhost-key.pem|], @@ -119,6 +126,7 @@ testSettings = assertParsesAs settings . B8.pack $ [QQ.i| useSystemCAStore: true + tcpConnectionTimeout: 1000 federationStrategy: allowAll: null clientCertificate: non-existent @@ -140,6 +148,7 @@ testSettings = assertParsesAs settings . B8.pack $ [QQ.i| useSystemCAStore: true + tcpConnectionTimeout: 1000 federationStrategy: allowAll: null clientCertificate: test/resources/unit/invalid.pem @@ -161,6 +170,7 @@ testSettings = assertParsesAs settings . B8.pack $ [QQ.i| useSystemCAStore: true + tcpConnectionTimeout: 1000 federationStrategy: allowAll: null clientCertificate: test/resources/unit/localhost.pem diff --git a/services/federator/test/unit/Test/Federator/Remote.hs b/services/federator/test/unit/Test/Federator/Remote.hs index f9a1f1161ed..5f82cea2753 100644 --- a/services/federator/test/unit/Test/Federator/Remote.hs +++ b/services/federator/test/unit/Test/Federator/Remote.hs @@ -80,7 +80,7 @@ assertNoRemoteError = \case mkTestCall :: SSLContext -> ByteString -> Int -> Codensity IO (Either RemoteError ()) mkTestCall sslCtx hostname port = do - mgr <- liftIO $ mkHttp2Manager sslCtx + mgr <- liftIO $ mkHttp2Manager 1_000_000 sslCtx runM . runEmbedded @IO @(Codensity IO) liftIO . runError @RemoteError @@ -142,14 +142,14 @@ testValidatesCertificateWrongHostname = withMockServer certForWrongDomain $ \port -> do tlsSettings <- mkTLSSettingsOrThrow settings runCodensity (mkTestCall tlsSettings "localhost" port) $ \case - Left (RemoteError _ (FederatorClientTLSException _)) -> pure () + Left (RemoteError _ _ (FederatorClientTLSException _)) -> pure () Left x -> assertFailure $ "Expected TLS failure, got: " <> show x Right _ -> assertFailure "Expected connection with the server to fail", testCase "when the server's certificate does not have the server key usage flag" $ withMockServer certWithoutServerKeyUsage $ \port -> do tlsSettings <- mkTLSSettingsOrThrow settings runCodensity (mkTestCall tlsSettings "localhost" port) $ \case - Left (RemoteError _ (FederatorClientTLSException _)) -> pure () + Left (RemoteError _ _ (FederatorClientTLSException _)) -> pure () Left x -> assertFailure $ "Expected TLS failure, got: " <> show x Right _ -> assertFailure "Expected connection with the server to fail" ] @@ -160,7 +160,7 @@ testConnectionError :: TestTree testConnectionError = testCase "connection failures are reported correctly" $ do tlsSettings <- mkTLSSettingsOrThrow settings runCodensity (mkTestCall tlsSettings "localhost" 1) $ \case - Left (RemoteError _ (FederatorClientConnectionError _)) -> pure () + Left (RemoteError _ _ (FederatorClientConnectionError _)) -> pure () Left x -> assertFailure $ "Expected connection error, got: " <> show x Right _ -> assertFailure "Expected connection with the server to fail" diff --git a/services/federator/test/unit/Test/Federator/Response.hs b/services/federator/test/unit/Test/Federator/Response.hs index 8bc559cd9a6..dcc0fec008c 100644 --- a/services/federator/test/unit/Test/Federator/Response.hs +++ b/services/federator/test/unit/Test/Federator/Response.hs @@ -95,6 +95,7 @@ testRemoteError = $ throw ( RemoteError (SrvTarget "example.com" 7777) + "" FederatorClientNoStatusCode ) body <- Wai.lazyResponseBody resp diff --git a/services/galley/default.nix b/services/galley/default.nix index 6f114b76486..68e29faedeb 100644 --- a/services/galley/default.nix +++ b/services/galley/default.nix @@ -14,6 +14,7 @@ , base , base64-bytestring , bilge +, binary , brig-types , bytestring , bytestring-conversion @@ -30,7 +31,6 @@ , currency-codes , data-default , data-timeout -, directory , either , enclosed-exceptions , errors @@ -42,9 +42,9 @@ , galley-types , gitignoreSource , gundeck-types -, hex , HsOpenSSL , hspec +, http-api-data , http-client , http-client-openssl , http-client-tls @@ -90,6 +90,7 @@ , streaming-commons , tagged , tasty +, tasty-ant-xml , tasty-cannon , tasty-hunit , tasty-quickcheck @@ -173,6 +174,7 @@ mkDerivation { metrics-core metrics-wai mtl + optparse-applicative pem polysemy polysemy-wire-zoo @@ -221,6 +223,7 @@ mkDerivation { base base64-bytestring bilge + binary brig-types bytestring bytestring-conversion @@ -232,11 +235,9 @@ mkDerivation { conduit containers cookie - cryptonite currency-codes data-default data-timeout - directory errors exceptions extended @@ -244,9 +245,9 @@ mkDerivation { federator filepath galley-types - hex HsOpenSSL hspec + http-api-data http-client http-client-openssl http-client-tls @@ -269,7 +270,6 @@ mkDerivation { QuickCheck quickcheck-instances random - raw-strings-qq retry saml2-web-sso schema-profunctor @@ -282,6 +282,7 @@ mkDerivation { streaming-commons tagged tasty + tasty-ant-xml tasty-cannon tasty-hunit temporary diff --git a/services/galley/galley.cabal b/services/galley/galley.cabal index 91fd95fbc7e..2fba1c3df8e 100644 --- a/services/galley/galley.cabal +++ b/services/galley/galley.cabal @@ -1,4 +1,4 @@ -cabal-version: 1.12 +cabal-version: 3.0 name: galley version: 0.83.0 synopsis: Conversations @@ -6,7 +6,7 @@ category: Network author: Wire Swiss GmbH maintainer: Wire Swiss GmbH copyright: (c) 2017 Wire Swiss GmbH -license: AGPL-3 +license: AGPL-3.0-only license-file: LICENSE build-type: Simple @@ -15,7 +15,61 @@ flag static manual: True default: False +common common-all + default-language: GHC2021 + ghc-options: + -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates + -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path + -Wredundant-constraints -Wunused-packages + + default-extensions: + NoImplicitPrelude + AllowAmbiguousTypes + BangPatterns + ConstraintKinds + DataKinds + DefaultSignatures + DeriveFunctor + DeriveGeneric + DeriveLift + DeriveTraversable + DerivingStrategies + DerivingVia + DuplicateRecordFields + EmptyCase + FlexibleContexts + FlexibleInstances + FunctionalDependencies + GADTs + GeneralizedNewtypeDeriving + InstanceSigs + KindSignatures + LambdaCase + MultiParamTypeClasses + MultiWayIf + NamedFieldPuns + NumericUnderscores + OverloadedLabels + OverloadedRecordDot + OverloadedStrings + PackageImports + PatternSynonyms + PolyKinds + QuasiQuotes + RankNTypes + ScopedTypeVariables + StandaloneDeriving + TupleSections + TypeApplications + TypeFamilies + TypeFamilyDependencies + TypeOperators + UndecidableInstances + ViewPatterns + library + import: common-all + -- cabal-fmt: expand src exposed-modules: Galley.API @@ -32,13 +86,22 @@ library Galley.API.Mapping Galley.API.Message Galley.API.MLS + Galley.API.MLS.Commit + Galley.API.MLS.Commit.Core + Galley.API.MLS.Commit.ExternalCommit + Galley.API.MLS.Commit.InternalCommit + Galley.API.MLS.Conversation Galley.API.MLS.Enabled Galley.API.MLS.GroupInfo - Galley.API.MLS.KeyPackage + Galley.API.MLS.IncomingMessage Galley.API.MLS.Keys Galley.API.MLS.Message + Galley.API.MLS.Migration + Galley.API.MLS.One2One Galley.API.MLS.Propagate + Galley.API.MLS.Proposal Galley.API.MLS.Removal + Galley.API.MLS.SubConversation Galley.API.MLS.Types Galley.API.MLS.Util Galley.API.MLS.Welcome @@ -82,6 +145,7 @@ library Galley.Cassandra.SearchVisibility Galley.Cassandra.Services Galley.Cassandra.Store + Galley.Cassandra.SubConversation Galley.Cassandra.Team Galley.Cassandra.TeamFeatures Galley.Cassandra.TeamNotifications @@ -99,7 +163,6 @@ library Galley.Effects.CodeStore Galley.Effects.ConversationStore Galley.Effects.CustomBackendStore - Galley.Effects.DefederationNotifications Galley.Effects.ExternalAccess Galley.Effects.FederatorAccess Galley.Effects.FireAndForget @@ -112,6 +175,7 @@ library Galley.Effects.SearchVisibilityStore Galley.Effects.ServiceStore Galley.Effects.SparAccess + Galley.Effects.SubConversationStore Galley.Effects.TeamFeatureStore Galley.Effects.TeamMemberStore Galley.Effects.TeamNotificationStore @@ -139,64 +203,87 @@ library Galley.Options Galley.Queue Galley.Run + Galley.Schema.Run + Galley.Schema.V20 + Galley.Schema.V21 + Galley.Schema.V22 + Galley.Schema.V23 + Galley.Schema.V24 + Galley.Schema.V25 + Galley.Schema.V26 + Galley.Schema.V27 + Galley.Schema.V28 + Galley.Schema.V29 + Galley.Schema.V30 + Galley.Schema.V31 + Galley.Schema.V32 + Galley.Schema.V33 + Galley.Schema.V34 + Galley.Schema.V35 + Galley.Schema.V36 + Galley.Schema.V37 + Galley.Schema.V38_CreateTableBillingTeamMember + Galley.Schema.V39 + Galley.Schema.V40_CreateTableDataMigration + Galley.Schema.V41_TeamNotificationQueue + Galley.Schema.V42_TeamFeatureValidateSamlEmails + Galley.Schema.V43_TeamFeatureDigitalSignatures + Galley.Schema.V44_AddRemoteIdentifiers + Galley.Schema.V45_AddFederationIdMapping + Galley.Schema.V46_TeamFeatureAppLock + Galley.Schema.V47_RemoveFederationIdMapping + Galley.Schema.V48_DeleteRemoteIdentifiers + Galley.Schema.V49_ReAddRemoteIdentifiers + Galley.Schema.V50_AddLegalholdWhitelisted + Galley.Schema.V51_FeatureFileSharing + Galley.Schema.V52_FeatureConferenceCalling + Galley.Schema.V53_AddRemoteConvStatus + Galley.Schema.V54_TeamFeatureSelfDeletingMessages + Galley.Schema.V55_SelfDeletingMessagesLockStatus + Galley.Schema.V56_GuestLinksTeamFeatureStatus + Galley.Schema.V57_GuestLinksLockStatus + Galley.Schema.V58_ConversationAccessRoleV2 + Galley.Schema.V59_FileSharingLockStatus + Galley.Schema.V60_TeamFeatureSndFactorPasswordChallenge + Galley.Schema.V61_MLSConversation + Galley.Schema.V62_TeamFeatureSearchVisibilityInbound + Galley.Schema.V63_MLSConversationClients + Galley.Schema.V64_Epoch + Galley.Schema.V65_MLSRemoteClients + Galley.Schema.V66_AddSplashScreen + Galley.Schema.V67_MLSFeature + Galley.Schema.V68_MLSCommitLock + Galley.Schema.V69_MLSProposal + Galley.Schema.V70_MLSCipherSuite + Galley.Schema.V71_MemberClientKeypackage + Galley.Schema.V72_DropManagedConversations + Galley.Schema.V73_MemberClientTable + Galley.Schema.V74_ExposeInvitationsToTeamAdmin + Galley.Schema.V75_MLSGroupInfo + Galley.Schema.V76_ProposalOrigin + Galley.Schema.V77_MLSGroupMemberClient + Galley.Schema.V78_TeamFeatureOutlookCalIntegration + Galley.Schema.V79_TeamFeatureMlsE2EId + Galley.Schema.V80_AddConversationCodePassword + Galley.Schema.V81_TeamFeatureMlsE2EIdUpdate + Galley.Schema.V82_RemoteDomainIndexes + Galley.Schema.V83_CreateTableTeamAdmin + Galley.Schema.V84_MLSSubconversation + Galley.Schema.V85_MLSDraft17 + Galley.Schema.V86_TeamFeatureMlsMigration + Galley.Schema.V87_TeamFeatureSupportedProtocols + Galley.Schema.V88_TruncateMLSGroupMemberClient + Galley.Schema.V89_RemoveMemberClient Galley.Types.Clients Galley.Types.ToUserRole Galley.Types.UserList Galley.Validation - other-modules: Paths_galley - hs-source-dirs: src - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - NumericUnderscores - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - - ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -fplugin=TransitiveAnns.Plugin -Wredundant-constraints - -Wunused-packages - + ghc-options: -fplugin=TransitiveAnns.Plugin + other-modules: Paths_galley + hs-source-dirs: src build-depends: - aeson >=2.0.1.0 + , aeson >=2.0.1.0 , amazonka >=1.4.5 , amazonka-sqs >=1.4.5 , amqp @@ -239,6 +326,7 @@ library , metrics-core , metrics-wai >=0.4 , mtl >=2.2 + , optparse-applicative , pem , polysemy , polysemy-wire-zoo @@ -280,62 +368,13 @@ library , wire-api-federation , x509 - default-language: GHC2021 - executable galley - main-is: exec/Main.hs - other-modules: Paths_galley - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - NumericUnderscores - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - - ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -threaded -with-rtsopts=-T -rtsopts -Wredundant-constraints - -Wunused-packages - + import: common-all + main-is: exec/Main.hs + other-modules: Paths_galley + ghc-options: -threaded -with-rtsopts=-T -rtsopts build-depends: - base + , base , galley , HsOpenSSL , imports @@ -344,10 +383,10 @@ executable galley if flag(static) ld-options: -static - default-language: GHC2021 - executable galley-integration - main-is: Main.hs + import: common-all + main-is: ../integration.hs + ghc-options: -Wno-unused-packages -- cabal-fmt: expand test/integration other-modules: @@ -369,10 +408,12 @@ executable galley-integration API.Util API.Util.TeamFeature Federation - Main + Run TestHelpers TestSetup + ghc-options: -threaded -with-rtsopts=-N -rtsopts + hs-source-dirs: test/integration hs-source-dirs: test/integration default-extensions: NoImplicitPrelude @@ -424,12 +465,13 @@ executable galley-integration -Wunused-packages build-depends: - aeson + , aeson , aeson-qq , async , base , base64-bytestring , bilge + , binary , brig-types , bytestring , bytestring-conversion @@ -440,11 +482,9 @@ executable galley-integration , cereal , containers , cookie - , cryptonite , currency-codes , data-default , data-timeout - , directory , errors , exceptions , extra >=1.3 @@ -452,9 +492,9 @@ executable galley-integration , filepath , galley , galley-types - , hex , HsOpenSSL , hspec + , http-api-data , http-client , http-client-openssl , http-client-tls @@ -489,6 +529,7 @@ executable galley-integration , streaming-commons , tagged , tasty >=0.8 + , tasty-ant-xml , tasty-cannon >=0.3.2 , tasty-hunit >=0.9 , temporary @@ -513,78 +554,27 @@ executable galley-integration , wire-api-federation , yaml - default-language: GHC2021 - executable galley-migrate-data - main-is: Main.hs + import: common-all + main-is: ../main.hs -- cabal-fmt: expand migrate-data/src other-modules: Galley.DataMigration Galley.DataMigration.Types - Main Paths_galley + Run V1_BackfillBillingTeamMembers - V2_MigrateMLSMembers V3_BackfillTeamAdmins - hs-source-dirs: migrate-data/src - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - NumericUnderscores - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - - ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -Wredundant-constraints -Wunused-packages - + hs-source-dirs: migrate-data/src build-depends: - base + , base , cassandra-util , conduit , containers , exceptions , extended - , galley , galley-types , imports , lens @@ -593,210 +583,41 @@ executable galley-migrate-data , time , tinylog , types-common - , unliftio , wire-api if flag(static) ld-options: -static - default-language: GHC2021 - executable galley-schema + import: common-all main-is: Main.hs - - -- cabal-fmt: expand schema/src - other-modules: - Main - V20 - V21 - V22 - V23 - V24 - V25 - V26 - V27 - V28 - V29 - V30 - V31 - V32 - V33 - V34 - V35 - V36 - V37 - V38_CreateTableBillingTeamMember - V39 - V40_CreateTableDataMigration - V41_TeamNotificationQueue - V42_TeamFeatureValidateSamlEmails - V43_TeamFeatureDigitalSignatures - V44_AddRemoteIdentifiers - V45_AddFederationIdMapping - V46_TeamFeatureAppLock - V47_RemoveFederationIdMapping - V48_DeleteRemoteIdentifiers - V49_ReAddRemoteIdentifiers - V50_AddLegalholdWhitelisted - V51_FeatureFileSharing - V52_FeatureConferenceCalling - V53_AddRemoteConvStatus - V54_TeamFeatureSelfDeletingMessages - V55_SelfDeletingMessagesLockStatus - V56_GuestLinksTeamFeatureStatus - V57_GuestLinksLockStatus - V58_ConversationAccessRoleV2 - V59_FileSharingLockStatus - V60_TeamFeatureSndFactorPasswordChallenge - V61_MLSConversation - V62_TeamFeatureSearchVisibilityInbound - V63_MLSConversationClients - V64_Epoch - V65_MLSRemoteClients - V66_AddSplashScreen - V67_MLSFeature - V68_MLSCommitLock - V69_MLSProposal - V70_MLSCipherSuite - V71_MemberClientKeypackage - V72_DropManagedConversations - V73_MemberClientTable - V74_ExposeInvitationsToTeamAdmin - V75_MLSGroupInfo - V76_ProposalOrigin - V77_MLSGroupMemberClient - V78_TeamFeatureOutlookCalIntegration - V79_TeamFeatureMlsE2EId - V80_AddConversationCodePassword - V81_TeamFeatureMlsE2EIdUpdate - V82_RemoteDomainIndexes - V83_CreateTableTeamAdmin - - hs-source-dirs: schema/src - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - NumericUnderscores - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - - ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -Wredundant-constraints -Wunused-packages - + hs-source-dirs: schema + default-extensions: TemplateHaskell build-depends: - base - , cassandra-util - , extended + , galley , imports - , optparse-applicative - , raw-strings-qq >=1.0 if flag(static) ld-options: -static - default-language: GHC2021 - test-suite galley-tests - type: exitcode-stdio-1.0 - main-is: Main.hs + import: common-all + type: exitcode-stdio-1.0 + main-is: ../unit.hs other-modules: Paths_galley + Run Test.Galley.API.Action Test.Galley.API.Message Test.Galley.API.One2One + Test.Galley.Intra.Push Test.Galley.Intra.User Test.Galley.Mapping - hs-source-dirs: test/unit - default-extensions: - NoImplicitPrelude - AllowAmbiguousTypes - BangPatterns - ConstraintKinds - DataKinds - DefaultSignatures - DeriveFunctor - DeriveGeneric - DeriveLift - DeriveTraversable - DerivingStrategies - DerivingVia - DuplicateRecordFields - EmptyCase - FlexibleContexts - FlexibleInstances - FunctionalDependencies - GADTs - InstanceSigs - KindSignatures - LambdaCase - MultiParamTypeClasses - MultiWayIf - NamedFieldPuns - NumericUnderscores - OverloadedRecordDot - OverloadedStrings - PackageImports - PatternSynonyms - PolyKinds - QuasiQuotes - RankNTypes - ScopedTypeVariables - StandaloneDeriving - TupleSections - TypeApplications - TypeFamilies - TypeFamilyDependencies - TypeOperators - UndecidableInstances - ViewPatterns - - ghc-options: - -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates - -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path - -threaded -with-rtsopts=-N -Wredundant-constraints - -Wunused-packages - + ghc-options: -threaded -with-rtsopts=-N + hs-source-dirs: test/unit build-depends: - base + , base , containers , extra >=1.3 , galley @@ -813,5 +634,3 @@ test-suite galley-tests , uuid-types , wire-api , wire-api-federation - - default-language: GHC2021 diff --git a/services/galley/galley.integration.yaml b/services/galley/galley.integration.yaml index 1d68ca943a3..e47801460b8 100644 --- a/services/galley/galley.integration.yaml +++ b/services/galley/galley.integration.yaml @@ -83,6 +83,15 @@ settings: verificationExpiration: 86400 acmeDiscoveryUrl: null lockStatus: unlocked + mlsMigration: + defaults: + status: enabled + config: + startTime: "2029-05-16T10:11:12.123Z" + finaliseRegardlessAfter: "2029-10-17T00:00:00.000Z" + usersThreshold: 100 + clientsThreshold: 50 + lockStatus: locked logLevel: Warn logNetStrings: false diff --git a/services/galley/migrate-data/main.hs b/services/galley/migrate-data/main.hs new file mode 100644 index 00000000000..a26473d24ee --- /dev/null +++ b/services/galley/migrate-data/main.hs @@ -0,0 +1 @@ +import Run diff --git a/services/galley/migrate-data/src/Main.hs b/services/galley/migrate-data/src/Run.hs similarity index 93% rename from services/galley/migrate-data/src/Main.hs rename to services/galley/migrate-data/src/Run.hs index 01e460fe7e7..bf85d0e97d2 100644 --- a/services/galley/migrate-data/src/Main.hs +++ b/services/galley/migrate-data/src/Run.hs @@ -15,14 +15,13 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Main where +module Run where import Galley.DataMigration import Imports import Options.Applicative import System.Logger.Extended qualified as Log import V1_BackfillBillingTeamMembers qualified -import V2_MigrateMLSMembers qualified import V3_BackfillTeamAdmins qualified main :: IO () @@ -33,7 +32,6 @@ main = do l o [ V1_BackfillBillingTeamMembers.migration, - V2_MigrateMLSMembers.migration, V3_BackfillTeamAdmins.migration ] where diff --git a/services/galley/migrate-data/src/V2_MigrateMLSMembers.hs b/services/galley/migrate-data/src/V2_MigrateMLSMembers.hs deleted file mode 100644 index 6fca23a7696..00000000000 --- a/services/galley/migrate-data/src/V2_MigrateMLSMembers.hs +++ /dev/null @@ -1,101 +0,0 @@ --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module V2_MigrateMLSMembers where - -import Cassandra -import Conduit -import Data.Conduit.Internal (zipSources) -import Data.Conduit.List qualified as C -import Data.Domain -import Data.Id -import Data.Map.Strict (lookup) -import Data.Map.Strict qualified as Map -import Galley.Cassandra.Instances () -import Galley.DataMigration.Types -import Imports hiding (lookup) -import System.Logger.Class qualified as Log -import UnliftIO (pooledMapConcurrentlyN_) -import UnliftIO.Async (pooledMapConcurrentlyN) -import Wire.API.MLS.Group -import Wire.API.MLS.KeyPackage - -migration :: Migration -migration = - Migration - { version = MigrationVersion 2, - text = "Migrating from member_client to mls_group_member_client", - action = - runConduit $ - zipSources - (C.sourceList [(1 :: Int32) ..]) - getMemberClientsFromLegacy - .| C.mapM_ - ( \(i, rows) -> do - Log.info (Log.field "Entries " (show (i * pageSize))) - let convIds = map rowConvId rows - m <- lookupGroupIds convIds - let newRows = flip mapMaybe rows $ \(conv, domain, uid, client, ref) -> - conv `lookup` m >>= \groupId -> pure (groupId, domain, uid, client, ref) - insertMemberClients newRows - ) - } - -rowConvId :: (ConvId, Domain, UserId, ClientId, KeyPackageRef) -> ConvId -rowConvId (conv, _, _, _, _) = conv - -pageSize :: Int32 -pageSize = 1000 - -getMemberClientsFromLegacy :: MonadClient m => ConduitM () [(ConvId, Domain, UserId, ClientId, KeyPackageRef)] m () -getMemberClientsFromLegacy = paginateC cql (paramsP LocalQuorum () pageSize) x5 - where - cql :: PrepQuery R () (ConvId, Domain, UserId, ClientId, KeyPackageRef) - cql = "SELECT conv, user_domain, user, client, key_package_ref from member_client" - -lookupGroupIds :: [ConvId] -> MigrationActionT IO (Map ConvId GroupId) -lookupGroupIds convIds = do - rows <- pooledMapConcurrentlyN 8 (\convId -> retry x5 (query1 cql (params LocalQuorum (Identity convId)))) convIds - rows' <- - rows - & mapM - ( \case - (Just (c, mg)) -> do - case mg of - Nothing -> do - Log.warn (Log.msg ("No group found for conv " <> show c)) - pure Nothing - Just g -> pure (Just (c, g)) - Nothing -> do - Log.warn (Log.msg ("Conversation is missing for entry" :: Text)) - pure Nothing - ) - - rows' - & catMaybes - & Map.fromList - & pure - where - cql :: PrepQuery R (Identity ConvId) (ConvId, Maybe GroupId) - cql = "SELECT conv, group_id from conversation where conv = ?" - -insertMemberClients :: (MonadUnliftIO m, MonadClient m) => [(GroupId, Domain, UserId, ClientId, KeyPackageRef)] -> m () -insertMemberClients rows = do - pooledMapConcurrentlyN_ 8 (\row -> retry x5 (write cql (params LocalQuorum row))) rows - where - cql :: PrepQuery W (GroupId, Domain, UserId, ClientId, KeyPackageRef) () - cql = "INSERT INTO mls_group_member_client (group_id, user_domain, user, client, key_package_ref) VALUES (?, ?, ?, ?, ?)" diff --git a/services/galley/schema/Main.hs b/services/galley/schema/Main.hs new file mode 100644 index 00000000000..6a175771138 --- /dev/null +++ b/services/galley/schema/Main.hs @@ -0,0 +1,5 @@ +import Galley.Schema.Run qualified as Run +import Imports + +main :: IO () +main = Run.main diff --git a/services/galley/schema/src/Main.hs b/services/galley/schema/src/Main.hs deleted file mode 100644 index f1703b43f80..00000000000 --- a/services/galley/schema/src/Main.hs +++ /dev/null @@ -1,174 +0,0 @@ --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Main where - -import Cassandra.Schema -import Control.Exception (finally) -import Imports -import Options.Applicative -import System.Logger.Extended qualified as Log -import V20 qualified -import V21 qualified -import V22 qualified -import V23 qualified -import V24 qualified -import V25 qualified -import V26 qualified -import V27 qualified -import V28 qualified -import V29 qualified -import V30 qualified -import V31 qualified -import V32 qualified -import V33 qualified -import V34 qualified -import V35 qualified -import V36 qualified -import V37 qualified -import V38_CreateTableBillingTeamMember qualified -import V39 qualified -import V40_CreateTableDataMigration qualified -import V41_TeamNotificationQueue qualified -import V42_TeamFeatureValidateSamlEmails qualified -import V43_TeamFeatureDigitalSignatures qualified -import V44_AddRemoteIdentifiers qualified -import V45_AddFederationIdMapping qualified -import V46_TeamFeatureAppLock qualified -import V47_RemoveFederationIdMapping qualified -import V48_DeleteRemoteIdentifiers qualified -import V49_ReAddRemoteIdentifiers qualified -import V50_AddLegalholdWhitelisted qualified -import V51_FeatureFileSharing qualified -import V52_FeatureConferenceCalling qualified -import V53_AddRemoteConvStatus qualified -import V54_TeamFeatureSelfDeletingMessages qualified -import V55_SelfDeletingMessagesLockStatus qualified -import V56_GuestLinksTeamFeatureStatus qualified -import V57_GuestLinksLockStatus qualified -import V58_ConversationAccessRoleV2 qualified -import V59_FileSharingLockStatus qualified -import V60_TeamFeatureSndFactorPasswordChallenge qualified -import V61_MLSConversation qualified -import V62_TeamFeatureSearchVisibilityInbound qualified -import V63_MLSConversationClients qualified -import V64_Epoch qualified -import V65_MLSRemoteClients qualified -import V66_AddSplashScreen qualified -import V67_MLSFeature qualified -import V68_MLSCommitLock qualified -import V69_MLSProposal qualified -import V70_MLSCipherSuite qualified -import V71_MemberClientKeypackage qualified -import V72_DropManagedConversations qualified -import V73_MemberClientTable qualified -import V74_ExposeInvitationsToTeamAdmin qualified -import V75_MLSGroupInfo qualified -import V76_ProposalOrigin qualified -import V77_MLSGroupMemberClient qualified -import V78_TeamFeatureOutlookCalIntegration qualified -import V79_TeamFeatureMlsE2EId qualified -import V80_AddConversationCodePassword qualified -import V81_TeamFeatureMlsE2EIdUpdate qualified -import V82_RemoteDomainIndexes qualified -import V83_CreateTableTeamAdmin qualified - -main :: IO () -main = do - o <- execParser (info (helper <*> migrationOptsParser) desc) - l <- Log.mkLogger' - migrateSchema - l - o - [ V20.migration, - V21.migration, - V22.migration, - V23.migration, - V24.migration, - V25.migration, - V26.migration, - V27.migration, - V28.migration, - V29.migration, - V30.migration, - V31.migration, - V32.migration, - V33.migration, - V34.migration, - V35.migration, - V36.migration, - V37.migration, - V38_CreateTableBillingTeamMember.migration, - V39.migration, - V40_CreateTableDataMigration.migration, - V41_TeamNotificationQueue.migration, - V42_TeamFeatureValidateSamlEmails.migration, - V43_TeamFeatureDigitalSignatures.migration, - V44_AddRemoteIdentifiers.migration, - V45_AddFederationIdMapping.migration, - V46_TeamFeatureAppLock.migration, - V47_RemoveFederationIdMapping.migration, - V48_DeleteRemoteIdentifiers.migration, - V49_ReAddRemoteIdentifiers.migration, - V50_AddLegalholdWhitelisted.migration, - V51_FeatureFileSharing.migration, - V52_FeatureConferenceCalling.migration, - V53_AddRemoteConvStatus.migration, - V54_TeamFeatureSelfDeletingMessages.migration, - V55_SelfDeletingMessagesLockStatus.migration, - V56_GuestLinksTeamFeatureStatus.migration, - V57_GuestLinksLockStatus.migration, - V58_ConversationAccessRoleV2.migration, - V59_FileSharingLockStatus.migration, - V60_TeamFeatureSndFactorPasswordChallenge.migration, - V61_MLSConversation.migration, - V62_TeamFeatureSearchVisibilityInbound.migration, - V63_MLSConversationClients.migration, - V64_Epoch.migration, - V65_MLSRemoteClients.migration, - V66_AddSplashScreen.migration, - V67_MLSFeature.migration, - V68_MLSCommitLock.migration, - V69_MLSProposal.migration, - V70_MLSCipherSuite.migration, - V71_MemberClientKeypackage.migration, - V72_DropManagedConversations.migration, - V73_MemberClientTable.migration, - V74_ExposeInvitationsToTeamAdmin.migration, - V75_MLSGroupInfo.migration, - V76_ProposalOrigin.migration, - V77_MLSGroupMemberClient.migration, - V78_TeamFeatureOutlookCalIntegration.migration, - V79_TeamFeatureMlsE2EId.migration, - V80_AddConversationCodePassword.migration, - V81_TeamFeatureMlsE2EIdUpdate.migration, - V82_RemoteDomainIndexes.migration, - V83_CreateTableTeamAdmin.migration - -- When adding migrations here, don't forget to update - -- 'schemaVersion' in Galley.Cassandra - -- (see also docs/developer/cassandra-interaction.md) - -- - -- FUTUREWORK: once #1726 has made its way to master/production, - -- the 'message' field in connections table can be dropped. - -- See also https://github.com/wireapp/wire-server/pull/1747/files - -- for an explanation - -- FUTUREWORK: once #1751 has made its way to master/production, - -- the 'otr_muted' field in the member table can be dropped. - ] - `finally` Log.close l - where - desc = header "Galley Cassandra Schema" <> fullDesc diff --git a/services/galley/src/Galley/API/Action.hs b/services/galley/src/Galley/API/Action.hs index 6e577eb0320..d8d5c530cdf 100644 --- a/services/galley/src/Galley/API/Action.hs +++ b/services/galley/src/Galley/API/Action.hs @@ -59,17 +59,23 @@ import Data.List.NonEmpty qualified as NE import Data.Map qualified as Map import Data.Misc import Data.Qualified +import Data.Set ((\\)) import Data.Set qualified as Set import Data.Singletons import Data.Time.Clock import Galley.API.Error +import Galley.API.MLS.Conversation +import Galley.API.MLS.Migration import Galley.API.MLS.Removal +import Galley.API.Teams.Features.Get import Galley.API.Util import Galley.Data.Conversation import Galley.Data.Conversation qualified as Data +import Galley.Data.Conversation.Types import Galley.Data.Scope (Scope (ReusableCode)) import Galley.Data.Services import Galley.Effects +import Galley.Effects.BackendNotificationQueueAccess import Galley.Effects.BotAccess qualified as E import Galley.Effects.BrigAccess qualified as E import Galley.Effects.CodeStore qualified as E @@ -78,7 +84,8 @@ import Galley.Effects.FederatorAccess qualified as E import Galley.Effects.FireAndForget qualified as E import Galley.Effects.GundeckAccess import Galley.Effects.MemberStore qualified as E -import Galley.Effects.ProposalStore +import Galley.Effects.ProposalStore qualified as E +import Galley.Effects.SubConversationStore qualified as E import Galley.Effects.TeamStore qualified as E import Galley.Env (Env) import Galley.Intra.Push @@ -86,7 +93,8 @@ import Galley.Options import Galley.Types.Conversations.Members import Galley.Types.UserList import Galley.Validation -import Imports +import Imports hiding ((\\)) +import Network.AMQP qualified as Q import Polysemy import Polysemy.Error import Polysemy.Input @@ -108,7 +116,9 @@ import Wire.API.Federation.API.Galley import Wire.API.Federation.API.Galley qualified as F import Wire.API.Federation.Error import Wire.API.FederationStatus (FederationStatus (FullyConnected, NotConnectedDomains), RemoteDomains (..)) +import Wire.API.MLS.CipherSuite import Wire.API.Routes.Internal.Brig.Connection +import Wire.API.Team.Feature import Wire.API.Team.LegalHold import Wire.API.Team.Member import Wire.API.User qualified as User @@ -140,6 +150,7 @@ type family HasConversationActionEffects (tag :: ConversationActionTag) r :: Con Member LegalHoldStore r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TeamStore r, Member TinyLog r, Member ConversationStore r, @@ -155,10 +166,20 @@ type family HasConversationActionEffects (tag :: ConversationActionTag) r :: Con Member (Input UTCTime) r, Member (Input Env) r, Member ProposalStore r, + Member SubConversationStore r, Member TinyLog r ) HasConversationActionEffects 'ConversationRemoveMembersTag r = ( Member MemberStore r, + Member (Error NoChanges) r, + Member SubConversationStore r, + Member ProposalStore r, + Member (Input Env) r, + Member (Input UTCTime) r, + Member ExternalAccess r, + Member FederatorAccess r, + Member GundeckAccess r, + Member (Error InternalError) r, Member TinyLog r, Member (Error NoChanges) r ) @@ -167,11 +188,16 @@ type family HasConversationActionEffects (tag :: ConversationActionTag) r :: Con Member (ErrorS 'ConvMemberNotFound) r ) HasConversationActionEffects 'ConversationDeleteTag r = - ( Member (Error FederationError) r, - Member (ErrorS 'NotATeamMember) r, + ( Member BrigAccess r, Member CodeStore r, - Member TeamStore r, - Member ConversationStore r + Member ConversationStore r, + Member (Error FederationError) r, + Member (ErrorS 'NotATeamMember) r, + Member FederatorAccess r, + Member MemberStore r, + Member ProposalStore r, + Member SubConversationStore r, + Member TeamStore r ) HasConversationActionEffects 'ConversationRenameTag r = ( Member (Error InvalidInput) r, @@ -196,7 +222,8 @@ type family HasConversationActionEffects (tag :: ConversationActionTag) r :: Con Member TeamStore r, Member TinyLog r, Member (Input UTCTime) r, - Member ConversationStore r + Member ConversationStore r, + Member SubConversationStore r ) HasConversationActionEffects 'ConversationMessageTimerUpdateTag r = ( Member ConversationStore r, @@ -206,6 +233,28 @@ type family HasConversationActionEffects (tag :: ConversationActionTag) r :: Con ( Member ConversationStore r, Member (Error NoChanges) r ) + HasConversationActionEffects 'ConversationUpdateProtocolTag r = + ( Member ConversationStore r, + Member (ErrorS 'ConvInvalidProtocolTransition) r, + Member (ErrorS OperationDenied) r, + Member (ErrorS 'MLSMigrationCriteriaNotSatisfied) r, + Member (Error NoChanges) r, + Member (ErrorS 'NotATeamMember) r, + Member (ErrorS 'TeamNotFound) r, + Member BrigAccess r, + Member ExternalAccess r, + Member FederatorAccess r, + Member GundeckAccess r, + Member (Input Env) r, + Member (Input Opts) r, + Member (Input UTCTime) r, + Member MemberStore r, + Member ProposalStore r, + Member SubConversationStore r, + Member TeamFeatureStore r, + Member TeamStore r, + Member TinyLog r + ) type family HasConversationActionGalleyErrors (tag :: ConversationActionTag) :: EffectRow where HasConversationActionGalleyErrors 'ConversationJoinTag = @@ -263,6 +312,16 @@ type family HasConversationActionGalleyErrors (tag :: ConversationActionTag) :: ErrorS 'InvalidTargetAccess, ErrorS 'ConvNotFound ] + HasConversationActionGalleyErrors 'ConversationUpdateProtocolTag = + '[ ErrorS ('ActionDenied 'LeaveConversation), + ErrorS 'InvalidOperation, + ErrorS 'ConvNotFound, + ErrorS 'ConvInvalidProtocolTransition, + ErrorS 'MLSMigrationCriteriaNotSatisfied, + ErrorS 'NotATeamMember, + ErrorS OperationDenied, + ErrorS 'TeamNotFound + ] checkFederationStatus :: ( Member (Error UnreachableBackends) r, @@ -348,6 +407,7 @@ ensureAllowed tag loc action conv origUser = do performAction :: forall tag r. ( HasConversationActionEffects tag r, + Member BackendNotificationQueueAccess r, Member (Error FederationError) r ) => Sing tag -> @@ -356,49 +416,48 @@ performAction :: ConversationAction tag -> Sem r (BotsAndMembers, ConversationAction tag) performAction tag origUser lconv action = do - let lcnv = fmap convId lconv + let lcnv = fmap (.convId) lconv conv = tUnqualified lconv case tag of SConversationJoinTag -> do performConversationJoin origUser lconv action SConversationLeaveTag -> do let victims = [origUser] - E.deleteMembers (tUnqualified lcnv) (toUserList lconv victims) - -- update in-memory view of the conversation - let lconv' = - lconv <&> \c -> - foldQualified - lconv - ( \lu -> - c - { convLocalMembers = - filter (\lm -> lmId lm /= tUnqualified lu) (convLocalMembers c) - } - ) - ( \ru -> - c - { convRemoteMembers = - filter (\rm -> rmId rm /= ru) (convRemoteMembers c) - } - ) - origUser + lconv' <- traverse (convDeleteMembers (toUserList lconv victims)) lconv + -- send remove proposals in the MLS case traverse_ (removeUser lconv') victims pure (mempty, action) SConversationRemoveMembersTag -> do let presentVictims = filter (isConvMemberL lconv) (toList action) when (null presentVictims) noChanges - E.deleteMembers (tUnqualified lcnv) (toUserList lconv presentVictims) + traverse_ (convDeleteMembers (toUserList lconv presentVictims)) lconv + -- send remove proposals in the MLS case + traverse_ (removeUser lconv) presentVictims pure (mempty, action) -- FUTUREWORK: should we return the filtered action here? SConversationMemberUpdateTag -> do void $ ensureOtherMember lconv (cmuTarget action) conv E.setOtherMember lcnv (cmuTarget action) (cmuUpdate action) pure (mempty, action) SConversationDeleteTag -> do + let deleteGroup groupId = do + E.removeAllMLSClients groupId + E.deleteAllProposals groupId + + let cid = conv.convId + for_ (conv & mlsMetadata <&> cnvmlsGroupId . fst) $ \gidParent -> do + sconvs <- E.listSubConversations cid + for_ (Map.assocs sconvs) $ \(subid, mlsData) -> do + let gidSub = cnvmlsGroupId mlsData + E.deleteSubConversation cid subid + deleteGroup gidSub + deleteGroup gidParent + key <- E.makeKey (tUnqualified lcnv) E.deleteCode key ReusableCode case convTeam conv of Nothing -> E.deleteConversation (tUnqualified lcnv) Just tid -> E.deleteTeamConversation tid (tUnqualified lcnv) + pure (mempty, action) SConversationRenameTag -> do cn <- rangeChecked (cupName action) @@ -415,10 +474,32 @@ performAction tag origUser lconv action = do SConversationAccessDataTag -> do (bm, act) <- performConversationAccessData origUser lconv action pure (bm, act) + SConversationUpdateProtocolTag -> do + case (protocolTag (convProtocol (tUnqualified lconv)), action, convTeam (tUnqualified lconv)) of + (ProtocolProteusTag, ProtocolMixedTag, Just _) -> do + E.updateToMixedProtocol lcnv (convType (tUnqualified lconv)) MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + pure (mempty, action) + (ProtocolMixedTag, ProtocolMLSTag, Just tid) -> do + mig <- getFeatureStatus @MlsMigrationConfig DontDoAuth tid + now <- input + mlsConv <- mkMLSConversation conv >>= noteS @'ConvInvalidProtocolTransition + ok <- checkMigrationCriteria now mlsConv mig + unless ok $ throwS @'MLSMigrationCriteriaNotSatisfied + removeExtraneousClients origUser lconv + E.updateToMLSProtocol lcnv + pure (mempty, action) + (ProtocolProteusTag, ProtocolProteusTag, _) -> + noChanges + (ProtocolMixedTag, ProtocolMixedTag, _) -> + noChanges + (ProtocolMLSTag, ProtocolMLSTag, _) -> + noChanges + (_, _, _) -> throwS @'ConvInvalidProtocolTransition performConversationJoin :: forall r. - ( HasConversationActionEffects 'ConversationJoinTag r + ( HasConversationActionEffects 'ConversationJoinTag r, + Member BackendNotificationQueueAccess r ) => Qualified UserId -> Local Conversation -> @@ -428,7 +509,7 @@ performConversationJoin qusr lconv (ConversationJoin invited role) = do let newMembers = ulNewMembers lconv conv . toUserList lconv $ invited lusr <- ensureLocal lconv qusr - ensureMemberLimit (toList (convLocalMembers conv)) newMembers + ensureMemberLimit (convProtocolTag conv) (toList (convLocalMembers conv)) newMembers ensureAccess conv InviteAccess checkLocals lusr (convTeam conv) (ulLocals newMembers) checkRemotes lusr (ulRemotes newMembers) @@ -436,21 +517,23 @@ performConversationJoin qusr lconv (ConversationJoin invited role) = do checkLHPolicyConflictsRemote (FutureWork (ulRemotes newMembers)) checkRemoteBackendsConnected lusr - addMembersToLocalConversation (fmap convId lconv) newMembers role + addMembersToLocalConversation (fmap (.convId) lconv) newMembers role where checkRemoteBackendsConnected :: Local UserId -> Sem r () checkRemoteBackendsConnected lusr = do - let remoteDomains = tDomain <$> snd (partitionQualified lusr $ NE.toList invited) - -- Note: - -- - -- In some cases, this federation status check might be redundant (for - -- example if there are only local users in the conversation). However, - -- it is important that we attempt to connect to the backends of the new - -- users here, because that results in the correct error when those - -- backends are not reachable. - checkFederationStatus (RemoteDomains $ Set.fromList remoteDomains) + let invitedRemoteUsers = filter ((/= tDomain lconv) . tDomain) $ snd (partitionQualified lusr $ NE.toList invited) + invitedRemoteDomains = Set.fromList $ tDomain <$> invitedRemoteUsers + existingRemoteDomains = Set.fromList $ tDomain . rmId <$> convRemoteMembers (tUnqualified lconv) + allInvitedAlreadyInConversation = null $ invitedRemoteDomains \\ existingRemoteDomains + + if not allInvitedAlreadyInConversation + then checkFederationStatus (RemoteDomains (invitedRemoteDomains <> existingRemoteDomains)) + else -- even if there are no new remotes, we still need to check they are reachable + void . (ensureNoUnreachableBackends =<<) $ + E.runFederatedConcurrentlyEither @_ @'Brig invitedRemoteUsers $ \_ -> + pure () conv :: Data.Conversation conv = tUnqualified lconv @@ -462,14 +545,14 @@ performConversationJoin qusr lconv (ConversationJoin invited role) = do Sem r () checkLocals lusr (Just tid) newUsers = do tms <- - Map.fromList . map (view userId &&& id) + Map.fromList . map (view Wire.API.Team.Member.userId &&& Imports.id) <$> E.selectTeamMembers tid newUsers - let userMembershipMap = map (id &&& flip Map.lookup tms) newUsers + let userMembershipMap = map (Imports.id &&& flip Map.lookup tms) newUsers ensureAccessRole (convAccessRoles conv) userMembershipMap - ensureConnectedOrSameTeam lusr newUsers + ensureConnectedToLocalsOrSameTeam lusr newUsers checkLocals lusr Nothing newUsers = do ensureAccessRole (convAccessRoles conv) (zip newUsers $ repeat Nothing) - ensureConnectedOrSameTeam lusr newUsers + ensureConnectedToLocalsOrSameTeam lusr newUsers checkRemotes :: Local UserId -> @@ -526,7 +609,8 @@ performConversationJoin qusr lconv (ConversationJoin invited role) = do performConversationAccessData :: ( HasConversationActionEffects 'ConversationAccessDataTag r, - Member (Error FederationError) r + Member (Error FederationError) r, + Member BackendNotificationQueueAccess r ) => Qualified UserId -> Local Conversation -> @@ -566,7 +650,7 @@ performConversationAccessData qusr lconv action = do pure (mempty, action) where - lcnv = fmap convId lconv + lcnv = fmap (.convId) lconv conv = tUnqualified lconv maybeRemoveBots :: BotsAndMembers -> Sem r BotsAndMembers @@ -612,13 +696,13 @@ data LocalConversationUpdate = LocalConversationUpdate updateLocalConversation :: forall tag r. - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (Error FederationError) r, Member (ErrorS ('ActionDenied (ConversationActionPermission tag))) r, Member (ErrorS 'InvalidOperation) r, Member (ErrorS 'ConvNotFound) r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, Member (Input UTCTime) r, Member (Logger (Log.Msg -> Log.Msg)) r, @@ -640,7 +724,7 @@ updateLocalConversation lcnv qusr con action = do unless (protocolValidAction (convProtocol conv) (fromSing tag)) $ throwS @'InvalidOperation - -- perform all authorisation checks and, if successful, the update itself + -- perform all authorisation checks and, if successful, then update itself updateLocalConversationUnchecked @tag (qualifyAs lcnv conv) qusr con action -- | Similar to 'updateLocalConversationWithLocalUser', but takes a @@ -653,12 +737,12 @@ updateLocalConversation lcnv qusr con action = do updateLocalConversationUnchecked :: forall tag r. ( SingI tag, + Member BackendNotificationQueueAccess r, Member (Error FederationError) r, Member (ErrorS ('ActionDenied (ConversationActionPermission tag))) r, Member (ErrorS 'ConvNotFound) r, Member (ErrorS 'InvalidOperation) r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, Member (Input UTCTime) r, Member (Logger (Log.Msg -> Log.Msg)) r, @@ -671,7 +755,7 @@ updateLocalConversationUnchecked :: Sem r LocalConversationUpdate updateLocalConversationUnchecked lconv qusr con action = do let tag = sing @tag - lcnv = fmap convId lconv + lcnv = fmap (.convId) lconv conv = tUnqualified lconv -- retrieve member @@ -701,6 +785,7 @@ updateLocalConversationUserUnchecked :: forall tag r. ( SingI tag, HasConversationActionEffects tag r, + Member BackendNotificationQueueAccess r, Member (Error FederationError) r ) => Local Conversation -> @@ -759,7 +844,7 @@ addMembersToLocalConversation lcnv users role = do notifyConversationAction :: forall tag r. - ( Member FederatorAccess r, + ( Member BackendNotificationQueueAccess r, Member ExternalAccess r, Member GundeckAccess r, Member (Input UTCTime) r, @@ -775,36 +860,31 @@ notifyConversationAction :: Sem r LocalConversationUpdate notifyConversationAction tag quid notifyOrigDomain con lconv targets action = do now <- input - let lcnv = fmap convId lconv + let lcnv = fmap (.convId) lconv e = conversationActionToEvent tag now quid (tUntagged lcnv) Nothing action - - let mkUpdate uids = + mkUpdate uids = ConversationUpdate now quid (tUnqualified lcnv) uids (SomeConversationAction tag action) - - update <- do - updates <- - E.runFederatedConcurrentlyEither (toList (bmRemotes targets)) $ - \ruids -> do - let update = mkUpdate (tUnqualified ruids) - -- if notifyOrigDomain is false, filter out user from quid's domain, - -- because quid's backend will update local state and notify its users - -- itself using the ConversationUpdate returned by this function - if notifyOrigDomain || tDomain ruids /= qDomain quid - then fedClient @'Galley @"on-conversation-updated" update $> Nothing - else pure (Just update) - let f = fromMaybe (mkUpdate []) . asum . map tUnqualified . rights - update = f updates - failedUpdates = lefts updates - for_ failedUpdates $ - logError - "on-conversation-updated" - "An error occurred while communicating with federated server: " - pure update + handleError :: FederationError -> Sem r (Maybe ConversationUpdate) + handleError fedErr = + logRemoteNotificationError @"on-conversation-updated" fedErr $> Nothing + + update <- + fmap (fromMaybe (mkUpdate [])) + . (either handleError (pure . asum . map tUnqualified)) + <=< enqueueNotificationsConcurrently Q.Persistent (toList (bmRemotes targets)) + $ \ruids -> do + let update = mkUpdate (tUnqualified ruids) + -- if notifyOrigDomain is false, filter out user from quid's domain, + -- because quid's backend will update local state and notify its users + -- itself using the ConversationUpdate returned by this function + if notifyOrigDomain || tDomain ruids /= qDomain quid + then fedQueueClient @'OnConversationUpdatedTag update $> Nothing + else pure (Just update) -- notify local participants and bots pushConversationEvent con e (qualifyAs lcnv (bmLocals targets)) (bmBots targets) @@ -812,11 +892,6 @@ notifyConversationAction tag quid notifyOrigDomain con lconv targets action = do -- return both the event and the 'ConversationUpdate' structure corresponding -- to the originating domain (if it is remote) pure $ LocalConversationUpdate e update - where - logError :: (Show a) => String -> String -> (a, FederationError) -> Sem r () - logError field msg e = - P.warn $ - Log.field "federation call" field . Log.msg (msg <> show e) -- | Update the local database with information on conversation members joining -- or leaving. Finally, push out notifications to local users. @@ -882,6 +957,7 @@ updateLocalStateOfRemoteConv rcu con = do SConversationMessageTimerUpdateTag -> pure (Just sca, []) SConversationReceiptModeUpdateTag -> pure (Just sca, []) SConversationAccessDataTag -> pure (Just sca, []) + SConversationUpdateProtocolTag -> pure (Just sca, []) unless allUsersArePresent $ P.warn $ @@ -912,7 +988,15 @@ addLocalUsersToRemoteConv :: addLocalUsersToRemoteConv remoteConvId qAdder localUsers = do connStatus <- E.getConnections localUsers (Just [qAdder]) (Just Accepted) let localUserIdsSet = Set.fromList localUsers - connected = Set.fromList $ fmap csv2From connStatus + adder = qUnqualified qAdder + -- If alice@A creates a 1-1 conversation on B, it can appear as if alice is + -- adding herself to a remote conversation. To make sure this is allowed, we + -- always consider a user as connected to themself. + connected = + Set.fromList (fmap csv2From connStatus) + <> if Set.member adder localUserIdsSet + then Set.singleton adder + else mempty unconnected = Set.difference localUserIdsSet connected connectedList = Set.toList connected @@ -935,7 +1019,8 @@ addLocalUsersToRemoteConv remoteConvId qAdder localUsers = do -- leave, but then sends notifications as if the user was removed by someone -- else. kickMember :: - ( Member (Error FederationError) r, + ( Member BackendNotificationQueueAccess r, + Member (Error FederationError) r, Member (Error InternalError) r, Member ExternalAccess r, Member FederatorAccess r, @@ -944,6 +1029,7 @@ kickMember :: Member (Input UTCTime) r, Member (Input Env) r, Member MemberStore r, + Member SubConversationStore r, Member TinyLog r ) => Qualified UserId -> @@ -988,11 +1074,11 @@ notifyTypingIndicator conv qusr mcon ts = do let (remoteMemsOrig, remoteMemsOther) = List.partition ((origDomain ==) . tDomain . rmId) (Data.convRemoteMembers conv) tdu users = TypingDataUpdated - { tudTime = now, - tudOrigUserId = qusr, - tudConvId = Data.convId conv, - tudUsersInConv = users, - tudTypingStatus = ts + { time = now, + origUserId = qusr, + convId = Data.convId conv, + usersInConv = users, + typingStatus = ts } void $ E.runFederatedConcurrentlyEither (fmap rmId remoteMemsOther) $ \rmems -> do diff --git a/services/galley/src/Galley/API/Clients.hs b/services/galley/src/Galley/API/Clients.hs index 1b14df11a48..044447c488d 100644 --- a/services/galley/src/Galley/API/Clients.hs +++ b/services/galley/src/Galley/API/Clients.hs @@ -36,7 +36,6 @@ import Galley.Effects.BackendNotificationQueueAccess import Galley.Effects.BrigAccess qualified as E import Galley.Effects.ClientStore qualified as E import Galley.Effects.ConversationStore (getConversation) -import Galley.Effects.ProposalStore (ProposalStore) import Galley.Env import Galley.Types.Clients (clientIds, fromUserClients) import Imports @@ -85,6 +84,9 @@ addClientH (usr ::: clt) = do E.createClient usr clt pure empty +-- | Remove a client from conversations it is part of according to the +-- conversation protocol (Proteus or MLS). In addition, remove the client from +-- the "clients" table in Galley. rmClientH :: forall p1 r. ( p1 ~ CassandraPaging, @@ -102,6 +104,7 @@ rmClientH :: Member MemberStore r, Member (Error InternalError) r, Member ProposalStore r, + Member SubConversationStore r, Member P.TinyLog r ) ) => @@ -134,5 +137,5 @@ rmClientH (usr ::: cid) = do removeRemoteMLSClients :: Range 1 1000 [Remote ConvId] -> Sem r () removeRemoteMLSClients convIds = do for_ (bucketRemote (fromRange convIds)) $ \remoteConvs -> - let rpc = void $ fedQueueClient @'Galley @"on-client-removed" (ClientRemovedRequest usr cid (tUnqualified remoteConvs)) + let rpc = void $ fedQueueClient @'OnClientRemovedTag (ClientRemovedRequest usr cid (tUnqualified remoteConvs)) in enqueueNotification remoteConvs Q.Persistent rpc diff --git a/services/galley/src/Galley/API/Create.hs b/services/galley/src/Galley/API/Create.hs index c786ae1ab9a..31ff4dece0e 100644 --- a/services/galley/src/Galley/API/Create.hs +++ b/services/galley/src/Galley/API/Create.hs @@ -43,7 +43,6 @@ import Data.UUID.Tagged qualified as U import Galley.API.Action import Galley.API.Error import Galley.API.MLS -import Galley.API.MLS.KeyPackage (nullKeyPackageRef) import Galley.API.MLS.Keys (getMLSRemovalKey) import Galley.API.Mapping import Galley.API.One2One @@ -52,6 +51,7 @@ import Galley.App (Env) import Galley.Data.Conversation qualified as Data import Galley.Data.Conversation.Types import Galley.Effects +import Galley.Effects.BrigAccess import Galley.Effects.ConversationStore qualified as E import Galley.Effects.FederatorAccess qualified as E import Galley.Effects.GundeckAccess qualified as E @@ -70,7 +70,6 @@ import Polysemy.Error import Polysemy.Input import Polysemy.TinyLog qualified as P import Wire.API.Conversation hiding (Conversation, Member) -import Wire.API.Conversation.Protocol import Wire.API.Error import Wire.API.Error.Galley import Wire.API.Event.Conversation @@ -82,6 +81,7 @@ import Wire.API.Team import Wire.API.Team.LegalHold (LegalholdProtectee (LegalholdPlusFederationNotImplemented)) import Wire.API.Team.Member import Wire.API.Team.Permission hiding (self) +import Wire.API.User ---------------------------------------------------------------------------- -- Group conversations @@ -91,7 +91,6 @@ import Wire.API.Team.Permission hiding (self) createGroupConversationUpToV3 :: ( Member BrigAccess r, Member ConversationStore r, - Member MemberStore r, Member (ErrorS 'ConvAccessDenied) r, Member (Error FederationError) r, Member (Error InternalError) r, @@ -101,7 +100,6 @@ createGroupConversationUpToV3 :: Member (ErrorS 'NotConnected) r, Member (ErrorS 'MLSNotEnabled) r, Member (ErrorS 'MLSNonEmptyMemberList) r, - Member (ErrorS 'MLSMissingSenderClient) r, Member (ErrorS 'MissingLegalholdConsent) r, Member (Error UnreachableBackendsLegacy) r, Member FederatorAccess r, @@ -114,16 +112,14 @@ createGroupConversationUpToV3 :: Member P.TinyLog r ) => Local UserId -> - Maybe ClientId -> Maybe ConnId -> NewConv -> Sem r ConversationResponse -createGroupConversationUpToV3 lusr mCreatorClient conn newConv = mapError UnreachableBackendsLegacy $ +createGroupConversationUpToV3 lusr conn newConv = mapError UnreachableBackendsLegacy $ do conv <- createGroupConversationGeneric lusr - mCreatorClient conn newConv conversationCreated lusr conv @@ -133,7 +129,6 @@ createGroupConversationUpToV3 lusr mCreatorClient conn newConv = mapError Unreac createGroupConversation :: ( Member BrigAccess r, Member ConversationStore r, - Member MemberStore r, Member (ErrorS 'ConvAccessDenied) r, Member (Error FederationError) r, Member (Error InternalError) r, @@ -144,7 +139,6 @@ createGroupConversation :: Member (ErrorS 'NotConnected) r, Member (ErrorS 'MLSNotEnabled) r, Member (ErrorS 'MLSNonEmptyMemberList) r, - Member (ErrorS 'MLSMissingSenderClient) r, Member (ErrorS 'MissingLegalholdConsent) r, Member (Error UnreachableBackends) r, Member FederatorAccess r, @@ -157,17 +151,15 @@ createGroupConversation :: Member P.TinyLog r ) => Local UserId -> - Maybe ClientId -> Maybe ConnId -> NewConv -> Sem r CreateGroupConversationResponse -createGroupConversation lusr mCreatorClient conn newConv = do +createGroupConversation lusr conn newConv = do let remoteDomains = tDomain <$> snd (partitionQualified lusr $ newConv.newConvQualifiedUsers) checkFederationStatus (RemoteDomains $ Set.fromList remoteDomains) cnv <- createGroupConversationGeneric lusr - mCreatorClient conn newConv conv <- conversationView lusr cnv @@ -177,7 +169,6 @@ createGroupConversation lusr mCreatorClient conn newConv = do createGroupConversationGeneric :: ( Member BrigAccess r, Member ConversationStore r, - Member MemberStore r, Member (ErrorS 'ConvAccessDenied) r, Member (Error FederationError) r, Member (Error InternalError) r, @@ -187,7 +178,6 @@ createGroupConversationGeneric :: Member (ErrorS 'NotConnected) r, Member (ErrorS 'MLSNotEnabled) r, Member (ErrorS 'MLSNonEmptyMemberList) r, - Member (ErrorS 'MLSMissingSenderClient) r, Member (ErrorS 'MissingLegalholdConsent) r, Member (Error UnreachableBackends) r, Member FederatorAccess r, @@ -200,17 +190,16 @@ createGroupConversationGeneric :: Member P.TinyLog r ) => Local UserId -> - Maybe ClientId -> Maybe ConnId -> NewConv -> Sem r Conversation -createGroupConversationGeneric lusr mCreatorClient conn newConv = do +createGroupConversationGeneric lusr conn newConv = do (nc, fromConvSize -> allUsers) <- newRegularConversation lusr newConv let tinfo = newConvTeam newConv checkCreateConvPermissions lusr newConv tinfo allUsers ensureNoLegalholdConflicts allUsers - when (newConvProtocol newConv == ProtocolMLSTag) $ do + when (newConvProtocol newConv == BaseProtocolMLSTag) $ do -- Here we fail early in order to notify users of this misconfiguration assertMLSEnabled unlessM (isJust <$> getMLSRemovalKey) $ @@ -220,16 +209,6 @@ createGroupConversationGeneric lusr mCreatorClient conn newConv = do do conv <- E.createConversation lcnv nc - -- set creator client for MLS conversations - case (convProtocol conv, mCreatorClient) of - (ProtocolProteus, _) -> pure () - (ProtocolMLS mlsMeta, Just c) -> - E.addMLSClients - (cnvmlsGroupId mlsMeta) - (tUntagged lusr) - (Set.singleton (c, nullKeyPackageRef)) - (ProtocolMLS _mlsMeta, Nothing) -> throwS @'MLSMissingSenderClient - -- NOTE: We only send (conversation) events to members of the conversation notifyCreatedConversation lusr conn conv E.getConversation (tUnqualified lcnv) @@ -262,7 +241,9 @@ checkCreateConvPermissions :: Maybe ConvTeamInfo -> UserList UserId -> Sem r () -checkCreateConvPermissions lusr _newConv Nothing allUsers = +checkCreateConvPermissions lusr _newConv Nothing allUsers = do + activated <- listToMaybe <$> lookupActivatedUsers [tUnqualified lusr] + void $ noteS @OperationDenied activated ensureConnected lusr allUsers checkCreateConvPermissions lusr newConv (Just tinfo) allUsers = do let convTeam = cnvTeamId tinfo @@ -308,9 +289,9 @@ createProteusSelfConversation lusr = do create lcnv = do let nc = NewConversation - { ncMetadata = (defConversationMetadata (tUnqualified lusr)) {cnvmType = SelfConv}, + { ncMetadata = (defConversationMetadata (Just (tUnqualified lusr))) {cnvmType = SelfConv}, ncUsers = ulFromLocals [toUserRole (tUnqualified lusr)], - ncProtocol = ProtocolProteusTag + ncProtocol = BaseProtocolProteusTag } c <- E.createConversation lcnv nc conversationCreated lusr c @@ -411,7 +392,7 @@ createLegacyOne2OneConversationUnchecked :: createLegacyOne2OneConversationUnchecked self zcon name mtid other = do lcnv <- localOne2OneConvId self other let meta = - (defConversationMetadata (tUnqualified self)) + (defConversationMetadata (Just (tUnqualified self))) { cnvmType = One2OneConv, cnvmTeam = mtid, cnvmName = fmap fromRange name @@ -419,7 +400,7 @@ createLegacyOne2OneConversationUnchecked self zcon name mtid other = do let nc = NewConversation { ncUsers = ulFromLocals (map (toUserRole . tUnqualified) [self, other]), - ncProtocol = ProtocolProteusTag, + ncProtocol = BaseProtocolProteusTag, ncMetadata = meta } mc <- E.getConversation (tUnqualified lcnv) @@ -456,7 +437,7 @@ createOne2OneConversationUnchecked self zcon name mtid other = do self createOne2OneConversationLocally createOne2OneConversationRemotely - create (one2OneConvId (tUntagged self) other) self zcon name mtid other + create (one2OneConvId BaseProtocolProteusTag (tUntagged self) other) self zcon name mtid other createOne2OneConversationLocally :: ( Member ConversationStore r, @@ -481,7 +462,7 @@ createOne2OneConversationLocally lcnv self zcon name mtid other = do Just c -> conversationExisted self c Nothing -> do let meta = - (defConversationMetadata (tUnqualified self)) + (defConversationMetadata (Just (tUnqualified self))) { cnvmType = One2OneConv, cnvmTeam = mtid, cnvmName = fmap fromRange name @@ -490,7 +471,7 @@ createOne2OneConversationLocally lcnv self zcon name mtid other = do NewConversation { ncMetadata = meta, ncUsers = fmap toUserRole (toUserList lcnv [tUntagged self, other]), - ncProtocol = ProtocolProteusTag + ncProtocol = BaseProtocolProteusTag } c <- E.createConversation lcnv nc notifyCreatedConversation self (Just zcon) c @@ -530,7 +511,7 @@ createConnectConversation lusr conn j = do lrecipient <- ensureLocal lusr (cRecipient j) n <- rangeCheckedMaybe (cName j) let meta = - (defConversationMetadata (tUnqualified lusr)) + (defConversationMetadata (Just (tUnqualified lusr))) { cnvmType = ConnectConv, cnvmName = fmap fromRange n } @@ -540,7 +521,7 @@ createConnectConversation lusr conn j = do { -- We add only one member, second one gets added later, -- when the other user accepts the connection request. ncUsers = ulFromLocals (map (toUserRole . tUnqualified) [lusr]), - ncProtocol = ProtocolProteusTag, + ncProtocol = BaseProtocolProteusTag, ncMetadata = meta } E.getConversation (tUnqualified lcnv) @@ -614,8 +595,8 @@ newRegularConversation lusr newConv = do o <- input let uncheckedUsers = newConvMembers lusr newConv users <- case newConvProtocol newConv of - ProtocolProteusTag -> checkedConvSize o uncheckedUsers - ProtocolMLSTag -> do + BaseProtocolProteusTag -> checkedConvSize o uncheckedUsers + BaseProtocolMLSTag -> do unless (null uncheckedUsers) $ throwS @'MLSNonEmptyMemberList pure mempty let nc = @@ -623,7 +604,7 @@ newRegularConversation lusr newConv = do { ncMetadata = ConversationMetadata { cnvmType = RegularConv, - cnvmCreator = tUnqualified lusr, + cnvmCreator = Just (tUnqualified lusr), cnvmAccess = access newConv, cnvmAccessRoles = accessRoles newConv, cnvmName = fmap fromRange (newConvName newConv), @@ -670,7 +651,7 @@ notifyCreatedConversation lusr conn c = do now <- input -- Ask remote servers to store conversation membership and notify remote users -- of being added to a conversation - registerRemoteConversationMemberships now (qualifyAs lusr c) + registerRemoteConversationMemberships now lusr (qualifyAs lusr c) unless (null (Data.convRemoteMembers c)) $ unlessM E.isFederationConfigured $ throw FederationNotConfigured diff --git a/services/galley/src/Galley/API/Federation.hs b/services/galley/src/Galley/API/Federation.hs index c1a475c6a54..3a22a033b23 100644 --- a/services/galley/src/Galley/API/Federation.hs +++ b/services/galley/src/Galley/API/Federation.hs @@ -1,4 +1,3 @@ -{-# OPTIONS -Wno-redundant-constraints #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE RecordWildCards #-} @@ -21,13 +20,14 @@ module Galley.API.Federation where -import Control.Error +import Control.Error hiding (note) import Control.Lens import Data.Bifunctor import Data.ByteString.Conversion (toByteString') import Data.Domain (Domain) import Data.Id import Data.Json.Util +import Data.List1 (List1 (..)) import Data.Map qualified as Map import Data.Map.Lens (toMapOf) import Data.Qualified @@ -41,10 +41,13 @@ import Galley.API.Action import Galley.API.Error import Galley.API.MLS.Enabled import Galley.API.MLS.GroupInfo -import Galley.API.MLS.KeyPackage import Galley.API.MLS.Message +import Galley.API.MLS.One2One import Galley.API.MLS.Removal +import Galley.API.MLS.SubConversation hiding (leaveSubConversation) +import Galley.API.MLS.Util import Galley.API.MLS.Welcome +import Galley.API.Mapping import Galley.API.Mapping qualified as Mapping import Galley.API.Message import Galley.API.Push @@ -52,14 +55,15 @@ import Galley.API.Util import Galley.App import Galley.Data.Conversation qualified as Data import Galley.Effects -import Galley.Effects.BackendNotificationQueueAccess import Galley.Effects.ConversationStore qualified as E import Galley.Effects.FireAndForget qualified as E import Galley.Effects.MemberStore qualified as E -import Galley.Effects.ProposalStore (ProposalStore) +import Galley.Intra.Push.Internal hiding (push) import Galley.Options import Galley.Types.Conversations.Members +import Galley.Types.Conversations.One2One import Galley.Types.UserList (UserList (UserList)) +import Gundeck.Types.Push.V2 (RecipientClients (..)) import Imports import Polysemy import Polysemy.Error @@ -81,18 +85,15 @@ import Wire.API.Event.Conversation import Wire.API.Federation.API import Wire.API.Federation.API.Common (EmptyResponse (..)) import Wire.API.Federation.API.Galley -import Wire.API.Federation.API.Galley qualified as F import Wire.API.Federation.Error -import Wire.API.MLS.CommitBundle import Wire.API.MLS.Credential -import Wire.API.MLS.Message -import Wire.API.MLS.PublicGroupState +import Wire.API.MLS.GroupInfo import Wire.API.MLS.Serialisation import Wire.API.MLS.SubConversation -import Wire.API.MLS.Welcome import Wire.API.Message import Wire.API.Routes.Named import Wire.API.ServantProto +import Wire.API.User (BaseProtocolTag (..)) type FederationAPI = "federation" :> FedApi 'Galley @@ -102,44 +103,49 @@ federationSitemap :: federationSitemap = Named @"on-conversation-created" onConversationCreated :<|> Named @"get-conversations" getConversations - :<|> Named @"on-conversation-updated" onConversationUpdated :<|> Named @"leave-conversation" (callsFed (exposeAnnotations leaveConversation)) - :<|> Named @"on-message-sent" onMessageSent :<|> Named @"send-message" (callsFed (exposeAnnotations sendMessage)) - :<|> Named @"on-user-deleted-conversations" (callsFed (exposeAnnotations onUserDeleted)) :<|> Named @"update-conversation" (callsFed (exposeAnnotations updateConversation)) :<|> Named @"mls-welcome" mlsSendWelcome - :<|> Named @"on-mls-message-sent" onMLSMessageSent :<|> Named @"send-mls-message" (callsFed (exposeAnnotations sendMLSMessage)) :<|> Named @"send-mls-commit-bundle" (callsFed (exposeAnnotations sendMLSCommitBundle)) :<|> Named @"query-group-info" queryGroupInfo - :<|> Named @"on-client-removed" (callsFed (exposeAnnotations onClientRemoved)) :<|> Named @"update-typing-indicator" (callsFed (exposeAnnotations updateTypingIndicator)) :<|> Named @"on-typing-indicator-updated" onTypingIndicatorUpdated + :<|> Named @"get-sub-conversation" getSubConversationForRemoteUser + :<|> Named @"delete-sub-conversation" (callsFed deleteSubConversationForRemoteUser) + :<|> Named @"leave-sub-conversation" (callsFed leaveSubConversation) + :<|> Named @"get-one2one-conversation" getOne2OneConversation + :<|> Named @"on-client-removed" (callsFed (exposeAnnotations onClientRemoved)) + :<|> Named @"on-message-sent" onMessageSent + :<|> Named @"on-mls-message-sent" onMLSMessageSent + :<|> Named @"on-conversation-updated" onConversationUpdated + :<|> Named @"on-user-deleted-conversations" (callsFed (exposeAnnotations onUserDeleted)) onClientRemoved :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, Member (Input Env) r, Member (Input (Local ())) r, Member (Input UTCTime) r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TinyLog r ) => Domain -> ClientRemovedRequest -> Sem r EmptyResponse onClientRemoved domain req = do - let qusr = Qualified (F.crrUser req) domain + let qusr = Qualified req.user domain whenM isMLSEnabled $ do - for_ (F.crrConvs req) $ \convId -> do + for_ req.convs $ \convId -> do mConv <- E.getConversation convId for mConv $ \conv -> do lconv <- qualifyLocal conv - removeClient lconv qusr (F.crrClient req) + removeClient lconv qusr (req.client) pure EmptyResponse onConversationCreated :: @@ -151,17 +157,17 @@ onConversationCreated :: Member P.TinyLog r ) => Domain -> - F.ConversationCreated ConvId -> + ConversationCreated ConvId -> Sem r EmptyResponse onConversationCreated domain rc = do let qrc = fmap (toRemoteUnsafe domain) rc loc <- qualifyLocal () - let (localUserIds, _) = partitionQualified loc (map omQualifiedId (toList (F.ccNonCreatorMembers rc))) + let (localUserIds, _) = partitionQualified loc (map omQualifiedId (toList (nonCreatorMembers rc))) addedUserIds <- addLocalUsersToRemoteConv - (F.ccCnvId qrc) - (tUntagged (F.ccRemoteOrigUserId qrc)) + (cnvId qrc) + (tUntagged (ccRemoteOrigUserId qrc)) localUserIds let connectedMembers = @@ -172,17 +178,17 @@ onConversationCreated domain rc = do (const True) . omQualifiedId ) - (F.ccNonCreatorMembers rc) + (nonCreatorMembers rc) -- Make sure to notify only about local users connected to the adder - let qrcConnected = qrc {F.ccNonCreatorMembers = connectedMembers} + let qrcConnected = qrc {nonCreatorMembers = connectedMembers} for_ (fromConversationCreated loc qrcConnected) $ \(mem, c) -> do let event = Event - (tUntagged (F.ccCnvId qrcConnected)) + (tUntagged (cnvId qrcConnected)) Nothing - (tUntagged (F.ccRemoteOrigUserId qrcConnected)) - (F.ccTime qrcConnected) + (tUntagged (ccRemoteOrigUserId qrcConnected)) + qrcConnected.time (EdConversation c) pushConversationEvent Nothing event (qualifyAs loc [qUnqualified . Public.memId $ mem]) [] pure EmptyResponse @@ -192,12 +198,12 @@ getConversations :: Member (Input (Local ())) r ) => Domain -> - F.GetConversationsRequest -> - Sem r F.GetConversationsResponse -getConversations domain (F.GetConversationsRequest uid cids) = do + GetConversationsRequest -> + Sem r GetConversationsResponse +getConversations domain (GetConversationsRequest uid cids) = do let ruid = toRemoteUnsafe domain uid loc <- qualifyLocal () - F.GetConversationsResponse + GetConversationsResponse . mapMaybe (Mapping.conversationToRemote (tDomain loc) ruid) <$> E.getConversations cids @@ -212,7 +218,7 @@ onConversationUpdated :: Member P.TinyLog r ) => Domain -> - F.ConversationUpdate -> + ConversationUpdate -> Sem r EmptyResponse onConversationUpdated requestingDomain cu = do let rcu = toRemoteUnsafe requestingDomain cu @@ -221,9 +227,9 @@ onConversationUpdated requestingDomain cu = do -- as of now this will not generate the necessary events on the leaver's domain leaveConversation :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (Error InternalError) r, - Member (Error FederationError) r, Member ExternalAccess r, Member FederatorAccess r, Member GundeckAccess r, @@ -232,21 +238,22 @@ leaveConversation :: Member (Input UTCTime) r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TinyLog r ) => Domain -> - F.LeaveConversationRequest -> - Sem r F.LeaveConversationResponse + LeaveConversationRequest -> + Sem r LeaveConversationResponse leaveConversation requestingDomain lc = do - let leaver = Qualified (F.lcLeaver lc) requestingDomain - lcnv <- qualifyLocal (F.lcConvId lc) + let leaver = Qualified lc.leaver requestingDomain + lcnv <- qualifyLocal lc.convId res <- runError - . mapToRuntimeError @'ConvNotFound F.RemoveFromConversationErrorNotFound - . mapToRuntimeError @('ActionDenied 'LeaveConversation) F.RemoveFromConversationErrorRemovalNotAllowed - . mapToRuntimeError @'InvalidOperation F.RemoveFromConversationErrorRemovalNotAllowed - . mapError @NoChanges (const F.RemoveFromConversationErrorUnchanged) + . mapToRuntimeError @'ConvNotFound RemoveFromConversationErrorNotFound + . mapToRuntimeError @('ActionDenied 'LeaveConversation) RemoveFromConversationErrorRemovalNotAllowed + . mapToRuntimeError @'InvalidOperation RemoveFromConversationErrorRemovalNotAllowed + . mapError @NoChanges (const RemoveFromConversationErrorUnchanged) $ do (conv, _self) <- getConversationAndMemberWithError @'ConvNotFound leaver lcnv outcome <- @@ -265,7 +272,7 @@ leaveConversation requestingDomain lc = do Right _ -> pure conv case res of - Left e -> pure $ F.LeaveConversationResponse (Left e) + Left e -> pure $ LeaveConversationResponse (Left e) Right conv -> do let remotes = filter ((== qDomain leaver) . tDomain) (rmId <$> Data.convRemoteMembers conv) let botsAndMembers = BotsAndMembers mempty (Set.fromList remotes) mempty @@ -286,7 +293,7 @@ leaveConversation requestingDomain lc = do throw . internalErr $ e Right _ -> pure () - pure $ F.LeaveConversationResponse (Right ()) + pure $ LeaveConversationResponse (Right ()) where internalErr = InternalErrorWithDescription . LT.pack . displayException @@ -301,23 +308,23 @@ onMessageSent :: Member P.TinyLog r ) => Domain -> - F.RemoteMessage ConvId -> + RemoteMessage ConvId -> Sem r EmptyResponse onMessageSent domain rmUnqualified = do let rm = fmap (toRemoteUnsafe domain) rmUnqualified - convId = tUntagged $ F.rmConversation rm + convId = tUntagged rm.conversation msgMetadata = MessageMetadata - { mmNativePush = F.rmPush rm, - mmTransient = F.rmTransient rm, - mmNativePriority = F.rmPriority rm, - mmData = F.rmData rm + { mmNativePush = push rm, + mmTransient = transient rm, + mmNativePriority = priority rm, + mmData = _data rm } - recipientMap = userClientMap $ F.rmRecipients rm + recipientMap = userClientMap rm.recipients msgs = toMapOf (itraversed <.> itraversed) recipientMap (members, allMembers) <- first Set.fromList - <$> E.selectRemoteMembers (Map.keys recipientMap) (F.rmConversation rm) + <$> E.selectRemoteMembers (Map.keys recipientMap) rm.conversation unless allMembers $ P.warn $ Log.field "conversation" (toByteString' (qUnqualified convId)) @@ -331,9 +338,9 @@ onMessageSent domain rmUnqualified = do void $ sendLocalMessages loc - (F.rmTime rm) - (F.rmSender rm) - (F.rmSenderClient rm) + rm.time + rm.sender + rm.senderClient Nothing (Just convId) mempty @@ -357,20 +364,19 @@ sendMessage :: Member P.TinyLog r ) => Domain -> - F.ProteusMessageSendRequest -> - Sem r F.MessageSendResponse + ProteusMessageSendRequest -> + Sem r MessageSendResponse sendMessage originDomain msr = do - let sender = Qualified (F.pmsrSender msr) originDomain - msg <- either throwErr pure (fromProto (fromBase64ByteString (F.pmsrRawMessage msr))) - lcnv <- qualifyLocal (F.pmsrConvId msr) - F.MessageSendResponse <$> postQualifiedOtrMessage User sender Nothing lcnv msg + let sender = Qualified msr.sender originDomain + msg <- either throwErr pure (fromProto (fromBase64ByteString msr.rawMessage)) + lcnv <- qualifyLocal msr.convId + MessageSendResponse <$> postQualifiedOtrMessage User sender Nothing lcnv msg where throwErr = throw . InvalidPayload . LT.pack onUserDeleted :: - ( Member (Error FederationError) r, + ( Member BackendNotificationQueueAccess r, Member ConversationStore r, - Member FederatorAccess r, Member FireAndForget r, Member ExternalAccess r, Member GundeckAccess r, @@ -379,15 +385,16 @@ onUserDeleted :: Member (Input Env) r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TinyLog r ) => Domain -> - F.UserDeletedConversationsNotification -> + UserDeletedConversationsNotification -> Sem r EmptyResponse onUserDeleted origDomain udcn = do - let deletedUser = toRemoteUnsafe origDomain (F.udcvUser udcn) + let deletedUser = toRemoteUnsafe origDomain udcn.user untaggedDeletedUser = tUntagged deletedUser - convIds = F.udcvConversations udcn + convIds = conversations udcn E.spawnMany $ fromRange convIds <&> \c -> do @@ -425,7 +432,8 @@ onUserDeleted origDomain udcn = do updateConversation :: forall r. - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member CodeStore r, Member BotAccess r, Member FireAndForget r, @@ -444,17 +452,19 @@ updateConversation :: Member TeamStore r, Member TinyLog r, Member ConversationStore r, + Member SubConversationStore r, + Member TeamFeatureStore r, Member (Input (Local ())) r ) => Domain -> - F.ConversationUpdateRequest -> + ConversationUpdateRequest -> Sem r ConversationUpdateResponse updateConversation origDomain updateRequest = do loc <- qualifyLocal () - let rusr = toRemoteUnsafe origDomain (F.curUser updateRequest) - lcnv = qualifyAs loc (F.curConvId updateRequest) + let rusr = toRemoteUnsafe origDomain updateRequest.user + lcnv = qualifyAs loc updateRequest.convId - mkResponse $ case F.curAction updateRequest of + mkResponse $ case action updateRequest of SomeConversationAction tag action -> case tag of SConversationJoinTag -> mapToGalleyError @(HasConversationActionGalleyErrors 'ConversationJoinTag) @@ -499,17 +509,22 @@ updateConversation origDomain updateRequest = do @(HasConversationActionGalleyErrors 'ConversationAccessDataTag) . fmap lcuUpdate $ updateLocalConversation @'ConversationAccessDataTag lcnv (tUntagged rusr) Nothing action + SConversationUpdateProtocolTag -> + mapToGalleyError + @(HasConversationActionGalleyErrors 'ConversationUpdateProtocolTag) + . fmap lcuUpdate + $ updateLocalConversation @'ConversationUpdateProtocolTag lcnv (tUntagged rusr) Nothing action where mkResponse = - fmap (either F.ConversationUpdateResponseError id) + fmap (either ConversationUpdateResponseError Imports.id) . runError @GalleyError - . fmap (fromRight F.ConversationUpdateResponseNoChanges) + . fmap (fromRight ConversationUpdateResponseNoChanges) . runError @NoChanges - . fmap (either F.ConversationUpdateResponseNonFederatingBackends id) + . fmap (either ConversationUpdateResponseNonFederatingBackends Imports.id) . runError @NonFederatingBackends - . fmap (either F.ConversationUpdateResponseUnreachableBackends id) + . fmap (either ConversationUpdateResponseUnreachableBackends Imports.id) . runError @UnreachableBackends - . fmap F.ConversationUpdateResponseUpdate + . fmap ConversationUpdateResponseUpdate handleMLSMessageErrors :: ( r1 @@ -526,26 +541,26 @@ handleMLSMessageErrors :: Sem r1 MLSMessageResponse -> Sem r MLSMessageResponse handleMLSMessageErrors = - fmap (either (F.MLSMessageResponseProtocolError . unTagged) id) + fmap (either (MLSMessageResponseProtocolError . unTagged) Imports.id) . runError @MLSProtocolError - . fmap (either F.MLSMessageResponseError id) + . fmap (either MLSMessageResponseError Imports.id) . runError - . fmap (either (F.MLSMessageResponseProposalFailure . pfInner) id) + . fmap (either (MLSMessageResponseProposalFailure . pfInner) Imports.id) . runError - . fmap (either F.MLSMessageResponseNonFederatingBackends id) + . fmap (either MLSMessageResponseNonFederatingBackends Imports.id) . runError - . fmap (either (F.MLSMessageResponseUnreachableBackends . Set.fromList . (.backends)) id) + . fmap (either (MLSMessageResponseUnreachableBackends . Set.fromList . (.backends)) Imports.id) . runError @UnreachableBackends . mapToGalleyError @MLSBundleStaticErrors sendMLSCommitBundle :: - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member ExternalAccess r, Member (Error FederationError) r, Member (Error InternalError) r, Member FederatorAccess r, - Member BackendNotificationQueueAccess r, Member GundeckAccess r, Member (Input (Local ())) r, Member (Input Env) r, @@ -556,24 +571,36 @@ sendMLSCommitBundle :: Member Resource r, Member TeamStore r, Member P.TinyLog r, + Member SubConversationStore r, Member ProposalStore r ) => Domain -> - F.MLSMessageSendRequest -> - Sem r F.MLSMessageResponse + MLSMessageSendRequest -> + Sem r MLSMessageResponse sendMLSCommitBundle remoteDomain msr = handleMLSMessageErrors $ do assertMLSEnabled loc <- qualifyLocal () - let sender = toRemoteUnsafe remoteDomain (F.mmsrSender msr) - bundle <- either (throw . mlsProtocolError) pure $ deserializeCommitBundle (fromBase64ByteString (F.mmsrRawMessage msr)) - let msg = rmValue (cbCommitMsg bundle) - qcnv <- E.getConversationIdByGroupId (msgGroupId msg) >>= noteS @'ConvNotFound - when (Conv (qUnqualified qcnv) /= F.mmsrConvOrSubId msr) $ throwS @'MLSGroupConversationMismatch - uncurry F.MLSMessageResponseUpdates . (,mempty) . map lcuUpdate - <$> postMLSCommitBundle loc (tUntagged sender) Nothing qcnv Nothing bundle + let sender = toRemoteUnsafe remoteDomain msr.sender + bundle <- + either (throw . mlsProtocolError) pure $ + decodeMLS' (fromBase64ByteString msr.rawMessage) + + ibundle <- noteS @'MLSUnsupportedMessage $ mkIncomingBundle bundle + (ctype, qConvOrSub) <- getConvFromGroupId ibundle.groupId + when (qUnqualified qConvOrSub /= msr.convOrSubId) $ throwS @'MLSGroupConversationMismatch + MLSMessageResponseUpdates . map lcuUpdate + <$> postMLSCommitBundle + loc + (tUntagged sender) + msr.senderClient + ctype + qConvOrSub + Nothing + ibundle sendMLSMessage :: - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member ExternalAccess r, Member (Error FederationError) r, @@ -586,26 +613,134 @@ sendMLSMessage :: Member (Input UTCTime) r, Member LegalHoldStore r, Member MemberStore r, - Member Resource r, Member TeamStore r, Member P.TinyLog r, - Member ProposalStore r + Member ProposalStore r, + Member SubConversationStore r ) => Domain -> - F.MLSMessageSendRequest -> - Sem r F.MLSMessageResponse + MLSMessageSendRequest -> + Sem r MLSMessageResponse sendMLSMessage remoteDomain msr = handleMLSMessageErrors $ do assertMLSEnabled loc <- qualifyLocal () - let sender = toRemoteUnsafe remoteDomain (F.mmsrSender msr) - raw <- either (throw . mlsProtocolError) pure $ decodeMLS' (fromBase64ByteString (F.mmsrRawMessage msr)) - case rmValue raw of - SomeMessage _ msg -> do - qcnv <- E.getConversationIdByGroupId (msgGroupId msg) >>= noteS @'ConvNotFound - when (Conv (qUnqualified qcnv) /= F.mmsrConvOrSubId msr) $ throwS @'MLSGroupConversationMismatch - uncurry F.MLSMessageResponseUpdates - . first (map lcuUpdate) - <$> postMLSMessage loc (tUntagged sender) Nothing qcnv Nothing raw + let sender = toRemoteUnsafe remoteDomain msr.sender + raw <- either (throw . mlsProtocolError) pure $ decodeMLS' (fromBase64ByteString msr.rawMessage) + msg <- noteS @'MLSUnsupportedMessage $ mkIncomingMessage raw + (ctype, qConvOrSub) <- getConvFromGroupId msg.groupId + when (qUnqualified qConvOrSub /= msr.convOrSubId) $ throwS @'MLSGroupConversationMismatch + MLSMessageResponseUpdates . map lcuUpdate + <$> postMLSMessage + loc + (tUntagged sender) + msr.senderClient + ctype + qConvOrSub + Nothing + msg + +getSubConversationForRemoteUser :: + Members + '[ SubConversationStore, + ConversationStore, + Input (Local ()), + Error InternalError, + P.TinyLog + ] + r => + Domain -> + GetSubConversationsRequest -> + Sem r GetSubConversationsResponse +getSubConversationForRemoteUser domain GetSubConversationsRequest {..} = + fmap (either GetSubConversationsResponseError GetSubConversationsResponseSuccess) + . runError @GalleyError + . mapToGalleyError @MLSGetSubConvStaticErrors + $ do + let qusr = Qualified gsreqUser domain + lconv <- qualifyLocal gsreqConv + getLocalSubConversation qusr lconv gsreqSubConv + +leaveSubConversation :: + ( HasLeaveSubConversationEffects r, + Member (Input (Local ())) r, + Member Resource r + ) => + Domain -> + LeaveSubConversationRequest -> + Sem r LeaveSubConversationResponse +leaveSubConversation domain lscr = do + let rusr = toRemoteUnsafe domain (lscrUser lscr) + cid = mkClientIdentity (tUntagged rusr) (lscrClient lscr) + lcnv <- qualifyLocal (lscrConv lscr) + fmap (either (LeaveSubConversationResponseProtocolError . unTagged) Imports.id) + . runError @MLSProtocolError + . fmap (either LeaveSubConversationResponseError Imports.id) + . runError @GalleyError + . mapToGalleyError @LeaveSubConversationStaticErrors + $ leaveLocalSubConversation cid lcnv (lscrSubConv lscr) + $> LeaveSubConversationResponseOk + +deleteSubConversationForRemoteUser :: + ( Members + '[ ConversationStore, + FederatorAccess, + Input (Local ()), + Input Env, + MemberStore, + Resource, + SubConversationStore + ] + r + ) => + Domain -> + DeleteSubConversationFedRequest -> + Sem r DeleteSubConversationResponse +deleteSubConversationForRemoteUser domain DeleteSubConversationFedRequest {..} = + fmap + ( either + DeleteSubConversationResponseError + (\() -> DeleteSubConversationResponseSuccess) + ) + . runError @GalleyError + . mapToGalleyError @MLSDeleteSubConvStaticErrors + $ do + let qusr = Qualified dscreqUser domain + dsc = DeleteSubConversationRequest dscreqGroupId dscreqEpoch + lconv <- qualifyLocal dscreqConv + deleteLocalSubConversation qusr lconv dscreqSubConv dsc + +getOne2OneConversation :: + ( Member ConversationStore r, + Member (Input (Local ())) r, + Member (Error InternalError) r, + Member BrigAccess r + ) => + Domain -> + GetOne2OneConversationRequest -> + Sem r GetOne2OneConversationResponse +getOne2OneConversation domain (GetOne2OneConversationRequest self other) = + fmap (Imports.fromRight GetOne2OneConversationNotConnected) + . runError @(Tagged 'NotConnected ()) + $ do + lother <- qualifyLocal other + let rself = toRemoteUnsafe domain self + ensureConnectedToRemotes lother [rself] + let getLocal lconv = do + mconv <- E.getConversation (tUnqualified lconv) + fmap GetOne2OneConversationOk $ case mconv of + Nothing -> pure (localMLSOne2OneConversationAsRemote lconv) + Just conv -> + note + (InternalErrorWithDescription "Unexpected member list in 1-1 conversation") + (conversationToRemote (tDomain lother) rself conv) + foldQualified + lother + getLocal + (const (pure GetOne2OneConversationBackendMismatch)) + (one2OneConvId BaseProtocolMLSTag (tUntagged lother) (tUntagged rself)) + +-------------------------------------------------------------------------------- +-- Error handling machinery class ToGalleyRuntimeError (effs :: EffectRow) r where mapToGalleyError :: @@ -614,7 +749,7 @@ class ToGalleyRuntimeError (effs :: EffectRow) r where Sem r a instance ToGalleyRuntimeError '[] r where - mapToGalleyError = id + mapToGalleyError = Imports.id instance forall (err :: GalleyError) effs r. @@ -630,40 +765,6 @@ instance Left _ -> throw (demote @err) Right res -> pure res -mlsSendWelcome :: - ( Member BrigAccess r, - Member (Error InternalError) r, - Member GundeckAccess r, - Member ExternalAccess r, - Member P.TinyLog r, - Member (Input Env) r, - Member (Input (Local ())) r, - Member (Input UTCTime) r - ) => - Domain -> - F.MLSWelcomeRequest -> - Sem r F.MLSWelcomeResponse -mlsSendWelcome _origDomain (fromBase64ByteString . F.unMLSWelcomeRequest -> rawWelcome) = - fmap (either (const MLSWelcomeMLSNotEnabled) (const MLSWelcomeSent)) - . runError @(Tagged 'MLSNotEnabled ()) - $ do - assertMLSEnabled - loc <- qualifyLocal () - now <- input - welcome <- either (throw . InternalErrorWithDescription . LT.fromStrict) pure $ decodeMLS' rawWelcome - -- Extract only recipients local to this backend - rcpts <- - fmap catMaybes - $ traverse - ( fmap (fmap cidQualifiedClient . hush) - . runError @(Tagged 'MLSKeyPackageRefNotFound ()) - . derefKeyPackage - . gsNewMember - ) - $ welSecrets welcome - let lrcpts = qualifyAs loc $ fst $ partitionQualified loc rcpts - sendLocalWelcomes Nothing now rawWelcome lrcpts - onMLSMessageSent :: ( Member ExternalAccess r, Member GundeckAccess r, @@ -673,16 +774,17 @@ onMLSMessageSent :: Member P.TinyLog r ) => Domain -> - F.RemoteMLSMessage -> - Sem r F.RemoteMLSMessageResponse + RemoteMLSMessage -> + Sem r EmptyResponse onMLSMessageSent domain rmm = - fmap (either (const RemoteMLSMessageMLSNotEnabled) (const RemoteMLSMessageOk)) + (EmptyResponse <$) + . (logError =<<) . runError @(Tagged 'MLSNotEnabled ()) $ do assertMLSEnabled loc <- qualifyLocal () - let rcnv = toRemoteUnsafe domain (F.rmmConversation rmm) - let users = Set.fromList (map fst (F.rmmRecipients rmm)) + let rcnv = toRemoteUnsafe domain rmm.conversation + let users = Map.keys rmm.recipients (members, allMembers) <- first Set.fromList <$> E.selectRemoteMembers (toList users) rcnv @@ -695,36 +797,79 @@ onMLSMessageSent domain rmm = \ users not in the conversation" :: ByteString ) - let recipients = filter (\(u, _) -> Set.member u members) (F.rmmRecipients rmm) + let recipients = + filter (\r -> Set.member (_recipientUserId r) members) + . map (\(u, clts) -> Recipient u (RecipientClientsSome (List1 clts))) + . Map.assocs + $ rmm.recipients -- FUTUREWORK: support local bots let e = - Event (tUntagged rcnv) Nothing (F.rmmSender rmm) (F.rmmTime rmm) $ - EdMLSMessage (fromBase64ByteString (F.rmmMessage rmm)) + Event (tUntagged rcnv) rmm.subConversation rmm.sender rmm.time $ + EdMLSMessage (fromBase64ByteString rmm.message) runMessagePush loc (Just (tUntagged rcnv)) $ - newMessagePush mempty Nothing (F.rmmMetadata rmm) recipients e + newMessagePush mempty Nothing rmm.metadata recipients e + where + logError :: Member P.TinyLog r => Either (Tagged 'MLSNotEnabled ()) () -> Sem r () + logError (Left _) = + P.warn $ + Log.field "conversation" (toByteString' rmm.conversation) + Log.~~ Log.field "domain" (toByteString' domain) + Log.~~ Log.msg + ("Cannot process remote MLS message because MLS is disabled on this backend" :: ByteString) + logError _ = pure () + +mlsSendWelcome :: + ( Member (Error InternalError) r, + Member GundeckAccess r, + Member ExternalAccess r, + Member P.TinyLog r, + Member (Input Env) r, + Member (Input (Local ())) r, + Member (Input UTCTime) r + ) => + Domain -> + MLSWelcomeRequest -> + Sem r MLSWelcomeResponse +mlsSendWelcome origDomain req = do + fmap (either (const MLSWelcomeMLSNotEnabled) (const MLSWelcomeSent)) + . runError @(Tagged 'MLSNotEnabled ()) + $ do + assertMLSEnabled + loc <- qualifyLocal () + now <- input + welcome <- + either (throw . InternalErrorWithDescription . LT.fromStrict) pure $ + decodeMLS' (fromBase64ByteString req.welcomeMessage) + sendLocalWelcomes req.qualifiedConvId (Qualified req.originatingUser origDomain) Nothing now welcome (qualifyAs loc req.recipients) queryGroupInfo :: ( Member ConversationStore r, Member (Input (Local ())) r, Member (Input Env) r, + Member SubConversationStore r, Member MemberStore r ) => Domain -> - F.GetGroupInfoRequest -> - Sem r F.GetGroupInfoResponse + GetGroupInfoRequest -> + Sem r GetGroupInfoResponse queryGroupInfo origDomain req = - fmap (either F.GetGroupInfoResponseError F.GetGroupInfoResponseState) + fmap (either GetGroupInfoResponseError GetGroupInfoResponseState) . runError @GalleyError . mapToGalleyError @MLSGroupInfoStaticErrors $ do assertMLSEnabled - lconvId <- qualifyLocal . ggireqConv $ req - let sender = toRemoteUnsafe origDomain . ggireqSender $ req - state <- getGroupInfoFromLocalConv (tUntagged sender) lconvId + let sender = toRemoteUnsafe origDomain . (.sender) $ req + state <- case req.conv of + Conv convId -> do + lconvId <- qualifyLocal convId + getGroupInfoFromLocalConv (tUntagged sender) lconvId + SubConv convId subConvId -> do + lconvId <- qualifyLocal convId + getSubConversationGroupInfoFromLocalConv (tUntagged sender) subConvId lconvId pure . Base64ByteString - . unOpaquePublicGroupState + . unGroupInfoData $ state updateTypingIndicator :: @@ -735,17 +880,17 @@ updateTypingIndicator :: Member (Input (Local ())) r ) => Domain -> - F.TypingDataUpdateRequest -> - Sem r F.TypingDataUpdateResponse + TypingDataUpdateRequest -> + Sem r TypingDataUpdateResponse updateTypingIndicator origDomain TypingDataUpdateRequest {..} = do - let qusr = Qualified tdurUserId origDomain - lcnv <- qualifyLocal tdurConvId + let qusr = Qualified userId origDomain + lcnv <- qualifyLocal convId ret <- runError . mapToRuntimeError @'ConvNotFound ConvNotFound $ do (conv, _) <- getConversationAndMemberWithError @'ConvNotFound qusr lcnv - notifyTypingIndicator conv qusr Nothing tdurTypingStatus + notifyTypingIndicator conv qusr Nothing typingStatus pure (either TypingDataUpdateError TypingDataUpdateSuccess ret) @@ -756,8 +901,8 @@ onTypingIndicatorUpdated :: TypingDataUpdated -> Sem r EmptyResponse onTypingIndicatorUpdated origDomain TypingDataUpdated {..} = do - let qcnv = Qualified tudConvId origDomain - pushTypingIndicatorEvents tudOrigUserId tudTime tudUsersInConv Nothing qcnv tudTypingStatus + let qcnv = Qualified convId origDomain + pushTypingIndicatorEvents origUserId time usersInConv Nothing qcnv typingStatus pure EmptyResponse -------------------------------------------------------------------------------- diff --git a/services/galley/src/Galley/API/Internal.hs b/services/galley/src/Galley/API/Internal.hs index dc4176dc049..b33bab98ac5 100644 --- a/services/galley/src/Galley/API/Internal.hs +++ b/services/galley/src/Galley/API/Internal.hs @@ -21,24 +21,17 @@ module Galley.API.Internal InternalAPI, deleteLoop, safeForever, - -- Exported for tests - deleteFederationDomain, ) where -import Bilge.Retry import Control.Exception.Safe (catchAny) import Control.Lens hiding (Getter, Setter, (.=)) -import Control.Retry -import Data.Domain import Data.Id as Id -import Data.List.NonEmpty qualified as N import Data.List1 (maybeList1) import Data.Map qualified as Map import Data.Qualified import Data.Range import Data.Singletons -import Data.Text (unpack) import Data.Time import Galley.API.Action import Galley.API.Clients qualified as Clients @@ -59,22 +52,18 @@ import Galley.API.Update qualified as Update import Galley.API.Util import Galley.App import Galley.Data.Conversation qualified as Data -import Galley.Data.Conversation.Types import Galley.Effects import Galley.Effects.BackendNotificationQueueAccess import Galley.Effects.ClientStore import Galley.Effects.ConversationStore -import Galley.Effects.DefederationNotifications (DefederationNotifications, sendDefederationNotifications) import Galley.Effects.FederatorAccess import Galley.Effects.GundeckAccess import Galley.Effects.LegalHoldStore as LegalHoldStore -import Galley.Effects.MemberStore import Galley.Effects.MemberStore qualified as E -import Galley.Effects.ProposalStore import Galley.Effects.TeamStore import Galley.Intra.Push qualified as Intra import Galley.Monad -import Galley.Options +import Galley.Options hiding (brig) import Galley.Queue qualified as Q import Galley.Types.Bot (AddBot, RemoveBot) import Galley.Types.Bot.Service @@ -82,8 +71,6 @@ import Galley.Types.Conversations.Members (RemoteMember (rmId)) import Galley.Types.UserList import Imports hiding (head) import Network.AMQP qualified as Q -import Network.HTTP.Types -import Network.Wai import Network.Wai.Predicate hiding (Error, err, result, setStatus) import Network.Wai.Predicate qualified as Predicate hiding (result) import Network.Wai.Routing hiding (App, route, toList) @@ -94,10 +81,8 @@ import Polysemy.Error import Polysemy.Input import Polysemy.TinyLog qualified as P import Servant hiding (JSON, WithStatus) -import Servant.Client (BaseUrl (BaseUrl), ClientEnv (ClientEnv), Scheme (Http), defaultMakeClientRequest) import System.Logger.Class hiding (Path, name) import System.Logger.Class qualified as Log -import Util.Options import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Action import Wire.API.CustomBackend @@ -107,7 +92,6 @@ import Wire.API.Event.Conversation import Wire.API.Federation.API import Wire.API.Federation.API.Galley import Wire.API.Federation.Error -import Wire.API.FederationUpdate import Wire.API.Provider.Service hiding (Service) import Wire.API.Routes.API import Wire.API.Routes.Internal.Galley @@ -115,15 +99,17 @@ import Wire.API.Routes.Internal.Galley.TeamsIntra import Wire.API.Routes.MultiTablePaging (mtpHasMore, mtpPagingState, mtpResults) import Wire.API.Team.Feature hiding (setStatus) import Wire.API.Team.Member +import Wire.API.User.Client import Wire.Sem.Paging import Wire.Sem.Paging.Cassandra internalAPI :: API InternalAPI GalleyEffects internalAPI = - hoistAPI @InternalAPIBase id $ + hoistAPI @InternalAPIBase Imports.id $ mkNamedAPI @"status" (pure ()) <@> mkNamedAPI @"delete-user" (callsFed (exposeAnnotations rmUser)) <@> mkNamedAPI @"connect" (callsFed (exposeAnnotations Create.createConnectConversation)) + <@> mkNamedAPI @"get-conversation-clients" iGetMLSClientListForConv <@> mkNamedAPI @"guard-legalhold-policy-conflicts" guardLegalholdPolicyConflictsH <@> legalholdWhitelistedTeamsAPI <@> iTeamsAPI @@ -136,7 +122,7 @@ federationAPI = mkNamedAPI @"get-federation-status" (const getFederationStatus) legalholdWhitelistedTeamsAPI :: API ILegalholdWhitelistedTeamsAPI GalleyEffects -legalholdWhitelistedTeamsAPI = mkAPI $ \tid -> hoistAPIHandler id (base tid) +legalholdWhitelistedTeamsAPI = mkAPI $ \tid -> hoistAPIHandler Imports.id (base tid) where base :: TeamId -> API ILegalholdWhitelistedTeamsAPIBase GalleyEffects base tid = @@ -145,13 +131,13 @@ legalholdWhitelistedTeamsAPI = mkAPI $ \tid -> hoistAPIHandler id (base tid) <@> mkNamedAPI @"get-team-legalhold-whitelisted" (LegalHoldStore.isTeamLegalholdWhitelisted tid) iTeamsAPI :: API ITeamsAPI GalleyEffects -iTeamsAPI = mkAPI $ \tid -> hoistAPIHandler id (base tid) +iTeamsAPI = mkAPI $ \tid -> hoistAPIHandler Imports.id (base tid) where hoistAPISegment :: (ServerT (seg :> inner) (Sem r) ~ ServerT inner (Sem r)) => API inner r -> API (seg :> inner) r - hoistAPISegment = hoistAPI id + hoistAPISegment = hoistAPI Imports.id base :: TeamId -> API ITeamsAPIBase GalleyEffects base tid = @@ -234,6 +220,10 @@ featureAPI = <@> mkNamedAPI @'("iput", MlsE2EIdConfig) setFeatureStatusInternal <@> mkNamedAPI @'("ipatch", MlsE2EIdConfig) patchFeatureStatusInternal <@> mkNamedAPI @'("ilock", MlsE2EIdConfig) (updateLockStatus @MlsE2EIdConfig) + <@> mkNamedAPI @'("iget", MlsMigrationConfig) (getFeatureStatus DontDoAuth) + <@> mkNamedAPI @'("iput", MlsMigrationConfig) setFeatureStatusInternal + <@> mkNamedAPI @'("ipatch", MlsMigrationConfig) patchFeatureStatusInternal + <@> mkNamedAPI @'("ilock", MlsMigrationConfig) (updateLockStatus @MlsMigrationConfig) <@> mkNamedAPI @"feature-configs-internal" (maybe getAllFeatureConfigsForServer getAllFeatureConfigsForUser) internalSitemap :: Routes a (Sem GalleyEffects) () @@ -321,10 +311,6 @@ internalSitemap = unsafeCallsFed @'Galley @"on-client-removed" $ unsafeCallsFed capture "domain" .&. accept "application" "json" - delete "/i/federation/:domain" (continue internalDeleteFederationDomainH) $ - capture "domain" - .&. accept "application" "json" - rmUser :: forall p1 p2 r. ( p1 ~ CassandraPaging, @@ -346,6 +332,7 @@ rmUser :: Member MemberStore r, Member ProposalStore r, Member P.TinyLog r, + Member SubConversationStore r, Member TeamStore r ) ) => @@ -385,21 +372,21 @@ rmUser lusr conn = do now <- input pp <- for cc $ \c -> case Data.convType c of SelfConv -> pure Nothing - One2OneConv -> deleteMembers (Data.convId c) (UserList [tUnqualified lusr] []) $> Nothing - ConnectConv -> deleteMembers (Data.convId c) (UserList [tUnqualified lusr] []) $> Nothing + One2OneConv -> E.deleteMembers (Data.convId c) (UserList [tUnqualified lusr] []) $> Nothing + ConnectConv -> E.deleteMembers (Data.convId c) (UserList [tUnqualified lusr] []) $> Nothing RegularConv | tUnqualified lusr `isMember` Data.convLocalMembers c -> do runError (removeUser (qualifyAs lusr c) (tUntagged lusr)) >>= \case Left e -> P.err $ Log.msg ("failed to send remove proposal: " <> internalErrorDescription e) Right _ -> pure () - deleteMembers (Data.convId c) (UserList [tUnqualified lusr] []) + E.deleteMembers (Data.convId c) (UserList [tUnqualified lusr] []) let e = Event (tUntagged (qualifyAs lusr (Data.convId c))) Nothing (tUntagged lusr) now - (EdMembersLeave (QualifiedUserIdList [qUser])) + (EdMembersLeave EdReasonDeleted (QualifiedUserIdList [qUser])) for_ (bucketRemote (fmap rmId (Data.convRemoteMembers c))) $ notifyRemoteMembers now qUser (Data.convId c) pure $ Intra.newPushLocal ListComplete (tUnqualified lusr) (Intra.ConvEvent e) (Intra.recipient <$> Data.convLocalMembers c) @@ -409,7 +396,7 @@ rmUser lusr conn = do for_ (maybeList1 (catMaybes pp)) - push + Galley.Effects.GundeckAccess.push -- FUTUREWORK: This could be optimized to reduce the number of RPCs -- made. When a team is deleted the burst of RPCs created here could @@ -433,7 +420,7 @@ rmUser lusr conn = do leaveRemoteConversations cids = for_ (bucketRemote (fromRange cids)) $ \remoteConvs -> do let userDelete = UserDeletedConversationsNotification (tUnqualified lusr) (unsafeRange (tUnqualified remoteConvs)) - let rpc = void $ fedQueueClient @'Galley @"on-user-deleted-conversations" userDelete + let rpc = void $ fedQueueClient @'OnUserDeletedConversationsTag userDelete enqueueNotification remoteConvs Q.Persistent rpc -- FUTUREWORK: Add a retry mechanism if there are federation errrors. @@ -493,139 +480,16 @@ guardLegalholdPolicyConflictsH glh = do mapError @LegalholdConflicts (const $ Tagged @'MissingLegalholdConsent ()) $ guardLegalholdPolicyConflicts (glhProtectee glh) (glhUserClients glh) --- Build the map, keyed by conversations to the list of members -insertIntoMap :: (ConvId, a) -> Map ConvId (N.NonEmpty a) -> Map ConvId (N.NonEmpty a) -insertIntoMap (cnvId, user) m = Map.alter (pure . maybe (pure user) (N.cons user)) cnvId m - --- Bundle all of the deletes together for easy calling --- Errors & exceptions are thrown to IO to stop the message being ACKed, eventually timing it --- out so that it can be redelivered. -deleteFederationDomain :: - ( Member (Input Env) r, - Member (P.Logger (Msg -> Msg)) r, - Member (Error FederationError) r, - Member MemberStore r, - Member ConversationStore r, - Member (Embed IO) r, - Member CodeStore r, - Member TeamStore r, - Member (Error InternalError) r - ) => - Domain -> - Sem r () -deleteFederationDomain d = do - deleteFederationDomainRemoteUserFromLocalConversations d - deleteFederationDomainLocalUserFromRemoteConversation d - deleteFederationDomainOneOnOne d - -internalDeleteFederationDomainH :: - ( Member (Input Env) r, - Member (P.Logger (Msg -> Msg)) r, - Member (Error FederationError) r, - Member MemberStore r, - Member ConversationStore r, - Member (Embed IO) r, - Member CodeStore r, - Member TeamStore r, - Member DefederationNotifications r, - Member (Error InternalError) r - ) => - Domain ::: JSON -> - Sem r Response -internalDeleteFederationDomainH (domain ::: _) = do - -- We have to send the same event twice. - -- Once before and once after defederation work. - -- https://wearezeta.atlassian.net/wiki/spaces/ENGINEERIN/pages/809238539/Use+case+Stopping+to+federate+with+a+domain - sendDefederationNotifications domain - deleteFederationDomain domain - sendDefederationNotifications domain - pure (empty & setStatus status200) - --- Remove remote members from local conversations -deleteFederationDomainRemoteUserFromLocalConversations :: +-- | Get an MLS conversation client list +iGetMLSClientListForConv :: forall r. - ( Member (Input Env) r, - Member (P.Logger (Msg -> Msg)) r, - Member (Error FederationError) r, - Member MemberStore r, - Member ConversationStore r, - Member CodeStore r, - Member TeamStore r - ) => - Domain -> - Sem r () -deleteFederationDomainRemoteUserFromLocalConversations dom = do - remoteUsers <- E.getRemoteMembersByDomain dom - env <- input - let lCnvMap = foldr insertIntoMap mempty remoteUsers - localDomain = env ^. Galley.App.options . optSettings . setFederationDomain - for_ (Map.toList lCnvMap) $ \(cnvId, rUsers) -> do - let mapAllErrors :: - Text -> - Sem (Error NoChanges ': ErrorS 'NotATeamMember ': r) () -> - Sem r () - mapAllErrors msgText = - -- This can be thrown in `updateLocalConversationUserUnchecked @'ConversationDeleteTag`. - P.logAndIgnoreErrors @(Tagged 'NotATeamMember ()) (const "Not a team member") msgText - -- This can be thrown in `updateLocalConversationUserUnchecked @'ConversationRemoveMembersTag` - . P.logAndIgnoreErrors @NoChanges (const "No changes") msgText - - mapAllErrors "Federation domain removal" $ do - getConversation cnvId - >>= maybe (pure () {- conv already gone, nothing to do -}) (delConv localDomain rUsers) - where - delConv :: - Domain -> - N.NonEmpty RemoteMember -> - Galley.Data.Conversation.Types.Conversation -> - Sem (Error NoChanges : ErrorS 'NotATeamMember : r) () - delConv localDomain rUsers conv = - do - let lConv = toLocalUnsafe localDomain conv - updateLocalConversationUserUnchecked - @'ConversationRemoveMembersTag - lConv - undefined - $ tUntagged . rmId <$> rUsers -- This field can be undefined as the path for ConversationRemoveMembersTag doens't use it - -- Check if the conversation if type 2 or 3, one-on-one conversations. - -- If it is, then we need to remove the entire conversation as users - -- aren't able to delete those types of conversations themselves. - -- Check that we are in a type 2 or a type 3 conversation - when (cnvmType (convMetadata conv) `elem` [One2OneConv, ConnectConv]) $ - -- If we are, delete it. - updateLocalConversationUserUnchecked - @'ConversationDeleteTag - lConv - undefined - () - --- Remove local members from remote conversations -deleteFederationDomainLocalUserFromRemoteConversation :: - ( Member (Error InternalError) r, - Member MemberStore r - ) => - Domain -> - Sem r () -deleteFederationDomainLocalUserFromRemoteConversation dom = do - remoteConvs <- foldr insertIntoMap mempty <$> E.getLocalMembersByDomain dom - for_ (Map.toList remoteConvs) $ \(cnv, lUsers) -> do - -- All errors, either exceptions or Either e, get thrown into IO - mapError @NoChanges (const (InternalErrorWithDescription "No Changes: Could not remove a local member from a remote conversation.")) $ do - E.deleteMembersInRemoteConversation (toRemoteUnsafe dom cnv) (N.toList lUsers) - --- These need to be recoverable? --- This is recoverable with the following flow conditions. --- 1) Deletion calls to the Brig endpoint `delete-federation-remote-from-galley` are idempotent for a given domain. --- 2) This call is made from a function that is backed by a RabbitMQ queue. --- The calling function needs to catch thrown exceptions and NACK the deletion --- message. This will allow Rabbit to redeliver the message and give us a second --- go at performing the deletion. -deleteFederationDomainOneOnOne :: (Member (Input Env) r, Member (Embed IO) r) => Domain -> Sem r () -deleteFederationDomainOneOnOne dom = do - env <- input - let c = mkClientEnv (env ^. manager) (env ^. brig) - -- This is the same policy as background-worker for retrying. - policy = capDelay 60_000_000 $ fullJitterBackoff 200_000 - void . liftIO . recovering policy httpHandlers $ \_ -> deleteFederationRemoteGalley dom c - where - mkClientEnv mgr (Endpoint h p) = ClientEnv mgr (BaseUrl Http (unpack h) (fromIntegral p) "") Nothing defaultMakeClientRequest + Members + '[ MemberStore, + ErrorS 'ConvNotFound + ] + r => + GroupId -> + Sem r ClientList +iGetMLSClientListForConv gid = do + cm <- E.lookupMLSClients gid + pure $ ClientList (concatMap (Map.keys . snd) (Map.assocs cm)) diff --git a/services/galley/src/Galley/API/LegalHold.hs b/services/galley/src/Galley/API/LegalHold.hs index b3b3d6b34be..5c23f29b89d 100644 --- a/services/galley/src/Galley/API/LegalHold.hs +++ b/services/galley/src/Galley/API/LegalHold.hs @@ -54,7 +54,6 @@ import Galley.Effects import Galley.Effects.BrigAccess import Galley.Effects.FireAndForget import Galley.Effects.LegalHoldStore qualified as LegalHoldData -import Galley.Effects.ProposalStore import Galley.Effects.TeamMemberStore import Galley.Effects.TeamStore import Galley.External.LegalHoldService qualified as LHService @@ -139,15 +138,13 @@ getSettings lzusr tid = do removeSettingsInternalPaging :: forall r. - ( Member BotAccess r, + ( Member BackendNotificationQueueAccess r, Member BrigAccess r, - Member CodeStore r, Member ConversationStore r, Member (Error AuthenticationError) r, Member (Error FederationError) r, Member (Error InternalError) r, Member (ErrorS ('ActionDenied 'RemoveConversationMember)) r, - Member (ErrorS 'InvalidOperation) r, Member (ErrorS 'LegalHoldCouldNotBlockConnections) r, Member (ErrorS 'LegalHoldDisableUnimplemented) r, Member (ErrorS 'LegalHoldNotEnabled) r, @@ -167,6 +164,7 @@ removeSettingsInternalPaging :: Member MemberStore r, Member ProposalStore r, Member P.TinyLog r, + Member SubConversationStore r, Member TeamFeatureStore r, Member (TeamMemberStore InternalPaging) r, Member TeamStore r @@ -181,38 +179,36 @@ removeSettings :: forall p r. ( Paging p, Bounded (PagingBounds p TeamMember), - ( Member BotAccess r, - Member BrigAccess r, - Member CodeStore r, - Member ConversationStore r, - Member (Error AuthenticationError) r, - Member (Error FederationError) r, - Member (Error InternalError) r, - Member (ErrorS ('ActionDenied 'RemoveConversationMember)) r, - Member (ErrorS 'InvalidOperation) r, - Member (ErrorS 'LegalHoldCouldNotBlockConnections) r, - Member (ErrorS 'LegalHoldDisableUnimplemented) r, - Member (ErrorS 'LegalHoldNotEnabled) r, - Member (ErrorS 'LegalHoldServiceNotRegistered) r, - Member (ErrorS 'NotATeamMember) r, - Member (ErrorS OperationDenied) r, - Member (ErrorS 'UserLegalHoldIllegalOperation) r, - Member ExternalAccess r, - Member FederatorAccess r, - Member FireAndForget r, - Member GundeckAccess r, - Member (Input Env) r, - Member (Input (Local ())) r, - Member (Input UTCTime) r, - Member LegalHoldStore r, - Member (ListItems LegacyPaging ConvId) r, - Member MemberStore r, - Member ProposalStore r, - Member P.TinyLog r, - Member TeamFeatureStore r, - Member (TeamMemberStore p) r, - Member TeamStore r - ) + Member TeamFeatureStore r, + Member (TeamMemberStore p) r, + Member TeamStore r, + Member BackendNotificationQueueAccess r, + Member BrigAccess r, + Member ConversationStore r, + Member (Error AuthenticationError) r, + Member (Error FederationError) r, + Member (Error InternalError) r, + Member (ErrorS ('ActionDenied 'RemoveConversationMember)) r, + Member (ErrorS 'LegalHoldCouldNotBlockConnections) r, + Member (ErrorS 'LegalHoldDisableUnimplemented) r, + Member (ErrorS 'LegalHoldNotEnabled) r, + Member (ErrorS 'LegalHoldServiceNotRegistered) r, + Member (ErrorS 'NotATeamMember) r, + Member (ErrorS OperationDenied) r, + Member (ErrorS 'UserLegalHoldIllegalOperation) r, + Member ExternalAccess r, + Member FederatorAccess r, + Member FireAndForget r, + Member GundeckAccess r, + Member (Input Env) r, + Member (Input (Local ())) r, + Member (Input UTCTime) r, + Member LegalHoldStore r, + Member (ListItems LegacyPaging ConvId) r, + Member MemberStore r, + Member ProposalStore r, + Member P.TinyLog r, + Member SubConversationStore r ) => UserId -> TeamId -> @@ -243,33 +239,30 @@ removeSettings' :: forall p r. ( Paging p, Bounded (PagingBounds p TeamMember), - ( Member BotAccess r, - Member BrigAccess r, - Member CodeStore r, - Member ConversationStore r, - Member (Error FederationError) r, - Member (Error InternalError) r, - Member (Error AuthenticationError) r, - Member (ErrorS 'NotATeamMember) r, - Member (ErrorS ('ActionDenied 'RemoveConversationMember)) r, - Member (ErrorS 'LegalHoldServiceNotRegistered) r, - Member (ErrorS 'UserLegalHoldIllegalOperation) r, - Member (ErrorS 'LegalHoldCouldNotBlockConnections) r, - Member ExternalAccess r, - Member FederatorAccess r, - Member FireAndForget r, - Member GundeckAccess r, - Member (Input UTCTime) r, - Member (Input (Local ())) r, - Member (Input Env) r, - Member LegalHoldStore r, - Member (ListItems LegacyPaging ConvId) r, - Member MemberStore r, - Member (TeamMemberStore p) r, - Member TeamStore r, - Member ProposalStore r, - Member P.TinyLog r - ) + Member BackendNotificationQueueAccess r, + Member BrigAccess r, + Member ConversationStore r, + Member (Error FederationError) r, + Member (Error InternalError) r, + Member (ErrorS ('ActionDenied 'RemoveConversationMember)) r, + Member (ErrorS 'LegalHoldServiceNotRegistered) r, + Member (ErrorS 'UserLegalHoldIllegalOperation) r, + Member (ErrorS 'LegalHoldCouldNotBlockConnections) r, + Member ExternalAccess r, + Member FederatorAccess r, + Member FireAndForget r, + Member GundeckAccess r, + Member (Input UTCTime) r, + Member (Input (Local ())) r, + Member (Input Env) r, + Member LegalHoldStore r, + Member (ListItems LegacyPaging ConvId) r, + Member MemberStore r, + Member (TeamMemberStore p) r, + Member TeamStore r, + Member ProposalStore r, + Member P.TinyLog r, + Member SubConversationStore r ) => TeamId -> Sem r () @@ -335,7 +328,8 @@ getUserStatus _lzusr tid uid = do -- @withdrawExplicitConsentH@ (lots of corner cases we'd have to implement for that to pan -- out). grantConsent :: - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member (Error FederationError) r, Member (Error InternalError) r, @@ -353,6 +347,7 @@ grantConsent :: Member MemberStore r, Member ProposalStore r, Member P.TinyLog r, + Member SubConversationStore r, Member TeamStore r ) => Local UserId -> @@ -372,7 +367,8 @@ grantConsent lusr tid = do -- | Request to provision a device on the legal hold service for a user requestDevice :: forall r. - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member (Error FederationError) r, Member (Error InternalError) r, @@ -398,6 +394,7 @@ requestDevice :: Member MemberStore r, Member ProposalStore r, Member P.TinyLog r, + Member SubConversationStore r, Member TeamFeatureStore r, Member TeamStore r ) => @@ -450,7 +447,8 @@ requestDevice lzusr tid uid = do -- since they are replaced if needed when registering new LH devices. approveDevice :: forall r. - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member (Error AuthenticationError) r, Member (Error FederationError) r, @@ -476,6 +474,7 @@ approveDevice :: Member MemberStore r, Member ProposalStore r, Member P.TinyLog r, + Member SubConversationStore r, Member TeamFeatureStore r, Member TeamStore r ) => @@ -528,7 +527,8 @@ approveDevice lzusr connId tid uid (Public.ApproveLegalHoldForUserRequest mPassw disableForUser :: forall r. - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member (Error AuthenticationError) r, Member (Error FederationError) r, @@ -550,6 +550,7 @@ disableForUser :: Member MemberStore r, Member ProposalStore r, Member P.TinyLog r, + Member SubConversationStore r, Member TeamStore r ) => Local UserId -> @@ -585,7 +586,8 @@ disableForUser lzusr tid uid (Public.DisableLegalHoldForUserRequest mPassword) = -- or disabled, make sure the affected connections are screened for policy conflict (anybody -- with no-consent), and put those connections in the appropriate blocked state. changeLegalholdStatus :: - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member (Error FederationError) r, Member (Error InternalError) r, @@ -602,7 +604,8 @@ changeLegalholdStatus :: Member MemberStore r, Member TeamStore r, Member ProposalStore r, - Member P.TinyLog r + Member P.TinyLog r, + Member SubConversationStore r ) => TeamId -> Local UserId -> @@ -702,7 +705,8 @@ unsetTeamLegalholdWhitelistedH tid = do -- contains the hypothetical new LH status of `uid`'s so it can be consulted instead of the -- one from the database. handleGroupConvPolicyConflicts :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (Error FederationError) r, Member (Error InternalError) r, Member (ErrorS ('ActionDenied 'RemoveConversationMember)) r, @@ -715,6 +719,7 @@ handleGroupConvPolicyConflicts :: Member MemberStore r, Member ProposalStore r, Member P.TinyLog r, + Member SubConversationStore r, Member TeamStore r ) => Local UserId -> diff --git a/services/galley/src/Galley/API/LegalHold/Conflicts.hs b/services/galley/src/Galley/API/LegalHold/Conflicts.hs index 68b7920f856..a6d098b459b 100644 --- a/services/galley/src/Galley/API/LegalHold/Conflicts.hs +++ b/services/galley/src/Galley/API/LegalHold/Conflicts.hs @@ -84,7 +84,7 @@ guardLegalholdPolicyConflicts LegalholdPlusFederationNotImplemented _otherClient guardLegalholdPolicyConflicts UnprotectedBot _otherClients = pure () guardLegalholdPolicyConflicts (ProtectedUser self) otherClients = do opts <- input - case view (optSettings . setFeatureFlags . flagLegalHold) opts of + case view (settings . featureFlags . flagLegalHold) opts of FeatureLegalHoldDisabledPermanently -> case FutureWork @'LegalholdPlusFederationNotImplemented () of FutureWork () -> pure () -- FUTUREWORK: if federation is enabled, we still need to run the guard! FeatureLegalHoldDisabledByDefault -> guardLegalholdPolicyConflictsUid self otherClients diff --git a/services/galley/src/Galley/API/MLS.hs b/services/galley/src/Galley/API/MLS.hs index cbd8307232e..2b06791739d 100644 --- a/services/galley/src/Galley/API/MLS.hs +++ b/services/galley/src/Galley/API/MLS.hs @@ -18,11 +18,9 @@ module Galley.API.MLS ( isMLSEnabled, assertMLSEnabled, - postMLSWelcomeFromLocalUser, postMLSMessage, postMLSCommitBundleFromLocalUser, postMLSMessageFromLocalUser, - postMLSMessageFromLocalUserV1, getMLSPublicKeys, ) where @@ -32,7 +30,6 @@ import Data.Id import Data.Qualified import Galley.API.MLS.Enabled import Galley.API.MLS.Message -import Galley.API.MLS.Welcome import Galley.Env import Imports import Polysemy diff --git a/services/galley/src/Galley/API/MLS/Commit.hs b/services/galley/src/Galley/API/MLS/Commit.hs new file mode 100644 index 00000000000..39088273b8b --- /dev/null +++ b/services/galley/src/Galley/API/MLS/Commit.hs @@ -0,0 +1,28 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.API.MLS.Commit + ( getCommitData, + getExternalCommitData, + processInternalCommit, + processExternalCommit, + ) +where + +import Galley.API.MLS.Commit.Core +import Galley.API.MLS.Commit.ExternalCommit +import Galley.API.MLS.Commit.InternalCommit diff --git a/services/galley/src/Galley/API/MLS/Commit/Core.hs b/services/galley/src/Galley/API/MLS/Commit/Core.hs new file mode 100644 index 00000000000..99cbd5106c6 --- /dev/null +++ b/services/galley/src/Galley/API/MLS/Commit/Core.hs @@ -0,0 +1,198 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.API.MLS.Commit.Core + ( getCommitData, + incrementEpoch, + getClientInfo, + HasProposalActionEffects, + ProposalErrors, + HandleMLSProposalFailures (..), + ) +where + +import Control.Comonad +import Data.Id +import Data.Qualified +import Data.Time +import Galley.API.Error +import Galley.API.MLS.Conversation +import Galley.API.MLS.Proposal +import Galley.API.MLS.Types +import Galley.Effects +import Galley.Effects.BrigAccess +import Galley.Effects.ConversationStore +import Galley.Effects.FederatorAccess +import Galley.Effects.SubConversationStore +import Galley.Env +import Galley.Options +import Imports +import Polysemy +import Polysemy.Error +import Polysemy.Input +import Polysemy.Internal +import Polysemy.State +import Polysemy.TinyLog +import Wire.API.Conversation.Protocol +import Wire.API.Conversation.Role +import Wire.API.Error +import Wire.API.Error.Galley +import Wire.API.Federation.API +import Wire.API.Federation.API.Brig +import Wire.API.Federation.Error +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.Commit +import Wire.API.MLS.Credential +import Wire.API.MLS.SubConversation +import Wire.API.User.Client + +type HasProposalActionEffects r = + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, + Member ConversationStore r, + Member (Error InternalError) r, + Member (ErrorS 'ConvNotFound) r, + Member (ErrorS 'MLSClientMismatch) r, + Member (Error MLSProposalFailure) r, + Member (ErrorS 'MissingLegalholdConsent) r, + Member (ErrorS 'MLSUnsupportedProposal) r, + Member (Error MLSProtocolError) r, + Member (Error NonFederatingBackends) r, + Member (Error UnreachableBackends) r, + Member (ErrorS 'MLSSelfRemovalNotAllowed) r, + Member ExternalAccess r, + Member FederatorAccess r, + Member GundeckAccess r, + Member (Input Env) r, + Member (Input Opts) r, + Member (Input UTCTime) r, + Member LegalHoldStore r, + Member MemberStore r, + Member ProposalStore r, + Member SubConversationStore r, + Member TeamStore r, + Member TinyLog r + ) + +getCommitData :: + ( HasProposalEffects r, + Member (ErrorS 'MLSProposalNotFound) r + ) => + ClientIdentity -> + Local ConvOrSubConv -> + Epoch -> + Commit -> + Sem r ProposalAction +getCommitData senderIdentity lConvOrSub epoch commit = do + let convOrSub = tUnqualified lConvOrSub + groupId = cnvmlsGroupId convOrSub.mlsMeta + + evalState convOrSub.indexMap $ do + creatorAction <- + if epoch == Epoch 0 + then addProposedClient senderIdentity + else mempty + proposals <- traverse (derefOrCheckProposal convOrSub.mlsMeta groupId epoch) commit.proposals + action <- applyProposals convOrSub.mlsMeta groupId proposals + pure (creatorAction <> action) + +incrementEpoch :: + ( Member ConversationStore r, + Member (ErrorS 'ConvNotFound) r, + Member MemberStore r, + Member SubConversationStore r + ) => + ConvOrSubConv -> + Sem r ConvOrSubConv +incrementEpoch (Conv c) = do + let epoch' = succ (cnvmlsEpoch (mcMLSData c)) + setConversationEpoch (mcId c) epoch' + conv <- getConversation (mcId c) >>= noteS @'ConvNotFound + fmap Conv (mkMLSConversation conv >>= noteS @'ConvNotFound) +incrementEpoch (SubConv c s) = do + let epoch' = succ (cnvmlsEpoch (scMLSData s)) + setSubConversationEpoch (scParentConvId s) (scSubConvId s) epoch' + subconv <- + getSubConversation (mcId c) (scSubConvId s) >>= noteS @'ConvNotFound + pure (SubConv c subconv) + +getClientInfo :: + ( Member BrigAccess r, + Member FederatorAccess r + ) => + Local x -> + Qualified UserId -> + CipherSuiteTag -> + Sem r (Either FederationError (Set ClientInfo)) +getClientInfo loc = + foldQualified loc (\lusr -> fmap Right . getLocalMLSClients lusr) getRemoteMLSClients + +getRemoteMLSClients :: + ( Member FederatorAccess r + ) => + Remote UserId -> + CipherSuiteTag -> + Sem r (Either FederationError (Set ClientInfo)) +getRemoteMLSClients rusr suite = do + runFederatedEither rusr $ + fedClient @'Brig @"get-mls-clients" $ + MLSClientsRequest + { userId = tUnqualified rusr, + cipherSuite = tagCipherSuite suite + } + +-------------------------------------------------------------------------------- +-- Error handling of proposal execution + +-- The following errors are caught by 'executeProposalAction' and wrapped in a +-- 'MLSProposalFailure'. This way errors caused by the execution of proposals are +-- separated from those caused by the commit processing itself. +type ProposalErrors = + '[ Error FederationError, + Error InvalidInput, + ErrorS ('ActionDenied 'AddConversationMember), + ErrorS ('ActionDenied 'LeaveConversation), + ErrorS ('ActionDenied 'RemoveConversationMember), + ErrorS 'ConvAccessDenied, + ErrorS 'InvalidOperation, + ErrorS 'NotATeamMember, + ErrorS 'NotConnected, + ErrorS 'TooManyMembers + ] + +class HandleMLSProposalFailures effs r where + handleMLSProposalFailures :: Sem (Append effs r) a -> Sem r a + +class HandleMLSProposalFailure eff r where + handleMLSProposalFailure :: Sem (eff ': r) a -> Sem r a + +instance HandleMLSProposalFailures '[] r where + handleMLSProposalFailures = id + +instance + ( HandleMLSProposalFailures effs r, + HandleMLSProposalFailure eff (Append effs r) + ) => + HandleMLSProposalFailures (eff ': effs) r + where + handleMLSProposalFailures = handleMLSProposalFailures @effs . handleMLSProposalFailure @eff + +instance + (APIError e, Member (Error MLSProposalFailure) r) => + HandleMLSProposalFailure (Error e) r + where + handleMLSProposalFailure = mapError (MLSProposalFailure . toResponse) diff --git a/services/galley/src/Galley/API/MLS/Commit/ExternalCommit.hs b/services/galley/src/Galley/API/MLS/Commit/ExternalCommit.hs new file mode 100644 index 00000000000..907e9ecb36d --- /dev/null +++ b/services/galley/src/Galley/API/MLS/Commit/ExternalCommit.hs @@ -0,0 +1,198 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.API.MLS.Commit.ExternalCommit + ( getExternalCommitData, + processExternalCommit, + ) +where + +import Control.Comonad +import Control.Lens (forOf_) +import Data.Map qualified as Map +import Data.Qualified +import Data.Set qualified as Set +import Galley.API.MLS.Commit.Core +import Galley.API.MLS.Proposal +import Galley.API.MLS.Removal +import Galley.API.MLS.Types +import Galley.API.MLS.Util +import Galley.Effects +import Galley.Effects.MemberStore +import Imports hiding (cs) +import Polysemy +import Polysemy.Error +import Polysemy.Resource (Resource) +import Polysemy.State +import Wire.API.Conversation.Protocol +import Wire.API.Error +import Wire.API.Error.Galley +import Wire.API.MLS.Commit +import Wire.API.MLS.Credential +import Wire.API.MLS.LeafNode +import Wire.API.MLS.Proposal +import Wire.API.MLS.ProposalTag +import Wire.API.MLS.Serialisation +import Wire.API.MLS.SubConversation +import Wire.API.MLS.Validation + +data ExternalCommitAction = ExternalCommitAction + { add :: LeafIndex, + remove :: Maybe LeafIndex + } + +getExternalCommitData :: + forall r. + ( Member (Error MLSProtocolError) r, + Member (ErrorS 'MLSStaleMessage) r, + Member (ErrorS 'MLSUnsupportedProposal) r, + Member (ErrorS 'MLSInvalidLeafNodeIndex) r + ) => + ClientIdentity -> + Local ConvOrSubConv -> + Epoch -> + Commit -> + Sem r ExternalCommitAction +getExternalCommitData senderIdentity lConvOrSub epoch commit = do + let convOrSub = tUnqualified lConvOrSub + curEpoch = cnvmlsEpoch convOrSub.mlsMeta + groupId = cnvmlsGroupId convOrSub.mlsMeta + when (epoch /= curEpoch) $ throwS @'MLSStaleMessage + when (epoch == Epoch 0) $ + throw $ + mlsProtocolError "The first commit in a group cannot be external" + proposals <- traverse getInlineProposal commit.proposals + + -- According to the spec, an external commit must contain: + -- (https://messaginglayersecurity.rocks/mls-protocol/draft-ietf-mls-protocol.html#section-12.2) + -- + -- > Exactly one ExternalInit + -- > At most one Remove proposal, with which the joiner removes an old + -- > version of themselves. + -- > Zero or more PreSharedKey proposals. + -- > No other proposals. + let counts = foldr (\x -> Map.insertWith (+) x.tag (1 :: Int)) mempty proposals + + unless (Map.lookup ExternalInitProposalTag counts == Just 1) $ + throw (mlsProtocolError "External commits must contain exactly one ExternalInit proposal") + unless (null (Map.keys counts \\ allowedProposals)) $ + throw (mlsProtocolError "Invalid proposal type in an external commit") + + evalState convOrSub.indexMap $ do + -- process optional removal + propAction <- applyProposals convOrSub.mlsMeta groupId proposals + removedIndex <- case cmAssocs (paRemove propAction) of + [(cid, idx)] + | cid /= senderIdentity -> + throw $ mlsProtocolError "Only the self client can be removed by an external commit" + | otherwise -> pure (Just idx) + [] -> pure Nothing + _ -> throw (mlsProtocolError "External commits must contain at most one Remove proposal") + + -- add sender client + addedIndex <- gets imNextIndex + + pure + ExternalCommitAction + { add = addedIndex, + remove = removedIndex + } + where + allowedProposals = [ExternalInitProposalTag, RemoveProposalTag, PreSharedKeyProposalTag] + + getInlineProposal :: ProposalOrRef -> Sem r Proposal + getInlineProposal (Ref _) = + throw (mlsProtocolError "External commits cannot reference proposals") + getInlineProposal (Inline p) = pure p + +processExternalCommit :: + forall r. + ( Member (ErrorS 'MLSStaleMessage) r, + Member (ErrorS 'MLSSubConvClientNotInParent) r, + Member Resource r, + HasProposalActionEffects r + ) => + ClientIdentity -> + Local ConvOrSubConv -> + Epoch -> + ExternalCommitAction -> + Maybe UpdatePath -> + Sem r () +processExternalCommit senderIdentity lConvOrSub epoch action updatePath = do + let convOrSub = tUnqualified lConvOrSub + + -- only members can join a subconversation + forOf_ _SubConv convOrSub $ \(mlsConv, _) -> + unless (isClientMember senderIdentity (mcMembers mlsConv)) $ + throwS @'MLSSubConvClientNotInParent + + -- extract leaf node from update path and validate it + leafNode <- + (.leaf) + <$> note + (mlsProtocolError "External commits need an update path") + updatePath + let cs = cnvmlsCipherSuite (tUnqualified lConvOrSub).mlsMeta + let groupId = cnvmlsGroupId convOrSub.mlsMeta + let extra = LeafNodeTBSExtraCommit groupId action.add + case validateLeafNode cs (Just senderIdentity) extra leafNode.value of + Left errMsg -> + throw $ + mlsProtocolError ("Tried to add invalid LeafNode: " <> errMsg) + Right _ -> pure () + + withCommitLock (fmap (.id) lConvOrSub) groupId epoch $ do + executeExternalCommitAction lConvOrSub senderIdentity action + + -- increment epoch number + lConvOrSub' <- for lConvOrSub incrementEpoch + + -- fetch backend remove proposals of the previous epoch + indicesInRemoveProposals <- + -- skip remove proposals of already removed by the external commit + (\\ toList action.remove) + <$> getPendingBackendRemoveProposals groupId epoch + + -- requeue backend remove proposals for the current epoch + createAndSendRemoveProposals + lConvOrSub' + indicesInRemoveProposals + (cidQualifiedUser senderIdentity) + (tUnqualified lConvOrSub').members + +executeExternalCommitAction :: + forall r. + HasProposalActionEffects r => + Local ConvOrSubConv -> + ClientIdentity -> + ExternalCommitAction -> + Sem r () +executeExternalCommitAction lconvOrSub senderIdentity action = do + let mlsMeta = (tUnqualified lconvOrSub).mlsMeta + + -- Remove deprecated sender client from conversation state. + for_ action.remove $ \_ -> + removeMLSClients + (cnvmlsGroupId mlsMeta) + (cidQualifiedUser senderIdentity) + (Set.singleton (ciClient senderIdentity)) + + -- Add new sender client to the conversation state. + addMLSClients + (cnvmlsGroupId mlsMeta) + (cidQualifiedUser senderIdentity) + (Set.singleton (ciClient senderIdentity, action.add)) diff --git a/services/galley/src/Galley/API/MLS/Commit/InternalCommit.hs b/services/galley/src/Galley/API/MLS/Commit/InternalCommit.hs new file mode 100644 index 00000000000..b7ac03592c9 --- /dev/null +++ b/services/galley/src/Galley/API/MLS/Commit/InternalCommit.hs @@ -0,0 +1,317 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.API.MLS.Commit.InternalCommit (processInternalCommit) where + +import Control.Comonad +import Control.Error.Util (hush) +import Control.Lens +import Control.Lens.Extras (is) +import Data.Id +import Data.List.NonEmpty (NonEmpty, nonEmpty) +import Data.Map qualified as Map +import Data.Qualified +import Data.Set qualified as Set +import Data.Tuple.Extra +import Galley.API.Action +import Galley.API.Error +import Galley.API.MLS.Commit.Core +import Galley.API.MLS.Conversation +import Galley.API.MLS.One2One +import Galley.API.MLS.Proposal +import Galley.API.MLS.Types +import Galley.API.MLS.Util +import Galley.API.Util +import Galley.Data.Conversation.Types hiding (Conversation) +import Galley.Data.Conversation.Types qualified as Data +import Galley.Effects +import Galley.Effects.MemberStore +import Galley.Effects.ProposalStore +import Galley.Effects.SubConversationStore +import Galley.Types.Conversations.Members +import Imports +import Polysemy +import Polysemy.Error +import Polysemy.Resource (Resource) +import Wire.API.Conversation hiding (Member) +import Wire.API.Conversation.Action +import Wire.API.Conversation.Protocol +import Wire.API.Conversation.Role +import Wire.API.Error +import Wire.API.Error.Galley +import Wire.API.MLS.Commit +import Wire.API.MLS.Credential +import Wire.API.MLS.Proposal qualified as Proposal +import Wire.API.MLS.SubConversation +import Wire.API.Unreachable +import Wire.API.User.Client + +processInternalCommit :: + forall r. + ( HasProposalEffects r, + Member (ErrorS 'ConvNotFound) r, + Member (ErrorS 'MLSCommitMissingReferences) r, + Member (ErrorS 'MLSSelfRemovalNotAllowed) r, + Member (ErrorS 'MLSStaleMessage) r, + Member (ErrorS 'MissingLegalholdConsent) r, + Member SubConversationStore r, + Member Resource r + ) => + ClientIdentity -> + Maybe ConnId -> + Local ConvOrSubConv -> + Epoch -> + ProposalAction -> + Commit -> + Sem r [LocalConversationUpdate] +processInternalCommit senderIdentity con lConvOrSub epoch action commit = do + let convOrSub = tUnqualified lConvOrSub + qusr = cidQualifiedUser senderIdentity + cm = convOrSub.members + suite = cnvmlsCipherSuite convOrSub.mlsMeta + newUserClients = Map.assocs (paAdd action) + + -- check all pending proposals are referenced in the commit + allPendingProposals <- getAllPendingProposalRefs (cnvmlsGroupId convOrSub.mlsMeta) epoch + let referencedProposals = Set.fromList $ mapMaybe (\x -> preview Proposal._Ref x) commit.proposals + unless (all (`Set.member` referencedProposals) allPendingProposals) $ + throwS @'MLSCommitMissingReferences + + withCommitLock (fmap (.id) lConvOrSub) (cnvmlsGroupId convOrSub.mlsMeta) epoch $ do + -- no client can be directly added to a subconversation + when (is _SubConv convOrSub && any ((senderIdentity /=) . fst) (cmAssocs (paAdd action))) $ + throw (mlsProtocolError "Add proposals in subconversations are not supported") + + events <- + if convOrSub.migrationState == MLSMigrationMLS + then do + -- Note [client removal] + -- We support two types of removals: + -- 1. when a user is removed from a group, all their clients have to be removed + -- 2. when a client is deleted, that particular client (but not necessarily + -- other clients of the same user) has to be removed. + -- + -- Type 2 requires no special processing on the backend, so here we filter + -- out all removals of that type, so that further checks and processing can + -- be applied only to type 1 removals. + -- + -- Furthermore, subconversation clients can be removed arbitrarily, so this + -- processing is only necessary for main conversations. In the + -- subconversation case, an empty list is returned. + membersToRemove <- case convOrSub of + SubConv _ _ -> pure [] + Conv _ -> mapMaybe hush <$$> for (Map.assocs (paRemove action)) $ + \(qtarget, Map.keysSet -> clients) -> runError @() $ do + let clientsInConv = Map.keysSet (Map.findWithDefault mempty qtarget cm) + let removedClients = Set.intersection clients clientsInConv + + -- ignore user if none of their clients are being removed + when (Set.null removedClients) $ throw () + + -- return error if the user is trying to remove themself + when (cidQualifiedUser senderIdentity == qtarget) $ + throwS @'MLSSelfRemovalNotAllowed + + -- FUTUREWORK: add tests against this situation for conv v subconv + when (removedClients /= clientsInConv) $ do + -- FUTUREWORK: turn this error into a proper response + throwS @'MLSClientMismatch + + pure qtarget + + -- for each user, we compare their clients with the ones being added to the conversation + failedAddFetching <- fmap catMaybes . forM newUserClients $ + \(qtarget, newclients) -> case Map.lookup qtarget cm of + -- user is already present, skip check in this case + Just _ -> do + -- new user + pure Nothing + Nothing -> do + -- final set of clients in the conversation + let clients = Map.keysSet (newclients <> Map.findWithDefault mempty qtarget cm) + -- get list of mls clients from Brig (local or remote) + getClientInfo lConvOrSub qtarget suite >>= \case + Left _e -> pure (Just qtarget) + Right clientInfo -> do + let allClients = Set.map ciId clientInfo + let allMLSClients = Set.map ciId (Set.filter ciMLS clientInfo) + -- We check the following condition: + -- allMLSClients ⊆ clients ⊆ allClients + -- i.e. + -- - if a client has at least 1 key package, it has to be added + -- - if a client is being added, it has to still exist + -- + -- The reason why we can't simply check that clients == allMLSClients is + -- that a client with no remaining key packages might be added by a user + -- who just fetched its last key package. + unless + ( Set.isSubsetOf allMLSClients clients + && Set.isSubsetOf clients allClients + ) + $ do + -- FUTUREWORK: turn this error into a proper response + throwS @'MLSClientMismatch + pure Nothing + for_ + (unreachableFromList failedAddFetching) + (throw . unreachableUsersToUnreachableBackends) + + -- Some types of conversations are created lazily on the first + -- commit. We do that here, with the commit lock held, but before + -- applying changes to the member list. + case convOrSub.id of + SubConv cnv sub | epoch == Epoch 0 -> do + -- create subconversation if it doesn't exist + msub' <- getSubConversation cnv sub + when (isNothing msub') $ + void $ + createSubConversation + cnv + sub + convOrSub.mlsMeta.cnvmlsCipherSuite + convOrSub.mlsMeta.cnvmlsGroupId + pure [] + Conv _ + | convOrSub.meta.cnvmType == One2OneConv + && epoch == Epoch 0 -> do + -- create 1-1 conversation with the users as members, set + -- epoch to 0 for now, it will be incremented later + let senderUser = cidQualifiedUser senderIdentity + mlsConv = fmap (.conv) lConvOrSub + lconv = fmap mcConv mlsConv + conv <- case filter ((/= senderUser) . fst) newUserClients of + [(otherUser, _)] -> + createMLSOne2OneConversation + senderUser + otherUser + mlsConv + _ -> + throw + ( mlsProtocolError + "The first commit in a 1-1 conversation should add exactly 1 other user" + ) + -- notify otherUser about being added to this 1-1 conversation + let bm = convBotsAndMembers conv + members <- + note + ( InternalErrorWithDescription + "Unexpected empty member list in MLS 1-1 conversation" + ) + $ nonEmpty (bmQualifiedMembers lconv bm) + update <- + notifyConversationAction + SConversationJoinTag + senderUser + False + con + lconv + bm + ConversationJoin + { cjUsers = members, + cjRole = roleNameWireMember + } + pure [update] + _ -> do + -- remove users from the conversation and send events + removeEvents <- + foldMap + (removeMembers qusr con lConvOrSub) + (nonEmpty membersToRemove) + + -- add users to the conversation and send events + addEvents <- + foldMap (addMembers qusr con lConvOrSub) + . nonEmpty + . map fst + $ newUserClients + pure (addEvents <> removeEvents) + else pure [] + + -- Remove clients from the conversation state. This includes client removals + -- of all types (see Note [client removal]). + for_ (Map.assocs (paRemove action)) $ \(qtarget, clients) -> do + removeMLSClients (cnvmlsGroupId convOrSub.mlsMeta) qtarget (Map.keysSet clients) + + -- add clients to the conversation state + for_ newUserClients $ \(qtarget, newClients) -> do + addMLSClients (cnvmlsGroupId convOrSub.mlsMeta) qtarget (Set.fromList (Map.assocs newClients)) + + -- increment epoch number + for_ lConvOrSub incrementEpoch + + pure events + +addMembers :: + HasProposalActionEffects r => + Qualified UserId -> + Maybe ConnId -> + Local ConvOrSubConv -> + NonEmpty (Qualified UserId) -> + Sem r [LocalConversationUpdate] +addMembers qusr con lConvOrSub users = case tUnqualified lConvOrSub of + Conv mlsConv -> do + let lconv = qualifyAs lConvOrSub (mcConv mlsConv) + -- FUTUREWORK: update key package ref mapping to reflect conversation membership + foldMap + ( handleNoChanges + . handleMLSProposalFailures @ProposalErrors + . fmap pure + . updateLocalConversationUnchecked @'ConversationJoinTag lconv qusr con + . flip ConversationJoin roleNameWireMember + ) + . nonEmpty + . filter (flip Set.notMember (existingMembers lconv)) + . toList + $ users + SubConv _ _ -> pure [] + +removeMembers :: + HasProposalActionEffects r => + Qualified UserId -> + Maybe ConnId -> + Local ConvOrSubConv -> + NonEmpty (Qualified UserId) -> + Sem r [LocalConversationUpdate] +removeMembers qusr con lConvOrSub users = case tUnqualified lConvOrSub of + Conv mlsConv -> do + let lconv = qualifyAs lConvOrSub (mcConv mlsConv) + foldMap + ( handleNoChanges + . handleMLSProposalFailures @ProposalErrors + . fmap pure + . updateLocalConversationUnchecked @'ConversationRemoveMembersTag lconv qusr con + ) + . nonEmpty + . filter (flip Set.member (existingMembers lconv)) + . toList + $ users + SubConv _ _ -> pure [] + +handleNoChanges :: Monoid a => Sem (Error NoChanges ': r) a -> Sem r a +handleNoChanges = fmap fold . runError + +existingLocalMembers :: Local Data.Conversation -> Set (Qualified UserId) +existingLocalMembers lconv = + (Set.fromList . map (fmap lmId . tUntagged)) (traverse convLocalMembers lconv) + +existingRemoteMembers :: Local Data.Conversation -> Set (Qualified UserId) +existingRemoteMembers lconv = + Set.fromList . map (tUntagged . rmId) . convRemoteMembers . tUnqualified $ + lconv + +existingMembers :: Local Data.Conversation -> Set (Qualified UserId) +existingMembers lconv = existingLocalMembers lconv <> existingRemoteMembers lconv diff --git a/services/galley/src/Galley/API/MLS/Conversation.hs b/services/galley/src/Galley/API/MLS/Conversation.hs new file mode 100644 index 00000000000..1a7ed3d62bc --- /dev/null +++ b/services/galley/src/Galley/API/MLS/Conversation.hs @@ -0,0 +1,77 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.API.MLS.Conversation + ( mkMLSConversation, + newMLSConversation, + mcConv, + ) +where + +import Data.Id +import Data.Qualified +import Galley.API.MLS.Types +import Galley.Data.Conversation.Types as Data +import Galley.Effects.MemberStore +import Imports +import Polysemy +import Wire.API.Conversation hiding (Member) +import Wire.API.Conversation.Protocol + +mkMLSConversation :: + Member MemberStore r => + Data.Conversation -> + Sem r (Maybe MLSConversation) +mkMLSConversation conv = + for (Data.mlsMetadata conv) $ \(mlsData, migrationState) -> do + (cm, im) <- lookupMLSClientLeafIndices (cnvmlsGroupId mlsData) + pure + MLSConversation + { mcId = Data.convId conv, + mcMetadata = Data.convMetadata conv, + mcLocalMembers = Data.convLocalMembers conv, + mcRemoteMembers = Data.convRemoteMembers conv, + mcMLSData = mlsData, + mcMembers = cm, + mcIndexMap = im, + mcMigrationState = migrationState + } + +-- | Creates a new MLS conversation with members but no clients. +newMLSConversation :: Local ConvId -> ConversationMetadata -> ConversationMLSData -> MLSConversation +newMLSConversation lcnv meta mlsData = + MLSConversation + { mcId = tUnqualified lcnv, + mcMetadata = meta, + mcMLSData = mlsData, + mcLocalMembers = [], + mcRemoteMembers = [], + mcMembers = mempty, + mcIndexMap = mempty, + mcMigrationState = MLSMigrationMLS + } + +mcConv :: MLSConversation -> Data.Conversation +mcConv mlsConv = + Data.Conversation + { convId = mcId mlsConv, + convLocalMembers = mcLocalMembers mlsConv, + convRemoteMembers = mcRemoteMembers mlsConv, + convDeleted = False, + convMetadata = mcMetadata mlsConv, + convProtocol = ProtocolMLS (mcMLSData mlsConv) + } diff --git a/services/galley/src/Galley/API/MLS/GroupInfo.hs b/services/galley/src/Galley/API/MLS/GroupInfo.hs index 58772657b25..692b9524a5d 100644 --- a/services/galley/src/Galley/API/MLS/GroupInfo.hs +++ b/services/galley/src/Galley/API/MLS/GroupInfo.hs @@ -36,7 +36,8 @@ import Wire.API.Error.Galley import Wire.API.Federation.API import Wire.API.Federation.API.Galley import Wire.API.Federation.Error -import Wire.API.MLS.PublicGroupState +import Wire.API.MLS.GroupInfo +import Wire.API.MLS.SubConversation type MLSGroupInfoStaticErrors = '[ ErrorS 'ConvNotFound, @@ -54,13 +55,13 @@ getGroupInfo :: Members MLSGroupInfoStaticErrors r => Local UserId -> Qualified ConvId -> - Sem r OpaquePublicGroupState + Sem r GroupInfoData getGroupInfo lusr qcnvId = do assertMLSEnabled foldQualified lusr (getGroupInfoFromLocalConv . tUntagged $ lusr) - (getGroupInfoFromRemoteConv lusr) + (getGroupInfoFromRemoteConv lusr . fmap Conv) qcnvId getGroupInfoFromLocalConv :: @@ -70,10 +71,10 @@ getGroupInfoFromLocalConv :: Members MLSGroupInfoStaticErrors r => Qualified UserId -> Local ConvId -> - Sem r OpaquePublicGroupState + Sem r GroupInfoData getGroupInfoFromLocalConv qusr lcnvId = do void $ getLocalConvForUser qusr lcnvId - E.getPublicGroupState (tUnqualified lcnvId) + E.getGroupInfo (tUnqualified lcnvId) >>= noteS @'MLSMissingGroupInfo getGroupInfoFromRemoteConv :: @@ -82,19 +83,19 @@ getGroupInfoFromRemoteConv :: ) => Members MLSGroupInfoStaticErrors r => Local UserId -> - Remote ConvId -> - Sem r OpaquePublicGroupState + Remote ConvOrSubConvId -> + Sem r GroupInfoData getGroupInfoFromRemoteConv lusr rcnv = do let getRequest = GetGroupInfoRequest - { ggireqSender = tUnqualified lusr, - ggireqConv = tUnqualified rcnv + { sender = tUnqualified lusr, + conv = tUnqualified rcnv } response <- E.runFederated rcnv (fedClient @'Galley @"query-group-info" getRequest) case response of GetGroupInfoResponseError e -> rethrowErrors @MLSGroupInfoStaticErrors e GetGroupInfoResponseState s -> pure - . OpaquePublicGroupState + . GroupInfoData . fromBase64ByteString $ s diff --git a/services/galley/src/Galley/API/MLS/IncomingMessage.hs b/services/galley/src/Galley/API/MLS/IncomingMessage.hs new file mode 100644 index 00000000000..b4a8b7fb206 --- /dev/null +++ b/services/galley/src/Galley/API/MLS/IncomingMessage.hs @@ -0,0 +1,131 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.API.MLS.IncomingMessage + ( IncomingMessage (..), + IncomingMessageContent (..), + IncomingPublicMessageContent (..), + IncomingBundle (..), + mkIncomingMessage, + incomingMessageAuthenticatedContent, + mkIncomingBundle, + ) +where + +import GHC.Records +import Imports +import Wire.API.MLS.AuthenticatedContent +import Wire.API.MLS.Commit +import Wire.API.MLS.CommitBundle +import Wire.API.MLS.Epoch +import Wire.API.MLS.Group +import Wire.API.MLS.GroupInfo +import Wire.API.MLS.Message +import Wire.API.MLS.Serialisation +import Wire.API.MLS.Welcome + +data IncomingMessage = IncomingMessage + { epoch :: Epoch, + groupId :: GroupId, + content :: IncomingMessageContent, + rawMessage :: RawMLS Message + } + +instance HasField "sender" IncomingMessage (Maybe Sender) where + getField msg = case msg.content of + IncomingMessageContentPublic pub -> Just pub.sender + _ -> Nothing + +data IncomingMessageContent + = IncomingMessageContentPublic IncomingPublicMessageContent + | IncomingMessageContentPrivate + +data IncomingPublicMessageContent = IncomingPublicMessageContent + { sender :: Sender, + content :: FramedContentData, + -- for verification + framedContent :: RawMLS FramedContent, + authData :: RawMLS FramedContentAuthData + } + +data IncomingBundle = IncomingBundle + { epoch :: Epoch, + groupId :: GroupId, + sender :: Sender, + commit :: RawMLS Commit, + rawMessage :: RawMLS Message, + welcome :: Maybe (RawMLS Welcome), + groupInfo :: RawMLS GroupInfo, + serialized :: ByteString + } + +mkIncomingMessage :: RawMLS Message -> Maybe IncomingMessage +mkIncomingMessage msg = case msg.value.content of + MessagePublic pmsg -> + Just + IncomingMessage + { epoch = pmsg.content.value.epoch, + groupId = pmsg.content.value.groupId, + content = + IncomingMessageContentPublic + IncomingPublicMessageContent + { sender = pmsg.content.value.sender, + content = pmsg.content.value.content, + framedContent = pmsg.content, + authData = pmsg.authData + }, + rawMessage = msg + } + MessagePrivate pmsg + | pmsg.value.tag == FramedContentApplicationDataTag -> + Just + IncomingMessage + { epoch = pmsg.value.epoch, + groupId = pmsg.value.groupId, + content = IncomingMessageContentPrivate, + rawMessage = msg + } + _ -> Nothing + +incomingMessageAuthenticatedContent :: IncomingPublicMessageContent -> AuthenticatedContent +incomingMessageAuthenticatedContent pmsg = + AuthenticatedContent + { wireFormat = WireFormatPublicTag, + content = pmsg.framedContent, + authData = pmsg.authData + } + +mkIncomingBundle :: RawMLS CommitBundle -> Maybe IncomingBundle +mkIncomingBundle bundle = do + imsg <- mkIncomingMessage bundle.value.commitMsg + content <- case imsg.content of + IncomingMessageContentPublic c -> pure c + _ -> Nothing + commit <- case content.content of + FramedContentCommit c -> pure c + _ -> Nothing + pure + IncomingBundle + { epoch = imsg.epoch, + groupId = imsg.groupId, + sender = content.sender, + commit = commit, + rawMessage = bundle.value.commitMsg, + welcome = bundle.value.welcome, + groupInfo = bundle.value.groupInfo, + serialized = bundle.raw + } diff --git a/services/galley/src/Galley/API/MLS/Message.hs b/services/galley/src/Galley/API/MLS/Message.hs index c8509d199e6..6e25da04853 100644 --- a/services/galley/src/Galley/API/MLS/Message.hs +++ b/services/galley/src/Galley/API/MLS/Message.hs @@ -16,10 +16,13 @@ -- with this program. If not, see . module Galley.API.MLS.Message - ( postMLSCommitBundle, + ( IncomingBundle (..), + mkIncomingBundle, + IncomingMessage (..), + mkIncomingMessage, + postMLSCommitBundle, postMLSCommitBundleFromLocalUser, postMLSMessageFromLocalUser, - postMLSMessageFromLocalUserV1, postMLSMessage, MLSMessageStaticErrors, MLSBundleStaticErrors, @@ -27,77 +30,63 @@ module Galley.API.MLS.Message where import Control.Comonad -import Control.Error.Util (hush) -import Control.Lens (preview) import Data.Domain import Data.Id import Data.Json.Util -import Data.List.NonEmpty (NonEmpty, nonEmpty) -import Data.List.NonEmpty qualified as NE -import Data.Map qualified as Map import Data.Qualified import Data.Set qualified as Set -import Data.Text qualified as T import Data.Text.Lazy qualified as LT -import Data.Time import Data.Tuple.Extra import Galley.API.Action import Galley.API.Error +import Galley.API.MLS.Commit.Core (getCommitData) +import Galley.API.MLS.Commit.ExternalCommit +import Galley.API.MLS.Commit.InternalCommit +import Galley.API.MLS.Conversation import Galley.API.MLS.Enabled -import Galley.API.MLS.KeyPackage +import Galley.API.MLS.IncomingMessage +import Galley.API.MLS.One2One import Galley.API.MLS.Propagate -import Galley.API.MLS.Removal +import Galley.API.MLS.Proposal import Galley.API.MLS.Types import Galley.API.MLS.Util -import Galley.API.MLS.Welcome (postMLSWelcome) +import Galley.API.MLS.Welcome (sendWelcomes) import Galley.API.Util -import Galley.Data.Conversation.Types hiding (Conversation) -import Galley.Data.Conversation.Types qualified as Data -import Galley.Data.Types +import Galley.Data.Conversation.Types import Galley.Effects -import Galley.Effects.BrigAccess import Galley.Effects.ConversationStore import Galley.Effects.FederatorAccess import Galley.Effects.MemberStore -import Galley.Effects.ProposalStore -import Galley.Env -import Galley.Options -import Galley.Types.Conversations.Members +import Galley.Effects.SubConversationStore import Imports import Polysemy import Polysemy.Error import Polysemy.Input import Polysemy.Internal import Polysemy.Output -import Polysemy.Resource (Resource, bracket) +import Polysemy.Resource (Resource) import Polysemy.TinyLog import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Protocol -import Wire.API.Conversation.Role import Wire.API.Error import Wire.API.Error.Galley -import Wire.API.Event.Conversation import Wire.API.Federation.API -import Wire.API.Federation.API.Brig import Wire.API.Federation.API.Galley import Wire.API.Federation.Error import Wire.API.MLS.CipherSuite -import Wire.API.MLS.Commit +import Wire.API.MLS.Commit hiding (output) import Wire.API.MLS.CommitBundle import Wire.API.MLS.Credential -import Wire.API.MLS.GroupInfoBundle -import Wire.API.MLS.KeyPackage +import Wire.API.MLS.GroupInfo import Wire.API.MLS.Message -import Wire.API.MLS.Proposal -import Wire.API.MLS.Proposal qualified as Proposal -import Wire.API.MLS.PublicGroupState import Wire.API.MLS.Serialisation import Wire.API.MLS.SubConversation -import Wire.API.MLS.Welcome -import Wire.API.Message -import Wire.API.Routes.Internal.Brig -import Wire.API.Unreachable -import Wire.API.User.Client + +-- FUTUREWORK +-- - Check that the capabilities of a leaf node in an add proposal contains all +-- the required_capabilities of the group context. This would require fetching +-- the group info from the DB in order to read the group context. +-- - Verify message signature, this also requires the group context. (see above) type MLSMessageStaticErrors = '[ ErrorS 'ConvAccessDenied, @@ -108,14 +97,14 @@ type MLSMessageStaticErrors = ErrorS 'MLSStaleMessage, ErrorS 'MLSProposalNotFound, ErrorS 'MissingLegalholdConsent, - ErrorS 'MLSKeyPackageRefNotFound, + ErrorS 'MLSInvalidLeafNodeIndex, ErrorS 'MLSClientMismatch, ErrorS 'MLSUnsupportedProposal, ErrorS 'MLSCommitMissingReferences, ErrorS 'MLSSelfRemovalNotAllowed, ErrorS 'MLSClientSenderUserMismatch, ErrorS 'MLSGroupConversationMismatch, - ErrorS 'MLSMissingSenderClient + ErrorS 'MLSSubConvClientNotInParent ] type MLSBundleStaticErrors = @@ -123,187 +112,155 @@ type MLSBundleStaticErrors = MLSMessageStaticErrors '[ErrorS 'MLSWelcomeMismatch] -postMLSMessageFromLocalUserV1 :: - ( HasProposalEffects r, - ( Member (Error FederationError) r, - Member (Error InternalError) r, - Member (ErrorS 'ConvAccessDenied) r, - Member (ErrorS 'ConvMemberNotFound) r, - Member (ErrorS 'ConvNotFound) r, - Member (ErrorS 'MissingLegalholdConsent) r, - Member (ErrorS 'MLSClientSenderUserMismatch) r, - Member (ErrorS 'MLSCommitMissingReferences) r, - Member (ErrorS 'MLSGroupConversationMismatch) r, - Member (ErrorS 'MLSMissingSenderClient) r, - Member (ErrorS 'MLSNotEnabled) r, - Member (ErrorS 'MLSProposalNotFound) r, - Member (ErrorS 'MLSSelfRemovalNotAllowed) r, - Member (ErrorS 'MLSStaleMessage) r, - Member (ErrorS 'MLSUnsupportedMessage) r, - Member (Error NonFederatingBackends) r, - Member (Error UnreachableBackends) r, - Member (Input (Local ())) r, - Member ProposalStore r, - Member Resource r, - Member TinyLog r - ) - ) => - Local UserId -> - Maybe ClientId -> - ConnId -> - RawMLS SomeMessage -> - Sem r [Event] -postMLSMessageFromLocalUserV1 lusr mc conn smsg = do - assertMLSEnabled - case rmValue smsg of - SomeMessage _ msg -> do - qcnv <- getConversationIdByGroupId (msgGroupId msg) >>= noteS @'ConvNotFound - fst . first (map lcuEvent) - <$> postMLSMessage lusr (tUntagged lusr) mc qcnv (Just conn) smsg - postMLSMessageFromLocalUser :: ( HasProposalEffects r, - ( Member (Error FederationError) r, - Member (Error InternalError) r, - Member (ErrorS 'ConvAccessDenied) r, - Member (ErrorS 'ConvMemberNotFound) r, - Member (ErrorS 'ConvNotFound) r, - Member (ErrorS 'MissingLegalholdConsent) r, - Member (ErrorS 'MLSClientSenderUserMismatch) r, - Member (ErrorS 'MLSCommitMissingReferences) r, - Member (ErrorS 'MLSGroupConversationMismatch) r, - Member (ErrorS 'MLSMissingSenderClient) r, - Member (ErrorS 'MLSNotEnabled) r, - Member (ErrorS 'MLSProposalNotFound) r, - Member (ErrorS 'MLSSelfRemovalNotAllowed) r, - Member (ErrorS 'MLSStaleMessage) r, - Member (ErrorS 'MLSUnsupportedMessage) r, - Member (Error NonFederatingBackends) r, - Member (Error UnreachableBackends) r, - Member (Input (Local ())) r, - Member ProposalStore r, - Member Resource r, - Member TinyLog r - ) + Member (Error FederationError) r, + Member (ErrorS 'ConvAccessDenied) r, + Member (ErrorS 'ConvMemberNotFound) r, + Member (ErrorS 'ConvNotFound) r, + Member (ErrorS 'MissingLegalholdConsent) r, + Member (ErrorS 'MLSClientSenderUserMismatch) r, + Member (ErrorS 'MLSCommitMissingReferences) r, + Member (ErrorS 'MLSGroupConversationMismatch) r, + Member (ErrorS 'MLSNotEnabled) r, + Member (ErrorS 'MLSProposalNotFound) r, + Member (ErrorS 'MLSSelfRemovalNotAllowed) r, + Member (ErrorS 'MLSStaleMessage) r, + Member (ErrorS 'MLSUnsupportedMessage) r, + Member (ErrorS 'MLSSubConvClientNotInParent) r, + Member SubConversationStore r ) => Local UserId -> - Maybe ClientId -> + ClientId -> ConnId -> - RawMLS SomeMessage -> + RawMLS Message -> Sem r MLSMessageSendingStatus -postMLSMessageFromLocalUser lusr mc conn smsg = do - -- FUTUREWORK: Inline the body of 'postMLSMessageFromLocalUserV1' once version - -- V1 is dropped +postMLSMessageFromLocalUser lusr c conn smsg = do assertMLSEnabled - (events, unreachables) <- case rmValue smsg of - SomeMessage _ msg -> do - qcnv <- getConversationIdByGroupId (msgGroupId msg) >>= noteS @'ConvNotFound - first (map lcuEvent) - <$> postMLSMessage lusr (tUntagged lusr) mc qcnv (Just conn) smsg + imsg <- noteS @'MLSUnsupportedMessage $ mkIncomingMessage smsg + (ctype, cnvOrSub) <- getConvFromGroupId imsg.groupId + events <- + map lcuEvent + <$> postMLSMessage lusr (tUntagged lusr) c ctype cnvOrSub (Just conn) imsg t <- toUTCTimeMillis <$> input - pure $ MLSMessageSendingStatus events t unreachables + pure $ MLSMessageSendingStatus events t postMLSCommitBundle :: ( HasProposalEffects r, Members MLSBundleStaticErrors r, Member (Error FederationError) r, - Member (Error NonFederatingBackends) r, - Member (Error UnreachableBackends) r, - Member Resource r + Member Resource r, + Member SubConversationStore r ) => Local x -> Qualified UserId -> - Maybe ClientId -> - Qualified ConvId -> + ClientId -> + ConvType -> + Qualified ConvOrSubConvId -> Maybe ConnId -> - CommitBundle -> + IncomingBundle -> Sem r [LocalConversationUpdate] -postMLSCommitBundle loc qusr mc qcnv conn rawBundle = +postMLSCommitBundle loc qusr c ctype qConvOrSub conn bundle = foldQualified loc - (postMLSCommitBundleToLocalConv qusr mc conn rawBundle) - (postMLSCommitBundleToRemoteConv loc qusr conn rawBundle) - qcnv + (postMLSCommitBundleToLocalConv qusr c conn bundle ctype) + (postMLSCommitBundleToRemoteConv loc qusr c conn bundle ctype) + qConvOrSub postMLSCommitBundleFromLocalUser :: ( HasProposalEffects r, Members MLSBundleStaticErrors r, Member (Error FederationError) r, - Member (Error NonFederatingBackends) r, - Member (Error UnreachableBackends) r, - Member Resource r + Member Resource r, + Member SubConversationStore r ) => Local UserId -> - Maybe ClientId -> + ClientId -> ConnId -> - CommitBundle -> + RawMLS CommitBundle -> Sem r MLSMessageSendingStatus -postMLSCommitBundleFromLocalUser lusr mc conn bundle = do +postMLSCommitBundleFromLocalUser lusr c conn bundle = do assertMLSEnabled - let msg = rmValue (cbCommitMsg bundle) - qcnv <- getConversationIdByGroupId (msgGroupId msg) >>= noteS @'ConvNotFound + ibundle <- noteS @'MLSUnsupportedMessage $ mkIncomingBundle bundle + (ctype, qConvOrSub) <- getConvFromGroupId ibundle.groupId events <- map lcuEvent - <$> postMLSCommitBundle lusr (tUntagged lusr) mc qcnv (Just conn) bundle + <$> postMLSCommitBundle lusr (tUntagged lusr) c ctype qConvOrSub (Just conn) ibundle t <- toUTCTimeMillis <$> input - pure $ MLSMessageSendingStatus events t mempty + pure $ MLSMessageSendingStatus events t postMLSCommitBundleToLocalConv :: ( HasProposalEffects r, Members MLSBundleStaticErrors r, - Member (Error NonFederatingBackends) r, - Member (Error UnreachableBackends) r, - Member Resource r + Member Resource r, + Member SubConversationStore r ) => Qualified UserId -> - Maybe ClientId -> + ClientId -> Maybe ConnId -> - CommitBundle -> - Local ConvId -> + IncomingBundle -> + ConvType -> + Local ConvOrSubConvId -> Sem r [LocalConversationUpdate] -postMLSCommitBundleToLocalConv qusr mc conn bundle lcnv = do - let msg = rmValue (cbCommitMsg bundle) - conv <- getLocalConvForUser qusr lcnv - mlsMeta <- Data.mlsMetadata conv & noteS @'ConvNotFound - - let lconv = qualifyAs lcnv conv - cm <- lookupMLSClients (cnvmlsGroupId mlsMeta) - - senderClient <- fmap ciClient <$> getSenderIdentity qusr mc SMLSPlainText msg - - events <- case msgPayload msg of - CommitMessage commit -> - do - action <- getCommitData lconv mlsMeta (msgEpoch msg) commit - -- check that the welcome message matches the action - for_ (cbWelcome bundle) $ \welcome -> - when - ( Set.fromList (map gsNewMember (welSecrets (rmValue welcome))) - /= Set.fromList (map (snd . snd) (cmAssocs (paAdd action))) - ) - $ throwS @'MLSWelcomeMismatch - updates <- - processCommitWithAction - qusr - senderClient - conn - lconv - mlsMeta - cm - (msgEpoch msg) - action - (msgSender msg) - commit - storeGroupInfoBundle lconv (cbGroupInfoBundle bundle) - pure updates - ApplicationMessage _ -> throwS @'MLSUnsupportedMessage - ProposalMessage _ -> throwS @'MLSUnsupportedMessage - - mUnreachables <- propagateMessage qusr (qualifyAs lcnv conv) cm conn (rmRaw (cbCommitMsg bundle)) - traverse_ (throw . unreachableUsersToUnreachableBackends) mUnreachables - - for_ (cbWelcome bundle) $ - postMLSWelcome lcnv conn +postMLSCommitBundleToLocalConv qusr c conn bundle ctype lConvOrSubId = do + lConvOrSub <- do + lConvOrSub <- fetchConvOrSub qusr bundle.groupId ctype lConvOrSubId + let convOrSub = tUnqualified lConvOrSub + giCipherSuite <- + note (mlsProtocolError "Unsupported ciphersuite") $ + cipherSuiteTag bundle.groupInfo.value.groupContext.cipherSuite + let convCipherSuite = convOrSub.mlsMeta.cnvmlsCipherSuite + -- if this is the first commit of the conversation, update ciphersuite + if (giCipherSuite == convCipherSuite) + then pure lConvOrSub + else do + unless (convOrSub.mlsMeta.cnvmlsEpoch == Epoch 0) $ + throw $ + mlsProtocolError "GroupInfo ciphersuite does not match conversation" + -- save to cassandra + case convOrSub.id of + Conv cid -> setConversationCipherSuite cid giCipherSuite + SubConv cid sub -> + setSubConversationCipherSuite cid sub giCipherSuite + pure $ fmap (convOrSubConvSetCipherSuite giCipherSuite) lConvOrSub + + senderIdentity <- getSenderIdentity qusr c bundle.sender lConvOrSub + + (events, newClients) <- case bundle.sender of + SenderMember _index -> do + -- extract added/removed clients from bundle + action <- getCommitData senderIdentity lConvOrSub bundle.epoch bundle.commit.value + -- process additions and removals + events <- + processInternalCommit + senderIdentity + conn + lConvOrSub + bundle.epoch + action + bundle.commit.value + -- the sender client is included in the Add action on the first commit, + -- but it doesn't need to get a welcome message, so we filter it out here + let newClients = filter ((/=) senderIdentity) (cmIdentities (paAdd action)) + pure (events, newClients) + SenderExternal _ -> throw (mlsProtocolError "Unexpected sender") + SenderNewMemberProposal -> throw (mlsProtocolError "Unexpected sender") + SenderNewMemberCommit -> do + action <- getExternalCommitData senderIdentity lConvOrSub bundle.epoch bundle.commit.value + processExternalCommit + senderIdentity + lConvOrSub + bundle.epoch + action + bundle.commit.value.path + pure ([], []) + + storeGroupInfo (tUnqualified lConvOrSub).id (GroupInfoData bundle.groupInfo.raw) + + propagateMessage qusr (Just c) lConvOrSub conn bundle.rawMessage (tUnqualified lConvOrSub).members + + for_ bundle.welcome $ \welcome -> + sendWelcomes lConvOrSubId qusr conn newClients welcome pure events @@ -311,7 +268,6 @@ postMLSCommitBundleToRemoteConv :: ( Member BrigAccess r, Members MLSBundleStaticErrors r, Member (Error FederationError) r, - Member (Error InternalError) r, Member (Error MLSProtocolError) r, Member (Error MLSProposalFailure) r, Member (Error NonFederatingBackends) r, @@ -324,208 +280,166 @@ postMLSCommitBundleToRemoteConv :: ) => Local x -> Qualified UserId -> + ClientId -> Maybe ConnId -> - CommitBundle -> - Remote ConvId -> + IncomingBundle -> + ConvType -> + Remote ConvOrSubConvId -> Sem r [LocalConversationUpdate] -postMLSCommitBundleToRemoteConv loc qusr con bundle rcnv = do +postMLSCommitBundleToRemoteConv loc qusr c con bundle ctype rConvOrSubId = do -- only local users can send messages to remote conversations lusr <- foldQualified loc pure (\_ -> throwS @'ConvAccessDenied) qusr -- only members may send commit bundles to a remote conversation - flip unless (throwS @'ConvMemberNotFound) =<< checkLocalMemberRemoteConv (tUnqualified lusr) rcnv + + unless (bundle.epoch == Epoch 0 && ctype == One2OneConv) $ + flip unless (throwS @'ConvMemberNotFound) =<< checkLocalMemberRemoteConv (tUnqualified lusr) ((.conv) <$> rConvOrSubId) resp <- - runFederated rcnv $ + runFederated rConvOrSubId $ fedClient @'Galley @"send-mls-commit-bundle" $ MLSMessageSendRequest - { mmsrConvOrSubId = Conv $ tUnqualified rcnv, - mmsrSender = tUnqualified lusr, - mmsrRawMessage = Base64ByteString (serializeCommitBundle bundle) + { convOrSubId = tUnqualified rConvOrSubId, + sender = tUnqualified lusr, + senderClient = c, + rawMessage = Base64ByteString bundle.serialized } case resp of MLSMessageResponseError e -> rethrowErrors @MLSBundleStaticErrors e MLSMessageResponseProtocolError e -> throw (mlsProtocolError e) MLSMessageResponseProposalFailure e -> throw (MLSProposalFailure e) MLSMessageResponseUnreachableBackends ds -> throw (UnreachableBackends (toList ds)) - MLSMessageResponseUpdates updates unreachables -> do - for_ unreachables $ \us -> - throw . InternalErrorWithDescription $ - "A commit to a remote conversation should not ever return a \ - \non-empty list of users an application message could not be \ - \sent to. The remote end returned: " - <> LT.pack (intercalate ", " (show <$> NE.toList (unreachableUsers us))) + MLSMessageResponseUpdates updates -> do fmap fst . runOutputList . runInputConst (void loc) $ for_ updates $ \update -> do - me <- updateLocalStateOfRemoteConv (qualifyAs rcnv update) con + me <- updateLocalStateOfRemoteConv (qualifyAs rConvOrSubId update) con for_ me $ \e -> output (LocalConversationUpdate e update) MLSMessageResponseNonFederatingBackends e -> throw e postMLSMessage :: ( HasProposalEffects r, - ( Member (Error FederationError) r, - Member (Error InternalError) r, - Member (ErrorS 'ConvAccessDenied) r, - Member (ErrorS 'ConvMemberNotFound) r, - Member (ErrorS 'ConvNotFound) r, - Member (ErrorS 'MLSNotEnabled) r, - Member (ErrorS 'MissingLegalholdConsent) r, - Member (ErrorS 'MLSClientSenderUserMismatch) r, - Member (ErrorS 'MLSCommitMissingReferences) r, - Member (ErrorS 'MLSGroupConversationMismatch) r, - Member (ErrorS 'MLSMissingSenderClient) r, - Member (ErrorS 'MLSProposalNotFound) r, - Member (ErrorS 'MLSSelfRemovalNotAllowed) r, - Member (ErrorS 'MLSStaleMessage) r, - Member (ErrorS 'MLSUnsupportedMessage) r, - Member (Error NonFederatingBackends) r, - Member (Error UnreachableBackends) r, - Member (Input (Local ())) r, - Member ProposalStore r, - Member Resource r, - Member TinyLog r - ) + Member (Error FederationError) r, + Member (ErrorS 'ConvAccessDenied) r, + Member (ErrorS 'ConvMemberNotFound) r, + Member (ErrorS 'ConvNotFound) r, + Member (ErrorS 'MLSNotEnabled) r, + Member (ErrorS 'MissingLegalholdConsent) r, + Member (ErrorS 'MLSClientSenderUserMismatch) r, + Member (ErrorS 'MLSCommitMissingReferences) r, + Member (ErrorS 'MLSGroupConversationMismatch) r, + Member (ErrorS 'MLSProposalNotFound) r, + Member (ErrorS 'MLSSelfRemovalNotAllowed) r, + Member (ErrorS 'MLSStaleMessage) r, + Member (ErrorS 'MLSUnsupportedMessage) r, + Member (ErrorS 'MLSSubConvClientNotInParent) r, + Member SubConversationStore r ) => Local x -> Qualified UserId -> - Maybe ClientId -> - Qualified ConvId -> + ClientId -> + ConvType -> + Qualified ConvOrSubConvId -> Maybe ConnId -> - RawMLS SomeMessage -> - Sem r ([LocalConversationUpdate], Maybe UnreachableUsers) -postMLSMessage loc qusr mc qcnv con smsg = - case rmValue smsg of - SomeMessage tag msg -> do - mSender <- fmap ciClient <$> getSenderIdentity qusr mc tag msg - foldQualified - loc - (postMLSMessageToLocalConv qusr mSender con smsg) - (postMLSMessageToRemoteConv loc qusr mSender con smsg) - qcnv - --- Check that the MLS client who created the message belongs to the user who --- is the sender of the REST request, identified by HTTP header. --- --- The check is skipped in case of conversation creation and encrypted messages. -getSenderClient :: - ( Member (ErrorS 'MLSKeyPackageRefNotFound) r, - Member (ErrorS 'MLSClientSenderUserMismatch) r, - Member BrigAccess r - ) => - Qualified UserId -> - SWireFormatTag tag -> - Message tag -> - Sem r (Maybe ClientId) -getSenderClient _ SMLSCipherText _ = pure Nothing -getSenderClient _ _ msg | msgEpoch msg == Epoch 0 = pure Nothing -getSenderClient qusr SMLSPlainText msg = case msgSender msg of - PreconfiguredSender _ -> pure Nothing - NewMemberSender -> pure Nothing - MemberSender ref -> do - cid <- derefKeyPackage ref - when (fmap fst (cidQualifiedClient cid) /= qusr) $ - throwS @'MLSClientSenderUserMismatch - pure (Just (ciClient cid)) + IncomingMessage -> + Sem r [LocalConversationUpdate] +postMLSMessage loc qusr c ctype qconvOrSub con msg = do + foldQualified + loc + (postMLSMessageToLocalConv qusr c con msg ctype) + (postMLSMessageToRemoteConv loc qusr c con msg) + qconvOrSub --- FUTUREWORK: once we can assume that the Z-Client header is present (i.e. --- when v2 is dropped), remove the Maybe in the return type. getSenderIdentity :: - ( Member (ErrorS 'MLSKeyPackageRefNotFound) r, - Member (ErrorS 'MLSClientSenderUserMismatch) r, - Member BrigAccess r + ( Member (ErrorS 'MLSClientSenderUserMismatch) r, + Member (Error MLSProtocolError) r ) => Qualified UserId -> - Maybe ClientId -> - SWireFormatTag tag -> - Message tag -> - Sem r (Maybe ClientIdentity) -getSenderIdentity qusr mc fmt msg = do - mSender <- getSenderClient qusr fmt msg - -- At this point, mc is the client ID of the request, while mSender is the - -- one contained in the message. We throw an error if the two don't match. - when (((==) <$> mc <*> mSender) == Just False) $ - throwS @'MLSClientSenderUserMismatch - pure (mkClientIdentity qusr <$> (mc <|> mSender)) + ClientId -> + Sender -> + Local ConvOrSubConv -> + Sem r ClientIdentity +getSenderIdentity qusr c mSender lConvOrSubConv = do + let cid = mkClientIdentity qusr c + let epoch = epochNumber . cnvmlsEpoch . (.mlsMeta) . tUnqualified $ lConvOrSubConv + case mSender of + SenderMember idx | epoch > 0 -> do + cid' <- note (mlsProtocolError "unknown sender leaf index") $ imLookup (tUnqualified lConvOrSubConv).indexMap idx + unless (cid' == cid) $ throwS @'MLSClientSenderUserMismatch + _ -> pure () + pure cid postMLSMessageToLocalConv :: ( HasProposalEffects r, - ( Member (Error FederationError) r, - Member (Error InternalError) r, - Member (ErrorS 'ConvNotFound) r, - Member (ErrorS 'MissingLegalholdConsent) r, - Member (ErrorS 'MLSClientSenderUserMismatch) r, - Member (ErrorS 'MLSCommitMissingReferences) r, - Member (ErrorS 'MLSMissingSenderClient) r, - Member (ErrorS 'MLSProposalNotFound) r, - Member (ErrorS 'MLSSelfRemovalNotAllowed) r, - Member (ErrorS 'MLSStaleMessage) r, - Member (ErrorS 'MLSUnsupportedMessage) r, - Member (Error NonFederatingBackends) r, - Member (Error UnreachableBackends) r, - Member ProposalStore r, - Member Resource r, - Member TinyLog r - ) + Member (ErrorS 'ConvNotFound) r, + Member (ErrorS 'MLSClientSenderUserMismatch) r, + Member (ErrorS 'MLSStaleMessage) r, + Member (ErrorS 'MLSUnsupportedMessage) r, + Member SubConversationStore r ) => Qualified UserId -> - Maybe ClientId -> + ClientId -> Maybe ConnId -> - RawMLS SomeMessage -> - Local ConvId -> - Sem r ([LocalConversationUpdate], Maybe UnreachableUsers) -postMLSMessageToLocalConv qusr senderClient con smsg lcnv = - case rmValue smsg of - SomeMessage tag msg -> do - conv <- getLocalConvForUser qusr lcnv - mlsMeta <- Data.mlsMetadata conv & noteS @'ConvNotFound - - -- construct client map - cm <- lookupMLSClients (cnvmlsGroupId mlsMeta) - let lconv = qualifyAs lcnv conv - - -- validate message - events <- case tag of - SMLSPlainText -> case msgPayload msg of - CommitMessage c -> - processCommit qusr senderClient con lconv mlsMeta cm (msgEpoch msg) (msgSender msg) c - ApplicationMessage _ -> throwS @'MLSUnsupportedMessage - ProposalMessage prop -> - processProposal qusr conv mlsMeta msg prop $> mempty - SMLSCipherText -> case toMLSEnum' (msgContentType (msgPayload msg)) of - Right CommitMessageTag -> throwS @'MLSUnsupportedMessage - Right ProposalMessageTag -> throwS @'MLSUnsupportedMessage - Right ApplicationMessageTag -> pure mempty - Left _ -> throwS @'MLSUnsupportedMessage - - -- forward message - unreachables <- propagateMessage qusr lconv cm con (rmRaw smsg) - pure (events, unreachables) + IncomingMessage -> + ConvType -> + Local ConvOrSubConvId -> + Sem r [LocalConversationUpdate] +postMLSMessageToLocalConv qusr c con msg ctype convOrSubId = do + lConvOrSub <- fetchConvOrSub qusr msg.groupId ctype convOrSubId + let convOrSub = tUnqualified lConvOrSub + + for_ msg.sender $ \sender -> + void $ getSenderIdentity qusr c sender lConvOrSub + + -- validate message + case msg.content of + IncomingMessageContentPublic pub -> case pub.content of + FramedContentCommit _commit -> throwS @'MLSUnsupportedMessage + FramedContentApplicationData _ -> throwS @'MLSUnsupportedMessage + -- proposal message + FramedContentProposal prop -> + processProposal qusr lConvOrSub msg.groupId msg.epoch pub prop + IncomingMessageContentPrivate -> do + -- application message: + + -- reject all application messages if the conv is in mixed state + when (convOrSub.migrationState == MLSMigrationMixed) $ + throwS @'MLSUnsupportedMessage + + -- reject application messages older than 2 epochs + let epochInt :: Epoch -> Integer + epochInt = fromIntegral . epochNumber + when + (epochInt msg.epoch < epochInt convOrSub.mlsMeta.cnvmlsEpoch - 2) + $ throwS @'MLSStaleMessage + + propagateMessage qusr (Just c) lConvOrSub con msg.rawMessage (tUnqualified lConvOrSub).members + pure [] postMLSMessageToRemoteConv :: ( Members MLSMessageStaticErrors r, Member (Error FederationError) r, - Member (Error NonFederatingBackends) r, HasProposalEffects r ) => Local x -> Qualified UserId -> - Maybe ClientId -> + ClientId -> Maybe ConnId -> - RawMLS SomeMessage -> - Remote ConvId -> - Sem r ([LocalConversationUpdate], Maybe UnreachableUsers) -postMLSMessageToRemoteConv loc qusr _senderClient con smsg rcnv = do + IncomingMessage -> + Remote ConvOrSubConvId -> + Sem r [LocalConversationUpdate] +postMLSMessageToRemoteConv loc qusr senderClient con msg rConvOrSubId = do -- only local users can send messages to remote conversations lusr <- foldQualified loc pure (\_ -> throwS @'ConvAccessDenied) qusr -- only members may send messages to the remote conversation + flip unless (throwS @'ConvMemberNotFound) =<< checkLocalMemberRemoteConv (tUnqualified lusr) ((.conv) <$> rConvOrSubId) - flip unless (throwS @'ConvMemberNotFound) =<< checkLocalMemberRemoteConv (tUnqualified lusr) rcnv resp <- - runFederated rcnv $ + runFederated rConvOrSubId $ fedClient @'Galley @"send-mls-message" $ MLSMessageSendRequest - { mmsrConvOrSubId = Conv $ tUnqualified rcnv, - mmsrSender = tUnqualified lusr, - mmsrRawMessage = Base64ByteString (rmRaw smsg) + { convOrSubId = tUnqualified rConvOrSubId, + sender = tUnqualified lusr, + senderClient = senderClient, + rawMessage = Base64ByteString msg.rawMessage.raw } case resp of MLSMessageResponseError e -> rethrowErrors @MLSMessageStaticErrors e @@ -538,834 +452,77 @@ postMLSMessageToRemoteConv loc qusr _senderClient con smsg rcnv = do \not ever return a non-empty list of domains a commit could not be \ \sent to. The remote end returned: " <> LT.pack (intercalate ", " (show <$> Set.toList (Set.map domainText ds))) - MLSMessageResponseUpdates updates unreachables -> do - lcus <- fmap fst . runOutputList $ + MLSMessageResponseUpdates updates -> do + fmap fst . runOutputList $ for_ updates $ \update -> do - me <- updateLocalStateOfRemoteConv (qualifyAs rcnv update) con + me <- updateLocalStateOfRemoteConv (qualifyAs rConvOrSubId update) con for_ me $ \e -> output (LocalConversationUpdate e update) - pure (lcus, unreachables) MLSMessageResponseNonFederatingBackends e -> throw e -type HasProposalEffects r = - ( Member BrigAccess r, - Member ConversationStore r, - Member (Error InternalError) r, - Member (Error MLSProposalFailure) r, - Member (Error MLSProtocolError) r, - Member (ErrorS 'MLSClientMismatch) r, - Member (ErrorS 'MLSKeyPackageRefNotFound) r, - Member (ErrorS 'MLSUnsupportedProposal) r, - Member ExternalAccess r, - Member FederatorAccess r, - Member GundeckAccess r, - Member (Input Env) r, - Member (Input (Local ())) r, - Member (Input Opts) r, - Member (Input UTCTime) r, - Member LegalHoldStore r, - Member MemberStore r, - Member ProposalStore r, - Member TeamStore r, - Member TeamStore r, - Member TinyLog r - ) - -data ProposalAction = ProposalAction - { paAdd :: ClientMap, - paRemove :: ClientMap, - -- The backend does not process external init proposals, but still it needs - -- to know if a commit has one when processing external commits - paExternalInit :: Any - } - -instance Semigroup ProposalAction where - ProposalAction add1 rem1 init1 <> ProposalAction add2 rem2 init2 = - ProposalAction - (Map.unionWith mappend add1 add2) - (Map.unionWith mappend rem1 rem2) - (init1 <> init2) - -instance Monoid ProposalAction where - mempty = ProposalAction mempty mempty mempty - -paAddClient :: Qualified (UserId, (ClientId, KeyPackageRef)) -> ProposalAction -paAddClient quc = mempty {paAdd = Map.singleton (fmap fst quc) (Set.singleton (snd (qUnqualified quc)))} - -paRemoveClient :: Qualified (UserId, (ClientId, KeyPackageRef)) -> ProposalAction -paRemoveClient quc = mempty {paRemove = Map.singleton (fmap fst quc) (Set.singleton (snd (qUnqualified quc)))} - -paExternalInitPresent :: ProposalAction -paExternalInitPresent = mempty {paExternalInit = Any True} - -getCommitData :: - ( HasProposalEffects r, - Member (ErrorS 'ConvNotFound) r, - Member (ErrorS 'MLSProposalNotFound) r, - Member (ErrorS 'MLSStaleMessage) r - ) => - Local Data.Conversation -> - ConversationMLSData -> - Epoch -> - Commit -> - Sem r ProposalAction -getCommitData lconv mlsMeta epoch commit = do - let curEpoch = cnvmlsEpoch mlsMeta - groupId = cnvmlsGroupId mlsMeta - suite = cnvmlsCipherSuite mlsMeta - - -- check epoch number - when (epoch /= curEpoch) $ throwS @'MLSStaleMessage - foldMap (applyProposalRef (tUnqualified lconv) mlsMeta groupId epoch suite) (cProposals commit) - -processCommit :: - ( HasProposalEffects r, - Member (ErrorS 'ConvNotFound) r, - Member (ErrorS 'MLSClientSenderUserMismatch) r, - Member (ErrorS 'MLSCommitMissingReferences) r, - Member (ErrorS 'MLSMissingSenderClient) r, - Member (ErrorS 'MLSProposalNotFound) r, - Member (ErrorS 'MLSSelfRemovalNotAllowed) r, - Member (ErrorS 'MLSStaleMessage) r, - Member (ErrorS 'MissingLegalholdConsent) r, - Member (Error NonFederatingBackends) r, - Member (Error UnreachableBackends) r, - Member Resource r - ) => - Qualified UserId -> - Maybe ClientId -> - Maybe ConnId -> - Local Data.Conversation -> - ConversationMLSData -> - ClientMap -> - Epoch -> - Sender 'MLSPlainText -> - Commit -> - Sem r [LocalConversationUpdate] -processCommit qusr senderClient con lconv mlsMeta cm epoch sender commit = do - action <- getCommitData lconv mlsMeta epoch commit - processCommitWithAction qusr senderClient con lconv mlsMeta cm epoch action sender commit - -processExternalCommit :: - forall r. - ( Member BrigAccess r, - Member ConversationStore r, - Member (Error MLSProtocolError) r, - Member (ErrorS 'ConvNotFound) r, - Member (ErrorS 'MLSClientSenderUserMismatch) r, - Member (ErrorS 'MLSKeyPackageRefNotFound) r, - Member (ErrorS 'MLSStaleMessage) r, - Member (ErrorS 'MLSMissingSenderClient) r, - Member ExternalAccess r, - Member FederatorAccess r, - Member GundeckAccess r, - Member (Input Env) r, - Member (Input UTCTime) r, - Member MemberStore r, - Member ProposalStore r, - Member Resource r, - Member TinyLog r +storeGroupInfo :: + ( Member ConversationStore r, + Member SubConversationStore r ) => - Qualified UserId -> - Maybe ClientId -> - Local Data.Conversation -> - ConversationMLSData -> - ClientMap -> - Epoch -> - ProposalAction -> - Maybe UpdatePath -> + ConvOrSubConvId -> + GroupInfoData -> Sem r () -processExternalCommit qusr mSenderClient lconv mlsMeta cm epoch action updatePath = withCommitLock (cnvmlsGroupId mlsMeta) epoch $ do - newKeyPackage <- - upLeaf - <$> note - (mlsProtocolError "External commits need an update path") - updatePath - when (paExternalInit action == mempty) $ - throw . mlsProtocolError $ - "The external commit is missing an external init proposal" - unless (paAdd action == mempty) $ - throw . mlsProtocolError $ - "The external commit must not have add proposals" - - newRef <- - kpRef' newKeyPackage - & note (mlsProtocolError "An invalid key package in the update path") - - -- validate and update mapping in brig - eithCid <- - nkpresClientIdentity - <$$> validateAndAddKeyPackageRef - NewKeyPackage - { nkpConversation = Data.convId <$> tUntagged lconv, - nkpKeyPackage = KeyPackageData (rmRaw newKeyPackage) - } - cid <- either (\errMsg -> throw (mlsProtocolError ("Tried to add invalid KeyPackage: " <> errMsg))) pure eithCid - - unless (cidQualifiedUser cid == qusr) $ - throw . mlsProtocolError $ - "The external commit attempts to add another user" - - senderClient <- noteS @'MLSMissingSenderClient mSenderClient - - unless (ciClient cid == senderClient) $ - throw . mlsProtocolError $ - "The external commit attempts to add another client of the user, it must only add itself" - - -- check if there is a key package ref in the remove proposal - remRef <- - if Map.null (paRemove action) - then pure Nothing - else do - (remCid, r) <- derefUser (paRemove action) qusr - unless (cidQualifiedUser cid == cidQualifiedUser remCid) - . throw - . mlsProtocolError - $ "The external commit attempts to remove a client from a user other than themselves" - pure (Just r) - - updateKeyPackageMapping lconv (cnvmlsGroupId mlsMeta) qusr (ciClient cid) remRef newRef - - -- increment epoch number - setConversationEpoch (Data.convId (tUnqualified lconv)) (succ epoch) - -- fetch local conversation with new epoch - lc <- qualifyAs lconv <$> getLocalConvForUser qusr (convId <$> lconv) - -- fetch backend remove proposals of the previous epoch - kpRefs <- getPendingBackendRemoveProposals (cnvmlsGroupId mlsMeta) epoch - -- requeue backend remove proposals for the current epoch - removeClientsWithClientMap lc kpRefs cm qusr - where - derefUser :: ClientMap -> Qualified UserId -> Sem r (ClientIdentity, KeyPackageRef) - derefUser (Map.toList -> l) user = case l of - [(u, s)] -> do - unless (user == u) $ - throwS @'MLSClientSenderUserMismatch - ref <- snd <$> ensureSingleton s - ci <- derefKeyPackage ref - unless (cidQualifiedUser ci == user) $ - throwS @'MLSClientSenderUserMismatch - pure (ci, ref) - _ -> throwRemProposal - ensureSingleton :: Set a -> Sem r a - ensureSingleton (Set.toList -> l) = case l of - [e] -> pure e - _ -> throwRemProposal - throwRemProposal = - throw . mlsProtocolError $ - "The external commit must have at most one remove proposal" +storeGroupInfo convOrSub ginfo = case convOrSub of + Conv cid -> setGroupInfo cid ginfo + SubConv cid subconvid -> setSubConversationGroupInfo cid subconvid (Just ginfo) -processCommitWithAction :: +fetchConvOrSub :: forall r. - ( HasProposalEffects r, + ( Member ConversationStore r, Member (ErrorS 'ConvNotFound) r, - Member (ErrorS 'MLSClientSenderUserMismatch) r, - Member (ErrorS 'MLSCommitMissingReferences) r, - Member (ErrorS 'MLSMissingSenderClient) r, - Member (ErrorS 'MLSSelfRemovalNotAllowed) r, - Member (ErrorS 'MLSStaleMessage) r, - Member (ErrorS 'MissingLegalholdConsent) r, - Member (Error NonFederatingBackends) r, - Member (Error UnreachableBackends) r, - Member Resource r - ) => - Qualified UserId -> - Maybe ClientId -> - Maybe ConnId -> - Local Data.Conversation -> - ConversationMLSData -> - ClientMap -> - Epoch -> - ProposalAction -> - Sender 'MLSPlainText -> - Commit -> - Sem r [LocalConversationUpdate] -processCommitWithAction qusr senderClient con lconv mlsMeta cm epoch action sender commit = - case sender of - MemberSender ref -> processInternalCommit qusr senderClient con lconv mlsMeta cm epoch action ref commit - NewMemberSender -> processExternalCommit qusr senderClient lconv mlsMeta cm epoch action (cPath commit) $> [] - _ -> throw (mlsProtocolError "Unexpected sender") - -processInternalCommit :: - forall r. - ( HasProposalEffects r, - Member (ErrorS 'ConvNotFound) r, - Member (ErrorS 'MLSCommitMissingReferences) r, - Member (ErrorS 'MLSMissingSenderClient) r, - Member (ErrorS 'MLSSelfRemovalNotAllowed) r, - Member (ErrorS 'MLSStaleMessage) r, - Member (ErrorS 'MissingLegalholdConsent) r, - Member (Error NonFederatingBackends) r, - Member (Error UnreachableBackends) r, - Member Resource r - ) => - Qualified UserId -> - Maybe ClientId -> - Maybe ConnId -> - Local Data.Conversation -> - ConversationMLSData -> - ClientMap -> - Epoch -> - ProposalAction -> - KeyPackageRef -> - Commit -> - Sem r [LocalConversationUpdate] -processInternalCommit qusr senderClient con lconv mlsMeta cm epoch action senderRef commit = do - self <- noteS @'ConvNotFound $ getConvMember lconv (tUnqualified lconv) qusr - - withCommitLock (cnvmlsGroupId mlsMeta) epoch $ do - postponedKeyPackageRefUpdate <- - if epoch == Epoch 0 - then do - let cType = cnvmType . convMetadata . tUnqualified $ lconv - case (self, cType, cmAssocs cm) of - (Left _, SelfConv, []) -> do - creatorClient <- noteS @'MLSMissingSenderClient senderClient - creatorRef <- - maybe - (pure senderRef) - ( note (mlsProtocolError "Could not compute key package ref") - . kpRef' - . upLeaf - ) - $ cPath commit - addMLSClients - (cnvmlsGroupId mlsMeta) - qusr - (Set.singleton (creatorClient, creatorRef)) - (Left _, SelfConv, _) -> - throw . InternalErrorWithDescription $ - "Unexpected creator client set in a self-conversation" - -- this is a newly created conversation, and it should contain exactly one - -- client (the creator) - (Left lm, _, [(qu, (creatorClient, _))]) - | qu == tUntagged (qualifyAs lconv (lmId lm)) -> do - -- use update path as sender reference and if not existing fall back to sender - senderRef' <- - maybe - (pure senderRef) - ( note (mlsProtocolError "Could not compute key package ref") - . kpRef' - . upLeaf - ) - $ cPath commit - -- register the creator client - updateKeyPackageMapping lconv (cnvmlsGroupId mlsMeta) qusr creatorClient Nothing senderRef' - -- remote clients cannot send the first commit - (Right _, _, _) -> throwS @'MLSStaleMessage - -- uninitialised conversations should contain exactly one client - (_, _, _) -> - throw (InternalErrorWithDescription "Unexpected creator client set") - pure $ pure () -- no key package ref update necessary - else case upLeaf <$> cPath commit of - Just updatedKeyPackage -> do - updatedRef <- kpRef' updatedKeyPackage & note (mlsProtocolError "Could not compute key package ref") - -- postpone key package ref update until other checks/processing passed - case senderClient of - Just cli -> pure (updateKeyPackageMapping lconv (cnvmlsGroupId mlsMeta) qusr cli (Just senderRef) updatedRef) - Nothing -> pure (pure ()) - Nothing -> pure (pure ()) -- ignore commits without update path - - -- check all pending proposals are referenced in the commit - allPendingProposals <- getAllPendingProposalRefs (cnvmlsGroupId mlsMeta) epoch - let referencedProposals = Set.fromList $ mapMaybe (\x -> preview Proposal._Ref x) (cProposals commit) - unless (all (`Set.member` referencedProposals) allPendingProposals) $ - throwS @'MLSCommitMissingReferences - - -- process and execute proposals - updates <- executeProposalAction qusr con lconv mlsMeta cm action - - -- update key package ref if necessary - postponedKeyPackageRefUpdate - -- increment epoch number - setConversationEpoch (Data.convId (tUnqualified lconv)) (succ epoch) - - pure updates - --- | Note: Use this only for KeyPackage that are already validated -updateKeyPackageMapping :: - ( Member BrigAccess r, - Member MemberStore r + Member (Error MLSProtocolError) r, + Member MemberStore r, + Member SubConversationStore r ) => - Local Data.Conversation -> - GroupId -> Qualified UserId -> - ClientId -> - Maybe KeyPackageRef -> - KeyPackageRef -> - Sem r () -updateKeyPackageMapping lconv groupId qusr cid mOld new = do - let lcnv = fmap Data.convId lconv - -- update actual mapping in brig - case mOld of - Nothing -> - addKeyPackageRef new qusr cid (tUntagged lcnv) - Just old -> - updateKeyPackageRef - KeyPackageUpdate - { kpupPrevious = old, - kpupNext = new - } - - -- remove old (client, key package) pair - removeMLSClients groupId qusr (Set.singleton cid) - -- add new (client, key package) pair - addMLSClients groupId qusr (Set.singleton (cid, new)) - -applyProposalRef :: - ( HasProposalEffects r, - ( Member (ErrorS 'ConvNotFound) r, - Member (ErrorS 'MLSProposalNotFound) r, - Member (ErrorS 'MLSStaleMessage) r, - Member ProposalStore r - ) - ) => - Data.Conversation -> - ConversationMLSData -> - GroupId -> - Epoch -> - CipherSuiteTag -> - ProposalOrRef -> - Sem r ProposalAction -applyProposalRef conv mlsMeta groupId epoch _suite (Ref ref) = do - p <- getProposal groupId epoch ref >>= noteS @'MLSProposalNotFound - checkEpoch epoch mlsMeta - checkGroup groupId mlsMeta - applyProposal (convId conv) groupId (rmValue p) -applyProposalRef conv _mlsMeta groupId _epoch suite (Inline p) = do - checkProposalCipherSuite suite p - applyProposal (convId conv) groupId p - -applyProposal :: - forall r. - HasProposalEffects r => - ConvId -> GroupId -> - Proposal -> - Sem r ProposalAction -applyProposal convId groupId (AddProposal kp) = do - ref <- kpRef' kp & note (mlsProtocolError "Could not compute ref of a key package in an Add proposal") - mbClientIdentity <- getClientByKeyPackageRef ref - clientIdentity <- case mbClientIdentity of - Nothing -> do - -- external add proposal for a new key package unknown to the backend - lconvId <- qualifyLocal convId - addKeyPackageMapping lconvId ref (KeyPackageData (rmRaw kp)) - Just ci -> - -- ad-hoc add proposal in commit, the key package has been claimed before - pure ci - pure (paAddClient . (<$$>) (,ref) . cidQualifiedClient $ clientIdentity) - where - addKeyPackageMapping :: Local ConvId -> KeyPackageRef -> KeyPackageData -> Sem r ClientIdentity - addKeyPackageMapping lconv ref kpdata = do - -- validate and update mapping in brig - eithCid <- - nkpresClientIdentity - <$$> validateAndAddKeyPackageRef - NewKeyPackage - { nkpConversation = tUntagged lconv, - nkpKeyPackage = kpdata - } - cid <- either (\errMsg -> throw (mlsProtocolError ("Tried to add invalid KeyPackage: " <> errMsg))) pure eithCid - let qcid = cidQualifiedClient cid - let qusr = fst <$> qcid - -- update mapping in galley - addMLSClients groupId qusr (Set.singleton (ciClient cid, ref)) - pure cid -applyProposal _conv _groupId (RemoveProposal ref) = do - qclient <- cidQualifiedClient <$> derefKeyPackage ref - pure (paRemoveClient ((,ref) <$$> qclient)) -applyProposal _conv _groupId (ExternalInitProposal _) = - -- only record the fact there was an external init proposal, but do not - -- process it in any way. - pure paExternalInitPresent -applyProposal _conv _groupId _ = pure mempty - -checkProposalCipherSuite :: - Member (Error MLSProtocolError) r => - CipherSuiteTag -> - Proposal -> - Sem r () -checkProposalCipherSuite suite (AddProposal kpRaw) = do - let kp = rmValue kpRaw - unless (kpCipherSuite kp == tagCipherSuite suite) - . throw - . mlsProtocolError - . T.pack - $ "The group's cipher suite " - <> show (cipherSuiteNumber (tagCipherSuite suite)) - <> " and the cipher suite of the proposal's key package " - <> show (cipherSuiteNumber (kpCipherSuite kp)) - <> " do not match." -checkProposalCipherSuite _suite _prop = pure () - -processProposal :: - HasProposalEffects r => + ConvType -> + Local ConvOrSubConvId -> + Sem r (Local ConvOrSubConv) +fetchConvOrSub qusr groupId ctype convOrSubId = for convOrSubId $ \case + Conv convId -> Conv <$> getMLSConv qusr (Just groupId) ctype (qualifyAs convOrSubId convId) + SubConv convId sconvId -> do + let lconv = qualifyAs convOrSubId convId + c <- getMLSConv qusr Nothing ctype lconv + msubconv <- getSubConversation convId sconvId + subconv <- case msubconv of + Nothing -> pure $ newSubConversationFromParent lconv sconvId (mcMLSData c) + Just subconv -> do + when (groupId /= subconv.scMLSData.cnvmlsGroupId) $ + throw (mlsProtocolError "The message group ID does not match the subconversation") + pure subconv + pure (SubConv c subconv) + +getMLSConv :: ( Member (ErrorS 'ConvNotFound) r, - Member (ErrorS 'MLSStaleMessage) r - ) => - Qualified UserId -> - Data.Conversation -> - ConversationMLSData -> - Message 'MLSPlainText -> - RawMLS Proposal -> - Sem r () -processProposal qusr conv mlsMeta msg prop = do - checkEpoch (msgEpoch msg) mlsMeta - checkGroup (msgGroupId msg) mlsMeta - let suiteTag = cnvmlsCipherSuite mlsMeta - - -- validate the proposal - -- - -- is the user a member of the conversation? - loc <- qualifyLocal () - isMember' <- - foldQualified - loc - ( fmap isJust - . getLocalMember (convId conv) - . tUnqualified - ) - ( fmap isJust - . getRemoteMember (convId conv) - ) - qusr - unless isMember' $ throwS @'ConvNotFound - - -- FUTUREWORK: validate the member's conversation role - let propValue = rmValue prop - checkProposalCipherSuite suiteTag propValue - when (isExternalProposal msg) $ do - checkExternalProposalSignature suiteTag msg prop - checkExternalProposalUser qusr propValue - let propRef = proposalRef suiteTag prop - storeProposal (msgGroupId msg) (msgEpoch msg) propRef ProposalOriginClient prop - -checkExternalProposalSignature :: - Member (ErrorS 'MLSUnsupportedProposal) r => - CipherSuiteTag -> - Message 'MLSPlainText -> - RawMLS Proposal -> - Sem r () -checkExternalProposalSignature csTag msg prop = case rmValue prop of - AddProposal kp -> do - let pubKey = bcSignatureKey . kpCredential $ rmValue kp - unless (verifyMessageSignature csTag msg pubKey) $ throwS @'MLSUnsupportedProposal - _ -> pure () -- FUTUREWORK: check signature of other proposals as well - -isExternalProposal :: Message 'MLSPlainText -> Bool -isExternalProposal msg = case msgSender msg of - NewMemberSender -> True - PreconfiguredSender _ -> True - _ -> False - --- check owner/subject of the key package exists and belongs to the user -checkExternalProposalUser :: - ( Member BrigAccess r, - Member (ErrorS 'MLSUnsupportedProposal) r, - Member (Input (Local ())) r - ) => - Qualified UserId -> - Proposal -> - Sem r () -checkExternalProposalUser qusr prop = do - loc <- qualifyLocal () - foldQualified - loc - ( \lusr -> case prop of - AddProposal keyPackage -> do - ClientIdentity {ciUser, ciClient} <- - either - (const $ throwS @'MLSUnsupportedProposal) - pure - . kpIdentity - . rmValue - $ keyPackage - -- requesting user must match key package owner - when (tUnqualified lusr /= ciUser) $ throwS @'MLSUnsupportedProposal - -- client referenced in key package must be one of the user's clients - UserClients {userClients} <- lookupClients [ciUser] - maybe - (throwS @'MLSUnsupportedProposal) - (flip when (throwS @'MLSUnsupportedProposal) . Set.null . Set.filter (== ciClient)) - $ userClients Map.!? ciUser - _ -> throwS @'MLSUnsupportedProposal - ) - (const $ pure ()) -- FUTUREWORK: check external proposals from remote backends - qusr - -executeProposalAction :: - forall r. - ( Member BrigAccess r, + Member (Error MLSProtocolError) r, Member ConversationStore r, - Member (Error InternalError) r, - Member (ErrorS 'ConvNotFound) r, - Member (ErrorS 'MLSClientMismatch) r, - Member (Error MLSProposalFailure) r, - Member (ErrorS 'MissingLegalholdConsent) r, - Member (ErrorS 'MLSUnsupportedProposal) r, - Member (ErrorS 'MLSSelfRemovalNotAllowed) r, - Member (Error NonFederatingBackends) r, - Member (Error UnreachableBackends) r, - Member ExternalAccess r, - Member FederatorAccess r, - Member GundeckAccess r, - Member (Input Env) r, - Member (Input Opts) r, - Member (Input UTCTime) r, - Member LegalHoldStore r, - Member MemberStore r, - Member ProposalStore r, - Member TeamStore r, - Member TinyLog r - ) => - Qualified UserId -> - Maybe ConnId -> - Local Data.Conversation -> - ConversationMLSData -> - ClientMap -> - ProposalAction -> - Sem r [LocalConversationUpdate] -executeProposalAction qusr con lconv mlsMeta cm action = do - let ss = csSignatureScheme (cnvmlsCipherSuite mlsMeta) - newUserClients = Map.assocs (paAdd action) - - -- Note [client removal] - -- We support two types of removals: - -- 1. when a user is removed from a group, all their clients have to be removed - -- 2. when a client is deleted, that particular client (but not necessarily - -- other clients of the same user), has to be removed. - -- - -- Type 2 requires no special processing on the backend, so here we filter - -- out all removals of that type, so that further checks and processing can - -- be applied only to type 1 removals. - removedUsers <- mapMaybe hush <$$> for (Map.assocs (paRemove action)) $ - \(qtarget, Set.map fst -> clients) -> runError @() $ do - -- fetch clients from brig - clientInfo <- Set.map ciId <$> getClientInfo lconv qtarget ss - -- if the clients being removed don't exist, consider this as a removal of - -- type 2, and skip it - when (Set.null (clientInfo `Set.intersection` clients)) $ - throw () - pure (qtarget, clients) - - -- FUTUREWORK: remove this check after remote admins are implemented in federation https://wearezeta.atlassian.net/browse/FS-216 - foldQualified lconv (\_ -> pure ()) (\_ -> throwS @'MLSUnsupportedProposal) qusr - - -- for each user, we compare their clients with the ones being added to the conversation - for_ newUserClients $ \(qtarget, newclients) -> case Map.lookup qtarget cm of - -- user is already present, skip check in this case - Just _ -> pure () - -- new user - Nothing -> do - -- final set of clients in the conversation - let clients = Set.map fst (newclients <> Map.findWithDefault mempty qtarget cm) - -- get list of mls clients from brig - clientInfo <- getClientInfo lconv qtarget ss - let allClients = Set.map ciId clientInfo - let allMLSClients = Set.map ciId (Set.filter ciMLS clientInfo) - -- We check the following condition: - -- allMLSClients ⊆ clients ⊆ allClients - -- i.e. - -- - if a client has at least 1 key package, it has to be added - -- - if a client is being added, it has to still exist - -- - -- The reason why we can't simply check that clients == allMLSClients is - -- that a client with no remaining key packages might be added by a user - -- who just fetched its last key package. - unless - ( Set.isSubsetOf allMLSClients clients - && Set.isSubsetOf clients allClients - ) - $ do - -- unless (Set.isSubsetOf allClients clients) $ do - -- FUTUREWORK: turn this error into a proper response - throwS @'MLSClientMismatch - - membersToRemove <- catMaybes <$> for removedUsers (uncurry checkRemoval) - - -- add users to the conversation and send events - addEvents <- foldMap addMembers . nonEmpty . map fst $ newUserClients - - -- add clients in the conversation state - for_ newUserClients $ \(qtarget, newClients) -> do - addMLSClients (cnvmlsGroupId mlsMeta) qtarget newClients - - -- remove users from the conversation and send events - removeEvents <- foldMap removeMembers (nonEmpty membersToRemove) - - -- Remove clients from the conversation state. This includes client removals - -- of all types (see Note [client removal]). - for_ (Map.assocs (paRemove action)) $ \(qtarget, clients) -> do - removeMLSClients (cnvmlsGroupId mlsMeta) qtarget (Set.map fst clients) - - pure (addEvents <> removeEvents) - where - checkRemoval :: - Qualified UserId -> - Set ClientId -> - Sem r (Maybe (Qualified UserId)) - checkRemoval qtarget clients = do - let clientsInConv = Set.map fst (Map.findWithDefault mempty qtarget cm) - when (clients /= clientsInConv) $ do - -- FUTUREWORK: turn this error into a proper response - throwS @'MLSClientMismatch - when (qusr == qtarget) $ - throwS @'MLSSelfRemovalNotAllowed - pure (Just qtarget) - - existingLocalMembers :: Set (Qualified UserId) - existingLocalMembers = - (Set.fromList . map (fmap lmId . tUntagged)) (traverse convLocalMembers lconv) - - existingRemoteMembers :: Set (Qualified UserId) - existingRemoteMembers = - Set.fromList . map (tUntagged . rmId) . convRemoteMembers . tUnqualified $ - lconv - - existingMembers :: Set (Qualified UserId) - existingMembers = existingLocalMembers <> existingRemoteMembers - - addMembers :: NonEmpty (Qualified UserId) -> Sem r [LocalConversationUpdate] - addMembers = - -- FUTUREWORK: update key package ref mapping to reflect conversation membership - foldMap - ( handleNoChanges - . handleMLSProposalFailures @ProposalErrors - . fmap pure - . updateLocalConversationUnchecked @'ConversationJoinTag lconv qusr con - . flip ConversationJoin roleNameWireMember - ) - . nonEmpty - . filter (flip Set.notMember existingMembers) - . toList - - removeMembers :: NonEmpty (Qualified UserId) -> Sem r [LocalConversationUpdate] - removeMembers = - foldMap - ( handleNoChanges - . handleMLSProposalFailures @ProposalErrors - . fmap pure - . updateLocalConversationUnchecked @'ConversationRemoveMembersTag lconv qusr con - ) - . nonEmpty - . filter (flip Set.member existingMembers) - . toList - -handleNoChanges :: Monoid a => Sem (Error NoChanges ': r) a -> Sem r a -handleNoChanges = fmap fold . runError - -getClientInfo :: - ( Member BrigAccess r, - Member FederatorAccess r + Member MemberStore r ) => - Local x -> Qualified UserId -> - SignatureSchemeTag -> - Sem r (Set ClientInfo) -getClientInfo loc = foldQualified loc getLocalMLSClients getRemoteMLSClients - -getRemoteMLSClients :: - ( Member FederatorAccess r - ) => - Remote UserId -> - SignatureSchemeTag -> - Sem r (Set ClientInfo) -getRemoteMLSClients rusr ss = do - runFederated rusr $ - fedClient @'Brig @"get-mls-clients" $ - MLSClientsRequest - { mcrUserId = tUnqualified rusr, - mcrSignatureScheme = ss - } - --- | Check if the epoch number matches that of a conversation -checkEpoch :: - Member (ErrorS 'MLSStaleMessage) r => - Epoch -> - ConversationMLSData -> - Sem r () -checkEpoch epoch mlsMeta = do - unless (epoch == cnvmlsEpoch mlsMeta) $ throwS @'MLSStaleMessage - --- | Check if the group ID matches that of a conversation -checkGroup :: - Member (ErrorS 'ConvNotFound) r => - GroupId -> - ConversationMLSData -> - Sem r () -checkGroup gId mlsMeta = do - unless (gId == cnvmlsGroupId mlsMeta) $ throwS @'ConvNotFound - --------------------------------------------------------------------------------- --- Error handling of proposal execution - --- The following errors are caught by 'executeProposalAction' and wrapped in a --- 'MLSProposalFailure'. This way errors caused by the execution of proposals are --- separated from those caused by the commit processing itself. -type ProposalErrors = - '[ Error FederationError, - Error InvalidInput, - ErrorS ('ActionDenied 'AddConversationMember), - ErrorS ('ActionDenied 'LeaveConversation), - ErrorS ('ActionDenied 'RemoveConversationMember), - ErrorS 'ConvAccessDenied, - ErrorS 'InvalidOperation, - ErrorS 'NotATeamMember, - ErrorS 'NotConnected, - ErrorS 'TooManyMembers - ] - -class HandleMLSProposalFailures effs r where - handleMLSProposalFailures :: Sem (Append effs r) a -> Sem r a - -class HandleMLSProposalFailure eff r where - handleMLSProposalFailure :: Sem (eff ': r) a -> Sem r a - -instance HandleMLSProposalFailures '[] r where - handleMLSProposalFailures = id - -instance - ( HandleMLSProposalFailures effs r, - HandleMLSProposalFailure eff (Append effs r) - ) => - HandleMLSProposalFailures (eff ': effs) r - where - handleMLSProposalFailures = handleMLSProposalFailures @effs . handleMLSProposalFailure @eff - -instance - (APIError e, Member (Error MLSProposalFailure) r) => - HandleMLSProposalFailure (Error e) r - where - handleMLSProposalFailure = mapError (MLSProposalFailure . toResponse) - -withCommitLock :: - forall r a. - ( Member Resource r, - Member ConversationStore r, - Member (ErrorS 'MLSStaleMessage) r - ) => - GroupId -> - Epoch -> - Sem r a -> - Sem r a -withCommitLock gid epoch action = - bracket - ( acquireCommitLock gid epoch ttl >>= \lockAcquired -> - when (lockAcquired == NotAcquired) $ - throwS @'MLSStaleMessage - ) - (const $ releaseCommitLock gid epoch) - $ \_ -> do - -- FUTUREWORK: fetch epoch again and check that is matches - action - where - ttl = fromIntegral (600 :: Int) -- 10 minutes - -storeGroupInfoBundle :: - Member ConversationStore r => - Local Data.Conversation -> - GroupInfoBundle -> - Sem r () -storeGroupInfoBundle lconv = - setPublicGroupState (Data.convId (tUnqualified lconv)) - . toOpaquePublicGroupState - . gipGroupState + Maybe GroupId -> + ConvType -> + Local ConvId -> + Sem r MLSConversation +getMLSConv u mGroupId ctype lcnv = do + mlsConv <- case ctype of + One2OneConv -> do + mconv <- getConversation (tUnqualified lcnv) + case mconv of + Just conv -> mkMLSConversation conv >>= noteS @'ConvNotFound + Nothing -> + let (meta, mlsData) = localMLSOne2OneConversationMetadata (tUntagged lcnv) + in pure (newMLSConversation lcnv meta mlsData) + _ -> + getLocalConvForUser u lcnv + >>= mkMLSConversation + >>= noteS @'ConvNotFound + -- check that the group ID in the message matches that of the conversation + for_ mGroupId $ \groupId -> + when (groupId /= mlsConv.mcMLSData.cnvmlsGroupId) $ + throw (mlsProtocolError "The message group ID does not match the conversation") + pure mlsConv diff --git a/services/galley/src/Galley/API/MLS/Migration.hs b/services/galley/src/Galley/API/MLS/Migration.hs new file mode 100644 index 00000000000..b59fa410655 --- /dev/null +++ b/services/galley/src/Galley/API/MLS/Migration.hs @@ -0,0 +1,78 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.API.MLS.Migration where + +import Brig.Types.Intra +import Data.Qualified +import Data.Set qualified as Set +import Data.Time +import Galley.API.MLS.Types +import Galley.Effects.BrigAccess +import Galley.Effects.FederatorAccess +import Galley.Types.Conversations.Members +import Imports +import Polysemy +import Wire.API.Federation.API +import Wire.API.Team.Feature +import Wire.API.User + +-- | Similar to @Ap f All@, but short-circuiting. +-- +-- For example: +-- @ +-- ApAll (pure False) <> ApAll (putStrLn "hi" $> True) +-- @ +-- does not print anything. +newtype ApAll f = ApAll {unApAll :: f Bool} + +instance Monad f => Semigroup (ApAll f) where + ApAll a <> ApAll b = ApAll $ a >>= \x -> if x then b else pure False + +instance Monad f => Monoid (ApAll f) where + mempty = ApAll (pure True) + +checkMigrationCriteria :: + ( Member BrigAccess r, + Member FederatorAccess r + ) => + UTCTime -> + MLSConversation -> + WithStatus MlsMigrationConfig -> + Sem r Bool +checkMigrationCriteria now conv ws + | wsStatus ws == FeatureStatusDisabled = pure False + | afterDeadline = pure True + | otherwise = unApAll $ mconcat [localUsersMigrated, remoteUsersMigrated] + where + mig = wsConfig ws + afterDeadline = maybe False (now >=) mig.finaliseRegardlessAfter + + containsMLS = Set.member BaseProtocolMLSTag + + localUsersMigrated = ApAll $ do + localProfiles <- + map accountUser + <$> getUsers (map lmId conv.mcLocalMembers) + pure $ all (containsMLS . userSupportedProtocols) localProfiles + + remoteUsersMigrated = ApAll $ do + remoteProfiles <- fmap (foldMap tUnqualified) + . runFederatedConcurrently (map rmId conv.mcRemoteMembers) + $ \ruids -> + fedClient @'Brig @"get-users-by-ids" (tUnqualified ruids) + pure $ all (containsMLS . profileSupportedProtocols) remoteProfiles diff --git a/services/galley/src/Galley/API/MLS/One2One.hs b/services/galley/src/Galley/API/MLS/One2One.hs new file mode 100644 index 00000000000..c194d72302e --- /dev/null +++ b/services/galley/src/Galley/API/MLS/One2One.hs @@ -0,0 +1,136 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.API.MLS.One2One + ( localMLSOne2OneConversation, + localMLSOne2OneConversationAsRemote, + localMLSOne2OneConversationMetadata, + remoteMLSOne2OneConversation, + createMLSOne2OneConversation, + ) +where + +import Data.Id as Id +import Data.Qualified +import Galley.API.MLS.Types +import Galley.Data.Conversation.Types qualified as Data +import Galley.Effects.ConversationStore +import Galley.Types.UserList +import Imports hiding (cs) +import Polysemy +import Wire.API.Conversation hiding (Member) +import Wire.API.Conversation.Protocol +import Wire.API.Conversation.Role +import Wire.API.Federation.API.Galley +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.Group.Serialisation +import Wire.API.MLS.SubConversation +import Wire.API.User + +-- | Construct a local MLS 1-1 'Conversation' between a local user and another +-- (possibly remote) user. +localMLSOne2OneConversation :: + Local UserId -> + Local ConvId -> + Conversation +localMLSOne2OneConversation lself (tUntagged -> convId) = + let members = + ConvMembers + { cmSelf = defMember (tUntagged lself), + cmOthers = [] + } + (metadata, mlsData) = localMLSOne2OneConversationMetadata convId + in Conversation + { cnvQualifiedId = convId, + cnvMetadata = metadata, + cnvMembers = members, + cnvProtocol = ProtocolMLS mlsData + } + +-- | Construct a 'RemoteConversation' structure for a local MLS 1-1 +-- conversation to be returned to a remote backend. +localMLSOne2OneConversationAsRemote :: + Local ConvId -> + RemoteConversation +localMLSOne2OneConversationAsRemote lcnv = + let members = + RemoteConvMembers + { selfRole = roleNameWireMember, + others = [] + } + (metadata, mlsData) = localMLSOne2OneConversationMetadata (tUntagged lcnv) + in RemoteConversation + { id = tUnqualified lcnv, + metadata = metadata, + members = members, + protocol = ProtocolMLS mlsData + } + +localMLSOne2OneConversationMetadata :: + Qualified ConvId -> + (ConversationMetadata, ConversationMLSData) +localMLSOne2OneConversationMetadata convId = + let metadata = + (defConversationMetadata Nothing) + { cnvmType = One2OneConv + } + groupId = convToGroupId $ groupIdParts One2OneConv (fmap Conv convId) + mlsData = + ConversationMLSData + { cnvmlsGroupId = groupId, + cnvmlsEpoch = Epoch 0, + cnvmlsEpochTimestamp = Nothing, + cnvmlsCipherSuite = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + } + in (metadata, mlsData) + +-- | Convert an MLS 1-1 conversation returned by a remote backend into a +-- 'Conversation' to be returned to the client. +remoteMLSOne2OneConversation :: + Local UserId -> + Remote UserId -> + RemoteConversation -> + Conversation +remoteMLSOne2OneConversation lself rother rc = + let members = + ConvMembers + { cmSelf = defMember (tUntagged lself), + cmOthers = [] + } + in Conversation + { cnvQualifiedId = tUntagged (qualifyAs rother rc.id), + cnvMetadata = rc.metadata, + cnvMembers = members, + cnvProtocol = rc.protocol + } + +-- | Create a new record for an MLS 1-1 conversation in the database and add +-- the two members to it. +createMLSOne2OneConversation :: + Member ConversationStore r => + Qualified UserId -> + Qualified UserId -> + Local MLSConversation -> + Sem r Data.Conversation +createMLSOne2OneConversation self other lconv = do + createConversation + (fmap mcId lconv) + Data.NewConversation + { ncMetadata = mcMetadata (tUnqualified lconv), + ncUsers = fmap (,roleNameWireMember) (toUserList lconv [self, other]), + ncProtocol = BaseProtocolMLSTag + } diff --git a/services/galley/src/Galley/API/MLS/Propagate.hs b/services/galley/src/Galley/API/MLS/Propagate.hs index c95e4c7dca6..6b17a3a8a62 100644 --- a/services/galley/src/Galley/API/MLS/Propagate.hs +++ b/services/galley/src/Galley/API/MLS/Propagate.hs @@ -18,116 +18,105 @@ module Galley.API.MLS.Propagate where import Control.Comonad -import Data.Aeson qualified as A -import Data.Domain import Data.Id import Data.Json.Util +import Data.List.NonEmpty (NonEmpty, nonEmpty) +import Data.List1 import Data.Map qualified as Map import Data.Qualified import Data.Time import Galley.API.MLS.Types import Galley.API.Push -import Galley.Data.Conversation.Types qualified as Data +import Galley.API.Util import Galley.Data.Services import Galley.Effects -import Galley.Effects.FederatorAccess +import Galley.Effects.BackendNotificationQueueAccess +import Galley.Intra.Push.Internal import Galley.Types.Conversations.Members +import Gundeck.Types.Push.V2 (RecipientClients (..)) import Imports -import Network.Wai.Utilities.JSONResponse +import Network.AMQP qualified as Q import Polysemy import Polysemy.Input import Polysemy.TinyLog hiding (trace) -import System.Logger.Class qualified as Logger -import Wire.API.Error -import Wire.API.Error.Galley import Wire.API.Event.Conversation import Wire.API.Federation.API import Wire.API.Federation.API.Galley -import Wire.API.Federation.Error +import Wire.API.MLS.Credential +import Wire.API.MLS.Message +import Wire.API.MLS.Serialisation +import Wire.API.MLS.SubConversation import Wire.API.Message -import Wire.API.Unreachable -- | Propagate a message. +-- The message will not be propagated to the sender client if provided. This is +-- a requirement from Core Crypto and the clients. propagateMessage :: - ( Member ExternalAccess r, - Member FederatorAccess r, + ( Member BackendNotificationQueueAccess r, + Member ExternalAccess r, Member GundeckAccess r, Member (Input UTCTime) r, Member TinyLog r ) => Qualified UserId -> - Local Data.Conversation -> - ClientMap -> + Maybe ClientId -> + Local ConvOrSubConv -> Maybe ConnId -> - ByteString -> - Sem r (Maybe UnreachableUsers) -propagateMessage qusr lconv cm con raw = do - -- FUTUREWORK: check the epoch - let lmems = Data.convLocalMembers . tUnqualified $ lconv - rmems = Data.convRemoteMembers . tUnqualified $ lconv + RawMLS Message -> + ClientMap -> + Sem r () +propagateMessage qusr mSenderClient lConvOrSub con msg cm = do + now <- input @UTCTime + let mlsConv = (.conv) <$> lConvOrSub + lmems = mcLocalMembers . tUnqualified $ mlsConv + rmems = mcRemoteMembers . tUnqualified $ mlsConv botMap = Map.fromList $ do m <- lmems b <- maybeToList $ newBotMember m pure (lmId m, b) mm = defMessageMetadata - now <- input @UTCTime - let lcnv = fmap Data.convId lconv - qcnv = tUntagged lcnv - e = Event qcnv Nothing qusr now $ EdMLSMessage raw - mkPush :: UserId -> ClientId -> MessagePush - mkPush u c = newMessagePush botMap con mm [(u, c)] e + let qt = + tUntagged lConvOrSub <&> \case + Conv c -> (mcId c, Nothing) + SubConv c s -> (mcId c, Just (scSubConvId s)) + qcnv = fst <$> qt + sconv = snd (qUnqualified qt) + e = Event qcnv sconv qusr now $ EdMLSMessage msg.raw - for_ (lmems >>= localMemberMLSClients lcnv) $ \(u, c) -> - runMessagePush lconv (Just qcnv) (mkPush u c) + runMessagePush lConvOrSub (Just qcnv) $ + newMessagePush botMap con mm (lmems >>= toList . localMemberRecipient mlsConv) e -- send to remotes - unreachableFromList . concat - <$$> traverse handleError - <=< runFederatedConcurrentlyEither (map remoteMemberQualify rmems) - $ \(tUnqualified -> rs) -> - fedClient @'Galley @"on-mls-message-sent" $ + (either (logRemoteNotificationError @"on-mls-message-sent") (const (pure ())) <=< enqueueNotificationsConcurrently Q.Persistent (map remoteMemberQualify rmems)) $ + \rs -> + fedQueueClient @'OnMLSMessageSentTag $ RemoteMLSMessage - { rmmTime = now, - rmmSender = qusr, - rmmMetadata = mm, - rmmConversation = tUnqualified lcnv, - rmmRecipients = rs >>= remoteMemberMLSClients, - rmmMessage = Base64ByteString raw + { time = now, + sender = qusr, + metadata = mm, + conversation = qUnqualified qcnv, + subConversation = sconv, + recipients = + Map.fromList $ + tUnqualified rs + >>= toList . remoteMemberMLSClients, + message = Base64ByteString msg.raw } where - localMemberMLSClients :: Local x -> LocalMember -> [(UserId, ClientId)] - localMemberMLSClients loc lm = + cmWithoutSender = maybe cm (flip cmRemoveClient cm . mkClientIdentity qusr) mSenderClient + + localMemberRecipient :: Local x -> LocalMember -> Maybe Recipient + localMemberRecipient loc lm = do let localUserQId = tUntagged (qualifyAs loc localUserId) localUserId = lmId lm - in map - (\(c, _) -> (localUserId, c)) - (toList (Map.findWithDefault mempty localUserQId cm)) + clients <- nonEmpty $ Map.keys (Map.findWithDefault mempty localUserQId cmWithoutSender) + pure $ Recipient localUserId (RecipientClientsSome (List1 clients)) - remoteMemberMLSClients :: RemoteMember -> [(UserId, ClientId)] - remoteMemberMLSClients rm = + remoteMemberMLSClients :: RemoteMember -> Maybe (UserId, NonEmpty ClientId) + remoteMemberMLSClients rm = do let remoteUserQId = tUntagged (rmId rm) remoteUserId = qUnqualified remoteUserQId - in map - (\(c, _) -> (remoteUserId, c)) - (toList (Map.findWithDefault mempty remoteUserQId cm)) - - remotesToQIds = fmap (tUntagged . rmId) - - handleError :: - Member TinyLog r => - Either (Remote [RemoteMember], FederationError) (Remote RemoteMLSMessageResponse) -> - Sem r [Qualified UserId] - handleError (Right x) = case tUnqualified x of - RemoteMLSMessageOk -> pure [] - RemoteMLSMessageMLSNotEnabled -> do - logFedError x (errorToResponse @'MLSNotEnabled) - pure [] - handleError (Left (r, e)) = do - logFedError r (toResponse e) - pure $ remotesToQIds (tUnqualified r) - logFedError :: Member TinyLog r => Remote x -> JSONResponse -> Sem r () - logFedError r e = - warn $ - Logger.msg ("A message could not be delivered to a remote backend" :: ByteString) - . Logger.field "remote_domain" (domainText (tDomain r)) - . Logger.field "error" (A.encode e.value) + clients <- + nonEmpty . map fst $ + Map.assocs (Map.findWithDefault mempty remoteUserQId cmWithoutSender) + pure (remoteUserId, clients) diff --git a/services/galley/src/Galley/API/MLS/Proposal.hs b/services/galley/src/Galley/API/MLS/Proposal.hs new file mode 100644 index 00000000000..90875ecf585 --- /dev/null +++ b/services/galley/src/Galley/API/MLS/Proposal.hs @@ -0,0 +1,300 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.API.MLS.Proposal + ( -- * Proposal processing + derefOrCheckProposal, + checkProposal, + processProposal, + proposalProcessingStage, + addProposedClient, + applyProposals, + + -- * Proposal actions + paAddClient, + paRemoveClient, + + -- * Types + ProposalAction (..), + HasProposalEffects, + ) +where + +import Data.Id +import Data.Map qualified as Map +import Data.Qualified +import Data.Set qualified as Set +import Data.Time +import Galley.API.Error +import Galley.API.MLS.IncomingMessage +import Galley.API.MLS.Types +import Galley.API.Util +import Galley.Effects +import Galley.Effects.BrigAccess +import Galley.Effects.ProposalStore +import Galley.Env +import Galley.Options +import Imports hiding (cs) +import Polysemy +import Polysemy.Error +import Polysemy.Input +import Polysemy.State +import Polysemy.TinyLog +import Wire.API.Conversation hiding (Member) +import Wire.API.Conversation.Protocol +import Wire.API.Error +import Wire.API.Error.Galley +import Wire.API.MLS.AuthenticatedContent +import Wire.API.MLS.Credential +import Wire.API.MLS.KeyPackage +import Wire.API.MLS.LeafNode +import Wire.API.MLS.Message +import Wire.API.MLS.Proposal +import Wire.API.MLS.Serialisation +import Wire.API.MLS.Validation +import Wire.API.Message + +data ProposalAction = ProposalAction + { paAdd :: ClientMap, + paRemove :: ClientMap + } + deriving (Show) + +instance Semigroup ProposalAction where + ProposalAction add1 rem1 <> ProposalAction add2 rem2 = + ProposalAction + (Map.unionWith mappend add1 add2) + (Map.unionWith mappend rem1 rem2) + +instance Monoid ProposalAction where + mempty = ProposalAction mempty mempty + +paAddClient :: ClientIdentity -> LeafIndex -> ProposalAction +paAddClient cid idx = mempty {paAdd = cmSingleton cid idx} + +paRemoveClient :: ClientIdentity -> LeafIndex -> ProposalAction +paRemoveClient cid idx = mempty {paRemove = cmSingleton cid idx} + +-- | This is used to sort proposals into the correct processing order, as defined by the spec +data ProposalProcessingStage + = ProposalProcessingStageExtensions + | ProposalProcessingStageUpdate + | ProposalProcessingStageRemove + | ProposalProcessingStageAdd + | ProposalProcessingStagePreSharedKey + | ProposalProcessingStageExternalInit + | ProposalProcessingStageReInit + deriving (Eq, Ord) + +proposalProcessingStage :: Proposal -> ProposalProcessingStage +proposalProcessingStage (AddProposal _) = ProposalProcessingStageAdd +proposalProcessingStage (RemoveProposal _) = ProposalProcessingStageRemove +proposalProcessingStage (UpdateProposal _) = ProposalProcessingStageUpdate +proposalProcessingStage (PreSharedKeyProposal _) = ProposalProcessingStagePreSharedKey +proposalProcessingStage (ReInitProposal _) = ProposalProcessingStageReInit +proposalProcessingStage (ExternalInitProposal _) = ProposalProcessingStageExternalInit +proposalProcessingStage (GroupContextExtensionsProposal _) = ProposalProcessingStageExtensions + +type HasProposalEffects r = + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, + Member ConversationStore r, + Member (Error InternalError) r, + Member (Error MLSProposalFailure) r, + Member (Error MLSProtocolError) r, + Member (ErrorS 'MLSClientMismatch) r, + Member (ErrorS 'MLSInvalidLeafNodeIndex) r, + Member (ErrorS 'MLSUnsupportedProposal) r, + Member (Error NonFederatingBackends) r, + Member (Error UnreachableBackends) r, + Member ExternalAccess r, + Member FederatorAccess r, + Member GundeckAccess r, + Member (Input Env) r, + Member (Input (Local ())) r, + Member (Input Opts) r, + Member (Input UTCTime) r, + Member LegalHoldStore r, + Member MemberStore r, + Member ProposalStore r, + Member TeamStore r, + Member TeamStore r, + Member TinyLog r + ) + +derefOrCheckProposal :: + ( Member (Error MLSProtocolError) r, + Member (ErrorS 'MLSInvalidLeafNodeIndex) r, + Member ProposalStore r, + Member (State IndexMap) r, + Member (ErrorS 'MLSProposalNotFound) r + ) => + ConversationMLSData -> + GroupId -> + Epoch -> + ProposalOrRef -> + Sem r Proposal +derefOrCheckProposal _mlsMeta groupId epoch (Ref ref) = do + p <- getProposal groupId epoch ref >>= noteS @'MLSProposalNotFound + pure p.value +derefOrCheckProposal mlsMeta _ _ (Inline p) = do + im <- get + checkProposal mlsMeta im p + pure p + +checkProposal :: + ( Member (Error MLSProtocolError) r, + Member (ErrorS 'MLSInvalidLeafNodeIndex) r + ) => + ConversationMLSData -> + IndexMap -> + Proposal -> + Sem r () +checkProposal mlsMeta im p = case p of + AddProposal kp -> do + (cs, _lifetime) <- + either + (\msg -> throw (mlsProtocolError ("Invalid key package in Add proposal: " <> msg))) + pure + $ validateKeyPackage Nothing kp.value + -- we are not checking lifetime constraints here + unless (mlsMeta.cnvmlsCipherSuite == cs) $ + throw (mlsProtocolError "Key package ciphersuite does not match conversation") + RemoveProposal idx -> do + void $ noteS @'MLSInvalidLeafNodeIndex $ imLookup im idx + _ -> pure () + +addProposedClient :: Member (State IndexMap) r => ClientIdentity -> Sem r ProposalAction +addProposedClient cid = do + im <- get + let (idx, im') = imAddClient im cid + put im' + pure (paAddClient cid idx) + +applyProposals :: + ( Member (State IndexMap) r, + Member (Error MLSProtocolError) r, + Member (ErrorS 'MLSUnsupportedProposal) r, + Member (ErrorS 'MLSInvalidLeafNodeIndex) r + ) => + ConversationMLSData -> + GroupId -> + [Proposal] -> + Sem r ProposalAction +applyProposals mlsMeta groupId = + -- proposals are sorted before processing + foldMap (applyProposal mlsMeta groupId) + . sortOn proposalProcessingStage + +applyProposal :: + ( Member (State IndexMap) r, + Member (Error MLSProtocolError) r, + Member (ErrorS 'MLSUnsupportedProposal) r, + Member (ErrorS 'MLSInvalidLeafNodeIndex) r + ) => + ConversationMLSData -> + GroupId -> + Proposal -> + Sem r ProposalAction +applyProposal mlsMeta _groupId (AddProposal kp) = do + (cs, _lifetime) <- + either + (\msg -> throw (mlsProtocolError ("Invalid key package in Add proposal: " <> msg))) + pure + $ validateKeyPackage Nothing kp.value + unless (mlsMeta.cnvmlsCipherSuite == cs) $ + throw (mlsProtocolError "Key package ciphersuite does not match conversation") + -- we are not checking lifetime constraints here + cid <- getKeyPackageIdentity kp.value + addProposedClient cid +applyProposal _mlsMeta _groupId (RemoveProposal idx) = do + im <- get + (cid, im') <- noteS @'MLSInvalidLeafNodeIndex $ imRemoveClient im idx + put im' + pure (paRemoveClient cid idx) +applyProposal _mlsMeta _groupId _ = pure mempty + +processProposal :: + HasProposalEffects r => + ( Member (ErrorS 'ConvNotFound) r, + Member (ErrorS 'MLSStaleMessage) r + ) => + Qualified UserId -> + Local ConvOrSubConv -> + GroupId -> + Epoch -> + IncomingPublicMessageContent -> + RawMLS Proposal -> + Sem r () +processProposal qusr lConvOrSub groupId epoch pub prop = do + let mlsMeta = (tUnqualified lConvOrSub).mlsMeta + -- Check if the epoch number matches that of a conversation + unless (epoch == cnvmlsEpoch mlsMeta) $ throwS @'MLSStaleMessage + -- Check if the group ID matches that of a conversation + unless (groupId == cnvmlsGroupId mlsMeta) $ throwS @'ConvNotFound + let suiteTag = cnvmlsCipherSuite mlsMeta + + -- Reject proposals before first commit + when (mlsMeta.cnvmlsEpoch == Epoch 0) $ + throw (mlsProtocolError "Bare proposals at epoch 0 are not supported") + + -- FUTUREWORK: validate the member's conversation role + checkProposal mlsMeta (tUnqualified lConvOrSub).indexMap prop.value + when (isExternal pub.sender) $ checkExternalProposalUser qusr prop.value + let propRef = authContentRef suiteTag (incomingMessageAuthenticatedContent pub) + storeProposal groupId epoch propRef ProposalOriginClient prop + +getKeyPackageIdentity :: + Member (ErrorS 'MLSUnsupportedProposal) r => + KeyPackage -> + Sem r ClientIdentity +getKeyPackageIdentity = + either (\_ -> throwS @'MLSUnsupportedProposal) pure + . keyPackageIdentity + +isExternal :: Sender -> Bool +isExternal (SenderMember _) = False +isExternal _ = True + +-- check owner/subject of the key package exists and belongs to the user +checkExternalProposalUser :: + ( Member BrigAccess r, + Member (ErrorS 'MLSUnsupportedProposal) r, + Member (Input (Local ())) r + ) => + Qualified UserId -> + Proposal -> + Sem r () +checkExternalProposalUser qusr prop = do + loc <- qualifyLocal () + foldQualified + loc + ( \lusr -> case prop of + AddProposal kp -> do + ClientIdentity {ciUser, ciClient} <- getKeyPackageIdentity kp.value + -- requesting user must match key package owner + when (tUnqualified lusr /= ciUser) $ throwS @'MLSUnsupportedProposal + -- client referenced in key package must be one of the user's clients + UserClients {userClients} <- lookupClients [ciUser] + maybe + (throwS @'MLSUnsupportedProposal) + (flip when (throwS @'MLSUnsupportedProposal) . Set.null . Set.filter (== ciClient)) + $ userClients Map.!? ciUser + _ -> throwS @'MLSUnsupportedProposal + ) + (const $ pure ()) -- FUTUREWORK: check external proposals from remote backends + qusr diff --git a/services/galley/src/Galley/API/MLS/Removal.hs b/services/galley/src/Galley/API/MLS/Removal.hs index 7cfcff57311..3a796a75c22 100644 --- a/services/galley/src/Galley/API/MLS/Removal.hs +++ b/services/galley/src/Galley/API/MLS/Removal.hs @@ -16,129 +16,221 @@ -- with this program. If not, see . module Galley.API.MLS.Removal - ( removeClientsWithClientMap, + ( createAndSendRemoveProposals, + removeExtraneousClients, removeClient, - removeUserWithClientMap, removeUser, ) where +import Data.Bifunctor import Data.Id import Data.Map qualified as Map import Data.Qualified import Data.Set qualified as Set import Data.Time +import Galley.API.MLS.Conversation import Galley.API.MLS.Keys (getMLSRemovalKey) import Galley.API.MLS.Propagate import Galley.API.MLS.Types +import Galley.Data.Conversation.Types import Galley.Data.Conversation.Types qualified as Data import Galley.Effects import Galley.Effects.MemberStore import Galley.Effects.ProposalStore +import Galley.Effects.SubConversationStore import Galley.Env +import Galley.Types.Conversations.Members import Imports hiding (cs) import Polysemy import Polysemy.Input import Polysemy.TinyLog import System.Logger qualified as Log import Wire.API.Conversation.Protocol -import Wire.API.MLS.KeyPackage +import Wire.API.MLS.AuthenticatedContent +import Wire.API.MLS.Credential +import Wire.API.MLS.LeafNode import Wire.API.MLS.Message import Wire.API.MLS.Proposal import Wire.API.MLS.Serialisation +import Wire.API.MLS.SubConversation -- | Send remove proposals for a set of clients to clients in the ClientMap. -removeClientsWithClientMap :: +createAndSendRemoveProposals :: ( Member (Input UTCTime) r, Member TinyLog r, + Member BackendNotificationQueueAccess r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, Member ProposalStore r, Member (Input Env) r, - Traversable t + Foldable t ) => - Local Data.Conversation -> - t KeyPackageRef -> + Local ConvOrSubConv -> + t LeafIndex -> + Qualified UserId -> + -- | The client map that has all the recipients of the message. This is an + -- argument, and not constructed within the function, because of a special + -- case of subconversations where everyone but the subconversation leaver + -- client should get the remove proposal message; in this case the recipients + -- are a strict subset of all the clients represented by the in-memory + -- conversation/subconversation client maps. ClientMap -> + Sem r () +createAndSendRemoveProposals lConvOrSubConv indices qusr cm = do + let meta = (tUnqualified lConvOrSubConv).mlsMeta + mKeyPair <- getMLSRemovalKey + case mKeyPair of + Nothing -> do + warn $ Log.msg ("No backend removal key is configured (See 'mlsPrivateKeyPaths' in galley's config). Not able to remove client from MLS conversation." :: Text) + Just (secKey, pubKey) -> do + for_ indices $ \idx -> do + let proposal = mkRawMLS (RemoveProposal idx) + pmsg = + mkSignedPublicMessage + secKey + pubKey + (cnvmlsGroupId meta) + (cnvmlsEpoch meta) + (TaggedSenderExternal 0) + (FramedContentProposal proposal) + msg = mkRawMLS (mkMessage (MessagePublic pmsg)) + storeProposal + (cnvmlsGroupId meta) + (cnvmlsEpoch meta) + (publicMessageRef (cnvmlsCipherSuite meta) pmsg) + ProposalOriginBackend + proposal + propagateMessage qusr Nothing lConvOrSubConv Nothing msg cm + +removeClientsWithClientMapRecursively :: + ( Member (Input UTCTime) r, + Member TinyLog r, + Member BackendNotificationQueueAccess r, + Member ExternalAccess r, + Member GundeckAccess r, + Member MemberStore r, + Member ProposalStore r, + Member SubConversationStore r, + Member (Input Env) r, + Functor f, + Foldable f + ) => + Local MLSConversation -> + -- | A function returning the "list" of clients to be removed from either the + -- main conversation or each of its subconversations. + (ConvOrSubConv -> f (ClientIdentity, LeafIndex)) -> + -- | Originating user. The resulting proposals will appear to be sent by this user. Qualified UserId -> Sem r () -removeClientsWithClientMap lc cs cm qusr = do - case Data.convProtocol (tUnqualified lc) of - ProtocolProteus -> pure () - ProtocolMLS meta -> do - mKeyPair <- getMLSRemovalKey - case mKeyPair of - Nothing -> do - warn $ Log.msg ("No backend removal key is configured (See 'mlsPrivateKeyPaths' in galley's config). Not able to remove client from MLS conversation." :: Text) - Just (secKey, pubKey) -> do - for_ cs $ \kpref -> do - let proposal = mkRemoveProposal kpref - msg = mkSignedMessage secKey pubKey (cnvmlsGroupId meta) (cnvmlsEpoch meta) (ProposalMessage proposal) - msgEncoded = encodeMLS' msg - storeProposal - (cnvmlsGroupId meta) - (cnvmlsEpoch meta) - (proposalRef (cnvmlsCipherSuite meta) proposal) - ProposalOriginBackend - proposal - propagateMessage qusr lc cm Nothing msgEncoded +removeClientsWithClientMapRecursively lMlsConv getClients qusr = do + let mainConv = fmap Conv lMlsConv + cm = mcMembers (tUnqualified lMlsConv) + do + let gid = cnvmlsGroupId . mcMLSData . tUnqualified $ lMlsConv + clients = getClients (tUnqualified mainConv) + + planClientRemoval gid (fmap fst clients) + createAndSendRemoveProposals mainConv (fmap snd clients) qusr cm + + -- remove this client from all subconversations + subs <- listSubConversations' (mcId (tUnqualified lMlsConv)) + for_ subs $ \sub -> do + let subConv = fmap (flip SubConv sub) lMlsConv + sgid = cnvmlsGroupId . scMLSData $ sub + clients = getClients (tUnqualified subConv) + + planClientRemoval sgid (fmap fst clients) + createAndSendRemoveProposals + subConv + (fmap snd clients) + qusr + cm -- | Send remove proposals for a single client of a user to the local conversation. removeClient :: - ( Member ExternalAccess r, - Member FederatorAccess r, + ( Member BackendNotificationQueueAccess r, + Member ExternalAccess r, Member GundeckAccess r, Member (Input Env) r, Member (Input UTCTime) r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TinyLog r ) => Local Data.Conversation -> Qualified UserId -> ClientId -> Sem r () -removeClient lc qusr cid = do - for_ (cnvmlsGroupId <$> Data.mlsMetadata (tUnqualified lc)) $ \groupId -> do - cm <- lookupMLSClients groupId - let cidAndKP = Set.toList . Set.map snd . Set.filter ((==) cid . fst) $ Map.findWithDefault mempty qusr cm - removeClientsWithClientMap lc cidAndKP cm qusr +removeClient lc qusr c = do + mMlsConv <- mkMLSConversation (tUnqualified lc) + for_ mMlsConv $ \mlsConv -> do + let cid = mkClientIdentity qusr c + let getClients = fmap (cid,) . cmLookupIndex cid . (.members) + removeClientsWithClientMapRecursively (qualifyAs lc mlsConv) getClients qusr --- | Send remove proposals for all clients of the user to clients in the ClientMap. --- --- All clients of the user have to be contained in the ClientMap. -removeUserWithClientMap :: - ( Member (Input UTCTime) r, - Member TinyLog r, +-- | Send remove proposals for all clients of the user to the local conversation. +removeUser :: + ( Member BackendNotificationQueueAccess r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, + Member (Input Env) r, + Member (Input UTCTime) r, + Member MemberStore r, Member ProposalStore r, - Member (Input Env) r + Member SubConversationStore r, + Member TinyLog r ) => Local Data.Conversation -> - ClientMap -> Qualified UserId -> Sem r () -removeUserWithClientMap lc cm qusr = - removeClientsWithClientMap lc (Set.toList . Set.map snd $ Map.findWithDefault mempty qusr cm) cm qusr +removeUser lc qusr = do + mMlsConv <- mkMLSConversation (tUnqualified lc) + for_ mMlsConv $ \mlsConv -> do + let getClients :: ConvOrSubConv -> [(ClientIdentity, LeafIndex)] + getClients = + map (first (mkClientIdentity qusr)) + . Map.assocs + . Map.findWithDefault mempty qusr + . (.members) + removeClientsWithClientMapRecursively (qualifyAs lc mlsConv) getClients qusr --- | Send remove proposals for all clients of the user to the local conversation. -removeUser :: - ( Member ExternalAccess r, - Member FederatorAccess r, +-- | Convert cassandra subconv maps into SubConversations +listSubConversations' :: + Member SubConversationStore r => + ConvId -> + Sem r [SubConversation] +listSubConversations' cid = do + subs <- listSubConversations cid + msubs <- for (Map.assocs subs) $ \(subId, _) -> do + getSubConversation cid subId + pure (catMaybes msubs) + +-- | Send remove proposals for clients of users that are not part of a conversation +removeExtraneousClients :: + ( Member BackendNotificationQueueAccess r, + Member ExternalAccess r, Member GundeckAccess r, Member (Input Env) r, Member (Input UTCTime) r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TinyLog r ) => - Local Data.Conversation -> Qualified UserId -> + Local Conversation -> Sem r () -removeUser lc qusr = do - for_ (Data.mlsMetadata (tUnqualified lc)) $ \meta -> do - cm <- lookupMLSClients (cnvmlsGroupId meta) - removeUserWithClientMap lc cm qusr +removeExtraneousClients qusr lconv = do + mMlsConv <- mkMLSConversation (tUnqualified lconv) + for_ mMlsConv $ \mlsConv -> do + let allMembers = + Set.fromList $ + map (tUntagged . qualifyAs lconv . lmId) (mcLocalMembers mlsConv) + <> map (tUntagged . rmId) (mcRemoteMembers mlsConv) + let getClients c = + filter + (\(cid, _) -> cidQualifiedUser cid `Set.notMember` allMembers) + (cmAssocs c.members) + removeClientsWithClientMapRecursively (qualifyAs lconv mlsConv) getClients qusr diff --git a/services/galley/src/Galley/API/MLS/SubConversation.hs b/services/galley/src/Galley/API/MLS/SubConversation.hs new file mode 100644 index 00000000000..9b5ed34274d --- /dev/null +++ b/services/galley/src/Galley/API/MLS/SubConversation.hs @@ -0,0 +1,443 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.API.MLS.SubConversation + ( getSubConversation, + getLocalSubConversation, + deleteSubConversation, + deleteLocalSubConversation, + getSubConversationGroupInfo, + getSubConversationGroupInfoFromLocalConv, + leaveSubConversation, + HasLeaveSubConversationEffects, + LeaveSubConversationStaticErrors, + leaveLocalSubConversation, + MLSGetSubConvStaticErrors, + MLSDeleteSubConvStaticErrors, + ) +where + +import Control.Arrow +import Data.Id +import Data.Map qualified as Map +import Data.Qualified +import Data.Time.Clock +import Galley.API.MLS +import Galley.API.MLS.Conversation +import Galley.API.MLS.GroupInfo +import Galley.API.MLS.Removal +import Galley.API.MLS.Types +import Galley.API.MLS.Util +import Galley.API.Util +import Galley.App (Env) +import Galley.Data.Conversation qualified as Data +import Galley.Data.Conversation.Types +import Galley.Effects +import Galley.Effects.FederatorAccess +import Galley.Effects.MemberStore qualified as Eff +import Galley.Effects.SubConversationStore qualified as Eff +import Imports hiding (cs) +import Polysemy +import Polysemy.Error +import Polysemy.Input +import Polysemy.Resource +import Polysemy.TinyLog +import Wire.API.Conversation hiding (Member) +import Wire.API.Conversation.Protocol +import Wire.API.Error +import Wire.API.Error.Galley +import Wire.API.Federation.API +import Wire.API.Federation.API.Galley +import Wire.API.Federation.Error +import Wire.API.MLS.Credential +import Wire.API.MLS.Group.Serialisation +import Wire.API.MLS.GroupInfo +import Wire.API.MLS.SubConversation + +type MLSGetSubConvStaticErrors = + '[ ErrorS 'ConvNotFound, + ErrorS 'ConvAccessDenied, + ErrorS 'MLSSubConvUnsupportedConvType + ] + +getSubConversation :: + ( Members + '[ SubConversationStore, + ConversationStore, + ErrorS 'ConvNotFound, + ErrorS 'ConvAccessDenied, + ErrorS 'MLSSubConvUnsupportedConvType, + Error FederationError, + FederatorAccess + ] + r + ) => + Local UserId -> + Qualified ConvId -> + SubConvId -> + Sem r PublicSubConversation +getSubConversation lusr qconv sconv = do + foldQualified + lusr + (\lcnv -> getLocalSubConversation (tUntagged lusr) lcnv sconv) + (\rcnv -> getRemoteSubConversation lusr rcnv sconv) + qconv + +getLocalSubConversation :: + Members + '[ SubConversationStore, + ConversationStore, + ErrorS 'ConvNotFound, + ErrorS 'ConvAccessDenied, + ErrorS 'MLSSubConvUnsupportedConvType + ] + r => + Qualified UserId -> + Local ConvId -> + SubConvId -> + Sem r PublicSubConversation +getLocalSubConversation qusr lconv sconv = do + c <- getConversationAndCheckMembership qusr lconv + + unless (Data.convType c == RegularConv) $ + throwS @'MLSSubConvUnsupportedConvType + + msub <- Eff.getSubConversation (tUnqualified lconv) sconv + sub <- case msub of + Nothing -> do + (mlsMeta, mlsProtocol) <- noteS @'ConvNotFound (mlsMetadata c) + + case mlsProtocol of + MLSMigrationMixed -> throwS @'MLSSubConvUnsupportedConvType + MLSMigrationMLS -> pure () + + -- deriving this deterministically to prevent race conditions with + -- multiple threads creating the subconversation + pure (newSubConversationFromParent lconv sconv mlsMeta) + Just sub -> pure sub + pure (toPublicSubConv (tUntagged (qualifyAs lconv sub))) + +getRemoteSubConversation :: + forall r. + ( Members + '[ ErrorS 'ConvNotFound, + ErrorS 'ConvAccessDenied, + ErrorS 'MLSSubConvUnsupportedConvType, + FederatorAccess + ] + r, + RethrowErrors MLSGetSubConvStaticErrors r + ) => + Local UserId -> + Remote ConvId -> + SubConvId -> + Sem r PublicSubConversation +getRemoteSubConversation lusr rcnv sconv = do + res <- runFederated rcnv $ do + fedClient @'Galley @"get-sub-conversation" $ + GetSubConversationsRequest + { gsreqUser = tUnqualified lusr, + gsreqConv = tUnqualified rcnv, + gsreqSubConv = sconv + } + case res of + GetSubConversationsResponseError e -> + rethrowErrors @MLSGetSubConvStaticErrors @r e + GetSubConversationsResponseSuccess subconv -> + pure subconv + +getSubConversationGroupInfo :: + ( Members + '[ ConversationStore, + Error FederationError, + FederatorAccess, + Input Env, + MemberStore, + SubConversationStore + ] + r, + Members MLSGroupInfoStaticErrors r + ) => + Local UserId -> + Qualified ConvId -> + SubConvId -> + Sem r GroupInfoData +getSubConversationGroupInfo lusr qcnvId subconv = do + assertMLSEnabled + foldQualified + lusr + (getSubConversationGroupInfoFromLocalConv (tUntagged lusr) subconv) + (getGroupInfoFromRemoteConv lusr . fmap (flip SubConv subconv)) + qcnvId + +getSubConversationGroupInfoFromLocalConv :: + Members + '[ ConversationStore, + SubConversationStore, + MemberStore + ] + r => + Members MLSGroupInfoStaticErrors r => + Qualified UserId -> + SubConvId -> + Local ConvId -> + Sem r GroupInfoData +getSubConversationGroupInfoFromLocalConv qusr subConvId lcnvId = do + void $ getLocalConvForUser qusr lcnvId + Eff.getSubConversationGroupInfo (tUnqualified lcnvId) subConvId + >>= noteS @'MLSMissingGroupInfo + +type MLSDeleteSubConvStaticErrors = + '[ ErrorS 'ConvAccessDenied, + ErrorS 'ConvNotFound, + ErrorS 'MLSNotEnabled, + ErrorS 'MLSStaleMessage + ] + +deleteSubConversation :: + ( Members + '[ ConversationStore, + ErrorS 'ConvAccessDenied, + ErrorS 'ConvNotFound, + ErrorS 'MLSNotEnabled, + ErrorS 'MLSStaleMessage, + Error FederationError, + FederatorAccess, + Input Env, + MemberStore, + Resource, + SubConversationStore + ] + r + ) => + Local UserId -> + Qualified ConvId -> + SubConvId -> + DeleteSubConversationRequest -> + Sem r () +deleteSubConversation lusr qconv sconv dsc = + foldQualified + lusr + (\lcnv -> deleteLocalSubConversation (tUntagged lusr) lcnv sconv dsc) + (\rcnv -> deleteRemoteSubConversation lusr rcnv sconv dsc) + qconv + +deleteLocalSubConversation :: + ( Members + '[ ConversationStore, + ErrorS 'ConvAccessDenied, + ErrorS 'ConvNotFound, + ErrorS 'MLSNotEnabled, + ErrorS 'MLSStaleMessage, + FederatorAccess, + Input Env, + MemberStore, + Resource, + SubConversationStore + ] + r + ) => + Qualified UserId -> + Local ConvId -> + SubConvId -> + DeleteSubConversationRequest -> + Sem r () +deleteLocalSubConversation qusr lcnvId scnvId dsc = do + assertMLSEnabled + let cnvId = tUnqualified lcnvId + lConvOrSubId = qualifyAs lcnvId (SubConv cnvId scnvId) + cnv <- getConversationAndCheckMembership qusr lcnvId + + (mlsMeta, _mlsProtocol) <- noteS @'ConvNotFound (mlsMetadata cnv) + + let cs = cnvmlsCipherSuite mlsMeta + + withCommitLock lConvOrSubId (dscGroupId dsc) (dscEpoch dsc) $ do + sconv <- + Eff.getSubConversation cnvId scnvId + >>= noteS @'ConvNotFound + let (gid, epoch) = (cnvmlsGroupId &&& cnvmlsEpoch) (scMLSData sconv) + unless (dscGroupId dsc == gid) $ throwS @'ConvNotFound + unless (dscEpoch dsc == epoch) $ throwS @'MLSStaleMessage + Eff.removeAllMLSClients gid + + -- swallowing the error and starting with GroupIdGen 0 if nextGenGroupId + let newGid = + fromRight + ( convToGroupId $ + groupIdParts + (Data.convType cnv) + (flip SubConv scnvId <$> tUntagged lcnvId) + ) + $ nextGenGroupId gid + + -- the following overwrites any prior information about the subconversation + void $ Eff.createSubConversation cnvId scnvId cs newGid + +deleteRemoteSubConversation :: + ( Members + '[ ErrorS 'ConvAccessDenied, + ErrorS 'ConvNotFound, + ErrorS 'MLSNotEnabled, + ErrorS 'MLSStaleMessage, + Error FederationError, + FederatorAccess, + Input Env + ] + r + ) => + Local UserId -> + Remote ConvId -> + SubConvId -> + DeleteSubConversationRequest -> + Sem r () +deleteRemoteSubConversation lusr rcnvId scnvId dsc = do + assertMLSEnabled + let deleteRequest = + DeleteSubConversationFedRequest + { dscreqUser = tUnqualified lusr, + dscreqConv = tUnqualified rcnvId, + dscreqSubConv = scnvId, + dscreqGroupId = dscGroupId dsc, + dscreqEpoch = dscEpoch dsc + } + response <- + runFederated + rcnvId + (fedClient @'Galley @"delete-sub-conversation" deleteRequest) + case response of + DeleteSubConversationResponseError e -> rethrowErrors @MLSDeleteSubConvStaticErrors e + DeleteSubConversationResponseSuccess -> pure () + +type HasLeaveSubConversationEffects r = + ( Members + '[ BackendNotificationQueueAccess, + ConversationStore, + ExternalAccess, + FederatorAccess, + GundeckAccess, + Input Env, + Input UTCTime, + MemberStore, + ProposalStore, + SubConversationStore, + TinyLog + ] + r + ) + +type LeaveSubConversationStaticErrors = + '[ ErrorS 'ConvNotFound, + ErrorS 'ConvAccessDenied, + ErrorS 'MLSStaleMessage, + ErrorS 'MLSNotEnabled + ] + +leaveSubConversation :: + ( HasLeaveSubConversationEffects r, + Member (Error MLSProtocolError) r, + Member (Error FederationError) r, + Member (ErrorS 'MLSStaleMessage) r, + Member (ErrorS 'MLSNotEnabled) r, + Member Resource r, + Members LeaveSubConversationStaticErrors r + ) => + Local UserId -> + ClientId -> + Qualified ConvId -> + SubConvId -> + Sem r () +leaveSubConversation lusr cli qcnv sub = + foldQualified + lusr + (leaveLocalSubConversation cid) + (leaveRemoteSubConversation cid) + qcnv + sub + where + cid = mkClientIdentity (tUntagged lusr) cli + +leaveLocalSubConversation :: + ( HasLeaveSubConversationEffects r, + Member (Error MLSProtocolError) r, + Member (ErrorS 'MLSStaleMessage) r, + Member (ErrorS 'MLSNotEnabled) r, + Member Resource r, + Members LeaveSubConversationStaticErrors r + ) => + ClientIdentity -> + Local ConvId -> + SubConvId -> + Sem r () +leaveLocalSubConversation cid lcnv sub = do + assertMLSEnabled + cnv <- getConversationAndCheckMembership (cidQualifiedUser cid) lcnv + mlsConv <- noteS @'ConvNotFound =<< mkMLSConversation cnv + subConv <- + noteS @'ConvNotFound + =<< Eff.getSubConversation (tUnqualified lcnv) sub + idx <- + note (mlsProtocolError "Client is not a member of the subconversation") $ + cmLookupIndex cid (scMembers subConv) + let (gid, epoch) = (cnvmlsGroupId &&& cnvmlsEpoch) (scMLSData subConv) + -- plan to remove the leaver from the member list + Eff.planClientRemoval gid (Identity cid) + let cm = cmRemoveClient cid (scMembers subConv) + if Map.null cm + then do + deleteLocalSubConversation + (cidQualifiedUser cid) + lcnv + sub + $ DeleteSubConversationRequest gid epoch + else + createAndSendRemoveProposals + (qualifyAs lcnv (SubConv mlsConv subConv)) + (Identity idx) + (cidQualifiedUser cid) + cm + +leaveRemoteSubConversation :: + ( Members + '[ ErrorS 'ConvNotFound, + ErrorS 'ConvAccessDenied, + Error FederationError, + Error MLSProtocolError, + FederatorAccess + ] + r + ) => + ClientIdentity -> + Remote ConvId -> + SubConvId -> + Sem r () +leaveRemoteSubConversation cid rcnv sub = do + res <- + runFederated rcnv $ + fedClient @'Galley @"leave-sub-conversation" $ + LeaveSubConversationRequest + { lscrUser = ciUser cid, + lscrClient = ciClient cid, + lscrConv = tUnqualified rcnv, + lscrSubConv = sub + } + case res of + LeaveSubConversationResponseError e -> + rethrowErrors @'[ErrorS 'ConvNotFound, ErrorS 'ConvAccessDenied] e + LeaveSubConversationResponseProtocolError e -> + throw (mlsProtocolError e) + LeaveSubConversationResponseOk -> pure () diff --git a/services/galley/src/Galley/API/MLS/Types.hs b/services/galley/src/Galley/API/MLS/Types.hs index 7582a02b5a5..13a14d9b6a4 100644 --- a/services/galley/src/Galley/API/MLS/Types.hs +++ b/services/galley/src/Galley/API/MLS/Types.hs @@ -14,37 +14,215 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . +{-# LANGUAGE RecordWildCards #-} -module Galley.API.MLS.Types - ( ClientMap, - mkClientMap, - cmAssocs, - ListGlobalSelfConvs (..), - ) -where +module Galley.API.MLS.Types where import Data.Domain import Data.Id +import Data.IntMap (IntMap) +import Data.IntMap qualified as IntMap import Data.Map qualified as Map import Data.Qualified -import Data.Set qualified as Set -import Imports -import Wire.API.MLS.KeyPackage +import GHC.Records (HasField (..)) +import Galley.Data.Conversation.Types +import Galley.Types.Conversations.Members +import Imports hiding (cs) +import Wire.API.Conversation +import Wire.API.Conversation.Protocol +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.Credential +import Wire.API.MLS.Group.Serialisation +import Wire.API.MLS.LeafNode +import Wire.API.MLS.SubConversation -type ClientMap = Map (Qualified UserId) (Set (ClientId, KeyPackageRef)) +-- | A map of leaf index to members. +-- +-- This is used to reconstruct client +-- identities from leaf indices in remove proposals, as well as to allocate new +-- indices for added clients. +-- +-- Note that clients that are in the process of being removed from a group +-- (i.e. there is a pending remove proposals for them) are included in this +-- mapping. +newtype IndexMap = IndexMap {unIndexMap :: IntMap ClientIdentity} + deriving (Eq, Show) + deriving newtype (Semigroup, Monoid) + +mkIndexMap :: [(Domain, UserId, ClientId, Int32, Bool)] -> IndexMap +mkIndexMap = IndexMap . foldr addEntry mempty + where + addEntry (dom, usr, c, leafidx, _pending_removal) = + IntMap.insert (fromIntegral leafidx) (ClientIdentity dom usr c) + +imLookup :: IndexMap -> LeafIndex -> Maybe ClientIdentity +imLookup m i = IntMap.lookup (fromIntegral i) (unIndexMap m) + +imNextIndex :: IndexMap -> LeafIndex +imNextIndex im = + fromIntegral . fromJust $ + find (\n -> not $ IntMap.member n (unIndexMap im)) [0 ..] + +imAddClient :: IndexMap -> ClientIdentity -> (LeafIndex, IndexMap) +imAddClient im cid = let idx = imNextIndex im in (idx, IndexMap $ IntMap.insert (fromIntegral idx) cid $ unIndexMap im) + +imRemoveClient :: IndexMap -> LeafIndex -> Maybe (ClientIdentity, IndexMap) +imRemoveClient im idx = do + cid <- imLookup im idx + pure (cid, IndexMap . IntMap.delete (fromIntegral idx) $ unIndexMap im) -mkClientMap :: [(Domain, UserId, ClientId, KeyPackageRef)] -> ClientMap +-- | A two-level map of users to clients to leaf indices. +-- +-- This is used to keep track of the state of an MLS group for e.g. propagating +-- a message to all the clients that are supposed to receive it. +-- +-- Note that clients that are in the process of being removed from a group +-- (i.e. there is a pending remove proposals for them) are __not__ included in +-- this mapping. +type ClientMap = Map (Qualified UserId) (Map ClientId LeafIndex) + +mkClientMap :: [(Domain, UserId, ClientId, Int32, Bool)] -> ClientMap mkClientMap = foldr addEntry mempty where - addEntry :: (Domain, UserId, ClientId, KeyPackageRef) -> ClientMap -> ClientMap - addEntry (dom, usr, c, kpr) = - Map.insertWith (<>) (Qualified usr dom) (Set.singleton (c, kpr)) + addEntry :: (Domain, UserId, ClientId, Int32, Bool) -> ClientMap -> ClientMap + addEntry (dom, usr, c, leafidx, pending_removal) + | pending_removal = id -- treat as removed, don't add to ClientMap + | otherwise = Map.insertWith (<>) (Qualified usr dom) (Map.singleton c (fromIntegral leafidx)) + +cmLookupIndex :: ClientIdentity -> ClientMap -> Maybe LeafIndex +cmLookupIndex cid cm = do + clients <- Map.lookup (cidQualifiedUser cid) cm + Map.lookup (ciClient cid) clients + +cmRemoveClient :: ClientIdentity -> ClientMap -> ClientMap +cmRemoveClient cid cm = case Map.lookup (cidQualifiedUser cid) cm of + Nothing -> cm + Just clients -> + let clients' = Map.delete (ciClient cid) clients + in if Map.null clients' + then Map.delete (cidQualifiedUser cid) cm + else Map.insert (cidQualifiedUser cid) clients' cm + +isClientMember :: ClientIdentity -> ClientMap -> Bool +isClientMember ci = isJust . cmLookupIndex ci + +cmAssocs :: ClientMap -> [(ClientIdentity, LeafIndex)] +cmAssocs cm = do + (quid, clients) <- Map.assocs cm + (clientId, idx) <- Map.assocs clients + pure (mkClientIdentity quid clientId, idx) -cmAssocs :: ClientMap -> [(Qualified UserId, (ClientId, KeyPackageRef))] -cmAssocs cm = Map.assocs cm >>= traverse toList +cmIdentities :: ClientMap -> [ClientIdentity] +cmIdentities = map fst . cmAssocs + +cmSingleton :: ClientIdentity -> LeafIndex -> ClientMap +cmSingleton cid idx = + Map.singleton + (cidQualifiedUser cid) + (Map.singleton (ciClient cid) idx) -- | Inform a handler for 'POST /conversations/list-ids' if the MLS global team -- conversation and the MLS self-conversation should be included in the -- response. data ListGlobalSelfConvs = ListGlobalSelf | DoNotListGlobalSelf deriving (Eq) + +data MLSConversation = MLSConversation + { mcId :: ConvId, + mcMetadata :: ConversationMetadata, + mcMLSData :: ConversationMLSData, + mcLocalMembers :: [LocalMember], + mcRemoteMembers :: [RemoteMember], + mcMembers :: ClientMap, + mcIndexMap :: IndexMap, + mcMigrationState :: MLSMigrationState + } + deriving (Show) + +data SubConversation = SubConversation + { scParentConvId :: ConvId, + scSubConvId :: SubConvId, + scMLSData :: ConversationMLSData, + scMembers :: ClientMap, + scIndexMap :: IndexMap + } + deriving (Eq, Show) + +newSubConversation :: ConvId -> SubConvId -> CipherSuiteTag -> GroupId -> SubConversation +newSubConversation convId subConvId suite groupId = + SubConversation + { scParentConvId = convId, + scSubConvId = subConvId, + scMLSData = + ConversationMLSData + { cnvmlsGroupId = groupId, + cnvmlsEpoch = Epoch 0, + cnvmlsEpochTimestamp = Nothing, + cnvmlsCipherSuite = suite + }, + scMembers = mkClientMap [], + scIndexMap = mempty + } + +newSubConversationFromParent :: + Local ConvId -> + SubConvId -> + ConversationMLSData -> + SubConversation +newSubConversationFromParent lconv sconv mlsMeta = + let groupId = + convToGroupId + . groupIdParts RegularConv + $ flip SubConv sconv <$> tUntagged lconv + suite = cnvmlsCipherSuite mlsMeta + in newSubConversation (tUnqualified lconv) sconv suite groupId + +toPublicSubConv :: Qualified SubConversation -> PublicSubConversation +toPublicSubConv (Qualified (SubConversation {..}) domain) = + let members = map fst (cmAssocs scMembers) + in PublicSubConversation + { pscParentConvId = Qualified scParentConvId domain, + pscSubConvId = scSubConvId, + pscGroupId = cnvmlsGroupId scMLSData, + pscEpoch = cnvmlsEpoch scMLSData, + pscEpochTimestamp = cnvmlsEpochTimestamp scMLSData, + pscCipherSuite = cnvmlsCipherSuite scMLSData, + pscMembers = members + } + +type ConvOrSubConv = ConvOrSubChoice MLSConversation SubConversation + +instance HasField "meta" ConvOrSubConv ConversationMetadata where + getField x = x.conv.mcMetadata + +instance HasField "mlsMeta" ConvOrSubConv ConversationMLSData where + getField (Conv c) = mcMLSData c + getField (SubConv _ s) = scMLSData s + +instance HasField "members" ConvOrSubConv ClientMap where + getField (Conv c) = mcMembers c + getField (SubConv _ s) = scMembers s + +instance HasField "indexMap" ConvOrSubConv IndexMap where + getField (Conv c) = mcIndexMap c + getField (SubConv _ s) = scIndexMap s + +instance HasField "id" ConvOrSubConv ConvOrSubConvId where + getField (Conv c) = Conv (mcId c) + getField (SubConv c s) = SubConv (mcId c) (scSubConvId s) + +instance HasField "migrationState" ConvOrSubConv MLSMigrationState where + getField (Conv c) = c.mcMigrationState + getField (SubConv _ _) = MLSMigrationMLS + +convOrSubConvSetCipherSuite :: CipherSuiteTag -> ConvOrSubConv -> ConvOrSubConv +convOrSubConvSetCipherSuite cs (Conv c) = + Conv $ + c + { mcMLSData = (mcMLSData c) {cnvmlsCipherSuite = cs} + } +convOrSubConvSetCipherSuite cs (SubConv c s) = + SubConv c $ + s + { scMLSData = (scMLSData s) {cnvmlsCipherSuite = cs} + } diff --git a/services/galley/src/Galley/API/MLS/Util.hs b/services/galley/src/Galley/API/MLS/Util.hs index 14437bf6512..cc77d6f3dfa 100644 --- a/services/galley/src/Galley/API/MLS/Util.hs +++ b/services/galley/src/Galley/API/MLS/Util.hs @@ -20,24 +20,31 @@ module Galley.API.MLS.Util where import Control.Comonad import Data.Id import Data.Qualified +import Data.Text qualified as T import Galley.Data.Conversation.Types hiding (Conversation) import Galley.Data.Conversation.Types qualified as Data +import Galley.Data.Types import Galley.Effects import Galley.Effects.ConversationStore import Galley.Effects.MemberStore import Galley.Effects.ProposalStore +import Galley.Effects.SubConversationStore import Imports import Polysemy +import Polysemy.Error +import Polysemy.Resource (Resource, bracket) import Polysemy.TinyLog (TinyLog) import Polysemy.TinyLog qualified as TinyLog import System.Logger qualified as Log +import Wire.API.Conversation hiding (Member) import Wire.API.Error import Wire.API.Error.Galley import Wire.API.MLS.Epoch -import Wire.API.MLS.Group -import Wire.API.MLS.KeyPackage +import Wire.API.MLS.Group.Serialisation +import Wire.API.MLS.LeafNode import Wire.API.MLS.Proposal import Wire.API.MLS.Serialisation +import Wire.API.MLS.SubConversation getLocalConvForUser :: ( Member (ErrorS 'ConvNotFound) r, @@ -70,18 +77,58 @@ getPendingBackendRemoveProposals :: ) => GroupId -> Epoch -> - Sem r [KeyPackageRef] + Sem r [LeafIndex] getPendingBackendRemoveProposals gid epoch = do proposals <- getAllPendingProposals gid epoch catMaybes <$> for proposals ( \case - (Just ProposalOriginBackend, proposal) -> case rmValue proposal of - RemoveProposal kp -> pure . Just $ kp + (Just ProposalOriginBackend, proposal) -> case value proposal of + RemoveProposal i -> pure (Just i) _ -> pure Nothing (Just ProposalOriginClient, _) -> pure Nothing (Nothing, _) -> do TinyLog.warn $ Log.msg ("found pending proposal without origin, ignoring" :: ByteString) pure Nothing ) + +withCommitLock :: + forall r a. + ( Members + '[ Resource, + ConversationStore, + ErrorS 'MLSStaleMessage, + SubConversationStore + ] + r + ) => + Local ConvOrSubConvId -> + GroupId -> + Epoch -> + Sem r a -> + Sem r a +withCommitLock lConvOrSubId gid epoch action = + bracket + ( acquireCommitLock gid epoch ttl >>= \lockAcquired -> + when (lockAcquired == NotAcquired) $ + throwS @'MLSStaleMessage + ) + (const $ releaseCommitLock gid epoch) + $ \_ -> do + actualEpoch <- + fromMaybe (Epoch 0) <$> case tUnqualified lConvOrSubId of + Conv cnv -> getConversationEpoch cnv + SubConv cnv sub -> getSubConversationEpoch cnv sub + unless (actualEpoch == epoch) $ throwS @'MLSStaleMessage + action + where + ttl = fromIntegral (600 :: Int) -- 10 minutes + +getConvFromGroupId :: + Member (Error MLSProtocolError) r => + GroupId -> + Sem r (ConvType, Qualified ConvOrSubConvId) +getConvFromGroupId gid = case groupIdToConv gid of + Left e -> throw (mlsProtocolError (T.pack e)) + Right parts -> pure (parts.convType, parts.qConvId) diff --git a/services/galley/src/Galley/API/MLS/Welcome.hs b/services/galley/src/Galley/API/MLS/Welcome.hs index 81357ad69b2..5ee163ea4f7 100644 --- a/services/galley/src/Galley/API/MLS/Welcome.hs +++ b/services/galley/src/Galley/API/MLS/Welcome.hs @@ -1,5 +1,3 @@ -{-# OPTIONS -Wno-redundant-constraints#-} - -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -18,8 +16,7 @@ -- with this program. If not, see . module Galley.API.MLS.Welcome - ( postMLSWelcome, - postMLSWelcomeFromLocalUser, + ( sendWelcomes, sendLocalWelcomes, ) where @@ -31,13 +28,10 @@ import Data.Id import Data.Json.Util import Data.Qualified import Data.Time -import Galley.API.MLS.Enabled -import Galley.API.MLS.KeyPackage -import Galley.Effects.BrigAccess +import Galley.API.Push import Galley.Effects.ExternalAccess import Galley.Effects.FederatorAccess import Galley.Effects.GundeckAccess -import Galley.Env import Imports import Network.Wai.Utilities.JSONResponse import Polysemy @@ -46,88 +40,76 @@ import Polysemy.TinyLog qualified as P import System.Logger.Class qualified as Logger import Wire.API.Error import Wire.API.Error.Galley +import Wire.API.Event.Conversation import Wire.API.Federation.API import Wire.API.Federation.API.Galley import Wire.API.Federation.Error import Wire.API.MLS.Credential +import Wire.API.MLS.Message import Wire.API.MLS.Serialisation +import Wire.API.MLS.SubConversation import Wire.API.MLS.Welcome +import Wire.API.Message -postMLSWelcome :: - ( Member BrigAccess r, - Member FederatorAccess r, +sendWelcomes :: + ( Member FederatorAccess r, Member GundeckAccess r, Member ExternalAccess r, - Member (ErrorS 'MLSKeyPackageRefNotFound) r, - Member (Input UTCTime) r, - Member P.TinyLog r + Member P.TinyLog r, + Member (Input UTCTime) r ) => - Local x -> + Local ConvOrSubConvId -> + Qualified UserId -> Maybe ConnId -> + [ClientIdentity] -> RawMLS Welcome -> Sem r () -postMLSWelcome loc con wel = do +sendWelcomes loc qusr con cids welcome = do now <- input - rcpts <- welcomeRecipients (rmValue wel) - let (locals, remotes) = partitionQualified loc rcpts - sendLocalWelcomes con now (rmRaw wel) (qualifyAs loc locals) - sendRemoteWelcomes (rmRaw wel) remotes - -postMLSWelcomeFromLocalUser :: - ( Member BrigAccess r, - Member FederatorAccess r, - Member GundeckAccess r, - Member ExternalAccess r, - Member (ErrorS 'MLSKeyPackageRefNotFound) r, - Member (ErrorS 'MLSNotEnabled) r, - Member (Input UTCTime) r, - Member (Input Env) r, - Member P.TinyLog r - ) => - Local x -> - ConnId -> - RawMLS Welcome -> - Sem r () -postMLSWelcomeFromLocalUser loc con wel = do - assertMLSEnabled - postMLSWelcome loc (Just con) wel - -welcomeRecipients :: - ( Member BrigAccess r, - Member (ErrorS 'MLSKeyPackageRefNotFound) r - ) => - Welcome -> - Sem r [Qualified (UserId, ClientId)] -welcomeRecipients = - traverse - ( fmap cidQualifiedClient - . derefKeyPackage - . gsNewMember - ) - . welSecrets + let qcnv = convFrom <$> tUntagged loc + (locals, remotes) = partitionQualified loc (map cidQualifiedClient cids) + msg = mkRawMLS $ mkMessage (MessageWelcome welcome) + sendLocalWelcomes qcnv qusr con now msg (qualifyAs loc locals) + sendRemoteWelcomes qcnv qusr msg remotes + where + convFrom (Conv c) = c + convFrom (SubConv c _) = c sendLocalWelcomes :: + Member GundeckAccess r => + Member P.TinyLog r => + Member ExternalAccess r => + Qualified ConvId -> + Qualified UserId -> Maybe ConnId -> UTCTime -> - ByteString -> + RawMLS Message -> Local [(UserId, ClientId)] -> Sem r () -sendLocalWelcomes _con _now _rawWelcome _lclients = do - -- This function is only implemented on the MLS branch. - pure () +sendLocalWelcomes qcnv qusr con now welcome lclients = do + let e = Event qcnv Nothing qusr now $ EdMLSWelcome welcome.raw + runMessagePush lclients (Just qcnv) $ + newMessagePush mempty con defMessageMetadata (tUnqualified lclients) e sendRemoteWelcomes :: ( Member FederatorAccess r, Member P.TinyLog r ) => - ByteString -> + Qualified ConvId -> + Qualified UserId -> + RawMLS Message -> [Remote (UserId, ClientId)] -> Sem r () -sendRemoteWelcomes rawWelcome clients = do - let req = MLSWelcomeRequest . Base64ByteString $ rawWelcome - rpc = fedClient @'Galley @"mls-welcome" req - traverse_ handleError <=< runFederatedConcurrentlyEither clients $ - const rpc +sendRemoteWelcomes qcnv qusr welcome clients = do + let msg = Base64ByteString welcome.raw + traverse_ handleError <=< runFederatedConcurrentlyEither clients $ \rcpts -> + fedClient @'Galley @"mls-welcome" + MLSWelcomeRequest + { originatingUser = qUnqualified qusr, + welcomeMessage = msg, + recipients = tUnqualified rcpts, + qualifiedConvId = qcnv + } where handleError :: Member P.TinyLog r => diff --git a/services/galley/src/Galley/API/Mapping.hs b/services/galley/src/Galley/API/Mapping.hs index 074cab6f2f9..ec6f0993e12 100644 --- a/services/galley/src/Galley/API/Mapping.hs +++ b/services/galley/src/Galley/API/Mapping.hs @@ -29,7 +29,6 @@ import Data.Id (UserId, idToText) import Data.Qualified import Galley.API.Error import Galley.Data.Conversation qualified as Data -import Galley.Data.Types (convId) import Galley.Types.Conversations.Members import Imports import Polysemy @@ -76,7 +75,7 @@ conversationViewWithCachedOthers remoteOthers localOthers conv luid = do val "User " +++ idToText (tUnqualified luid) +++ val " is not a member of conv " - +++ idToText (convId conv) + +++ idToText (Data.convId conv) throw BadMemberState -- | View for a given user of a stored conversation. @@ -89,7 +88,7 @@ conversationViewMaybe luid remoteOthers localOthers conv = do let others = filter (\oth -> tUntagged luid /= omQualifiedId oth) localOthers <> remoteOthers pure $ Conversation - (tUntagged . qualifyAs luid . convId $ conv) + (tUntagged . qualifyAs luid . Data.convId $ conv) (Data.convMetadata conv) (ConvMembers self others) (Data.convProtocol conv) @@ -101,8 +100,8 @@ remoteConversationView :: Remote RemoteConversation -> Conversation remoteConversationView uid status (tUntagged -> Qualified rconv rDomain) = - let mems = rcnvMembers rconv - others = rcmOthers mems + let mems = rconv.members + others = mems.others self = localMemberToSelf uid @@ -110,13 +109,13 @@ remoteConversationView uid status (tUntagged -> Qualified rconv rDomain) = { lmId = tUnqualified uid, lmService = Nothing, lmStatus = status, - lmConvRoleName = rcmSelfRole mems + lmConvRoleName = mems.selfRole } in Conversation - (Qualified (rcnvId rconv) rDomain) - (rcnvMetadata rconv) + (Qualified rconv.id rDomain) + rconv.metadata (ConvMembers self others) - (rcnvProtocol rconv) + rconv.protocol -- | Convert a local conversation to a structure to be returned to a remote -- backend. @@ -130,20 +129,20 @@ conversationToRemote :: conversationToRemote localDomain ruid conv = do let (selfs, rothers) = partition ((== ruid) . rmId) (Data.convRemoteMembers conv) lothers = Data.convLocalMembers conv - selfRole <- rmConvRoleName <$> listToMaybe selfs - let others = + selfRole' <- rmConvRoleName <$> listToMaybe selfs + let others' = map (localMemberToOther localDomain) lothers <> map remoteMemberToOther rothers pure $ RemoteConversation - { rcnvId = Data.convId conv, - rcnvMetadata = Data.convMetadata conv, - rcnvMembers = + { id = Data.convId conv, + metadata = Data.convMetadata conv, + members = RemoteConvMembers - { rcmSelfRole = selfRole, - rcmOthers = others + { selfRole = selfRole', + others = others' }, - rcnvProtocol = Data.convProtocol conv + protocol = Data.convProtocol conv } -- | Convert a local conversation member (as stored in the DB) to a publicly diff --git a/services/galley/src/Galley/API/Message.hs b/services/galley/src/Galley/API/Message.hs index 5b210ee8347..66657736a6c 100644 --- a/services/galley/src/Galley/API/Message.hs +++ b/services/galley/src/Galley/API/Message.hs @@ -148,7 +148,7 @@ mkMessageSendingStatus time mismatch = } clientMismatchStrategyApply :: ClientMismatchStrategy -> QualifiedRecipientSet -> QualifiedRecipientSet -clientMismatchStrategyApply MismatchReportAll = id +clientMismatchStrategyApply MismatchReportAll = Imports.id clientMismatchStrategyApply MismatchIgnoreAll = const mempty clientMismatchStrategyApply (MismatchReportOnly users) = Set.filter (\(d, u, _) -> Set.member (Qualified u d) users) @@ -190,7 +190,7 @@ checkMessageClients :: ClientMismatchStrategy -> (Bool, Map (Domain, UserId, ClientId) ByteString, QualifiedMismatch) checkMessageClients sender participantMap recipientMap mismatchStrat = - let participants = setOf ((itraversed <. folded) . withIndex . to (\((d, u), c) -> (d, u, c))) participantMap + let participants = setOf ((itraversed <. folded) . withIndex . Control.Lens.to (\((d, u), c) -> (d, u, c))) participantMap expected = Set.delete sender participants expectedUsers :: Set (Domain, UserId) = Map.keysSet participantMap @@ -242,12 +242,12 @@ postRemoteOtrMessage :: postRemoteOtrMessage sender conv rawMsg = do let msr = ProteusMessageSendRequest - { pmsrConvId = tUnqualified conv, - pmsrSender = qUnqualified sender, - pmsrRawMessage = Base64ByteString rawMsg + { convId = tUnqualified conv, + sender = qUnqualified sender, + rawMessage = Base64ByteString rawMsg } rpc = fedClient @'Galley @"send-message" msr - msResponse <$> runFederated conv rpc + (.response) <$> runFederated conv rpc postBroadcast :: ( Member BrigAccess r, @@ -287,7 +287,7 @@ postBroadcast lusr con msg = runError $ do -- is used and length `report_missing` < limit since we cannot fetch larger teams than -- that. tMembers <- - fmap (view userId) <$> case qualifiedNewOtrClientMismatchStrategy msg of + fmap (view Wire.API.Team.Member.userId) <$> case qualifiedNewOtrClientMismatchStrategy msg of -- Note: remote ids are not in a local team MismatchReportOnly qus -> maybeFetchLimitedTeamMemberList @@ -397,7 +397,7 @@ postQualifiedOtrMessage senderType sender mconn lcnv msg = let senderClient = qualifiedNewOtrSender msg conv <- getConversation (tUnqualified lcnv) >>= noteS @'ConvNotFound - unless (protocolTag (convProtocol conv) == ProtocolProteusTag) $ + unless (protocolTag (convProtocol conv) `elem` [ProtocolProteusTag, ProtocolMixedTag]) $ throwS @'InvalidOperation let localMemberIds = lmId <$> convLocalMembers conv @@ -411,7 +411,7 @@ postQualifiedOtrMessage senderType sender mconn lcnv msg = Set.fromList $ map (tUntagged . qualifyAs lcnv) localMemberIds <> map (tUntagged . rmId) (convRemoteMembers conv) - isInternal <- view (optSettings . setIntraListing) <$> input + isInternal <- view (settings . intraListing) <$> input -- check if the sender is part of the conversation unless (Set.member sender members) $ @@ -653,17 +653,17 @@ sendRemoteMessages domain now sender senderClient lcnv metadata messages = (hand (Map.assocs messages) rm = RemoteMessage - { rmTime = now, - rmData = mmData metadata, - rmSender = sender, - rmSenderClient = senderClient, - rmConversation = tUnqualified lcnv, - rmPriority = mmNativePriority metadata, - rmPush = mmNativePush metadata, - rmTransient = mmTransient metadata, - rmRecipients = UserClientMap rcpts + { time = now, + _data = mmData metadata, + sender = sender, + senderClient = senderClient, + conversation = tUnqualified lcnv, + priority = mmNativePriority metadata, + push = mmNativePush metadata, + transient = mmTransient metadata, + recipients = UserClientMap rcpts } - let rpc = void $ fedQueueClient @'Galley @"on-message-sent" rm + let rpc = void $ fedQueueClient @'OnMessageSentTag rm enqueueNotification domain Q.Persistent rpc where handle :: Either FederationError a -> Sem r (Set (UserId, ClientId)) @@ -716,7 +716,7 @@ class Unqualify a b where unqualify :: Domain -> a -> b instance Unqualify a a where - unqualify _ = id + unqualify _ = Imports.id instance Unqualify MessageSendingStatus ClientMismatch where unqualify domain status = diff --git a/services/galley/src/Galley/API/One2One.hs b/services/galley/src/Galley/API/One2One.hs index c2a98414ada..039ca96f012 100644 --- a/services/galley/src/Galley/API/One2One.hs +++ b/services/galley/src/Galley/API/One2One.hs @@ -35,8 +35,8 @@ import Galley.Types.UserList import Imports import Polysemy import Wire.API.Conversation hiding (Member) -import Wire.API.Conversation.Protocol -import Wire.API.Routes.Internal.Galley.ConversationsIntra (Actor (..), DesiredMembership (..), UpsertOne2OneConversationRequest (..), UpsertOne2OneConversationResponse (..)) +import Wire.API.Routes.Internal.Galley.ConversationsIntra +import Wire.API.User newConnectConversationWithRemote :: Local UserId -> @@ -45,11 +45,11 @@ newConnectConversationWithRemote :: newConnectConversationWithRemote creator users = NewConversation { ncMetadata = - (defConversationMetadata (tUnqualified creator)) + (defConversationMetadata (Just (tUnqualified creator))) { cnvmType = One2OneConv }, ncUsers = fmap toUserRole users, - ncProtocol = ProtocolProteusTag + ncProtocol = BaseProtocolProteusTag } iUpsertOne2OneConversation :: @@ -60,7 +60,14 @@ iUpsertOne2OneConversation :: UpsertOne2OneConversationRequest -> Sem r UpsertOne2OneConversationResponse iUpsertOne2OneConversation UpsertOne2OneConversationRequest {..} = do - let convId = fromMaybe (one2OneConvId (tUntagged uooLocalUser) (tUntagged uooRemoteUser)) uooConvId + let convId = + fromMaybe + ( one2OneConvId + BaseProtocolProteusTag + (tUntagged uooLocalUser) + (tUntagged uooRemoteUser) + ) + uooConvId let dolocal :: Local ConvId -> Sem r () dolocal lconvId = do diff --git a/services/galley/src/Galley/API/Public/Conversation.hs b/services/galley/src/Galley/API/Public/Conversation.hs index 6ea0853f8ba..6341091e356 100644 --- a/services/galley/src/Galley/API/Public/Conversation.hs +++ b/services/galley/src/Galley/API/Public/Conversation.hs @@ -19,6 +19,7 @@ module Galley.API.Public.Conversation where import Galley.API.Create import Galley.API.MLS.GroupInfo +import Galley.API.MLS.SubConversation import Galley.API.MLS.Types import Galley.API.Query import Galley.API.Update @@ -50,8 +51,13 @@ conversationAPI = <@> mkNamedAPI @"create-self-conversation@v2" createProteusSelfConversation <@> mkNamedAPI @"create-self-conversation" createProteusSelfConversation <@> mkNamedAPI @"get-mls-self-conversation" getMLSSelfConversationWithError + <@> mkNamedAPI @"get-subconversation" (callsFed getSubConversation) + <@> mkNamedAPI @"leave-subconversation" (callsFed leaveSubConversation) + <@> mkNamedAPI @"delete-subconversation" (callsFed deleteSubConversation) + <@> mkNamedAPI @"get-subconversation-group-info" (callsFed getSubConversationGroupInfo) <@> mkNamedAPI @"create-one-to-one-conversation@v2" (callsFed createOne2OneConversation) <@> mkNamedAPI @"create-one-to-one-conversation" (callsFed createOne2OneConversation) + <@> mkNamedAPI @"get-one-to-one-mls-conversation" getMLSOne2OneConversation <@> mkNamedAPI @"add-members-to-conversation-unqualified" (callsFed addMembersUnqualified) <@> mkNamedAPI @"add-members-to-conversation-unqualified2" (callsFed addMembersUnqualifiedV2) <@> mkNamedAPI @"add-members-to-conversation" (callsFed addMembers) @@ -82,3 +88,4 @@ conversationAPI = <@> mkNamedAPI @"get-conversation-self-unqualified" getLocalSelf <@> mkNamedAPI @"update-conversation-self-unqualified" updateUnqualifiedSelfMember <@> mkNamedAPI @"update-conversation-self" updateSelfMember + <@> mkNamedAPI @"update-conversation-protocol" updateConversationProtocolWithLocalUser diff --git a/services/galley/src/Galley/API/Public/Feature.hs b/services/galley/src/Galley/API/Public/Feature.hs index 65fd370b2b4..20ad5e4bef6 100644 --- a/services/galley/src/Galley/API/Public/Feature.hs +++ b/services/galley/src/Galley/API/Public/Feature.hs @@ -63,6 +63,8 @@ featureAPI = <@> mkNamedAPI @'("put", OutlookCalIntegrationConfig) (setFeatureStatus . DoAuth) <@> mkNamedAPI @'("get", MlsE2EIdConfig) (getFeatureStatus . DoAuth) <@> mkNamedAPI @'("put", MlsE2EIdConfig) (setFeatureStatus . DoAuth) + <@> mkNamedAPI @'("get", MlsMigrationConfig) (getFeatureStatus . DoAuth) + <@> mkNamedAPI @'("put", MlsMigrationConfig) (setFeatureStatus . DoAuth) <@> mkNamedAPI @"get-all-feature-configs-for-user" getAllFeatureConfigsForUser <@> mkNamedAPI @"get-all-feature-configs-for-team" getAllFeatureConfigsForTeam <@> mkNamedAPI @'("get-config", LegalholdConfig) getFeatureStatusForUser diff --git a/services/galley/src/Galley/API/Public/MLS.hs b/services/galley/src/Galley/API/Public/MLS.hs index 73187b06da9..fa05f9bf5d6 100644 --- a/services/galley/src/Galley/API/Public/MLS.hs +++ b/services/galley/src/Galley/API/Public/MLS.hs @@ -19,14 +19,12 @@ module Galley.API.Public.MLS where import Galley.API.MLS import Galley.App -import Wire.API.Federation.API +import Wire.API.MakesFederatedCall import Wire.API.Routes.API import Wire.API.Routes.Public.Galley.MLS mlsAPI :: API MLSAPI GalleyEffects mlsAPI = - mkNamedAPI @"mls-welcome-message" (callsFed (exposeAnnotations postMLSWelcomeFromLocalUser)) - <@> mkNamedAPI @"mls-message-v1" (callsFed (exposeAnnotations postMLSMessageFromLocalUserV1)) - <@> mkNamedAPI @"mls-message" (callsFed (exposeAnnotations postMLSMessageFromLocalUser)) + mkNamedAPI @"mls-message" (callsFed (exposeAnnotations postMLSMessageFromLocalUser)) <@> mkNamedAPI @"mls-commit-bundle" (callsFed (exposeAnnotations postMLSCommitBundleFromLocalUser)) <@> mkNamedAPI @"mls-public-keys" getMLSPublicKeys diff --git a/services/galley/src/Galley/API/Public/Servant.hs b/services/galley/src/Galley/API/Public/Servant.hs index 86e223c522a..ea777ec4992 100644 --- a/services/galley/src/Galley/API/Public/Servant.hs +++ b/services/galley/src/Galley/API/Public/Servant.hs @@ -32,7 +32,7 @@ import Galley.App import Wire.API.Routes.API import Wire.API.Routes.Public.Galley -servantSitemap :: API ServantAPI GalleyEffects +servantSitemap :: API GalleyAPI GalleyEffects servantSitemap = conversationAPI <@> teamConversationAPI diff --git a/services/galley/src/Galley/API/Push.hs b/services/galley/src/Galley/API/Push.hs index 51ba1db90f1..786a805a293 100644 --- a/services/galley/src/Galley/API/Push.hs +++ b/services/galley/src/Galley/API/Push.hs @@ -53,22 +53,28 @@ data MessagePush type BotMap = Map UserId BotMember +class ToRecipient a where + toRecipient :: a -> Recipient + +instance ToRecipient (UserId, ClientId) where + toRecipient (u, c) = Recipient u (RecipientClientsSome (List1.singleton c)) + +instance ToRecipient Recipient where + toRecipient = id + newMessagePush :: + ToRecipient r => BotMap -> Maybe ConnId -> MessageMetadata -> - [(UserId, ClientId)] -> + [r] -> Event -> MessagePush newMessagePush botMap mconn mm userOrBots event = - let (recipients, botMembers) = - foldMap - ( \(u, c) -> - case Map.lookup u botMap of - Just botMember -> ([], [botMember]) - Nothing -> ([Recipient u (RecipientClientsSome (List1.singleton c))], []) - ) - userOrBots + let toPair r = case Map.lookup (_recipientUserId r) botMap of + Just botMember -> ([], [botMember]) + Nothing -> ([r], []) + (recipients, botMembers) = foldMap (toPair . toRecipient) userOrBots in MessagePush mconn mm recipients botMembers event runMessagePush :: @@ -90,9 +96,9 @@ runMessagePush loc mqcnv mp@(MessagePush _ _ _ botMembers event) = do else deliverAndDeleteAsync (qUnqualified qcnv) (map (,event) botMembers) toPush :: MessagePush -> Maybe Push -toPush (MessagePush mconn mm userRecipients _ event) = +toPush (MessagePush mconn mm rs _ event) = let usr = qUnqualified (evtFrom event) - in newPush ListComplete (Just usr) (ConvEvent event) userRecipients + in newPush ListComplete (Just usr) (ConvEvent event) rs <&> set pushConn mconn . set pushNativePriority (mmNativePriority mm) . set pushRoute (bool RouteDirect RouteAny (mmNativePush mm)) diff --git a/services/galley/src/Galley/API/Query.hs b/services/galley/src/Galley/API/Query.hs index 16635d55b37..3e9fb0243df 100644 --- a/services/galley/src/Galley/API/Query.hs +++ b/services/galley/src/Galley/API/Query.hs @@ -37,6 +37,7 @@ module Galley.API.Query ensureConvAdmin, getMLSSelfConversation, getMLSSelfConversationWithError, + getMLSOne2OneConversation, ) where @@ -56,9 +57,11 @@ import Data.Set qualified as Set import Galley.API.Error import Galley.API.MLS import Galley.API.MLS.Keys +import Galley.API.MLS.One2One import Galley.API.MLS.Types import Galley.API.Mapping import Galley.API.Mapping qualified as Mapping +import Galley.API.One2One import Galley.API.Util import Galley.Data.Conversation qualified as Data import Galley.Data.Types (Code (codeConversation)) @@ -97,6 +100,7 @@ import Wire.API.Federation.Error import Wire.API.Provider.Bot qualified as Public import Wire.API.Routes.MultiTablePaging qualified as Public import Wire.API.Team.Feature as Public hiding (setStatus) +import Wire.API.User import Wire.Sem.Paging.Cassandra getBotConversationH :: @@ -141,7 +145,7 @@ getUnqualifiedConversation :: ConvId -> Sem r Public.Conversation getUnqualifiedConversation lusr cnv = do - c <- getConversationAndCheckMembership (tUnqualified lusr) (qualifyAs lusr cnv) + c <- getConversationAndCheckMembership (tUntagged lusr) (qualifyAs lusr cnv) Mapping.conversationView lusr c getConversation :: @@ -249,7 +253,7 @@ getRemoteConversationsWithFailures lusr convs = do lusr ( Map.findWithDefault defMemberStatus - (fmap rcnvId rconv) + ((.id) <$> rconv) statusMap ) rconv @@ -277,7 +281,7 @@ getRemoteConversationsWithFailures lusr convs = do Logger.msg ("Error occurred while fetching remote conversations" :: ByteString) . Logger.field "error" (show e) pure . Left $ failedGetConversationRemotely (sequenceA rcids) e - handleFailure (Right c) = pure . Right . traverse gcresConvs $ c + handleFailure (Right c) = pure . Right . traverse (.convs) $ c getConversationRoles :: ( Member ConversationStore r, @@ -288,7 +292,7 @@ getConversationRoles :: ConvId -> Sem r Public.ConversationRolesList getConversationRoles lusr cnv = do - void $ getConversationAndCheckMembership (tUnqualified lusr) (qualifyAs lusr cnv) + void $ getConversationAndCheckMembership (tUntagged lusr) (qualifyAs lusr cnv) -- NOTE: If/when custom roles are added, these roles should -- be merged with the team roles (if they exist) pure $ Public.ConversationRolesList wireConvRoles @@ -690,7 +694,7 @@ getConversationGuestLinksFeatureStatus :: Maybe TeamId -> Sem r (WithStatus GuestLinksConfig) getConversationGuestLinksFeatureStatus mbTid = do - defaultStatus :: WithStatus GuestLinksConfig <- input <&> view (optSettings . setFeatureFlags . flagConversationGuestLinks . unDefaults) + defaultStatus :: WithStatus GuestLinksConfig <- input <&> view (settings . featureFlags . flagConversationGuestLinks . unDefaults) case mbTid of Nothing -> pure defaultStatus Just tid -> do @@ -735,6 +739,81 @@ getMLSSelfConversation lusr = do cnv <- maybe (E.createMLSSelfConversation lusr) pure mconv conversationView lusr cnv +-- | Get an MLS 1-1 conversation. If not already existing, the conversation +-- object is created on the fly, but not persisted. The conversation will only +-- be stored in the database when its first commit arrives. +-- +-- For the federated case, we do not make the assumption that the other backend +-- uses the same function to calculate the conversation ID and corresponding +-- group ID, however we /do/ assume that the two backends agree on which of the +-- two is responsible for hosting the conversation. +getMLSOne2OneConversation :: + ( Member BrigAccess r, + Member ConversationStore r, + Member (Input Env) r, + Member (Error FederationError) r, + Member (Error InternalError) r, + Member (ErrorS 'MLSNotEnabled) r, + Member (ErrorS 'NotConnected) r, + Member FederatorAccess r, + Member TeamStore r, + Member P.TinyLog r + ) => + Local UserId -> + Qualified UserId -> + Sem r Conversation +getMLSOne2OneConversation lself qother = do + assertMLSEnabled + ensureConnectedOrSameTeam lself [qother] + let convId = one2OneConvId BaseProtocolMLSTag (tUntagged lself) qother + foldQualified + lself + (getLocalMLSOne2OneConversation lself) + (getRemoteMLSOne2OneConversation lself qother) + convId + +getLocalMLSOne2OneConversation :: + ( Member ConversationStore r, + Member (Error InternalError) r, + Member P.TinyLog r + ) => + Local UserId -> + Local ConvId -> + Sem r Conversation +getLocalMLSOne2OneConversation lself lconv = do + mconv <- E.getConversation (tUnqualified lconv) + case mconv of + Nothing -> pure (localMLSOne2OneConversation lself lconv) + Just conv -> conversationView lself conv + +getRemoteMLSOne2OneConversation :: + ( Member (Error InternalError) r, + Member (Error FederationError) r, + Member (ErrorS 'NotConnected) r, + Member FederatorAccess r + ) => + Local UserId -> + Qualified UserId -> + Remote conv -> + Sem r Conversation +getRemoteMLSOne2OneConversation lself qother rconv = do + -- a conversation can only be remote if it is hosted on the other user's domain + rother <- + if qDomain qother == tDomain rconv + then pure (toRemoteUnsafe (tDomain rconv) (qUnqualified qother)) + else throw (InternalErrorWithDescription "Unexpected 1-1 conversation domain") + + resp <- + E.runFederated rconv $ + fedClient @'Galley @"get-one2one-conversation" $ + GetOne2OneConversationRequest (tUnqualified lself) (tUnqualified rother) + case resp of + GetOne2OneConversationOk rc -> + pure (remoteMLSOne2OneConversation lself rother rc) + GetOne2OneConversationBackendMismatch -> + throw (FederationUnexpectedBody "Backend mismatch when retrieving a remote 1-1 conversation") + GetOne2OneConversationNotConnected -> throwS @'NotConnected + ------------------------------------------------------------------------------- -- Helpers diff --git a/services/galley/src/Galley/API/Teams.hs b/services/galley/src/Galley/API/Teams.hs index 53f1a9ad815..410c0152857 100644 --- a/services/galley/src/Galley/API/Teams.hs +++ b/services/galley/src/Galley/API/Teams.hs @@ -290,11 +290,11 @@ updateTeamStatus tid (TeamStatusUpdate newStatus cur) = do oldStatus <- fmap tdStatus $ E.getTeam tid >>= noteS @'TeamNotFound valid <- validateTransition (oldStatus, newStatus) when valid $ do - journal newStatus cur + runJournal newStatus cur E.setTeamStatus tid newStatus where - journal Suspended _ = Journal.teamSuspend tid - journal Active c = do + runJournal Suspended _ = Journal.teamSuspend tid + runJournal Active c = do teamCreationTime <- E.getTeamCreationTime tid -- When teams are created, they are activated immediately. In this situation, Brig will -- most likely report team size as 0 due to ES taking some time to index the team creator. @@ -305,7 +305,7 @@ updateTeamStatus tid (TeamStatusUpdate newStatus cur) = do then 1 else possiblyStaleSize Journal.teamActivate tid size c teamCreationTime - journal _ _ = throwS @'InvalidTeamStatusUpdate + runJournal _ _ = throwS @'InvalidTeamStatusUpdate validateTransition :: Member (ErrorS 'InvalidTeamStatusUpdate) r => (TeamStatus, TeamStatus) -> Sem r Bool validateTransition = \case (PendingActive, Active) -> pure True @@ -437,10 +437,10 @@ uncheckedDeleteTeam lusr zcon tid = do where pushDeleteEvents :: [TeamMember] -> Event -> [Push] -> Sem r () pushDeleteEvents membs e ue = do - o <- inputs (view optSettings) + o <- inputs (view settings) let r = list1 (userRecipient (tUnqualified lusr)) (membersToRecipients (Just (tUnqualified lusr)) membs) -- To avoid DoS on gundeck, send team deletion events in chunks - let chunkSize = fromMaybe defConcurrentDeletionEvents (o ^. setConcurrentDeletionEvents) + let chunkSize = fromMaybe defConcurrentDeletionEvents (o ^. concurrentDeletionEvents) let chunks = List.chunksOf chunkSize (toList r) forM_ chunks $ \case [] -> pure () @@ -1027,7 +1027,7 @@ uncheckedDeleteTeamMember lusr zcon tid remove mems = do -- remove the user from conversations but never send out any events. We assume that clients -- handle nicely these missing events, regardless of whether they are in the same team or not let tmids = Set.fromList $ map (view userId) (mems ^. teamMembers) - let edata = Conv.EdMembersLeave (Conv.QualifiedUserIdList [tUntagged (qualifyAs lusr remove)]) + let edata = Conv.EdMembersLeave Conv.EdReasonDeleted (Conv.QualifiedUserIdList [tUntagged (qualifyAs lusr remove)]) cc <- E.getTeamConversations tid for_ cc $ \c -> E.getConversation (c ^. conversationId) >>= \conv -> @@ -1082,17 +1082,22 @@ getTeamConversation zusr tid cid = do >>= noteS @'ConvNotFound deleteTeamConversation :: - ( Member CodeStore r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, + Member CodeStore r, Member ConversationStore r, Member (Error FederationError) r, Member (ErrorS 'ConvNotFound) r, Member (ErrorS 'InvalidOperation) r, Member (ErrorS 'NotATeamMember) r, Member (ErrorS ('ActionDenied 'DeleteConversation)) r, - Member ExternalAccess r, Member FederatorAccess r, + Member MemberStore r, + Member ProposalStore r, + Member ExternalAccess r, Member GundeckAccess r, Member (Input UTCTime) r, + Member SubConversationStore r, Member TeamStore r, Member (P.Logger (Msg -> Msg)) r ) => @@ -1216,7 +1221,7 @@ ensureNotTooLarge :: ensureNotTooLarge tid = do o <- input (TeamSize size) <- E.getSize tid - unless (size < fromIntegral (o ^. optSettings . setMaxTeamSize)) $ + unless (size < fromIntegral (o ^. settings . maxTeamSize)) $ throwS @'TooManyTeamMembers pure $ TeamSize size diff --git a/services/galley/src/Galley/API/Teams/Features.hs b/services/galley/src/Galley/API/Teams/Features.hs index 8b87f4850c0..1736b887bca 100644 --- a/services/galley/src/Galley/API/Teams/Features.hs +++ b/services/galley/src/Galley/API/Teams/Features.hs @@ -53,7 +53,6 @@ import Galley.App import Galley.Effects import Galley.Effects.BrigAccess (updateSearchVisibilityInbound) import Galley.Effects.GundeckAccess -import Galley.Effects.ProposalStore import Galley.Effects.SearchVisibilityStore qualified as SearchVisibilityData import Galley.Effects.TeamFeatureStore import Galley.Effects.TeamFeatureStore qualified as TeamFeatures @@ -284,6 +283,7 @@ instance SetFeatureConfig LegalholdConfig where type SetConfigForTeamConstraints LegalholdConfig (r :: EffectRow) = ( Bounded (PagingBounds InternalPaging TeamMember), + Member BackendNotificationQueueAccess r, Member BotAccess r, Member BrigAccess r, Member CodeStore r, @@ -311,6 +311,7 @@ instance SetFeatureConfig LegalholdConfig where Member (ListItems LegacyPaging ConvId) r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TeamFeatureStore r, Member TeamStore r, Member (TeamMemberStore InternalPaging) r, @@ -360,10 +361,32 @@ instance SetFeatureConfig SearchVisibilityInboundConfig where updateSearchVisibilityInbound $ toTeamStatus tid wsnl persistAndPushEvent tid wsnl -instance SetFeatureConfig MLSConfig +instance SetFeatureConfig MLSConfig where + type SetConfigForTeamConstraints MLSConfig (r :: EffectRow) = (Member (Error TeamFeatureError) r) + setConfigForTeam tid wsnl = do + mlsMigrationConfig <- getConfigForTeam @MlsMigrationConfig tid + unless + ( -- default protocol needs to be included in supported protocols + mlsDefaultProtocol (wssConfig wsnl) `elem` mlsSupportedProtocols (wssConfig wsnl) + -- when MLS migration is enabled, MLS needs to be enabled as well + && (wsStatus mlsMigrationConfig == FeatureStatusDisabled || wssStatus wsnl == FeatureStatusEnabled) + ) + $ throw MLSProtocolMismatch + persistAndPushEvent tid wsnl instance SetFeatureConfig ExposeInvitationURLsToTeamAdminConfig instance SetFeatureConfig OutlookCalIntegrationConfig instance SetFeatureConfig MlsE2EIdConfig + +instance SetFeatureConfig MlsMigrationConfig where + type SetConfigForTeamConstraints MlsMigrationConfig (r :: EffectRow) = (Member (Error TeamFeatureError) r) + setConfigForTeam tid wsnl = do + mlsConfig <- getConfigForTeam @MLSConfig tid + unless + ( -- when MLS migration is enabled, MLS needs to be enabled as well + wssStatus wsnl == FeatureStatusDisabled || wsStatus mlsConfig == FeatureStatusEnabled + ) + $ throw MLSProtocolMismatch + persistAndPushEvent tid wsnl diff --git a/services/galley/src/Galley/API/Teams/Features/Get.hs b/services/galley/src/Galley/API/Teams/Features/Get.hs index 9ba9d1e2159..006fbde97d1 100644 --- a/services/galley/src/Galley/API/Teams/Features/Get.hs +++ b/services/galley/src/Galley/API/Teams/Features/Get.hs @@ -237,6 +237,7 @@ getAllFeatureConfigsForServer = <*> getConfigForServer @ExposeInvitationURLsToTeamAdminConfig <*> getConfigForServer @OutlookCalIntegrationConfig <*> getConfigForServer @MlsE2EIdConfig + <*> getConfigForServer @MlsMigrationConfig getAllFeatureConfigsUser :: forall r. @@ -270,6 +271,7 @@ getAllFeatureConfigsUser uid = <*> getConfigForUser @ExposeInvitationURLsToTeamAdminConfig uid <*> getConfigForUser @OutlookCalIntegrationConfig uid <*> getConfigForUser @MlsE2EIdConfig uid + <*> getConfigForUser @MlsMigrationConfig uid getAllFeatureConfigsTeam :: forall r. @@ -299,6 +301,7 @@ getAllFeatureConfigsTeam tid = <*> getConfigForTeam @ExposeInvitationURLsToTeamAdminConfig tid <*> getConfigForTeam @OutlookCalIntegrationConfig tid <*> getConfigForTeam @MlsE2EIdConfig tid + <*> getConfigForTeam @MlsMigrationConfig tid -- | Note: this is an internal function which doesn't cover all features, e.g. LegalholdConfig genericGetConfigForTeam :: @@ -356,7 +359,7 @@ genericGetConfigForUser uid = do instance GetFeatureConfig SSOConfig where getConfigForServer = do status <- - inputs (view (optSettings . setFeatureFlags . flagSSO)) <&> \case + inputs (view (settings . featureFlags . flagSSO)) <&> \case FeatureSSOEnabledByDefault -> FeatureStatusEnabled FeatureSSODisabledByDefault -> FeatureStatusDisabled pure $ setStatus status defFeatureStatus @@ -366,14 +369,14 @@ instance GetFeatureConfig SSOConfig where instance GetFeatureConfig SearchVisibilityAvailableConfig where getConfigForServer = do status <- - inputs (view (optSettings . setFeatureFlags . flagTeamSearchVisibility)) <&> \case + inputs (view (settings . featureFlags . flagTeamSearchVisibility)) <&> \case FeatureTeamSearchVisibilityAvailableByDefault -> FeatureStatusEnabled FeatureTeamSearchVisibilityUnavailableByDefault -> FeatureStatusDisabled pure $ setStatus status defFeatureStatus instance GetFeatureConfig ValidateSAMLEmailsConfig where getConfigForServer = - inputs (view (optSettings . setFeatureFlags . flagsTeamFeatureValidateSAMLEmailsStatus . unDefaults . unImplicitLockStatus)) + inputs (view (settings . featureFlags . flagsTeamFeatureValidateSAMLEmailsStatus . unDefaults . unImplicitLockStatus)) instance GetFeatureConfig DigitalSignaturesConfig @@ -405,15 +408,15 @@ instance GetFeatureConfig LegalholdConfig where instance GetFeatureConfig FileSharingConfig where getConfigForServer = - input <&> view (optSettings . setFeatureFlags . flagFileSharing . unDefaults) + input <&> view (settings . featureFlags . flagFileSharing . unDefaults) instance GetFeatureConfig AppLockConfig where getConfigForServer = - input <&> view (optSettings . setFeatureFlags . flagAppLockDefaults . unDefaults . unImplicitLockStatus) + input <&> view (settings . featureFlags . flagAppLockDefaults . unDefaults . unImplicitLockStatus) instance GetFeatureConfig ClassifiedDomainsConfig where getConfigForServer = - input <&> view (optSettings . setFeatureFlags . flagClassifiedDomains . unImplicitLockStatus) + input <&> view (settings . featureFlags . flagClassifiedDomains . unImplicitLockStatus) instance GetFeatureConfig ConferenceCallingConfig where type @@ -428,7 +431,7 @@ instance GetFeatureConfig ConferenceCallingConfig where ) getConfigForServer = - input <&> view (optSettings . setFeatureFlags . flagConferenceCalling . unDefaults . unImplicitLockStatus) + input <&> view (settings . featureFlags . flagConferenceCalling . unDefaults . unImplicitLockStatus) getConfigForUser uid = do wsnl <- getAccountConferenceCallingConfigClient uid @@ -436,27 +439,27 @@ instance GetFeatureConfig ConferenceCallingConfig where instance GetFeatureConfig SelfDeletingMessagesConfig where getConfigForServer = - input <&> view (optSettings . setFeatureFlags . flagSelfDeletingMessages . unDefaults) + input <&> view (settings . featureFlags . flagSelfDeletingMessages . unDefaults) instance GetFeatureConfig GuestLinksConfig where getConfigForServer = - input <&> view (optSettings . setFeatureFlags . flagConversationGuestLinks . unDefaults) + input <&> view (settings . featureFlags . flagConversationGuestLinks . unDefaults) instance GetFeatureConfig SndFactorPasswordChallengeConfig where getConfigForServer = - input <&> view (optSettings . setFeatureFlags . flagTeamFeatureSndFactorPasswordChallengeStatus . unDefaults) + input <&> view (settings . featureFlags . flagTeamFeatureSndFactorPasswordChallengeStatus . unDefaults) instance GetFeatureConfig SearchVisibilityInboundConfig where getConfigForServer = - input <&> view (optSettings . setFeatureFlags . flagTeamFeatureSearchVisibilityInbound . unDefaults . unImplicitLockStatus) + input <&> view (settings . featureFlags . flagTeamFeatureSearchVisibilityInbound . unDefaults . unImplicitLockStatus) instance GetFeatureConfig MLSConfig where getConfigForServer = - input <&> view (optSettings . setFeatureFlags . flagMLS . unDefaults . unImplicitLockStatus) + input <&> view (settings . featureFlags . flagMLS . unDefaults . unImplicitLockStatus) instance GetFeatureConfig ExposeInvitationURLsToTeamAdminConfig where getConfigForTeam tid = do - allowList <- input <&> view (optSettings . setExposeInvitationURLsTeamAllowlist . to (fromMaybe [])) + allowList <- input <&> view (settings . exposeInvitationURLsTeamAllowlist . to (fromMaybe [])) mbOldStatus <- TeamFeatures.getFeatureConfig FeatureSingletonExposeInvitationURLsToTeamAdminConfig tid <&> fmap wssStatus let teamAllowed = tid `elem` allowList pure $ computeConfigForTeam teamAllowed (fromMaybe FeatureStatusDisabled mbOldStatus) @@ -477,11 +480,15 @@ instance GetFeatureConfig ExposeInvitationURLsToTeamAdminConfig where instance GetFeatureConfig OutlookCalIntegrationConfig where getConfigForServer = - input <&> view (optSettings . setFeatureFlags . flagOutlookCalIntegration . unDefaults) + input <&> view (settings . featureFlags . flagOutlookCalIntegration . unDefaults) instance GetFeatureConfig MlsE2EIdConfig where getConfigForServer = - input <&> view (optSettings . setFeatureFlags . flagMlsE2EId . unDefaults) + input <&> view (settings . featureFlags . flagMlsE2EId . unDefaults) + +instance GetFeatureConfig MlsMigrationConfig where + getConfigForServer = + input <&> view (settings . featureFlags . flagMlsMigration . unDefaults) -- -- | If second factor auth is enabled, make sure that end-points that don't support it, but should, are blocked completely. (This is a workaround until we have 2FA for those end-points as well.) -- -- diff --git a/services/galley/src/Galley/API/Update.hs b/services/galley/src/Galley/API/Update.hs index c16c805172c..bbb54cd7b37 100644 --- a/services/galley/src/Galley/API/Update.hs +++ b/services/galley/src/Galley/API/Update.hs @@ -1,4 +1,3 @@ -{-# LANGUAGE OverloadedRecordDot #-} -- This file is part of the Wire Server implementation. -- -- Copyright (C) 2022 Wire Swiss GmbH @@ -15,6 +14,7 @@ -- -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . +{-# LANGUAGE OverloadedRecordDot #-} {-# LANGUAGE RecordWildCards #-} module Galley.API.Update @@ -39,6 +39,7 @@ module Galley.API.Update updateConversationAccess, deleteLocalConversation, updateRemoteConversation, + updateConversationProtocolWithLocalUser, updateLocalStateOfRemoteConv, -- * Managing Members @@ -79,6 +80,7 @@ import Data.Id import Data.Json.Util import Data.List1 import Data.Map.Strict qualified as Map +import Data.Misc (HttpsUrl) import Data.Qualified import Data.Set qualified as Set import Data.Singletons @@ -91,10 +93,10 @@ import Galley.API.Query qualified as Query import Galley.API.Util import Galley.App import Galley.Data.Conversation qualified as Data +import Galley.Data.Conversation.Types qualified as Data import Galley.Data.Services as Data import Galley.Data.Types hiding (Conversation) import Galley.Effects -import Galley.Effects.BackendNotificationQueueAccess import Galley.Effects.ClientStore qualified as E import Galley.Effects.CodeStore qualified as E import Galley.Effects.ConversationStore qualified as E @@ -102,7 +104,6 @@ import Galley.Effects.ExternalAccess qualified as E import Galley.Effects.FederatorAccess qualified as E import Galley.Effects.GundeckAccess qualified as E import Galley.Effects.MemberStore qualified as E -import Galley.Effects.ProposalStore import Galley.Effects.ServiceStore qualified as E import Galley.Effects.WaiRoutes import Galley.Intra.Push @@ -124,6 +125,7 @@ import System.Logger (Msg) import Wire.API.Conversation hiding (Member) import Wire.API.Conversation.Action import Wire.API.Conversation.Code +import Wire.API.Conversation.Protocol qualified as P import Wire.API.Conversation.Role import Wire.API.Conversation.Typing import Wire.API.Error @@ -135,6 +137,7 @@ import Wire.API.Federation.Error import Wire.API.Message import Wire.API.Password (mkSafePassword) import Wire.API.Provider.Service (ServiceRef) +import Wire.API.Routes.Public (ZHostValue) import Wire.API.Routes.Public.Galley.Messaging import Wire.API.Routes.Public.Util (UpdateResult (..)) import Wire.API.ServantProto (RawProto (..)) @@ -253,7 +256,8 @@ handleUpdateResult = \case Unchanged -> empty & setStatus status204 type UpdateConversationAccessEffects = - '[ BotAccess, + '[ BackendNotificationQueueAccess, + BotAccess, BrigAccess, CodeStore, ConversationStore, @@ -273,6 +277,7 @@ type UpdateConversationAccessEffects = Input UTCTime, MemberStore, ProposalStore, + SubConversationStore, TeamStore, TinyLog ] @@ -307,7 +312,8 @@ updateConversationAccessUnqualified lusr con cnv update = update updateConversationReceiptMode :: - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member (Error FederationError) r, Member (Error InternalError) r, @@ -353,9 +359,9 @@ updateRemoteConversation :: Member (Input (Local ())) r, Member MemberStore r, Member TinyLog r, + RethrowErrors (HasConversationActionGalleyErrors tag) r, Member (Error NonFederatingBackends) r, Member (Error UnreachableBackends) r, - RethrowErrors (HasConversationActionGalleyErrors tag) (Error NoChanges : r), SingI tag ) => Remote ConvId -> @@ -366,21 +372,22 @@ updateRemoteConversation :: updateRemoteConversation rcnv lusr conn action = getUpdateResult $ do let updateRequest = ConversationUpdateRequest - { curUser = tUnqualified lusr, - curConvId = tUnqualified rcnv, - curAction = SomeConversationAction (sing @tag) action + { user = tUnqualified lusr, + convId = tUnqualified rcnv, + action = SomeConversationAction (sing @tag) action } response <- E.runFederated rcnv (fedClient @'Galley @"update-conversation" updateRequest) convUpdate <- case response of ConversationUpdateResponseNoChanges -> throw NoChanges - ConversationUpdateResponseError err' -> rethrowErrors @(HasConversationActionGalleyErrors tag) err' + ConversationUpdateResponseError err' -> raise $ rethrowErrors @(HasConversationActionGalleyErrors tag) err' ConversationUpdateResponseUpdate convUpdate -> pure convUpdate ConversationUpdateResponseNonFederatingBackends e -> throw e ConversationUpdateResponseUnreachableBackends e -> throw e updateLocalStateOfRemoteConv (qualifyAs rcnv convUpdate) (Just conn) >>= note NoChanges updateConversationReceiptModeUnqualified :: - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member (Error FederationError) r, Member (Error InternalError) r, @@ -403,13 +410,13 @@ updateConversationReceiptModeUnqualified :: updateConversationReceiptModeUnqualified lusr zcon cnv = updateConversationReceiptMode lusr zcon (tUntagged (qualifyAs lusr cnv)) updateConversationMessageTimer :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (ErrorS ('ActionDenied 'ModifyConversationMessageTimer)) r, Member (ErrorS 'ConvNotFound) r, Member (ErrorS 'InvalidOperation) r, Member (Error FederationError) r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, Member (Input UTCTime) r, Member (Logger (Msg -> Msg)) r @@ -436,13 +443,13 @@ updateConversationMessageTimer lusr zcon qcnv update = qcnv updateConversationMessageTimerUnqualified :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (ErrorS ('ActionDenied 'ModifyConversationMessageTimer)) r, Member (ErrorS 'ConvNotFound) r, Member (ErrorS 'InvalidOperation) r, Member (Error FederationError) r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, Member (Input UTCTime) r, Member (Logger (Msg -> Msg)) r @@ -455,7 +462,9 @@ updateConversationMessageTimerUnqualified :: updateConversationMessageTimerUnqualified lusr zcon cnv = updateConversationMessageTimer lusr zcon (tUntagged (qualifyAs lusr cnv)) deleteLocalConversation :: - ( Member CodeStore r, + ( Member BrigAccess r, + Member BackendNotificationQueueAccess r, + Member CodeStore r, Member ConversationStore r, Member (Error FederationError) r, Member (ErrorS 'NotATeamMember) r, @@ -465,6 +474,9 @@ deleteLocalConversation :: Member ExternalAccess r, Member FederatorAccess r, Member GundeckAccess r, + Member SubConversationStore r, + Member MemberStore r, + Member ProposalStore r, Member (Input UTCTime) r, Member TeamStore r, Member (Logger (Msg -> Msg)) r @@ -497,11 +509,12 @@ addCodeUnqualifiedWithReqBody :: Member TeamFeatureStore r ) => UserId -> + Maybe Text -> Maybe ConnId -> ConvId -> CreateConversationCodeRequest -> Sem r AddCodeResult -addCodeUnqualifiedWithReqBody usr mZcon cnv req = addCodeUnqualified (Just req) usr mZcon cnv +addCodeUnqualifiedWithReqBody usr mbZHost mZcon cnv req = addCodeUnqualified (Just req) usr mbZHost mZcon cnv addCodeUnqualified :: forall r. @@ -521,13 +534,14 @@ addCodeUnqualified :: ) => Maybe CreateConversationCodeRequest -> UserId -> + Maybe ZHostValue -> Maybe ConnId -> ConvId -> Sem r AddCodeResult -addCodeUnqualified mReq usr mZcon cnv = do +addCodeUnqualified mReq usr mbZHost mZcon cnv = do lusr <- qualifyLocal usr lcnv <- qualifyLocal cnv - addCode lusr mZcon lcnv mReq + addCode lusr mbZHost mZcon lcnv mReq addCode :: forall r. @@ -545,37 +559,34 @@ addCode :: Member (Embed IO) r ) => Local UserId -> + Maybe ZHostValue -> Maybe ConnId -> Local ConvId -> Maybe CreateConversationCodeRequest -> Sem r AddCodeResult -addCode lusr mZcon lcnv mReq = do +addCode lusr mbZHost mZcon lcnv mReq = do conv <- E.getConversation (tUnqualified lcnv) >>= noteS @'ConvNotFound Query.ensureGuestLinksEnabled (Data.convTeam conv) Query.ensureConvAdmin (Data.convLocalMembers conv) (tUnqualified lusr) ensureAccess conv CodeAccess ensureGuestsOrNonTeamMembersAllowed conv - let (bots, users) = localBotsAndUsers $ Data.convLocalMembers conv + convUri <- getConversationCodeURI mbZHost key <- E.makeKey (tUnqualified lcnv) - mCode <- E.getCode key ReusableCode - case mCode of + E.getCode key ReusableCode >>= \case Nothing -> do code <- E.generateCode (tUnqualified lcnv) ReusableCode (Timeout 3600 * 24 * 365) -- one year FUTUREWORK: configurable - mPw <- forM ((.password) =<< mReq) mkSafePassword + mPw <- for (mReq >>= (.password)) mkSafePassword E.createCode code mPw now <- input - conversationCode <- createCode (isJust mPw) code - let event = Event (tUntagged lcnv) Nothing (tUntagged lusr) now (EdConvCodeUpdate conversationCode) + let event = Event (tUntagged lcnv) Nothing (tUntagged lusr) now (EdConvCodeUpdate (mkConversationCodeInfo (isJust mPw) (codeKey code) (codeValue code) convUri)) + let (bots, users) = localBotsAndUsers $ Data.convLocalMembers conv pushConversationEvent mZcon event (qualifyAs lusr (map lmId users)) bots pure $ CodeAdded event + -- In case conversation already has a code this case covers the allowed no-ops Just (code, mPw) -> do when (isJust mPw || isJust (mReq >>= (.password))) $ throwS @'CreateConversationCodeConflict - conversationCode <- createCode (isJust mPw) code - pure $ CodeAlreadyExisted conversationCode + pure $ CodeAlreadyExisted (mkConversationCodeInfo (isJust mPw) (codeKey code) (codeValue code) convUri) where - createCode :: Bool -> Code -> Sem r ConversationCodeInfo - createCode hasPw code = do - mkConversationCodeInfo hasPw (codeKey code) (codeValue code) <$> E.getConversationCodeURI ensureGuestsOrNonTeamMembersAllowed :: Data.Conversation -> Sem r () ensureGuestsOrNonTeamMembersAllowed conv = unless @@ -639,10 +650,11 @@ getCode :: Member (Input Opts) r, Member TeamFeatureStore r ) => + Maybe ZHostValue -> Local UserId -> ConvId -> Sem r ConversationCodeInfo -getCode lusr cnv = do +getCode mbZHost lusr cnv = do conv <- E.getConversation cnv >>= noteS @'ConvNotFound Query.ensureGuestLinksEnabled (Data.convTeam conv) @@ -650,7 +662,8 @@ getCode lusr cnv = do ensureConvMember (Data.convLocalMembers conv) (tUnqualified lusr) key <- E.makeKey cnv (c, mPw) <- E.getCode key ReusableCode >>= noteS @'CodeNotFound - mkConversationCodeInfo (isJust mPw) (codeKey c) (codeValue c) <$> E.getConversationCodeURI + convUri <- getConversationCodeURI mbZHost + pure $ mkConversationCodeInfo (isJust mPw) (codeKey c) (codeValue c) convUri checkReusableCode :: forall r. @@ -670,9 +683,61 @@ checkReusableCode convCode = do mapErrorS @'GuestLinksDisabled @'CodeNotFound $ Query.ensureGuestLinksEnabled (Data.convTeam conv) +updateConversationProtocolWithLocalUser :: + forall r. + ( Member (ErrorS 'ConvNotFound) r, + Member (ErrorS 'ConvInvalidProtocolTransition) r, + Member (ErrorS ('ActionDenied 'LeaveConversation)) r, + Member (ErrorS 'InvalidOperation) r, + Member (Error FederationError) r, + Member (ErrorS 'MLSMigrationCriteriaNotSatisfied) r, + Member (ErrorS 'NotATeamMember) r, + Member (ErrorS OperationDenied) r, + Member (ErrorS 'TeamNotFound) r, + Member (Error InternalError) r, + Member (Input UTCTime) r, + Member (Input Env) r, + Member (Input (Local ())) r, + Member (Input Opts) r, + Member BackendNotificationQueueAccess r, + Member BrigAccess r, + Member ConversationStore r, + Member MemberStore r, + Member TinyLog r, + Member GundeckAccess r, + Member ExternalAccess r, + Member FederatorAccess r, + Member ProposalStore r, + Member SubConversationStore r, + Member TeamFeatureStore r, + Member TeamStore r + ) => + Local UserId -> + ConnId -> + Qualified ConvId -> + P.ProtocolUpdate -> + Sem r (UpdateResult Event) +updateConversationProtocolWithLocalUser lusr conn qcnv (P.ProtocolUpdate newProtocol) = + mapError @UnreachableBackends @InternalError (\_ -> InternalErrorWithDescription "Unexpected UnreachableBackends error when updating remote protocol") + . mapError @NonFederatingBackends @InternalError (\_ -> InternalErrorWithDescription "Unexpected NonFederatingBackends error when updating remote protocol") + $ foldQualified + lusr + ( \lcnv -> do + fmap (maybe Unchanged (Updated . lcuEvent) . hush) + . runError @NoChanges + . updateLocalConversation @'ConversationUpdateProtocolTag lcnv (tUntagged lusr) (Just conn) + $ newProtocol + ) + ( \rcnv -> + updateRemoteConversation @'ConversationUpdateProtocolTag rcnv lusr conn $ + newProtocol + ) + qcnv + joinConversationByReusableCode :: forall r. - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member CodeStore r, Member ConversationStore r, Member (ErrorS 'CodeNotFound) r, @@ -683,7 +748,6 @@ joinConversationByReusableCode :: Member (ErrorS 'InvalidOperation) r, Member (ErrorS 'NotATeamMember) r, Member (ErrorS 'TooManyMembers) r, - Member FederatorAccess r, Member ExternalAccess r, Member GundeckAccess r, Member (Input Opts) r, @@ -705,8 +769,8 @@ joinConversationByReusableCode lusr zcon req = do joinConversationById :: forall r. - ( Member BrigAccess r, - Member FederatorAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member (ErrorS 'ConvAccessDenied) r, Member (ErrorS 'ConvNotFound) r, @@ -731,8 +795,8 @@ joinConversationById lusr zcon cnv = do joinConversation :: forall r. - ( Member BrigAccess r, - Member FederatorAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member (ErrorS 'ConvAccessDenied) r, Member (ErrorS 'InvalidOperation) r, Member (ErrorS 'NotATeamMember) r, @@ -751,11 +815,11 @@ joinConversation :: Access -> Sem r (UpdateResult Event) joinConversation lusr zcon conv access = do - let lcnv = qualifyAs lusr (convId conv) + let lcnv = qualifyAs lusr conv.convId ensureConversationAccess (tUnqualified lusr) conv access ensureGroupConversation conv -- FUTUREWORK: remote users? - ensureMemberLimit (toList $ Data.convLocalMembers conv) [tUnqualified lusr] + ensureMemberLimit (Data.convProtocolTag conv) (toList $ Data.convLocalMembers conv) [tUnqualified lusr] getUpdateResult $ do -- NOTE: When joining conversations, all users become members -- as this is our desired behavior for these types of conversations @@ -774,7 +838,8 @@ joinConversation lusr zcon conv access = do action addMembers :: - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member (Error InternalError) r, Member (ErrorS ('ActionDenied 'AddConversationMember)) r, @@ -798,6 +863,7 @@ addMembers :: Member LegalHoldStore r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TeamStore r, Member TinyLog r ) => @@ -813,7 +879,8 @@ addMembers lusr zcon qcnv (InviteQualified users role) = do ConversationJoin users role addMembersUnqualifiedV2 :: - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member (Error FederationError) r, Member (Error InternalError) r, @@ -837,6 +904,7 @@ addMembersUnqualifiedV2 :: Member LegalHoldStore r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TeamStore r, Member TinyLog r ) => @@ -852,7 +920,8 @@ addMembersUnqualifiedV2 lusr zcon cnv (InviteQualified users role) = do ConversationJoin users role addMembersUnqualified :: - ( Member BrigAccess r, + ( Member BackendNotificationQueueAccess r, + Member BrigAccess r, Member ConversationStore r, Member (Error FederationError) r, Member (Error InternalError) r, @@ -876,6 +945,7 @@ addMembersUnqualified :: Member LegalHoldStore r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TeamStore r, Member TinyLog r ) => @@ -953,7 +1023,8 @@ updateUnqualifiedSelfMember lusr zcon cnv update = do updateSelfMember lusr zcon (tUntagged lcnv) update updateOtherMemberLocalConv :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (Error FederationError) r, Member (ErrorS ('ActionDenied 'ModifyOtherConversationMember)) r, Member (ErrorS 'InvalidTarget) r, @@ -961,7 +1032,6 @@ updateOtherMemberLocalConv :: Member (ErrorS 'ConvNotFound) r, Member (ErrorS 'ConvMemberNotFound) r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, Member (Input UTCTime) r, Member MemberStore r, @@ -980,7 +1050,8 @@ updateOtherMemberLocalConv lcnv lusr con qvictim update = void . getUpdateResult ConversationMemberUpdate qvictim update updateOtherMemberUnqualified :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (Error FederationError) r, Member (ErrorS ('ActionDenied 'ModifyOtherConversationMember)) r, Member (ErrorS 'InvalidTarget) r, @@ -988,7 +1059,6 @@ updateOtherMemberUnqualified :: Member (ErrorS 'ConvNotFound) r, Member (ErrorS 'ConvMemberNotFound) r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, Member (Input UTCTime) r, Member MemberStore r, @@ -1006,7 +1076,8 @@ updateOtherMemberUnqualified lusr zcon cnv victim update = do updateOtherMemberLocalConv lcnv lusr zcon (tUntagged lvictim) update updateOtherMember :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (Error FederationError) r, Member (ErrorS ('ActionDenied 'ModifyOtherConversationMember)) r, Member (ErrorS 'InvalidTarget) r, @@ -1014,7 +1085,6 @@ updateOtherMember :: Member (ErrorS 'ConvNotFound) r, Member (ErrorS 'ConvMemberNotFound) r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, Member (Input UTCTime) r, Member MemberStore r, @@ -1041,7 +1111,8 @@ updateOtherMemberRemoteConv :: updateOtherMemberRemoteConv _ _ _ _ _ = throw FederationNotImplemented removeMemberUnqualified :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (Error FederationError) r, Member (Error InternalError) r, Member (ErrorS ('ActionDenied 'RemoveConversationMember)) r, @@ -1054,6 +1125,7 @@ removeMemberUnqualified :: Member (Input UTCTime) r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TinyLog r ) => Local UserId -> @@ -1067,7 +1139,8 @@ removeMemberUnqualified lusr con cnv victim = do removeMemberQualified lusr con (tUntagged lcnv) (tUntagged lvictim) removeMemberQualified :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (Error FederationError) r, Member (Error InternalError) r, Member (ErrorS ('ActionDenied 'RemoveConversationMember)) r, @@ -1080,6 +1153,7 @@ removeMemberQualified :: Member (Input UTCTime) r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TinyLog r ) => Local UserId -> @@ -1096,6 +1170,11 @@ removeMemberQualified lusr con qcnv victim = qcnv victim +-- | if the public member leave api was called, we can assume that +-- it was called by a user +pattern EdMembersLeaveRemoved :: QualifiedUserIdList -> EventData +pattern EdMembersLeaveRemoved l = EdMembersLeave EdReasonRemoved l + removeMemberFromRemoteConv :: ( Member FederatorAccess r, Member (ErrorS ('ActionDenied 'RemoveConversationMember)) r, @@ -1110,8 +1189,8 @@ removeMemberFromRemoteConv cnv lusr victim | tUntagged lusr == victim = do let lc = LeaveConversationRequest (tUnqualified cnv) (qUnqualified victim) let rpc = fedClient @'Galley @"leave-conversation" lc - (either handleError handleSuccess . void . leaveResponse =<<) $ - E.runFederated cnv rpc + E.runFederated cnv rpc + >>= either handleError handleSuccess . void . (.response) | otherwise = throwS @('ActionDenied 'RemoveConversationMember) where handleError :: @@ -1130,11 +1209,12 @@ removeMemberFromRemoteConv cnv lusr victim t <- input pure . Just $ Event (tUntagged cnv) Nothing (tUntagged lusr) t $ - EdMembersLeave (QualifiedUserIdList [victim]) + EdMembersLeaveRemoved (QualifiedUserIdList [victim]) -- | Remove a member from a local conversation. removeMemberFromLocalConv :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (Error FederationError) r, Member (Error InternalError) r, Member (ErrorS ('ActionDenied 'LeaveConversation)) r, @@ -1148,6 +1228,7 @@ removeMemberFromLocalConv :: Member (Input UTCTime) r, Member MemberStore r, Member ProposalStore r, + Member SubConversationStore r, Member TinyLog r ) => Local ConvId -> @@ -1326,14 +1407,14 @@ postOtrMessageUnqualified sender zcon cnv = (runLocalInput sender . postQualifiedOtrMessage User (tUntagged sender) (Just zcon) lcnv) updateConversationName :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (Error FederationError) r, Member (Error InvalidInput) r, Member (ErrorS ('ActionDenied 'ModifyConversationName)) r, Member (ErrorS 'ConvNotFound) r, Member (ErrorS 'InvalidOperation) r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, Member (Input UTCTime) r, Member (Logger (Msg -> Msg)) r @@ -1352,14 +1433,14 @@ updateConversationName lusr zcon qcnv convRename = do convRename updateUnqualifiedConversationName :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (Error FederationError) r, Member (Error InvalidInput) r, Member (ErrorS ('ActionDenied 'ModifyConversationName)) r, Member (ErrorS 'ConvNotFound) r, Member (ErrorS 'InvalidOperation) r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, Member (Input UTCTime) r, Member (Logger (Msg -> Msg)) r @@ -1374,14 +1455,14 @@ updateUnqualifiedConversationName lusr zcon cnv rename = do updateLocalConversationName lusr zcon lcnv rename updateLocalConversationName :: - ( Member ConversationStore r, + ( Member BackendNotificationQueueAccess r, + Member ConversationStore r, Member (Error FederationError) r, Member (Error InvalidInput) r, Member (ErrorS ('ActionDenied 'ModifyConversationName)) r, Member (ErrorS 'ConvNotFound) r, Member (ErrorS 'InvalidOperation) r, Member ExternalAccess r, - Member FederatorAccess r, Member GundeckAccess r, Member (Input UTCTime) r, Member (Logger (Msg -> Msg)) r @@ -1421,14 +1502,14 @@ memberTyping lusr zcon qcnv ts = do unless isMemberRemoteConv $ throwS @'ConvNotFound let rpc = TypingDataUpdateRequest - { tdurTypingStatus = ts, - tdurUserId = tUnqualified lusr, - tdurConvId = tUnqualified rcnv + { typingStatus = ts, + userId = tUnqualified lusr, + convId = tUnqualified rcnv } res <- E.runFederated rcnv (fedClient @'Galley @"update-typing-indicator" rpc) case res of TypingDataUpdateSuccess (TypingDataUpdated {..}) -> do - pushTypingIndicatorEvents tudOrigUserId tudTime tudUsersInConv (Just zcon) qcnv tudTypingStatus + pushTypingIndicatorEvents origUserId time usersInConv (Just zcon) qcnv typingStatus TypingDataUpdateError _ -> pure () ) qcnv @@ -1548,7 +1629,7 @@ addBot lusr zcon b = do ensureActionAllowed SAddConversationMember self unless (any ((== b ^. addBotId) . botMemId) bots) $ do let botId = qualifyAs lusr (botUserId (b ^. addBotId)) - ensureMemberLimit (toList $ Data.convLocalMembers c) [tUntagged botId] + ensureMemberLimit (Data.convProtocolTag c) (toList $ Data.convLocalMembers c) [tUntagged botId] pure (bots, users) rmBotH :: @@ -1603,7 +1684,7 @@ rmBot lusr zcon b = do else do t <- input do - let evd = EdMembersLeave (QualifiedUserIdList [tUntagged (qualifyAs lusr (botUserId (b ^. rmBotId)))]) + let evd = EdMembersLeaveRemoved (QualifiedUserIdList [tUntagged (qualifyAs lusr (botUserId (b ^. rmBotId)))]) let e = Event (tUntagged lcnv) Nothing (tUntagged lusr) t evd for_ (newPushLocal ListComplete (tUnqualified lusr) (ConvEvent e) (recipient <$> users)) $ \p -> E.push1 $ p & pushConn .~ zcon @@ -1618,3 +1699,15 @@ rmBot lusr zcon b = do ensureConvMember :: (Member (ErrorS 'ConvNotFound) r) => [LocalMember] -> UserId -> Sem r () ensureConvMember users usr = unless (usr `isMember` users) $ throwS @'ConvNotFound + +getConversationCodeURI :: + ( Member (ErrorS 'ConvAccessDenied) r, + Member CodeStore r + ) => + Maybe ZHostValue -> + Sem r HttpsUrl +getConversationCodeURI mbZHost = do + mbURI <- E.getConversationCodeURI mbZHost + case mbURI of + Just uri -> pure uri + Nothing -> throwS @'ConvAccessDenied diff --git a/services/galley/src/Galley/API/Util.hs b/services/galley/src/Galley/API/Util.hs index 7dfa122432f..a97672cc94b 100644 --- a/services/galley/src/Galley/API/Util.hs +++ b/services/galley/src/Galley/API/Util.hs @@ -37,6 +37,7 @@ import Data.Set qualified as Set import Data.Singletons import Data.Text qualified as T import Data.Time +import GHC.TypeLits import Galley.API.Error import Galley.API.Mapping import Galley.Data.Conversation qualified as Data @@ -54,7 +55,7 @@ import Galley.Effects.MemberStore import Galley.Effects.TeamStore import Galley.Intra.Push import Galley.Options -import Galley.Types.Conversations.Members (LocalMember (..), RemoteMember (..), localMemberToOther, remoteMemberQualify, remoteMemberToOther) +import Galley.Types.Conversations.Members import Galley.Types.Conversations.Roles import Galley.Types.Teams import Galley.Types.UserList @@ -67,8 +68,9 @@ import Polysemy import Polysemy.Error import Polysemy.Input import Polysemy.TinyLog qualified as P +import System.Logger qualified as Log import Wire.API.Connection -import Wire.API.Conversation hiding (Member) +import Wire.API.Conversation hiding (Member, cnvAccess, cnvAccessRoles, cnvName, cnvType) import Wire.API.Conversation qualified as Public import Wire.API.Conversation.Action import Wire.API.Conversation.Protocol @@ -83,9 +85,9 @@ import Wire.API.Password (verifyPassword) import Wire.API.Routes.Public.Galley.Conversation import Wire.API.Routes.Public.Util import Wire.API.Team.Member +import Wire.API.Team.Member qualified as Mem import Wire.API.Team.Role -import Wire.API.User (VerificationAction) -import Wire.API.User qualified as User +import Wire.API.User hiding (userId) import Wire.API.User.Auth.ReAuth type JSON = Media "application" "json" @@ -107,15 +109,30 @@ ensureAccessRole roles users = do activated <- lookupActivatedUsers (fst <$> users) let guestsExist = length activated /= length users unless (not guestsExist || GuestAccessRole `Set.member` roles) $ throwS @'ConvAccessDenied - let botsExist = any (isJust . User.userService) activated + let botsExist = any (isJust . userService) activated unless (not botsExist || ServiceAccessRole `Set.member` roles) $ throwS @'ConvAccessDenied +-- | Check that the given user is either part of the same team as the other +-- users OR that there is a connection. +ensureConnectedOrSameTeam :: + ( Member BrigAccess r, + Member (ErrorS 'NotConnected) r, + Member TeamStore r + ) => + Local UserId -> + [Qualified UserId] -> + Sem r () +ensureConnectedOrSameTeam lusr others = do + let (locals, remotes) = partitionQualified lusr others + ensureConnectedToLocalsOrSameTeam lusr locals + ensureConnectedToRemotes lusr remotes + -- | Check that the given user is either part of the same team(s) as the other -- users OR that there is a connection. -- -- Team members are always considered connected, so we only check 'ensureConnected' -- for non-team-members of the _given_ user -ensureConnectedOrSameTeam :: +ensureConnectedToLocalsOrSameTeam :: ( Member BrigAccess r, Member (ErrorS 'NotConnected) r, Member TeamStore r @@ -123,12 +140,12 @@ ensureConnectedOrSameTeam :: Local UserId -> [UserId] -> Sem r () -ensureConnectedOrSameTeam _ [] = pure () -ensureConnectedOrSameTeam (tUnqualified -> u) uids = do +ensureConnectedToLocalsOrSameTeam _ [] = pure () +ensureConnectedToLocalsOrSameTeam (tUnqualified -> u) uids = do uTeams <- getUserTeams u -- We collect all the relevant uids from same teams as the origin user sameTeamUids <- forM uTeams $ \team -> - fmap (view userId) <$> selectTeamMembers team uids + fmap (view Mem.userId) <$> selectTeamMembers team uids -- Do not check connections for users that are on the same team ensureConnectedToLocals u (uids \\ join sameTeamUids) @@ -352,6 +369,24 @@ memberJoinEvent lorig qconv t lmems rmems = localToSimple u = SimpleMember (tUntagged (qualifyAs lorig (lmId u))) (lmConvRoleName u) remoteToSimple u = SimpleMember (tUntagged (rmId u)) (rmConvRoleName u) +convDeleteMembers :: + Member MemberStore r => + UserList UserId -> + Data.Conversation -> + Sem r Data.Conversation +convDeleteMembers ul conv = do + deleteMembers (Data.convId conv) ul + let locals = Set.fromList (ulLocals ul) + remotes = Set.fromList (ulRemotes ul) + -- update in-memory view of the conversation + pure $ + conv + { Data.convLocalMembers = + filter (\lm -> Set.notMember (lmId lm) locals) (Data.convLocalMembers conv), + Data.convRemoteMembers = + filter (\rm -> Set.notMember (rmId rm) remotes) (Data.convRemoteMembers conv) + } + isMember :: Foldable m => UserId -> m LocalMember -> Bool isMember u = isJust . find ((u ==) . lmId) @@ -488,8 +523,8 @@ nonTeamMembers cm tm = filter (not . isMemberOfTeam . lmId) cm uid -> isTeamMember uid tm membersToRecipients :: Maybe UserId -> [TeamMember] -> [Recipient] -membersToRecipients Nothing = map (userRecipient . view userId) -membersToRecipients (Just u) = map userRecipient . filter (/= u) . map (view userId) +membersToRecipients Nothing = map (userRecipient . view Mem.userId) +membersToRecipients (Just u) = map userRecipient . filter (/= u) . map (view Mem.userId) getSelfMemberFromLocals :: (Foldable t, Member (ErrorS 'ConvNotFound) r) => @@ -528,16 +563,29 @@ getConversationAndCheckMembership :: Member (ErrorS 'ConvNotFound) r, Member (ErrorS 'ConvAccessDenied) r ) => - UserId -> + Qualified UserId -> Local ConvId -> Sem r Data.Conversation -getConversationAndCheckMembership uid lcnv = do - (conv, _) <- - getConversationAndMemberWithError - @'ConvAccessDenied - uid - lcnv - pure conv +getConversationAndCheckMembership quid lcnv = do + foldQualified + lcnv + ( \lusr -> do + (conv, _) <- + getConversationAndMemberWithError + @'ConvAccessDenied + (tUnqualified lusr) + lcnv + pure conv + ) + ( \rusr -> do + (conv, _) <- + getConversationAndMemberWithError + @'ConvNotFound + rusr + lcnv + pure conv + ) + quid getConversationWithError :: ( Member ConversationStore r, @@ -572,7 +620,7 @@ canDeleteMember :: TeamMember -> TeamMember -> Bool canDeleteMember deleter deletee | getRole deletee == RoleOwner = getRole deleter == RoleOwner -- owners can only be deleted by another owner - && (deleter ^. userId /= deletee ^. userId) -- owner cannot delete itself + && (deleter ^. Mem.userId /= deletee ^. Mem.userId) -- owner cannot delete itself | otherwise = True where @@ -668,25 +716,27 @@ runLocalInput = runInputConst . void toConversationCreated :: -- | The time stamp the conversation was created at UTCTime -> + -- | The user that created the conversation + Local UserId -> -- | The conversation to convert for sending to a remote Galley Data.Conversation -> -- | The resulting information to be sent to a remote Galley ConversationCreated ConvId -toConversationCreated now Data.Conversation {convMetadata = ConversationMetadata {..}, ..} = do +toConversationCreated now lusr Data.Conversation {convMetadata = ConversationMetadata {..}, ..} = ConversationCreated - { ccTime = now, - ccOrigUserId = cnvmCreator, - ccCnvId = convId, - ccCnvType = cnvmType, - ccCnvAccess = cnvmAccess, - ccCnvAccessRoles = cnvmAccessRoles, - ccCnvName = cnvmName, + { time = now, + origUserId = tUnqualified lusr, + cnvId = convId, + cnvType = cnvmType, + cnvAccess = cnvmAccess, + cnvAccessRoles = cnvmAccessRoles, + cnvName = cnvmName, -- non-creator members are a function of the remote backend and will be -- overridden when fanning out the notification to remote backends. - ccNonCreatorMembers = Set.empty, - ccMessageTimer = cnvmMessageTimer, - ccReceiptMode = cnvmReceiptMode, - ccProtocol = convProtocol + nonCreatorMembers = Set.empty, + messageTimer = cnvmMessageTimer, + receiptMode = cnvmReceiptMode, + protocol = convProtocol } -- | The function converts a 'ConversationCreated' value to a @@ -699,7 +749,7 @@ fromConversationCreated :: ConversationCreated (Remote ConvId) -> [(Public.Member, Public.Conversation)] fromConversationCreated loc rc@ConversationCreated {..} = - let membersView = fmap (second Set.toList) . setHoles $ ccNonCreatorMembers + let membersView = fmap (second Set.toList) . setHoles $ nonCreatorMembers creatorOther = OtherMember (tUntagged (ccRemoteOrigUserId rc)) @@ -712,7 +762,7 @@ fromConversationCreated loc rc@ConversationCreated {..} = membersView where inDomain :: OtherMember -> Bool - inDomain = (== tDomain loc) . qDomain . omQualifiedId + inDomain = (== tDomain loc) . qDomain . Public.omQualifiedId setHoles :: Ord a => Set a -> [(a, Set a)] setHoles s = foldMap (\x -> [(x, Set.delete x s)]) s -- Currently this function creates a Member with default conversation attributes @@ -720,33 +770,33 @@ fromConversationCreated loc rc@ConversationCreated {..} = toMember :: OtherMember -> Public.Member toMember m = Public.Member - { memId = omQualifiedId m, - memService = omService m, + { memId = Public.omQualifiedId m, + memService = Public.omService m, memOtrMutedStatus = Nothing, memOtrMutedRef = Nothing, memOtrArchived = False, memOtrArchivedRef = Nothing, memHidden = False, memHiddenRef = Nothing, - memConvRoleName = omConvRoleName m + memConvRoleName = Public.omConvRoleName m } conv :: Public.Member -> [OtherMember] -> Public.Conversation conv this others = Public.Conversation - (tUntagged ccCnvId) + (tUntagged cnvId) ConversationMetadata - { cnvmType = ccCnvType, + { cnvmType = cnvType, -- FUTUREWORK: Document this is the same domain as the conversation -- domain - cnvmCreator = ccOrigUserId, - cnvmAccess = ccCnvAccess, - cnvmAccessRoles = ccCnvAccessRoles, - cnvmName = ccCnvName, + cnvmCreator = Just origUserId, + cnvmAccess = cnvAccess, + cnvmAccessRoles = cnvAccessRoles, + cnvmName = cnvName, -- FUTUREWORK: Document this is the same domain as the conversation -- domain. cnvmTeam = Nothing, - cnvmMessageTimer = ccMessageTimer, - cnvmReceiptMode = ccReceiptMode + cnvmMessageTimer = messageTimer, + cnvmReceiptMode = receiptMode } (ConvMembers this others) ProtocolProteus @@ -771,11 +821,12 @@ registerRemoteConversationMemberships :: ) => -- | The time stamp when the conversation was created UTCTime -> + Local UserId -> Local Data.Conversation -> Sem r () -registerRemoteConversationMemberships now lc = deleteOnUnreachable $ do +registerRemoteConversationMemberships now lusr lc = deleteOnUnreachable $ do let c = tUnqualified lc - rc = toConversationCreated now c + rc = toConversationCreated now lusr c allRemoteMembers = nubOrd {- (but why would there be duplicates?) -} (Data.convRemoteMembers c) allRemoteMembersQualified = remoteMemberQualify <$> allRemoteMembers allRemoteBuckets :: [Remote [RemoteMember]] = bucketRemote allRemoteMembersQualified @@ -791,7 +842,7 @@ registerRemoteConversationMemberships now lc = deleteOnUnreachable $ do \rrms -> fedClient @'Galley @"on-conversation-created" ( rc - { ccNonCreatorMembers = + { nonCreatorMembers = toMembers (tUnqualified rrms) } ) @@ -815,13 +866,13 @@ registerRemoteConversationMemberships now lc = deleteOnUnreachable $ do runFederatedConcurrentlyBucketsEither joinedCoupled $ fedClient @'Galley @"on-conversation-updated" . convUpdateJoin where - creator :: UserId + creator :: Maybe UserId creator = cnvmCreator . DataTypes.convMetadata . tUnqualified $ lc localNonCreators :: [OtherMember] localNonCreators = fmap (localMemberToOther . tDomain $ lc) - . filter (\lm -> lmId lm /= creator) + . filter (\lm -> lmId lm `notElem` creator) . Data.convLocalMembers . tUnqualified $ lc @@ -834,8 +885,8 @@ registerRemoteConversationMemberships now lc = deleteOnUnreachable $ do convUpdateJoin (toNotify, newMembers) = ConversationUpdate { cuTime = now, - cuOrigUserId = tUntagged . qualifyAs lc $ creator, - cuConvId = DataTypes.convId . tUnqualified $ lc, + cuOrigUserId = tUntagged lusr, + cuConvId = DataTypes.convId (tUnqualified lc), cuAlreadyPresentUsers = fmap (tUnqualified . rmId) . tUnqualified $ toNotify, cuAction = SomeConversationAction @@ -905,7 +956,7 @@ anyLegalholdActivated :: Sem r Bool anyLegalholdActivated uids = do opts <- input - case view (optSettings . setFeatureFlags . flagLegalHold) opts of + case view (settings . featureFlags . flagLegalHold) opts of FeatureLegalHoldDisabledPermanently -> pure False FeatureLegalHoldDisabledByDefault -> check FeatureLegalHoldWhitelistTeamsAndImplicitConsent -> check @@ -924,7 +975,7 @@ allLegalholdConsentGiven :: Sem r Bool allLegalholdConsentGiven uids = do opts <- input - case view (optSettings . setFeatureFlags . flagLegalHold) opts of + case view (settings . featureFlags . flagLegalHold) opts of FeatureLegalHoldDisabledPermanently -> pure False FeatureLegalHoldDisabledByDefault -> do flip allM (chunksOf 32 uids) $ \uidsPage -> do @@ -964,12 +1015,14 @@ ensureMemberLimit :: Member (Input Opts) r ) ) => + ProtocolTag -> [LocalMember] -> f a -> Sem r () -ensureMemberLimit old new = do +ensureMemberLimit ProtocolMLSTag _ _ = pure () +ensureMemberLimit _ old new = do o <- input - let maxSize = fromIntegral (o ^. optSettings . setMaxConvSize) + let maxSize = fromIntegral (o ^. settings . maxConvSize) when (length old + length new > maxSize) $ throwS @'TooManyMembers @@ -1007,3 +1060,13 @@ instance if err' == demote @e then throwS @e else rethrowErrors @effs @r err' + +logRemoteNotificationError :: + forall rpc r. + (Member P.TinyLog r, KnownSymbol rpc) => + FederationError -> + Sem r () +logRemoteNotificationError e = + P.warn $ + Log.field "federation call" (symbolVal (Proxy @rpc)) + . Log.msg (displayException e) diff --git a/services/galley/src/Galley/App.hs b/services/galley/src/Galley/App.hs index f90daa29f29..9978c77ec0f 100644 --- a/services/galley/src/Galley/App.hs +++ b/services/galley/src/Galley/App.hs @@ -21,8 +21,8 @@ module Galley.App ( -- * Environment Env, reqId, - monitor, options, + monitor, applog, manager, federator, @@ -44,7 +44,7 @@ module Galley.App ) where -import Bilge hiding (Request, header, options, statusCode, statusMessage) +import Bilge hiding (Request, header, host, options, port, statusCode, statusMessage) import Cassandra hiding (Set) import Cassandra qualified as C import Cassandra.Settings qualified as C @@ -53,6 +53,7 @@ import Control.Lens hiding ((.=)) import Data.Default (def) import Data.List.NonEmpty qualified as NE import Data.Metrics.Middleware +import Data.Misc import Data.Qualified import Data.Range import Data.Text (unpack) @@ -69,6 +70,7 @@ import Galley.Cassandra.LegalHold import Galley.Cassandra.Proposal import Galley.Cassandra.SearchVisibility import Galley.Cassandra.Services +import Galley.Cassandra.SubConversation (interpretSubConversationStoreToCassandra) import Galley.Cassandra.Team import Galley.Cassandra.TeamFeatures import Galley.Cassandra.TeamNotifications @@ -81,13 +83,14 @@ import Galley.Intra.BackendNotificationQueue import Galley.Intra.Effects import Galley.Intra.Federator import Galley.Keys -import Galley.Options +import Galley.Options hiding (brig, endpoint, federator) +import Galley.Options qualified as O import Galley.Queue import Galley.Queue qualified as Q import Galley.Types.Teams qualified as Teams import HTTP2.Client.Manager (Http2Manager, http2ManagerWithSSLCtx) import Imports hiding (forkIO) -import Network.AMQP.Extended +import Network.AMQP.Extended (mkRabbitMqChannelMVar) import Network.HTTP.Client (responseTimeoutMicro) import Network.HTTP.Client.OpenSSL import Network.Wai.Utilities.JSONResponse @@ -101,13 +104,16 @@ import Polysemy.TinyLog qualified as P import Servant qualified import Ssl.Util import System.Logger qualified as Log -import System.Logger.Class +import System.Logger.Class (Logger) import System.Logger.Extended qualified as Logger import UnliftIO.Exception qualified as UnliftIO import Util.Options +import Wire.API.Conversation.Protocol import Wire.API.Error import Wire.API.Federation.Error +import Wire.API.Team.Feature import Wire.Sem.Logger qualified +import Wire.Sem.Random.IO -- Effects needed by the interpretation of other effects type GalleyEffects0 = @@ -127,51 +133,65 @@ type GalleyEffects0 = type GalleyEffects = Append GalleyEffects1 GalleyEffects0 -- Define some invariants for the options used -validateOptions :: Opts -> IO () +validateOptions :: Opts -> IO (Either HttpsUrl (Map Text HttpsUrl)) validateOptions o = do - let settings = view optSettings o + let settings' = view settings o optFanoutLimit = fromIntegral . fromRange $ currentFanoutLimit o - when (settings ^. setMaxConvSize > fromIntegral optFanoutLimit) $ + when (settings' ^. maxConvSize > fromIntegral optFanoutLimit) $ error "setMaxConvSize cannot be > setTruncationLimit" - when (settings ^. setMaxTeamSize < optFanoutLimit) $ + when (settings' ^. maxTeamSize < optFanoutLimit) $ error "setMaxTeamSize cannot be < setTruncationLimit" - case (o ^. optFederator, o ^. optRabbitmq) of + case (o ^. O.federator, o ^. rabbitmq) of (Nothing, Just _) -> error "RabbitMQ config is specified and federator is not, please specify both or none" (Just _, Nothing) -> error "Federator is specified and RabbitMQ config is not, please specify both or none" _ -> pure () + let mlsFlag = settings' ^. featureFlags . Teams.flagMLS . Teams.unDefaults . Teams.unImplicitLockStatus + mlsConfig = wsConfig mlsFlag + migrationStatus = wsStatus $ settings' ^. featureFlags . Teams.flagMlsMigration . Teams.unDefaults + when (migrationStatus == FeatureStatusEnabled && ProtocolMLSTag `notElem` mlsSupportedProtocols mlsConfig) $ + error "For starting MLS migration, MLS must be included in the supportedProtocol list" + unless (mlsDefaultProtocol mlsConfig `elem` mlsSupportedProtocols mlsConfig) $ + error "The list 'settings.featureFlags.mls.supportedProtocols' must include the value in the field 'settings.featureFlags.mls.defaultProtocol'" + let errMsg = "Either conversationCodeURI or multiIngress needs to be set." + case (settings' ^. conversationCodeURI, settings' ^. multiIngress) of + (Nothing, Nothing) -> error errMsg + (Nothing, Just mi) -> pure (Right mi) + (Just uri, Nothing) -> pure (Left uri) + (Just _, Just _) -> error errMsg createEnv :: Metrics -> Opts -> Logger -> IO Env createEnv m o l = do cass <- initCassandra o l mgr <- initHttpManager o h2mgr <- initHttp2Manager - validateOptions o - Env def m o l mgr h2mgr (o ^. optFederator) (o ^. optBrig) cass + codeURIcfg <- validateOptions o + Env def m o l mgr h2mgr (o ^. O.federator) (o ^. O.brig) cass <$> Q.new 16000 <*> initExtEnv - <*> maybe (pure Nothing) (fmap Just . Aws.mkEnv l mgr) (o ^. optJournal) - <*> loadAllMLSKeys (fold (o ^. optSettings . setMlsPrivateKeyPaths)) - <*> traverse (mkRabbitMqChannelMVar l) (o ^. optRabbitmq) + <*> maybe (pure Nothing) (fmap Just . Aws.mkEnv l mgr) (o ^. journal) + <*> loadAllMLSKeys (fold (o ^. settings . mlsPrivateKeyPaths)) + <*> traverse (mkRabbitMqChannelMVar l) (o ^. rabbitmq) + <*> pure codeURIcfg initCassandra :: Opts -> Logger -> IO ClientState initCassandra o l = do c <- maybe - (C.initialContactsPlain (o ^. optCassandra . casEndpoint . epHost)) + (C.initialContactsPlain (o ^. cassandra . endpoint . host)) (C.initialContactsDisco "cassandra_galley" . unpack) - (o ^. optDiscoUrl) + (o ^. discoUrl) C.init . C.setLogger (C.mkLogger (Logger.clone (Just "cassandra.galley") l)) . C.setContacts (NE.head c) (NE.tail c) - . C.setPortNumber (fromIntegral $ o ^. optCassandra . casEndpoint . epPort) - . C.setKeyspace (Keyspace $ o ^. optCassandra . casKeyspace) + . C.setPortNumber (fromIntegral $ o ^. cassandra . endpoint . port) + . C.setKeyspace (Keyspace $ o ^. cassandra . keyspace) . C.setMaxConnections 4 . C.setMaxStreams 128 . C.setPoolStripes 4 . C.setSendTimeout 3 . C.setResponseTimeout 10 . C.setProtocolVersion C.V4 - . C.setPolicy (C.dcFilterPolicyIfConfigured l (o ^. optCassandra . casFilterNodesByDatacentre)) + . C.setPolicy (C.dcFilterPolicyIfConfigured l (o ^. cassandra . filterNodesByDatacentre)) $ C.defSettings initHttpManager :: Opts -> IO Manager @@ -186,8 +206,8 @@ initHttpManager o = do newManager (opensslManagerSettings (pure ctx)) { managerResponseTimeout = responseTimeoutMicro 10000000, - managerConnCount = o ^. optSettings . setHttpPoolSize, - managerIdleConnectionCount = 3 * (o ^. optSettings . setHttpPoolSize) + managerConnCount = o ^. settings . httpPoolSize, + managerIdleConnectionCount = 3 * (o ^. settings . httpPoolSize) } initHttp2Manager :: IO Http2Manager @@ -246,7 +266,7 @@ evalGalley e = . runInputSem (embed getCurrentTime) -- FUTUREWORK: could we take the time only once instead? . interpretWaiRoutes . runInputConst (e ^. options) - . runInputConst (toLocalUnsafe (e ^. options . optSettings . setFederationDomain) ()) + . runInputConst (toLocalUnsafe (e ^. options . settings . federationDomain) ()) . interpretInternalTeamListToCassandra . interpretTeamListToCassandra . interpretLegacyConversationListToCassandra @@ -262,6 +282,8 @@ evalGalley e = . interpretMemberStoreToCassandra . interpretLegalHoldStoreToCassandra lh . interpretCustomBackendStoreToCassandra + . randomToIO + . interpretSubConversationStoreToCassandra . interpretConversationStoreToCassandra . interpretProposalStoreToCassandra . interpretCodeStoreToCassandra @@ -272,8 +294,7 @@ evalGalley e = . interpretFederatorAccess . interpretExternalAccess . interpretGundeckAccess - . interpretDefederationNotifications . interpretSparAccess . interpretBrigAccess where - lh = view (options . optSettings . setFeatureFlags . Teams.flagLegalHold) e + lh = view (options . settings . featureFlags . Teams.flagLegalHold) e diff --git a/services/galley/src/Galley/Aws.hs b/services/galley/src/Galley/Aws.hs index 176f8dc38db..07a84b42fca 100644 --- a/services/galley/src/Galley/Aws.hs +++ b/services/galley/src/Galley/Aws.hs @@ -57,7 +57,7 @@ import Network.TLS qualified as TLS import Proto.TeamEvents qualified as E import System.Logger qualified as Logger import System.Logger.Class -import Util.Options +import Util.Options hiding (endpoint) newtype QueueUrl = QueueUrl Text deriving (Show) @@ -102,14 +102,14 @@ mkEnv :: Logger -> Manager -> JournalOpts -> IO Env mkEnv lgr mgr opts = do let g = Logger.clone (Just "aws.galley") lgr e <- mkAwsEnv g - q <- getQueueUrl e (opts ^. awsQueueName) + q <- getQueueUrl e (opts ^. queueName) pure (Env e g q) where sqs e = AWS.setEndpoint (e ^. awsSecure) (e ^. awsHost) (e ^. awsPort) SQS.defaultService mkAwsEnv g = do baseEnv <- AWS.newEnv AWS.discover - <&> AWS.configureService (sqs (opts ^. awsEndpoint)) + <&> AWS.configureService (sqs (opts ^. endpoint)) pure $ baseEnv { AWS.logger = awsLogger g, diff --git a/services/galley/src/Galley/Cassandra.hs b/services/galley/src/Galley/Cassandra.hs index 32044a86a2d..ab948beca05 100644 --- a/services/galley/src/Galley/Cassandra.hs +++ b/services/galley/src/Galley/Cassandra.hs @@ -17,7 +17,8 @@ module Galley.Cassandra (schemaVersion) where +import Galley.Schema.Run qualified as Migrations import Imports schemaVersion :: Int32 -schemaVersion = 83 +schemaVersion = Migrations.lastSchemaVersion diff --git a/services/galley/src/Galley/Cassandra/Client.hs b/services/galley/src/Galley/Cassandra/Client.hs index c9fdc5e01bf..419feef79e6 100644 --- a/services/galley/src/Galley/Cassandra/Client.hs +++ b/services/galley/src/Galley/Cassandra/Client.hs @@ -41,7 +41,7 @@ import UnliftIO qualified updateClient :: Bool -> UserId -> ClientId -> Client () updateClient add usr cls = do - let q = if add then Cql.addMemberClient else Cql.rmMemberClient + let q = if add then Cql.upsertMemberAddClient else Cql.upsertMemberRmClient retry x5 $ write (q cls) (params LocalQuorum (Identity usr)) -- Do, at most, 16 parallel lookups of up to 128 users each @@ -69,4 +69,4 @@ interpretClientStoreToCassandra = interpret $ \case CreateClient uid cid -> embedClient $ updateClient True uid cid DeleteClient uid cid -> embedClient $ updateClient False uid cid DeleteClients uid -> embedClient $ eraseClients uid - UseIntraClientListing -> embedApp . view $ options . optSettings . setIntraListing + UseIntraClientListing -> embedApp . view $ options . settings . intraListing diff --git a/services/galley/src/Galley/Cassandra/Code.hs b/services/galley/src/Galley/Cassandra/Code.hs index 784e8d1089a..f5b2770b38a 100644 --- a/services/galley/src/Galley/Cassandra/Code.hs +++ b/services/galley/src/Galley/Cassandra/Code.hs @@ -23,13 +23,13 @@ where import Cassandra import Control.Lens import Data.Code +import Data.Map qualified as Map import Galley.Cassandra.Queries qualified as Cql import Galley.Cassandra.Store import Galley.Data.Types import Galley.Data.Types qualified as Code import Galley.Effects.CodeStore (CodeStore (..)) import Galley.Env -import Galley.Options import Imports import Polysemy import Polysemy.Input @@ -48,8 +48,14 @@ interpretCodeStoreToCassandra = interpret $ \case DeleteCode k s -> embedClient $ deleteCode k s MakeKey cid -> Code.mkKey cid GenerateCode cid s t -> Code.generate cid s t - GetConversationCodeURI -> - view (options . optSettings . setConversationCodeURI) <$> input + GetConversationCodeURI mbHost -> do + env <- input + case env ^. convCodeURI of + Left uri -> pure (Just uri) + Right map' -> + case mbHost of + Just host -> pure (Map.lookup host map') + Nothing -> pure Nothing -- | Insert a conversation code insertCode :: Code -> Maybe Password -> Client () diff --git a/services/galley/src/Galley/Cassandra/Conversation.hs b/services/galley/src/Galley/Cassandra/Conversation.hs index 404ea49cf97..2d24adb63b2 100644 --- a/services/galley/src/Galley/Cassandra/Conversation.hs +++ b/services/galley/src/Galley/Cassandra/Conversation.hs @@ -24,6 +24,7 @@ where import Cassandra hiding (Set) import Cassandra qualified as Cql +import Cassandra.Util import Control.Error.Util import Control.Monad.Trans.Maybe import Data.ByteString.Conversion @@ -33,6 +34,7 @@ import Data.Misc import Data.Qualified import Data.Range import Data.Set qualified as Set +import Data.Time.Clock import Data.UUID.V4 (nextRandom) import Galley.Cassandra.Access import Galley.Cassandra.Conversation.MLS @@ -54,8 +56,10 @@ import UnliftIO qualified import Wire.API.Conversation hiding (Conversation, Member) import Wire.API.Conversation.Protocol import Wire.API.MLS.CipherSuite -import Wire.API.MLS.Group -import Wire.API.MLS.PublicGroupState +import Wire.API.MLS.Group.Serialisation +import Wire.API.MLS.GroupInfo +import Wire.API.MLS.SubConversation +import Wire.API.User createMLSSelfConversation :: Local UserId -> @@ -66,12 +70,12 @@ createMLSSelfConversation lusr = do nc = NewConversation { ncMetadata = - (defConversationMetadata usr) {cnvmType = SelfConv}, + (defConversationMetadata (Just usr)) {cnvmType = SelfConv}, ncUsers = ulFromLocals [toUserRole usr], - ncProtocol = ProtocolMLSTag + ncProtocol = BaseProtocolMLSTag } meta = ncMetadata nc - gid = convToGroupId . qualifyAs lusr $ cnv + gid = convToGroupId . groupIdParts meta.cnvmType . fmap Conv . tUntagged . qualifyAs lusr $ cnv -- FUTUREWORK: Stop hard-coding the cipher suite -- -- 'CipherSuite 1' corresponds to @@ -82,6 +86,7 @@ createMLSSelfConversation lusr = do ConversationMLSData { cnvmlsGroupId = gid, cnvmlsEpoch = Epoch 0, + cnvmlsEpochTimestamp = Nothing, cnvmlsCipherSuite = cs } retry x5 . batch $ do @@ -101,7 +106,6 @@ createMLSSelfConversation lusr = do Just gid, Just cs ) - addPrepQuery Cql.insertGroupId (gid, cnv, tDomain lusr) (lmems, rmems) <- addMembers cnv (ncUsers nc) pure @@ -118,15 +122,16 @@ createConversation :: Local ConvId -> NewConversation -> Client Conversation createConversation lcnv nc = do let meta = ncMetadata nc (proto, mgid, mep, mcs) = case ncProtocol nc of - ProtocolProteusTag -> (ProtocolProteus, Nothing, Nothing, Nothing) - ProtocolMLSTag -> - let gid = convToGroupId lcnv + BaseProtocolProteusTag -> (ProtocolProteus, Nothing, Nothing, Nothing) + BaseProtocolMLSTag -> + let gid = convToGroupId . groupIdParts meta.cnvmType $ Conv <$> tUntagged lcnv ep = Epoch 0 cs = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 in ( ProtocolMLS ConversationMLSData { cnvmlsGroupId = gid, cnvmlsEpoch = ep, + cnvmlsEpochTimestamp = Nothing, cnvmlsCipherSuite = cs }, Just gid, @@ -152,13 +157,12 @@ createConversation lcnv nc = do cnvmTeam meta, cnvmMessageTimer meta, cnvmReceiptMode meta, - ncProtocol nc, + baseProtocolToProtocol (ncProtocol nc), mgid, mep, mcs ) for_ (cnvmTeam meta) $ \tid -> addPrepQuery Cql.insertTeamConv (tid, tUnqualified lcnv) - for_ mgid $ \gid -> addPrepQuery Cql.insertGroupId (gid, tUnqualified lcnv, tDomain lcnv) (lmems, rmems) <- addMembers (tUnqualified lcnv) (ncUsers nc) pure Conversation @@ -187,22 +191,20 @@ conversationMeta conv = (toConvMeta =<<) <$> retry x1 (query1 Cql.selectConv (params LocalQuorum (Identity conv))) where - toConvMeta (t, mc, a, r, r', n, i, _, mt, rm, _, _, _, _) = do - c <- mc + toConvMeta (t, mc, a, r, r', n, i, _, mt, rm, _, _, _, _, _) = do let mbAccessRolesV2 = Set.fromList . Cql.fromSet <$> r' accessRoles = maybeRole t $ parseAccessRoles r mbAccessRolesV2 - pure $ ConversationMetadata t c (defAccess t a) accessRoles n i mt rm - -getPublicGroupState :: ConvId -> Client (Maybe OpaquePublicGroupState) -getPublicGroupState cid = do - fmap join $ - runIdentity - <$$> retry - x1 - ( query1 - Cql.selectPublicGroupState - (params LocalQuorum (Identity cid)) - ) + pure $ ConversationMetadata t mc (defAccess t a) accessRoles n i mt rm + +getGroupInfo :: ConvId -> Client (Maybe GroupInfoData) +getGroupInfo cid = do + runIdentity + <$$> retry + x1 + ( query1 + Cql.selectGroupInfo + (params LocalQuorum (Identity cid)) + ) isConvAlive :: ConvId -> Client Bool isConvAlive cid = do @@ -232,12 +234,26 @@ updateConvReceiptMode cid receiptMode = retry x5 $ write Cql.updateConvReceiptMo updateConvMessageTimer :: ConvId -> Maybe Milliseconds -> Client () updateConvMessageTimer cid mtimer = retry x5 $ write Cql.updateConvMessageTimer (params LocalQuorum (mtimer, cid)) +getConvEpoch :: ConvId -> Client (Maybe Epoch) +getConvEpoch cid = + (runIdentity =<<) + <$> retry + x1 + (query1 Cql.getConvEpoch (params LocalQuorum (Identity cid))) + updateConvEpoch :: ConvId -> Epoch -> Client () updateConvEpoch cid epoch = retry x5 $ write Cql.updateConvEpoch (params LocalQuorum (epoch, cid)) -setPublicGroupState :: ConvId -> OpaquePublicGroupState -> Client () -setPublicGroupState conv gib = - write Cql.updatePublicGroupState (params LocalQuorum (gib, conv)) +updateConvCipherSuite :: ConvId -> CipherSuiteTag -> Client () +updateConvCipherSuite cid cs = + retry x5 $ + write + Cql.updateConvCipherSuite + (params LocalQuorum (cs, cid)) + +setGroupInfo :: ConvId -> GroupInfoData -> Client () +setGroupInfo conv gid = + write Cql.updateGroupInfo (params LocalQuorum (gid, conv)) getConversation :: ConvId -> Client (Maybe Conversation) getConversation conv = do @@ -270,7 +286,10 @@ localConversation cid = toConv cid <$> UnliftIO.Concurrently (members cid) <*> UnliftIO.Concurrently (lookupRemoteMembers cid) - <*> UnliftIO.Concurrently (retry x1 $ query1 Cql.selectConv (params LocalQuorum (Identity cid))) + <*> UnliftIO.Concurrently + ( retry x1 $ + query1 Cql.selectConv (params LocalQuorum (Identity cid)) + ) localConversations :: ( Member (Embed IO) r, @@ -323,31 +342,55 @@ toProtocol :: Maybe ProtocolTag -> Maybe GroupId -> Maybe Epoch -> + Maybe UTCTime -> Maybe CipherSuiteTag -> Maybe Protocol -toProtocol Nothing _ _ _ = Just ProtocolProteus -toProtocol (Just ProtocolProteusTag) _ _ _ = Just ProtocolProteus -toProtocol (Just ProtocolMLSTag) mgid mepoch mcs = - ProtocolMLS - <$> ( ConversationMLSData - <$> mgid - -- If there is no epoch in the database, assume the epoch is 0 - <*> (mepoch <|> Just (Epoch 0)) - <*> mcs - ) +toProtocol Nothing _ _ _ _ = Just ProtocolProteus +toProtocol (Just ProtocolProteusTag) _ _ _ _ = Just ProtocolProteus +toProtocol (Just ProtocolMLSTag) mgid mepoch mtimestamp mcs = ProtocolMLS <$> toConversationMLSData mgid mepoch mtimestamp mcs +toProtocol (Just ProtocolMixedTag) mgid mepoch mtimestamp mcs = ProtocolMixed <$> toConversationMLSData mgid mepoch mtimestamp mcs + +toConversationMLSData :: Maybe GroupId -> Maybe Epoch -> Maybe UTCTime -> Maybe CipherSuiteTag -> Maybe ConversationMLSData +toConversationMLSData mgid mepoch mtimestamp mcs = + ConversationMLSData + <$> mgid + -- If there is no epoch in the database, assume the epoch is 0 + <*> (mepoch <|> Just (Epoch 0)) + <*> pure (mepoch `toTimestamp` mtimestamp) + <*> mcs + where + toTimestamp :: Maybe Epoch -> Maybe UTCTime -> Maybe UTCTime + toTimestamp Nothing _ = Nothing + toTimestamp (Just (Epoch 0)) _ = Nothing + toTimestamp (Just _) ts = ts toConv :: ConvId -> [LocalMember] -> [RemoteMember] -> - Maybe (ConvType, Maybe UserId, Maybe (Cql.Set Access), Maybe AccessRoleLegacy, Maybe (Cql.Set AccessRole), Maybe Text, Maybe TeamId, Maybe Bool, Maybe Milliseconds, Maybe ReceiptMode, Maybe ProtocolTag, Maybe GroupId, Maybe Epoch, Maybe CipherSuiteTag) -> + Maybe + ( ConvType, + Maybe UserId, + Maybe (Cql.Set Access), + Maybe AccessRoleLegacy, + Maybe (Cql.Set AccessRole), + Maybe Text, + Maybe TeamId, + Maybe Bool, + Maybe Milliseconds, + Maybe ReceiptMode, + Maybe ProtocolTag, + Maybe GroupId, + Maybe Epoch, + Maybe (Writetime Epoch), + Maybe CipherSuiteTag + ) -> Maybe Conversation toConv cid ms remoteMems mconv = do - (cty, muid, acc, role, roleV2, nme, ti, del, timer, rm, ptag, mgid, mep, mcs) <- mconv - uid <- muid + (cty, muid, acc, role, roleV2, nme, ti, del, timer, rm, ptag, mgid, mep, mts, mcs) <- mconv let mbAccessRolesV2 = Set.fromList . Cql.fromSet <$> roleV2 accessRoles = maybeRole cty $ parseAccessRoles role mbAccessRolesV2 - proto <- toProtocol ptag mgid mep mcs + proto <- toProtocol ptag mgid mep (writetimeToUTC <$> mts) mcs pure Conversation { convId = cid, @@ -358,7 +401,7 @@ toConv cid ms remoteMems mconv = do convMetadata = ConversationMetadata { cnvmType = cty, - cnvmCreator = uid, + cnvmCreator = muid, cnvmAccess = defAccess cty acc, cnvmAccessRoles = accessRoles, cnvmName = nme, @@ -368,13 +411,36 @@ toConv cid ms remoteMems mconv = do } } -mapGroupId :: GroupId -> Qualified ConvId -> Client () -mapGroupId gId conv = - write Cql.insertGroupId (params LocalQuorum (gId, qUnqualified conv, qDomain conv)) - -lookupGroupId :: GroupId -> Client (Maybe (Qualified ConvId)) -lookupGroupId gId = - uncurry Qualified <$$> retry x1 (query1 Cql.lookupGroupId (params LocalQuorum (Identity gId))) +updateToMixedProtocol :: + Members + '[ Embed IO, + Input ClientState + ] + r => + Local ConvId -> + ConvType -> + CipherSuiteTag -> + Sem r () +updateToMixedProtocol lcnv ct cs = do + let gid = convToGroupId . groupIdParts ct $ Conv <$> tUntagged lcnv + epoch = Epoch 0 + embedClient . retry x5 . batch $ do + setType BatchLogged + setConsistency LocalQuorum + addPrepQuery Cql.updateToMixedConv (tUnqualified lcnv, ProtocolMixedTag, gid, epoch, cs) + pure () + +updateToMLSProtocol :: + Members + '[ Embed IO, + Input ClientState + ] + r => + Local ConvId -> + Sem r () +updateToMLSProtocol lcnv = + embedClient . retry x5 $ + write Cql.updateToMLSConv (params LocalQuorum (tUnqualified lcnv, ProtocolMLSTag)) interpretConversationStoreToCassandra :: ( Member (Embed IO) r, @@ -388,10 +454,10 @@ interpretConversationStoreToCassandra = interpret $ \case CreateConversation loc nc -> embedClient $ createConversation loc nc CreateMLSSelfConversation lusr -> embedClient $ createMLSSelfConversation lusr GetConversation cid -> embedClient $ getConversation cid - GetConversationIdByGroupId gId -> embedClient $ lookupGroupId gId + GetConversationEpoch cid -> embedClient $ getConvEpoch cid GetConversations cids -> localConversations cids GetConversationMetadata cid -> embedClient $ conversationMeta cid - GetPublicGroupState cid -> embedClient $ getPublicGroupState cid + GetGroupInfo cid -> embedClient $ getGroupInfo cid IsConversationAlive cid -> embedClient $ isConvAlive cid SelectConversations uid cids -> embedClient $ localConversationIdsOf uid cids GetRemoteConversationStatus uid cids -> embedClient $ remoteConversationStatus uid cids @@ -401,8 +467,10 @@ interpretConversationStoreToCassandra = interpret $ \case SetConversationReceiptMode cid value -> embedClient $ updateConvReceiptMode cid value SetConversationMessageTimer cid value -> embedClient $ updateConvMessageTimer cid value SetConversationEpoch cid epoch -> embedClient $ updateConvEpoch cid epoch + SetConversationCipherSuite cid cs -> embedClient $ updateConvCipherSuite cid cs DeleteConversation cid -> embedClient $ deleteConversation cid - SetGroupId gId cid -> embedClient $ mapGroupId gId cid - SetPublicGroupState cid gib -> embedClient $ setPublicGroupState cid gib + SetGroupInfo cid gib -> embedClient $ setGroupInfo cid gib AcquireCommitLock gId epoch ttl -> embedClient $ acquireCommitLock gId epoch ttl ReleaseCommitLock gId epoch -> embedClient $ releaseCommitLock gId epoch + UpdateToMixedProtocol cid ct cs -> updateToMixedProtocol cid ct cs + UpdateToMLSProtocol cid -> updateToMLSProtocol cid diff --git a/services/galley/src/Galley/Cassandra/Conversation/MLS.hs b/services/galley/src/Galley/Cassandra/Conversation/MLS.hs index 9fca81b063d..fbc2991247d 100644 --- a/services/galley/src/Galley/Cassandra/Conversation/MLS.hs +++ b/services/galley/src/Galley/Cassandra/Conversation/MLS.hs @@ -15,11 +15,19 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Galley.Cassandra.Conversation.MLS where +module Galley.Cassandra.Conversation.MLS + ( acquireCommitLock, + releaseCommitLock, + lookupMLSClients, + lookupMLSClientLeafIndices, + ) +where import Cassandra -import Cassandra.Settings (fromRow) +import Cassandra.Settings +import Control.Arrow import Data.Time +import Galley.API.MLS.Types import Galley.Cassandra.Queries qualified as Cql import Galley.Data.Types import Imports @@ -36,6 +44,8 @@ acquireCommitLock groupId epoch ttl = do LocalQuorum (groupId, epoch, round ttl) ) + { serialConsistency = Just LocalSerialConsistency + } pure $ if checkTransSuccess rows then Acquired @@ -54,3 +64,11 @@ releaseCommitLock groupId epoch = checkTransSuccess :: [Row] -> Bool checkTransSuccess [] = False checkTransSuccess (row : _) = either (const False) (fromMaybe False) $ fromRow 0 row + +lookupMLSClientLeafIndices :: GroupId -> Client (ClientMap, IndexMap) +lookupMLSClientLeafIndices groupId = do + entries <- retry x5 (query Cql.lookupMLSClients (params LocalQuorum (Identity groupId))) + pure $ (mkClientMap &&& mkIndexMap) entries + +lookupMLSClients :: GroupId -> Client ClientMap +lookupMLSClients = fmap fst . lookupMLSClientLeafIndices diff --git a/services/galley/src/Galley/Cassandra/Conversation/Members.hs b/services/galley/src/Galley/Cassandra/Conversation/Members.hs index fe6c18a4fdc..abd3a0139e6 100644 --- a/services/galley/src/Galley/Cassandra/Conversation/Members.hs +++ b/services/galley/src/Galley/Cassandra/Conversation/Members.hs @@ -34,7 +34,7 @@ import Data.List.Extra qualified as List import Data.Monoid import Data.Qualified import Data.Set qualified as Set -import Galley.API.MLS.Types +import Galley.Cassandra.Conversation.MLS import Galley.Cassandra.Instances () import Galley.Cassandra.Queries qualified as Cql import Galley.Cassandra.Services @@ -49,8 +49,9 @@ import Polysemy.Input import UnliftIO qualified import Wire.API.Conversation.Member hiding (Member) import Wire.API.Conversation.Role +import Wire.API.MLS.Credential import Wire.API.MLS.Group -import Wire.API.MLS.KeyPackage +import Wire.API.MLS.LeafNode (LeafIndex) import Wire.API.Provider.Service -- | Add members to a local conversation. @@ -359,12 +360,22 @@ removeLocalMembersFromRemoteConv (tUntagged -> Qualified conv convDomain) victim setConsistency LocalQuorum for_ victims $ \u -> addPrepQuery Cql.deleteUserRemoteConv (u, convDomain, conv) -addMLSClients :: GroupId -> Qualified UserId -> Set.Set (ClientId, KeyPackageRef) -> Client () +addMLSClients :: GroupId -> Qualified UserId -> Set.Set (ClientId, LeafIndex) -> Client () addMLSClients groupId (Qualified usr domain) cs = retry x5 . batch $ do setType BatchLogged setConsistency LocalQuorum - for_ cs $ \(c, kpr) -> - addPrepQuery Cql.addMLSClient (groupId, domain, usr, c, kpr) + for_ cs $ \(c, idx) -> + addPrepQuery Cql.addMLSClient (groupId, domain, usr, c, fromIntegral idx) + +planMLSClientRemoval :: Foldable f => GroupId -> f ClientIdentity -> Client () +planMLSClientRemoval groupId cids = + retry x5 . batch $ do + setType BatchLogged + setConsistency LocalQuorum + for_ cids $ \cid -> do + addPrepQuery + Cql.planMLSClientRemoval + (groupId, ciDomain cid, ciUser cid, ciClient cid) removeMLSClients :: GroupId -> Qualified UserId -> Set.Set ClientId -> Client () removeMLSClients groupId (Qualified usr domain) cs = retry x5 . batch $ do @@ -373,12 +384,9 @@ removeMLSClients groupId (Qualified usr domain) cs = retry x5 . batch $ do for_ cs $ \c -> addPrepQuery Cql.removeMLSClient (groupId, domain, usr, c) -lookupMLSClients :: GroupId -> Client ClientMap -lookupMLSClients groupId = - mkClientMap - <$> retry - x5 - (query Cql.lookupMLSClients (params LocalQuorum (Identity groupId))) +removeAllMLSClients :: GroupId -> Client () +removeAllMLSClients groupId = do + retry x5 $ write Cql.removeAllMLSClients (params LocalQuorum (Identity groupId)) interpretMemberStoreToCassandra :: ( Member (Embed IO) r, @@ -406,7 +414,10 @@ interpretMemberStoreToCassandra = interpret $ \case embedClient $ removeLocalMembersFromRemoteConv rcnv uids AddMLSClients lcnv quid cs -> embedClient $ addMLSClients lcnv quid cs + PlanClientRemoval lcnv cids -> embedClient $ planMLSClientRemoval lcnv cids RemoveMLSClients lcnv quid cs -> embedClient $ removeMLSClients lcnv quid cs + RemoveAllMLSClients gid -> embedClient $ removeAllMLSClients gid LookupMLSClients lcnv -> embedClient $ lookupMLSClients lcnv + LookupMLSClientLeafIndices lcnv -> embedClient $ lookupMLSClientLeafIndices lcnv GetRemoteMembersByDomain dom -> embedClient $ lookupRemoteMembersByDomain dom GetLocalMembersByDomain dom -> embedClient $ lookupLocalMembersByDomain dom diff --git a/services/galley/src/Galley/Cassandra/CustomBackend.hs b/services/galley/src/Galley/Cassandra/CustomBackend.hs index 0878c0a0a79..cabe4a3a43e 100644 --- a/services/galley/src/Galley/Cassandra/CustomBackend.hs +++ b/services/galley/src/Galley/Cassandra/CustomBackend.hs @@ -51,7 +51,7 @@ getCustomBackend domain = setCustomBackend :: MonadClient m => Domain -> CustomBackend -> m () setCustomBackend domain CustomBackend {..} = do - retry x5 $ write Cql.updateCustomBackend (params LocalQuorum (backendConfigJsonUrl, backendWebappWelcomeUrl, domain)) + retry x5 $ write Cql.upsertCustomBackend (params LocalQuorum (backendConfigJsonUrl, backendWebappWelcomeUrl, domain)) deleteCustomBackend :: MonadClient m => Domain -> m () deleteCustomBackend domain = do diff --git a/services/galley/src/Galley/Cassandra/Instances.hs b/services/galley/src/Galley/Cassandra/Instances.hs index 05373de442f..7e9c9f43406 100644 --- a/services/galley/src/Galley/Cassandra/Instances.hs +++ b/services/galley/src/Galley/Cassandra/Instances.hs @@ -37,9 +37,10 @@ import Wire.API.Asset (AssetKey, assetKeyToText) import Wire.API.Conversation import Wire.API.Conversation.Protocol import Wire.API.MLS.CipherSuite +import Wire.API.MLS.GroupInfo import Wire.API.MLS.Proposal -import Wire.API.MLS.PublicGroupState import Wire.API.MLS.Serialisation +import Wire.API.MLS.SubConversation import Wire.API.Routes.Internal.Galley.TeamsIntra import Wire.API.Team import Wire.API.Team.Feature qualified as Public @@ -200,12 +201,12 @@ instance Cql GroupId where fromCql (CqlBlob b) = Right . GroupId . LBS.toStrict $ b fromCql _ = Left "group_id: blob expected" -instance Cql OpaquePublicGroupState where +instance Cql GroupInfoData where ctype = Tagged BlobColumn - toCql = CqlBlob . LBS.fromStrict . unOpaquePublicGroupState - fromCql (CqlBlob b) = Right $ OpaquePublicGroupState (LBS.toStrict b) - fromCql _ = Left "OpaquePublicGroupState: blob expected" + toCql = CqlBlob . LBS.fromStrict . unGroupInfoData + fromCql (CqlBlob b) = Right $ GroupInfoData (LBS.toStrict b) + fromCql _ = Left "GroupInfoData: blob expected" instance Cql Icon where ctype = Tagged TextColumn @@ -243,7 +244,7 @@ instance Cql ProposalRef where instance Cql (RawMLS Proposal) where ctype = Tagged BlobColumn - toCql = CqlBlob . LBS.fromStrict . rmRaw + toCql = CqlBlob . LBS.fromStrict . raw fromCql (CqlBlob b) = mapLeft T.unpack $ decodeMLS b fromCql _ = Left "Proposal: blob expected" @@ -255,3 +256,9 @@ instance Cql CipherSuite where then Right . CipherSuite . fromIntegral $ i else Left "CipherSuite: an out of bounds value for Word16" fromCql _ = Left "CipherSuite: int expected" + +instance Cql SubConvId where + ctype = Tagged TextColumn + toCql = CqlText . unSubConvId + fromCql (CqlText txt) = Right (SubConvId txt) + fromCql _ = Left "SubConvId: Text expected" diff --git a/services/galley/src/Galley/Cassandra/Proposal.hs b/services/galley/src/Galley/Cassandra/Proposal.hs index e7d0c0b64f1..04263a234c2 100644 --- a/services/galley/src/Galley/Cassandra/Proposal.hs +++ b/services/galley/src/Galley/Cassandra/Proposal.hs @@ -56,6 +56,8 @@ interpretProposalStoreToCassandra = runIdentity <$$> retry x1 (query getAllPendingRef (params LocalQuorum (groupId, epoch))) GetAllPendingProposals groupId epoch -> retry x1 (query getAllPending (params LocalQuorum (groupId, epoch))) + DeleteAllProposals groupId -> + retry x5 (write deleteAllProposalsForGroup (params LocalQuorum (Identity groupId))) storeQuery :: Timeout -> PrepQuery W (GroupId, Epoch, ProposalRef, ProposalOrigin, RawMLS Proposal) () storeQuery ttl = @@ -72,3 +74,6 @@ getAllPendingRef = "select ref from mls_proposal_refs where group_id = ? and epo getAllPending :: PrepQuery R (GroupId, Epoch) (Maybe ProposalOrigin, RawMLS Proposal) getAllPending = "select origin, proposal from mls_proposal_refs where group_id = ? and epoch = ?" + +deleteAllProposalsForGroup :: PrepQuery W (Identity GroupId) () +deleteAllProposalsForGroup = "delete from mls_proposal_refs where group_id = ?" diff --git a/services/galley/src/Galley/Cassandra/Queries.hs b/services/galley/src/Galley/Cassandra/Queries.hs index e04507c944e..c5904013608 100644 --- a/services/galley/src/Galley/Cassandra/Queries.hs +++ b/services/galley/src/Galley/Cassandra/Queries.hs @@ -34,8 +34,8 @@ import Wire.API.Conversation.Code import Wire.API.Conversation.Protocol import Wire.API.Conversation.Role import Wire.API.MLS.CipherSuite -import Wire.API.MLS.KeyPackage -import Wire.API.MLS.PublicGroupState +import Wire.API.MLS.GroupInfo +import Wire.API.MLS.SubConversation import Wire.API.Password (Password) import Wire.API.Provider import Wire.API.Provider.Service @@ -176,6 +176,10 @@ deleteTeamAdmin = "delete from team_admin where team = ? and user = ?" listTeamAdmins :: PrepQuery R (Identity TeamId) (Identity UserId) listTeamAdmins = "select user from team_admin where team = ?" +-- | This is not an upsert, but we can't add `IF EXISTS` here, or cassandra will yell `Invalid +-- "Batch with conditions cannot span multiple tables"` at us. So we make sure in the +-- application logic to only call this if the user exists (in the handler, not entirely +-- race-condition-proof, unfortunately). updatePermissions :: PrepQuery W (Permissions, TeamId, UserId) () updatePermissions = "update team_member set perms = ? where team = ? and user = ?" @@ -186,25 +190,25 @@ deleteUserTeam :: PrepQuery W (UserId, TeamId) () deleteUserTeam = "delete from user_team where user = ? and team = ?" markTeamDeleted :: PrepQuery W (TeamStatus, TeamId) () -markTeamDeleted = "update team set status = ? where team = ?" +markTeamDeleted = {- `IF EXISTS`, but that requires benchmarking -} "update team set status = ? where team = ?" deleteTeam :: PrepQuery W (TeamStatus, TeamId) () -deleteTeam = "update team using timestamp 32503680000000000 set name = 'default', icon = 'default', status = ? where team = ? " +deleteTeam = {- `IF EXISTS`, but that requires benchmarking -} "update team using timestamp 32503680000000000 set name = 'default', icon = 'default', status = ? where team = ? " updateTeamName :: PrepQuery W (Text, TeamId) () -updateTeamName = "update team set name = ? where team = ?" +updateTeamName = {- `IF EXISTS`, but that requires benchmarking -} "update team set name = ? where team = ?" updateTeamIcon :: PrepQuery W (Text, TeamId) () -updateTeamIcon = "update team set icon = ? where team = ?" +updateTeamIcon = {- `IF EXISTS`, but that requires benchmarking -} "update team set icon = ? where team = ?" updateTeamIconKey :: PrepQuery W (Text, TeamId) () -updateTeamIconKey = "update team set icon_key = ? where team = ?" +updateTeamIconKey = {- `IF EXISTS`, but that requires benchmarking -} "update team set icon_key = ? where team = ?" updateTeamStatus :: PrepQuery W (TeamStatus, TeamId) () -updateTeamStatus = "update team set status = ? where team = ?" +updateTeamStatus = {- `IF EXISTS`, but that requires benchmarking -} "update team set status = ? where team = ?" updateTeamSplashScreen :: PrepQuery W (Text, TeamId) () -updateTeamSplashScreen = "update team set splash_screen = ? where team = ?" +updateTeamSplashScreen = {- `IF EXISTS`, but that requires benchmarking -} "update team set splash_screen = ? where team = ?" -- Conversations ------------------------------------------------------------ @@ -225,9 +229,10 @@ selectConv :: Maybe ProtocolTag, Maybe GroupId, Maybe Epoch, + Maybe (Writetime Epoch), Maybe CipherSuiteTag ) -selectConv = "select type, creator, access, access_role, access_roles_v2, name, team, deleted, message_timer, receipt_mode, protocol, group_id, epoch, cipher_suite from conversation where conv = ?" +selectConv = "select type, creator, access, access_role, access_roles_v2, name, team, deleted, message_timer, receipt_mode, protocol, group_id, epoch, WRITETIME(epoch), cipher_suite from conversation where conv = ?" selectReceiptMode :: PrepQuery R (Identity ConvId) (Identity (Maybe ReceiptMode)) selectReceiptMode = "select receipt_mode from conversation where conv = ?" @@ -235,7 +240,7 @@ selectReceiptMode = "select receipt_mode from conversation where conv = ?" isConvDeleted :: PrepQuery R (Identity ConvId) (Identity (Maybe Bool)) isConvDeleted = "select deleted from conversation where conv = ?" -insertConv :: PrepQuery W (ConvId, ConvType, UserId, C.Set Access, C.Set AccessRole, Maybe Text, Maybe TeamId, Maybe Milliseconds, Maybe ReceiptMode, ProtocolTag, Maybe GroupId, Maybe Epoch, Maybe CipherSuiteTag) () +insertConv :: PrepQuery W (ConvId, ConvType, Maybe UserId, C.Set Access, C.Set AccessRole, Maybe Text, Maybe TeamId, Maybe Milliseconds, Maybe ReceiptMode, ProtocolTag, Maybe GroupId, Maybe Epoch, Maybe CipherSuiteTag) () insertConv = "insert into conversation (conv, type, creator, access, access_roles_v2, name, team, message_timer, receipt_mode, protocol, group_id, epoch, cipher_suite) values (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)" insertMLSSelfConv :: @@ -243,7 +248,7 @@ insertMLSSelfConv :: W ( ConvId, ConvType, - UserId, + Maybe UserId, C.Set Access, C.Set AccessRole, Maybe Text, @@ -263,35 +268,48 @@ insertMLSSelfConv = <> show (fromEnum ProtocolMLSTag) <> ", ?, ?)" +updateToMixedConv :: PrepQuery W (ConvId, ProtocolTag, GroupId, Epoch, CipherSuiteTag) () +updateToMixedConv = + "insert into conversation (conv, protocol, group_id, epoch, cipher_suite) values (?, ?, ?, ?, ?)" + +updateToMLSConv :: PrepQuery W (ConvId, ProtocolTag) () +updateToMLSConv = "insert into conversation (conv, protocol) values (?, ?)" + updateConvAccess :: PrepQuery W (C.Set Access, C.Set AccessRole, ConvId) () -updateConvAccess = "update conversation set access = ?, access_roles_v2 = ? where conv = ?" +updateConvAccess = {- `IF EXISTS`, but that requires benchmarking -} "update conversation set access = ?, access_roles_v2 = ? where conv = ?" updateConvReceiptMode :: PrepQuery W (ReceiptMode, ConvId) () -updateConvReceiptMode = "update conversation set receipt_mode = ? where conv = ?" +updateConvReceiptMode = {- `IF EXISTS`, but that requires benchmarking -} "update conversation set receipt_mode = ? where conv = ?" updateConvMessageTimer :: PrepQuery W (Maybe Milliseconds, ConvId) () -updateConvMessageTimer = "update conversation set message_timer = ? where conv = ?" +updateConvMessageTimer = {- `IF EXISTS`, but that requires benchmarking -} "update conversation set message_timer = ? where conv = ?" updateConvName :: PrepQuery W (Text, ConvId) () -updateConvName = "update conversation set name = ? where conv = ?" +updateConvName = {- `IF EXISTS`, but that requires benchmarking -} "update conversation set name = ? where conv = ?" updateConvType :: PrepQuery W (ConvType, ConvId) () -updateConvType = "update conversation set type = ? where conv = ?" +updateConvType = {- `IF EXISTS`, but that requires benchmarking -} "update conversation set type = ? where conv = ?" + +getConvEpoch :: PrepQuery R (Identity ConvId) (Identity (Maybe Epoch)) +getConvEpoch = "select epoch from conversation where conv = ?" updateConvEpoch :: PrepQuery W (Epoch, ConvId) () -updateConvEpoch = "update conversation set epoch = ? where conv = ?" +updateConvEpoch = {- `IF EXISTS`, but that requires benchmarking -} "update conversation set epoch = ? where conv = ?" + +updateConvCipherSuite :: PrepQuery W (CipherSuiteTag, ConvId) () +updateConvCipherSuite = "update conversation set cipher_suite = ? where conv = ?" deleteConv :: PrepQuery W (Identity ConvId) () deleteConv = "delete from conversation using timestamp 32503680000000000 where conv = ?" markConvDeleted :: PrepQuery W (Identity ConvId) () -markConvDeleted = "update conversation set deleted = true where conv = ?" +markConvDeleted = {- `IF EXISTS`, but that requires benchmarking -} "update conversation set deleted = true where conv = ?" -selectPublicGroupState :: PrepQuery R (Identity ConvId) (Identity (Maybe OpaquePublicGroupState)) -selectPublicGroupState = "select public_group_state from conversation where conv = ?" +selectGroupInfo :: PrepQuery R (Identity ConvId) (Identity GroupInfoData) +selectGroupInfo = "select public_group_state from conversation where conv = ?" -updatePublicGroupState :: PrepQuery W (OpaquePublicGroupState, ConvId) () -updatePublicGroupState = "update conversation set public_group_state = ? where conv = ?" +updateGroupInfo :: PrepQuery W (GroupInfoData, ConvId) () +updateGroupInfo = "update conversation set public_group_state = ? where conv = ?" -- Conversations accessible by code ----------------------------------------- @@ -321,13 +339,37 @@ insertUserConv = "insert into user (user, conv) values (?, ?)" deleteUserConv :: PrepQuery W (UserId, ConvId) () deleteUserConv = "delete from user where user = ? and conv = ?" --- MLS Conversations -------------------------------------------------------- +-- MLS SubConversations ----------------------------------------------------- + +selectSubConversation :: PrepQuery R (ConvId, SubConvId) (Maybe CipherSuiteTag, Maybe Epoch, Maybe (Writetime Epoch), Maybe GroupId) +selectSubConversation = "SELECT cipher_suite, epoch, WRITETIME(epoch), group_id FROM subconversation WHERE conv_id = ? and subconv_id = ?" + +insertSubConversation :: PrepQuery W (ConvId, SubConvId, CipherSuiteTag, Epoch, GroupId, Maybe GroupInfoData) () +insertSubConversation = "INSERT INTO subconversation (conv_id, subconv_id, cipher_suite, epoch, group_id, public_group_state) VALUES (?, ?, ?, ?, ?, ?)" + +updateSubConvGroupInfo :: PrepQuery W (ConvId, SubConvId, Maybe GroupInfoData) () +updateSubConvGroupInfo = "INSERT INTO subconversation (conv_id, subconv_id, public_group_state) VALUES (?, ?, ?)" -insertGroupId :: PrepQuery W (GroupId, ConvId, Domain) () -insertGroupId = "INSERT INTO group_id_conv_id (group_id, conv_id, domain) VALUES (?, ?, ?)" +selectSubConvGroupInfo :: PrepQuery R (ConvId, SubConvId) (Identity (Maybe GroupInfoData)) +selectSubConvGroupInfo = "SELECT public_group_state FROM subconversation WHERE conv_id = ? AND subconv_id = ?" -lookupGroupId :: PrepQuery R (Identity GroupId) (ConvId, Domain) -lookupGroupId = "SELECT conv_id, domain from group_id_conv_id where group_id = ?" +selectSubConvEpoch :: PrepQuery R (ConvId, SubConvId) (Identity (Maybe Epoch)) +selectSubConvEpoch = "SELECT epoch FROM subconversation WHERE conv_id = ? AND subconv_id = ?" + +insertEpochForSubConversation :: PrepQuery W (Epoch, ConvId, SubConvId) () +insertEpochForSubConversation = "UPDATE subconversation set epoch = ? WHERE conv_id = ? AND subconv_id = ?" + +insertCipherSuiteForSubConversation :: PrepQuery W (CipherSuiteTag, ConvId, SubConvId) () +insertCipherSuiteForSubConversation = "UPDATE subconversation set cipher_suite = ? WHERE conv_id = ? AND subconv_id = ?" + +listSubConversations :: PrepQuery R (Identity ConvId) (SubConvId, CipherSuiteTag, Epoch, Writetime Epoch, GroupId) +listSubConversations = "SELECT subconv_id, cipher_suite, epoch, WRITETIME(epoch), group_id FROM subconversation WHERE conv_id = ?" + +selectSubConversations :: PrepQuery R (Identity ConvId) (Identity SubConvId) +selectSubConversations = "SELECT subconv_id FROM subconversation WHERE conv_id = ?" + +deleteSubConversation :: PrepQuery W (ConvId, SubConvId) () +deleteSubConversation = "DELETE FROM subconversation where conv_id = ? and subconv_id = ?" -- Members ------------------------------------------------------------------ @@ -349,16 +391,16 @@ removeMember :: PrepQuery W (ConvId, UserId) () removeMember = "delete from member where conv = ? and user = ?" updateOtrMemberMutedStatus :: PrepQuery W (MutedStatus, Maybe Text, ConvId, UserId) () -updateOtrMemberMutedStatus = "update member set otr_muted_status = ?, otr_muted_ref = ? where conv = ? and user = ?" +updateOtrMemberMutedStatus = {- `IF EXISTS`, but that requires benchmarking -} "update member set otr_muted_status = ?, otr_muted_ref = ? where conv = ? and user = ?" updateOtrMemberArchived :: PrepQuery W (Bool, Maybe Text, ConvId, UserId) () -updateOtrMemberArchived = "update member set otr_archived = ?, otr_archived_ref = ? where conv = ? and user = ?" +updateOtrMemberArchived = {- `IF EXISTS`, but that requires benchmarking -} "update member set otr_archived = ?, otr_archived_ref = ? where conv = ? and user = ?" updateMemberHidden :: PrepQuery W (Bool, Maybe Text, ConvId, UserId) () -updateMemberHidden = "update member set hidden = ?, hidden_ref = ? where conv = ? and user = ?" +updateMemberHidden = {- `IF EXISTS`, but that requires benchmarking -} "update member set hidden = ?, hidden_ref = ? where conv = ? and user = ?" updateMemberConvRoleName :: PrepQuery W (RoleName, ConvId, UserId) () -updateMemberConvRoleName = "update member set conversation_role = ? where conv = ? and user = ?" +updateMemberConvRoleName = {- `IF EXISTS`, but that requires benchmarking -} "update member set conversation_role = ? where conv = ? and user = ?" -- Federated conversations ----------------------------------------------------- -- @@ -379,7 +421,7 @@ selectRemoteMembers :: PrepQuery R (Identity ConvId) (Domain, UserId, RoleName) selectRemoteMembers = "select user_remote_domain, user_remote_id, conversation_role from member_remote_user where conv = ?" updateRemoteMemberConvRoleName :: PrepQuery W (RoleName, ConvId, Domain, UserId) () -updateRemoteMemberConvRoleName = "update member_remote_user set conversation_role = ? where conv = ? and user_remote_domain = ? and user_remote_id = ?" +updateRemoteMemberConvRoleName = {- `IF EXISTS`, but that requires benchmarking -} "update member_remote_user set conversation_role = ? where conv = ? and user_remote_domain = ? and user_remote_id = ?" -- Used when removing a federation domain, so that we can quickly list all of the affected remote users and conversations -- This returns local conversation IDs and remote users @@ -411,13 +453,13 @@ selectLocalMembersByDomain = "select conv_remote_id, user from user_remote_conv -- remote conversation status for local user updateRemoteOtrMemberMutedStatus :: PrepQuery W (MutedStatus, Maybe Text, Domain, ConvId, UserId) () -updateRemoteOtrMemberMutedStatus = "update user_remote_conv set otr_muted_status = ?, otr_muted_ref = ? where conv_remote_domain = ? and conv_remote_id = ? and user = ?" +updateRemoteOtrMemberMutedStatus = {- `IF EXISTS`, but that requires benchmarking -} "update user_remote_conv set otr_muted_status = ?, otr_muted_ref = ? where conv_remote_domain = ? and conv_remote_id = ? and user = ?" updateRemoteOtrMemberArchived :: PrepQuery W (Bool, Maybe Text, Domain, ConvId, UserId) () -updateRemoteOtrMemberArchived = "update user_remote_conv set otr_archived = ?, otr_archived_ref = ? where conv_remote_domain = ? and conv_remote_id = ? and user = ?" +updateRemoteOtrMemberArchived = {- `IF EXISTS`, but that requires benchmarking -} "update user_remote_conv set otr_archived = ?, otr_archived_ref = ? where conv_remote_domain = ? and conv_remote_id = ? and user = ?" updateRemoteMemberHidden :: PrepQuery W (Bool, Maybe Text, Domain, ConvId, UserId) () -updateRemoteMemberHidden = "update user_remote_conv set hidden = ?, hidden_ref = ? where conv_remote_domain = ? and conv_remote_id = ? and user = ?" +updateRemoteMemberHidden = {- `IF EXISTS`, but that requires benchmarking -} "update user_remote_conv set hidden = ?, hidden_ref = ? where conv_remote_domain = ? and conv_remote_id = ? and user = ?" selectRemoteMemberStatus :: PrepQuery R (Domain, ConvId, UserId) (Maybe MutedStatus, Maybe Text, Maybe Bool, Maybe Text, Maybe Bool, Maybe Text) selectRemoteMemberStatus = "select otr_muted_status, otr_muted_ref, otr_archived, otr_archived_ref, hidden, hidden_ref from user_remote_conv where conv_remote_domain = ? and conv_remote_id = ? and user = ?" @@ -430,26 +472,32 @@ selectClients = "select user, clients from clients where user in ?" rmClients :: PrepQuery W (Identity UserId) () rmClients = "delete from clients where user = ?" -addMemberClient :: ClientId -> QueryString W (Identity UserId) () -addMemberClient c = +upsertMemberAddClient :: ClientId -> QueryString W (Identity UserId) () +upsertMemberAddClient c = let t = LT.fromStrict (client c) in QueryString $ "update clients set clients = clients + {'" <> t <> "'} where user = ?" -rmMemberClient :: ClientId -> QueryString W (Identity UserId) () -rmMemberClient c = +upsertMemberRmClient :: ClientId -> QueryString W (Identity UserId) () +upsertMemberRmClient c = let t = LT.fromStrict (client c) in QueryString $ "update clients set clients = clients - {'" <> t <> "'} where user = ?" -- MLS Clients -------------------------------------------------------------- -addMLSClient :: PrepQuery W (GroupId, Domain, UserId, ClientId, KeyPackageRef) () -addMLSClient = "insert into mls_group_member_client (group_id, user_domain, user, client, key_package_ref) values (?, ?, ?, ?, ?)" +addMLSClient :: PrepQuery W (GroupId, Domain, UserId, ClientId, Int32) () +addMLSClient = "insert into mls_group_member_client (group_id, user_domain, user, client, leaf_node_index, removal_pending) values (?, ?, ?, ?, ?, false)" + +planMLSClientRemoval :: PrepQuery W (GroupId, Domain, UserId, ClientId) () +planMLSClientRemoval = "update mls_group_member_client set removal_pending = true where group_id = ? and user_domain = ? and user = ? and client = ?" removeMLSClient :: PrepQuery W (GroupId, Domain, UserId, ClientId) () removeMLSClient = "delete from mls_group_member_client where group_id = ? and user_domain = ? and user = ? and client = ?" -lookupMLSClients :: PrepQuery R (Identity GroupId) (Domain, UserId, ClientId, KeyPackageRef) -lookupMLSClients = "select user_domain, user, client, key_package_ref from mls_group_member_client where group_id = ?" +removeAllMLSClients :: PrepQuery W (Identity GroupId) () +removeAllMLSClients = "DELETE FROM mls_group_member_client WHERE group_id = ?" + +lookupMLSClients :: PrepQuery R (Identity GroupId) (Domain, UserId, ClientId, Int32, Bool) +lookupMLSClients = "select user_domain, user, client, leaf_node_index, removal_pending from mls_group_member_client where group_id = ?" acquireCommitLock :: PrepQuery W (GroupId, Epoch, Int32) Row acquireCommitLock = "insert into mls_commit_locks (group_id, epoch) values (?, ?) if not exists using ttl ?" @@ -553,7 +601,7 @@ selectSearchVisibility = updateSearchVisibility :: PrepQuery W (TeamSearchVisibility, TeamId) () updateSearchVisibility = - "update team set search_visibility = ? where team = ?" + {- `IF EXISTS`, but that requires benchmarking -} "update team set search_visibility = ? where team = ?" -- Custom Backend ----------------------------------------------------------- @@ -561,8 +609,8 @@ selectCustomBackend :: PrepQuery R (Identity Domain) (HttpsUrl, HttpsUrl) selectCustomBackend = "select config_json_url, webapp_welcome_url from custom_backend where domain = ?" -updateCustomBackend :: PrepQuery W (HttpsUrl, HttpsUrl, Domain) () -updateCustomBackend = +upsertCustomBackend :: PrepQuery W (HttpsUrl, HttpsUrl, Domain) () +upsertCustomBackend = "update custom_backend set config_json_url = ?, webapp_welcome_url = ? where domain = ?" deleteCustomBackend :: PrepQuery W (Identity Domain) () diff --git a/services/galley/src/Galley/Cassandra/SubConversation.hs b/services/galley/src/Galley/Cassandra/SubConversation.hs new file mode 100644 index 00000000000..5827435aaa3 --- /dev/null +++ b/services/galley/src/Galley/Cassandra/SubConversation.hs @@ -0,0 +1,147 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.Cassandra.SubConversation + ( interpretSubConversationStoreToCassandra, + ) +where + +import Cassandra +import Cassandra.Util +import Control.Error.Util +import Control.Monad.Trans.Maybe +import Data.Id +import Data.Map qualified as Map +import Data.Time.Clock +import Galley.API.MLS.Types +import Galley.Cassandra.Conversation.MLS +import Galley.Cassandra.Queries qualified as Cql +import Galley.Cassandra.Store (embedClient) +import Galley.Effects.SubConversationStore (SubConversationStore (..)) +import Imports hiding (cs) +import Polysemy +import Polysemy.Input +import Wire.API.Conversation.Protocol +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.Group +import Wire.API.MLS.GroupInfo +import Wire.API.MLS.SubConversation + +selectSubConversation :: ConvId -> SubConvId -> Client (Maybe SubConversation) +selectSubConversation convId subConvId = runMaybeT $ do + (mSuite, mEpoch, mEpochWritetime, mGroupId) <- + MaybeT $ + retry x5 (query1 Cql.selectSubConversation (params LocalQuorum (convId, subConvId))) + suite <- hoistMaybe mSuite + epoch <- hoistMaybe mEpoch + epochWritetime <- hoistMaybe mEpochWritetime + groupId <- hoistMaybe mGroupId + (cm, im) <- lift $ lookupMLSClientLeafIndices groupId + pure $ + SubConversation + { scParentConvId = convId, + scSubConvId = subConvId, + scMLSData = + ConversationMLSData + { cnvmlsGroupId = groupId, + cnvmlsEpoch = epoch, + cnvmlsEpochTimestamp = epochTimestamp epoch epochWritetime, + cnvmlsCipherSuite = suite + }, + scMembers = cm, + scIndexMap = im + } + +insertSubConversation :: + ConvId -> + SubConvId -> + CipherSuiteTag -> + GroupId -> + Client SubConversation +insertSubConversation convId subConvId suite groupId = do + retry + x5 + ( write + Cql.insertSubConversation + ( params + LocalQuorum + (convId, subConvId, suite, Epoch 0, groupId, Nothing) + ) + ) + pure (newSubConversation convId subConvId suite groupId) + +updateSubConvGroupInfo :: ConvId -> SubConvId -> Maybe GroupInfoData -> Client () +updateSubConvGroupInfo convId subConvId mGroupInfo = + retry x5 (write Cql.updateSubConvGroupInfo (params LocalQuorum (convId, subConvId, mGroupInfo))) + +selectSubConvGroupInfo :: ConvId -> SubConvId -> Client (Maybe GroupInfoData) +selectSubConvGroupInfo convId subConvId = + (runIdentity =<<) <$> retry x5 (query1 Cql.selectSubConvGroupInfo (params LocalQuorum (convId, subConvId))) + +selectSubConvEpoch :: ConvId -> SubConvId -> Client (Maybe Epoch) +selectSubConvEpoch convId subConvId = + (runIdentity =<<) <$> retry x5 (query1 Cql.selectSubConvEpoch (params LocalQuorum (convId, subConvId))) + +setEpochForSubConversation :: ConvId -> SubConvId -> Epoch -> Client () +setEpochForSubConversation cid sconv epoch = + retry x5 (write Cql.insertEpochForSubConversation (params LocalQuorum (epoch, cid, sconv))) + +setCipherSuiteForSubConversation :: ConvId -> SubConvId -> CipherSuiteTag -> Client () +setCipherSuiteForSubConversation cid sconv cs = + retry x5 (write Cql.insertCipherSuiteForSubConversation (params LocalQuorum (cs, cid, sconv))) + +deleteSubConversation :: ConvId -> SubConvId -> Client () +deleteSubConversation cid sconv = + retry x5 $ write Cql.deleteSubConversation (params LocalQuorum (cid, sconv)) + +listSubConversations :: ConvId -> Client (Map SubConvId ConversationMLSData) +listSubConversations cid = do + subs <- retry x1 (query Cql.listSubConversations (params LocalQuorum (Identity cid))) + pure . Map.fromList $ do + (subId, cs, epoch, ts, gid) <- subs + pure + ( subId, + ConversationMLSData + { cnvmlsGroupId = gid, + cnvmlsEpoch = epoch, + cnvmlsEpochTimestamp = epochTimestamp epoch ts, + cnvmlsCipherSuite = cs + } + ) + +interpretSubConversationStoreToCassandra :: + Members '[Embed IO, Input ClientState] r => + Sem (SubConversationStore ': r) a -> + Sem r a +interpretSubConversationStoreToCassandra = interpret $ \case + CreateSubConversation convId subConvId suite groupId -> + embedClient (insertSubConversation convId subConvId suite groupId) + GetSubConversation convId subConvId -> embedClient (selectSubConversation convId subConvId) + GetSubConversationGroupInfo convId subConvId -> embedClient (selectSubConvGroupInfo convId subConvId) + GetSubConversationEpoch convId subConvId -> embedClient (selectSubConvEpoch convId subConvId) + SetSubConversationGroupInfo convId subConvId mPgs -> embedClient (updateSubConvGroupInfo convId subConvId mPgs) + SetSubConversationEpoch cid sconv epoch -> embedClient $ setEpochForSubConversation cid sconv epoch + SetSubConversationCipherSuite cid sconv cs -> embedClient $ setCipherSuiteForSubConversation cid sconv cs + ListSubConversations cid -> embedClient $ listSubConversations cid + DeleteSubConversation convId subConvId -> embedClient $ deleteSubConversation convId subConvId + +-------------------------------------------------------------------------------- +-- Utilities + +epochTimestamp :: Epoch -> Writetime Epoch -> Maybe UTCTime +epochTimestamp (Epoch 0) _ = Nothing +epochTimestamp _ (Writetime t) = Just t diff --git a/services/galley/src/Galley/Cassandra/Team.hs b/services/galley/src/Galley/Cassandra/Team.hs index bd22909d671..06560c01baf 100644 --- a/services/galley/src/Galley/Cassandra/Team.hs +++ b/services/galley/src/Galley/Cassandra/Team.hs @@ -99,7 +99,7 @@ interpretTeamStoreToCassandra lh = interpret $ \case SetTeamStatus tid st -> embedClient $ updateTeamStatus tid st FanoutLimit -> embedApp $ currentFanoutLimit <$> view options GetLegalHoldFlag -> - view (options . optSettings . setFeatureFlags . flagLegalHold) <$> input + view (options . settings . featureFlags . flagLegalHold) <$> input EnqueueTeamEvent e -> do menv <- inputs (view aEnv) for_ menv $ \env -> @@ -232,6 +232,7 @@ addTeamMember t m = m ^? invitation . _Just . _2 ) addPrepQuery Cql.insertUserTeam (m ^. userId, t) + when (m `hasPermission` SetBilling) $ addPrepQuery Cql.insertBillingTeamMember (t, m ^. userId) diff --git a/services/galley/src/Galley/Cassandra/TeamFeatures.hs b/services/galley/src/Galley/Cassandra/TeamFeatures.hs index f4e15e80908..85451bc46d2 100644 --- a/services/galley/src/Galley/Cassandra/TeamFeatures.hs +++ b/services/galley/src/Galley/Cassandra/TeamFeatures.hs @@ -26,7 +26,7 @@ import Cassandra qualified as C import Control.Monad.Trans.Maybe import Data.Id import Data.Misc (HttpsUrl) -import Data.Time (NominalDiffTime) +import Data.Time import Galley.Cassandra.Instances () import Galley.Cassandra.Store import Galley.Effects.TeamFeatureStore qualified as TFS @@ -105,7 +105,7 @@ getFeatureConfig FeatureSingletonMLSConfig tid = do m <- retry x1 $ query1 select (params LocalQuorum (Identity tid)) pure $ case m of Nothing -> Nothing - Just (status, defaultProtocol, protocolToggleUsers, allowedCipherSuites, defaultCipherSuite) -> + Just (status, defaultProtocol, protocolToggleUsers, allowedCipherSuites, defaultCipherSuite, supportedProtocols) -> WithStatusNoLock <$> status <*> ( MLSConfig @@ -113,13 +113,14 @@ getFeatureConfig FeatureSingletonMLSConfig tid = do <*> defaultProtocol <*> maybe (Just []) (Just . C.fromSet) allowedCipherSuites <*> defaultCipherSuite + <*> maybe (Just []) (Just . C.fromSet) supportedProtocols ) <*> Just FeatureTTLUnlimited where - select :: PrepQuery R (Identity TeamId) (Maybe FeatureStatus, Maybe ProtocolTag, Maybe (C.Set UserId), Maybe (C.Set CipherSuiteTag), Maybe CipherSuiteTag) + select :: PrepQuery R (Identity TeamId) (Maybe FeatureStatus, Maybe ProtocolTag, Maybe (C.Set UserId), Maybe (C.Set CipherSuiteTag), Maybe CipherSuiteTag, Maybe (C.Set ProtocolTag)) select = "select mls_status, mls_default_protocol, mls_protocol_toggle_users, mls_allowed_ciphersuites, \ - \mls_default_ciphersuite from team_features where team_id = ?" + \mls_default_ciphersuite, mls_supported_protocols from team_features where team_id = ?" getFeatureConfig FeatureSingletonMlsE2EIdConfig tid = do let q = query1 select (params LocalQuorum (Identity tid)) retry x1 q <&> \case @@ -139,6 +140,23 @@ getFeatureConfig FeatureSingletonMlsE2EIdConfig tid = do select = fromString $ "select mls_e2eid_status, mls_e2eid_grace_period, mls_e2eid_acme_discovery_url from team_features where team_id = ?" +getFeatureConfig FeatureSingletonMlsMigration tid = do + let q = query1 select (params LocalQuorum (Identity tid)) + retry x1 q <&> \case + Nothing -> Nothing + Just (Nothing, _, _) -> Nothing + Just (Just fs, startTime, finaliseRegardlessAfter) -> + Just $ + WithStatusNoLock + fs + MlsMigrationConfig + { startTime = startTime, + finaliseRegardlessAfter = finaliseRegardlessAfter + } + FeatureTTLUnlimited + where + select :: PrepQuery R (Identity TeamId) (Maybe FeatureStatus, Maybe UTCTime, Maybe UTCTime) + select = "select mls_migration_status, mls_migration_start_time, mls_migration_finalise_regardless_after from team_features where team_id = ?" getFeatureConfig FeatureSingletonExposeInvitationURLsToTeamAdminConfig tid = getTrivialConfigC "expose_invitation_urls_to_team_admin" tid getFeatureConfig FeatureSingletonOutlookCalIntegrationConfig tid = getTrivialConfigC "outlook_cal_integration_status" tid @@ -188,7 +206,7 @@ setFeatureConfig FeatureSingletonSndFactorPasswordChallengeConfig tid statusNoLo setFeatureConfig FeatureSingletonSearchVisibilityInboundConfig tid statusNoLock = setFeatureStatusC "search_visibility_status" tid (wssStatus statusNoLock) setFeatureConfig FeatureSingletonMLSConfig tid statusNoLock = do let status = wssStatus statusNoLock - let MLSConfig protocolToggleUsers defaultProtocol allowedCipherSuites defaultCipherSuite = wssConfig statusNoLock + let MLSConfig protocolToggleUsers defaultProtocol allowedCipherSuites defaultCipherSuite supportedProtocols = wssConfig statusNoLock retry x5 $ write insert @@ -199,14 +217,15 @@ setFeatureConfig FeatureSingletonMLSConfig tid statusNoLock = do defaultProtocol, C.Set protocolToggleUsers, C.Set allowedCipherSuites, - defaultCipherSuite + defaultCipherSuite, + C.Set supportedProtocols ) ) where - insert :: PrepQuery W (TeamId, FeatureStatus, ProtocolTag, C.Set UserId, C.Set CipherSuiteTag, CipherSuiteTag) () + insert :: PrepQuery W (TeamId, FeatureStatus, ProtocolTag, C.Set UserId, C.Set CipherSuiteTag, CipherSuiteTag, C.Set ProtocolTag) () insert = "insert into team_features (team_id, mls_status, mls_default_protocol, \ - \mls_protocol_toggle_users, mls_allowed_ciphersuites, mls_default_ciphersuite) values (?, ?, ?, ?, ?, ?)" + \mls_protocol_toggle_users, mls_allowed_ciphersuites, mls_default_ciphersuite, mls_supported_protocols) values (?, ?, ?, ?, ?, ?, ?)" setFeatureConfig FeatureSingletonMlsE2EIdConfig tid status = do let statusValue = wssStatus status vex = verificationExpiration . wssConfig $ status @@ -216,6 +235,15 @@ setFeatureConfig FeatureSingletonMlsE2EIdConfig tid status = do insert :: PrepQuery W (TeamId, FeatureStatus, Int32, Maybe HttpsUrl) () insert = "insert into team_features (team_id, mls_e2eid_status, mls_e2eid_grace_period, mls_e2eid_acme_discovery_url) values (?, ?, ?, ?)" +setFeatureConfig FeatureSingletonMlsMigration tid status = do + let statusValue = wssStatus status + config = wssConfig status + + retry x5 $ write insert (params LocalQuorum (tid, statusValue, config.startTime, config.finaliseRegardlessAfter)) + where + insert :: PrepQuery W (TeamId, FeatureStatus, Maybe UTCTime, Maybe UTCTime) () + insert = + "insert into team_features (team_id, mls_migration_status, mls_migration_start_time, mls_migration_finalise_regardless_after) values (?, ?, ?, ?)" setFeatureConfig FeatureSingletonExposeInvitationURLsToTeamAdminConfig tid statusNoLock = setFeatureStatusC "expose_invitation_urls_to_team_admin" tid (wssStatus statusNoLock) setFeatureConfig FeatureSingletonOutlookCalIntegrationConfig tid statusNoLock = setFeatureStatusC "outlook_cal_integration_status" tid (wssStatus statusNoLock) @@ -225,6 +253,7 @@ getFeatureLockStatus FeatureSingletonSelfDeletingMessagesConfig tid = getLockSta getFeatureLockStatus FeatureSingletonGuestLinksConfig tid = getLockStatusC "guest_links_lock_status" tid getFeatureLockStatus FeatureSingletonSndFactorPasswordChallengeConfig tid = getLockStatusC "snd_factor_password_challenge_lock_status" tid getFeatureLockStatus FeatureSingletonMlsE2EIdConfig tid = getLockStatusC "mls_e2eid_lock_status" tid +getFeatureLockStatus FeatureSingletonMlsMigration tid = getLockStatusC "mls_migration_lock_status" tid getFeatureLockStatus FeatureSingletonOutlookCalIntegrationConfig tid = getLockStatusC "outlook_cal_integration_lock_status" tid getFeatureLockStatus _ _ = pure Nothing @@ -234,6 +263,7 @@ setFeatureLockStatus FeatureSingletonSelfDeletingMessagesConfig tid status = set setFeatureLockStatus FeatureSingletonGuestLinksConfig tid status = setLockStatusC "guest_links_lock_status" tid status setFeatureLockStatus FeatureSingletonSndFactorPasswordChallengeConfig tid status = setLockStatusC "snd_factor_password_challenge_lock_status" tid status setFeatureLockStatus FeatureSingletonMlsE2EIdConfig tid status = setLockStatusC "mls_e2eid_lock_status" tid status +setFeatureLockStatus FeatureSingletonMlsMigration tid status = setLockStatusC "mls_migration_lock_status" tid status setFeatureLockStatus FeatureSingletonOutlookCalIntegrationConfig tid status = setLockStatusC "outlook_cal_integration_lock_status" tid status setFeatureLockStatus _ _tid _status = pure () diff --git a/services/galley/src/Galley/Data/Conversation.hs b/services/galley/src/Galley/Data/Conversation.hs index 36c7f0d859c..fe18548b2c0 100644 --- a/services/galley/src/Galley/Data/Conversation.hs +++ b/services/galley/src/Galley/Data/Conversation.hs @@ -86,7 +86,7 @@ convAccessData c = (Set.fromList (convAccess c)) (convAccessRoles c) -convCreator :: Conversation -> UserId +convCreator :: Conversation -> Maybe UserId convCreator = cnvmCreator . convMetadata convName :: Conversation -> Maybe Text diff --git a/services/galley/src/Galley/Data/Conversation/Types.hs b/services/galley/src/Galley/Data/Conversation/Types.hs index a8ad662a210..b7f3624b988 100644 --- a/services/galley/src/Galley/Data/Conversation/Types.hs +++ b/services/galley/src/Galley/Data/Conversation/Types.hs @@ -24,6 +24,7 @@ import Imports import Wire.API.Conversation hiding (Conversation) import Wire.API.Conversation.Protocol import Wire.API.Conversation.Role +import Wire.API.User -- | Internal conversation type, corresponding directly to database schema. -- Should never be sent to users (and therefore doesn't have 'FromJSON' or @@ -38,14 +39,23 @@ data Conversation = Conversation } deriving (Show) +convProtocolTag :: Conversation -> ProtocolTag +convProtocolTag = protocolTag . convProtocol + data NewConversation = NewConversation { ncMetadata :: ConversationMetadata, ncUsers :: UserList (UserId, RoleName), - ncProtocol :: ProtocolTag + ncProtocol :: BaseProtocolTag } -mlsMetadata :: Conversation -> Maybe ConversationMLSData +data MLSMigrationState + = MLSMigrationMixed + | MLSMigrationMLS + deriving (Show, Eq, Ord) + +mlsMetadata :: Conversation -> Maybe (ConversationMLSData, MLSMigrationState) mlsMetadata conv = case convProtocol conv of ProtocolProteus -> Nothing - ProtocolMLS meta -> pure meta + ProtocolMLS meta -> pure (meta, MLSMigrationMLS) + ProtocolMixed meta -> pure (meta, MLSMigrationMixed) diff --git a/services/galley/src/Galley/Effects.hs b/services/galley/src/Galley/Effects.hs index fc8f406becb..12c7c31df5f 100644 --- a/services/galley/src/Galley/Effects.hs +++ b/services/galley/src/Galley/Effects.hs @@ -39,8 +39,10 @@ module Galley.Effects CustomBackendStore, LegalHoldStore, MemberStore, + ProposalStore, SearchVisibilityStore, ServiceStore, + SubConversationStore, TeamFeatureStore, TeamMemberStore, TeamNotificationStore, @@ -56,6 +58,9 @@ module Galley.Effects -- * Polysemy re-exports Member, Members, + + -- * Queueing effects + BackendNotificationQueueAccess, ) where @@ -69,7 +74,6 @@ import Galley.Effects.ClientStore import Galley.Effects.CodeStore import Galley.Effects.ConversationStore import Galley.Effects.CustomBackendStore -import Galley.Effects.DefederationNotifications import Galley.Effects.ExternalAccess import Galley.Effects.FederatorAccess import Galley.Effects.FireAndForget @@ -82,6 +86,7 @@ import Galley.Effects.Queue import Galley.Effects.SearchVisibilityStore import Galley.Effects.ServiceStore import Galley.Effects.SparAccess +import Galley.Effects.SubConversationStore import Galley.Effects.TeamFeatureStore import Galley.Effects.TeamMemberStore import Galley.Effects.TeamNotificationStore @@ -95,12 +100,12 @@ import Polysemy.Input import Polysemy.TinyLog import Wire.API.Error import Wire.Sem.Paging.Cassandra +import Wire.Sem.Random -- All the possible high-level effects. type GalleyEffects1 = '[ BrigAccess, SparAccess, - DefederationNotifications, GundeckAccess, ExternalAccess, FederatorAccess, @@ -111,6 +116,8 @@ type GalleyEffects1 = CodeStore, ProposalStore, ConversationStore, + SubConversationStore, + Random, CustomBackendStore, LegalHoldStore, MemberStore, diff --git a/services/galley/src/Galley/Effects/BackendNotificationQueueAccess.hs b/services/galley/src/Galley/Effects/BackendNotificationQueueAccess.hs index c7ecdfcb771..bdefa146314 100644 --- a/services/galley/src/Galley/Effects/BackendNotificationQueueAccess.hs +++ b/services/galley/src/Galley/Effects/BackendNotificationQueueAccess.hs @@ -15,7 +15,13 @@ data BackendNotificationQueueAccess m a where KnownComponent c => Remote x -> Q.DeliveryMode -> - FedQueueClient c () -> - BackendNotificationQueueAccess m (Either FederationError ()) + FedQueueClient c a -> + BackendNotificationQueueAccess m (Either FederationError a) + EnqueueNotificationsConcurrently :: + (KnownComponent c, Foldable f, Functor f) => + Q.DeliveryMode -> + f (Remote x) -> + (Remote [x] -> FedQueueClient c a) -> + BackendNotificationQueueAccess m (Either FederationError [Remote a]) makeSem ''BackendNotificationQueueAccess diff --git a/services/galley/src/Galley/Effects/BrigAccess.hs b/services/galley/src/Galley/Effects/BrigAccess.hs index 58d6b0098ff..642a3ab4c10 100644 --- a/services/galley/src/Galley/Effects/BrigAccess.hs +++ b/services/galley/src/Galley/Effects/BrigAccess.hs @@ -48,11 +48,7 @@ module Galley.Effects.BrigAccess removeLegalHoldClientFromUser, -- * MLS - getClientByKeyPackageRef, getLocalMLSClients, - addKeyPackageRef, - validateAndAddKeyPackageRef, - updateKeyPackageRef, -- * Features getAccountConferenceCallingConfigClient, @@ -72,9 +68,7 @@ import Polysemy import Polysemy.Error import Wire.API.Connection import Wire.API.Error.Galley -import Wire.API.MLS.Credential -import Wire.API.MLS.KeyPackage -import Wire.API.Routes.Internal.Brig +import Wire.API.MLS.CipherSuite import Wire.API.Routes.Internal.Brig.Connection import Wire.API.Routes.Internal.Galley.TeamFeatureNoConfigMulti qualified as Multi import Wire.API.Team.Feature @@ -129,11 +123,7 @@ data BrigAccess m a where BrigAccess m (Either AuthenticationError ClientId) RemoveLegalHoldClientFromUser :: UserId -> BrigAccess m () GetAccountConferenceCallingConfigClient :: UserId -> BrigAccess m (WithStatusNoLock ConferenceCallingConfig) - GetClientByKeyPackageRef :: KeyPackageRef -> BrigAccess m (Maybe ClientIdentity) - GetLocalMLSClients :: Local UserId -> SignatureSchemeTag -> BrigAccess m (Set ClientInfo) - AddKeyPackageRef :: KeyPackageRef -> Qualified UserId -> ClientId -> Qualified ConvId -> BrigAccess m () - ValidateAndAddKeyPackageRef :: NewKeyPackage -> BrigAccess m (Either Text NewKeyPackageResult) - UpdateKeyPackageRef :: KeyPackageUpdate -> BrigAccess m () + GetLocalMLSClients :: Local UserId -> CipherSuiteTag -> BrigAccess m (Set ClientInfo) UpdateSearchVisibilityInbound :: Multi.TeamStatus SearchVisibilityInboundConfig -> BrigAccess m () diff --git a/services/galley/src/Galley/Effects/CodeStore.hs b/services/galley/src/Galley/Effects/CodeStore.hs index 88b31b0dfc1..15d71162f3b 100644 --- a/services/galley/src/Galley/Effects/CodeStore.hs +++ b/services/galley/src/Galley/Effects/CodeStore.hs @@ -53,6 +53,6 @@ data CodeStore m a where DeleteCode :: Key -> Scope -> CodeStore m () MakeKey :: ConvId -> CodeStore m Key GenerateCode :: ConvId -> Scope -> Timeout -> CodeStore m Code - GetConversationCodeURI :: CodeStore m HttpsUrl + GetConversationCodeURI :: Maybe Text -> CodeStore m (Maybe HttpsUrl) makeSem ''CodeStore diff --git a/services/galley/src/Galley/Effects/ConversationStore.hs b/services/galley/src/Galley/Effects/ConversationStore.hs index 1660c2f6893..cd0a2e8dce7 100644 --- a/services/galley/src/Galley/Effects/ConversationStore.hs +++ b/services/galley/src/Galley/Effects/ConversationStore.hs @@ -28,10 +28,10 @@ module Galley.Effects.ConversationStore -- * Read conversation getConversation, - getConversationIdByGroupId, + getConversationEpoch, getConversations, getConversationMetadata, - getPublicGroupState, + getGroupInfo, isConversationAlive, getRemoteConversationStatus, selectConversations, @@ -43,9 +43,11 @@ module Galley.Effects.ConversationStore setConversationReceiptMode, setConversationMessageTimer, setConversationEpoch, + setConversationCipherSuite, acceptConnectConversation, - setGroupId, - setPublicGroupState, + setGroupInfo, + updateToMixedProtocol, + updateToMLSProtocol, -- * Delete conversation deleteConversation, @@ -60,15 +62,16 @@ import Data.Id import Data.Misc import Data.Qualified import Data.Range -import Data.Time (NominalDiffTime) +import Data.Time.Clock import Galley.Data.Conversation import Galley.Data.Types import Galley.Types.Conversations.Members import Imports import Polysemy import Wire.API.Conversation hiding (Conversation, Member) -import Wire.API.MLS.Epoch -import Wire.API.MLS.PublicGroupState +import Wire.API.Conversation.Protocol +import Wire.API.MLS.CipherSuite (CipherSuiteTag) +import Wire.API.MLS.GroupInfo data ConversationStore m a where CreateConversationId :: ConversationStore m ConvId @@ -78,12 +81,10 @@ data ConversationStore m a where ConversationStore m Conversation DeleteConversation :: ConvId -> ConversationStore m () GetConversation :: ConvId -> ConversationStore m (Maybe Conversation) - GetConversationIdByGroupId :: GroupId -> ConversationStore m (Maybe (Qualified ConvId)) + GetConversationEpoch :: ConvId -> ConversationStore m (Maybe Epoch) GetConversations :: [ConvId] -> ConversationStore m [Conversation] GetConversationMetadata :: ConvId -> ConversationStore m (Maybe ConversationMetadata) - GetPublicGroupState :: - ConvId -> - ConversationStore m (Maybe OpaquePublicGroupState) + GetGroupInfo :: ConvId -> ConversationStore m (Maybe GroupInfoData) IsConversationAlive :: ConvId -> ConversationStore m Bool GetRemoteConversationStatus :: UserId -> @@ -96,13 +97,12 @@ data ConversationStore m a where SetConversationReceiptMode :: ConvId -> ReceiptMode -> ConversationStore m () SetConversationMessageTimer :: ConvId -> Maybe Milliseconds -> ConversationStore m () SetConversationEpoch :: ConvId -> Epoch -> ConversationStore m () - SetGroupId :: GroupId -> Qualified ConvId -> ConversationStore m () - SetPublicGroupState :: - ConvId -> - OpaquePublicGroupState -> - ConversationStore m () + SetConversationCipherSuite :: ConvId -> CipherSuiteTag -> ConversationStore m () + SetGroupInfo :: ConvId -> GroupInfoData -> ConversationStore m () AcquireCommitLock :: GroupId -> Epoch -> NominalDiffTime -> ConversationStore m LockAcquired ReleaseCommitLock :: GroupId -> Epoch -> ConversationStore m () + UpdateToMixedProtocol :: Local ConvId -> ConvType -> CipherSuiteTag -> ConversationStore m () + UpdateToMLSProtocol :: Local ConvId -> ConversationStore m () makeSem ''ConversationStore diff --git a/services/galley/src/Galley/Effects/DefederationNotifications.hs b/services/galley/src/Galley/Effects/DefederationNotifications.hs deleted file mode 100644 index 2c8cf12b987..00000000000 --- a/services/galley/src/Galley/Effects/DefederationNotifications.hs +++ /dev/null @@ -1,15 +0,0 @@ -{-# LANGUAGE TemplateHaskell #-} - -module Galley.Effects.DefederationNotifications - ( DefederationNotifications (..), - sendDefederationNotifications, - ) -where - -import Data.Domain (Domain) -import Polysemy - -data DefederationNotifications m a where - SendDefederationNotifications :: Domain -> DefederationNotifications m () - -makeSem ''DefederationNotifications diff --git a/services/galley/src/Galley/Effects/FederatorAccess.hs b/services/galley/src/Galley/Effects/FederatorAccess.hs index 4dbd3516b5e..8afd28cb842 100644 --- a/services/galley/src/Galley/Effects/FederatorAccess.hs +++ b/services/galley/src/Galley/Effects/FederatorAccess.hs @@ -75,6 +75,6 @@ makeSem ''FederatorAccess runFederatedConcurrently_ :: (KnownComponent c, Foldable f, Functor f, Member FederatorAccess r) => f (Remote a) -> - (Remote [a] -> FederatorClient c ()) -> + (Remote [a] -> FederatorClient c x) -> Sem r () runFederatedConcurrently_ xs = void . runFederatedConcurrently xs diff --git a/services/galley/src/Galley/Effects/MemberStore.hs b/services/galley/src/Galley/Effects/MemberStore.hs index c8542a71f3e..0513cc6570e 100644 --- a/services/galley/src/Galley/Effects/MemberStore.hs +++ b/services/galley/src/Galley/Effects/MemberStore.hs @@ -42,8 +42,11 @@ module Galley.Effects.MemberStore setSelfMember, setOtherMember, addMLSClients, + planClientRemoval, removeMLSClients, + removeAllMLSClients, lookupMLSClients, + lookupMLSClientLeafIndices, -- * Delete members deleteMembers, @@ -54,6 +57,7 @@ where import Data.Domain import Data.Id import Data.Qualified +import Galley.API.MLS.Types import Galley.Data.Services import Galley.Types.Conversations.Members import Galley.Types.ToUserRole @@ -61,8 +65,9 @@ import Galley.Types.UserList import Imports import Polysemy import Wire.API.Conversation.Member hiding (Member) +import Wire.API.MLS.Credential import Wire.API.MLS.Group -import Wire.API.MLS.KeyPackage +import Wire.API.MLS.LeafNode import Wire.API.Provider.Service data MemberStore m a where @@ -80,11 +85,12 @@ data MemberStore m a where SetOtherMember :: Local ConvId -> Qualified UserId -> OtherMemberUpdate -> MemberStore m () DeleteMembers :: ConvId -> UserList UserId -> MemberStore m () DeleteMembersInRemoteConversation :: Remote ConvId -> [UserId] -> MemberStore m () - AddMLSClients :: GroupId -> Qualified UserId -> Set (ClientId, KeyPackageRef) -> MemberStore m () + AddMLSClients :: GroupId -> Qualified UserId -> Set (ClientId, LeafIndex) -> MemberStore m () + PlanClientRemoval :: Foldable f => GroupId -> f ClientIdentity -> MemberStore m () RemoveMLSClients :: GroupId -> Qualified UserId -> Set ClientId -> MemberStore m () - LookupMLSClients :: - GroupId -> - MemberStore m (Map (Qualified UserId) (Set (ClientId, KeyPackageRef))) + RemoveAllMLSClients :: GroupId -> MemberStore m () + LookupMLSClients :: GroupId -> MemberStore m ClientMap + LookupMLSClientLeafIndices :: GroupId -> MemberStore m (ClientMap, IndexMap) GetRemoteMembersByDomain :: Domain -> MemberStore m [(ConvId, RemoteMember)] GetLocalMembersByDomain :: Domain -> MemberStore m [(ConvId, UserId)] diff --git a/services/galley/src/Galley/Effects/ProposalStore.hs b/services/galley/src/Galley/Effects/ProposalStore.hs index 4dfd5993177..cf549d576c3 100644 --- a/services/galley/src/Galley/Effects/ProposalStore.hs +++ b/services/galley/src/Galley/Effects/ProposalStore.hs @@ -47,5 +47,8 @@ data ProposalStore m a where GroupId -> Epoch -> ProposalStore m [(Maybe ProposalOrigin, RawMLS Proposal)] + DeleteAllProposals :: + GroupId -> + ProposalStore m () makeSem ''ProposalStore diff --git a/services/galley/src/Galley/Effects/SubConversationStore.hs b/services/galley/src/Galley/Effects/SubConversationStore.hs new file mode 100644 index 00000000000..2179781b134 --- /dev/null +++ b/services/galley/src/Galley/Effects/SubConversationStore.hs @@ -0,0 +1,43 @@ +{-# LANGUAGE TemplateHaskell #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.Effects.SubConversationStore where + +import Data.Id +import Galley.API.MLS.Types +import Imports +import Polysemy +import Wire.API.Conversation.Protocol +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.Group +import Wire.API.MLS.GroupInfo +import Wire.API.MLS.SubConversation + +data SubConversationStore m a where + CreateSubConversation :: ConvId -> SubConvId -> CipherSuiteTag -> GroupId -> SubConversationStore m SubConversation + GetSubConversation :: ConvId -> SubConvId -> SubConversationStore m (Maybe SubConversation) + GetSubConversationGroupInfo :: ConvId -> SubConvId -> SubConversationStore m (Maybe GroupInfoData) + GetSubConversationEpoch :: ConvId -> SubConvId -> SubConversationStore m (Maybe Epoch) + SetSubConversationGroupInfo :: ConvId -> SubConvId -> Maybe GroupInfoData -> SubConversationStore m () + SetSubConversationEpoch :: ConvId -> SubConvId -> Epoch -> SubConversationStore m () + SetSubConversationCipherSuite :: ConvId -> SubConvId -> CipherSuiteTag -> SubConversationStore m () + ListSubConversations :: ConvId -> SubConversationStore m (Map SubConvId ConversationMLSData) + DeleteSubConversation :: ConvId -> SubConvId -> SubConversationStore m () + +makeSem ''SubConversationStore diff --git a/services/galley/src/Galley/Env.hs b/services/galley/src/Galley/Env.hs index 2dbf0b40d33..2bdb38c27ff 100644 --- a/services/galley/src/Galley/Env.hs +++ b/services/galley/src/Galley/Env.hs @@ -25,10 +25,11 @@ import Control.Lens hiding ((.=)) import Data.ByteString.Conversion (toByteString') import Data.Id import Data.Metrics.Middleware -import Data.Misc (Fingerprint, Rsa) +import Data.Misc (Fingerprint, HttpsUrl, Rsa) import Data.Range import Galley.Aws qualified as Aws import Galley.Options +import Galley.Options qualified as O import Galley.Queue qualified as Q import HTTP2.Client.Manager (Http2Manager) import Imports @@ -62,7 +63,8 @@ data Env = Env _extEnv :: ExtEnv, _aEnv :: Maybe Aws.Env, _mlsKeys :: SignaturePurpose -> MLSKeys, - _rabbitmqChannel :: Maybe (MVar Q.Channel) + _rabbitmqChannel :: Maybe (MVar Q.Channel), + _convCodeURI :: Either HttpsUrl (Map Text HttpsUrl) } -- | Environment specific to the communication with external @@ -104,6 +106,6 @@ reqIdMsg = ("request" .=) . unRequestId currentFanoutLimit :: Opts -> Range 1 HardTruncationLimit Int32 currentFanoutLimit o = do - let optFanoutLimit = fromIntegral . fromRange $ fromMaybe defFanoutLimit (o ^. (optSettings . setMaxFanoutSize)) - let maxTeamSize = fromIntegral (o ^. (optSettings . setMaxTeamSize)) - unsafeRange (min maxTeamSize optFanoutLimit) + let optFanoutLimit = fromIntegral . fromRange $ fromMaybe defFanoutLimit (o ^. (O.settings . maxFanoutSize)) + let maxSize = fromIntegral (o ^. (O.settings . maxTeamSize)) + unsafeRange (min maxSize optFanoutLimit) diff --git a/services/galley/src/Galley/Intra/BackendNotificationQueue.hs b/services/galley/src/Galley/Intra/BackendNotificationQueue.hs index 57e4628484c..411897a0834 100644 --- a/services/galley/src/Galley/Intra/BackendNotificationQueue.hs +++ b/services/galley/src/Galley/Intra/BackendNotificationQueue.hs @@ -4,6 +4,7 @@ module Galley.Intra.BackendNotificationQueue (interpretBackendNotificationQueueA import Control.Lens (view) import Control.Monad.Catch +import Control.Monad.Trans.Except import Control.Retry import Data.Domain import Data.Qualified @@ -16,7 +17,7 @@ import Network.AMQP qualified as Q import Polysemy import Polysemy.Input import System.Logger.Class qualified as Log -import UnliftIO.Timeout (timeout) +import UnliftIO import Wire.API.Federation.BackendNotifications import Wire.API.Federation.Error @@ -28,20 +29,21 @@ interpretBackendNotificationQueueAccess :: Sem r a interpretBackendNotificationQueueAccess = interpret $ \case EnqueueNotification remote deliveryMode action -> do - embedApp $ enqueueNotification (tDomain remote) deliveryMode action + embedApp . runExceptT $ enqueueNotification (tDomain remote) deliveryMode action + EnqueueNotificationsConcurrently m xs rpc -> do + embedApp . runExceptT $ enqueueNotificationsConcurrently m xs rpc -enqueueNotification :: Domain -> Q.DeliveryMode -> FedQueueClient c () -> App (Either FederationError ()) -enqueueNotification remoteDomain deliveryMode action = do - mChanVar <- view rabbitmqChannel - ownDomain <- view (options . optSettings . setFederationDomain) - case mChanVar of - Nothing -> pure (Left FederationNotConfigured) - Just chanVar -> do - let policy = limitRetries 3 <> constantDelay 1_000_000 - handlers = - skipAsyncExceptions - <> [logRetries (const $ pure True) logError] - Right <$> recovering policy handlers (const $ go ownDomain chanVar) +getChannel :: ExceptT FederationError App (MVar Q.Channel) +getChannel = view rabbitmqChannel >>= maybe (throwE FederationNotConfigured) pure + +enqueueSingleNotification :: Domain -> Q.DeliveryMode -> MVar Q.Channel -> FedQueueClient c a -> App a +enqueueSingleNotification remoteDomain deliveryMode chanVar action = do + ownDomain <- view (options . settings . federationDomain) + let policy = limitRetries 3 <> constantDelay 1_000_000 + handlers = + skipAsyncExceptions + <> [logRetries (const $ pure True) logError] + recovering policy handlers (const $ go ownDomain) where logError willRetry (SomeException e) status = do Log.err $ @@ -49,13 +51,30 @@ enqueueNotification remoteDomain deliveryMode action = do . Log.field "error" (displayException e) . Log.field "willRetry" willRetry . Log.field "retryCount" status.rsIterNumber - go ownDomain chanVar = do + go ownDomain = do mChan <- timeout 1_000_000 (readMVar chanVar) case mChan of Nothing -> throwM NoRabbitMqChannel Just chan -> do liftIO $ enqueue chan ownDomain remoteDomain deliveryMode action +enqueueNotification :: Domain -> Q.DeliveryMode -> FedQueueClient c a -> ExceptT FederationError App a +enqueueNotification remoteDomain deliveryMode action = do + chanVar <- getChannel + lift $ enqueueSingleNotification remoteDomain deliveryMode chanVar action + +enqueueNotificationsConcurrently :: + (Foldable f, Functor f) => + Q.DeliveryMode -> + f (Remote x) -> + (Remote [x] -> FedQueueClient c a) -> + ExceptT FederationError App [Remote a] +enqueueNotificationsConcurrently m xs f = do + chanVar <- getChannel + lift $ pooledForConcurrentlyN 8 (bucketRemote xs) $ \r -> + qualifyAs r + <$> enqueueSingleNotification (tDomain r) m chanVar (f r) + data NoRabbitMqChannel = NoRabbitMqChannel deriving (Show) diff --git a/services/galley/src/Galley/Intra/Client.hs b/services/galley/src/Galley/Intra/Client.hs index 5a902401481..5907498ad60 100644 --- a/services/galley/src/Galley/Intra/Client.hs +++ b/services/galley/src/Galley/Intra/Client.hs @@ -22,11 +22,7 @@ module Galley.Intra.Client addLegalHoldClientToUser, removeLegalHoldClientFromUser, getLegalHoldAuthToken, - getClientByKeyPackageRef, getLocalMLSClients, - addKeyPackageRef, - updateKeyPackageRef, - validateAndAddKeyPackageRef, ) where @@ -34,14 +30,12 @@ import Bilge hiding (getHeader, options, statusCode) import Bilge.RPC import Brig.Types.Intra import Brig.Types.Team.LegalHold (LegalHoldClientRequest (..)) -import Control.Monad.Catch -import Data.ByteString.Conversion (toByteString') +import Data.ByteString.Conversion import Data.Id import Data.Misc import Data.Qualified import Data.Set qualified as Set import Data.Text.Encoding -import Data.Text.Lazy (toStrict) import Galley.API.Error import Galley.Effects import Galley.Env @@ -49,22 +43,17 @@ import Galley.External.LegalHoldService.Types import Galley.Intra.Util import Galley.Monad import Imports -import Network.HTTP.Client qualified as Rq -import Network.HTTP.Types qualified as HTTP import Network.HTTP.Types.Method import Network.HTTP.Types.Status import Network.Wai.Utilities.Error hiding (Error) -import Network.Wai.Utilities.Error qualified as Error import Polysemy import Polysemy.Error import Polysemy.Input import Polysemy.TinyLog qualified as P -import Servant +import Servant.API import System.Logger.Class qualified as Logger import Wire.API.Error.Galley -import Wire.API.MLS.Credential -import Wire.API.MLS.KeyPackage -import Wire.API.Routes.Internal.Brig +import Wire.API.MLS.CipherSuite import Wire.API.User.Auth.LegalHold import Wire.API.User.Client import Wire.API.User.Client.Prekey @@ -182,21 +171,9 @@ brigAddClient uid connId client = do then Right <$> parseResponse (mkError status502 "server-error") r else pure (Left ReAuthFailed) --- | Calls 'Brig.API.Internal.getClientByKeyPackageRef'. -getClientByKeyPackageRef :: KeyPackageRef -> App (Maybe ClientIdentity) -getClientByKeyPackageRef ref = do - r <- - call Brig $ - method GET - . paths ["i", "mls", "key-packages", toHeader ref] - . expectStatus (flip elem [200, 404]) - if statusCode (responseStatus r) == 200 - then Just <$> parseResponse (mkError status502 "server-error") r - else pure Nothing - -- | Calls 'Brig.API.Internal.getMLSClients'. -getLocalMLSClients :: Local UserId -> SignatureSchemeTag -> App (Set ClientInfo) -getLocalMLSClients lusr ss = +getLocalMLSClients :: Local UserId -> CipherSuiteTag -> App (Set ClientInfo) +getLocalMLSClients lusr suite = call Brig ( method GET @@ -206,46 +183,9 @@ getLocalMLSClients lusr ss = "clients", toByteString' (tUnqualified lusr) ] - . queryItem "sig_scheme" (toByteString' (signatureSchemeName ss)) + . queryItem + "ciphersuite" + (toHeader (tagCipherSuite suite)) . expect2xx ) >>= parseResponse (mkError status502 "server-error") - -addKeyPackageRef :: KeyPackageRef -> Qualified UserId -> ClientId -> Qualified ConvId -> App () -addKeyPackageRef ref qusr cl qcnv = - void $ - call - Brig - ( method PUT - . paths ["i", "mls", "key-packages", toHeader ref] - . json (NewKeyPackageRef qusr cl qcnv) - . expect2xx - ) - -updateKeyPackageRef :: KeyPackageUpdate -> App () -updateKeyPackageRef keyPackageRef = - void $ - call - Brig - ( method POST - . paths ["i", "mls", "key-packages", toHeader $ kpupPrevious keyPackageRef] - . json (kpupNext keyPackageRef) - . expect2xx - ) - -validateAndAddKeyPackageRef :: NewKeyPackage -> App (Either Text NewKeyPackageResult) -validateAndAddKeyPackageRef nkp = do - res <- - call - Brig - ( method PUT - . paths ["i", "mls", "key-package-add"] - . json nkp - ) - let statusCode = HTTP.statusCode (Rq.responseStatus res) - if - | statusCode `div` 100 == 2 -> Right <$> parseResponse (mkError status502 "server-error") res - | statusCode `div` 100 == 4 -> do - err <- parseResponse (mkError status502 "server-error") res - pure (Left ("Error validating keypackage: " <> toStrict (Error.label err) <> ": " <> toStrict (Error.message err))) - | otherwise -> throwM (mkError status502 "server-error" "Unexpected http status returned from /i/mls/key-packages/add") diff --git a/services/galley/src/Galley/Intra/Effects.hs b/services/galley/src/Galley/Intra/Effects.hs index e2fab6ac770..51909889e85 100644 --- a/services/galley/src/Galley/Intra/Effects.hs +++ b/services/galley/src/Galley/Intra/Effects.hs @@ -20,27 +20,16 @@ module Galley.Intra.Effects interpretSparAccess, interpretBotAccess, interpretGundeckAccess, - interpretDefederationNotifications, ) where -import Cassandra (ClientState, Consistency (LocalQuorum), Page (hasMore, nextPage, result), paginate, paramsP) -import Control.Lens ((.~)) -import Data.Range (Range (fromRange)) import Galley.API.Error -import Galley.API.Util (localBotsAndUsers) -import Galley.Cassandra.Conversation.Members (toMember) -import Galley.Cassandra.Queries (selectAllMembers) -import Galley.Cassandra.Store (embedClient) import Galley.Effects.BotAccess (BotAccess (..)) import Galley.Effects.BrigAccess (BrigAccess (..)) -import Galley.Effects.DefederationNotifications (DefederationNotifications (..)) -import Galley.Effects.ExternalAccess (ExternalAccess, deliverAsync) -import Galley.Effects.GundeckAccess (GundeckAccess (..), push1) +import Galley.Effects.GundeckAccess (GundeckAccess (..)) import Galley.Effects.SparAccess (SparAccess (..)) import Galley.Env import Galley.Intra.Client -import Galley.Intra.Push qualified as Intra import Galley.Intra.Push.Internal qualified as G import Galley.Intra.Spar import Galley.Intra.Team @@ -52,8 +41,6 @@ import Polysemy.Error import Polysemy.Input import Polysemy.TinyLog qualified as P import UnliftIO qualified -import Wire.API.Event.Federation qualified as Federation -import Wire.API.Team.Member (ListType (ListComplete)) interpretBrigAccess :: ( Member (Embed IO) r, @@ -93,18 +80,7 @@ interpretBrigAccess = interpret $ \case embedApp $ removeLegalHoldClientFromUser uid GetAccountConferenceCallingConfigClient uid -> embedApp $ getAccountConferenceCallingConfigClient uid - GetClientByKeyPackageRef ref -> - embedApp $ getClientByKeyPackageRef ref GetLocalMLSClients qusr ss -> embedApp $ getLocalMLSClients qusr ss - AddKeyPackageRef ref qusr cl qcnv -> - embedApp $ - addKeyPackageRef ref qusr cl qcnv - ValidateAndAddKeyPackageRef nkp -> - embedApp $ - validateAndAddKeyPackageRef nkp - UpdateKeyPackageRef update -> - embedApp $ - updateKeyPackageRef update UpdateSearchVisibilityInbound status -> embedApp $ updateSearchVisibilityInbound status @@ -136,36 +112,3 @@ interpretGundeckAccess :: interpretGundeckAccess = interpret $ \case Push ps -> embedApp $ G.push ps PushSlowly ps -> embedApp $ G.pushSlowly ps - -interpretDefederationNotifications :: - ( Member (Embed IO) r, - Member (Input Env) r, - Member (Input ClientState) r, - Member GundeckAccess r, - Member ExternalAccess r - ) => - Sem (DefederationNotifications ': r) a -> - Sem r a -interpretDefederationNotifications = interpret $ \case - SendDefederationNotifications domain -> do - maxPage <- inputs $ fromRange . currentFanoutLimit . _options -- This is based on the limits in removeIfLargeFanout - page <- embedClient $ paginate selectAllMembers (paramsP LocalQuorum () maxPage) - void $ sendNotificationPage page - where - pushEvents results = do - let (bots, mems) = localBotsAndUsers results - recipients = Intra.recipient <$> mems - event = Intra.FederationEvent $ Federation.Event Federation.FederationDelete domain - for_ (Intra.newPush ListComplete Nothing event recipients) $ \p -> do - -- Futurework: Transient or not? - -- RouteAny is used as it will wake up mobile clients - -- and notify them of the changes to federation state. - push1 $ p & Intra.pushRoute .~ Intra.RouteAny - deliverAsync (bots `zip` repeat (G.pushEventJson event)) - sendNotificationPage page = do - let res = result page - mems = mapMaybe toMember res - pushEvents mems - when (hasMore page) $ do - page' <- embedClient $ nextPage page - sendNotificationPage page' diff --git a/services/galley/src/Galley/Intra/Federator.hs b/services/galley/src/Galley/Intra/Federator.hs index b8e12474e42..e0ac966dcba 100644 --- a/services/galley/src/Galley/Intra/Federator.hs +++ b/services/galley/src/Galley/Intra/Federator.hs @@ -23,6 +23,7 @@ import Data.Bifunctor import Data.Qualified import Galley.Effects.FederatorAccess (FederatorAccess (..)) import Galley.Env +import Galley.Env qualified as E import Galley.Monad import Galley.Options import Imports @@ -48,15 +49,15 @@ interpretFederatorAccess = interpret $ \case RunFederatedConcurrentlyBucketsEither rs f -> embedApp $ runFederatedConcurrentlyBucketsEither rs f - IsFederationConfigured -> embedApp $ isJust <$> view federator + IsFederationConfigured -> embedApp $ isJust <$> view E.federator runFederatedEither :: Remote x -> FederatorClient c a -> App (Either FederationError a) runFederatedEither (tDomain -> remoteDomain) rpc = do - ownDomain <- view (options . optSettings . setFederationDomain) - mfedEndpoint <- view federator + ownDomain <- view (options . settings . federationDomain) + mfedEndpoint <- view E.federator mgr <- view http2Manager case mfedEndpoint of Nothing -> pure (Left FederationNotConfigured) diff --git a/services/galley/src/Galley/Intra/Push/Internal.hs b/services/galley/src/Galley/Intra/Push/Internal.hs index 971c9e283a7..4adcf716f73 100644 --- a/services/galley/src/Galley/Intra/Push/Internal.hs +++ b/services/galley/src/Galley/Intra/Push/Internal.hs @@ -24,6 +24,7 @@ import Control.Lens (makeLenses, set, view, (.~)) import Data.Aeson (Object) import Data.Id (ConnId, UserId) import Data.Json.Util +import Data.List.Extra import Data.List.NonEmpty (NonEmpty, nonEmpty) import Data.List1 import Data.Qualified @@ -43,6 +44,7 @@ import Wire.API.Event.FeatureConfig qualified as FeatureConfig import Wire.API.Event.Federation qualified as Federation import Wire.API.Event.Team qualified as Teams import Wire.API.Team.Member +import Wire.Arbitrary data PushEvent = ConvEvent Event @@ -60,7 +62,8 @@ data RecipientBy user = Recipient { _recipientUserId :: user, _recipientClients :: RecipientClients } - deriving stock (Functor, Foldable, Traversable, Show) + deriving stock (Functor, Foldable, Traversable, Show, Ord, Eq, Generic) + deriving (Arbitrary) via GenericUniform (RecipientBy user) makeLenses ''RecipientBy @@ -77,7 +80,8 @@ data PushTo user = Push pushJson :: Object, pushRecipientListType :: ListType } - deriving stock (Functor, Foldable, Traversable, Show) + deriving stock (Eq, Generic, Functor, Foldable, Traversable, Show) + deriving (Arbitrary) via GenericUniform (PushTo user) makeLenses ''PushTo @@ -93,32 +97,24 @@ push ps = do nonEmpty (toList (_pushRecipients p)) <&> \nonEmptyRecipients -> p {_pushRecipients = List1 nonEmptyRecipients} --- | Asynchronously send multiple pushes, aggregating them into as --- few requests as possible, such that no single request targets --- more than 128 recipients. -pushLocal :: NonEmpty (PushTo UserId) -> App () -pushLocal ps = do - opts <- view options - let limit = currentFanoutLimit opts - -- Do not fan out for very large teams - let (asyncs, syncs) = partition _pushAsync (removeIfLargeFanout limit $ toList ps) - traverse_ (asyncCall Gundeck <=< jsonChunkedIO) (pushes asyncs) - mapConcurrently_ (call Gundeck <=< jsonChunkedIO) (pushes syncs) +-- | Split a list of pushes into chunks with the given maximum number of +-- recipients. maxRecipients must be strictly positive. Note that the order of +-- pushes within a chunk is reversed compared to the order of the input list. +chunkPushes :: Int -> [PushTo a] -> [[PushTo a]] +chunkPushes maxRecipients | maxRecipients <= 0 = error "maxRecipients must be positive" +chunkPushes maxRecipients = go 0 [] where - pushes :: [PushTo UserId] -> [[Gundeck.Push]] - pushes = map (map (\p -> toPush p (recipientList p))) . chunk 0 [] - - chunk :: Int -> [PushTo a] -> [PushTo a] -> [[PushTo a]] - chunk _ acc [] = [acc] - chunk n acc (y : ys) - | n >= maxRecipients = acc : chunk 0 [] (y : ys) + go _ [] [] = [] + go _ acc [] = [acc] + go n acc (y : ys) + | n >= maxRecipients = acc : go 0 [] (y : ys) | otherwise = let totalLength = (n + length (_pushRecipients y)) in if totalLength > maxRecipients then let (y1, y2) = splitPush (maxRecipients - n) y - in chunk maxRecipients (y1 : acc) (y2 : ys) - else chunk totalLength (y : acc) ys + in go maxRecipients (y1 : acc) (y2 : ys) + else go totalLength (y : acc) ys -- n must be strictly > 0 and < length (_pushRecipients p) splitPush :: Int -> PushTo a -> (PushTo a, PushTo a) @@ -126,8 +122,20 @@ pushLocal ps = do let (r1, r2) = splitAt n (toList (_pushRecipients p)) in (p {_pushRecipients = fromJust $ maybeList1 r1}, p {_pushRecipients = fromJust $ maybeList1 r2}) - maxRecipients :: Int - maxRecipients = 128 +-- | Asynchronously send multiple pushes, aggregating them into as +-- few requests as possible, such that no single request targets +-- more than 128 recipients. +pushLocal :: NonEmpty (PushTo UserId) -> App () +pushLocal ps = do + opts <- view options + let limit = currentFanoutLimit opts + -- Do not fan out for very large teams + let (asyncs, syncs) = partition _pushAsync (removeIfLargeFanout limit $ toList ps) + traverse_ (asyncCall Gundeck <=< jsonChunkedIO) (pushes asyncs) + mapConcurrently_ (call Gundeck <=< jsonChunkedIO) (pushes syncs) + where + pushes :: [PushTo UserId] -> [[Gundeck.Push]] + pushes = map (map (\p -> toPush p (recipientList p))) . chunkPushes 128 recipientList :: PushTo UserId -> [Gundeck.Recipient] recipientList p = map (toRecipient p) . toList $ _pushRecipients p @@ -191,7 +199,7 @@ newConversationEventPush e users = pushSlowly :: Foldable f => f Push -> App () pushSlowly ps = do - mmillis <- view (options . optSettings . setDeleteConvThrottleMillis) + mmillis <- view (options . settings . deleteConvThrottleMillis) let delay = 1000 * fromMaybe defDeleteConvThrottleMillis mmillis forM_ ps $ \p -> do push [p] diff --git a/services/galley/src/Galley/Intra/User.hs b/services/galley/src/Galley/Intra/User.hs index 1cf0ea992ef..a1b0b8cd6b4 100644 --- a/services/galley/src/Galley/Intra/User.hs +++ b/services/galley/src/Galley/Intra/User.hs @@ -35,7 +35,7 @@ module Galley.Intra.User ) where -import Bilge hiding (getHeader, options, statusCode) +import Bilge hiding (getHeader, host, options, port, statusCode) import Bilge.RPC import Brig.Types.Intra qualified as Brig import Control.Error hiding (bool, isRight) @@ -253,7 +253,7 @@ runHereClientM action = do mgr <- view manager brigep <- view brig let env = Client.mkClientEnv mgr baseurl - baseurl = Client.BaseUrl Client.Http (cs $ brigep ^. epHost) (fromIntegral $ brigep ^. epPort) "" + baseurl = Client.BaseUrl Client.Http (cs $ brigep ^. host) (fromIntegral $ brigep ^. port) "" liftIO $ Client.runClientM action env handleServantResp :: diff --git a/services/galley/src/Galley/Intra/Util.hs b/services/galley/src/Galley/Intra/Util.hs index d59fad9afd8..0ebff1f349f 100644 --- a/services/galley/src/Galley/Intra/Util.hs +++ b/services/galley/src/Galley/Intra/Util.hs @@ -22,7 +22,8 @@ module Galley.Intra.Util ) where -import Bilge hiding (getHeader, options, statusCode) +import Bilge hiding (getHeader, host, options, port, statusCode) +import Bilge qualified as B import Bilge.RPC import Bilge.Retry import Control.Lens (view, (^.)) @@ -32,7 +33,7 @@ import Data.ByteString.Lazy qualified as LB import Data.Misc (portNumber) import Data.Text.Encoding (encodeUtf8) import Data.Text.Lazy qualified as LT -import Galley.Env +import Galley.Env hiding (brig) import Galley.Monad import Galley.Options import Imports hiding (log) @@ -51,14 +52,14 @@ componentName Gundeck = "gundeck" componentRequest :: IntraComponent -> Opts -> Request -> Request componentRequest Brig o = - host (encodeUtf8 (o ^. optBrig . epHost)) - . port (portNumber (fromIntegral (o ^. optBrig . epPort))) + B.host (encodeUtf8 (o ^. brig . host)) + . B.port (portNumber (fromIntegral (o ^. brig . port))) componentRequest Spar o = - host (encodeUtf8 (o ^. optSpar . epHost)) - . port (portNumber (fromIntegral (o ^. optSpar . epPort))) + B.host (encodeUtf8 (o ^. spar . host)) + . B.port (portNumber (fromIntegral (o ^. spar . port))) componentRequest Gundeck o = - host (encodeUtf8 $ o ^. optGundeck . epHost) - . port (portNumber $ fromIntegral (o ^. optGundeck . epPort)) + B.host (encodeUtf8 $ o ^. gundeck . host) + . B.port (portNumber $ fromIntegral (o ^. gundeck . port)) . method POST . path "/i/push/v2" . expect2xx diff --git a/services/galley/src/Galley/Keys.hs b/services/galley/src/Galley/Keys.hs index 0023ad9130a..7614ed9fc9f 100644 --- a/services/galley/src/Galley/Keys.hs +++ b/services/galley/src/Galley/Keys.hs @@ -33,6 +33,7 @@ import Data.Map qualified as Map import Data.PEM import Data.X509 import Imports +import Wire.API.MLS.CipherSuite import Wire.API.MLS.Credential import Wire.API.MLS.Keys diff --git a/services/galley/src/Galley/Options.hs b/services/galley/src/Galley/Options.hs index 584282051b7..df7d4296868 100644 --- a/services/galley/src/Galley/Options.hs +++ b/services/galley/src/Galley/Options.hs @@ -19,39 +19,40 @@ module Galley.Options ( Settings, - setHttpPoolSize, - setMaxTeamSize, - setMaxFanoutSize, - setExposeInvitationURLsTeamAllowlist, - setMaxConvSize, - setIntraListing, - setDisabledAPIVersions, - setConversationCodeURI, - setConcurrentDeletionEvents, - setDeleteConvThrottleMillis, - setFederationDomain, - setMlsPrivateKeyPaths, - setFeatureFlags, + httpPoolSize, + maxTeamSize, + maxFanoutSize, + exposeInvitationURLsTeamAllowlist, + maxConvSize, + intraListing, + disabledAPIVersions, + conversationCodeURI, + multiIngress, + concurrentDeletionEvents, + deleteConvThrottleMillis, + federationDomain, + mlsPrivateKeyPaths, + featureFlags, defConcurrentDeletionEvents, defDeleteConvThrottleMillis, defFanoutLimit, JournalOpts (JournalOpts), - awsQueueName, - awsEndpoint, + queueName, + endpoint, Opts, - optGalley, - optCassandra, - optBrig, - optGundeck, - optSpar, - optFederator, - optRabbitmq, - optDiscoUrl, - optSettings, - optJournal, - optLogLevel, - optLogNetStrings, - optLogFormat, + galley, + cassandra, + brig, + gundeck, + spar, + federator, + rabbitmq, + discoUrl, + settings, + journal, + logLevel, + logNetStrings, + logFormat, ) where @@ -66,35 +67,47 @@ import Galley.Types.Teams import Imports import Network.AMQP.Extended import System.Logger.Extended (Level, LogFormat) -import Util.Options +import Util.Options hiding (endpoint) import Util.Options.Common import Wire.API.Routes.Version import Wire.API.Team.Member data Settings = Settings { -- | Number of connections for the HTTP client pool - _setHttpPoolSize :: !Int, + _httpPoolSize :: !Int, -- | Max number of members in a team. NOTE: This must be in sync with Brig - _setMaxTeamSize :: !Word32, + _maxTeamSize :: !Word32, -- | Max number of team members users to fanout events to. For teams larger than -- this value, team events and user updates will no longer be sent to team users. -- This defaults to setMaxTeamSize and cannot be > HardTruncationLimit. Useful -- to tune mainly for testing purposes. - _setMaxFanoutSize :: !(Maybe (Range 1 HardTruncationLimit Int32)), + _maxFanoutSize :: !(Maybe (Range 1 HardTruncationLimit Int32)), -- | List of teams for which the invitation URL can be added to the list of all -- invitations retrievable by team admins. See also: -- 'ExposeInvitationURLsToTeamAdminConfig'. - _setExposeInvitationURLsTeamAllowlist :: !(Maybe [TeamId]), + _exposeInvitationURLsTeamAllowlist :: !(Maybe [TeamId]), -- | Max number of members in a conversation. NOTE: This must be in sync with Brig - _setMaxConvSize :: !Word16, + _maxConvSize :: !Word16, -- | Whether to call Brig for device listing - _setIntraListing :: !Bool, + _intraListing :: !Bool, -- | URI prefix for conversations with access mode @code@ - _setConversationCodeURI :: !HttpsUrl, + _conversationCodeURI :: !(Maybe HttpsUrl), + -- | Map from @Z-Host@ header to URI prefix for conversations with access mode @code@ + -- + -- If setMultiIngress is set then the URI prefix for guest links is looked + -- up in this config setting using the @Z-Host@ header value as a key. If + -- the lookup fails then no guest link can be created via the API. + -- + -- This option is only useful in the context of multi-ingress setups where + -- one backend / deployment is is reachable under several domains. + -- + -- multiIngress and conversationCodeURI are mutually exclusive. One of + -- both options need to be configured. + _multiIngress :: Maybe (Map Text HttpsUrl), -- | Throttling: limits to concurrent deletion events - _setConcurrentDeletionEvents :: !(Maybe Int), + _concurrentDeletionEvents :: !(Maybe Int), -- | Throttling: delay between sending events upon team deletion - _setDeleteConvThrottleMillis :: !(Maybe Int), + _deleteConvThrottleMillis :: !(Maybe Int), -- | FederationDomain is required, even when not wanting to federate with other backends -- (in that case the 'allowedDomains' can be set to empty in Federator) -- Federation domain is used to qualify local IDs and handles, @@ -107,16 +120,16 @@ data Settings = Settings -- allowedDomains: -- - wire.com -- - example.com - _setFederationDomain :: !Domain, + _federationDomain :: !Domain, -- | When true, galley will assume data in `billing_team_member` table is -- consistent and use it for billing. -- When false, billing information for large teams is not guaranteed to have all -- the owners. -- Defaults to false. - _setMlsPrivateKeyPaths :: !(Maybe MLSPrivateKeyPaths), + _mlsPrivateKeyPaths :: !(Maybe MLSPrivateKeyPaths), -- | FUTUREWORK: 'setFeatureFlags' should be renamed to 'setFeatureConfigs' in all types. - _setFeatureFlags :: !FeatureFlags, - _setDisabledAPIVersions :: Maybe (Set Version) + _featureFlags :: !FeatureFlags, + _disabledAPIVersions :: Maybe (Set Version) } deriving (Show, Generic) @@ -135,9 +148,9 @@ defFanoutLimit = unsafeRange hardTruncationLimit data JournalOpts = JournalOpts { -- | SQS queue name to send team events - _awsQueueName :: !Text, + _queueName :: !Text, -- | AWS endpoint - _awsEndpoint :: !AWSEndpoint + _endpoint :: !AWSEndpoint } deriving (Show, Generic) @@ -147,34 +160,34 @@ makeLenses ''JournalOpts data Opts = Opts { -- | Host and port to bind to - _optGalley :: !Endpoint, + _galley :: !Endpoint, -- | Cassandra settings - _optCassandra :: !CassandraOpts, + _cassandra :: !CassandraOpts, -- | Brig endpoint - _optBrig :: !Endpoint, + _brig :: !Endpoint, -- | Gundeck endpoint - _optGundeck :: !Endpoint, + _gundeck :: !Endpoint, -- | Spar endpoint - _optSpar :: !Endpoint, + _spar :: !Endpoint, -- | Federator endpoint - _optFederator :: !(Maybe Endpoint), + _federator :: !(Maybe Endpoint), -- | RabbitMQ settings, required when federation is enabled. - _optRabbitmq :: !(Maybe RabbitMqOpts), + _rabbitmq :: !(Maybe RabbitMqOpts), -- | Disco URL - _optDiscoUrl :: !(Maybe Text), + _discoUrl :: !(Maybe Text), -- | Other settings - _optSettings :: !Settings, + _settings :: !Settings, -- | Journaling options ('Nothing' -- disables journaling) -- Logging - _optJournal :: !(Maybe JournalOpts), + _journal :: !(Maybe JournalOpts), -- | Log level (Debug, Info, etc) - _optLogLevel :: !Level, + _logLevel :: !Level, -- | Use netstrings encoding -- - _optLogNetStrings :: !(Maybe (Last Bool)), + _logNetStrings :: !(Maybe (Last Bool)), -- | What log format to use - _optLogFormat :: !(Maybe (Last LogFormat)) + _logFormat :: !(Maybe (Last LogFormat)) } deriveFromJSON toOptionFieldName ''Opts diff --git a/services/galley/src/Galley/Run.hs b/services/galley/src/Galley/Run.hs index 88c92783ba5..60924f451f8 100644 --- a/services/galley/src/Galley/Run.hs +++ b/services/galley/src/Galley/Run.hs @@ -63,18 +63,18 @@ import System.Logger qualified as Log import System.Logger.Extended (mkLogger) import Util.Options import Wire.API.Routes.API -import Wire.API.Routes.Public.Galley qualified as GalleyAPI +import Wire.API.Routes.Public.Galley import Wire.API.Routes.Version.Wai run :: Opts -> IO () run opts = lowerCodensity $ do (app, env) <- mkApp opts - settings <- + settings' <- lift $ newSettings $ defaultServer - (unpack $ opts ^. optGalley . epHost) - (portNumber $ fromIntegral $ opts ^. optGalley . epPort) + (unpack $ opts ^. galley . host) + (portNumber $ fromIntegral $ opts ^. galley . port) (env ^. App.applog) (env ^. monitor) @@ -83,17 +83,17 @@ run opts = lowerCodensity $ do void $ Codensity $ Async.withAsync $ runApp env deleteLoop void $ Codensity $ Async.withAsync $ runApp env refreshMetrics - lift $ finally (runSettingsWithShutdown settings app Nothing) (closeApp env) + lift $ finally (runSettingsWithShutdown settings' app Nothing) (closeApp env) mkApp :: Opts -> Codensity IO (Application, Env) mkApp opts = do - logger <- lift $ mkLogger (opts ^. optLogLevel) (opts ^. optLogNetStrings) (opts ^. optLogFormat) + logger <- lift $ mkLogger (opts ^. logLevel) (opts ^. logNetStrings) (opts ^. logFormat) metrics <- lift $ M.metrics env <- lift $ App.createEnv metrics opts logger lift $ runClient (env ^. cstate) $ versionCheck schemaVersion let middlewares = - versionMiddleware (opts ^. optSettings . setDisabledAPIVersions . traverse) + versionMiddleware (opts ^. settings . disabledAPIVersions . traverse) . servantPlusWAIPrometheusMiddleware API.sitemap (Proxy @CombinedAPI) . GZip.gunzip . GZip.gzip GZip.def @@ -111,7 +111,7 @@ mkApp opts = let e = reqId .~ lookupReqId r $ e0 in Servant.serveWithContext (Proxy @CombinedAPI) - ( view (options . optSettings . setFederationDomain) e + ( view (options . settings . federationDomain) e :. customFormatters :. Servant.EmptyContext ) @@ -151,7 +151,7 @@ bodyParserErrorFormatter' _ _ errMsg = } type CombinedAPI = - GalleyAPI.ServantAPI + GalleyAPI :<|> InternalAPI :<|> FederationAPI :<|> Servant.Raw diff --git a/services/galley/src/Galley/Schema/Run.hs b/services/galley/src/Galley/Schema/Run.hs new file mode 100644 index 00000000000..0c34f67d870 --- /dev/null +++ b/services/galley/src/Galley/Schema/Run.hs @@ -0,0 +1,189 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.Schema.Run where + +import Cassandra.Schema +import Control.Exception (finally) +import Galley.Schema.V20 qualified as V20 +import Galley.Schema.V21 qualified as V21 +import Galley.Schema.V22 qualified as V22 +import Galley.Schema.V23 qualified as V23 +import Galley.Schema.V24 qualified as V24 +import Galley.Schema.V25 qualified as V25 +import Galley.Schema.V26 qualified as V26 +import Galley.Schema.V27 qualified as V27 +import Galley.Schema.V28 qualified as V28 +import Galley.Schema.V29 qualified as V29 +import Galley.Schema.V30 qualified as V30 +import Galley.Schema.V31 qualified as V31 +import Galley.Schema.V32 qualified as V32 +import Galley.Schema.V33 qualified as V33 +import Galley.Schema.V34 qualified as V34 +import Galley.Schema.V35 qualified as V35 +import Galley.Schema.V36 qualified as V36 +import Galley.Schema.V37 qualified as V37 +import Galley.Schema.V38_CreateTableBillingTeamMember qualified as V38_CreateTableBillingTeamMember +import Galley.Schema.V39 qualified as V39 +import Galley.Schema.V40_CreateTableDataMigration qualified as V40_CreateTableDataMigration +import Galley.Schema.V41_TeamNotificationQueue qualified as V41_TeamNotificationQueue +import Galley.Schema.V42_TeamFeatureValidateSamlEmails qualified as V42_TeamFeatureValidateSamlEmails +import Galley.Schema.V43_TeamFeatureDigitalSignatures qualified as V43_TeamFeatureDigitalSignatures +import Galley.Schema.V44_AddRemoteIdentifiers qualified as V44_AddRemoteIdentifiers +import Galley.Schema.V45_AddFederationIdMapping qualified as V45_AddFederationIdMapping +import Galley.Schema.V46_TeamFeatureAppLock qualified as V46_TeamFeatureAppLock +import Galley.Schema.V47_RemoveFederationIdMapping qualified as V47_RemoveFederationIdMapping +import Galley.Schema.V48_DeleteRemoteIdentifiers qualified as V48_DeleteRemoteIdentifiers +import Galley.Schema.V49_ReAddRemoteIdentifiers qualified as V49_ReAddRemoteIdentifiers +import Galley.Schema.V50_AddLegalholdWhitelisted qualified as V50_AddLegalholdWhitelisted +import Galley.Schema.V51_FeatureFileSharing qualified as V51_FeatureFileSharing +import Galley.Schema.V52_FeatureConferenceCalling qualified as V52_FeatureConferenceCalling +import Galley.Schema.V53_AddRemoteConvStatus qualified as V53_AddRemoteConvStatus +import Galley.Schema.V54_TeamFeatureSelfDeletingMessages qualified as V54_TeamFeatureSelfDeletingMessages +import Galley.Schema.V55_SelfDeletingMessagesLockStatus qualified as V55_SelfDeletingMessagesLockStatus +import Galley.Schema.V56_GuestLinksTeamFeatureStatus qualified as V56_GuestLinksTeamFeatureStatus +import Galley.Schema.V57_GuestLinksLockStatus qualified as V57_GuestLinksLockStatus +import Galley.Schema.V58_ConversationAccessRoleV2 qualified as V58_ConversationAccessRoleV2 +import Galley.Schema.V59_FileSharingLockStatus qualified as V59_FileSharingLockStatus +import Galley.Schema.V60_TeamFeatureSndFactorPasswordChallenge qualified as V60_TeamFeatureSndFactorPasswordChallenge +import Galley.Schema.V61_MLSConversation qualified as V61_MLSConversation +import Galley.Schema.V62_TeamFeatureSearchVisibilityInbound qualified as V62_TeamFeatureSearchVisibilityInbound +import Galley.Schema.V63_MLSConversationClients qualified as V63_MLSConversationClients +import Galley.Schema.V64_Epoch qualified as V64_Epoch +import Galley.Schema.V65_MLSRemoteClients qualified as V65_MLSRemoteClients +import Galley.Schema.V66_AddSplashScreen qualified as V66_AddSplashScreen +import Galley.Schema.V67_MLSFeature qualified as V67_MLSFeature +import Galley.Schema.V68_MLSCommitLock qualified as V68_MLSCommitLock +import Galley.Schema.V69_MLSProposal qualified as V69_MLSProposal +import Galley.Schema.V70_MLSCipherSuite qualified as V70_MLSCipherSuite +import Galley.Schema.V71_MemberClientKeypackage qualified as V71_MemberClientKeypackage +import Galley.Schema.V72_DropManagedConversations qualified as V72_DropManagedConversations +import Galley.Schema.V73_MemberClientTable qualified as V73_MemberClientTable +import Galley.Schema.V74_ExposeInvitationsToTeamAdmin qualified as V74_ExposeInvitationsToTeamAdmin +import Galley.Schema.V75_MLSGroupInfo qualified as V75_MLSGroupInfo +import Galley.Schema.V76_ProposalOrigin qualified as V76_ProposalOrigin +import Galley.Schema.V77_MLSGroupMemberClient qualified as V77_MLSGroupMemberClient +import Galley.Schema.V78_TeamFeatureOutlookCalIntegration qualified as V78_TeamFeatureOutlookCalIntegration +import Galley.Schema.V79_TeamFeatureMlsE2EId qualified as V79_TeamFeatureMlsE2EId +import Galley.Schema.V80_AddConversationCodePassword qualified as V80_AddConversationCodePassword +import Galley.Schema.V81_TeamFeatureMlsE2EIdUpdate qualified as V81_TeamFeatureMlsE2EIdUpdate +import Galley.Schema.V82_RemoteDomainIndexes qualified as V82_RemoteDomainIndexes +import Galley.Schema.V83_CreateTableTeamAdmin qualified as V83_CreateTableTeamAdmin +import Galley.Schema.V84_MLSSubconversation qualified as V84_MLSSubconversation +import Galley.Schema.V85_MLSDraft17 qualified as V85_MLSDraft17 +import Galley.Schema.V86_TeamFeatureMlsMigration qualified as V86_TeamFeatureMlsMigration +import Galley.Schema.V87_TeamFeatureSupportedProtocols qualified as V87_TeamFeatureSupportedProtocols +import Galley.Schema.V88_TruncateMLSGroupMemberClient qualified as V88_TruncateMLSGroupMemberClient +import Galley.Schema.V89_RemoveMemberClient qualified as V89_RemoveMemberClient +import Imports +import Options.Applicative +import System.Logger.Extended qualified as Log + +main :: IO () +main = do + o <- execParser (info (helper <*> migrationOptsParser) desc) + l <- Log.mkLogger' + migrateSchema + l + o + migrations + `finally` Log.close l + where + desc = header "Galley Cassandra Schema" <> fullDesc + +lastSchemaVersion :: Int32 +lastSchemaVersion = migVersion $ last migrations + +migrations :: [Migration] +migrations = + [ V20.migration, + V21.migration, + V22.migration, + V23.migration, + V24.migration, + V25.migration, + V26.migration, + V27.migration, + V28.migration, + V29.migration, + V30.migration, + V31.migration, + V32.migration, + V33.migration, + V34.migration, + V35.migration, + V36.migration, + V37.migration, + V38_CreateTableBillingTeamMember.migration, + V39.migration, + V40_CreateTableDataMigration.migration, + V41_TeamNotificationQueue.migration, + V42_TeamFeatureValidateSamlEmails.migration, + V43_TeamFeatureDigitalSignatures.migration, + V44_AddRemoteIdentifiers.migration, + V45_AddFederationIdMapping.migration, + V46_TeamFeatureAppLock.migration, + V47_RemoveFederationIdMapping.migration, + V48_DeleteRemoteIdentifiers.migration, + V49_ReAddRemoteIdentifiers.migration, + V50_AddLegalholdWhitelisted.migration, + V51_FeatureFileSharing.migration, + V52_FeatureConferenceCalling.migration, + V53_AddRemoteConvStatus.migration, + V54_TeamFeatureSelfDeletingMessages.migration, + V55_SelfDeletingMessagesLockStatus.migration, + V56_GuestLinksTeamFeatureStatus.migration, + V57_GuestLinksLockStatus.migration, + V58_ConversationAccessRoleV2.migration, + V59_FileSharingLockStatus.migration, + V60_TeamFeatureSndFactorPasswordChallenge.migration, + V61_MLSConversation.migration, + V62_TeamFeatureSearchVisibilityInbound.migration, + V63_MLSConversationClients.migration, + V64_Epoch.migration, + V65_MLSRemoteClients.migration, + V66_AddSplashScreen.migration, + V67_MLSFeature.migration, + V68_MLSCommitLock.migration, + V69_MLSProposal.migration, + V70_MLSCipherSuite.migration, + V71_MemberClientKeypackage.migration, + V72_DropManagedConversations.migration, + V73_MemberClientTable.migration, + V74_ExposeInvitationsToTeamAdmin.migration, + V75_MLSGroupInfo.migration, + V76_ProposalOrigin.migration, + V77_MLSGroupMemberClient.migration, + V78_TeamFeatureOutlookCalIntegration.migration, + V79_TeamFeatureMlsE2EId.migration, + V80_AddConversationCodePassword.migration, + V81_TeamFeatureMlsE2EIdUpdate.migration, + V82_RemoteDomainIndexes.migration, + V83_CreateTableTeamAdmin.migration, + V84_MLSSubconversation.migration, + V85_MLSDraft17.migration, + V86_TeamFeatureMlsMigration.migration, + V87_TeamFeatureSupportedProtocols.migration, + V88_TruncateMLSGroupMemberClient.migration, + V89_RemoveMemberClient.migration + -- FUTUREWORK: once #1726 has made its way to master/production, + -- the 'message' field in connections table can be dropped. + -- See also https://github.com/wireapp/wire-server/pull/1747/files + -- for an explanation + -- FUTUREWORK: once #1751 has made its way to master/production, + -- the 'otr_muted' field in the member table can be dropped. + ] diff --git a/services/galley/schema/src/V20.hs b/services/galley/src/Galley/Schema/V20.hs similarity index 99% rename from services/galley/schema/src/V20.hs rename to services/galley/src/Galley/Schema/V20.hs index a7a0432e58d..2e7db8dd359 100644 --- a/services/galley/schema/src/V20.hs +++ b/services/galley/src/Galley/Schema/V20.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V20 +module Galley.Schema.V20 ( migration, ) where diff --git a/services/galley/schema/src/V21.hs b/services/galley/src/Galley/Schema/V21.hs similarity index 99% rename from services/galley/schema/src/V21.hs rename to services/galley/src/Galley/Schema/V21.hs index 73c38cd9c58..0e9160fa550 100644 --- a/services/galley/schema/src/V21.hs +++ b/services/galley/src/Galley/Schema/V21.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V21 +module Galley.Schema.V21 ( migration, ) where diff --git a/services/galley/schema/src/V22.hs b/services/galley/src/Galley/Schema/V22.hs similarity index 97% rename from services/galley/schema/src/V22.hs rename to services/galley/src/Galley/Schema/V22.hs index fcf8b5a7a7f..313de020a1a 100644 --- a/services/galley/schema/src/V22.hs +++ b/services/galley/src/Galley/Schema/V22.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V22 +module Galley.Schema.V22 ( migration, ) where diff --git a/services/galley/schema/src/V23.hs b/services/galley/src/Galley/Schema/V23.hs similarity index 97% rename from services/galley/schema/src/V23.hs rename to services/galley/src/Galley/Schema/V23.hs index 364f1e801de..2eed13f3c30 100644 --- a/services/galley/schema/src/V23.hs +++ b/services/galley/src/Galley/Schema/V23.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V23 +module Galley.Schema.V23 ( migration, ) where diff --git a/services/galley/schema/src/V24.hs b/services/galley/src/Galley/Schema/V24.hs similarity index 97% rename from services/galley/schema/src/V24.hs rename to services/galley/src/Galley/Schema/V24.hs index c07a7732b83..7ace99b3b71 100644 --- a/services/galley/schema/src/V24.hs +++ b/services/galley/src/Galley/Schema/V24.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V24 +module Galley.Schema.V24 ( migration, ) where diff --git a/services/galley/schema/src/V25.hs b/services/galley/src/Galley/Schema/V25.hs similarity index 98% rename from services/galley/schema/src/V25.hs rename to services/galley/src/Galley/Schema/V25.hs index 2dbec4d4268..420afffec0a 100644 --- a/services/galley/schema/src/V25.hs +++ b/services/galley/src/Galley/Schema/V25.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V25 +module Galley.Schema.V25 ( migration, ) where diff --git a/services/galley/schema/src/V26.hs b/services/galley/src/Galley/Schema/V26.hs similarity index 97% rename from services/galley/schema/src/V26.hs rename to services/galley/src/Galley/Schema/V26.hs index 0e3e89b0ff4..8dc98b98fa1 100644 --- a/services/galley/schema/src/V26.hs +++ b/services/galley/src/Galley/Schema/V26.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V26 +module Galley.Schema.V26 ( migration, ) where diff --git a/services/galley/schema/src/V27.hs b/services/galley/src/Galley/Schema/V27.hs similarity index 97% rename from services/galley/schema/src/V27.hs rename to services/galley/src/Galley/Schema/V27.hs index 178c774cd94..096f210ed6e 100644 --- a/services/galley/schema/src/V27.hs +++ b/services/galley/src/Galley/Schema/V27.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V27 +module Galley.Schema.V27 ( migration, ) where diff --git a/services/galley/schema/src/V28.hs b/services/galley/src/Galley/Schema/V28.hs similarity index 97% rename from services/galley/schema/src/V28.hs rename to services/galley/src/Galley/Schema/V28.hs index 5e125e44d0a..959dee20c7f 100644 --- a/services/galley/schema/src/V28.hs +++ b/services/galley/src/Galley/Schema/V28.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V28 +module Galley.Schema.V28 ( migration, ) where diff --git a/services/galley/schema/src/V29.hs b/services/galley/src/Galley/Schema/V29.hs similarity index 97% rename from services/galley/schema/src/V29.hs rename to services/galley/src/Galley/Schema/V29.hs index b3bbfebac3b..a30664fdc2f 100644 --- a/services/galley/schema/src/V29.hs +++ b/services/galley/src/Galley/Schema/V29.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V29 +module Galley.Schema.V29 ( migration, ) where diff --git a/services/galley/schema/src/V30.hs b/services/galley/src/Galley/Schema/V30.hs similarity index 97% rename from services/galley/schema/src/V30.hs rename to services/galley/src/Galley/Schema/V30.hs index b11c8d3ee24..83f0ce3e372 100644 --- a/services/galley/schema/src/V30.hs +++ b/services/galley/src/Galley/Schema/V30.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V30 +module Galley.Schema.V30 ( migration, ) where diff --git a/services/galley/schema/src/V31.hs b/services/galley/src/Galley/Schema/V31.hs similarity index 98% rename from services/galley/schema/src/V31.hs rename to services/galley/src/Galley/Schema/V31.hs index b41a320f609..6c63e4ee175 100644 --- a/services/galley/schema/src/V31.hs +++ b/services/galley/src/Galley/Schema/V31.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V31 +module Galley.Schema.V31 ( migration, ) where diff --git a/services/galley/schema/src/V32.hs b/services/galley/src/Galley/Schema/V32.hs similarity index 97% rename from services/galley/schema/src/V32.hs rename to services/galley/src/Galley/Schema/V32.hs index 9ecccedcde0..21c23ac466e 100644 --- a/services/galley/schema/src/V32.hs +++ b/services/galley/src/Galley/Schema/V32.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V32 +module Galley.Schema.V32 ( migration, ) where diff --git a/services/galley/schema/src/V33.hs b/services/galley/src/Galley/Schema/V33.hs similarity index 98% rename from services/galley/schema/src/V33.hs rename to services/galley/src/Galley/Schema/V33.hs index 9f97a7af4a0..1445f5c4039 100644 --- a/services/galley/schema/src/V33.hs +++ b/services/galley/src/Galley/Schema/V33.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V33 +module Galley.Schema.V33 ( migration, ) where diff --git a/services/galley/schema/src/V34.hs b/services/galley/src/Galley/Schema/V34.hs similarity index 98% rename from services/galley/schema/src/V34.hs rename to services/galley/src/Galley/Schema/V34.hs index 57d98b84773..b87b5427898 100644 --- a/services/galley/schema/src/V34.hs +++ b/services/galley/src/Galley/Schema/V34.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V34 +module Galley.Schema.V34 ( migration, ) where diff --git a/services/galley/schema/src/V35.hs b/services/galley/src/Galley/Schema/V35.hs similarity index 97% rename from services/galley/schema/src/V35.hs rename to services/galley/src/Galley/Schema/V35.hs index 5b42b57fa2e..6b397865f69 100644 --- a/services/galley/schema/src/V35.hs +++ b/services/galley/src/Galley/Schema/V35.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V35 +module Galley.Schema.V35 ( migration, ) where diff --git a/services/galley/schema/src/V36.hs b/services/galley/src/Galley/Schema/V36.hs similarity index 97% rename from services/galley/schema/src/V36.hs rename to services/galley/src/Galley/Schema/V36.hs index 82c9c046993..19f893134e5 100644 --- a/services/galley/schema/src/V36.hs +++ b/services/galley/src/Galley/Schema/V36.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V36 +module Galley.Schema.V36 ( migration, ) where diff --git a/services/galley/schema/src/V37.hs b/services/galley/src/Galley/Schema/V37.hs similarity index 97% rename from services/galley/schema/src/V37.hs rename to services/galley/src/Galley/Schema/V37.hs index 214f1ef4e79..295b507bd29 100644 --- a/services/galley/schema/src/V37.hs +++ b/services/galley/src/Galley/Schema/V37.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V37 +module Galley.Schema.V37 ( migration, ) where diff --git a/services/galley/schema/src/V38_CreateTableBillingTeamMember.hs b/services/galley/src/Galley/Schema/V38_CreateTableBillingTeamMember.hs similarity index 95% rename from services/galley/schema/src/V38_CreateTableBillingTeamMember.hs rename to services/galley/src/Galley/Schema/V38_CreateTableBillingTeamMember.hs index eb03f182c34..2b28f6b418c 100644 --- a/services/galley/schema/src/V38_CreateTableBillingTeamMember.hs +++ b/services/galley/src/Galley/Schema/V38_CreateTableBillingTeamMember.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V38_CreateTableBillingTeamMember +module Galley.Schema.V38_CreateTableBillingTeamMember ( migration, ) where diff --git a/services/galley/schema/src/V39.hs b/services/galley/src/Galley/Schema/V39.hs similarity index 97% rename from services/galley/schema/src/V39.hs rename to services/galley/src/Galley/Schema/V39.hs index a0e6d85bb2c..66ff7bd5ed0 100644 --- a/services/galley/schema/src/V39.hs +++ b/services/galley/src/Galley/Schema/V39.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V39 +module Galley.Schema.V39 ( migration, ) where diff --git a/services/galley/schema/src/V40_CreateTableDataMigration.hs b/services/galley/src/Galley/Schema/V40_CreateTableDataMigration.hs similarity index 94% rename from services/galley/schema/src/V40_CreateTableDataMigration.hs rename to services/galley/src/Galley/Schema/V40_CreateTableDataMigration.hs index 7d3c0a1f6bc..cef66944912 100644 --- a/services/galley/schema/src/V40_CreateTableDataMigration.hs +++ b/services/galley/src/Galley/Schema/V40_CreateTableDataMigration.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V40_CreateTableDataMigration (migration) where +module Galley.Schema.V40_CreateTableDataMigration (migration) where import Cassandra.Schema import Imports diff --git a/services/galley/schema/src/V41_TeamNotificationQueue.hs b/services/galley/src/Galley/Schema/V41_TeamNotificationQueue.hs similarity index 96% rename from services/galley/schema/src/V41_TeamNotificationQueue.hs rename to services/galley/src/Galley/Schema/V41_TeamNotificationQueue.hs index cde9f8c0935..4a195d1a90e 100644 --- a/services/galley/schema/src/V41_TeamNotificationQueue.hs +++ b/services/galley/src/Galley/Schema/V41_TeamNotificationQueue.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V41_TeamNotificationQueue +module Galley.Schema.V41_TeamNotificationQueue ( migration, ) where diff --git a/services/galley/schema/src/V42_TeamFeatureValidateSamlEmails.hs b/services/galley/src/Galley/Schema/V42_TeamFeatureValidateSamlEmails.hs similarity index 95% rename from services/galley/schema/src/V42_TeamFeatureValidateSamlEmails.hs rename to services/galley/src/Galley/Schema/V42_TeamFeatureValidateSamlEmails.hs index 2232fdeffef..3c5da6ca73a 100644 --- a/services/galley/schema/src/V42_TeamFeatureValidateSamlEmails.hs +++ b/services/galley/src/Galley/Schema/V42_TeamFeatureValidateSamlEmails.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V42_TeamFeatureValidateSamlEmails +module Galley.Schema.V42_TeamFeatureValidateSamlEmails ( migration, ) where diff --git a/services/galley/schema/src/V43_TeamFeatureDigitalSignatures.hs b/services/galley/src/Galley/Schema/V43_TeamFeatureDigitalSignatures.hs similarity index 95% rename from services/galley/schema/src/V43_TeamFeatureDigitalSignatures.hs rename to services/galley/src/Galley/Schema/V43_TeamFeatureDigitalSignatures.hs index 701396655aa..d938a803c66 100644 --- a/services/galley/schema/src/V43_TeamFeatureDigitalSignatures.hs +++ b/services/galley/src/Galley/Schema/V43_TeamFeatureDigitalSignatures.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V43_TeamFeatureDigitalSignatures +module Galley.Schema.V43_TeamFeatureDigitalSignatures ( migration, ) where diff --git a/services/galley/schema/src/V44_AddRemoteIdentifiers.hs b/services/galley/src/Galley/Schema/V44_AddRemoteIdentifiers.hs similarity index 96% rename from services/galley/schema/src/V44_AddRemoteIdentifiers.hs rename to services/galley/src/Galley/Schema/V44_AddRemoteIdentifiers.hs index 07eae794270..b4130f9129b 100644 --- a/services/galley/schema/src/V44_AddRemoteIdentifiers.hs +++ b/services/galley/src/Galley/Schema/V44_AddRemoteIdentifiers.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V44_AddRemoteIdentifiers (migration) where +module Galley.Schema.V44_AddRemoteIdentifiers (migration) where import Cassandra.Schema import Imports diff --git a/services/galley/schema/src/V45_AddFederationIdMapping.hs b/services/galley/src/Galley/Schema/V45_AddFederationIdMapping.hs similarity index 96% rename from services/galley/schema/src/V45_AddFederationIdMapping.hs rename to services/galley/src/Galley/Schema/V45_AddFederationIdMapping.hs index 842aaae4144..0dac30d1ab7 100644 --- a/services/galley/schema/src/V45_AddFederationIdMapping.hs +++ b/services/galley/src/Galley/Schema/V45_AddFederationIdMapping.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V45_AddFederationIdMapping +module Galley.Schema.V45_AddFederationIdMapping ( migration, ) where diff --git a/services/galley/schema/src/V46_TeamFeatureAppLock.hs b/services/galley/src/Galley/Schema/V46_TeamFeatureAppLock.hs similarity index 96% rename from services/galley/schema/src/V46_TeamFeatureAppLock.hs rename to services/galley/src/Galley/Schema/V46_TeamFeatureAppLock.hs index b8ec3c03f81..20e7f98bc22 100644 --- a/services/galley/schema/src/V46_TeamFeatureAppLock.hs +++ b/services/galley/src/Galley/Schema/V46_TeamFeatureAppLock.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V46_TeamFeatureAppLock +module Galley.Schema.V46_TeamFeatureAppLock ( migration, ) where diff --git a/services/galley/schema/src/V47_RemoveFederationIdMapping.hs b/services/galley/src/Galley/Schema/V47_RemoveFederationIdMapping.hs similarity index 95% rename from services/galley/schema/src/V47_RemoveFederationIdMapping.hs rename to services/galley/src/Galley/Schema/V47_RemoveFederationIdMapping.hs index 4f8e0b5a9e0..2cc5a6f530a 100644 --- a/services/galley/schema/src/V47_RemoveFederationIdMapping.hs +++ b/services/galley/src/Galley/Schema/V47_RemoveFederationIdMapping.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V47_RemoveFederationIdMapping +module Galley.Schema.V47_RemoveFederationIdMapping ( migration, ) where diff --git a/services/galley/schema/src/V48_DeleteRemoteIdentifiers.hs b/services/galley/src/Galley/Schema/V48_DeleteRemoteIdentifiers.hs similarity index 96% rename from services/galley/schema/src/V48_DeleteRemoteIdentifiers.hs rename to services/galley/src/Galley/Schema/V48_DeleteRemoteIdentifiers.hs index 3268147ffe8..22ff43ec591 100644 --- a/services/galley/schema/src/V48_DeleteRemoteIdentifiers.hs +++ b/services/galley/src/Galley/Schema/V48_DeleteRemoteIdentifiers.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V48_DeleteRemoteIdentifiers +module Galley.Schema.V48_DeleteRemoteIdentifiers ( migration, ) where diff --git a/services/galley/schema/src/V49_ReAddRemoteIdentifiers.hs b/services/galley/src/Galley/Schema/V49_ReAddRemoteIdentifiers.hs similarity index 98% rename from services/galley/schema/src/V49_ReAddRemoteIdentifiers.hs rename to services/galley/src/Galley/Schema/V49_ReAddRemoteIdentifiers.hs index 91748953c54..946904ccad9 100644 --- a/services/galley/schema/src/V49_ReAddRemoteIdentifiers.hs +++ b/services/galley/src/Galley/Schema/V49_ReAddRemoteIdentifiers.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V49_ReAddRemoteIdentifiers +module Galley.Schema.V49_ReAddRemoteIdentifiers ( migration, ) where diff --git a/services/galley/schema/src/V50_AddLegalholdWhitelisted.hs b/services/galley/src/Galley/Schema/V50_AddLegalholdWhitelisted.hs similarity index 95% rename from services/galley/schema/src/V50_AddLegalholdWhitelisted.hs rename to services/galley/src/Galley/Schema/V50_AddLegalholdWhitelisted.hs index a7111849a06..dae85e18b5d 100644 --- a/services/galley/schema/src/V50_AddLegalholdWhitelisted.hs +++ b/services/galley/src/Galley/Schema/V50_AddLegalholdWhitelisted.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V50_AddLegalholdWhitelisted +module Galley.Schema.V50_AddLegalholdWhitelisted ( migration, ) where diff --git a/services/galley/schema/src/V51_FeatureFileSharing.hs b/services/galley/src/Galley/Schema/V51_FeatureFileSharing.hs similarity index 95% rename from services/galley/schema/src/V51_FeatureFileSharing.hs rename to services/galley/src/Galley/Schema/V51_FeatureFileSharing.hs index 734ef96280a..ae46aeda94d 100644 --- a/services/galley/schema/src/V51_FeatureFileSharing.hs +++ b/services/galley/src/Galley/Schema/V51_FeatureFileSharing.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V51_FeatureFileSharing +module Galley.Schema.V51_FeatureFileSharing ( migration, ) where diff --git a/services/galley/schema/src/V52_FeatureConferenceCalling.hs b/services/galley/src/Galley/Schema/V52_FeatureConferenceCalling.hs similarity index 95% rename from services/galley/schema/src/V52_FeatureConferenceCalling.hs rename to services/galley/src/Galley/Schema/V52_FeatureConferenceCalling.hs index 78e4d9d5f1a..a3c37a8eb52 100644 --- a/services/galley/schema/src/V52_FeatureConferenceCalling.hs +++ b/services/galley/src/Galley/Schema/V52_FeatureConferenceCalling.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V52_FeatureConferenceCalling +module Galley.Schema.V52_FeatureConferenceCalling ( migration, ) where diff --git a/services/galley/schema/src/V53_AddRemoteConvStatus.hs b/services/galley/src/Galley/Schema/V53_AddRemoteConvStatus.hs similarity index 95% rename from services/galley/schema/src/V53_AddRemoteConvStatus.hs rename to services/galley/src/Galley/Schema/V53_AddRemoteConvStatus.hs index 2db8a204b00..674a14273df 100644 --- a/services/galley/schema/src/V53_AddRemoteConvStatus.hs +++ b/services/galley/src/Galley/Schema/V53_AddRemoteConvStatus.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V53_AddRemoteConvStatus (migration) where +module Galley.Schema.V53_AddRemoteConvStatus (migration) where import Cassandra.Schema import Imports diff --git a/services/galley/schema/src/V54_TeamFeatureSelfDeletingMessages.hs b/services/galley/src/Galley/Schema/V54_TeamFeatureSelfDeletingMessages.hs similarity index 95% rename from services/galley/schema/src/V54_TeamFeatureSelfDeletingMessages.hs rename to services/galley/src/Galley/Schema/V54_TeamFeatureSelfDeletingMessages.hs index ab36ce3cb99..17bfa279795 100644 --- a/services/galley/schema/src/V54_TeamFeatureSelfDeletingMessages.hs +++ b/services/galley/src/Galley/Schema/V54_TeamFeatureSelfDeletingMessages.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V54_TeamFeatureSelfDeletingMessages +module Galley.Schema.V54_TeamFeatureSelfDeletingMessages ( migration, ) where diff --git a/services/galley/schema/src/V55_SelfDeletingMessagesLockStatus.hs b/services/galley/src/Galley/Schema/V55_SelfDeletingMessagesLockStatus.hs similarity index 95% rename from services/galley/schema/src/V55_SelfDeletingMessagesLockStatus.hs rename to services/galley/src/Galley/Schema/V55_SelfDeletingMessagesLockStatus.hs index 888fde95d81..12094c97e11 100644 --- a/services/galley/schema/src/V55_SelfDeletingMessagesLockStatus.hs +++ b/services/galley/src/Galley/Schema/V55_SelfDeletingMessagesLockStatus.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V55_SelfDeletingMessagesLockStatus +module Galley.Schema.V55_SelfDeletingMessagesLockStatus ( migration, ) where diff --git a/services/galley/schema/src/V56_GuestLinksTeamFeatureStatus.hs b/services/galley/src/Galley/Schema/V56_GuestLinksTeamFeatureStatus.hs similarity index 95% rename from services/galley/schema/src/V56_GuestLinksTeamFeatureStatus.hs rename to services/galley/src/Galley/Schema/V56_GuestLinksTeamFeatureStatus.hs index d8b07ed2764..d1341779ff3 100644 --- a/services/galley/schema/src/V56_GuestLinksTeamFeatureStatus.hs +++ b/services/galley/src/Galley/Schema/V56_GuestLinksTeamFeatureStatus.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V56_GuestLinksTeamFeatureStatus +module Galley.Schema.V56_GuestLinksTeamFeatureStatus ( migration, ) where diff --git a/services/galley/schema/src/V57_GuestLinksLockStatus.hs b/services/galley/src/Galley/Schema/V57_GuestLinksLockStatus.hs similarity index 95% rename from services/galley/schema/src/V57_GuestLinksLockStatus.hs rename to services/galley/src/Galley/Schema/V57_GuestLinksLockStatus.hs index f347e3de81e..385d4c31456 100644 --- a/services/galley/schema/src/V57_GuestLinksLockStatus.hs +++ b/services/galley/src/Galley/Schema/V57_GuestLinksLockStatus.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V57_GuestLinksLockStatus +module Galley.Schema.V57_GuestLinksLockStatus ( migration, ) where diff --git a/services/galley/schema/src/V58_ConversationAccessRoleV2.hs b/services/galley/src/Galley/Schema/V58_ConversationAccessRoleV2.hs similarity index 95% rename from services/galley/schema/src/V58_ConversationAccessRoleV2.hs rename to services/galley/src/Galley/Schema/V58_ConversationAccessRoleV2.hs index a477e9b152d..7c7e2f2c175 100644 --- a/services/galley/schema/src/V58_ConversationAccessRoleV2.hs +++ b/services/galley/src/Galley/Schema/V58_ConversationAccessRoleV2.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V58_ConversationAccessRoleV2 +module Galley.Schema.V58_ConversationAccessRoleV2 ( migration, ) where diff --git a/services/galley/schema/src/V59_FileSharingLockStatus.hs b/services/galley/src/Galley/Schema/V59_FileSharingLockStatus.hs similarity index 95% rename from services/galley/schema/src/V59_FileSharingLockStatus.hs rename to services/galley/src/Galley/Schema/V59_FileSharingLockStatus.hs index d1b83924828..2064df5c938 100644 --- a/services/galley/schema/src/V59_FileSharingLockStatus.hs +++ b/services/galley/src/Galley/Schema/V59_FileSharingLockStatus.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V59_FileSharingLockStatus +module Galley.Schema.V59_FileSharingLockStatus ( migration, ) where diff --git a/services/galley/schema/src/V60_TeamFeatureSndFactorPasswordChallenge.hs b/services/galley/src/Galley/Schema/V60_TeamFeatureSndFactorPasswordChallenge.hs similarity index 94% rename from services/galley/schema/src/V60_TeamFeatureSndFactorPasswordChallenge.hs rename to services/galley/src/Galley/Schema/V60_TeamFeatureSndFactorPasswordChallenge.hs index f71ab44871f..bcfdce48328 100644 --- a/services/galley/schema/src/V60_TeamFeatureSndFactorPasswordChallenge.hs +++ b/services/galley/src/Galley/Schema/V60_TeamFeatureSndFactorPasswordChallenge.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V60_TeamFeatureSndFactorPasswordChallenge +module Galley.Schema.V60_TeamFeatureSndFactorPasswordChallenge ( migration, ) where diff --git a/services/galley/schema/src/V61_MLSConversation.hs b/services/galley/src/Galley/Schema/V61_MLSConversation.hs similarity index 96% rename from services/galley/schema/src/V61_MLSConversation.hs rename to services/galley/src/Galley/Schema/V61_MLSConversation.hs index 7d7c06af66a..97673bdf309 100644 --- a/services/galley/schema/src/V61_MLSConversation.hs +++ b/services/galley/src/Galley/Schema/V61_MLSConversation.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V61_MLSConversation +module Galley.Schema.V61_MLSConversation ( migration, ) where diff --git a/services/galley/schema/src/V62_TeamFeatureSearchVisibilityInbound.hs b/services/galley/src/Galley/Schema/V62_TeamFeatureSearchVisibilityInbound.hs similarity index 94% rename from services/galley/schema/src/V62_TeamFeatureSearchVisibilityInbound.hs rename to services/galley/src/Galley/Schema/V62_TeamFeatureSearchVisibilityInbound.hs index c2112899ca7..6bc46dd5cbf 100644 --- a/services/galley/schema/src/V62_TeamFeatureSearchVisibilityInbound.hs +++ b/services/galley/src/Galley/Schema/V62_TeamFeatureSearchVisibilityInbound.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V62_TeamFeatureSearchVisibilityInbound +module Galley.Schema.V62_TeamFeatureSearchVisibilityInbound ( migration, ) where diff --git a/services/galley/schema/src/V63_MLSConversationClients.hs b/services/galley/src/Galley/Schema/V63_MLSConversationClients.hs similarity index 95% rename from services/galley/schema/src/V63_MLSConversationClients.hs rename to services/galley/src/Galley/Schema/V63_MLSConversationClients.hs index 4b3a80c350b..1a82ab231b6 100644 --- a/services/galley/schema/src/V63_MLSConversationClients.hs +++ b/services/galley/src/Galley/Schema/V63_MLSConversationClients.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V63_MLSConversationClients where +module Galley.Schema.V63_MLSConversationClients where import Cassandra.Schema import Imports diff --git a/services/galley/schema/src/V64_Epoch.hs b/services/galley/src/Galley/Schema/V64_Epoch.hs similarity index 97% rename from services/galley/schema/src/V64_Epoch.hs rename to services/galley/src/Galley/Schema/V64_Epoch.hs index 7bec37d3d2b..70cd8e8e617 100644 --- a/services/galley/schema/src/V64_Epoch.hs +++ b/services/galley/src/Galley/Schema/V64_Epoch.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V64_Epoch +module Galley.Schema.V64_Epoch ( migration, ) where diff --git a/services/galley/schema/src/V65_MLSRemoteClients.hs b/services/galley/src/Galley/Schema/V65_MLSRemoteClients.hs similarity index 95% rename from services/galley/schema/src/V65_MLSRemoteClients.hs rename to services/galley/src/Galley/Schema/V65_MLSRemoteClients.hs index c772b84ec45..c2a7deb9795 100644 --- a/services/galley/schema/src/V65_MLSRemoteClients.hs +++ b/services/galley/src/Galley/Schema/V65_MLSRemoteClients.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V65_MLSRemoteClients where +module Galley.Schema.V65_MLSRemoteClients where import Cassandra.Schema import Imports diff --git a/services/galley/schema/src/V66_AddSplashScreen.hs b/services/galley/src/Galley/Schema/V66_AddSplashScreen.hs similarity index 95% rename from services/galley/schema/src/V66_AddSplashScreen.hs rename to services/galley/src/Galley/Schema/V66_AddSplashScreen.hs index 04fc5bb90c0..b5e9c31c327 100644 --- a/services/galley/schema/src/V66_AddSplashScreen.hs +++ b/services/galley/src/Galley/Schema/V66_AddSplashScreen.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V66_AddSplashScreen where +module Galley.Schema.V66_AddSplashScreen where import Cassandra.Schema import Imports diff --git a/services/galley/schema/src/V67_MLSFeature.hs b/services/galley/src/Galley/Schema/V67_MLSFeature.hs similarity index 96% rename from services/galley/schema/src/V67_MLSFeature.hs rename to services/galley/src/Galley/Schema/V67_MLSFeature.hs index b3c5a3066a0..5391d1967ed 100644 --- a/services/galley/schema/src/V67_MLSFeature.hs +++ b/services/galley/src/Galley/Schema/V67_MLSFeature.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V67_MLSFeature where +module Galley.Schema.V67_MLSFeature where import Cassandra.Schema import Imports diff --git a/services/galley/schema/src/V68_MLSCommitLock.hs b/services/galley/src/Galley/Schema/V68_MLSCommitLock.hs similarity index 96% rename from services/galley/schema/src/V68_MLSCommitLock.hs rename to services/galley/src/Galley/Schema/V68_MLSCommitLock.hs index 33edb236735..24380e5d914 100644 --- a/services/galley/schema/src/V68_MLSCommitLock.hs +++ b/services/galley/src/Galley/Schema/V68_MLSCommitLock.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V68_MLSCommitLock where +module Galley.Schema.V68_MLSCommitLock where import Cassandra.Schema import Imports diff --git a/services/galley/schema/src/V69_MLSProposal.hs b/services/galley/src/Galley/Schema/V69_MLSProposal.hs similarity index 96% rename from services/galley/schema/src/V69_MLSProposal.hs rename to services/galley/src/Galley/Schema/V69_MLSProposal.hs index 5b0e0c9ab1f..e0730ce253a 100644 --- a/services/galley/schema/src/V69_MLSProposal.hs +++ b/services/galley/src/Galley/Schema/V69_MLSProposal.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V69_MLSProposal +module Galley.Schema.V69_MLSProposal ( migration, ) where diff --git a/services/galley/schema/src/V70_MLSCipherSuite.hs b/services/galley/src/Galley/Schema/V70_MLSCipherSuite.hs similarity index 96% rename from services/galley/schema/src/V70_MLSCipherSuite.hs rename to services/galley/src/Galley/Schema/V70_MLSCipherSuite.hs index 637b8ea7c4d..d445270b99d 100644 --- a/services/galley/schema/src/V70_MLSCipherSuite.hs +++ b/services/galley/src/Galley/Schema/V70_MLSCipherSuite.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V70_MLSCipherSuite +module Galley.Schema.V70_MLSCipherSuite ( migration, ) where diff --git a/services/galley/schema/src/V71_MemberClientKeypackage.hs b/services/galley/src/Galley/Schema/V71_MemberClientKeypackage.hs similarity index 96% rename from services/galley/schema/src/V71_MemberClientKeypackage.hs rename to services/galley/src/Galley/Schema/V71_MemberClientKeypackage.hs index 1695957905c..21e9903ca77 100644 --- a/services/galley/schema/src/V71_MemberClientKeypackage.hs +++ b/services/galley/src/Galley/Schema/V71_MemberClientKeypackage.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V71_MemberClientKeypackage where +module Galley.Schema.V71_MemberClientKeypackage where import Cassandra.Schema import Imports diff --git a/services/galley/schema/src/V72_DropManagedConversations.hs b/services/galley/src/Galley/Schema/V72_DropManagedConversations.hs similarity index 94% rename from services/galley/schema/src/V72_DropManagedConversations.hs rename to services/galley/src/Galley/Schema/V72_DropManagedConversations.hs index acb633fe5e9..92d25c6928a 100644 --- a/services/galley/schema/src/V72_DropManagedConversations.hs +++ b/services/galley/src/Galley/Schema/V72_DropManagedConversations.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V72_DropManagedConversations where +module Galley.Schema.V72_DropManagedConversations where import Cassandra.Schema import Imports diff --git a/services/galley/schema/src/V73_MemberClientTable.hs b/services/galley/src/Galley/Schema/V73_MemberClientTable.hs similarity index 96% rename from services/galley/schema/src/V73_MemberClientTable.hs rename to services/galley/src/Galley/Schema/V73_MemberClientTable.hs index 15f642018b9..9f18e360520 100644 --- a/services/galley/schema/src/V73_MemberClientTable.hs +++ b/services/galley/src/Galley/Schema/V73_MemberClientTable.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V73_MemberClientTable where +module Galley.Schema.V73_MemberClientTable where import Cassandra.Schema import Imports diff --git a/services/galley/schema/src/V74_ExposeInvitationsToTeamAdmin.hs b/services/galley/src/Galley/Schema/V74_ExposeInvitationsToTeamAdmin.hs similarity index 95% rename from services/galley/schema/src/V74_ExposeInvitationsToTeamAdmin.hs rename to services/galley/src/Galley/Schema/V74_ExposeInvitationsToTeamAdmin.hs index f3df5766d9c..d6b5c56037b 100644 --- a/services/galley/schema/src/V74_ExposeInvitationsToTeamAdmin.hs +++ b/services/galley/src/Galley/Schema/V74_ExposeInvitationsToTeamAdmin.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V74_ExposeInvitationsToTeamAdmin +module Galley.Schema.V74_ExposeInvitationsToTeamAdmin ( migration, ) where diff --git a/services/galley/schema/src/V75_MLSGroupInfo.hs b/services/galley/src/Galley/Schema/V75_MLSGroupInfo.hs similarity index 96% rename from services/galley/schema/src/V75_MLSGroupInfo.hs rename to services/galley/src/Galley/Schema/V75_MLSGroupInfo.hs index 4615c73954e..84b6df1504b 100644 --- a/services/galley/schema/src/V75_MLSGroupInfo.hs +++ b/services/galley/src/Galley/Schema/V75_MLSGroupInfo.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V75_MLSGroupInfo +module Galley.Schema.V75_MLSGroupInfo ( migration, ) where diff --git a/services/galley/schema/src/V76_ProposalOrigin.hs b/services/galley/src/Galley/Schema/V76_ProposalOrigin.hs similarity index 96% rename from services/galley/schema/src/V76_ProposalOrigin.hs rename to services/galley/src/Galley/Schema/V76_ProposalOrigin.hs index c47ffc4d490..3324af00cb8 100644 --- a/services/galley/schema/src/V76_ProposalOrigin.hs +++ b/services/galley/src/Galley/Schema/V76_ProposalOrigin.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V76_ProposalOrigin +module Galley.Schema.V76_ProposalOrigin ( migration, ) where diff --git a/services/galley/schema/src/V77_MLSGroupMemberClient.hs b/services/galley/src/Galley/Schema/V77_MLSGroupMemberClient.hs similarity index 95% rename from services/galley/schema/src/V77_MLSGroupMemberClient.hs rename to services/galley/src/Galley/Schema/V77_MLSGroupMemberClient.hs index 8847b53c22c..ed22adf2768 100644 --- a/services/galley/schema/src/V77_MLSGroupMemberClient.hs +++ b/services/galley/src/Galley/Schema/V77_MLSGroupMemberClient.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V77_MLSGroupMemberClient (migration) where +module Galley.Schema.V77_MLSGroupMemberClient (migration) where import Cassandra.Schema import Imports diff --git a/services/galley/schema/src/V78_TeamFeatureOutlookCalIntegration.hs b/services/galley/src/Galley/Schema/V78_TeamFeatureOutlookCalIntegration.hs similarity index 95% rename from services/galley/schema/src/V78_TeamFeatureOutlookCalIntegration.hs rename to services/galley/src/Galley/Schema/V78_TeamFeatureOutlookCalIntegration.hs index cd52a49ec43..808f49a1407 100644 --- a/services/galley/schema/src/V78_TeamFeatureOutlookCalIntegration.hs +++ b/services/galley/src/Galley/Schema/V78_TeamFeatureOutlookCalIntegration.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V78_TeamFeatureOutlookCalIntegration +module Galley.Schema.V78_TeamFeatureOutlookCalIntegration ( migration, ) where diff --git a/services/galley/schema/src/V79_TeamFeatureMlsE2EId.hs b/services/galley/src/Galley/Schema/V79_TeamFeatureMlsE2EId.hs similarity index 96% rename from services/galley/schema/src/V79_TeamFeatureMlsE2EId.hs rename to services/galley/src/Galley/Schema/V79_TeamFeatureMlsE2EId.hs index 9a544ab9b15..704b3ea6a3a 100644 --- a/services/galley/schema/src/V79_TeamFeatureMlsE2EId.hs +++ b/services/galley/src/Galley/Schema/V79_TeamFeatureMlsE2EId.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V79_TeamFeatureMlsE2EId +module Galley.Schema.V79_TeamFeatureMlsE2EId ( migration, ) where diff --git a/services/galley/schema/src/V80_AddConversationCodePassword.hs b/services/galley/src/Galley/Schema/V80_AddConversationCodePassword.hs similarity index 95% rename from services/galley/schema/src/V80_AddConversationCodePassword.hs rename to services/galley/src/Galley/Schema/V80_AddConversationCodePassword.hs index 34c67a42538..24ae8e2faf4 100644 --- a/services/galley/schema/src/V80_AddConversationCodePassword.hs +++ b/services/galley/src/Galley/Schema/V80_AddConversationCodePassword.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V80_AddConversationCodePassword +module Galley.Schema.V80_AddConversationCodePassword ( migration, ) where diff --git a/services/galley/schema/src/V81_TeamFeatureMlsE2EIdUpdate.hs b/services/galley/src/Galley/Schema/V81_TeamFeatureMlsE2EIdUpdate.hs similarity index 95% rename from services/galley/schema/src/V81_TeamFeatureMlsE2EIdUpdate.hs rename to services/galley/src/Galley/Schema/V81_TeamFeatureMlsE2EIdUpdate.hs index d5b22b2adba..c7804d59c50 100644 --- a/services/galley/schema/src/V81_TeamFeatureMlsE2EIdUpdate.hs +++ b/services/galley/src/Galley/Schema/V81_TeamFeatureMlsE2EIdUpdate.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V81_TeamFeatureMlsE2EIdUpdate +module Galley.Schema.V81_TeamFeatureMlsE2EIdUpdate ( migration, ) where diff --git a/services/galley/schema/src/V82_RemoteDomainIndexes.hs b/services/galley/src/Galley/Schema/V82_RemoteDomainIndexes.hs similarity index 91% rename from services/galley/schema/src/V82_RemoteDomainIndexes.hs rename to services/galley/src/Galley/Schema/V82_RemoteDomainIndexes.hs index fcc5fca6128..25b8f9f6f07 100644 --- a/services/galley/schema/src/V82_RemoteDomainIndexes.hs +++ b/services/galley/src/Galley/Schema/V82_RemoteDomainIndexes.hs @@ -1,7 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -module V82_RemoteDomainIndexes +module Galley.Schema.V82_RemoteDomainIndexes ( migration, ) where diff --git a/services/galley/schema/src/V83_CreateTableTeamAdmin.hs b/services/galley/src/Galley/Schema/V83_CreateTableTeamAdmin.hs similarity index 96% rename from services/galley/schema/src/V83_CreateTableTeamAdmin.hs rename to services/galley/src/Galley/Schema/V83_CreateTableTeamAdmin.hs index f52cd0cebc4..e5fd7a4a5cf 100644 --- a/services/galley/schema/src/V83_CreateTableTeamAdmin.hs +++ b/services/galley/src/Galley/Schema/V83_CreateTableTeamAdmin.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V83_CreateTableTeamAdmin +module Galley.Schema.V83_CreateTableTeamAdmin ( migration, ) where diff --git a/services/galley/src/Galley/Schema/V84_MLSSubconversation.hs b/services/galley/src/Galley/Schema/V84_MLSSubconversation.hs new file mode 100644 index 00000000000..e9e70252c22 --- /dev/null +++ b/services/galley/src/Galley/Schema/V84_MLSSubconversation.hs @@ -0,0 +1,42 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.Schema.V84_MLSSubconversation (migration) where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +migration :: Migration +migration = + Migration 84 "Add the MLS subconversation tables" $ do + schema' + [r| CREATE TABLE subconversation ( + conv_id uuid, + subconv_id text, + group_id blob, + cipher_suite int, + public_group_state blob, + epoch bigint, + PRIMARY KEY (conv_id, subconv_id) + ); + |] + schema' + [r| ALTER TABLE group_id_conv_id ADD ( + subconv_id text + ); + |] diff --git a/services/galley/src/Galley/Schema/V85_MLSDraft17.hs b/services/galley/src/Galley/Schema/V85_MLSDraft17.hs new file mode 100644 index 00000000000..958c8c629d9 --- /dev/null +++ b/services/galley/src/Galley/Schema/V85_MLSDraft17.hs @@ -0,0 +1,32 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.Schema.V85_MLSDraft17 (migration) where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +migration :: Migration +migration = + Migration 85 "Upgrade to MLS draft 17 structures" $ do + schema' + [r| ALTER TABLE mls_group_member_client + ADD (leaf_node_index int, + removal_pending boolean + ); + |] diff --git a/services/galley/src/Galley/Schema/V86_TeamFeatureMlsMigration.hs b/services/galley/src/Galley/Schema/V86_TeamFeatureMlsMigration.hs new file mode 100644 index 00000000000..431a93b4d38 --- /dev/null +++ b/services/galley/src/Galley/Schema/V86_TeamFeatureMlsMigration.hs @@ -0,0 +1,36 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.Schema.V86_TeamFeatureMlsMigration + ( migration, + ) +where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +migration :: Migration +migration = Migration 86 "Add feature config for team feature MLS Migration" $ do + schema' + [r| ALTER TABLE team_features ADD ( + mls_migration_status int, + mls_migration_lock_status int, + mls_migration_start_time timestamp, + mls_migration_finalise_regardless_after timestamp + ) + |] diff --git a/services/galley/src/Galley/Schema/V87_TeamFeatureSupportedProtocols.hs b/services/galley/src/Galley/Schema/V87_TeamFeatureSupportedProtocols.hs new file mode 100644 index 00000000000..03f57315705 --- /dev/null +++ b/services/galley/src/Galley/Schema/V87_TeamFeatureSupportedProtocols.hs @@ -0,0 +1,33 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Galley.Schema.V87_TeamFeatureSupportedProtocols + ( migration, + ) +where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +migration :: Migration +migration = Migration 87 "Add feature config for supported protocols" $ do + schema' + [r| ALTER TABLE team_features ADD ( + mls_supported_protocols set + ) + |] diff --git a/services/galley/src/Galley/Schema/V88_TruncateMLSGroupMemberClient.hs b/services/galley/src/Galley/Schema/V88_TruncateMLSGroupMemberClient.hs new file mode 100644 index 00000000000..77e9d15a11f --- /dev/null +++ b/services/galley/src/Galley/Schema/V88_TruncateMLSGroupMemberClient.hs @@ -0,0 +1,33 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . +module Galley.Schema.V88_TruncateMLSGroupMemberClient + ( migration, + ) +where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +-- | This migration exists because the table could have some rogue data in it +-- before MLS Draft-17 was implemented. It was not supposed to be used, but it +-- could've been. This migration just deletes old data. This could break some +-- conversations/users in unknown ways. But those are most likely test users. +migration :: Migration +migration = Migration 88 "Truncate mls_group_member_client" $ do + schema' + [r|TRUNCATE TABLE mls_group_member_client|] diff --git a/services/galley/src/Galley/Schema/V89_RemoveMemberClient.hs b/services/galley/src/Galley/Schema/V89_RemoveMemberClient.hs new file mode 100644 index 00000000000..53f2d0df2d1 --- /dev/null +++ b/services/galley/src/Galley/Schema/V89_RemoveMemberClient.hs @@ -0,0 +1,33 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . +module Galley.Schema.V89_RemoveMemberClient + ( migration, + ) +where + +import Cassandra.Schema +import Imports +import Text.RawString.QQ + +-- | This migration exists because the table could have some rogue data in it +-- before MLS Draft-17 was implemented. It was not supposed to be used, but it +-- could've been. This migration just deletes old data. This could break some +-- conversations/users in unknown ways. But those are most likely test users. +migration :: Migration +migration = Migration 88 "Remove member_client" $ do + schema' + [r|DROP TABLE IF EXISTS member_client|] diff --git a/services/galley/src/Galley/Validation.hs b/services/galley/src/Galley/Validation.hs index f87db6df4bf..964963e4e65 100644 --- a/services/galley/src/Galley/Validation.hs +++ b/services/galley/src/Galley/Validation.hs @@ -62,7 +62,7 @@ checkedConvSize :: Sem r (ConvSizeChecked f a) checkedConvSize o x = do let minV :: Integer = 0 - limit = o ^. optSettings . setMaxConvSize - 1 + limit = o ^. settings . maxConvSize - 1 if length x <= fromIntegral limit then pure (ConvSizeChecked x) else throwErr (errorMsg minV limit "") diff --git a/services/galley/test/integration.hs b/services/galley/test/integration.hs new file mode 100644 index 00000000000..a26473d24ee --- /dev/null +++ b/services/galley/test/integration.hs @@ -0,0 +1 @@ +import Run diff --git a/services/galley/test/integration/API.hs b/services/galley/test/integration/API.hs index 664a2c80337..6f6ed15666a 100644 --- a/services/galley/test/integration/API.hs +++ b/services/galley/test/integration/API.hs @@ -44,14 +44,13 @@ import Bilge qualified import Bilge.Assert import Control.Concurrent.Async qualified as Async import Control.Exception (throw) -import Control.Lens (at, view, (.~), (?~)) +import Control.Lens hiding ((#), (.=)) import Control.Monad.Trans.Maybe import Data.Aeson hiding (json) import Data.ByteString qualified as BS import Data.ByteString.Conversion import Data.Code qualified as Code import Data.Domain -import Data.Either.Extra (eitherToMaybe) import Data.Id import Data.Json.Util (toBase64Text, toUTCTimeMillis) import Data.List.NonEmpty (NonEmpty (..)) @@ -69,9 +68,10 @@ import Data.Time.Clock (getCurrentTime) import Federator.Discovery (DiscoveryFailure (..)) import Federator.MockServer import Galley.API.Mapping -import Galley.Options (optFederator, optRabbitmq) +import Galley.Options (federator, rabbitmq) import Galley.Types.Conversations.Members -import Imports +import Imports hiding (id) +import Imports qualified as I import Network.HTTP.Types.Status qualified as HTTP import Network.Wai.Utilities.Error import Test.QuickCheck (arbitrary, generate) @@ -81,9 +81,9 @@ import Test.Tasty.Cannon qualified as WS import Test.Tasty.HUnit import TestHelpers import TestSetup -import Util.Options (Endpoint (Endpoint)) import Wire.API.Connection import Wire.API.Conversation +import Wire.API.Conversation qualified as C import Wire.API.Conversation.Action import Wire.API.Conversation.Code hiding (Value) import Wire.API.Conversation.Protocol @@ -93,10 +93,8 @@ import Wire.API.Error.Galley import Wire.API.Event.Conversation import Wire.API.Federation.API import Wire.API.Federation.API.Brig -import Wire.API.Federation.API.Brig qualified as F import Wire.API.Federation.API.Common import Wire.API.Federation.API.Galley -import Wire.API.Federation.API.Galley qualified as F import Wire.API.Internal.Notification import Wire.API.Message import Wire.API.Message qualified as Message @@ -177,9 +175,6 @@ tests s = test s "generate guest link forbidden when no guest or non-team-member access role" generateGuestLinkFailIfNoNonTeamMemberOrNoGuestAccess, test s "fail to add members when not connected" postMembersFail, test s "fail to add too many members" postTooManyMembersFail, - test s "add remote members" testAddRemoteMember, - test s "delete conversation with remote members" testDeleteTeamConversationWithRemoteMembers, - test s "delete conversation with unavailable remote members" testDeleteTeamConversationWithUnavailableRemoteMembers, test s "get conversations/:domain/:cnv - local" testGetQualifiedLocalConv, test s "get conversations/:domain/:cnv - local, not found" testGetQualifiedLocalConvNotFound, test s "get conversations/:domain/:cnv - local, not participating" testGetQualifiedLocalConvNotParticipating, @@ -189,13 +184,9 @@ tests s = test s "post conversations/list/v2" testBulkGetQualifiedConvs, test s "add remote members on invalid domain" testAddRemoteMemberInvalidDomain, test s "add remote members when federation isn't enabled" testAddRemoteMemberFederationDisabled, - test s "add remote members when federator is unavailable" testAddRemoteMemberFederationUnavailable, test s "delete conversations/:domain/:cnv/members/:domain/:usr - fail, self conv" deleteMembersQualifiedFailSelf, test s "delete conversations/:domain:/cnv/members/:domain/:usr - fail, 1:1 conv" deleteMembersQualifiedFailO2O, test s "delete conversations/:domain/:cnv/members/:domain/:usr - local conv with all locals" deleteMembersConvLocalQualifiedOk, - test s "delete conversations/:domain/:cnv/members/:domain/:usr - local conv with locals and remote, delete local" deleteLocalMemberConvLocalQualifiedOk, - test s "delete conversations/:domain/:cnv/members/:domain/:usr - local conv with locals and remote, delete remote" deleteRemoteMemberConvLocalQualifiedOk, - test s "delete conversations/:domain/:cnv/members/:domain/:usr - local conv with locals and remote, delete unavailable remote" deleteUnavailableRemoteMemberConvLocalQualifiedOk, test s "delete conversations/:domain/:cnv/members/:domain/:usr - remote conv, leave conv" leaveRemoteConvQualifiedOk, test s "delete conversations/:domain/:cnv/members/:domain/:usr - remote conv, leave conv, non-existent" leaveNonExistentRemoteConv, test s "delete conversations/:domain/:cnv/members/:domain/:usr - remote conv, leave conv, denied" leaveRemoteConvDenied, @@ -204,8 +195,6 @@ tests s = test s "rename conversation (deprecated endpoint)" putConvDeprecatedRenameOk, test s "rename conversation" putConvRenameOk, test s "rename qualified conversation" putQualifiedConvRenameOk, - test s "rename qualified conversation with remote members" putQualifiedConvRenameWithRemotesOk, - test s "rename qualified conversation with unavailable remote" putQualifiedConvRenameWithRemotesUnavailable, test s "rename qualified conversation failure" putQualifiedConvRenameFailure, test s "other member update role" putOtherMemberOk, test s "qualified other member update role" putQualifiedOtherMemberOk, @@ -218,8 +207,6 @@ tests s = test s "remote conversation member update (otr hidden)" putRemoteConvMemberHiddenOk, test s "remote conversation member update (everything)" putRemoteConvMemberAllOk, test s "conversation receipt mode update" putReceiptModeOk, - test s "conversation receipt mode update with remote members" putReceiptModeWithRemotesOk, - test s "conversation receipt mode update with unavailable remote members" putReceiptModeWithRemotesUnavailable, test s "remote conversation receipt mode update" putRemoteReceiptModeOk, test s "leave connect conversation" leaveConnectConversation, test s "post conversations/:cnv/otr/message: message delivery and missing clients" postCryptoMessageVerifyMsgSentAndRejectIfMissingClient, @@ -240,8 +227,6 @@ tests s = test s "join code-access conversation - password" postJoinCodeConvWithPassword, test s "convert invite to code-access conversation" postConvertCodeConv, test s "convert code to team-access conversation" postConvertTeamConv, - test s "local and remote guests are removed when access changes" testAccessUpdateGuestRemoved, - test s "local and remote guests are removed when access changes remotes unavailable" testAccessUpdateGuestRemovedRemotesUnavailable, test s "team member can't join via guest link if access role removed" testTeamMemberCantJoinViaGuestLinkIfAccessRoleRemoved, test s "cannot join private conversation" postJoinConvFail, test s "revoke guest links for team conversation" testJoinTeamConvGuestLinksDisabled, @@ -266,13 +251,7 @@ tests s = [ test s "send typing indicators" postTypingIndicators, test s "send typing indicators without domain" postTypingIndicatorsV2, test s "send typing indicators with invalid pyaload" postTypingIndicatorsHandlesNonsense - ], - -- NOTE: These federation notification tests need to run after all of the other tests are finished. - -- This is because they will send notifications to _ALL_ registered clients for the local domain. - -- As a lot of these tests are waiting on specific notifications to come through in a specified - -- order, these tests will cause them to fail. - -- See the Tasty docs on patterns. https://hackage.haskell.org/package/tasty-1.4.3#patterns - after AllFinish "$0 !~ /delete federation notifications/" $ test s "delete federation notifications" API.testDefederationNotifications + ] ] rb1, rb2, rb3, rb4 :: Remote Backend rb1 = @@ -363,7 +342,7 @@ postProteusConvOk = do rsp <- postConv alice [bob, jane] (Just nameMaxSize) [] Nothing Nothing participatingRemotes) (Just convName) @@ -505,33 +484,32 @@ postConvWithRemoteUsersOk rbs = do -- assertions on the conversation.create event triggering federation request let fedReqsCreated = filter (\r -> frRPC r == "on-conversation-created") federatedRequests fedReqCreatedBodies <- for fedReqsCreated $ assertRight . parseFedRequest - forM_ fedReqCreatedBodies $ \fedReqCreatedBody -> liftIO $ do - F.ccOrigUserId fedReqCreatedBody @?= alice - F.ccCnvId fedReqCreatedBody @?= cid - F.ccCnvType fedReqCreatedBody @?= RegularConv - F.ccCnvAccess fedReqCreatedBody @?= [InviteAccess] - F.ccCnvAccessRoles fedReqCreatedBody + forM_ fedReqCreatedBodies $ \(fedReqCreatedBody :: ConversationCreated ConvId) -> liftIO $ do + fedReqCreatedBody.origUserId @?= alice + fedReqCreatedBody.cnvId @?= cid + fedReqCreatedBody.cnvType @?= RegularConv + fedReqCreatedBody.cnvAccess @?= [InviteAccess] + fedReqCreatedBody.cnvAccessRoles @?= Set.fromList [TeamMemberAccessRole, NonTeamMemberAccessRole, ServiceAccessRole] - F.ccCnvName fedReqCreatedBody @?= Just convName + fedReqCreatedBody.cnvName @?= Just convName assertBool "Notifying an incorrect set of conversation members" $ - minimalShouldBePresentSet `Set.isSubsetOf` F.ccNonCreatorMembers fedReqCreatedBody - F.ccMessageTimer fedReqCreatedBody @?= Nothing - F.ccReceiptMode fedReqCreatedBody @?= Nothing + minimalShouldBePresentSet `Set.isSubsetOf` fedReqCreatedBody.nonCreatorMembers + fedReqCreatedBody.messageTimer @?= Nothing + fedReqCreatedBody.receiptMode @?= Nothing -- assertions on the conversation.member-join event triggering federation request let fedReqsAdd = filter (\r -> frRPC r == "on-conversation-updated") federatedRequests fedReqAddBodies <- for fedReqsAdd $ assertRight . parseFedRequest - forM_ fedReqAddBodies $ \fedReqAddBody -> liftIO $ do - F.cuOrigUserId fedReqAddBody @?= qAlice - F.cuConvId fedReqAddBody @?= cid + forM_ fedReqAddBodies $ \(fedReqAddBody :: ConversationUpdate) -> liftIO $ do + fedReqAddBody.cuOrigUserId @?= qAlice + fedReqAddBody.cuConvId @?= cid -- This remote backend must already have their users in the conversation, -- otherwise they should not be receiving the conversation update message assertBool "The list of already present users should be non-empty" . not . null - . F.cuAlreadyPresentUsers - $ fedReqAddBody - case F.cuAction fedReqAddBody of + $ fedReqAddBody.cuAlreadyPresentUsers + case fedReqAddBody.cuAction of SomeConversationAction SConversationJoinTag _action -> pure () _ -> assertFailure @() "Unexpected update action" where @@ -566,13 +544,13 @@ postCryptoMessageVerifyMsgSentAndRejectIfMissingClient = do let t = 5 # Second -- Missing eve let m1 = [(bob, bc, "ciphertext1")] - postOtrMessage id alice ac conv m1 !!! do + postOtrMessage I.id alice ac conv m1 !!! do const 412 === statusCode assertMismatch [(eve, Set.singleton ec)] [] [] -- Complete WS.bracketR2 c bob eve $ \(wsB, wsE) -> do let m2 = [(bob, bc, toBase64Text "ciphertext2"), (eve, ec, toBase64Text "ciphertext2")] - postOtrMessage id alice ac conv m2 !!! do + postOtrMessage I.id alice ac conv m2 !!! do const 201 === statusCode assertMismatch [] [] [] void . liftIO $ WS.assertMatch t wsB (wsAssertOtr qconv qalice ac bc (toBase64Text "ciphertext2")) @@ -580,7 +558,7 @@ postCryptoMessageVerifyMsgSentAndRejectIfMissingClient = do -- Redundant self WS.bracketR3 c alice bob eve $ \(wsA, wsB, wsE) -> do let m3 = [(alice, ac, toBase64Text "ciphertext3"), (bob, bc, toBase64Text "ciphertext3"), (eve, ec, toBase64Text "ciphertext3")] - postOtrMessage id alice ac conv m3 !!! do + postOtrMessage I.id alice ac conv m3 !!! do const 201 === statusCode assertMismatch [] [(alice, Set.singleton ac)] [] void . liftIO $ WS.assertMatch t wsB (wsAssertOtr qconv qalice ac bc (toBase64Text "ciphertext3")) @@ -594,7 +572,7 @@ postCryptoMessageVerifyMsgSentAndRejectIfMissingClient = do WS.assertMatch_ (5 # WS.Second) wsE $ wsAssertClientRemoved ec let m4 = [(bob, bc, toBase64Text "ciphertext4"), (eve, ec, toBase64Text "ciphertext4")] - postOtrMessage id alice ac conv m4 !!! do + postOtrMessage I.id alice ac conv m4 !!! do const 201 === statusCode assertMismatch [] [] [(eve, Set.singleton ec)] void . liftIO $ WS.assertMatch t wsB (wsAssertOtr qconv qalice ac bc (toBase64Text "ciphertext4")) @@ -603,7 +581,7 @@ postCryptoMessageVerifyMsgSentAndRejectIfMissingClient = do -- Deleted eve & redundant self WS.bracketR3 c alice bob eve $ \(wsA, wsB, wsE) -> do let m5 = [(bob, bc, toBase64Text "ciphertext5"), (eve, ec, toBase64Text "ciphertext5"), (alice, ac, toBase64Text "ciphertext5")] - postOtrMessage id alice ac conv m5 !!! do + postOtrMessage I.id alice ac conv m5 !!! do const 201 === statusCode assertMismatch [] [(alice, Set.singleton ac)] [(eve, Set.singleton ec)] void . liftIO $ WS.assertMatch t wsB (wsAssertOtr qconv qalice ac bc (toBase64Text "ciphertext5")) @@ -612,7 +590,7 @@ postCryptoMessageVerifyMsgSentAndRejectIfMissingClient = do assertNoMsg wsE (wsAssertOtr qconv qalice ac ec (toBase64Text "ciphertext5")) -- Missing Bob, deleted eve & redundant self let m6 = [(eve, ec, toBase64Text "ciphertext6"), (alice, ac, toBase64Text "ciphertext6")] - postOtrMessage id alice ac conv m6 !!! do + postOtrMessage I.id alice ac conv m6 !!! do const 412 === statusCode assertMismatch [(bob, Set.singleton bc)] @@ -626,7 +604,7 @@ postCryptoMessageVerifyMsgSentAndRejectIfMissingClient = do -- The second client listens only for his own messages WS.bracketR (c . queryItem "client" (toByteString' bc2)) bob $ \wsB2 -> do let m7 = [(bob, bc, cipher), (bob, bc2, cipher)] - postOtrMessage id alice ac conv m7 !!! do + postOtrMessage I.id alice ac conv m7 !!! do const 201 === statusCode assertMismatch [] [] [] -- Bob's first client gets both messages @@ -651,7 +629,7 @@ postCryptoMessageVerifyRejectMissingClientAndRepondMissingPrekeysJson = do -- Missing eve let m = [(bob, bc, toBase64Text "hello bob")] r1 <- - postOtrMessage id alice ac conv m do let msgToAllIncludingChad = [(bob, bc, toBase64Text "ciphertext2"), (eve, ec, toBase64Text "ciphertext2"), (chad, cc, toBase64Text "ciphertext2")] - postOtrMessage id alice ac conversationWithAllButChad msgToAllIncludingChad !!! const 201 === statusCode + postOtrMessage I.id alice ac conversationWithAllButChad msgToAllIncludingChad !!! const 201 === statusCode let checkBobGetsMsg = void . liftIO $ WS.assertMatch (5 # Second) wsBob (wsAssertOtr qconv qalice ac bc (toBase64Text "ciphertext2")) let checkEveGetsMsg = void . liftIO $ WS.assertMatch (5 # Second) wsEve (wsAssertOtr qconv qalice ac ec (toBase64Text "ciphertext2")) let checkChadDoesNotGetMsg = assertNoMsg wsChad (wsAssertOtr qconv qalice ac ac (toBase64Text "ciphertext2")) @@ -753,12 +731,12 @@ postMessageRejectIfMissingClients = do let msgMissingClients = mkMsg "hello!" <$> drop 1 allReceivers let checkSendToAllClientShouldBeSuccessful = - postOtrMessage id sender senderClient conv msgToAllClients !!! do + postOtrMessage I.id sender senderClient conv msgToAllClients !!! do const 201 === statusCode assertMismatch [] [] [] let checkSendWitMissingClientsShouldFail = - postOtrMessage id sender senderClient conv msgMissingClients !!! do + postOtrMessage I.id sender senderClient conv msgMissingClients !!! do const 412 === statusCode assertMismatch [(receiver1, Set.singleton receiverClient1)] [] [] @@ -785,7 +763,7 @@ postCryptoMessageVerifyCorrectResponseIfIgnoreAndReportMissingQueryParam = do let msgMissingChadAndEve = [(bob, bc, toBase64Text "hello bob")] let m' = otrRecipients [(bob, bc, toBase64Text "hello bob")] -- These three are equivalent (i.e. report all missing clients) - postOtrMessage id alice ac conv msgMissingChadAndEve + postOtrMessage I.id alice ac conv msgMissingChadAndEve !!! const 412 === statusCode postOtrMessage (queryItem "ignore_missing" "false") alice ac conv msgMissingChadAndEve !!! const 412 === statusCode @@ -806,10 +784,10 @@ postCryptoMessageVerifyCorrectResponseIfIgnoreAndReportMissingQueryParam = do postOtrMessage' (Just [bob]) (queryItem "report_missing" (listToByteString [eve, chad])) alice ac conv msgMissingChadAndEve !!! const 201 === statusCode -- Set it only in the body of the message - postOtrMessage' (Just [bob]) id alice ac conv msgMissingChadAndEve + postOtrMessage' (Just [bob]) I.id alice ac conv msgMissingChadAndEve !!! const 201 === statusCode -- Let's make sure that protobuf works too, when specified in the body only - postProtoOtrMessage' (Just [bob]) id alice ac conv m' + postProtoOtrMessage' (Just [bob]) I.id alice ac conv m' !!! const 201 === statusCode reportEveAndChad <- -- send message with no clients @@ -946,12 +924,12 @@ postMessageQualifiedLocalOwningBackendRedundantAndDeletedClients = do -- FUTUREWORK: Mock federator and ensure that a message to Dee is sent let brigMock = do guardRPC "get-user-clients" - getUserClients <- getRequestBody + getUserClients <- getRequestBody @GetUserClients let lookupClients uid | uid == deeRemoteUnqualified = Just (uid, Set.fromList [PubClient deeClient Nothing]) | uid == nonMemberRemoteUnqualified = Just (uid, Set.fromList [PubClient nonMemberRemoteClient Nothing]) | otherwise = Nothing - mockReply $ UserMap . Map.fromList . mapMaybe lookupClients $ F.gucUsers getUserClients + mockReply $ UserMap . Map.fromList $ mapMaybe lookupClients getUserClients.users galleyMock = "on-message-sent" ~> EmptyResponse (resp2, _requests) <- postProteusMessageQualifiedWithMockFederator aliceUnqualified aliceClient convId message "data" Message.MismatchReportAll (brigMock <|> galleyMock) @@ -1264,7 +1242,7 @@ postMessageQualifiedRemoteOwningBackendSuccess = do Message.mssFailedToConfirmClients = mempty } message = [(bobOwningDomain, bobClient, "text-for-bob"), (deeRemote, deeClient, "text-for-dee")] - mock = "send-message" ~> F.MessageSendResponse (Right mss) + mock = "send-message" ~> MessageSendResponse (Right mss) (resp2, _requests) <- postProteusMessageQualifiedWithMockFederator aliceUnqualified aliceClient convId message "data" Message.MismatchReportAll mock @@ -1627,191 +1605,14 @@ postConvertTeamConv = do -- non-team members get kicked out liftIO $ do WS.assertMatchN_ (5 # Second) [wsA, wsB, wsE, wsM] $ - wsAssertMemberLeave qconv qalice (pure qeve) + wsAssertMemberLeave qconv qalice (pure qeve) EdReasonRemoved WS.assertMatchN_ (5 # Second) [wsA, wsB, wsE, wsM] $ - wsAssertMemberLeave qconv qalice (pure qmallory) + wsAssertMemberLeave qconv qalice (pure qmallory) EdReasonRemoved -- joining (for mallory) is no longer possible postJoinCodeConv mallory j !!! const 403 === statusCode -- team members (dave) can still join postJoinCodeConv dave j !!! const 200 === statusCode --- @SF.Federation @SF.Separation @TSFI.RESTfulAPI @S2 --- --- The test asserts that, among others, remote users are removed from a --- conversation when an access update occurs that disallows guests from --- accessing. -testAccessUpdateGuestRemoved :: TestM () -testAccessUpdateGuestRemoved = do - -- alice, bob are in a team - (tid, alice, [bob]) <- createBindingTeamWithQualifiedMembers 2 - - -- charlie is a local guest - charlie <- randomQualifiedUser - connectUsers (qUnqualified alice) (pure (qUnqualified charlie)) - - -- dee is a remote guest - let remoteDomain = Domain "far-away.example.com" - dee <- Qualified <$> randomId <*> pure remoteDomain - - connectWithRemoteUser (qUnqualified alice) dee - - -- they are all in a local conversation - conv <- - responseJsonError - =<< postConvWithRemoteUsers - (qUnqualified alice) - Nothing - defNewProteusConv - { newConvQualifiedUsers = [bob, charlie, dee], - newConvTeam = Just (ConvTeamInfo tid) - } - do - -- conversation access role changes to team only - (_, reqs) <- withTempMockFederator' (mockReply EmptyResponse) $ do - putQualifiedAccessUpdate - (qUnqualified alice) - (cnvQualifiedId conv) - (ConversationAccessData mempty (Set.fromList [TeamMemberAccessRole])) - !!! const 200 === statusCode - - -- charlie and dee are kicked out - -- - -- note that removing users happens asynchronously, so this check should - -- happen while the mock federator is still available - WS.assertMatchN_ (5 # Second) [wsA, wsB, wsC] $ - wsAssertMembersLeave (cnvQualifiedId conv) alice [charlie] - WS.assertMatchN_ (5 # Second) [wsA, wsB, wsC] $ - wsAssertMembersLeave (cnvQualifiedId conv) alice [dee] - - -- dee's remote receives a notification - let compareLists [] ys = [] @?= ys - compareLists (x : xs) ys = case break (== x) ys of - (ys1, _ : ys2) -> compareLists xs (ys1 <> ys2) - _ -> assertFailure $ "Could not find " <> show x <> " in " <> show ys - liftIO $ - compareLists - ( map - ( \fr -> do - cu <- eitherDecode (frBody fr) - pure (F.cuOrigUserId cu, F.cuAction cu) - ) - ( filter - ( \fr -> - frComponent fr == Galley - && frRPC fr == "on-conversation-updated" - ) - reqs - ) - ) - [ Right (alice, SomeConversationAction (sing @'ConversationRemoveMembersTag) (pure charlie)), - Right (alice, SomeConversationAction (sing @'ConversationRemoveMembersTag) (pure dee)), - Right - ( alice, - SomeConversationAction - (sing @'ConversationAccessDataTag) - ConversationAccessData - { cupAccess = mempty, - cupAccessRoles = Set.fromList [TeamMemberAccessRole] - } - ) - ] - - -- only alice and bob remain - conv2 <- - responseJsonError - =<< getConvQualified (qUnqualified alice) (cnvQualifiedId conv) - randomId <*> pure remoteDomain - - connectWithRemoteUser (qUnqualified alice) dee - - -- they are all in a local conversation - conv <- - responseJsonError - =<< postConvWithRemoteUsers - (qUnqualified alice) - Nothing - defNewProteusConv - { newConvQualifiedUsers = [bob, charlie, dee], - newConvTeam = Just (ConvTeamInfo tid) - } - do - -- conversation access role changes to team only - (_, reqs) <- withTempMockFederator' (throw $ MockErrorResponse HTTP.status503 "Down for maintenance") $ do - -- This request should still succeed even with an unresponsive federation member. - putQualifiedAccessUpdate - (qUnqualified alice) - (cnvQualifiedId conv) - (ConversationAccessData mempty (Set.fromList [TeamMemberAccessRole])) - !!! const 200 === statusCode - -- charlie and dee are kicked out - -- - -- note that removing users happens asynchronously, so this check should - -- happen while the mock federator is still available - WS.assertMatchN_ (5 # Second) [wsA, wsB, wsC] $ - wsAssertMembersLeave (cnvQualifiedId conv) alice [charlie] - WS.assertMatchN_ (5 # Second) [wsA, wsB, wsC] $ - wsAssertMembersLeave (cnvQualifiedId conv) alice [dee] - - let compareLists [] ys = [] @?= ys - compareLists (x : xs) ys = case break (== x) ys of - (ys1, _ : ys2) -> compareLists xs (ys1 <> ys2) - _ -> assertFailure $ "Could not find " <> show x <> " in " <> show ys - liftIO $ - compareLists - ( map - ( \fr -> do - cu <- eitherDecode (frBody fr) - pure (F.cuOrigUserId cu, F.cuAction cu) - ) - ( filter - ( \fr -> - frComponent fr == Galley - && frRPC fr == "on-conversation-updated" - ) - reqs - ) - ) - [ Right (alice, SomeConversationAction (sing @'ConversationRemoveMembersTag) (pure charlie)), - Right (alice, SomeConversationAction (sing @'ConversationRemoveMembersTag) (pure dee)), - Right - ( alice, - SomeConversationAction - (sing @'ConversationAccessDataTag) - ConversationAccessData - { cupAccess = mempty, - cupAccessRoles = Set.fromList [TeamMemberAccessRole] - } - ) - ] - -- only alice and bob remain - conv2 <- - responseJsonError - =<< getConvQualified (qUnqualified alice) (cnvQualifiedId conv) - do assertEqual "name mismatch" - (Just $ cnvName expected) - (cnvName <$> actual) + (Just $ C.cnvName expected) + (C.cnvName <$> actual) assertEqual "self member mismatch" (Just . cmSelf $ cnvMembers expected) @@ -2064,12 +1865,12 @@ paginateConvListIds = do replicateM_ 25 $ do conv <- randomId let cu = - F.ConversationUpdate - { F.cuTime = now, - F.cuOrigUserId = qChad, - F.cuConvId = conv, - F.cuAlreadyPresentUsers = [], - F.cuAction = SomeConversationAction (sing @'ConversationJoinTag) (ConversationJoin (pure qAlice) roleNameWireMember) + ConversationUpdate + { cuTime = now, + cuOrigUserId = qChad, + cuConvId = conv, + cuAlreadyPresentUsers = [], + cuAction = SomeConversationAction (sing @'ConversationJoinTag) (ConversationJoin (pure qAlice) roleNameWireMember) } void $ runFedClient @"on-conversation-updated" fedGalleyClient chadDomain cu @@ -2080,12 +1881,12 @@ paginateConvListIds = do replicateM_ 31 $ do conv <- randomId let cu = - F.ConversationUpdate - { F.cuTime = now, - F.cuOrigUserId = qDee, - F.cuConvId = conv, - F.cuAlreadyPresentUsers = [], - F.cuAction = SomeConversationAction (sing @'ConversationJoinTag) (ConversationJoin (pure qAlice) roleNameWireMember) + ConversationUpdate + { cuTime = now, + cuOrigUserId = qDee, + cuConvId = conv, + cuAlreadyPresentUsers = [], + cuAction = SomeConversationAction (sing @'ConversationJoinTag) (ConversationJoin (pure qAlice) roleNameWireMember) } void $ runFedClient @"on-conversation-updated" fedGalleyClient deeDomain cu @@ -2125,12 +1926,12 @@ paginateConvListIdsPageEndingAtLocalsAndDomain = do replicateM_ 16 $ do conv <- randomId let cu = - F.ConversationUpdate - { F.cuTime = now, - F.cuOrigUserId = qChad, - F.cuConvId = conv, - F.cuAlreadyPresentUsers = [], - F.cuAction = SomeConversationAction (sing @'ConversationJoinTag) (ConversationJoin (pure qAlice) roleNameWireMember) + ConversationUpdate + { cuTime = now, + cuOrigUserId = qChad, + cuConvId = conv, + cuAlreadyPresentUsers = [], + cuAction = SomeConversationAction (sing @'ConversationJoinTag) (ConversationJoin (pure qAlice) roleNameWireMember) } void $ runFedClient @"on-conversation-updated" fedGalleyClient chadDomain cu @@ -2143,12 +1944,12 @@ paginateConvListIdsPageEndingAtLocalsAndDomain = do replicateM_ 16 $ do conv <- randomId let cu = - F.ConversationUpdate - { F.cuTime = now, - F.cuOrigUserId = qDee, - F.cuConvId = conv, - F.cuAlreadyPresentUsers = [], - F.cuAction = SomeConversationAction (sing @'ConversationJoinTag) (ConversationJoin (pure qAlice) roleNameWireMember) + ConversationUpdate + { cuTime = now, + cuOrigUserId = qDee, + cuConvId = conv, + cuAlreadyPresentUsers = [], + cuAction = SomeConversationAction (sing @'ConversationJoinTag) (ConversationJoin (pure qAlice) roleNameWireMember) } void $ runFedClient @"on-conversation-updated" fedGalleyClient deeDomain cu @@ -2345,8 +2146,8 @@ postConvQualifiedFederationNotEnabled = do connectWithRemoteUser alice bob let federatorNotConfigured o = o - & optFederator .~ Nothing - & optRabbitmq .~ Nothing + & federator .~ Nothing + & rabbitmq .~ Nothing withSettingsOverrides federatorNotConfigured $ do g <- viewGalley unreachable :: UnreachableBackends <- @@ -2359,7 +2160,7 @@ postConvQualifiedFederationNotEnabled = do -- FUTUREWORK: figure out how to use functions in the TestM monad inside withSettingsOverrides and remove this duplication postConvHelper :: MonadHttp m => (Request -> Request) -> UserId -> [Qualified UserId] -> m ResponseLBS postConvHelper g zusr newUsers = do - let conv = NewConv [] newUsers (checked "gossip") (Set.fromList []) Nothing Nothing Nothing Nothing roleNameWireAdmin ProtocolProteusTag + let conv = NewConv [] newUsers (checked "gossip") (Set.fromList []) Nothing Nothing Nothing Nothing roleNameWireAdmin BaseProtocolProteusTag post $ g . path "/conversations" . zUser zusr . zConn "conn" . zType "access" . json conv postSelfConvOk :: TestM () @@ -2368,8 +2169,8 @@ postSelfConvOk = do let alice = qUnqualified qalice m <- postSelfConv alice getConvQualified bob qconvId liftIO $ do - ConnectConv @=? cnvType cnvX - Just "B" @=? cnvName cnvX - privateAccess @=? cnvAccess cnvX + ConnectConv @=? C.cnvType cnvX + Just "B" @=? C.cnvName cnvX + privateAccess @=? C.cnvAccess cnvX -- Alice accepts, finally turning it into a 1-1 putConvAccept alice convId !!! const 200 === statusCode cnv4 <- responseJsonUnsafeWithMsg "conversation" <$> getConvQualified alice qconvId liftIO $ do - One2OneConv @=? cnvType cnv4 - Just "B" @=? cnvName cnv4 - privateAccess @=? cnvAccess cnv4 + One2OneConv @=? C.cnvType cnv4 + Just "B" @=? C.cnvName cnv4 + privateAccess @=? C.cnvAccess cnv4 where cancel u c = do g <- viewGalley @@ -2586,7 +2387,7 @@ accessConvMeta = do let meta = ConversationMetadata RegularConv - alice + (Just alice) [InviteAccess] (Set.fromList [TeamMemberAccessRole, NonTeamMemberAccessRole, ServiceAccessRole]) (Just "gossip") @@ -2606,126 +2407,14 @@ leaveConnectConversation = do qc <- Qualified c <$> viewFederationDomain deleteMemberQualified alice qalice qc !!! const 403 === statusCode -testAddRemoteMember :: TestM () -testAddRemoteMember = do - qalice <- randomQualifiedUser - let alice = qUnqualified qalice - let localDomain = qDomain qalice - bobId <- randomId - let remoteDomain = Domain "far-away.example.com" - remoteBob = Qualified bobId remoteDomain - convId <- decodeConvId <$> postConv alice [] (Just "remote gossip") [] Nothing Nothing - let qconvId = Qualified convId localDomain - - postQualifiedMembers alice (remoteBob :| []) qconvId !!! do - const 403 === statusCode - const (Right (Just "not-connected")) === fmap (view (at "label")) . responseJsonEither @Object - - connectWithRemoteUser alice remoteBob - - (resp, reqs) <- - withTempMockFederator' (respond remoteBob) $ - postQualifiedMembers alice (remoteBob :| []) qconvId - getConvQualified alice qconvId - liftIO $ do - let actual = cmOthers $ cnvMembers conv - let expected = [OtherMember remoteBob Nothing roleNameWireAdmin] - assertEqual "other members should include remoteBob" expected actual - where - respond :: Qualified UserId -> Mock LByteString - respond bob = - asum - [ getNotFullyConnectedBackendsMock <|> guardComponent Brig *> mockReply [mkProfile bob (Name "bob")] - ] - -testDeleteTeamConversationWithRemoteMembers :: TestM () -testDeleteTeamConversationWithRemoteMembers = do - (alice, tid) <- createBindingTeam - localDomain <- viewFederationDomain - let qalice = Qualified alice localDomain - - bobId <- randomId - let remoteDomain = Domain "far-away.example.com" - remoteBob = Qualified bobId remoteDomain - - convId <- decodeConvId <$> postTeamConv tid alice [] (Just "remote gossip") [] Nothing Nothing - let qconvId = Qualified convId localDomain - - connectWithRemoteUser alice remoteBob - - let mock = getNotFullyConnectedBackendsMock <|> "api-version" ~> EmptyResponse - (_, received) <- withTempMockFederator' mock $ do - postQualifiedMembers alice (remoteBob :| []) qconvId - !!! const 200 === statusCode - - deleteTeamConv tid convId alice - !!! const 200 === statusCode - - liftIO $ do - let convUpdates = mapMaybe (eitherToMaybe . parseFedRequest) received - convUpdate <- case filter ((== SomeConversationAction (sing @'ConversationDeleteTag) ()) . cuAction) convUpdates of - [] -> assertFailure "No ConversationUpdate requests received" - [convDelete] -> pure convDelete - _ -> assertFailure "Multiple ConversationUpdate requests received" - cuAlreadyPresentUsers convUpdate @?= [bobId] - cuOrigUserId convUpdate @?= qalice - -testDeleteTeamConversationWithUnavailableRemoteMembers :: TestM () -testDeleteTeamConversationWithUnavailableRemoteMembers = do - (alice, tid) <- createBindingTeam - localDomain <- viewFederationDomain - let qalice = Qualified alice localDomain - - bobId <- randomId - let remoteDomain = Domain "far-away.example.com" - remoteBob = Qualified bobId remoteDomain - - convId <- decodeConvId <$> postTeamConv tid alice [] (Just "remote gossip") [] Nothing Nothing - let qconvId = Qualified convId localDomain - - connectWithRemoteUser alice remoteBob - - let mock = - getNotFullyConnectedBackendsMock - <|> - -- Mock an unavailable federation server for the deletion call - (guardRPC "on-conversation-updated" *> throw (MockErrorResponse HTTP.status503 "Down for maintenance.")) - <|> (guardRPC "delete-team-conversation" *> throw (MockErrorResponse HTTP.status503 "Down for maintenance.")) - (_, received) <- withTempMockFederator' mock $ do - postQualifiedMembers alice (remoteBob :| []) qconvId - !!! const 200 === statusCode - - deleteTeamConv tid convId alice - !!! const 200 === statusCode - liftIO $ do - let convUpdates = mapMaybe (eitherToMaybe . parseFedRequest) received - convUpdate <- case filter ((== SomeConversationAction (sing @'ConversationDeleteTag) ()) . cuAction) convUpdates of - [] -> assertFailure "No ConversationUpdate requests received" - [convDelete] -> pure convDelete - _ -> assertFailure "Multiple ConversationUpdate requests received" - cuAlreadyPresentUsers convUpdate @?= [bobId] - cuOrigUserId convUpdate @?= qalice - testGetQualifiedLocalConv :: TestM () testGetQualifiedLocalConv = do alice <- randomUser convId <- decodeQualifiedConvId <$> postConv alice [] (Just "gossip") [] Nothing Nothing conv :: Conversation <- fmap responseJsonUnsafe $ getConvQualified alice convId randomId - qconvId <- decodeQualifiedConvId <$> postConv alice [] (Just "remote gossip") [] Nothing Nothing - connectWithRemoteUser alice remoteBob - - -- federator endpoint being configured in brig and/or galley, but not being - -- available (i.e. no service listing on that IP/port) can happen due to a - -- misconfiguration of federator. That should give an unreachable_backends error. - -- Port 1 should always be wrong hopefully. - let federatorUnavailable = optFederator ?~ Endpoint "127.0.0.1" 1 - withSettingsOverrides federatorUnavailable $ do - e :: UnreachableBackends <- - responseJsonError - =<< postQualifiedMembers alice (remoteBob :| []) qconvId [alice, bob] - remoteDomain = Domain "far-away.example.com" - qEve = Qualified eve remoteDomain - - connectUsers alice (singleton bob) - connectWithRemoteUser alice qEve - convId <- - decodeConvId - <$> postConvWithRemoteUsers - alice - Nothing - defNewProteusConv {newConvQualifiedUsers = [qBob, qEve]} - let qconvId = Qualified convId localDomain - - let mockReturnEve = - mockedFederatedBrigResponse [(qEve, "Eve")] - <|> mockReply EmptyResponse - (respDel, fedRequests) <- - withTempMockFederator' mockReturnEve $ - deleteMemberQualified alice qBob qconvId - let [galleyFederatedRequest] = fedRequestsForDomain remoteDomain Galley fedRequests - assertRemoveUpdate galleyFederatedRequest qconvId qAlice [qUnqualified qEve] qBob - - liftIO $ do - statusCode respDel @?= 200 - case responseJsonEither respDel of - Left err -> assertFailure err - Right e -> assertLeaveEvent qconvId qAlice [qBob] e - - -- Now that Bob is gone, try removing him once again - deleteMemberQualified alice qBob qconvId !!! do - const 204 === statusCode - const Nothing === responseBody - --- Creates a conversation with five users. Alice and Bob are on the local --- domain. Chad and Dee are on far-away-1.example.com. Eve is on --- far-away-2.example.com. It uses a qualified endpoint to remove Chad from the --- conversation: --- --- DELETE /conversations/:domain/:cnv/members/:domain/:usr -deleteRemoteMemberConvLocalQualifiedOk :: TestM () -deleteRemoteMemberConvLocalQualifiedOk = do - localDomain <- viewFederationDomain - [alice, bob] <- randomUsers 2 - let [qAlice, qBob] = (`Qualified` localDomain) <$> [alice, bob] - remoteDomain1 = Domain "far-away-1.example.com" - remoteDomain2 = Domain "far-away-2.example.com" - qChad <- (`Qualified` remoteDomain1) <$> randomId - qDee <- (`Qualified` remoteDomain1) <$> randomId - qEve <- (`Qualified` remoteDomain2) <$> randomId - connectUsers alice (singleton bob) - mapM_ (connectWithRemoteUser alice) [qChad, qDee, qEve] - - let mockedResponse = do - guardRPC "get-users-by-ids" - d <- frTargetDomain <$> getRequest - asum - [ guard (d == remoteDomain1) - *> mockReply [mkProfile qChad (Name "Chad"), mkProfile qDee (Name "Dee")], - guard (d == remoteDomain2) - *> mockReply [mkProfile qEve (Name "Eve")] - ] - (convId, _) <- - withTempMockFederator' (getNotFullyConnectedBackendsMock <|> mockedResponse <|> mockReply EmptyResponse) $ - fmap decodeConvId $ - postConvQualified - alice - Nothing - defNewProteusConv {newConvQualifiedUsers = [qBob, qChad, qDee, qEve]} - mockedResponse <|> mockReply EmptyResponse) $ - deleteMemberQualified alice qChad qconvId - liftIO $ do - statusCode respDel @?= 200 - case responseJsonEither respDel of - Left err -> assertFailure err - Right e -> assertLeaveEvent qconvId qAlice [qChad] e - - let [remote1GalleyFederatedRequest] = fedRequestsForDomain remoteDomain1 Galley federatedRequests - [remote2GalleyFederatedRequest] = fedRequestsForDomain remoteDomain2 Galley federatedRequests - assertRemoveUpdate remote1GalleyFederatedRequest qconvId qAlice [qUnqualified qChad, qUnqualified qDee] qChad - assertRemoveUpdate remote2GalleyFederatedRequest qconvId qAlice [qUnqualified qEve] qChad - - -- Now that Chad is gone, try removing him once again - deleteMemberQualified alice qChad qconvId !!! do - const 204 === statusCode - const Nothing === responseBody - --- Creates a conversation with five users. Alice and Bob are on the local --- domain. Chad and Dee are on far-away-1.example.com. Eve is on --- far-away-2.example.com. It uses a qualified endpoint to remove Chad from the --- conversation. The federator for far-away-2.example.com isn't availabe: --- --- DELETE /conversations/:domain/:cnv/members/:domain/:usr -deleteUnavailableRemoteMemberConvLocalQualifiedOk :: TestM () -deleteUnavailableRemoteMemberConvLocalQualifiedOk = do - localDomain <- viewFederationDomain - [alice, bob] <- randomUsers 2 - let [qAlice, qBob] = (`Qualified` localDomain) <$> [alice, bob] - remoteDomain1 = Domain "far-away-1.example.com" - remoteDomain2 = Domain "far-away-2.example.com" - qChad <- (`Qualified` remoteDomain1) <$> randomId - qDee <- (`Qualified` remoteDomain1) <$> randomId - qEve <- (`Qualified` remoteDomain2) <$> randomId - connectUsers alice (singleton bob) - mapM_ (connectWithRemoteUser alice) [qChad, qDee, qEve] - - let mockedGetUsers remote2Response = do - guardRPC "get-users-by-ids" - d <- frTargetDomain <$> getRequest - asum - [ guard (d == remoteDomain1) - *> mockReply [mkProfile qChad (Name "Chad"), mkProfile qDee (Name "Dee")], - guard (d == remoteDomain2) - *> remote2Response - ] - mockedCreateConvGetUsers = - mockedGetUsers (mockReply [mkProfile qEve (Name "Eve")]) - mockedRemMemGetUsers = - mockedGetUsers (throw (MockErrorResponse HTTP.status503 "Down for maintenance.")) - mockedOther = do - d <- frTargetDomain <$> getRequest - asum - [ guard (d == remoteDomain1) - *> mockReply EmptyResponse, - guard (d == remoteDomain2) - *> asum - [ guardRPC "on-conversation-created" *> mockReply EmptyResponse, - guardRPC "on-conversation-updated" *> mockReply EmptyResponse, - throw $ MockErrorResponse HTTP.status503 "Down for maintenance." - ] - ] - convId <- - fmap decodeConvId $ - postConvWithRemoteUsersGeneric - (mockedCreateConvGetUsers <|> mockedOther) - alice - Nothing - defNewProteusConv {newConvQualifiedUsers = [qBob, qChad, qDee, qEve]} - mockedOther) $ - deleteMemberQualified alice qChad qconvId - liftIO $ do - statusCode respDel @?= 200 - case responseJsonEither respDel of - Left err -> assertFailure err - Right e -> assertLeaveEvent qconvId qAlice [qChad] e - - let [remote1GalleyFederatedRequest] = fedRequestsForDomain remoteDomain1 Galley federatedRequests - [remote2GalleyFederatedRequest] = fedRequestsForDomain remoteDomain2 Galley federatedRequests - assertRemoveUpdate remote1GalleyFederatedRequest qconvId qAlice [qUnqualified qChad, qUnqualified qDee] qChad - assertRemoveUpdate remote2GalleyFederatedRequest qconvId qAlice [qUnqualified qEve] qChad - - -- Now that Chad is gone, try removing him once again - deleteMemberQualified alice qChad qconvId !!! do - const 204 === statusCode - const Nothing === responseBody - -- Alice, a local user, leaves a remote conversation. Bob's domain is the same -- as that of the conversation. The test uses the following endpoint: -- @@ -3319,7 +2810,7 @@ leaveRemoteConvQualifiedOk = do qBob = Qualified bob remoteDomain let mockedFederatedGalleyResponse = do guardComponent Galley - mockReply (F.LeaveConversationResponse (Right mempty)) + mockReply (LeaveConversationResponse (Right mempty)) mockResponses = mockedFederatedBrigResponse [(qBob, "Bob")] <|> mockedFederatedGalleyResponse @@ -3327,7 +2818,7 @@ leaveRemoteConvQualifiedOk = do (resp, fedRequests) <- withTempMockFederator' mockResponses $ deleteMemberQualified alice qAlice qconv - let leaveRequest = + let leaveRequest :: LeaveConversationRequest = fromJust . decode . frBody . Imports.head $ fedRequests liftIO $ do @@ -3335,8 +2826,8 @@ leaveRemoteConvQualifiedOk = do case responseJsonEither resp of Left err -> assertFailure err Right e -> assertLeaveEvent qconv qAlice [qAlice] e - F.lcConvId leaveRequest @?= conv - F.lcLeaver leaveRequest @?= alice + leaveRequest.convId @?= conv + leaveRequest.leaver @?= alice -- Alice tries to leave a non-existent remote conversation leaveNonExistentRemoteConv :: TestM () @@ -3348,20 +2839,20 @@ leaveNonExistentRemoteConv = do let mockResponses = do guardComponent Galley mockReply $ - F.LeaveConversationResponse (Left F.RemoveFromConversationErrorNotFound) + LeaveConversationResponse (Left RemoveFromConversationErrorNotFound) (resp, fedRequests) <- withTempMockFederator' mockResponses $ responseJsonError =<< deleteMemberQualified (qUnqualified alice) alice conv randomId <*> pure remoteDomain - qbob <- randomQualifiedUser - let bob = qUnqualified qbob - - connectWithRemoteUser bob qalice - - resp <- - postConvWithRemoteUsers - bob - Nothing - defNewProteusConv {newConvQualifiedUsers = [qalice]} - do - (_, requests) <- - withTempMockFederator' (mockReply EmptyResponse) $ - putQualifiedConversationName bob qconv "gossip++" !!! const 200 === statusCode - - req <- assertOne requests - liftIO $ do - frTargetDomain req @?= remoteDomain - frComponent req @?= Galley - frRPC req @?= "on-conversation-updated" - Right cu <- pure . eitherDecode . frBody $ req - F.cuConvId cu @?= qUnqualified qconv - F.cuAction cu @?= SomeConversationAction (sing @'ConversationRenameTag) (ConversationRename "gossip++") - - void . liftIO . WS.assertMatch (5 # Second) wsB $ \n -> do - let e = List1.head (WS.unpackPayload n) - ntfTransient n @?= False - evtConv e @?= qconv - evtType e @?= ConvRename - evtFrom e @?= qbob - evtData e @?= EdConvRename (ConversationRename "gossip++") - -putQualifiedConvRenameWithRemotesUnavailable :: TestM () -putQualifiedConvRenameWithRemotesUnavailable = do - c <- view tsCannon - let remoteDomain = Domain "alice.example.com" - qalice <- Qualified <$> randomId <*> pure remoteDomain - qbob <- randomQualifiedUser - let bob = qUnqualified qbob - - connectWithRemoteUser bob qalice - - resp <- - postConvWithRemoteUsers - bob - Nothing - defNewProteusConv {newConvQualifiedUsers = [qalice]} - do - (_, requests) <- - withTempMockFederator' (throw $ MockErrorResponse HTTP.status503 "Down for maintenance") $ - putQualifiedConversationName bob qconv "gossip++" !!! const 200 === statusCode - - req <- assertOne requests - liftIO $ do - frTargetDomain req @?= remoteDomain - frComponent req @?= Galley - frRPC req @?= "on-conversation-updated" - Right cu <- pure . eitherDecode . frBody $ req - F.cuConvId cu @?= qUnqualified qconv - F.cuAction cu @?= SomeConversationAction (sing @'ConversationRenameTag) (ConversationRename "gossip++") - - void . liftIO . WS.assertMatch (5 # Second) wsB $ \n -> do - let e = List1.head (WS.unpackPayload n) - ntfTransient n @?= False - evtConv e @?= qconv - evtType e @?= ConvRename - evtFrom e @?= qbob - evtData e @?= EdConvRename (ConversationRename "gossip++") - putConvDeprecatedRenameOk :: TestM () putConvDeprecatedRenameOk = do c <- view tsCannon @@ -3791,7 +3202,7 @@ putRemoteConvMemberOk update = do fedGalleyClient <- view tsFedGalleyClient now <- liftIO getCurrentTime let cu = - F.ConversationUpdate + ConversationUpdate { cuTime = now, cuOrigUserId = qbob, cuConvId = qUnqualified qconv, @@ -3936,7 +3347,7 @@ putRemoteReceiptModeOk = do fedGalleyClient <- view tsFedGalleyClient now <- liftIO getCurrentTime let cuAddAlice = - F.ConversationUpdate + ConversationUpdate { cuTime = now, cuOrigUserId = qbob, cuConvId = qUnqualified qconv, @@ -3951,7 +3362,7 @@ putRemoteReceiptModeOk = do let adam = qUnqualified qadam connectWithRemoteUser adam qbob let cuAddAdam = - F.ConversationUpdate + ConversationUpdate { cuTime = now, cuOrigUserId = qbob, cuConvId = qUnqualified qconv, @@ -3964,7 +3375,7 @@ putRemoteReceiptModeOk = do let newReceiptMode = ReceiptMode 42 let action = ConversationReceiptModeUpdate newReceiptMode let responseConvUpdate = - F.ConversationUpdate + ConversationUpdate { cuTime = now, cuOrigUserId = qalice, cuConvId = qUnqualified qconv, @@ -3985,99 +3396,15 @@ putRemoteReceiptModeOk = do liftIO $ assertEqual "Unexcepected receipt mode in event" newReceiptMode receiptModeEvent cFedReq <- assertOne $ filter (\r -> frTargetDomain r == remoteDomain && frRPC r == "update-conversation") federatedRequests - cFedReqBody <- assertRight $ parseFedRequest cFedReq + cFedReqBody :: ConversationUpdateRequest <- assertRight $ parseFedRequest cFedReq liftIO $ do - curUser cFedReqBody @?= alice - curConvId cFedReqBody @?= qUnqualified qconv - curAction cFedReqBody @?= SomeConversationAction (sing @'ConversationReceiptModeUpdateTag) action + cFedReqBody.user @?= alice + cFedReqBody.convId @?= qUnqualified qconv + cFedReqBody.action @?= SomeConversationAction (sing @'ConversationReceiptModeUpdateTag) action WS.assertMatch_ (5 # Second) wsAdam $ \n -> do liftIO $ wsAssertConvReceiptModeUpdate qconv qalice newReceiptMode n -putReceiptModeWithRemotesOk :: TestM () -putReceiptModeWithRemotesOk = do - c <- view tsCannon - let remoteDomain = Domain "alice.example.com" - qalice <- Qualified <$> randomId <*> pure remoteDomain - qbob <- randomQualifiedUser - let bob = qUnqualified qbob - - connectWithRemoteUser bob qalice - - resp <- - postConvWithRemoteUsers - bob - Nothing - defNewProteusConv {newConvQualifiedUsers = [qalice]} - let qconv = decodeQualifiedConvId resp - - WS.bracketR c bob $ \wsB -> do - (_, requests) <- - withTempMockFederator' (mockReply EmptyResponse) $ - putQualifiedReceiptMode bob qconv (ReceiptMode 43) !!! const 200 === statusCode - - req <- assertOne requests - liftIO $ do - frTargetDomain req @?= remoteDomain - frComponent req @?= Galley - frRPC req @?= "on-conversation-updated" - Right cu <- pure . eitherDecode . frBody $ req - F.cuConvId cu @?= qUnqualified qconv - F.cuAction cu - @?= SomeConversationAction (sing @'ConversationReceiptModeUpdateTag) (ConversationReceiptModeUpdate (ReceiptMode 43)) - - void . liftIO . WS.assertMatch (5 # Second) wsB $ \n -> do - let e = List1.head (WS.unpackPayload n) - ntfTransient n @?= False - evtConv e @?= qconv - evtType e @?= ConvReceiptModeUpdate - evtFrom e @?= qbob - evtData e - @?= EdConvReceiptModeUpdate - (ConversationReceiptModeUpdate (ReceiptMode 43)) - -putReceiptModeWithRemotesUnavailable :: TestM () -putReceiptModeWithRemotesUnavailable = do - c <- view tsCannon - let remoteDomain = Domain "alice.example.com" - qalice <- Qualified <$> randomId <*> pure remoteDomain - qbob <- randomQualifiedUser - let bob = qUnqualified qbob - - connectWithRemoteUser bob qalice - - resp <- - postConvWithRemoteUsers - bob - Nothing - defNewProteusConv {newConvQualifiedUsers = [qalice]} - let qconv = decodeQualifiedConvId resp - - WS.bracketR c bob $ \wsB -> do - (_, requests) <- - withTempMockFederator' (throw $ MockErrorResponse HTTP.status503 "Down for maintenance") $ - putQualifiedReceiptMode bob qconv (ReceiptMode 43) !!! const 200 === statusCode - - req <- assertOne requests - liftIO $ do - frTargetDomain req @?= remoteDomain - frComponent req @?= Galley - frRPC req @?= "on-conversation-updated" - Right cu <- pure . eitherDecode . frBody $ req - F.cuConvId cu @?= qUnqualified qconv - F.cuAction cu - @?= SomeConversationAction (sing @'ConversationReceiptModeUpdateTag) (ConversationReceiptModeUpdate (ReceiptMode 43)) - - void . liftIO . WS.assertMatch (5 # Second) wsB $ \n -> do - let e = List1.head (WS.unpackPayload n) - ntfTransient n @?= False - evtConv e @?= qconv - evtType e @?= ConvReceiptModeUpdate - evtFrom e @?= qbob - evtData e - @?= EdConvReceiptModeUpdate - (ConversationReceiptModeUpdate (ReceiptMode 43)) - postTypingIndicatorsV2 :: TestM () postTypingIndicatorsV2 = do c <- view tsCannon @@ -4262,18 +3589,18 @@ removeUser = do now <- liftIO getCurrentTime fedGalleyClient <- view tsFedGalleyClient let nc cid creator quids = - F.ConversationCreated - { F.ccTime = now, - F.ccOrigUserId = qUnqualified creator, - F.ccCnvId = cid, - F.ccCnvType = RegularConv, - F.ccCnvAccess = [], - F.ccCnvAccessRoles = Set.fromList [], - F.ccCnvName = Just "gossip4", - F.ccNonCreatorMembers = Set.fromList $ createOtherMember <$> quids, - F.ccMessageTimer = Nothing, - F.ccReceiptMode = Nothing, - F.ccProtocol = ProtocolProteus + ConversationCreated + { time = now, + origUserId = qUnqualified creator, + cnvId = cid, + cnvType = RegularConv, + cnvAccess = [], + cnvAccessRoles = Set.fromList [], + cnvName = Just "gossip4", + nonCreatorMembers = Set.fromList $ createOtherMember <$> quids, + messageTimer = Nothing, + receiptMode = Nothing, + protocol = ProtocolProteus } void $ runFedClient @"on-conversation-created" fedGalleyClient bDomain $ nc convB1 bart [alice, alexDel] void $ runFedClient @"on-conversation-created" fedGalleyClient bDomain $ nc convB2 bart [alexDel] @@ -4289,7 +3616,7 @@ removeUser = do throw (DiscoveryFailureSrvNotAvailable "dDomain"), do guard (d `elem` [bDomain, cDomain]) - "leave-conversation" ~> F.LeaveConversationResponse (Right mempty) + "leave-conversation" ~> LeaveConversationResponse (Right mempty) ] (_, fedRequests) <- withTempMockFederator' handler $ @@ -4390,12 +3717,12 @@ testOne2OneConversationRequest shouldBeLocal actor desired = do fedGalleyClient <- view tsFedGalleyClient GetConversationsResponse convs <- runFedClient @"get-conversations" fedGalleyClient (tDomain bob) $ - F.GetConversationsRequest - { F.gcrUserId = tUnqualified bob, - F.gcrConvIds = [qUnqualified convId] + GetConversationsRequest + { userId = tUnqualified bob, + convIds = [qUnqualified convId] } pure - . fmap (map omQualifiedId . rcmOthers . rcnvMembers) + . fmap (map omQualifiedId . (.members.others)) . listToMaybe $ convs liftIO $ case desired of @@ -4407,84 +3734,8 @@ testOne2OneConversationRequest shouldBeLocal actor desired = do found <- do let rconv = mkProteusConv (qUnqualified convId) (tUnqualified bob) roleNameWireAdmin [] (resp, _) <- - withTempMockFederator' (mockReply (F.GetConversationsResponse [rconv])) $ + withTempMockFederator' (mockReply (GetConversationsResponse [rconv])) $ getConvQualified (tUnqualified alice) convId pure $ statusCode resp == 200 liftIO $ found @?= ((actor, desired) == (LocalActor, Included)) ) - --- Testing defederation notifications. The important thing to note for all --- of this is that when defederating from a remote domain only _2_ notifications --- are sent, and both are identical. One notification is at the start of --- defederation, and one is sent at the end of defederation. No other --- notifications about users being removed from conversations, or conversations --- being deleted are sent. We are do not want to DOS either our local clients, --- nor our own services. -testDefederationNotifications :: TestM () -testDefederationNotifications = do - -- alice, bob are in a team - (tid, alice, [bob]) <- createBindingTeamWithQualifiedMembers 2 - - -- charlie is a local guest - charlie <- randomQualifiedUser - connectUsers (qUnqualified alice) (pure (qUnqualified charlie)) - - let remoteDomain = Domain "far-away.example.com" - -- This variable should be commented out if the below - -- section is used to insert users to the database. - users = [] - -- This section of code is useful to massively increase - -- the amount of users in the testing database. This is - -- useful for checking that notifications are being fanned - -- out correctly, and that all users are sent a - -- notification. If the database already has a large - -- amount of users then this can be left out and will also - -- allow this test to run faster. - -- count = 10000 - -- users <- replicateM count randomQualifiedUser - -- replicateM_ count $ do - -- connectWithRemoteUser (qUnqualified alice) =<< - -- Qualified <$> randomId <*> pure remoteDomain - - -- dee is a remote guest - dee <- Qualified <$> randomId <*> pure remoteDomain - - connectWithRemoteUser (qUnqualified alice) dee - - -- they are all in a local conversation - conv <- - responseJsonError - =<< postConvWithRemoteUsers - (qUnqualified alice) - Nothing - defNewProteusConv - { newConvQualifiedUsers = [bob, charlie, dee], - newConvTeam = Just (ConvTeamInfo tid) - } - users) $ \(wsA : wsB : wsC : wsD : wsUsers) -> do - -- conversation access role changes to team only - (_, reqs) <- withTempMockFederator' (mockReply ()) $ do - -- Delete the domain that Dee lives on - deleteFederation remoteDomain !!! const 200 === statusCode - -- First notification to local clients - WS.assertMatchN_ (5 # Second) ([wsA, wsB, wsC] <> wsUsers) $ - wsAssertFederationDeleted remoteDomain - -- Second notification to local clients - WS.assertMatchN_ (5 # Second) ([wsA, wsB, wsC] <> wsUsers) $ - wsAssertFederationDeleted remoteDomain - -- dee's remote doesn't receive a notification - WS.assertNoEvent (5 # Second) [wsD] - -- There should be not requests out to the federtaion domain - liftIO $ reqs @?= [] - - -- only alice, bob, and charlie remain - conv2 <- - responseJsonError - =<< getConvQualified (qUnqualified alice) (cnvQualifiedId conv) - cmOthers (cnvMembers conv2)) @?= sort [bob, charlie] - --- @END diff --git a/services/galley/test/integration/API/Federation.hs b/services/galley/test/integration/API/Federation.hs index c0d83cae44c..6c71f87448b 100644 --- a/services/galley/test/integration/API/Federation.hs +++ b/services/galley/test/integration/API/Federation.hs @@ -23,7 +23,6 @@ import Bilge hiding (head) import Bilge.Assert import Control.Exception import Control.Lens hiding ((#)) -import Data.Aeson qualified as A import Data.ByteString.Conversion (toByteString') import Data.Domain import Data.Id @@ -34,7 +33,6 @@ import Data.List1 qualified as List1 import Data.Map qualified as Map import Data.ProtoLens qualified as Protolens import Data.Qualified -import Data.Range import Data.Set qualified as Set import Data.Singletons import Data.Time.Clock @@ -50,6 +48,7 @@ import Test.Tasty.HUnit import TestHelpers import TestSetup import Wire.API.Conversation +import Wire.API.Conversation qualified as Conv import Wire.API.Conversation.Action import Wire.API.Conversation.Role import Wire.API.Event.Conversation @@ -82,13 +81,10 @@ tests s = test s "POST /federation/on-conversation-updated : Notify local user about receipt mode update" notifyReceiptMode, test s "POST /federation/on-conversation-updated : Notify local user about access update" notifyAccess, test s "POST /federation/on-conversation-updated : Notify local users about a deleted conversation" notifyDeletedConversation, - test s "POST /federation/leave-conversation : Success" leaveConversationSuccess, test s "POST /federation/leave-conversation : Non-existent" leaveConversationNonExistent, test s "POST /federation/leave-conversation : Invalid type" leaveConversationInvalidType, test s "POST /federation/on-message-sent : Receive a message from another backend" onMessageSent, test s "POST /federation/send-message : Post a message sent from another backend" sendMessage, - test s "POST /federation/on-user-deleted-conversations : Remove deleted remote user from local conversations" onUserDeleted, - test s "POST /federation/update-conversation : Update local conversation by a remote admin " updateConversationByRemoteAdmin, test s "POST /federation/on-conversation-updated : Notify local user about conversation rename with an unavailable federator" notifyConvRenameUnavailable, test s "POST /federation/on-conversation-updated : Notify local user about message timer update with an unavailable federator" notifyMessageTimerUnavailable, test s "POST /federation/on-conversation-updated : Notify local user about receipt mode update with an unavailable federator" notifyReceiptModeUnavailable, @@ -149,21 +145,21 @@ getConversationsAllFound = do (qUnqualified aliceQ) (map qUnqualified [cnv1Id, cnvQualifiedId cnv2]) - let c2 = find ((== qUnqualified (cnvQualifiedId cnv2)) . rcnvId) convs + let c2 = find ((== qUnqualified (cnvQualifiedId cnv2)) . (.id)) convs liftIO $ do assertEqual "name mismatch" - (Just $ cnvName cnv2) - (cnvmName . rcnvMetadata <$> c2) + (Just $ Conv.cnvName cnv2) + ((.metadata.cnvmName) <$> c2) assertEqual "self member role mismatch" (Just . memConvRoleName . cmSelf $ cnvMembers cnv2) - (rcmSelfRole . rcnvMembers <$> c2) + ((.members.selfRole) <$> c2) assertEqual "other members mismatch" (Just (sort [bob, qUnqualified carlQ])) - (fmap (sort . map (qUnqualified . omQualifiedId) . rcmOthers . rcnvMembers) c2) + (fmap (sort . map (qUnqualified . omQualifiedId) . (.members.others)) c2) -- @SF.Federation @TSFI.RESTfulAPI @S2 -- @@ -710,69 +706,6 @@ addRemoteUser = do WS.assertNoEvent (1 # Second) [wsC] WS.assertNoEvent (1 # Second) [wsF] -leaveConversationSuccess :: TestM () -leaveConversationSuccess = do - localDomain <- viewFederationDomain - c <- view tsCannon - [alice, bob] <- randomUsers 2 - let qBob = Qualified bob localDomain - remoteDomain1 = Domain "far-away-1.example.com" - remoteDomain2 = Domain "far-away-2.example.com" - qChad <- (`Qualified` remoteDomain1) <$> randomId - qDee <- (`Qualified` remoteDomain1) <$> randomId - qEve <- (`Qualified` remoteDomain2) <$> randomId - connectUsers alice (singleton bob) - connectWithRemoteUser alice qChad - connectWithRemoteUser alice qDee - connectWithRemoteUser alice qEve - - let mock = do - guardRPC "get-users-by-ids" - d <- frTargetDomain <$> getRequest - asum - [ guard (d == remoteDomain1) - *> mockReply [mkProfile qChad (Name "Chad"), mkProfile qDee (Name "Dee")], - guard (d == remoteDomain2) - *> mockReply [mkProfile qEve (Name "Eve")] - ] - - convId <- - decodeConvId - <$> postConvWithRemoteUsersGeneric - (mock <|> mockReply EmptyResponse) - alice - Nothing - defNewProteusConv - { newConvQualifiedUsers = [qBob, qChad, qDee, qEve] - } - let qconvId = Qualified convId localDomain - - (_, federatedRequests) <- - WS.bracketR2 c alice bob $ \(wsAlice, wsBob) -> do - withTempMockFederator' ("get-not-fully-connected-backends" ~> NonConnectedBackends mempty <|> mock <|> mockReply EmptyResponse) $ do - g <- viewGalley - let leaveRequest = FedGalley.LeaveConversationRequest convId (qUnqualified qChad) - respBS <- - post - ( g - . paths ["federation", "leave-conversation"] - . content "application/json" - . header "Wire-Origin-Domain" (toByteString' remoteDomain1) - . json leaveRequest - ) - ) [(alice, msg aliceC1), (alice, msg aliceC2), (eve, msg eveC)] rm = FedGalley.RemoteMessage - { FedGalley.rmTime = now, - FedGalley.rmData = Nothing, - FedGalley.rmSender = qbob, - FedGalley.rmSenderClient = fromc, - FedGalley.rmConversation = conv, - FedGalley.rmPriority = Nothing, - FedGalley.rmTransient = False, - FedGalley.rmPush = False, - FedGalley.rmRecipients = rcpts + { FedGalley.time = now, + FedGalley._data = Nothing, + FedGalley.sender = qbob, + FedGalley.senderClient = fromc, + FedGalley.conversation = conv, + FedGalley.priority = Nothing, + FedGalley.transient = False, + FedGalley.push = False, + FedGalley.recipients = rcpts } -- send message to alice and check reception @@ -950,9 +883,9 @@ sendMessage = do msg = mkQualifiedOtrPayload bobClient rcpts "" MismatchReportAll msr = FedGalley.ProteusMessageSendRequest - { FedGalley.pmsrConvId = convId, - FedGalley.pmsrSender = bobId, - FedGalley.pmsrRawMessage = Base64ByteString (Protolens.encodeMessage msg) + { FedGalley.convId = convId, + FedGalley.sender = bobId, + FedGalley.rawMessage = Base64ByteString (Protolens.encodeMessage msg) } let mock = do guardComponent Brig @@ -987,232 +920,6 @@ sendMessage = do WS.assertMatch_ (5 # Second) ws $ wsAssertOtr' "" conv bob bobClient aliceClient (toBase64Text "hi alice") --- | There are 3 backends in action here: --- --- - Backend A (local) has Alice and Alex --- - Backend B has Bob and Bart --- - Backend C has Carl --- --- Bob is in these convs: --- - One2One Conv with Alice (ooConvId) --- - Group conv with all users (groupConvId) --- --- When bob gets deleted, backend A gets an RPC from bDomain stating that bob is --- deleted and they would like bob to leave these converstaions: --- - ooConvId -> Causes Alice to be notified --- - groupConvId -> Causes Alice and Alex to be notified --- - extraConvId -> Ignored --- - noBobConvId -> Ignored -onUserDeleted :: TestM () -onUserDeleted = do - cannon <- view tsCannon - let bDomain = Domain "b.far-away.example.com" - cDomain = Domain "c.far-away.example.com" - - alice <- qTagUnsafe <$> randomQualifiedUser - alex <- randomQualifiedUser - (bob, ooConvId) <- generateRemoteAndConvIdWithDomain bDomain True alice - bart <- randomQualifiedId bDomain - carl <- randomQualifiedId cDomain - - connectWithRemoteUser (tUnqualified alice) (tUntagged bob) - connectUsers (tUnqualified alice) (pure (qUnqualified alex)) - connectWithRemoteUser (tUnqualified alice) bart - connectWithRemoteUser (tUnqualified alice) carl - - -- create 1-1 conversation between alice and bob - createOne2OneConvWithRemote alice bob - - -- create group conversation with everybody - groupConvId <- WS.bracketR cannon (tUnqualified alice) $ \wsAlice -> do - convId <- - decodeQualifiedConvId - <$> ( postConvWithRemoteUsers - (tUnqualified alice) - Nothing - defNewProteusConv {newConvQualifiedUsers = [tUntagged bob, alex, bart, carl]} - do - convId <- - fmap decodeQualifiedConvId $ - postConvQualified - (tUnqualified alice) - Nothing - defNewProteusConv {newConvQualifiedUsers = [alex]} - do - (resp, rpcCalls) <- withTempMockFederator' (mockReply EmptyResponse) $ do - let udcn = - FedGalley.UserDeletedConversationsNotification - { FedGalley.udcvUser = tUnqualified bob, - FedGalley.udcvConversations = - unsafeRange - [ qUnqualified ooConvId, - qUnqualified groupConvId, - extraConvId, - qUnqualified noBobConvId - ] - } - g <- viewGalley - responseJsonError - =<< post - ( g - . paths ["federation", "on-user-deleted-conversations"] - . content "application/json" - . header "Wire-Origin-Domain" (toByteString' (tDomain bob)) - . json udcn - ) - show rpcCalls) 1 (length rpcCalls) - - -- Assertions about RPC to 'cDomain' - cDomainRPC <- assertOne $ filter (\c -> frTargetDomain c == cDomain) rpcCalls - cDomainRPCReq <- assertRight $ parseFedRequest cDomainRPC - FedGalley.cuOrigUserId cDomainRPCReq @?= tUntagged bob - FedGalley.cuConvId cDomainRPCReq @?= qUnqualified groupConvId - FedGalley.cuAlreadyPresentUsers cDomainRPCReq @?= [qUnqualified carl] - FedGalley.cuAction cDomainRPCReq @?= SomeConversationAction (sing @'ConversationLeaveTag) () - --- | We test only ReceiptMode update here --- --- A : local domain, owns the conversation --- B : bob is an admin of the converation --- C : charlie is a regular member of the conversation -updateConversationByRemoteAdmin :: TestM () -updateConversationByRemoteAdmin = do - c <- view tsCannon - (alice, qalice) <- randomUserTuple - - let bdomain = Domain "b.example.com" - cdomain = Domain "c.example.com" - qbob <- randomQualifiedId bdomain - qcharlie <- randomQualifiedId cdomain - mapM_ (connectWithRemoteUser alice) [qbob, qcharlie] - - let convName = "Test Conv" - WS.bracketR c alice $ \wsAlice -> do - (rsp, _federatedRequests) <- do - let mock = ("get-not-fully-connected-backends" ~> NonConnectedBackends mempty) <|> mockReply EmptyResponse - withTempMockFederator' mock $ do - postConvQualified alice Nothing defNewProteusConv {newConvName = checked convName, newConvQualifiedUsers = [qbob, qcharlie]} - assertFailure ("Expected ConversationUpdateResponseUpdate but got " <> show err) - ConversationUpdateResponseNoChanges -> assertFailure "Expected ConversationUpdateResponseUpdate but got ConversationUpdateResponseNoChanges" - ConversationUpdateResponseUpdate up -> pure up - ConversationUpdateResponseNonFederatingBackends _ -> assertFailure "Expected ConversationUpdateResponseUpdate but got ConversationUpdateResponseNonFederatingBackends" - ConversationUpdateResponseUnreachableBackends _ -> assertFailure "Expected ConversationUpdateResponseUpdate but got ConversationUpdateResponseUnreachableBackends" - - liftIO $ do - cuOrigUserId cnvUpdate' @?= qbob - cuAlreadyPresentUsers cnvUpdate' @?= [qUnqualified qbob] - cuAction cnvUpdate' @?= action - - -- backend A generates a notification for alice - void $ - WS.awaitMatch (5 # Second) wsAlice $ \n -> do - liftIO $ wsAssertConvReceiptModeUpdate cnv qalice newReceiptMode n - - -- backend B does *not* get notified of the conversation update ony of bob's promotion - liftIO $ do - [(_fr, cUpdate)] <- mapM parseConvUpdate $ filter (\r -> frTargetDomain r == bdomain) federatedRequests - assertBool "Action is not a ConversationMemberUpdate" (isJust (getConvAction (sing @'ConversationMemberUpdateTag) (cuAction cUpdate))) - - -- conversation has been modified by action - updatedConv :: Conversation <- fmap responseJsonUnsafe $ getConvQualified alice cnv frTargetDomain r == cdomain) federatedRequests - - (_fr1, _cu1, _up1) <- assertOne $ mapMaybe (\(fr, up) -> getConvAction (sing @'ConversationMemberUpdateTag) (cuAction up) <&> (fr,up,)) dUpdates - - (_fr2, convUpdate, receiptModeUpdate) <- assertOne $ mapMaybe (\(fr, up) -> getConvAction (sing @'ConversationReceiptModeUpdateTag) (cuAction up) <&> (fr,up,)) dUpdates - - cruReceiptMode receiptModeUpdate @?= newReceiptMode - cuOrigUserId convUpdate @?= qbob - cuConvId convUpdate @?= qUnqualified cnv - cuAlreadyPresentUsers convUpdate @?= [qUnqualified qcharlie] - - WS.assertMatch_ (5 # Second) wsAlice $ \n -> do - wsAssertConvReceiptModeUpdate cnv qbob newReceiptMode n - where - _toOtherMember qid = OtherMember qid Nothing roleNameWireAdmin - _convView cnv usr = responseJsonUnsafeWithMsg "conversation" <$> getConv usr cnv - - parseConvUpdate :: FederatedRequest -> IO (FederatedRequest, ConversationUpdate) - parseConvUpdate rpc = do - frComponent rpc @?= Galley - frRPC rpc @?= "on-conversation-updated" - let convUpdate :: ConversationUpdate = fromRight (error $ "Could not parse ConversationUpdate from " <> show (frBody rpc)) $ A.eitherDecode (frBody rpc) - pure (rpc, convUpdate) - getConvAction :: Sing tag -> SomeConversationAction -> Maybe (ConversationAction tag) getConvAction tquery (SomeConversationAction tag action) = case (tag, tquery) of @@ -1234,3 +941,5 @@ getConvAction tquery (SomeConversationAction tag action) = (SConversationAccessDataTag, _) -> Nothing (SConversationRemoveMembersTag, SConversationRemoveMembersTag) -> Just action (SConversationRemoveMembersTag, _) -> Nothing + (SConversationUpdateProtocolTag, SConversationUpdateProtocolTag) -> Just action + (SConversationUpdateProtocolTag, _) -> Nothing diff --git a/services/galley/test/integration/API/Federation/Util.hs b/services/galley/test/integration/API/Federation/Util.hs index 9d15edc5ee9..bb12cebb6ba 100644 --- a/services/galley/test/integration/API/Federation/Util.hs +++ b/services/galley/test/integration/API/Federation/Util.hs @@ -64,7 +64,7 @@ instance HasTrivialHandler api => HasTrivialHandler (From v :> api) where trivialNamedHandler :: forall (name :: Symbol) api. (KnownSymbol name, HasTrivialHandler api) => - Server (Named name api) + Server (UntypedNamed name api) trivialNamedHandler = Named (trivialHandler @api (symbolVal (Proxy @name))) -- | Generate a servant handler from an incomplete list of handlers of named @@ -74,40 +74,40 @@ class PartialAPI (api :: Type) (hs :: Type) where instance (KnownSymbol name, HasTrivialHandler endpoint) => - PartialAPI (Named (name :: Symbol) endpoint) EmptyAPI + PartialAPI (UntypedNamed (name :: Symbol) endpoint) EmptyAPI where mkHandler _ = trivialNamedHandler @name @endpoint instance {-# OVERLAPPING #-} (KnownSymbol name, HasTrivialHandler endpoint, PartialAPI api EmptyAPI) => - PartialAPI (Named (name :: Symbol) endpoint :<|> api) EmptyAPI + PartialAPI (UntypedNamed (name :: Symbol) endpoint :<|> api) EmptyAPI where mkHandler h = trivialNamedHandler @name @endpoint :<|> mkHandler @api h instance {-# OVERLAPPING #-} (h ~ Server endpoint, PartialAPI api hs) => - PartialAPI (Named (name :: Symbol) endpoint :<|> api) (Named name h :<|> hs) + PartialAPI (UntypedNamed (name :: Symbol) endpoint :<|> api) (UntypedNamed name h :<|> hs) where mkHandler (h :<|> hs) = h :<|> mkHandler @api hs instance (KnownSymbol name, HasTrivialHandler endpoint, PartialAPI api hs) => - PartialAPI (Named (name :: Symbol) endpoint :<|> api) hs + PartialAPI (UntypedNamed (name :: Symbol) endpoint :<|> api) hs where mkHandler hs = trivialNamedHandler @name @endpoint :<|> mkHandler @api hs instance (h ~ Server endpoint) => - PartialAPI (Named (name :: Symbol) endpoint) (Named name h) + PartialAPI (UntypedNamed (name :: Symbol) endpoint) (UntypedNamed name h) where mkHandler = id instance {-# OVERLAPPING #-} (h ~ Server endpoint, PartialAPI api EmptyAPI) => - PartialAPI (Named (name :: Symbol) endpoint :<|> api) (Named name h) + PartialAPI (UntypedNamed (name :: Symbol) endpoint :<|> api) (UntypedNamed name h) where mkHandler h = h :<|> mkHandler @api EmptyAPI diff --git a/services/galley/test/integration/API/MLS.hs b/services/galley/test/integration/API/MLS.hs index c8d546ef7c1..54f6e4ae177 100644 --- a/services/galley/test/integration/API/MLS.hs +++ b/services/galley/test/integration/API/MLS.hs @@ -19,12 +19,2438 @@ module API.MLS (tests) where +import API.MLS.Mocks +import API.MLS.Util +import API.Util +import Bilge hiding (empty, head) +import Bilge.Assert +import Cassandra hiding (Set) +import Control.Lens (view) +import Control.Lens.Extras +import Control.Monad.State qualified as State +import Data.Aeson qualified as Aeson +import Data.Domain +import Data.Id +import Data.Json.Util hiding ((#)) +import Data.List.NonEmpty (NonEmpty (..)) +import Data.List1 hiding (head) +import Data.Map qualified as Map +import Data.Qualified +import Data.Range +import Data.Set qualified as Set +import Data.Singletons +import Data.Text qualified as T +import Data.Time +import Federator.MockServer hiding (withTempMockFederator) import Imports +import Network.Wai.Utilities.Error qualified as Wai import Test.Tasty +import Test.Tasty.Cannon (TimeoutUnit (Second), (#)) +import Test.Tasty.Cannon qualified as WS +import Test.Tasty.HUnit +import TestHelpers import TestSetup +import Wire.API.Conversation +import Wire.API.Conversation.Action +import Wire.API.Conversation.Protocol +import Wire.API.Conversation.Role +import Wire.API.Error.Galley +import Wire.API.Event.Conversation +import Wire.API.Federation.API.Galley +import Wire.API.MLS.CipherSuite +import Wire.API.MLS.Credential +import Wire.API.MLS.Keys +import Wire.API.MLS.Serialisation +import Wire.API.MLS.SubConversation +import Wire.API.Message +import Wire.API.Routes.MultiTablePaging +import Wire.API.Routes.Version tests :: IO TestSetup -> TestTree -tests _s = +tests s = testGroup "MLS" - [] + [ testGroup + "Message" + [ test s "sender must be part of conversation" testSenderNotInConversation, + test s "send other user's commit" testSendAnotherUsersCommit + ], + testGroup + "Welcome" + [ test s "post a remote MLS welcome message" sendRemoteMLSWelcome + ], + testGroup + "Creation" + [ test s "fail to create MLS conversation" postMLSConvFail, + test s "create MLS conversation" postMLSConvOk + ], + testGroup + "Deletion" + [ test s "delete an MLS conversation" testDeleteMLSConv + ], + testGroup + "Commit" + [ test s "add user (not connected)" testAddUserNotConnected, + test s "add client of existing user" testAddClientPartial, + test s "add user with some non-MLS clients" testAddUserWithProteusClients, + test s "add remote users to a conversation (some unreachable)" testAddRemotesSomeUnreachable, + test s "return error when commit is locked" testCommitLock, + test s "post commit that references an unknown proposal" testUnknownProposalRefCommit + ], + testGroup + "External commit" + [ test s "non-member attempts to join a conversation" testExternalCommitNotMember, + test s "join a conversation with the same client" testExternalCommitSameClient, + test s "join a conversation with a new client" testExternalCommitNewClient, + test s "join a conversation with a new client and resend backend proposals" testExternalCommitNewClientResendBackendProposal + ], + testGroup + "Application Message" + [ testGroup + "Local Sender/Local Conversation" + [ test s "send application message" testAppMessage, + test s "another participant sends an application message" testAppMessage2, + test s "send message, remote users are unreachable" testAppMessageUnreachable + ], + testGroup + "Local Sender/Remote Conversation" + [ test s "send application message" testLocalToRemote, + test s "non-member sends application message" testLocalToRemoteNonMember + ], + testGroup + "Remote Sender/Local Conversation" + [ test s "POST /federation/send-mls-message" testRemoteToLocal, + test s "POST /federation/send-mls-message, remote user is not a conversation member" testRemoteNonMemberToLocal, + test s "POST /federation/send-mls-message, remote user sends to wrong conversation" testRemoteToLocalWrongConversation + ] + ], + testGroup + "Proposal" + [ test s "add a new client to a non-existing conversation" propNonExistingConv + ], + testGroup + "External Add Proposal" + [ test s "member adds new client" testExternalAddProposal, + test s "non-admin commits external add proposal" testExternalAddProposalNonAdminCommit, + test s "non-member adds new client" testExternalAddProposalWrongUser, + test s "member adds unknown new client" testExternalAddProposalWrongClient + ], + testGroup + "Backend-side External Remove Proposals" + [ test s "local conversation, local user deleted" testBackendRemoveProposalLocalConvLocalUser, + test s "local conversation, recreate client" testBackendRemoveProposalRecreateClient, + test s "local conversation, remote user deleted" testBackendRemoveProposalLocalConvRemoteUser, + test s "local conversation, creator leaving" testBackendRemoveProposalLocalConvLocalLeaverCreator, + test s "local conversation, local committer leaving" testBackendRemoveProposalLocalConvLocalLeaverCommitter, + test s "local conversation, remote user leaving" testBackendRemoveProposalLocalConvRemoteLeaver, + test s "local conversation, local client deleted" testBackendRemoveProposalLocalConvLocalClient, + test s "local conversation, remote client deleted" testBackendRemoveProposalLocalConvRemoteClient + ], + testGroup + "Protocol mismatch" + [ test s "send a commit to a proteus conversation" testAddUsersToProteus, + test s "add users bypassing MLS" testAddUsersDirectly, + test s "remove users bypassing MLS" testRemoveUsersDirectly, + test s "send proteus message to an MLS conversation" testProteusMessage + ], + test s "public keys" testPublicKeys, + testGroup + "GroupInfo" + [ test s "get group info for a local conversation" testGetGroupInfoOfLocalConv, + test s "get group info for a remote conversation" testGetGroupInfoOfRemoteConv, + test s "get group info for a remote user" testFederatedGetGroupInfo + ], + testGroup + "CommitBundle" + [ test s "add user with a commit bundle" testAddUserWithBundle, + test s "add user with a commit bundle to a remote conversation" testAddUserToRemoteConvWithBundle + ], + testGroup + "Self conversation" + [ test s "do not list a self conversation below v3" $ testSelfConversationList True, + test s "list a self conversation automatically from v3" $ testSelfConversationList False, + test s "listing conversations without MLS configured" testSelfConversationMLSNotConfigured, + test s "attempt to add another user to a conversation fails" testSelfConversationOtherUser, + test s "attempt to leave fails" testSelfConversationLeave + ], + testGroup + "MLS disabled" + [ test s "cannot create MLS conversations" postMLSConvDisabled, + test s "cannot send an MLS message" postMLSMessageDisabled, + test s "cannot send a commit bundle" postMLSBundleDisabled, + test s "cannot get group info" getGroupInfoDisabled, + test s "cannot delete a subconversation" deleteSubConversationDisabled + ], + testGroup + "SubConversation" + [ testGroup + "Local Sender/Local Subconversation" + [ test s "rejoin a subconversation with the same client" testExternalCommitSameClientSubConv, + test s "join subconversation with a client that is not in the parent conv" testJoinSubNonMemberClient, + test s "fail to add another client to a subconversation via internal commit" testAddClientSubConvFailure, + test s "remove another client from a subconversation" testRemoveClientSubConv, + test s "send an application message in a subconversation" testSendMessageSubConv, + test s "reset a subconversation and assert no leftover proposals" testJoinDeletedSubConvWithRemoval, + test s "fail to reset a subconversation with wrong epoch" testDeleteSubConvStale, + test s "last to leave a subconversation" testLastLeaverSubConv, + test s "leave a subconversation as a non-member" testLeaveSubConvNonMember, + test s "remove user from parent conversation" testRemoveUserParent, + test s "remove creator from parent conversation" testRemoveCreatorParent, + test s "creator removes user from parent conversation" testCreatorRemovesUserFromParent + ], + testGroup + "Local Sender/Remote Subconversation" + [ test s "get subconversation of remote conversation - member" (testGetRemoteSubConv True), + test s "get subconversation of remote conversation - not member" (testGetRemoteSubConv False), + test s "join remote subconversation" testJoinRemoteSubConv, + test s "backends are notified about subconvs when a user joins" testRemoteSubConvNotificationWhenUserJoins, + test s "reset a subconversation - member" (testDeleteRemoteSubConv True), + test s "reset a subconversation - not member" (testDeleteRemoteSubConv False), + test s "leave a remote subconversation" testLeaveRemoteSubConv + ], + testGroup + "Remote Sender/Local SubConversation" + [ test s "get subconversation as a remote member" (testRemoteMemberGetSubConv True), + test s "get subconversation as a remote non-member" (testRemoteMemberGetSubConv False) + ], + testGroup + "Remote Sender/Remote SubConversation" + [ test s "on-mls-message-sent in subconversation" testRemoteToRemoteInSub + ] + ] + ] + +postMLSConvFail :: TestM () +postMLSConvFail = do + qalice <- randomQualifiedUser + let alice = qUnqualified qalice + let aliceClient = newClientId 0 + bob <- randomUser + connectUsers alice (list1 bob []) + postConvQualified + alice + (Just aliceClient) + defNewMLSConv + { newConvQualifiedUsers = [Qualified bob (qDomain qalice)] + } + !!! do + const 400 === statusCode + const (Just "non-empty-member-list") === fmap Wai.label . responseJsonError + +postMLSConvOk :: TestM () +postMLSConvOk = do + c <- view tsCannon + qalice <- randomQualifiedUser + let alice = qUnqualified qalice + let aliceClient = newClientId 0 + let nameMaxSize = T.replicate 256 "a" + WS.bracketR c alice $ \wsA -> do + rsp <- + postConvQualified + alice + (Just aliceClient) + defNewMLSConv {newConvName = checked nameMaxSize} + pure rsp !!! do + const 201 === statusCode + const Nothing === fmap Wai.label . responseJsonError + qcid <- assertConv rsp RegularConv (Just alice) qalice [] (Just nameMaxSize) Nothing + checkConvCreateEvent (qUnqualified qcid) wsA + +testSenderNotInConversation :: TestM () +testSenderNotInConversation = do + -- create users + [alice, bob] <- createAndConnectUsers (replicate 2 Nothing) + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + + -- upload key packages + void $ uploadNewKeyPackage bob1 + + -- create group with alice1 and bob1, but do not commit adding Bob + void $ setupMLSGroup alice1 + mp <- createAddCommit alice1 [bob] + + traverse_ consumeWelcome (mpWelcome mp) + + message <- createApplicationMessage bob1 "some text" + + -- send the message as bob, who is not in the conversation + err <- + responseJsonError + =<< postMessage bob1 (mpMessage message) + do + events <- sendAndConsumeCommitBundle commit + for_ (zip bobClients wss) $ \(_, ws) -> + WS.assertMatch (5 # Second) ws $ + wsAssertMLSWelcome alice qcnv welcome + pure events + + event <- assertOne events + liftIO $ assertJoinEvent qcnv alice [bob] roleNameWireMember event + pure (qcnv, commit) + + -- check that bob can now see the conversation + convs <- getAllConvs (qUnqualified bob) + liftIO $ + assertBool + "Users added to an MLS group should find it when listing conversations" + (qcnv `elem` map cnvQualifiedId convs) + + returnedGS <- getGroupInfo alice (fmap Conv qcnv) + liftIO $ assertBool "Commit does not contain a public group State" (isJust (mpGroupInfo commit)) + liftIO $ mpGroupInfo commit @?= Just returnedGS + +testAddUserNotConnected :: TestM () +testAddUserNotConnected = do + users@[alice, bob] <- replicateM 2 randomQualifiedUser + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient users + void $ uploadNewKeyPackage bob1 + void $ setupMLSGroup alice1 + -- add unconnected user with a commit + commit <- createAddCommit alice1 [bob] + bundle <- createBundle commit + err <- mlsBracket [alice1, bob1] $ \wss -> do + err <- + responseJsonError + =<< localPostCommitBundle (mpSender commit) bundle + >= sendAndConsumeCommitBundle + +testAddClientPartial :: TestM () +testAddClientPartial = do + [alice, bob] <- createAndConnectUsers (replicate 2 Nothing) + runMLSTest $ do + alice1 <- createMLSClient alice + -- bob only has 1 usable client + [bob1, bob2, bob3] <- replicateM 3 (createMLSClient bob) + void $ uploadNewKeyPackage bob1 + + -- alice1 creates a group with bob1 + void $ setupMLSGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + -- now bob2 and bob3 upload key packages, and alice adds bob2 only + kp <- uploadNewKeyPackage bob2 + void $ uploadNewKeyPackage bob3 + void $ + createAddCommitWithKeyPackages alice1 [(bob2, kp.raw)] + >>= sendAndConsumeCommitBundle + +testSendAnotherUsersCommit :: TestM () +testSendAnotherUsersCommit = do + -- create users + [alice, bob] <- createAndConnectUsers (replicate 2 Nothing) + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + + -- upload key packages + void $ uploadNewKeyPackage bob1 + + -- create group with alice1 and bob1 + void $ setupMLSGroup alice1 + createAddCommit alice1 [bob] >>= void . sendAndConsumeCommitBundle + + -- Alice creates a commit that adds bob2 + bob2 <- createMLSClient bob + -- upload key packages + void $ uploadNewKeyPackage bob2 + mp <- createAddCommit alice1 [bob] + -- and the corresponding commit is sent from Bob instead of Alice + err <- + responseJsonError + =<< (localPostCommitBundle bob1 =<< createBundle mp) + setupMLSGroup alice1 + createAddCommit alice1 [bob] >>= void . sendAndConsumeCommitBundle + e <- + responseJsonError + =<< postMembers + (qUnqualified alice) + (pure charlie) + qcnv + setupMLSGroup alice1 + createAddCommit alice1 [bob] >>= void . sendAndConsumeCommitBundle + e <- + responseJsonError + =<< deleteMemberQualified + (qUnqualified alice) + bob + qcnv + setupMLSGroup alice1 + createAddCommit alice1 [bob] >>= void . sendAndConsumeCommitBundle + e <- + responseJsonError + =<< postProteusMessageQualified + (qUnqualified alice) + (ciClient bob1) + qcnv + [] + "data" + MismatchReportAll + [Nothing, Just bobDomain, Just charlieDomain] + runMLSTest $ do + [alice1, bob1, _charlie1] <- traverse createMLSClient users + (_, qcnv) <- setupMLSGroup alice1 + + commit <- createAddCommit alice1 [bob, charlie] + bundle <- createBundle commit + let unreachable = Set.singleton charlieDomain + void + $ withTempMockFederator' + ( receiveCommitMockByDomain [bob1] + <|> mockUnreachableFor unreachable + <|> welcomeMockByDomain [bobDomain] + ) + $ localPostCommitBundle (mpSender commit) bundle + >= sendAndConsumeCommitBundle + + -- alice adds charlie + void $ createAddCommit alice1 [cidQualifiedUser charlie1] >>= sendAndConsumeCommitBundle + + -- simulate concurrent commit by blocking epoch + casClient <- view tsCass + runClient casClient $ insertLock groupId (Epoch 2) + + -- commit should fail due to competing lock + do + commit <- createAddCommit alice1 [cidQualifiedUser dee1] + bundle <- createBundle commit + err <- + responseJsonError + =<< localPostCommitBundle alice1 bundle + convId, groupID +-- 2) alice creates an MLS group (locally) with bob in it -> commit, welcome +-- 3) alice sends commit +-- 4) deprecated & removed: A notifies B about the new conversation +-- 5) A notifies B about bob being in the conversation (Join event) +-- 6) B notifies bob about join event +-- 7) alice sends welcome @A +-- 8) A forwards welcome to B +-- 9) B forwards welcome to bob +-- 10) bob creates his view on the group (locally) using the welcome message +-- +-- 11) bob crafts a message (locally) +-- 12) bob sends the message @B +-- 13) B forwards the message to A +-- 14) A forwards the message to alice +-- +-- In the test: +-- +-- setup: 2 5 10 11 +-- skipped: 1 3 6 7 8 9 13 +-- faked: 4 +-- actual test step: 12 14 +testLocalToRemote :: TestM () +testLocalToRemote = do + -- create users + let aliceDomain = Domain "faraway.example.com" + [alice, bob] <- createAndConnectUsers [Just (domainText aliceDomain), Nothing] + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + + -- upload key packages + void $ uploadNewKeyPackage bob1 + + -- step 2 + (_groupId, qcnv) <- setupFakeMLSGroup alice1 Nothing + mp <- createAddCommit alice1 [bob] + -- step 10 + traverse_ consumeWelcome (mpWelcome mp) + -- step 11 + message <- createApplicationMessage bob1 "hi" + + -- A notifies B about bob being in the conversation (Join event): step 5 + receiveOnConvUpdated qcnv alice bob + + (_, reqs) <- + withTempMockFederator' sendMessageMock $ + -- bob sends a message: step 12 + sendAndConsumeMessage message + + -- check requests to mock federator: step 14 + liftIO $ do + req <- assertOne reqs + frRPC req @?= "send-mls-message" + frTargetDomain req @?= qDomain qcnv + bdy :: MLSMessageSendRequest <- case Aeson.eitherDecode (frBody req) of + Right b -> pure b + Left e -> assertFailure $ "Could not parse send-mls-message request body: " <> e + bdy.convOrSubId @?= Conv (qUnqualified qcnv) + bdy.sender @?= qUnqualified bob + bdy.rawMessage @?= Base64ByteString (mpMessage message) + +testLocalToRemoteNonMember :: TestM () +testLocalToRemoteNonMember = do + -- create users + let domain = Domain "faraway.example.com" + [alice, bob] <- createAndConnectUsers [Just (domainText domain), Nothing] + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + + void $ uploadNewKeyPackage bob1 + + -- step 2 + void $ setupFakeMLSGroup alice1 Nothing + + mp <- createAddCommit alice1 [bob] + -- step 10 + traverse_ consumeWelcome (mpWelcome mp) + -- step 11 + message <- createApplicationMessage bob1 "hi" + + void $ + withTempMockFederator' sendMessageMock $ do + galley <- viewGalley + + -- bob sends a message: step 12 + post + ( galley + . paths ["mls", "messages"] + . zUser (qUnqualified bob) + . zConn "conn" + . zClient (ciClient bob1) + . Bilge.content "message/mls" + . bytes (mpMessage message) + ) + !!! do + const 404 === statusCode + const (Just "no-conversation-member") + === fmap Wai.label . responseJsonError + +testExternalCommitNotMember :: TestM () +testExternalCommitNotMember = do + [alice, bob] <- createAndConnectUsers (replicate 2 Nothing) + + runMLSTest $ do + [alice1, alice2, bob1] <- traverse createMLSClient [alice, alice, bob] + traverse_ uploadNewKeyPackage [bob1, alice2] + (_, qcnv) <- setupMLSGroup alice1 + + -- so that we have the public group state + void $ createAddCommit alice1 [alice] >>= sendAndConsumeCommitBundle + + pgs <- liftTest $ getGroupInfo (cidQualifiedUser alice1) (fmap Conv qcnv) + mp <- createExternalCommit bob1 (Just pgs) (fmap Conv qcnv) + bundle <- createBundle mp + localPostCommitBundle (mpSender mp) bundle + !!! const 404 === statusCode + +testExternalCommitSameClient :: TestM () +testExternalCommitSameClient = do + [alice, bob] <- createAndConnectUsers (replicate 2 Nothing) + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + void $ uploadNewKeyPackage bob1 + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + let rejoiner = alice1 + ecEvents <- + createExternalCommit rejoiner Nothing (fmap Conv qcnv) + >>= sendAndConsumeCommitBundle + liftIO $ + assertBool "No events after external commit expected" (null ecEvents) + + message <- createApplicationMessage bob1 "hello" + void $ sendAndConsumeMessage message + +testExternalCommitNewClient :: TestM () +testExternalCommitNewClient = do + [alice, bob] <- createAndConnectUsers (replicate 2 Nothing) + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + void $ uploadNewKeyPackage bob1 + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + nc <- createMLSClient bob + ecEvents <- + createExternalCommit nc Nothing (fmap Conv qcnv) + >>= sendAndConsumeCommitBundle + liftIO $ + assertBool "No events after external commit expected" (null ecEvents) + + message <- createApplicationMessage nc "hello" + void $ sendAndConsumeMessage message + +-- the list of members should be [alice1, bob1] + +-- | Check that external backend proposals are replayed after external commits +-- AND that (external) client proposals are NOT replayed by the backend in the +-- same case (since this is up to the clients). +testExternalCommitNewClientResendBackendProposal :: TestM () +testExternalCommitNewClientResendBackendProposal = do + [alice, bob] <- createAndConnectUsers (replicate 2 Nothing) + + runMLSTest $ do + [alice1, bob1, bob2] <- traverse createMLSClient [alice, bob, bob] + forM_ [bob1, bob2] uploadNewKeyPackage + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + Just (_, bobIdx2) <- find (\(ci, _) -> ci == bob2) <$> getClientsFromGroupState alice1 bob + + mlsBracket [alice1, bob1] $ \[wsA, wsB] -> do + liftTest $ + deleteClient (qUnqualified bob) (ciClient bob2) (Just defPassword) + !!! statusCode === const 200 + WS.assertMatchN_ (5 # WS.Second) [wsB] $ + wsAssertClientRemoved (ciClient bob2) + + State.modify $ \mls -> + mls + { mlsMembers = Set.difference (mlsMembers mls) (Set.fromList [bob2]) + } + + WS.assertMatchN_ (5 # WS.Second) [wsA, wsB] $ + wsAssertBackendRemoveProposalWithEpoch bob qcnv bobIdx2 (Epoch 1) + + [bob3, bob4] <- for [bob, bob] $ \qusr' -> do + ci <- createMLSClient qusr' + WS.assertMatchN_ (5 # WS.Second) [wsB] $ + wsAssertClientAdded (ciClient ci) + pure ci + + void $ + createExternalAddProposal bob3 + >>= sendAndConsumeMessage + + WS.assertMatchN_ (5 # WS.Second) [wsA, wsB] $ + void . wsAssertAddProposal bob qcnv + + mp <- createExternalCommit bob4 Nothing (fmap Conv qcnv) + ecEvents <- sendAndConsumeCommitBundle mp + liftIO $ + assertBool "No events after external commit expected" (null ecEvents) + + WS.assertMatchN_ (5 # WS.Second) [wsA, wsB] $ + wsAssertMLSMessage (fmap Conv qcnv) bob (mpMessage mp) + + -- The backend proposals for bob2 are replayed, but the external add + -- proposal for bob3 has to replayed by the client and is thus not found + -- here. + WS.assertMatchN_ (5 # WS.Second) [wsA, wsB] $ + wsAssertBackendRemoveProposalWithEpoch bob qcnv bobIdx2 (Epoch 2) + WS.assertNoEvent (2 # WS.Second) [wsA, wsB] + +testAppMessage :: TestM () +testAppMessage = do + users@(alice : _) <- createAndConnectUsers (replicate 4 Nothing) + + runMLSTest $ do + clients@(alice1 : _) <- traverse createMLSClient users + traverse_ uploadNewKeyPackage (tail clients) + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 (tail users) >>= sendAndConsumeCommitBundle + message <- createApplicationMessage alice1 "some text" + + mlsBracket clients $ \wss -> do + events <- sendAndConsumeMessage message + liftIO $ events @?= [] + liftIO $ do + WS.assertMatchN_ (5 # WS.Second) (tail wss) $ + wsAssertMLSMessage (fmap Conv qcnv) alice (mpMessage message) + WS.assertNoEvent (2 # WS.Second) [head wss] + +testAppMessage2 :: TestM () +testAppMessage2 = do + -- create users + [alice, bob, charlie] <- createAndConnectUsers (replicate 3 Nothing) + + runMLSTest $ do + alice1 : clients@[bob1, _bob2, _charlie1] <- + traverse createMLSClient [alice, bob, bob, charlie] + + -- upload key packages + traverse_ uploadNewKeyPackage clients + + -- create group with alice1 and other clients + conversation <- snd <$> setupMLSGroup alice1 + mp <- createAddCommit alice1 [bob, charlie] + void $ sendAndConsumeCommitBundle mp + + traverse_ consumeWelcome (mpWelcome mp) + + message <- createApplicationMessage bob1 "some text" + + mlsBracket (alice1 : clients) $ \[wsAlice1, wsBob1, wsBob2, wsCharlie1] -> do + events <- sendAndConsumeMessage message + liftIO $ events @?= [] + + -- check that the corresponding event is received by everyone except bob1 + -- (the sender) and no message is received by bob1 + liftIO $ do + WS.assertMatchN_ (5 # WS.Second) [wsAlice1, wsBob2, wsCharlie1] $ + wsAssertMLSMessage (fmap Conv conversation) bob (mpMessage message) + WS.assertNoEvent (2 # WS.Second) [wsBob1] + +testAppMessageUnreachable :: TestM () +testAppMessageUnreachable = do + -- alice is local, bob is remote + -- alice creates a local conversation and invites bob + -- alice then sends a message to the conversation, but bob is not reachable anymore + -- since we did not properly setup federation, we can't reach the remote server with bob's msg + users@[_alice, bob] <- createAndConnectUsers [Nothing, Just "bob.example.com"] + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient users + void $ setupMLSGroup alice1 + + commit <- createAddCommit alice1 [bob] + ([event], _) <- + withTempMockFederator' (receiveCommitMock [bob1] <|> welcomeMock) $ + sendAndConsumeCommitBundle commit + + message <- createApplicationMessage alice1 "hi, bob!" + _ <- sendAndConsumeMessage message + liftIO $ do + assertBool "Event should be member join" $ is _EdMembersJoin (evtData event) + +testRemoteToRemoteInSub :: TestM () +testRemoteToRemoteInSub = do + localDomain <- viewFederationDomain + c <- view tsCannon + alice <- randomUser + eve <- randomUser + bob <- randomId + conv <- randomId + let subConvId = SubConvId "conference" + aliceC1 = newClientId 0 + aliceC2 = newClientId 1 + eveC = newClientId 0 + bdom = Domain "bob.example.com" + qconv = Qualified conv bdom + qbob = Qualified bob bdom + qalice = Qualified alice localDomain + now <- liftIO getCurrentTime + fedGalleyClient <- view tsFedGalleyClient + + -- only add alice to the remote conversation + connectWithRemoteUser alice qbob + let cu = + ConversationUpdate + { cuTime = now, + cuOrigUserId = qbob, + cuConvId = conv, + cuAlreadyPresentUsers = [], + cuAction = + SomeConversationAction (sing @'ConversationJoinTag) (ConversationJoin (pure qalice) roleNameWireMember) + } + void $ runFedClient @"on-conversation-updated" fedGalleyClient bdom cu + + let txt = "Hello from another backend" + rcpts = Map.fromList [(alice, aliceC1 :| [aliceC2]), (eve, eveC :| [])] + rm = + RemoteMLSMessage + { time = now, + metadata = defMessageMetadata, + sender = qbob, + conversation = conv, + subConversation = Just subConvId, + recipients = rcpts, + message = Base64ByteString txt + } + + -- send message to alice and check reception + WS.bracketAsClientRN c [(alice, aliceC1), (alice, aliceC2), (eve, eveC)] $ \[wsA1, wsA2, wsE] -> do + void $ runFedClient @"on-mls-message-sent" fedGalleyClient bdom rm + liftIO $ do + -- alice should receive the message on her first client + WS.assertMatch_ (5 # Second) wsA1 $ \n -> wsAssertMLSMessage (fmap (flip SubConv subConvId) qconv) qbob txt n + WS.assertMatch_ (5 # Second) wsA2 $ \n -> wsAssertMLSMessage (fmap (flip SubConv subConvId) qconv) qbob txt n + + -- eve should not receive the message + WS.assertNoEvent (1 # Second) [wsE] + +testRemoteToLocal :: TestM () +testRemoteToLocal = do + -- alice is local, bob is remote + -- alice creates a local conversation and invites bob + -- bob then sends a message to the conversation + + let bobDomain = Domain "faraway.example.com" + -- create users + [alice, bob] <- + createAndConnectUsers + [ Nothing, + Just (domainText bobDomain) + ] + + -- Simulate the whole MLS setup for both clients first. In reality, + -- backend calls would need to happen in order for bob to get ahold of a + -- welcome message, but that should not affect the correctness of the test. + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + + (_groupId, qcnv) <- setupMLSGroup alice1 + kpb <- claimKeyPackages alice1 bob + mp <- createAddCommit alice1 [bob] + + let mock = receiveCommitMock [bob1] <|> welcomeMock <|> claimKeyPackagesMock kpb + void . withTempMockFederator' mock $ + sendAndConsumeCommitBundle mp + + traverse_ consumeWelcome (mpWelcome mp) + message <- createApplicationMessage bob1 "hello from another backend" + + fedGalleyClient <- view tsFedGalleyClient + cannon <- view tsCannon + + -- actual test + let msr = + MLSMessageSendRequest + { convOrSubId = Conv (qUnqualified qcnv), + sender = qUnqualified bob, + senderClient = ciClient bob1, + rawMessage = Base64ByteString (mpMessage message) + } + + WS.bracketR cannon (qUnqualified alice) $ \ws -> do + MLSMessageResponseUpdates updates <- runFedClient @"send-mls-message" fedGalleyClient bobDomain msr + liftIO $ do + updates @?= [] + WS.assertMatch_ (5 # Second) ws $ + wsAssertMLSMessage (fmap Conv qcnv) bob (mpMessage message) + +testRemoteToLocalWrongConversation :: TestM () +testRemoteToLocalWrongConversation = do + -- alice is local, bob is remote + -- alice creates a local conversation and invites bob + -- bob then sends a message to the conversation + + let bobDomain = Domain "faraway.example.com" + [alice, bob] <- createAndConnectUsers [Nothing, Just (domainText bobDomain)] + + -- Simulate the whole MLS setup for both clients first. In reality, + -- backend calls would need to happen in order for bob to get ahold of a + -- welcome message, but that should not affect the correctness of the test. + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + + void $ claimKeyPackages alice1 bob + void $ setupMLSGroup alice1 + mp <- createAddCommit alice1 [bob] + + let mock = receiveCommitMock [bob1] <|> welcomeMock + void . withTempMockFederator' mock $ sendAndConsumeCommitBundle mp + traverse_ consumeWelcome (mpWelcome mp) + message <- createApplicationMessage bob1 "hello from another backend" + + fedGalleyClient <- view tsFedGalleyClient + + -- actual test + randomConfId <- randomId + let msr = + MLSMessageSendRequest + { convOrSubId = Conv randomConfId, + sender = qUnqualified bob, + senderClient = ciClient bob1, + rawMessage = Base64ByteString (mpMessage message) + } + + resp <- runFedClient @"send-mls-message" fedGalleyClient bobDomain msr + liftIO $ resp @?= MLSMessageResponseError MLSGroupConversationMismatch + +testRemoteNonMemberToLocal :: TestM () +testRemoteNonMemberToLocal = do + -- alice is local, bob is remote + -- alice creates a local conversation and invites bob + -- bob then sends a message to the conversation + + let bobDomain = Domain "faraway.example.com" + [alice, bob] <- createAndConnectUsers [Nothing, Just (domainText bobDomain)] + + -- Simulate the whole MLS setup for both clients first. In reality, + -- backend calls would need to happen in order for bob to get ahold of a + -- welcome message, but that should not affect the correctness of the test. + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + + qcnv <- snd <$> setupMLSGroup alice1 + void $ claimKeyPackages alice1 bob + mp <- createAddCommit alice1 [bob] + traverse_ consumeWelcome (mpWelcome mp) + + message <- createApplicationMessage bob1 "hello from another backend" + + let msr = + MLSMessageSendRequest + { convOrSubId = Conv (qUnqualified qcnv), + sender = qUnqualified bob, + senderClient = ciClient bob1, + rawMessage = Base64ByteString (mpMessage message) + } + + fedGalleyClient <- view tsFedGalleyClient + resp <- runFedClient @"send-mls-message" fedGalleyClient bobDomain msr + liftIO $ do + resp @?= MLSMessageResponseError ConvNotFound + +-- | The group exists in mls-test-cli's store, but not in wire-server's database. +propNonExistingConv :: TestM () +propNonExistingConv = do + [alice, bob] <- createAndConnectUsers (replicate 2 Nothing) + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + void $ uploadNewKeyPackage bob1 + void $ setupFakeMLSGroup alice1 Nothing + + [prop] <- createAddProposals alice1 [bob] + postMessage alice1 (mpMessage prop) !!! do + const 404 === statusCode + const (Just "no-conversation") === fmap Wai.label . responseJsonError + +-- scenario: +-- alice1 creates a group and adds bob1 +-- bob2 joins with external proposal (alice1 commits it) +-- bob2 adds charlie1 +-- alice1 sends a message +testExternalAddProposal :: TestM () +testExternalAddProposal = do + -- create users + [alice, bob, charlie] <- + createAndConnectUsers (replicate 3 Nothing) + + void . runMLSTest $ do + -- create clients + alice1 <- createMLSClient alice + bob1 <- createMLSClient bob + charlie1 <- createMLSClient charlie + + -- upload key packages + void $ uploadNewKeyPackage bob1 + void $ uploadNewKeyPackage charlie1 + + -- create group with alice1 and bob1 + (_, qcnv) <- setupMLSGroup alice1 + void $ + createAddCommit alice1 [bob] + >>= sendAndConsumeCommitBundle + + -- bob joins with an external proposal + bob2 <- createMLSClient bob + mlsBracket [alice1, bob1] $ \wss -> do + void $ + createExternalAddProposal bob2 + >>= sendAndConsumeMessage + liftTest $ + WS.assertMatchN_ (5 # Second) wss $ + void . wsAssertAddProposal bob qcnv + + void $ + createPendingProposalCommit alice1 + >>= sendAndConsumeCommitBundle + + -- alice sends a message + do + msg <- createApplicationMessage alice1 "hi bob" + mlsBracket [bob1, bob2] $ \wss -> do + void $ sendAndConsumeMessage msg + liftTest $ + WS.assertMatchN_ (5 # Second) wss $ + wsAssertMLSMessage (fmap Conv qcnv) alice (mpMessage msg) + + -- bob adds charlie + putOtherMemberQualified + (qUnqualified alice) + bob + (OtherMemberUpdate (Just roleNameWireAdmin)) + qcnv + !!! const 200 === statusCode + createAddCommit bob2 [charlie] + >>= sendAndConsumeCommitBundle + +testExternalAddProposalNonAdminCommit :: TestM () +testExternalAddProposalNonAdminCommit = do + -- create users + [alice, bob, charlie] <- + createAndConnectUsers (replicate 3 Nothing) + + void . runMLSTest $ do + -- create clients + alice1 <- createMLSClient alice + [bob1, bob2] <- replicateM 2 (createMLSClient bob) + charlie1 <- createMLSClient charlie + + -- upload key packages + void $ uploadNewKeyPackage bob1 + void $ uploadNewKeyPackage charlie1 + + -- create group with alice1 and bob1 + (_, qcnv) <- setupMLSGroup alice1 + void $ + createAddCommit alice1 [bob] + >>= sendAndConsumeCommitBundle + + -- bob joins with an external proposal + mlsBracket [alice1, bob1] $ \wss -> do + void $ + createExternalAddProposal bob2 + >>= sendAndConsumeMessage + liftTest $ + WS.assertMatchN_ (5 # Second) wss $ + void . wsAssertAddProposal bob qcnv + + -- bob1 commits + void $ + createPendingProposalCommit bob1 + >>= sendAndConsumeCommitBundle + +-- scenario: +-- alice adds bob and charlie +-- charlie sends an external proposal for bob +testExternalAddProposalWrongClient :: TestM () +testExternalAddProposalWrongClient = do + [alice, bob, charlie] <- + createAndConnectUsers (replicate 3 Nothing) + + runMLSTest $ do + -- setup clients + [alice1, bob1, bob2, charlie1] <- + traverse + createMLSClient + [alice, bob, bob, charlie] + void $ uploadNewKeyPackage bob1 + void $ uploadNewKeyPackage charlie1 + + void $ setupMLSGroup alice1 + void $ + createAddCommit alice1 [bob, charlie] + >>= sendAndConsumeCommitBundle + + prop <- createExternalAddProposal bob2 + postMessage charlie1 (mpMessage prop) + !!! do + const 422 === statusCode + const (Just "mls-unsupported-proposal") === fmap Wai.label . responseJsonError + +-- scenario: +-- alice adds bob +-- charlie attempts to join with an external add proposal +testExternalAddProposalWrongUser :: TestM () +testExternalAddProposalWrongUser = do + users@[_, bob, _charlie] <- createAndConnectUsers (replicate 3 Nothing) + + runMLSTest $ do + -- setup clients + [alice1, bob1, charlie1] <- traverse createMLSClient users + void $ uploadNewKeyPackage bob1 + + void $ setupMLSGroup alice1 + void $ + createAddCommit alice1 [bob] + >>= sendAndConsumeCommitBundle + + prop <- createExternalAddProposal charlie1 + postMessage charlie1 (mpMessage prop) + !!! do + const 404 === statusCode + const (Just "no-conversation") === fmap Wai.label . responseJsonError + +-- FUTUREWORK: test processing a commit containing the external proposal +testPublicKeys :: TestM () +testPublicKeys = do + u <- randomId + g <- viewGalley + keys <- + responseJsonError + =<< get + ( g + . paths ["mls", "public-keys"] + . zUser u + ) + qcnv + + void $ createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle + + (_, idx) <- assertOne =<< getClientsFromGroupState alice1 alice + + liftTest $ + deleteClient (qUnqualified alice) (ciClient alice1) (Just defPassword) + !!! const 200 === statusCode + State.modify $ \mls -> + mls + { mlsMembers = Set.difference (mlsMembers mls) (Set.singleton alice1) + } + + alice2 <- createMLSClient alice + proposal <- mlsBracket [alice2] $ \[wsA] -> do + -- alice2 joins the conversation, causing the external remove proposal to + -- be re-established + void $ + createExternalCommit alice2 Nothing cnv + >>= sendAndConsumeCommitBundle + WS.assertMatch (5 # WS.Second) wsA $ + wsAssertBackendRemoveProposal alice (Conv <$> qcnv) idx + + consumeMessage1 alice2 proposal + void $ createPendingProposalCommit alice2 >>= sendAndConsumeCommitBundle + + void $ createApplicationMessage alice2 "hello" >>= sendAndConsumeMessage + +testBackendRemoveProposalLocalConvLocalUser :: TestM () +testBackendRemoveProposalLocalConvLocalUser = do + [alice, bob] <- createAndConnectUsers (replicate 2 Nothing) + runMLSTest $ do + [alice1, bob1, bob2] <- traverse createMLSClient [alice, bob, bob] + traverse_ uploadNewKeyPackage [bob1, bob2] + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + bobClients <- getClientsFromGroupState alice1 bob + mlsBracket [alice1] $ \wss -> void $ do + liftTest $ deleteUser (qUnqualified bob) !!! const 200 === statusCode + -- remove bob clients from the test state + State.modify $ \mls -> + mls + { mlsMembers = Set.difference (mlsMembers mls) (Set.fromList [bob1, bob2]) + } + + for bobClients $ \(_, idx) -> do + [msg] <- WS.assertMatchN (5 # Second) wss $ \n -> + wsAssertBackendRemoveProposal bob (Conv <$> qcnv) idx n + consumeMessage1 alice1 msg + + -- alice commits the external proposals + events <- createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle + liftIO $ events @?= [] + +testBackendRemoveProposalLocalConvRemoteUser :: TestM () +testBackendRemoveProposalLocalConvRemoteUser = do + [alice, bob] <- createAndConnectUsers [Nothing, Just "bob.example.com"] + runMLSTest $ do + [alice1, bob1, bob2] <- traverse createMLSClient [alice, bob, bob] + (_, qcnv) <- setupMLSGroup alice1 + commit <- createAddCommit alice1 [bob] + + let mock = receiveCommitMock [bob1, bob2] <|> welcomeMock <|> messageSentMock + void . withTempMockFederator' mock $ do + mlsBracket [alice1] $ \[wsA] -> do + void $ sendAndConsumeCommitBundle commit + + bobClients <- getClientsFromGroupState alice1 bob + fedGalleyClient <- view tsFedGalleyClient + void $ + runFedClient + @"on-user-deleted-conversations" + fedGalleyClient + (qDomain bob) + ( UserDeletedConversationsNotification + { user = qUnqualified bob, + conversations = unsafeRange [qUnqualified qcnv] + } + ) + + for_ bobClients $ \(_, idx) -> + WS.assertMatch (5 # WS.Second) wsA $ + wsAssertBackendRemoveProposal bob (Conv <$> qcnv) idx + +sendRemoteMLSWelcome :: TestM () +sendRemoteMLSWelcome = do + -- Alice is from the originating domain and Bob is local, i.e., on the receiving domain + [alice, bob] <- createAndConnectUsers [Just "alice.example.com", Nothing] + (commit, bob1, qcid) <- runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + (_, qcid) <- setupFakeMLSGroup alice1 Nothing + void $ uploadNewKeyPackage bob1 + commit <- createAddCommit alice1 [bob] + pure (commit, bob1, qcid) + + welcome <- assertJust (mpWelcome commit) + + fedGalleyClient <- view tsFedGalleyClient + cannon <- view tsCannon + + WS.bracketR cannon (qUnqualified bob) $ \wsB -> do + -- send welcome message + void $ + runFedClient @"mls-welcome" fedGalleyClient (qDomain alice) $ + MLSWelcomeRequest + (qUnqualified alice) + (Base64ByteString welcome) + [qUnqualified (cidQualifiedClient bob1)] + qcid + + -- check that the corresponding event is received + liftIO $ do + WS.assertMatch_ (5 # WS.Second) wsB $ + wsAssertMLSWelcome alice qcid welcome + +testBackendRemoveProposalLocalConvLocalLeaverCreator :: TestM () +testBackendRemoveProposalLocalConvLocalLeaverCreator = do + [alice, bob] <- createAndConnectUsers (replicate 2 Nothing) + + runMLSTest $ do + [alice1, bob1, bob2] <- traverse createMLSClient [alice, bob, bob] + traverse_ uploadNewKeyPackage [bob1, bob2] + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + aliceClients <- getClientsFromGroupState alice1 alice + mlsBracket [alice1, bob1, bob2] $ \wss -> void $ do + liftTest $ + deleteMemberQualified (qUnqualified alice) alice qcnv + !!! const 200 === statusCode + -- remove alice's client from the test state + State.modify $ \mls -> + mls + { mlsMembers = Set.difference (mlsMembers mls) (Set.fromList [alice1]) + } + + for_ aliceClients $ \(_, idx) -> do + -- only bob's clients should receive the external proposals + msgs <- WS.assertMatchN (5 # Second) (drop 1 wss) $ \n -> + wsAssertBackendRemoveProposal alice (Conv <$> qcnv) idx n + traverse_ (uncurry consumeMessage1) (zip [bob1, bob2] msgs) + + -- but everyone should receive leave events + WS.assertMatchN_ (5 # WS.Second) wss $ + wsAssertMembersLeave qcnv alice [alice] + + -- check that no more events are sent, so in particular alice does not + -- receive any MLS messages + WS.assertNoEvent (1 # WS.Second) wss + + -- bob commits the external proposals + events <- createPendingProposalCommit bob1 >>= sendAndConsumeCommitBundle + liftIO $ events @?= [] + +testBackendRemoveProposalLocalConvLocalLeaverCommitter :: TestM () +testBackendRemoveProposalLocalConvLocalLeaverCommitter = do + [alice, bob, charlie] <- createAndConnectUsers (replicate 3 Nothing) + + runMLSTest $ do + [alice1, bob1, bob2, charlie1] <- traverse createMLSClient [alice, bob, bob, charlie] + traverse_ uploadNewKeyPackage [bob1, bob2, charlie1] + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + -- promote bob + putOtherMemberQualified (ciUser alice1) bob (OtherMemberUpdate (Just roleNameWireAdmin)) qcnv + !!! const 200 === statusCode + + void $ createAddCommit bob1 [charlie] >>= sendAndConsumeCommitBundle + + bobClients <- getClientsFromGroupState alice1 bob + mlsBracket [alice1, charlie1, bob1, bob2] $ \wss -> void $ do + liftTest $ + deleteMemberQualified (qUnqualified bob) bob qcnv + !!! const 200 === statusCode + -- remove bob clients from the test state + State.modify $ \mls -> + mls + { mlsMembers = Set.difference (mlsMembers mls) (Set.fromList [bob1, bob2]) + } + + for_ bobClients $ \(_, idx) -> do + -- only alice and charlie should receive the external proposals + msgs <- WS.assertMatchN (5 # Second) (take 2 wss) $ \n -> + wsAssertBackendRemoveProposal bob (Conv <$> qcnv) idx n + traverse_ (uncurry consumeMessage1) (zip [alice1, charlie1] msgs) + + -- but everyone should receive leave events + WS.assertMatchN_ (5 # WS.Second) wss $ + wsAssertMembersLeave qcnv bob [bob] + + -- check that no more events are sent, so in particular bob does not + -- receive any MLS messages + WS.assertNoEvent (1 # WS.Second) wss + + -- alice commits the external proposals + events <- createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle + liftIO $ events @?= [] + +testBackendRemoveProposalLocalConvRemoteLeaver :: TestM () +testBackendRemoveProposalLocalConvRemoteLeaver = do + [alice, bob] <- createAndConnectUsers [Nothing, Just "bob.example.com"] + + runMLSTest $ do + [alice1, bob1, bob2] <- traverse createMLSClient [alice, bob, bob] + (_, qcnv) <- setupMLSGroup alice1 + commit <- createAddCommit alice1 [bob] + + let mock = receiveCommitMock [bob1, bob2] <|> welcomeMock <|> messageSentMock + bobClients <- getClientsFromGroupState alice1 bob + void . withTempMockFederator' mock $ do + mlsBracket [alice1] $ \[wsA] -> void $ do + void $ sendAndConsumeCommitBundle commit + fedGalleyClient <- view tsFedGalleyClient + void $ + runFedClient + @"update-conversation" + fedGalleyClient + (qDomain bob) + ConversationUpdateRequest + { user = qUnqualified bob, + convId = qUnqualified qcnv, + action = SomeConversationAction SConversationLeaveTag () + } + + for_ bobClients $ \(_, idx) -> + WS.assertMatch_ (5 # WS.Second) wsA $ + wsAssertBackendRemoveProposal bob (Conv <$> qcnv) idx + +testBackendRemoveProposalLocalConvLocalClient :: TestM () +testBackendRemoveProposalLocalConvLocalClient = do + [alice, bob, charlie] <- createAndConnectUsers (replicate 3 Nothing) + + runMLSTest $ do + [alice1, bob1, bob2, charlie1] <- traverse createMLSClient [alice, bob, bob, charlie] + traverse_ uploadNewKeyPackage [bob1, bob2, charlie1] + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob, charlie] >>= sendAndConsumeCommitBundle + Just (_, idxBob1) <- find (\(ci, _) -> ci == bob1) <$> getClientsFromGroupState alice1 bob + + mlsBracket [alice1, bob1, charlie1] $ \[wsA, wsB, wsC] -> do + liftTest $ + deleteClient (ciUser bob1) (ciClient bob1) (Just defPassword) + !!! statusCode === const 200 + + State.modify $ \mls -> + mls + { mlsMembers = Set.difference (mlsMembers mls) (Set.fromList [bob1]) + } + + WS.assertMatch_ (5 # WS.Second) wsB $ + wsAssertClientRemoved (ciClient bob1) + + (msg : _) <- WS.assertMatchN (5 # WS.Second) [wsA, wsC] $ \notification -> do + wsAssertBackendRemoveProposal bob (Conv <$> qcnv) idxBob1 notification + + for_ [alice1, bob2, charlie1] $ + flip consumeMessage1 msg + + mp <- createPendingProposalCommit charlie1 + events <- sendAndConsumeCommitBundle mp + liftIO $ events @?= [] + WS.assertMatchN_ (5 # WS.Second) [wsA] $ \n -> do + wsAssertMLSMessage (Conv <$> qcnv) charlie (mpMessage mp) n + WS.assertNoEvent (2 # WS.Second) [wsC] + +testBackendRemoveProposalLocalConvRemoteClient :: TestM () +testBackendRemoveProposalLocalConvRemoteClient = do + [alice, bob] <- createAndConnectUsers [Nothing, Just "faraway.example.com"] + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + (_, qcnv) <- setupMLSGroup alice1 + commit <- createAddCommit alice1 [bob] + + [(_, idxBob1)] <- getClientsFromGroupState alice1 bob + let mock = receiveCommitMock [bob1] <|> welcomeMock <|> messageSentMock + void . withTempMockFederator' mock $ do + mlsBracket [alice1] $ \[wsA] -> void $ do + void $ sendAndConsumeCommitBundle commit + + fedGalleyClient <- view tsFedGalleyClient + void $ + runFedClient + @"on-client-removed" + fedGalleyClient + (ciDomain bob1) + (ClientRemovedRequest (ciUser bob1) (ciClient bob1) [qUnqualified qcnv]) + + WS.assertMatch_ (5 # WS.Second) wsA $ + \notification -> + void $ wsAssertBackendRemoveProposal bob (Conv <$> qcnv) idxBob1 notification + +testGetGroupInfoOfLocalConv :: TestM () +testGetGroupInfoOfLocalConv = do + [alice, bob] <- createAndConnectUsers [Nothing, Nothing] + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + traverse_ uploadNewKeyPackage [bob1] + (_, qcnv) <- setupMLSGroup alice1 + commit <- createAddCommit alice1 [bob] + + void $ sendAndConsumeCommitBundle commit + + -- check the group info matches + gs <- assertJust (mpGroupInfo commit) + returnedGS <- liftTest $ getGroupInfo alice (fmap Conv qcnv) + liftIO $ gs @=? returnedGS + +testGetGroupInfoOfRemoteConv :: TestM () +testGetGroupInfoOfRemoteConv = do + let aliceDomain = Domain "faraway.example.com" + [alice, bob, charlie] <- createAndConnectUsers [Just (domainText aliceDomain), Nothing, Nothing] + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + + void $ uploadNewKeyPackage bob1 + (_groupId, qcnv) <- setupFakeMLSGroup alice1 Nothing + mp <- createAddCommit alice1 [bob] + traverse_ consumeWelcome (mpWelcome mp) + + receiveOnConvUpdated qcnv alice bob + + let fakeGroupState = "\xde\xad\xbe\xef" + mock = queryGroupStateMock fakeGroupState bob + (_, reqs) <- withTempMockFederator' mock $ do + res <- liftTest $ getGroupInfo bob (fmap Conv qcnv) + liftIO $ res @?= fakeGroupState + + localGetGroupInfo (qUnqualified charlie) (fmap Conv qcnv) + !!! const 404 === statusCode + + -- check requests to mock federator: step 14 + liftIO $ do + let (req, _req2) = assertTwo reqs + frRPC req @?= "query-group-info" + frTargetDomain req @?= qDomain qcnv + +testFederatedGetGroupInfo :: TestM () +testFederatedGetGroupInfo = do + [alice, bob, charlie] <- createAndConnectUsers [Nothing, Just "faraway.example.com", Just "faraway.example.com"] + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + (_, qcnv) <- setupMLSGroup alice1 + commit <- createAddCommit alice1 [bob] + groupState <- assertJust (mpGroupInfo commit) + + let mock = receiveCommitMock [bob1] <|> welcomeMock + void . withTempMockFederator' mock $ do + void $ sendAndConsumeCommitBundle commit + + fedGalleyClient <- view tsFedGalleyClient + do + resp <- + runFedClient + @"query-group-info" + fedGalleyClient + (ciDomain bob1) + (GetGroupInfoRequest (Conv (qUnqualified qcnv)) (qUnqualified bob)) + + liftIO $ case resp of + GetGroupInfoResponseError err -> assertFailure ("Unexpected error: " <> show err) + GetGroupInfoResponseState gs -> + fromBase64ByteString gs @=? groupState + + do + resp <- + runFedClient + @"query-group-info" + fedGalleyClient + (ciDomain bob1) + (GetGroupInfoRequest (Conv (qUnqualified qcnv)) (qUnqualified charlie)) + + liftIO $ case resp of + GetGroupInfoResponseError err -> + err @?= ConvNotFound + GetGroupInfoResponseState _ -> + assertFailure "Unexpected success" + +testDeleteMLSConv :: TestM () +testDeleteMLSConv = do + localDomain <- viewFederationDomain + -- c <- view tsCannon + (tid, aliceUnq, [bobUnq]) <- API.Util.createBindingTeamWithMembers 2 + let alice = Qualified aliceUnq localDomain + bob = Qualified bobUnq localDomain + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + void $ uploadNewKeyPackage bob1 + + (_, qcnv) <- setupMLSGroup alice1 + commit <- createAddCommit alice1 [bob] + void $ sendAndConsumeCommitBundle commit + + deleteTeamConv tid (qUnqualified qcnv) aliceUnq + !!! statusCode === const 200 + +testAddUserToRemoteConvWithBundle :: TestM () +testAddUserToRemoteConvWithBundle = do + let aliceDomain = Domain "faraway.example.com" + [alice, bob, charlie] <- createAndConnectUsers [Just (domainText aliceDomain), Nothing, Nothing] + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + + void $ uploadNewKeyPackage bob1 + (_groupId, qcnv) <- setupFakeMLSGroup alice1 Nothing + + mp <- createAddCommit alice1 [bob] + traverse_ consumeWelcome (mpWelcome mp) + + receiveOnConvUpdated qcnv alice bob + + -- NB. this commit would be rejected by the owning backend, but for the + -- purpose of this test it's good enough. + [charlie1] <- traverse createMLSClient [charlie] + void $ uploadNewKeyPackage charlie1 + commit <- createAddCommit bob1 [charlie] + commitBundle <- createBundle commit + + let mock = "send-mls-commit-bundle" ~> MLSMessageResponseUpdates [] + (_, reqs) <- withTempMockFederator' mock $ do + void $ sendAndConsumeCommitBundle commit + + req <- liftIO $ assertOne reqs + liftIO $ do + frRPC req @?= "send-mls-commit-bundle" + frTargetDomain req @?= qDomain qcnv + + msr :: MLSMessageSendRequest <- case Aeson.eitherDecode (frBody req) of + Right b -> pure b + Left e -> assertFailure $ "Could not parse send-mls-commit-bundle request body: " <> e + + msr.convOrSubId @?= Conv (qUnqualified qcnv) + msr.sender @?= qUnqualified bob + fromBase64ByteString (msr.rawMessage) @?= commitBundle + +-- | The MLS self-conversation should be available even without explicitly +-- creating it by calling `GET /conversations/mls-self` starting from version 3 +-- of the client API and should not be listed in versions less than 3. +testSelfConversationList :: Bool -> TestM () +testSelfConversationList isBelowV3 = do + let (errMsg, justOrNothing, listCnvs) = + if isBelowV3 + then ("The MLS self-conversation is listed", isNothing, getConvPageV2) + else ("The MLS self-conversation is not listed", isJust, getConvPage) + alice <- randomUser + do + mMLSSelf <- findSelfConv alice listCnvs + liftIO $ assertBool errMsg (justOrNothing mMLSSelf) + + -- make sure that the self-conversation is not listed below V3 even once it + -- has been created. + unless isBelowV3 $ do + mMLSSelf <- findSelfConv alice getConvPageV2 + liftIO $ assertBool errMsg (isNothing mMLSSelf) + where + isMLSSelf u conv = mlsSelfConvId u == qUnqualified conv + + findSelfConv u listEndpoint = do + convIds :: ConvIdsPage <- + responseJsonError + =<< listEndpoint u Nothing (Just 100) + ) Nothing $ guard . isMLSSelf u <$> mtpResults convIds + + getConvPageV2 u s c = do + g <- view tsUnversionedGalley + getConvPageWithGalley (addPrefixAtVersion V2 . g) u s c + +testSelfConversationMLSNotConfigured :: TestM () +testSelfConversationMLSNotConfigured = do + alice <- randomUser + withMLSDisabled $ + getConvPage alice Nothing (Just 100) !!! const 200 === statusCode + +testSelfConversationOtherUser :: TestM () +testSelfConversationOtherUser = do + users@[_alice, bob] <- createAndConnectUsers [Nothing, Nothing] + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient users + void $ uploadNewKeyPackage bob1 + void $ setupMLSSelfGroup alice1 + commit <- createAddCommit alice1 [bob] + bundle <- createBundle commit + mlsBracket [alice1, bob1] $ \wss -> do + localPostCommitBundle (mpSender commit) bundle + !!! do + const 403 === statusCode + const (Just "invalid-op") === fmap Wai.label . responseJsonError + WS.assertNoEvent (1 # WS.Second) wss + +testSelfConversationLeave :: TestM () +testSelfConversationLeave = do + alice <- randomQualifiedUser + runMLSTest $ do + clients@(creator : others) <- traverse createMLSClient (replicate 3 alice) + traverse_ uploadNewKeyPackage others + (_, qcnv) <- setupMLSSelfGroup creator + void $ createAddCommit creator [alice] >>= sendAndConsumeCommitBundle + mlsBracket clients $ \wss -> do + liftTest $ + deleteMemberQualified (qUnqualified alice) alice qcnv + !!! do + const 403 === statusCode + const (Just "invalid-op") === fmap Wai.label . responseJsonError + WS.assertNoEvent (1 # WS.Second) wss + +assertMLSNotEnabled :: Assertions () +assertMLSNotEnabled = do + const 400 === statusCode + const (Just "mls-not-enabled") === fmap Wai.label . responseJsonError + +postMLSConvDisabled :: TestM () +postMLSConvDisabled = do + alice <- randomQualifiedUser + withMLSDisabled $ + postConvQualified + (qUnqualified alice) + (Just (newClientId 0)) + defNewMLSConv + !!! assertMLSNotEnabled + +postMLSMessageDisabled :: TestM () +postMLSMessageDisabled = do + [alice, bob] <- createAndConnectUsers [Nothing, Nothing] + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + void $ uploadNewKeyPackage bob1 + void $ setupMLSGroup alice1 + mp <- createAddCommit alice1 [bob] + bundle <- createBundle mp + withMLSDisabled $ + localPostCommitBundle (mpSender mp) bundle + !!! assertMLSNotEnabled + +postMLSBundleDisabled :: TestM () +postMLSBundleDisabled = do + [alice, bob] <- createAndConnectUsers [Nothing, Nothing] + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + void $ uploadNewKeyPackage bob1 + void $ setupMLSGroup alice1 + mp <- createAddCommit alice1 [bob] + withMLSDisabled $ do + bundle <- createBundle mp + localPostCommitBundle (mpSender mp) bundle + !!! assertMLSNotEnabled + +getGroupInfoDisabled :: TestM () +getGroupInfoDisabled = do + [alice, bob] <- createAndConnectUsers [Nothing, Nothing] + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + void $ uploadNewKeyPackage bob1 + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + withMLSDisabled $ + localGetGroupInfo (qUnqualified alice) (fmap Conv qcnv) + !!! assertMLSNotEnabled + +deleteSubConversationDisabled :: TestM () +deleteSubConversationDisabled = do + alice <- randomUser + cnvId <- Qualified <$> randomId <*> pure (Domain "www.example.com") + let scnvId = SubConvId "conference" + dsc = + DeleteSubConversationRequest + (GroupId "MLS") + (Epoch 0) + withMLSDisabled $ + deleteSubConv alice cnvId scnvId dsc !!! assertMLSNotEnabled + +testExternalCommitSameClientSubConv :: TestM () +testExternalCommitSameClientSubConv = do + [alice, bob] <- createAndConnectUsers (replicate 2 Nothing) + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + void $ uploadNewKeyPackage bob1 + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + let subId = SubConvId "conference" + + -- alice1 and bob1 create and join a subconversation, respectively + qsub <- createSubConv qcnv alice1 subId + void $ + createExternalCommit bob1 Nothing qsub + >>= sendAndConsumeCommitBundle + + Just (_, idxBob1) <- find (\(ci, _) -> ci == bob1) <$> getClientsFromGroupState alice1 bob + + -- bob1 leaves and immediately rejoins + mlsBracket [alice1, bob1] $ \[wsA, wsB] -> do + void $ leaveCurrentConv bob1 qsub + WS.assertMatchN_ (5 # WS.Second) [wsA] $ + wsAssertBackendRemoveProposal bob qsub idxBob1 + void $ + createExternalCommit bob1 Nothing qsub + >>= sendAndConsumeCommitBundle + WS.assertNoEvent (2 # WS.Second) [wsB] + +testJoinSubNonMemberClient :: TestM () +testJoinSubNonMemberClient = do + [alice, bob] <- createAndConnectUsers [Nothing, Nothing] + + runMLSTest $ do + [alice1, alice2, bob1] <- + traverse createMLSClient [alice, alice, bob] + traverse_ uploadNewKeyPackage [bob1, alice2] + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [alice] >>= sendAndConsumeCommitBundle + + qcs <- createSubConv qcnv alice1 (SubConvId "conference") + + -- now Bob attempts to get the group info so he can join via external commit + -- with his own client, but he cannot because he is not a member of the + -- parent conversation + localGetGroupInfo (ciUser bob1) qcs + !!! const 404 === statusCode + +testAddClientSubConvFailure :: TestM () +testAddClientSubConvFailure = do + [alice, bob] <- createAndConnectUsers [Nothing, Nothing] + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + void $ uploadNewKeyPackage bob1 + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + let subId = SubConvId "conference" + void $ createSubConv qcnv alice1 subId + + void $ uploadNewKeyPackage bob1 + + commit <- createAddCommit alice1 [bob] + (createBundle commit >>= localPostCommitBundle (mpSender commit)) + !!! do + const 400 === statusCode + const (Just "Add proposals in subconversations are not supported") + === fmap Wai.message . responseJsonError + + finalSub <- + liftTest $ + responseJsonError + =<< getSubConv (qUnqualified alice) qcnv subId + MLSMessageResponseUpdates []) + <|> queryGroupStateMock (fold pgs) bob + <|> sendMessageMock + (_, reqs) <- withTempMockFederator' mock $ do + commit <- createExternalCommit bob1 Nothing qcs + sendAndConsumeCommitBundle commit + + -- check that commit bundle is sent to remote backend + fr <- assertOne (filter ((== "send-mls-commit-bundle") . frRPC) reqs) + liftIO $ do + mmsr :: MLSMessageSendRequest <- assertJust (Aeson.decode (frBody fr)) + mmsr.convOrSubId @?= qUnqualified qcs + mmsr.sender @?= ciUser bob1 + mmsr.senderClient @?= ciClient bob1 + +testRemoteSubConvNotificationWhenUserJoins :: TestM () +testRemoteSubConvNotificationWhenUserJoins = do + [alice, bob] <- createAndConnectUsers [Nothing, Just "bob.example.com"] + + runMLSTest $ do + alice1 <- createMLSClient alice + bob1 <- createFakeMLSClient bob + + (_, qcnv) <- setupMLSGroup alice1 + gsBackup <- getClientGroupState alice1 + void $ createPendingProposalCommit alice1 >>= sendAndConsumeCommitBundle + let subId = SubConvId "conference" + s <- State.get + void $ createSubConv qcnv alice1 subId + + -- revert first commit and subconv + setClientGroupState alice1 gsBackup + + State.put s + + void $ + withTempMockFederator' (receiveCommitMock [bob1] <|> welcomeMock) $ + createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + +testSendMessageSubConv :: TestM () +testSendMessageSubConv = do + [alice, bob] <- createAndConnectUsers [Nothing, Nothing] + + runMLSTest $ do + [alice1, bob1, bob2] <- traverse createMLSClient [alice, bob, bob] + traverse_ uploadNewKeyPackage [bob1, bob2] + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + + qcs <- createSubConv qcnv bob1 (SubConvId "conference") + + void $ createExternalCommit alice1 Nothing qcs >>= sendAndConsumeCommitBundle + void $ createExternalCommit bob2 Nothing qcs >>= sendAndConsumeCommitBundle + + message <- createApplicationMessage alice1 "some text" + mlsBracket [bob1, bob2] $ \wss -> do + events <- sendAndConsumeMessage message + liftIO $ events @?= [] + liftIO $ + WS.assertMatchN_ (5 # WS.Second) wss $ \n -> do + wsAssertMLSMessage qcs alice (mpMessage message) n + +testGetRemoteSubConv :: Bool -> TestM () +testGetRemoteSubConv isAMember = do + alice <- randomQualifiedUser + let remoteDomain = Domain "faraway.example.com" + conv <- randomId + let qconv = Qualified conv remoteDomain + sconv = SubConvId "conference" + fakeSubConv = + PublicSubConversation + { pscParentConvId = qconv, + pscSubConvId = sconv, + pscGroupId = GroupId "deadbeef", + pscEpoch = Epoch 0, + pscEpochTimestamp = Nothing, + pscCipherSuite = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519, + pscMembers = [] + } + let mock = do + guardRPC "get-sub-conversation" + mockReply $ + if isAMember + then GetSubConversationsResponseSuccess fakeSubConv + else GetSubConversationsResponseError ConvNotFound + (_, reqs) <- + withTempMockFederator' mock $ + getSubConv (qUnqualified alice) qconv sconv + TestM () +testRemoteMemberGetSubConv isAMember = do + -- alice is local, bob is remote + -- alice creates a local conversation and invites bob + -- bob gets a subconversation via federated enpdoint + + let bobDomain = Domain "faraway.example.com" + [alice, bob] <- createAndConnectUsers [Nothing, Just (domainText bobDomain)] + + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + + (_groupId, qcnv) <- setupMLSGroup alice1 + kpb <- claimKeyPackages alice1 bob + mp <- createAddCommit alice1 [bob] + + let mock = receiveCommitMock [bob1] <|> welcomeMock <|> claimKeyPackagesMock kpb + void . withTempMockFederator' mock $ + sendAndConsumeCommitBundle mp + + let subconv = SubConvId "conference" + + randUser <- randomId + let gscr = + GetSubConversationsRequest + { gsreqUser = if isAMember then qUnqualified bob else randUser, + gsreqConv = qUnqualified qcnv, + gsreqSubConv = subconv + } + + fedGalleyClient <- view tsFedGalleyClient + res <- runFedClient @"get-sub-conversation" fedGalleyClient bobDomain gscr + + liftTest $ do + if isAMember + then do + sub <- expectSubConvSuccess res + liftIO $ do + pscParentConvId sub @?= qcnv + pscSubConvId sub @?= subconv + else do + expectSubConvError ConvNotFound res + where + expectSubConvSuccess :: GetSubConversationsResponse -> TestM PublicSubConversation + expectSubConvSuccess (GetSubConversationsResponseSuccess fakeSubConv) = pure fakeSubConv + expectSubConvSuccess (GetSubConversationsResponseError err) = liftIO $ assertFailure ("Unexpected GetSubConversationsResponseError: " <> show err) + + expectSubConvError :: GalleyError -> GetSubConversationsResponse -> TestM () + expectSubConvError _errExpected (GetSubConversationsResponseSuccess _) = liftIO $ assertFailure "Unexpected GetSubConversationsResponseSuccess" + expectSubConvError errExpected (GetSubConversationsResponseError err) = liftIO $ err @?= errExpected + +-- In this test case, Alice creates a subconversation, Bob joins and Alice +-- leaves. The leaving causes the backend to generate an external remove +-- proposal for the client by Alice. Next, Bob does not commit (simulating his +-- client crashing), and then deleting the subconversation after coming back up. +-- Then Bob creates a subconversation with the same subconversation ID and the +-- test asserts that both Alice and Bob get no events, which means the backend +-- does not resubmit the pending remove proposal for Alice's client. +testJoinDeletedSubConvWithRemoval :: TestM () +testJoinDeletedSubConvWithRemoval = do + [alice, bob] <- createAndConnectUsers [Nothing, Nothing] + runMLSTest $ do + [alice1, bob1] <- traverse createMLSClient [alice, bob] + void $ uploadNewKeyPackage bob1 + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob] >>= sendAndConsumeCommitBundle + let subConvId = SubConvId "conference" + qsconvId <- createSubConv qcnv alice1 subConvId + void $ + createExternalCommit bob1 Nothing qsconvId + >>= sendAndConsumeCommitBundle + liftTest $ + leaveSubConv (ciUser alice1) (ciClient alice1) qcnv subConvId + !!! const 200 === statusCode + -- no committing by Bob of the backend-generated remove proposal for alice1 + -- (simulating his client crashing) + + do + sub <- + liftTest $ + responseJsonError + =<< getSubConv (qUnqualified bob) qcnv subConvId + do + void $ createSubConv qcnv bob1 subConvId + void . liftIO $ WS.assertNoEvent (3 # WS.Second) wss + +testDeleteSubConvStale :: TestM () +testDeleteSubConvStale = do + alice <- randomQualifiedUser + let sconv = SubConvId "conference" + (qcnv, sub) <- runMLSTest $ do + alice1 <- createMLSClient alice + (_, qcnv) <- setupMLSGroup alice1 + sub <- + liftTest $ + responseJsonError + =<< getSubConv (qUnqualified alice) qcnv sconv + >= sendAndConsumeCommitBundle + pure (qcnv, sub) + + -- the commit was made, yet the epoch for the request body is old + let dsc = DeleteSubConversationRequest (pscGroupId sub) (pscEpoch sub) + deleteSubConv (qUnqualified alice) qcnv sconv dsc + !!! do const 409 === statusCode + +testDeleteRemoteSubConv :: Bool -> TestM () +testDeleteRemoteSubConv isAMember = do + alice <- randomQualifiedUser + let remoteDomain = Domain "faraway.example.com" + conv <- randomId + let qconv = Qualified conv remoteDomain + sconv = SubConvId "conference" + groupId = GroupId "deadbeef" + epoch = Epoch 0 + expectedReq = + DeleteSubConversationFedRequest + { dscreqUser = qUnqualified alice, + dscreqConv = conv, + dscreqSubConv = sconv, + dscreqGroupId = groupId, + dscreqEpoch = epoch + } + + let mock = do + guardRPC "delete-sub-conversation" + mockReply $ + if isAMember + then DeleteSubConversationResponseSuccess + else DeleteSubConversationResponseError ConvNotFound + dsc = DeleteSubConversationRequest groupId epoch + + (_, reqs) <- + withTempMockFederator' mock $ + deleteSubConv (qUnqualified alice) qconv sconv dsc + >= sendAndConsumeCommitBundle + + let subId = SubConvId "conference" + qsub <- createSubConv qcnv alice1 subId + prePsc <- + liftTest $ + responseJsonError + =<< getSubConv (qUnqualified alice) qcnv subId + >= sendAndConsumeCommitBundle + + let subId = SubConvId "conference" + _qsub <- createSubConv qcnv bob1 subId + + -- alice attempts to leave + liftTest $ do + e <- + responseJsonError + =<< leaveSubConv (ciUser alice1) (ciClient alice1) qcnv subId + MLSMessageResponseUpdates []) + <|> queryGroupStateMock (fold pgs) bob + <|> sendMessageMock + <|> ("leave-sub-conversation" ~> LeaveSubConversationResponseOk) + (_, reqs) <- withTempMockFederator' mock $ do + -- bob joins subconversation + void $ createExternalCommit bob1 Nothing qcs >>= sendAndConsumeCommitBundle + + -- bob leaves + liftTest $ + leaveSubConv (ciUser bob1) (ciClient bob1) qcnv subId + !!! const 200 === statusCode + + -- check that leave-sub-conversation is called + void $ assertOne (filter ((== "leave-sub-conversation") . frRPC) reqs) + +testRemoveUserParent :: TestM () +testRemoveUserParent = do + [alice, bob, charlie] <- createAndConnectUsers [Nothing, Nothing, Nothing] + let subname = SubConvId "conference" + + (qcnv, [alice1, bob1, bob2, _charlie1, _charlie2]) <- runMLSTest $ + do + clients@[alice1, bob1, bob2, charlie1, charlie2] <- + traverse + createMLSClient + [alice, bob, bob, charlie, charlie] + traverse_ uploadNewKeyPackage [bob1, bob2, charlie1, charlie2] + (_, qcnv) <- setupMLSGroup alice1 + void $ createAddCommit alice1 [bob, charlie] >>= sendAndConsumeCommitBundle + + void $ createSubConv qcnv bob1 subname + let qcs = fmap (flip SubConv subname) qcnv + + -- all clients join + for_ [alice1, bob2, charlie1, charlie2] $ \c -> + void $ createExternalCommit c Nothing qcs >>= sendAndConsumeCommitBundle + + pure (qcnv, clients) + + -- charlie leaves the main conversation + deleteMemberQualified (qUnqualified charlie) charlie qcnv + !!! const 200 === statusCode + + getSubConv (qUnqualified charlie) qcnv subname + !!! const 403 === statusCode + + sub :: PublicSubConversation <- + responseJsonError + =<< getSubConv (qUnqualified bob) qcnv subname + >= sendAndConsumeCommitBundle + + void $ createSubConv qcnv alice1 subname + let qcs = fmap (flip SubConv subname) qcnv + + -- all clients join + for_ [bob1, bob2, charlie1, charlie2] $ \c -> + void $ createExternalCommit c Nothing qcs >>= sendAndConsumeCommitBundle + + pure (qcnv, clients) + + -- creator leaves the main conversation + deleteMemberQualified (qUnqualified alice) alice qcnv + !!! const 200 === statusCode + + getSubConv (qUnqualified alice) qcnv subname + !!! const 403 === statusCode + + -- charlie sees updated memberlist + sub :: PublicSubConversation <- + responseJsonError + =<< getSubConv (qUnqualified charlie) qcnv subname + >= sendAndConsumeCommitBundle + + stateParent <- State.get + + let subId = SubConvId "conference" + qcs <- createSubConv qcnv alice1 subId + liftTest $ + getSubConv (qUnqualified alice) qcnv subId + !!! do const 200 === statusCode + + for_ [bob1, bob2, charlie1, charlie2] $ \c -> do + void $ createExternalCommit c Nothing qcs >>= sendAndConsumeCommitBundle + + stateSub <- State.get + State.put stateParent + + mlsBracket [alice1, charlie1, charlie2] $ \wss -> do + events <- createRemoveCommit alice1 [bob1, bob2] >>= sendAndConsumeCommitBundle + State.modify $ \s -> s {mlsMembers = Set.difference (mlsMembers s) (Set.fromList [bob1, bob2])} + + liftIO $ assertOne events >>= assertLeaveEvent qcnv alice [bob] + + WS.assertMatchN_ (5 # Second) wss $ \n -> do + wsAssertMemberLeave qcnv alice [bob] EdReasonRemoved n + + State.put stateSub + -- Get client state for alice and fetch bob client identities + [(_, idxBob1), (_, idxBob2)] <- getClientsFromGroupState alice1 bob + + -- handle bob1 removal + msgs <- WS.assertMatchN (5 # Second) wss $ \n -> do + -- it was an alice proposal for the parent, + -- but it's a backend proposal for the sub + wsAssertBackendRemoveProposal bob qcs idxBob1 n + + traverse_ (uncurry consumeMessage1) (zip [alice1, charlie1, charlie2] msgs) + + -- handle bob2 removal + msgs2 <- WS.assertMatchN (5 # Second) wss $ \n -> do + -- it was an alice proposal for the parent, + -- but it's a backend proposal for the sub + wsAssertBackendRemoveProposal bob qcs idxBob2 n + + traverse_ (uncurry consumeMessage1) (zip [alice1, charlie1, charlie2] msgs2) + + -- Remove bob from our state as well + State.modify $ \mls -> + mls + { mlsMembers = Set.difference (mlsMembers mls) (Set.fromList [bob1, bob2]) + } + -- alice commits the proposal and sends over for the backend to also process it + void $ + createPendingProposalCommit alice1 + >>= sendAndConsumeCommitBundle + + liftTest $ do + getSubConv (qUnqualified bob) qcnv (SubConvId "conference") + !!! const 403 === statusCode + + -- charlie sees updated memberlist + sub1 :: PublicSubConversation <- + responseJsonError + =<< getSubConv (qUnqualified charlie) qcnv (SubConvId "conference") + (show . length . pscMembers $ sub1) + ) + (sort [alice1, charlie1, charlie2]) + (sort $ pscMembers sub1) + + -- alice also sees updated memberlist + sub2 :: PublicSubConversation <- + responseJsonError + =<< getSubConv (qUnqualified alice) qcnv (SubConvId "conference") + (show . length . pscMembers $ sub2) + ) + (sort [alice1, charlie1, charlie2]) + (sort $ pscMembers sub2) diff --git a/services/galley/test/integration/API/MLS/Mocks.hs b/services/galley/test/integration/API/MLS/Mocks.hs index db80027d402..69cd62f2902 100644 --- a/services/galley/test/integration/API/MLS/Mocks.hs +++ b/services/galley/test/integration/API/MLS/Mocks.hs @@ -25,6 +25,7 @@ module API.MLS.Mocks sendMessageMock, claimKeyPackagesMock, queryGroupStateMock, + deleteMLSConvMock, ) where @@ -36,6 +37,8 @@ import Data.Set qualified as Set import Federator.MockServer import Imports import Wire.API.Error.Galley +import Wire.API.Federation.API.Brig +import Wire.API.Federation.API.Common (EmptyResponse (..)) import Wire.API.Federation.API.Galley import Wire.API.MLS.Credential import Wire.API.MLS.KeyPackage @@ -44,7 +47,9 @@ import Wire.API.User.Client receiveCommitMock :: [ClientIdentity] -> Mock LByteString receiveCommitMock clients = asum - [ "get-mls-clients" ~> + [ "on-conversation-updated" ~> EmptyResponse, + "get-not-fully-connected-backends" ~> NonConnectedBackends mempty, + "get-mls-clients" ~> Set.fromList ( map (flip ClientInfo True . ciClient) clients ) @@ -80,7 +85,6 @@ sendMessageMock = "send-mls-message" ~> MLSMessageResponseUpdates [] - mempty claimKeyPackagesMock :: KeyPackageBundle -> Mock LByteString claimKeyPackagesMock kpb = "claim-key-packages" ~> kpb @@ -88,8 +92,14 @@ claimKeyPackagesMock kpb = "claim-key-packages" ~> kpb queryGroupStateMock :: ByteString -> Qualified UserId -> Mock LByteString queryGroupStateMock gs qusr = do guardRPC "query-group-info" - uid <- ggireqSender <$> getRequestBody + uid <- (\(a :: GetGroupInfoRequest) -> a.sender) <$> getRequestBody mockReply $ if uid == qUnqualified qusr then GetGroupInfoResponseState (Base64ByteString gs) else GetGroupInfoResponseError ConvNotFound + +deleteMLSConvMock :: Mock LByteString +deleteMLSConvMock = + asum + [ "on-conversation-updated" ~> EmptyResponse + ] diff --git a/services/galley/test/integration/API/MLS/Util.hs b/services/galley/test/integration/API/MLS/Util.hs index a34b8bf714c..435c4f0c6a8 100644 --- a/services/galley/test/integration/API/MLS/Util.hs +++ b/services/galley/test/integration/API/MLS/Util.hs @@ -26,20 +26,22 @@ import Bilge import Bilge.Assert import Control.Arrow ((&&&)) import Control.Error.Util -import Control.Lens (preview, to, view, (.~), (^..)) +import Control.Lens (preview, to, view, (.~), (^..), (^?)) import Control.Monad.Catch +import Control.Monad.Cont import Control.Monad.State (StateT, evalStateT) import Control.Monad.State qualified as State import Control.Monad.Trans.Maybe -import Crypto.PubKey.Ed25519 import Data.Aeson.Lens +import Data.Bifunctor +import Data.Binary.Builder (toLazyByteString) +import Data.Binary.Get import Data.ByteArray qualified as BA import Data.ByteString qualified as BS import Data.ByteString.Base64.URL qualified as B64U import Data.ByteString.Conversion import Data.ByteString.Lazy qualified as LBS import Data.Domain -import Data.Hex import Data.Id import Data.Json.Util hiding ((#)) import Data.Map qualified as Map @@ -47,13 +49,13 @@ import Data.Qualified import Data.Set qualified as Set import Data.Text qualified as T import Data.Text.Encoding qualified as T -import Data.Time.Clock (getCurrentTime) -import Data.Tuple.Extra qualified as Tuple +import Data.Time +import Data.UUID qualified as UUID +import Data.UUID.V4 qualified as UUIDV4 import Galley.Keys import Galley.Options import Galley.Options qualified as Opts import Imports hiding (getSymbolicLinkTarget) -import System.Directory (getSymbolicLinkTarget) import System.FilePath import System.IO.Temp import System.Posix hiding (createDirectory) @@ -63,46 +65,34 @@ import Test.Tasty.Cannon qualified as WS import Test.Tasty.HUnit import TestHelpers import TestSetup +import Web.HttpApiData import Wire.API.Conversation import Wire.API.Conversation.Action import Wire.API.Conversation.Protocol import Wire.API.Conversation.Role (roleNameWireMember) import Wire.API.Event.Conversation import Wire.API.Federation.API.Galley +import Wire.API.MLS.CipherSuite (SignatureSchemeTag (Ed25519)) import Wire.API.MLS.CommitBundle import Wire.API.MLS.Credential -import Wire.API.MLS.GroupInfoBundle +import Wire.API.MLS.Group.Serialisation import Wire.API.MLS.KeyPackage import Wire.API.MLS.Keys +import Wire.API.MLS.LeafNode import Wire.API.MLS.Message -import Wire.API.MLS.Proposal import Wire.API.MLS.Serialisation -import Wire.API.Unreachable +import Wire.API.MLS.SubConversation import Wire.API.User.Client import Wire.API.User.Client.Prekey cid2Str :: ClientIdentity -> String cid2Str cid = - show (ciUser cid) + show cid.ciUser <> ":" - <> T.unpack (client . ciClient $ cid) + <> T.unpack cid.ciClient.client <> "@" <> T.unpack (domainText (ciDomain cid)) -mapRemoteKeyPackageRef :: - (MonadIO m, MonadHttp m, MonadCatch m) => - (Request -> Request) -> - KeyPackageBundle -> - m () -mapRemoteKeyPackageRef brig bundle = - void $ - put - ( brig - . paths ["i", "mls", "key-package-refs"] - . json bundle - ) - !!! const 204 === statusCode - postMessage :: ( HasCallStack, MonadIO m, @@ -113,18 +103,18 @@ postMessage :: ByteString -> m ResponseLBS postMessage sender msg = do - galley <- viewGalley + galleyCall <- viewGalley post - ( galley + ( galleyCall . paths ["mls", "messages"] . zUser (ciUser sender) . zClient (ciClient sender) . zConn "conn" - . content "message/mls" + . Bilge.content "message/mls" . bytes msg ) -postCommitBundle :: +localPostCommitBundle :: ( HasCallStack, MonadIO m, MonadHttp m, @@ -133,64 +123,79 @@ postCommitBundle :: ClientIdentity -> ByteString -> m ResponseLBS -postCommitBundle sender bundle = do - galley <- viewGalley +localPostCommitBundle sender bundle = do + galleyCall <- viewGalley post - ( galley + ( galleyCall . paths ["mls", "commit-bundles"] . zUser (ciUser sender) . zClient (ciClient sender) . zConn "conn" - . content "application/x-protobuf" + . Bilge.content "message/mls" . bytes bundle ) --- FUTUREWORK: remove this and start using commit bundles everywhere in tests -postWelcome :: +remotePostCommitBundle :: ( MonadIO m, - MonadHttp m, - MonadReader TestSetup m, - HasCallStack + MonadReader TestSetup m ) => - UserId -> + Remote ClientIdentity -> + Qualified ConvOrSubConvId -> ByteString -> - m ResponseLBS -postWelcome uid welcome = do - galley <- view tsUnversionedGalley - post - ( galley - . paths ["v2", "mls", "welcome"] - . zUser uid - . zConn "conn" - . content "message/mls" - . bytes welcome - ) + m [Event] +remotePostCommitBundle rsender qcs bundle = do + client <- view tsFedGalleyClient + let msr = + MLSMessageSendRequest + { convOrSubId = qUnqualified qcs, + sender = ciUser (tUnqualified rsender), + senderClient = ciClient (tUnqualified rsender), + rawMessage = Base64ByteString bundle + } + runFedClient + @"send-mls-commit-bundle" + client + (tDomain rsender) + msr + >>= liftIO . \case + MLSMessageResponseError e -> + assertFailure $ + "error while receiving commit bundle: " <> show e + MLSMessageResponseProtocolError e -> + assertFailure $ + "protocol error while receiving commit bundle: " <> T.unpack e + MLSMessageResponseProposalFailure e -> + assertFailure $ + "proposal failure while receiving commit bundle: " <> displayException e + e@(MLSMessageResponseUnreachableBackends _) -> + assertFailure $ + "error while receiving commit bundle: " <> show e + e@(MLSMessageResponseNonFederatingBackends _) -> + assertFailure $ + "error while receiving commit bundle: " <> show e + MLSMessageResponseUpdates _ -> pure [] -mkAppAckProposalMessage :: - GroupId -> - Epoch -> - KeyPackageRef -> - [MessageRange] -> - SecretKey -> - PublicKey -> - Message 'MLSPlainText -mkAppAckProposalMessage gid epoch ref mrs priv pub = do - let tbs = - mkRawMLS $ - MessageTBS - { tbsMsgFormat = KnownFormatTag, - tbsMsgGroupId = gid, - tbsMsgEpoch = epoch, - tbsMsgAuthData = mempty, - tbsMsgSender = MemberSender ref, - tbsMsgPayload = ProposalMessage (mkAppAckProposal mrs) - } - sig = BA.convert $ sign priv pub (rmRaw tbs) - in Message tbs (MessageExtraFields sig Nothing Nothing) +postCommitBundle :: + HasCallStack => + ClientIdentity -> + Qualified ConvOrSubConvId -> + ByteString -> + TestM [Event] +postCommitBundle sender qcs bundle = do + loc <- qualifyLocal () + foldQualified + loc + ( \_ -> + fmap mmssEvents . responseJsonError + =<< localPostCommitBundle sender bundle + remotePostCommitBundle rsender qcs bundle) + (cidQualifiedUser sender $> sender) saveRemovalKey :: FilePath -> TestM () saveRemovalKey fp = do - keys <- fromJust <$> view (tsGConf . optSettings . setMlsPrivateKeyPaths) + keys <- fromJust <$> view (tsGConf . settings . mlsPrivateKeyPaths) keysByPurpose <- liftIO $ loadAllMLSKeys keys let (_, pub) = fromJust (mlsKeyPair_ed25519 (keysByPurpose RemovalPurpose)) liftIO $ BS.writeFile fp (BA.convert pub) @@ -202,9 +207,12 @@ data MLSState = MLSState mlsMembers :: Set ClientIdentity, -- | users expected to receive a welcome message after the next commit mlsNewMembers :: Set ClientIdentity, + mlsClientGroupState :: Map ClientIdentity ByteString, mlsGroupId :: Maybe GroupId, + mlsConvId :: Maybe (Qualified ConvOrSubConvId), mlsEpoch :: Word64 } + deriving (Show) newtype MLSTest a = MLSTest {unMLSTest :: StateT MLSState TestM a} deriving newtype @@ -247,7 +255,9 @@ runMLSTest (MLSTest m) = mlsUnusedPrekeys = someLastPrekeys, mlsMembers = mempty, mlsNewMembers = mempty, + mlsClientGroupState = mempty, mlsGroupId = Nothing, + mlsConvId = Nothing, mlsEpoch = 0 } @@ -255,8 +265,9 @@ data MessagePackage = MessagePackage { mpSender :: ClientIdentity, mpMessage :: ByteString, mpWelcome :: Maybe ByteString, - mpPublicGroupState :: Maybe ByteString + mpGroupInfo :: Maybe ByteString } + deriving (Show) takeLastPrekeyNG :: HasCallStack => MLSTest LastPrekey takeLastPrekeyNG = do @@ -267,12 +278,54 @@ takeLastPrekeyNG = do pure pk [] -> error "no prekeys left" +toRandomFile :: ByteString -> MLSTest FilePath +toRandomFile bs = do + p <- randomFileName + liftIO $ BS.writeFile p bs + pure p + +randomFileName :: MLSTest FilePath +randomFileName = do + bd <- State.gets mlsBaseDir + (bd ) . UUID.toString <$> liftIO UUIDV4.nextRandom + mlscli :: HasCallStack => ClientIdentity -> [String] -> Maybe ByteString -> MLSTest ByteString mlscli qcid args mbstdin = do bd <- State.gets mlsBaseDir let cdir = bd cid2Str qcid - liftIO $ do - spawn (proc "mls-test-cli" (["--store", cdir "store"] <> args)) mbstdin + + groupOut <- randomFileName + let substOut = argSubst "" groupOut + + hasState <- hasClientGroupState qcid + substIn <- + if hasState + then do + gs <- getClientGroupState qcid + fn <- toRandomFile gs + pure (argSubst "" fn) + else pure Imports.id + + out <- + liftIO $ + spawn + ( proc + "mls-test-cli" + ( ["--store", cdir "store"] + <> map (substIn . substOut) args + ) + ) + mbstdin + + groupOutWritten <- liftIO $ doesFileExist groupOut + when groupOutWritten $ do + gs <- liftIO (BS.readFile groupOut) + setClientGroupState qcid gs + pure out + +argSubst :: String -> String -> String -> String +argSubst from to_ s = + if s == from then to_ else s createWireClient :: HasCallStack => Qualified UserId -> MLSTest ClientIdentity createWireClient qusr = do @@ -293,10 +346,10 @@ createLocalMLSClient (tUntagged -> qusr) = do -- set public key pkey <- mlscli qcid ["public-key"] Nothing - brig <- viewBrig + brigCall <- viewBrig let update = defUpdateClient {updateClientMLSPublicKeys = Map.singleton Ed25519 pkey} put - ( brig + ( brigCall . paths ["clients", toByteString' . ciClient $ qcid] . zUser (ciUser qcid) . json update @@ -320,88 +373,43 @@ createFakeMLSClient qusr = do pure cid -- | create and upload to backend -uploadNewKeyPackage :: HasCallStack => ClientIdentity -> MLSTest KeyPackageRef +uploadNewKeyPackage :: HasCallStack => ClientIdentity -> MLSTest (RawMLS KeyPackage) uploadNewKeyPackage qcid = do (kp, _) <- generateKeyPackage qcid -- upload key package - brig <- viewBrig + brigCall <- viewBrig post - ( brig + ( brigCall . paths ["mls", "key-packages", "self", toByteString' . ciClient $ qcid] . zUser (ciUser qcid) . json (KeyPackageUpload [kp]) ) !!! const 201 === statusCode - pure $ fromJust (kpRef' kp) + pure kp generateKeyPackage :: HasCallStack => ClientIdentity -> MLSTest (RawMLS KeyPackage, KeyPackageRef) generateKeyPackage qcid = do - kp <- liftIO . decodeMLSError =<< mlscli qcid ["key-package", "create"] Nothing + kpData <- mlscli qcid ["key-package", "create"] Nothing + kp <- liftIO $ decodeMLSError kpData let ref = fromJust (kpRef' kp) - fp <- keyPackageFile qcid ref - liftIO $ BS.writeFile fp (rmRaw kp) pure (kp, ref) -groupFileLink :: HasCallStack => ClientIdentity -> MLSTest FilePath -groupFileLink qcid = State.gets $ \mls -> - mlsBaseDir mls cid2Str qcid "group.latest" - -currentGroupFile :: HasCallStack => ClientIdentity -> MLSTest FilePath -currentGroupFile = liftIO . getSymbolicLinkTarget <=< groupFileLink - -parseGroupFileName :: FilePath -> IO (FilePath, Int) -parseGroupFileName fp = do - let base = takeFileName fp - (prefix, version) <- case break (== '.') base of - (p, '.' : v) -> pure (p, v) - _ -> assertFailure "invalid group file name" - n <- case reads version of - [(v, "")] -> pure (v :: Int) - _ -> assertFailure "could not parse group file version" - pure $ (prefix, n) - --- sets symlink and creates empty file -nextGroupFile :: HasCallStack => ClientIdentity -> MLSTest FilePath -nextGroupFile qcid = do - bd <- State.gets mlsBaseDir - link <- groupFileLink qcid - exists <- doesFileExist link - base' <- - liftIO $ - if exists - then -- group file exists, bump version and update link - do - (prefix, n) <- parseGroupFileName =<< getSymbolicLinkTarget link - removeFile link - pure $ prefix <> "." <> show (n + 1) - else -- group file does not exist yet, point link to version 0 - pure "group.0" - - let groupFile = bd cid2Str qcid base' - createFileLink groupFile link - pure groupFile - -rollBackClient :: HasCallStack => ClientIdentity -> MLSTest ByteString -rollBackClient cid = do - link <- groupFileLink cid - groupFile <- liftIO $ getSymbolicLinkTarget link - (prefix, n) <- - liftIO $ parseGroupFileName groupFile - when (n == 0) $ do - liftIO . assertFailure $ "Cannot roll back client " <> cid2Str cid - state <- liftIO $ BS.readFile groupFile - removeFile groupFile - removeFile link - bd <- State.gets mlsBaseDir - let newGroupFile = bd cid2Str cid (prefix <> "." <> show (n - 1)) - createFileLink newGroupFile link - pure state +setClientGroupState :: HasCallStack => ClientIdentity -> ByteString -> MLSTest () +setClientGroupState cid g = + State.modify $ \s -> + s {mlsClientGroupState = Map.insert cid g (mlsClientGroupState s)} + +getClientGroupState :: HasCallStack => ClientIdentity -> MLSTest ByteString +getClientGroupState cid = do + mgs <- State.gets (Map.lookup cid . mlsClientGroupState) + case mgs of + Nothing -> liftIO $ assertFailure ("Attempted to get non-existing group state for client " <> show cid) + Just g -> pure g -setGroupState :: HasCallStack => ClientIdentity -> ByteString -> MLSTest () -setGroupState cid state = do - fp <- nextGroupFile cid - liftIO $ BS.writeFile fp state +hasClientGroupState :: HasCallStack => ClientIdentity -> MLSTest Bool +hasClientGroupState cid = + State.gets (isJust . Map.lookup cid . mlsClientGroupState) -- | Create a conversation from a provided action and then create a -- corresponding group. @@ -416,10 +424,14 @@ setupMLSGroupWithConv convAction creator = do conv <- convAction let groupId = fromJust - (preview (to cnvProtocol . _ProtocolMLS . to cnvmlsGroupId) conv) - - createGroup creator groupId - pure (groupId, cnvQualifiedId conv) + ( asum + [ preview (to cnvProtocol . _ProtocolMLS . to cnvmlsGroupId) conv, + preview (to cnvProtocol . _ProtocolMixed . to cnvmlsGroupId) conv + ] + ) + let qcnv = cnvQualifiedId conv + createGroup creator (fmap Conv qcnv) groupId + pure (groupId, qcnv) -- | Create conversation and corresponding group. setupMLSGroup :: HasCallStack => ClientIdentity -> MLSTest (GroupId, Qualified ConvId) @@ -445,45 +457,81 @@ setupMLSSelfGroup creator = setupMLSGroupWithConv action creator (getSelfConv (ciUser creator)) GroupId -> MLSTest () -createGroup cid gid = do +createGroup :: ClientIdentity -> Qualified ConvOrSubConvId -> GroupId -> MLSTest () +createGroup cid qcs gid = do State.gets mlsGroupId >>= \case Just _ -> liftIO $ assertFailure "only one group can be created" Nothing -> pure () + resetGroup cid qcs gid - groupJSON <- mlscli cid ["group", "create", T.unpack (toBase64Text (unGroupId gid))] Nothing - g <- nextGroupFile cid - liftIO $ BS.writeFile g groupJSON +resetGroup :: ClientIdentity -> Qualified ConvOrSubConvId -> GroupId -> MLSTest () +resetGroup cid qcs gid = do State.modify $ \s -> s { mlsGroupId = Just gid, - mlsMembers = Set.singleton cid + mlsConvId = Just qcs, + mlsMembers = Set.singleton cid, + mlsEpoch = 0, + mlsNewMembers = mempty } + resetClientGroup cid gid + +resetClientGroup :: ClientIdentity -> GroupId -> MLSTest () +resetClientGroup cid gid = do + bd <- State.gets mlsBaseDir + groupJSON <- + mlscli + cid + [ "group", + "create", + "--removal-key", + bd "removal.key", + T.unpack (toBase64Text (unGroupId gid)) + ] + Nothing + setClientGroupState cid groupJSON + +getConvId :: MLSTest (Qualified ConvOrSubConvId) +getConvId = + State.gets mlsConvId + >>= maybe (liftIO (assertFailure "Uninitialised test conversation")) pure + +createSubConv :: + HasCallStack => + Qualified ConvId -> + ClientIdentity -> + SubConvId -> + MLSTest (Qualified ConvOrSubConvId) +createSubConv qcnv creator subId = do + sub <- + liftTest $ + responseJsonError + =<< getSubConv (ciUser creator) qcnv subId + >= sendAndConsumeCommitBundle + pure qcs -- | Create a local group only without a conversation. This simulates creating -- an MLS conversation on a remote backend. -setupFakeMLSGroup :: ClientIdentity -> MLSTest (GroupId, Qualified ConvId) -setupFakeMLSGroup creator = do - groupId <- - liftIO $ - fmap (GroupId . BS.pack) (replicateM 32 (generate arbitrary)) - createGroup creator groupId +setupFakeMLSGroup :: + HasCallStack => + ClientIdentity -> + Maybe SubConvId -> + MLSTest (GroupId, Qualified ConvId) +setupFakeMLSGroup creator mSubId = do qcnv <- randomQualifiedId (ciDomain creator) + let groupId = convToGroupId . groupIdParts RegularConv $ maybe (Conv <$> qcnv) ((<$> qcnv) . flip SubConv) mSubId + createGroup creator (fmap Conv qcnv) groupId pure (groupId, qcnv) -keyPackageFile :: HasCallStack => ClientIdentity -> KeyPackageRef -> MLSTest FilePath -keyPackageFile qcid ref = - State.gets $ \mls -> - mlsBaseDir mls - cid2Str qcid - T.unpack (T.decodeUtf8 (hex (unKeyPackageRef ref))) - claimLocalKeyPackages :: HasCallStack => ClientIdentity -> Local UserId -> MLSTest KeyPackageBundle claimLocalKeyPackages qcid lusr = do - brig <- viewBrig + brigCall <- viewBrig responseJsonError =<< post - ( brig + ( brigCall . paths ["mls", "key-packages", "claim", toByteString' (tDomain lusr), toByteString' (tUnqualified lusr)] . zUser (ciUser qcid) ) @@ -503,20 +551,17 @@ getUserClients qusr = do -- | Generate one key package for each client of a remote user claimRemoteKeyPackages :: HasCallStack => Remote UserId -> MLSTest KeyPackageBundle claimRemoteKeyPackages (tUntagged -> qusr) = do - brig <- viewBrig clients <- getUserClients qusr - bundle <- fmap (KeyPackageBundle . Set.fromList) $ + fmap (KeyPackageBundle . Set.fromList) $ for clients $ \cid -> do (kp, ref) <- generateKeyPackage cid pure $ KeyPackageBundleEntry - { kpbeUser = qusr, - kpbeClient = ciClient cid, - kpbeRef = ref, - kpbeKeyPackage = KeyPackageData (rmRaw kp) + { user = qusr, + client = ciClient cid, + ref = ref, + keyPackage = KeyPackageData (raw kp) } - mapRemoteKeyPackageRef brig bundle - pure bundle -- | Claim key package for a local user, or generate and map key packages for remote ones. claimKeyPackages :: @@ -528,16 +573,13 @@ claimKeyPackages cid qusr = do loc <- liftTest $ qualifyLocal () foldQualified loc (claimLocalKeyPackages cid) claimRemoteKeyPackages qusr -bundleKeyPackages :: KeyPackageBundle -> MLSTest [(ClientIdentity, FilePath)] -bundleKeyPackages bundle = do - let bundleEntries = kpbEntries bundle - entryIdentity be = mkClientIdentity (kpbeUser be) (kpbeClient be) - for (toList bundleEntries) $ \be -> do - let d = kpData . kpbeKeyPackage $ be - qcid = entryIdentity be - fn <- keyPackageFile qcid (kpbeRef be) - liftIO $ BS.writeFile fn d - pure (qcid, fn) +bundleKeyPackages :: KeyPackageBundle -> [(ClientIdentity, ByteString)] +bundleKeyPackages bundle = + let getEntry be = + ( mkClientIdentity be.user be.client, + kpData be.keyPackage + ) + in map getEntry (toList bundle.entries) -- | Claim keypackages and create a commit/welcome pair on a given client. -- Note that this alters the state of the group immediately. If we want to test @@ -545,41 +587,39 @@ bundleKeyPackages bundle = do -- group to the previous state by using an older version of the group file. createAddCommit :: HasCallStack => ClientIdentity -> [Qualified UserId] -> MLSTest MessagePackage createAddCommit cid users = do - kps <- concat <$> traverse (bundleKeyPackages <=< claimKeyPackages cid) users + kps <- fmap (concatMap bundleKeyPackages) . traverse (claimKeyPackages cid) $ users + liftIO $ assertBool "no key packages could be claimed" (not (null kps)) createAddCommitWithKeyPackages cid kps createExternalCommit :: HasCallStack => ClientIdentity -> Maybe ByteString -> - Qualified ConvId -> + Qualified ConvOrSubConvId -> MLSTest MessagePackage -createExternalCommit qcid mpgs qcnv = do +createExternalCommit qcid mpgs qcs = do bd <- State.gets mlsBaseDir - gNew <- nextGroupFile qcid pgsFile <- liftIO $ emptyTempFile bd "pgs" pgs <- case mpgs of - Nothing -> - LBS.toStrict . fromJust . responseBody - <$> getGroupInfo (ciUser qcid) qcnv + Nothing -> liftTest $ getGroupInfo (cidQualifiedUser qcid) qcs Just v -> pure v commit <- mlscli qcid [ "external-commit", - "--group-state-in", + "--group-info-in", "-", - "--group-state-out", + "--group-info-out", pgsFile, "--group-out", - gNew + "" ] (Just pgs) State.modify $ \mls -> mls - { mlsNewMembers = Set.singleton qcid -- This might be a different client - -- than those that have been in the + { mlsNewMembers = Set.singleton qcid + -- This might be a different client than those that have been in the -- group from before. } @@ -589,12 +629,12 @@ createExternalCommit qcid mpgs qcnv = do { mpSender = qcid, mpMessage = commit, mpWelcome = Nothing, - mpPublicGroupState = Just newPgs + mpGroupInfo = Just newPgs } createAddProposals :: HasCallStack => ClientIdentity -> [Qualified UserId] -> MLSTest [MessagePackage] createAddProposals cid users = do - kps <- concat <$> traverse (bundleKeyPackages <=< claimKeyPackages cid) users + kps <- fmap (concatMap bundleKeyPackages) . traverse (claimKeyPackages cid) $ users traverse (createAddProposalWithKeyPackage cid) kps -- | Create an application message. @@ -604,11 +644,10 @@ createApplicationMessage :: String -> MLSTest MessagePackage createApplicationMessage cid messageContent = do - groupFile <- currentGroupFile cid message <- mlscli cid - ["message", "--group", groupFile, messageContent] + ["message", "--group", "", messageContent] Nothing pure $ @@ -616,34 +655,34 @@ createApplicationMessage cid messageContent = do { mpSender = cid, mpMessage = message, mpWelcome = Nothing, - mpPublicGroupState = Nothing + mpGroupInfo = Nothing } createAddCommitWithKeyPackages :: + HasCallStack => ClientIdentity -> - [(ClientIdentity, FilePath)] -> + [(ClientIdentity, ByteString)] -> MLSTest MessagePackage createAddCommitWithKeyPackages qcid clientsAndKeyPackages = do bd <- State.gets mlsBaseDir - g <- currentGroupFile qcid - gNew <- nextGroupFile qcid welcomeFile <- liftIO $ emptyTempFile bd "welcome" - pgsFile <- liftIO $ emptyTempFile bd "pgs" - commit <- + giFile <- liftIO $ emptyTempFile bd "gi" + + commit <- runContT (traverse (withTempKeyPackageFile . snd) clientsAndKeyPackages) $ \kpFiles -> mlscli qcid ( [ "member", "add", "--group", - g, + "", "--welcome-out", welcomeFile, - "--group-state-out", - pgsFile, + "--group-info-out", + giFile, "--group-out", - gNew + "" ] - <> map snd clientsAndKeyPackages + <> kpFiles ) Nothing @@ -653,33 +692,31 @@ createAddCommitWithKeyPackages qcid clientsAndKeyPackages = do } welcome <- liftIO $ BS.readFile welcomeFile - pgs <- liftIO $ BS.readFile pgsFile + gi <- liftIO $ BS.readFile giFile pure $ MessagePackage { mpSender = qcid, mpMessage = commit, mpWelcome = Just welcome, - mpPublicGroupState = Just pgs + mpGroupInfo = Just gi } createAddProposalWithKeyPackage :: ClientIdentity -> - (ClientIdentity, FilePath) -> + (ClientIdentity, ByteString) -> MLSTest MessagePackage createAddProposalWithKeyPackage cid (_, kp) = do - g <- currentGroupFile cid - gNew <- nextGroupFile cid - prop <- + prop <- runContT (withTempKeyPackageFile kp) $ \kpFile -> mlscli cid - ["proposal", "--group-in", g, "--group-out", gNew, "add", kp] + ["proposal", "--group-in", "", "--group-out", "", "add", kpFile] Nothing pure MessagePackage { mpSender = cid, mpMessage = prop, mpWelcome = Nothing, - mpPublicGroupState = Nothing + mpGroupInfo = Nothing } createPendingProposalCommit :: HasCallStack => ClientIdentity -> MLSTest MessagePackage @@ -687,19 +724,17 @@ createPendingProposalCommit qcid = do bd <- State.gets mlsBaseDir welcomeFile <- liftIO $ emptyTempFile bd "welcome" pgsFile <- liftIO $ emptyTempFile bd "pgs" - g <- currentGroupFile qcid - gNew <- nextGroupFile qcid commit <- mlscli qcid [ "commit", "--group", - g, + "", "--group-out", - gNew, + "", "--welcome-out", welcomeFile, - "--group-state-out", + "--group-info-out", pgsFile ] Nothing @@ -711,7 +746,7 @@ createPendingProposalCommit qcid = do { mpSender = qcid, mpMessage = commit, mpWelcome = welcome, - mpPublicGroupState = Just pgs + mpGroupInfo = Just pgs } readWelcome :: FilePath -> IO (Maybe ByteString) @@ -726,28 +761,26 @@ createRemoveCommit cid targets = do bd <- State.gets mlsBaseDir welcomeFile <- liftIO $ emptyTempFile bd "welcome" pgsFile <- liftIO $ emptyTempFile bd "pgs" - g <- currentGroupFile cid - gNew <- nextGroupFile cid - kprefByClient <- liftIO $ Map.fromList <$> readGroupState g - let fetchKeyPackage c = keyPackageFile c (kprefByClient Map.! c) - kps <- traverse fetchKeyPackage targets + g <- getClientGroupState cid + let groupStateMap = Map.fromList (readGroupState g) + let indices = map (fromMaybe (error "could not find target") . flip Map.lookup groupStateMap) targets commit <- mlscli cid ( [ "member", "remove", "--group", - g, + "", "--group-out", - gNew, + "", "--welcome-out", welcomeFile, - "--group-state-out", + "--group-info-out", pgsFile ] - <> kps + <> map show indices ) Nothing welcome <- liftIO $ readWelcome welcomeFile @@ -757,7 +790,7 @@ createRemoveCommit cid targets = do { mpSender = cid, mpMessage = commit, mpWelcome = welcome, - mpPublicGroupState = Just pgs + mpGroupInfo = Just pgs } createExternalAddProposal :: HasCallStack => ClientIdentity -> MLSTest MessagePackage @@ -770,7 +803,7 @@ createExternalAddProposal joiner = do proposal <- mlscli joiner - [ "proposal-external", + [ "external-proposal", "--group-id", T.unpack (toBase64Text (unGroupId groupId)), "--epoch", @@ -788,25 +821,22 @@ createExternalAddProposal joiner = do { mpSender = joiner, mpMessage = proposal, mpWelcome = Nothing, - mpPublicGroupState = Nothing + mpGroupInfo = Nothing } consumeWelcome :: HasCallStack => ByteString -> MLSTest () consumeWelcome welcome = do qcids <- State.gets mlsNewMembers for_ qcids $ \qcid -> do - link <- groupFileLink qcid - liftIO $ - doesFileExist link >>= \e -> - assertBool "Existing clients in a conversation should not consume commits" (not e) - groupFile <- nextGroupFile qcid + hasState <- hasClientGroupState qcid + liftIO $ assertBool "Existing clients in a conversation should not consume welcomes" (not hasState) void $ mlscli qcid [ "group", "from-welcome", "--group-out", - groupFile, + "", "-" ] (Just welcome) @@ -819,89 +849,56 @@ consumeMessage msg = do consumeMessage1 cid (mpMessage msg) consumeMessage1 :: HasCallStack => ClientIdentity -> ByteString -> MLSTest () -consumeMessage1 cid msg = do - bd <- State.gets mlsBaseDir - g <- currentGroupFile cid - gNew <- nextGroupFile cid +consumeMessage1 cid msg = void $ mlscli cid [ "consume", "--group", - g, + "", "--group-out", - gNew, - "--signer-key", - bd "removal.key", + "", "-" ] (Just msg) -- | Send an MLS message and simulate clients receiving it. If the message is a --- commit, the 'sendAndConsumeCommit' function should be used instead. -sendAndConsumeMessage :: HasCallStack => MessagePackage -> MLSTest ([Event], Maybe UnreachableUsers) +-- commit, the 'sendAndConsumeCommitBundle' function should be used instead. +sendAndConsumeMessage :: HasCallStack => MessagePackage -> MLSTest [Event] sendAndConsumeMessage mp = do + for_ mp.mpWelcome $ \_ -> liftIO $ assertFailure "use sendAndConsumeCommitBundle" res <- - fmap (mmssEvents Tuple.&&& mmssFailedToSendTo) $ + fmap mmssEvents $ responseJsonError =<< postMessage (mpSender mp) (mpMessage mp) do - postWelcome (ciUser (mpSender mp)) welcome - !!! const 201 === statusCode - consumeWelcome welcome - pure res --- | Send an MLS commit message, simulate clients receiving it, and update the --- test state accordingly. -sendAndConsumeCommit :: - HasCallStack => - MessagePackage -> - MLSTest [Event] -sendAndConsumeCommit mp = do - (resp, Nothing) <- sendAndConsumeMessage mp - - -- increment epoch and add new clients - State.modify $ \mls -> - mls - { mlsEpoch = mlsEpoch mls + 1, - mlsMembers = mlsMembers mls <> mlsNewMembers mls, - mlsNewMembers = mempty - } - - pure resp - mkBundle :: MessagePackage -> Either Text CommitBundle mkBundle mp = do - commitB <- decodeMLS' (mpMessage mp) - welcomeB <- traverse decodeMLS' (mpWelcome mp) - pgs <- note "public group state unavailable" (mpPublicGroupState mp) - pgsB <- decodeMLS' pgs - pure $ - CommitBundle commitB welcomeB $ - GroupInfoBundle UnencryptedGroupInfo TreeFull pgsB - -createBundle :: MonadIO m => MessagePackage -> m ByteString + commitB <- first ("Commit: " <>) $ decodeMLS' (mpMessage mp) + welcomeB <- first ("Welcome: " <>) $ for (mpWelcome mp) $ \m -> do + w <- decodeMLS' @Message m + case w.content of + MessageWelcome welcomeB -> pure welcomeB + _ -> Left "expected welcome" + ginfo <- note "group info unavailable" (mpGroupInfo mp) + ginfoB <- first ("GroupInfo: " <>) $ decodeMLS' ginfo + pure $ CommitBundle commitB welcomeB ginfoB + +createBundle :: (HasCallStack, MonadIO m) => MessagePackage -> m ByteString createBundle mp = do bundle <- either (liftIO . assertFailure . T.unpack) pure $ mkBundle mp - pure (serializeCommitBundle bundle) + pure (encodeMLS' bundle) -sendAndConsumeCommitBundle :: - HasCallStack => - MessagePackage -> - MLSTest [Event] +sendAndConsumeCommitBundle :: HasCallStack => MessagePackage -> MLSTest [Event] sendAndConsumeCommitBundle mp = do + qcs <- getConvId bundle <- createBundle mp - events <- - fmap mmssEvents - . responseJsonError - =<< postCommitBundle (mpSender mp) bundle - @@ -924,25 +921,25 @@ mlsBracket clients k = do c <- view tsCannon WS.bracketAsClientRN c (map (ciUser &&& ciClient) clients) k -readGroupState :: FilePath -> IO [(ClientIdentity, KeyPackageRef)] -readGroupState fp = do - j <- BS.readFile fp - pure $ do - node <- j ^.. key "group" . key "tree" . key "tree" . key "nodes" . _Array . traverse - leafNode <- node ^.. key "node" . key "LeafNode" - identity <- - either (const []) pure . decodeMLS' . BS.pack . map fromIntegral $ - leafNode ^.. key "key_package" . key "payload" . key "credential" . key "credential" . key "Basic" . key "identity" . key "vec" . _Array . traverse . _Integer - kpr <- (unhexM . T.encodeUtf8 =<<) $ leafNode ^.. key "key_package_ref" . _String - pure (identity, KeyPackageRef kpr) +readGroupState :: ByteString -> [(ClientIdentity, LeafIndex)] +readGroupState j = do + (node, n) <- zip (j ^.. key "group" . key "public_group" . key "treesync" . key "tree" . key "leaf_nodes" . _Array . traverse) [0 ..] + case node ^? key "node" of + Just leafNode -> do + identityBytes <- leafNode ^.. key "payload" . key "credential" . key "credential" . key "Basic" . key "identity" . key "vec" + let identity = BS.pack (identityBytes ^.. _Array . traverse . _Integer . to fromIntegral) + cid <- case decodeMLS' identity of + Left _ -> [] + Right x -> pure x + pure (cid, n) + Nothing -> [] getClientsFromGroupState :: ClientIdentity -> Qualified UserId -> - MLSTest [(ClientIdentity, KeyPackageRef)] + MLSTest [(ClientIdentity, LeafIndex)] getClientsFromGroupState cid u = do - groupFile <- currentGroupFile cid - groupState <- liftIO $ readGroupState groupFile + groupState <- readGroupState <$> getClientGroupState cid pure $ filter (\(cid', _) -> cidQualifiedUser cid' == u) groupState clientKeyPair :: ClientIdentity -> MLSTest (ByteString, ByteString) @@ -951,11 +948,11 @@ clientKeyPair cid = do credential <- liftIO . BS.readFile $ bd cid2Str cid "store" T.unpack (T.decodeUtf8 (B64U.encode "self")) - let s = - credential ^.. key "signature_private_key" . key "value" . _Array . traverse . _Integer - & fmap fromIntegral - & BS.pack - pure $ BS.splitAt 32 s + case runGetOrFail + ((,) <$> parseMLSBytes @VarInt <*> parseMLSBytes @VarInt) + (LBS.fromStrict credential) of + Left (_, _, msg) -> liftIO $ assertFailure msg + Right (_, _, keys) -> pure keys receiveOnConvUpdated :: (MonadReader TestSetup m, MonadIO m) => @@ -987,28 +984,76 @@ receiveOnConvUpdated conv origUser joiner = do (qDomain conv) cu -getGroupInfo :: +getGroupInfo :: HasCallStack => Qualified UserId -> Qualified ConvOrSubConvId -> TestM ByteString +getGroupInfo qusr qcs = do + loc <- qualifyLocal () + foldQualified + loc + ( \lusr -> + fmap (LBS.toStrict . fromJust . responseBody) $ + localGetGroupInfo + (tUnqualified lusr) + qcs + remoteGetGroupInfo rusr qcs) + qusr + +localGetGroupInfo :: ( HasCallStack, MonadIO m, MonadHttp m, HasGalley m ) => UserId -> - Qualified ConvId -> + Qualified ConvOrSubConvId -> m ResponseLBS -getGroupInfo sender qcnv = do - galley <- viewGalley - get - ( galley - . paths - [ "conversations", - toByteString' (qDomain qcnv), - toByteString' (qUnqualified qcnv), - "groupinfo" - ] - . zUser sender - . zConn "conn" - ) +localGetGroupInfo sender qcs = do + galleyCall <- viewGalley + case qUnqualified qcs of + Conv cnv -> + get + ( galleyCall + . paths + [ "conversations", + toByteString' (qDomain qcs), + toByteString' cnv, + "groupinfo" + ] + . zUser sender + . zConn "conn" + ) + SubConv cnv sub -> + get + ( galleyCall + . paths + [ "conversations", + toByteString' (qDomain qcs), + toByteString' cnv, + "subconversations", + toByteString' sub, + "groupinfo" + ] + . zUser sender + . zConn "conn" + ) + +remoteGetGroupInfo :: + Remote UserId -> + Qualified ConvOrSubConvId -> + TestM ByteString +remoteGetGroupInfo rusr qcs = do + client <- view tsFedGalleyClient + GetGroupInfoResponseState (Base64ByteString pgs) <- + runFedClient + @"query-group-info" + client + (tDomain rusr) + GetGroupInfoRequest + { conv = qUnqualified qcs, + sender = tUnqualified rusr + } + pure pgs getSelfConv :: UserId -> @@ -1025,4 +1070,134 @@ getSelfConv u = do withMLSDisabled :: HasSettingsOverrides m => m a -> m a withMLSDisabled = withSettingsOverrides noMLS where - noMLS = Opts.optSettings . Opts.setMlsPrivateKeyPaths .~ Nothing + noMLS = Opts.settings . Opts.mlsPrivateKeyPaths .~ Nothing + +getSubConv :: + UserId -> + Qualified ConvId -> + SubConvId -> + TestM ResponseLBS +getSubConv u qcnv sconv = do + g <- viewGalley + get $ + g + . paths + [ "conversations", + toByteString' (qDomain qcnv), + toByteString' (qUnqualified qcnv), + "subconversations", + LBS.toStrict (toLazyByteString (toEncodedUrlPiece sconv)) + ] + . zUser u + +deleteSubConv :: + UserId -> + Qualified ConvId -> + SubConvId -> + DeleteSubConversationRequest -> + TestM ResponseLBS +deleteSubConv u qcnv sconv dsc = do + g <- viewGalley + delete $ + g + . paths + [ "conversations", + toByteString' (qDomain qcnv), + toByteString' (qUnqualified qcnv), + "subconversations", + LBS.toStrict (toLazyByteString (toEncodedUrlPiece sconv)) + ] + . zUser u + . contentJson + . json dsc + +leaveSubConv :: + UserId -> + ClientId -> + Qualified ConvId -> + SubConvId -> + TestM ResponseLBS +leaveSubConv u c qcnv subId = do + g <- viewGalley + delete $ + g + . paths + [ "conversations", + toByteString' (qDomain qcnv), + toByteString' (qUnqualified qcnv), + "subconversations", + toHeader subId, + "self" + ] + . zUser u + . zClient c + +remoteLeaveCurrentConv :: + Remote ClientIdentity -> + Qualified ConvId -> + SubConvId -> + TestM () +remoteLeaveCurrentConv rcid qcnv subId = do + client <- view tsFedGalleyClient + let lscr = + LeaveSubConversationRequest + { lscrUser = ciUser $ tUnqualified rcid, + lscrClient = ciClient $ tUnqualified rcid, + lscrConv = qUnqualified qcnv, + lscrSubConv = subId + } + runFedClient + @"leave-sub-conversation" + client + (tDomain rcid) + lscr + >>= liftIO . \case + LeaveSubConversationResponseError e -> + assertFailure $ + "error while leaving remote conversation: " <> show e + LeaveSubConversationResponseProtocolError e -> + assertFailure $ + "protocol error while leaving remote conversation: " <> T.unpack e + LeaveSubConversationResponseOk -> pure () + +leaveCurrentConv :: + HasCallStack => + ClientIdentity -> + Qualified ConvOrSubConvId -> + MLSTest () +leaveCurrentConv cid qsub = case qUnqualified qsub of + -- TODO: implement leaving main conversation as well + Conv _ -> liftIO $ assertFailure "Leaving conversations is not supported" + SubConv cnv subId -> do + liftTest $ do + loc <- qualifyLocal () + foldQualified + loc + ( \_ -> + leaveSubConv (ciUser cid) (ciClient cid) (qsub $> cnv) subId + !!! const 200 === statusCode + ) + ( \rcid -> remoteLeaveCurrentConv rcid (qsub $> cnv) subId + ) + (cidQualifiedUser cid $> cid) + State.modify $ \mls -> + mls + { mlsMembers = Set.difference (mlsMembers mls) (Set.singleton cid) + } + +getCurrentGroupId :: MLSTest GroupId +getCurrentGroupId = do + State.gets mlsGroupId >>= \case + Nothing -> liftIO $ assertFailure "Creating add proposal for non-existing group" + Just g -> pure g + +withTempKeyPackageFile :: ByteString -> ContT a MLSTest FilePath +withTempKeyPackageFile bs = do + bd <- State.gets mlsBaseDir + ContT $ \k -> + bracket + (liftIO (openBinaryTempFile bd "kp")) + (\(fp, _) -> liftIO (removeFile fp)) + $ \(fp, h) -> do + liftIO $ BS.hPut h bs `finally` hClose h + k fp diff --git a/services/galley/test/integration/API/MessageTimer.hs b/services/galley/test/integration/API/MessageTimer.hs index fcf5db5e909..2e85dec5a8f 100644 --- a/services/galley/test/integration/API/MessageTimer.hs +++ b/services/galley/test/integration/API/MessageTimer.hs @@ -23,34 +23,19 @@ where import API.Util import Bilge hiding (timeout) import Bilge.Assert -import Control.Exception import Control.Lens (view) -import Data.Aeson (eitherDecode) -import Data.Domain -import Data.Id import Data.List1 -import Data.List1 qualified as List1 import Data.Misc import Data.Qualified -import Data.Singletons -import Federator.MockServer import Imports hiding (head) -import Network.HTTP.Types qualified as Http import Network.Wai.Utilities.Error import Test.Tasty import Test.Tasty.Cannon (TimeoutUnit (..), (#)) import Test.Tasty.Cannon qualified as WS -import Test.Tasty.HUnit import TestHelpers import TestSetup import Wire.API.Conversation -import Wire.API.Conversation.Action import Wire.API.Conversation.Role -import Wire.API.Event.Conversation -import Wire.API.Federation.API.Common -import Wire.API.Federation.API.Galley qualified as F -import Wire.API.Federation.Component -import Wire.API.Internal.Notification (Notification (..)) tests :: IO TestSetup -> TestTree tests s = @@ -63,8 +48,6 @@ tests s = ], test s "timer can be changed" messageTimerChange, test s "timer can be changed with the qualified endpoint" messageTimerChangeQualified, - test s "timer changes are propagated to remote users" messageTimerChangeWithRemotes, - test s "timer changes unavailable remotes" messageTimerUnavailableRemotes, test s "timer can't be set by conv member without allowed action" messageTimerChangeWithoutAllowedAction, test s "timer can't be set in 1:1 conversations" messageTimerChangeO2O, test s "setting the timer generates an event" messageTimerEvent @@ -81,7 +64,7 @@ messageTimerInit mtimer = do rsp <- postConv alice [bob, jane] Nothing [] Nothing mtimer randomId <*> pure remoteDomain - qbob <- randomQualifiedUser - let bob = qUnqualified qbob - connectWithRemoteUser bob qalice - - resp <- - postConvWithRemoteUsers - bob - Nothing - defNewProteusConv {newConvQualifiedUsers = [qalice]} - let qconv = decodeQualifiedConvId resp - - WS.bracketR c bob $ \wsB -> do - (_, requests) <- - withTempMockFederator' (mockReply EmptyResponse) $ - putMessageTimerUpdateQualified bob qconv (ConversationMessageTimerUpdate timer1sec) - !!! const 200 === statusCode - - req <- assertOne requests - liftIO $ do - frTargetDomain req @?= remoteDomain - frComponent req @?= Galley - frRPC req @?= "on-conversation-updated" - Right cu <- pure . eitherDecode . frBody $ req - F.cuConvId cu @?= qUnqualified qconv - F.cuAction cu - @?= SomeConversationAction (sing @'ConversationMessageTimerUpdateTag) (ConversationMessageTimerUpdate timer1sec) - - void . liftIO . WS.assertMatch (5 # Second) wsB $ \n -> do - let e = List1.head (WS.unpackPayload n) - ntfTransient n @?= False - evtConv e @?= qconv - evtType e @?= ConvMessageTimerUpdate - evtFrom e @?= qbob - evtData e @?= EdConvMessageTimerUpdate (ConversationMessageTimerUpdate timer1sec) - -messageTimerUnavailableRemotes :: TestM () -messageTimerUnavailableRemotes = do - c <- view tsCannon - let remoteDomain = Domain "alice.example.com" - qalice <- Qualified <$> randomId <*> pure remoteDomain - qbob <- randomQualifiedUser - let bob = qUnqualified qbob - connectWithRemoteUser bob qalice - - resp <- - postConvWithRemoteUsers - bob - Nothing - defNewProteusConv {newConvQualifiedUsers = [qalice]} - let qconv = decodeQualifiedConvId resp - - WS.bracketR c bob $ \wsB -> do - (_, requests) <- - withTempMockFederator' (throw $ MockErrorResponse Http.status503 "Down for maintenance") $ - putMessageTimerUpdateQualified bob qconv (ConversationMessageTimerUpdate timer1sec) - !!! const 200 === statusCode - - req <- assertOne requests - liftIO $ do - frTargetDomain req @?= remoteDomain - frComponent req @?= Galley - frRPC req @?= "on-conversation-updated" - Right cu <- pure . eitherDecode . frBody $ req - F.cuConvId cu @?= qUnqualified qconv - F.cuAction cu - @?= SomeConversationAction (sing @'ConversationMessageTimerUpdateTag) (ConversationMessageTimerUpdate timer1sec) - - void . liftIO . WS.assertMatch (5 # Second) wsB $ \n -> do - let e = List1.head (WS.unpackPayload n) - ntfTransient n @?= False - evtConv e @?= qconv - evtType e @?= ConvMessageTimerUpdate - evtFrom e @?= qbob - evtData e @?= EdConvMessageTimerUpdate (ConversationMessageTimerUpdate timer1sec) - messageTimerChangeWithoutAllowedAction :: TestM () messageTimerChangeWithoutAllowedAction = do -- Create a team and a guest user @@ -256,7 +159,7 @@ messageTimerChangeO2O = do rsp <- postO2OConv alice bob Nothing do diff --git a/services/galley/test/integration/API/Roles.hs b/services/galley/test/integration/API/Roles.hs index 3e807e8eff3..1fdf61a1e4b 100644 --- a/services/galley/test/integration/API/Roles.hs +++ b/services/galley/test/integration/API/Roles.hs @@ -20,20 +20,14 @@ module API.Roles where import API.Util import Bilge hiding (timeout) import Bilge.Assert -import Control.Exception import Control.Lens (view) import Data.Aeson hiding (json) import Data.ByteString.Conversion (toByteString') -import Data.Domain import Data.Id import Data.List1 -import Data.List1 qualified as List1 import Data.Qualified import Data.Set qualified as Set -import Data.Singletons -import Federator.MockServer import Imports -import Network.HTTP.Types qualified as Http import Network.Wai.Utilities.Error import Test.Tasty import Test.Tasty.Cannon (TimeoutUnit (..), (#)) @@ -42,13 +36,7 @@ import Test.Tasty.HUnit import TestHelpers import TestSetup import Wire.API.Conversation -import Wire.API.Conversation.Action import Wire.API.Conversation.Role -import Wire.API.Event.Conversation -import Wire.API.Federation.API.Common -import Wire.API.Federation.API.Galley qualified as F -import Wire.API.Federation.Component -import Wire.API.Internal.Notification (Notification (..)) tests :: IO TestSetup -> TestTree tests s = @@ -56,10 +44,6 @@ tests s = "Conversation roles" [ test s "conversation roles admin (and downgrade)" handleConversationRoleAdmin, test s "conversation roles member (and upgrade)" handleConversationRoleMember, - test s "conversation role update with remote users present" roleUpdateWithRemotes, - test s "conversation role update with remote users present remotes unavailable" roleUpdateWithRemotesUnavailable, - test s "conversation access update with remote users present" accessUpdateWithRemotes, - test s "conversation role update of remote member" roleUpdateRemoteMember, test s "get all conversation roles" testAllConversationRoles, test s "access role update with v2" testAccessRoleUpdateV2, test s "test access roles of new conversations" testConversationAccessRole @@ -98,7 +82,7 @@ handleConversationRoleAdmin = do let role = roleNameWireAdmin cid <- WS.bracketR3 c alice bob chuck $ \(wsA, wsB, wsC) -> do rsp <- postConvWithRole alice [bob, chuck] (Just "gossip") [] Nothing Nothing role - void $ assertConvWithRole rsp RegularConv alice qalice [qbob, qchuck] (Just "gossip") Nothing role + void $ assertConvWithRole rsp RegularConv (Just alice) qalice [qbob, qchuck] (Just "gossip") Nothing role let cid = decodeConvId rsp qcid = Qualified cid localDomain -- Make sure everyone gets the correct event @@ -139,7 +123,7 @@ handleConversationRoleMember = do let role = roleNameWireMember cid <- WS.bracketR3 c alice bob chuck $ \(wsA, wsB, wsC) -> do rsp <- postConvWithRole alice [bob, chuck] (Just "gossip") [] Nothing Nothing role - void $ assertConvWithRole rsp RegularConv alice qalice [qbob, qchuck] (Just "gossip") Nothing role + void $ assertConvWithRole rsp RegularConv (Just alice) qalice [qbob, qchuck] (Just "gossip") Nothing role let cid = decodeConvId rsp qcid = Qualified cid localDomain -- Make sure everyone gets the correct event @@ -161,236 +145,6 @@ handleConversationRoleMember = do wsAssertMemberUpdateWithRole qcid qalice bob roleNameWireAdmin wireAdminChecks cid bob alice chuck -roleUpdateRemoteMember :: TestM () -roleUpdateRemoteMember = do - c <- view tsCannon - let remoteDomain = Domain "alice.example.com" - qalice <- Qualified <$> randomId <*> pure remoteDomain - qbob <- randomQualifiedUser - qcharlie <- Qualified <$> randomId <*> pure remoteDomain - let bob = qUnqualified qbob - - traverse_ (connectWithRemoteUser bob) [qalice, qcharlie] - resp <- - postConvWithRemoteUsers - bob - Nothing - defNewProteusConv {newConvQualifiedUsers = [qalice, qcharlie]} - let qconv = decodeQualifiedConvId resp - - WS.bracketR c bob $ \wsB -> do - (_, requests) <- - withTempMockFederator' (mockReply EmptyResponse) $ - putOtherMemberQualified - bob - qcharlie - (OtherMemberUpdate (Just roleNameWireMember)) - qconv - !!! const 200 === statusCode - - req <- assertOne requests - let mu = - MemberUpdateData - { misTarget = qcharlie, - misOtrMutedStatus = Nothing, - misOtrMutedRef = Nothing, - misOtrArchived = Nothing, - misOtrArchivedRef = Nothing, - misHidden = Nothing, - misHiddenRef = Nothing, - misConvRoleName = Just roleNameWireMember - } - liftIO $ do - frTargetDomain req @?= remoteDomain - frComponent req @?= Galley - frRPC req @?= "on-conversation-updated" - Right cu <- pure . eitherDecode . frBody $ req - F.cuConvId cu @?= qUnqualified qconv - F.cuAction cu - @?= SomeConversationAction (sing @'ConversationMemberUpdateTag) (ConversationMemberUpdate qcharlie (OtherMemberUpdate (Just roleNameWireMember))) - sort (F.cuAlreadyPresentUsers cu) @?= sort [qUnqualified qalice, qUnqualified qcharlie] - - liftIO . WS.assertMatch_ (5 # Second) wsB $ \n -> do - let e = List1.head (WS.unpackPayload n) - ntfTransient n @?= False - evtConv e @?= qconv - evtType e @?= MemberStateUpdate - evtFrom e @?= qbob - evtData e @?= EdMemberUpdate mu - - conv <- responseJsonError =<< getConvQualified bob qconv omQualifiedId m == qcharlie) (cmOthers (cnvMembers conv)) - liftIO $ - charlieAsMember - @=? Just - OtherMember - { omQualifiedId = qcharlie, - omService = Nothing, - omConvRoleName = roleNameWireMember - } - -roleUpdateWithRemotes :: TestM () -roleUpdateWithRemotes = do - c <- view tsCannon - let remoteDomain = Domain "alice.example.com" - qalice <- Qualified <$> randomId <*> pure remoteDomain - qbob <- randomQualifiedUser - qcharlie <- randomQualifiedUser - let bob = qUnqualified qbob - charlie = qUnqualified qcharlie - - connectUsers bob (singleton charlie) - connectWithRemoteUser bob qalice - resp <- - postConvWithRemoteUsers - bob - Nothing - defNewProteusConv {newConvQualifiedUsers = [qalice, qcharlie]} - let qconv = decodeQualifiedConvId resp - - WS.bracketR2 c bob charlie $ \(wsB, wsC) -> do - (_, requests) <- - withTempMockFederator' (mockReply EmptyResponse) $ - putOtherMemberQualified - bob - qcharlie - (OtherMemberUpdate (Just roleNameWireAdmin)) - qconv - !!! const 200 === statusCode - - req <- assertOne requests - let mu = - MemberUpdateData - { misTarget = qcharlie, - misOtrMutedStatus = Nothing, - misOtrMutedRef = Nothing, - misOtrArchived = Nothing, - misOtrArchivedRef = Nothing, - misHidden = Nothing, - misHiddenRef = Nothing, - misConvRoleName = Just roleNameWireAdmin - } - liftIO $ do - frTargetDomain req @?= remoteDomain - frComponent req @?= Galley - frRPC req @?= "on-conversation-updated" - Right cu <- pure . eitherDecode . frBody $ req - F.cuConvId cu @?= qUnqualified qconv - F.cuAction cu - @?= SomeConversationAction (sing @'ConversationMemberUpdateTag) (ConversationMemberUpdate qcharlie (OtherMemberUpdate (Just roleNameWireAdmin))) - F.cuAlreadyPresentUsers cu @?= [qUnqualified qalice] - - liftIO . WS.assertMatchN_ (5 # Second) [wsB, wsC] $ \n -> do - let e = List1.head (WS.unpackPayload n) - ntfTransient n @?= False - evtConv e @?= qconv - evtType e @?= MemberStateUpdate - evtFrom e @?= qbob - evtData e @?= EdMemberUpdate mu - -roleUpdateWithRemotesUnavailable :: TestM () -roleUpdateWithRemotesUnavailable = do - c <- view tsCannon - let remoteDomain = Domain "alice.example.com" - qalice <- Qualified <$> randomId <*> pure remoteDomain - qbob <- randomQualifiedUser - qcharlie <- randomQualifiedUser - let bob = qUnqualified qbob - charlie = qUnqualified qcharlie - - connectUsers bob (singleton charlie) - connectWithRemoteUser bob qalice - resp <- - postConvWithRemoteUsers - bob - Nothing - defNewProteusConv {newConvQualifiedUsers = [qalice, qcharlie]} - let qconv = decodeQualifiedConvId resp - - WS.bracketR2 c bob charlie $ \(wsB, wsC) -> do - (_, requests) <- - withTempMockFederator' (throw $ MockErrorResponse Http.status503 "Down for maintenance") $ - putOtherMemberQualified - bob - qcharlie - (OtherMemberUpdate (Just roleNameWireAdmin)) - qconv - !!! const 200 === statusCode - - req <- assertOne requests - let mu = - MemberUpdateData - { misTarget = qcharlie, - misOtrMutedStatus = Nothing, - misOtrMutedRef = Nothing, - misOtrArchived = Nothing, - misOtrArchivedRef = Nothing, - misHidden = Nothing, - misHiddenRef = Nothing, - misConvRoleName = Just roleNameWireAdmin - } - liftIO $ do - frTargetDomain req @?= remoteDomain - frComponent req @?= Galley - frRPC req @?= "on-conversation-updated" - Right cu <- pure . eitherDecode . frBody $ req - F.cuConvId cu @?= qUnqualified qconv - F.cuAction cu - @?= SomeConversationAction (sing @'ConversationMemberUpdateTag) (ConversationMemberUpdate qcharlie (OtherMemberUpdate (Just roleNameWireAdmin))) - F.cuAlreadyPresentUsers cu @?= [qUnqualified qalice] - - liftIO . WS.assertMatchN_ (5 # Second) [wsB, wsC] $ \n -> do - let e = List1.head (WS.unpackPayload n) - ntfTransient n @?= False - evtConv e @?= qconv - evtType e @?= MemberStateUpdate - evtFrom e @?= qbob - evtData e @?= EdMemberUpdate mu - -accessUpdateWithRemotes :: TestM () -accessUpdateWithRemotes = do - c <- view tsCannon - let remoteDomain = Domain "alice.example.com" - qalice <- Qualified <$> randomId <*> pure remoteDomain - qbob <- randomQualifiedUser - qcharlie <- randomQualifiedUser - let bob = qUnqualified qbob - charlie = qUnqualified qcharlie - - connectUsers bob (singleton charlie) - connectWithRemoteUser bob qalice - resp <- - postConvWithRemoteUsers - bob - Nothing - defNewProteusConv {newConvQualifiedUsers = [qalice, qcharlie]} - let qconv = decodeQualifiedConvId resp - - let access = ConversationAccessData (Set.singleton CodeAccess) (Set.fromList [TeamMemberAccessRole, NonTeamMemberAccessRole, GuestAccessRole, ServiceAccessRole]) - WS.bracketR2 c bob charlie $ \(wsB, wsC) -> do - (_, requests) <- - withTempMockFederator' (mockReply EmptyResponse) $ - putQualifiedAccessUpdate bob qconv access - !!! const 200 === statusCode - - req <- assertOne requests - liftIO $ do - frTargetDomain req @?= remoteDomain - frComponent req @?= Galley - frRPC req @?= "on-conversation-updated" - Right cu <- pure . eitherDecode . frBody $ req - F.cuConvId cu @?= qUnqualified qconv - F.cuAction cu @?= SomeConversationAction (sing @'ConversationAccessDataTag) access - F.cuAlreadyPresentUsers cu @?= [qUnqualified qalice] - - liftIO . WS.assertMatchN_ (5 # Second) [wsB, wsC] $ \n -> do - let e = List1.head (WS.unpackPayload n) - ntfTransient n @?= False - evtConv e @?= qconv - evtType e @?= ConvAccessUpdate - evtFrom e @?= qbob - evtData e @?= EdConvAccessUpdate access - -- | Given an admin, another admin and a member run all -- the necessary checks targeting the admin wireAdminChecks :: diff --git a/services/galley/test/integration/API/Teams.hs b/services/galley/test/integration/API/Teams.hs index a5a6f11816a..8b217f515f0 100644 --- a/services/galley/test/integration/API/Teams.hs +++ b/services/galley/test/integration/API/Teams.hs @@ -59,7 +59,7 @@ import Data.UUID.V1 qualified as UUID import Data.Vector qualified as V import GHC.TypeLits (KnownSymbol) import Galley.Env qualified as Galley -import Galley.Options (optSettings, setFeatureFlags, setMaxConvSize, setMaxFanoutSize) +import Galley.Options (featureFlags, maxConvSize, maxFanoutSize, settings) import Galley.Types.Conversations.Roles import Galley.Types.Teams import Imports @@ -415,7 +415,7 @@ testEnableSSOPerTeam = do liftIO $ do assertEqual "bad status" status403 status assertEqual "bad label" "not-implemented" label - featureSSO <- view (tsGConf . optSettings . setFeatureFlags . flagSSO) + featureSSO <- view (tsGConf . settings . featureFlags . flagSSO) case featureSSO of FeatureSSOEnabledByDefault -> check "Teams should start with SSO enabled" Public.FeatureStatusEnabled FeatureSSODisabledByDefault -> check "Teams should start with SSO disabled" Public.FeatureStatusDisabled @@ -1732,8 +1732,8 @@ postCryptoBroadcastMessageFilteredTooLargeTeam bcast = do WS.bracketR (c . queryItem "client" (toByteString' ac)) alice $ \wsA1 -> do -- We change also max conv size due to the invariants that galley forces us to keep let newOpts = - ((optSettings . setMaxFanoutSize) ?~ unsafeRange 4) - . (optSettings . setMaxConvSize .~ 4) + ((settings . maxFanoutSize) ?~ unsafeRange 4) + . (settings . maxConvSize .~ 4) withSettingsOverrides newOpts $ do -- Untargeted, Alice's team is too large Util.postBroadcast (q alice) ac bcast {bMessage = msg} !!! do diff --git a/services/galley/test/integration/API/Teams/Feature.hs b/services/galley/test/integration/API/Teams/Feature.hs index 73408aca64c..85e27e845f4 100644 --- a/services/galley/test/integration/API/Teams/Feature.hs +++ b/services/galley/test/integration/API/Teams/Feature.hs @@ -36,12 +36,13 @@ import Data.Aeson.KeyMap qualified as KeyMap import Data.ByteString.Char8 (unpack) import Data.Domain (Domain (..)) import Data.Id +import Data.Json.Util (fromUTCTimeMillis, readUTCTimeMillis) import Data.List1 qualified as List1 import Data.Schema (ToSchema) import Data.Set qualified as Set import Data.Timeout (TimeoutUnit (Second), (#)) import GHC.TypeLits (KnownSymbol) -import Galley.Options (optSettings, setExposeInvitationURLsTeamAllowlist, setFeatureFlags) +import Galley.Options (exposeInvitationURLsTeamAllowlist, featureFlags, settings) import Galley.Types.Teams import Imports import Network.Wai.Utilities (label) @@ -52,7 +53,7 @@ import Test.Tasty.Cannon qualified as WS import Test.Tasty.HUnit (assertBool, assertFailure, (@?=)) import TestHelpers (eventually, test) import TestSetup -import Wire.API.Conversation.Protocol (ProtocolTag (ProtocolMLSTag, ProtocolProteusTag)) +import Wire.API.Conversation.Protocol import Wire.API.Event.FeatureConfig qualified as FeatureConfig import Wire.API.Internal.Notification (Notification) import Wire.API.MLS.CipherSuite @@ -107,6 +108,8 @@ tests s = (wsConfig (defFeatureStatus @MlsE2EIdConfig)) FeatureTTLUnlimited ), + test s "MlsMigration feature config" $ + testNonTrivialConfigNoTTL defaultMlsMigrationConfig, testGroup "Patch" [ -- Note: `SSOConfig` and `LegalHoldConfig` may not be able to be reset @@ -134,6 +137,7 @@ tests s = ProtocolProteusTag [MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519] MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 + [ProtocolProteusTag, ProtocolMLSTag] ) validMLSConfigGen, test s (unpack $ featureNameBS @FileSharingConfig) $ @@ -160,12 +164,17 @@ tests s = validMLSConfigGen :: Gen (WithStatusPatch MLSConfig) validMLSConfigGen = arbitrary - `suchThat` ( \cfg -> case wspConfig cfg of - Just (MLSConfig us _ cTags ctag) -> - sortedAndNoDuplicates us - && sortedAndNoDuplicates cTags - && elem ctag cTags - _ -> True + `suchThat` ( \cfg -> + case wspConfig cfg of + Just (MLSConfig us defProtocol cTags ctag supProtocol) -> + sortedAndNoDuplicates us + && sortedAndNoDuplicates cTags + && elem ctag cTags + && notElem ProtocolMixedTag supProtocol + && elem defProtocol supProtocol + && sortedAndNoDuplicates supProtocol + _ -> True + && Just FeatureStatusEnabled == wspStatus cfg ) where sortedAndNoDuplicates xs = (sort . nub) xs == xs @@ -284,7 +293,7 @@ testSSO setSSOFeature = do assertFlagForbidden $ getTeamFeatureFlag @SSOConfig nonMember tid - featureSSO <- view (tsGConf . optSettings . setFeatureFlags . flagSSO) + featureSSO <- view (tsGConf . settings . featureFlags . flagSSO) case featureSSO of FeatureSSODisabledByDefault -> do -- Test default @@ -331,7 +340,7 @@ testLegalHold setLegalHoldInternal = do assertFlagForbidden $ getTeamFeatureFlag @LegalholdConfig nonMember tid -- FUTUREWORK: run two galleys, like below for custom search visibility. - featureLegalHold <- view (tsGConf . optSettings . setFeatureFlags . flagLegalHold) + featureLegalHold <- view (tsGConf . settings . featureFlags . flagLegalHold) case featureLegalHold of FeatureLegalHoldDisabledByDefault -> do -- Test default @@ -483,7 +492,7 @@ testClassifiedDomainsDisabled = do let classifiedDomainsDisabled opts = opts & over - (optSettings . setFeatureFlags . flagClassifiedDomains) + (settings . featureFlags . flagClassifiedDomains) (\(ImplicitLockStatus s) -> ImplicitLockStatus (s & setStatus FeatureStatusDisabled & setConfig (ClassifiedDomainsConfig []))) withSettingsOverrides classifiedDomainsDisabled $ do getClassifiedDomains member tid expected @@ -841,8 +850,8 @@ testSelfDeletingMessages = do defLockStatus :: LockStatus <- view ( tsGConf - . optSettings - . setFeatureFlags + . settings + . featureFlags . flagSelfDeletingMessages . unDefaults . to wsLockStatus @@ -996,8 +1005,8 @@ testAllFeatures = do defLockStatus :: LockStatus <- view ( tsGConf - . optSettings - . setFeatureFlags + . settings + . featureFlags . flagSelfDeletingMessages . unDefaults . to wsLockStatus @@ -1043,11 +1052,12 @@ testAllFeatures = do afcSelfDeletingMessages = withStatus FeatureStatusEnabled lockStateSelfDeleting (SelfDeletingMessagesConfig 0) FeatureTTLUnlimited, afcGuestLink = withStatus FeatureStatusEnabled LockStatusUnlocked GuestLinksConfig FeatureTTLUnlimited, afcSndFactorPasswordChallenge = withStatus FeatureStatusDisabled LockStatusLocked SndFactorPasswordChallengeConfig FeatureTTLUnlimited, - afcMLS = withStatus FeatureStatusDisabled LockStatusUnlocked (MLSConfig [] ProtocolProteusTag [MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519] MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519) FeatureTTLUnlimited, + afcMLS = withStatus FeatureStatusDisabled LockStatusUnlocked (MLSConfig [] ProtocolProteusTag [MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519] MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 [ProtocolProteusTag, ProtocolMLSTag]) FeatureTTLUnlimited, afcSearchVisibilityInboundConfig = withStatus FeatureStatusDisabled LockStatusUnlocked SearchVisibilityInboundConfig FeatureTTLUnlimited, afcExposeInvitationURLsToTeamAdmin = withStatus FeatureStatusDisabled LockStatusLocked ExposeInvitationURLsToTeamAdminConfig FeatureTTLUnlimited, afcOutlookCalIntegration = withStatus FeatureStatusDisabled LockStatusLocked OutlookCalIntegrationConfig FeatureTTLUnlimited, - afcMlsE2EId = withStatus FeatureStatusDisabled LockStatusUnlocked (wsConfig defFeatureStatus) FeatureTTLUnlimited + afcMlsE2EId = withStatus FeatureStatusDisabled LockStatusUnlocked (wsConfig defFeatureStatus) FeatureTTLUnlimited, + afcMlsMigration = defaultMlsMigrationConfig } testFeatureConfigConsistency :: TestM () @@ -1181,9 +1191,27 @@ testNonTrivialConfigNoTTL defaultCfg = do -- unlock feature setLockStatus LockStatusUnlocked + let defaultMLSConfig = + WithStatusNoLock + { wssStatus = FeatureStatusEnabled, + wssConfig = + MLSConfig + { mlsProtocolToggleUsers = [], + mlsDefaultProtocol = ProtocolMLSTag, + mlsAllowedCipherSuites = [MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519], + mlsDefaultCipherSuite = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519, + mlsSupportedProtocols = [ProtocolProteusTag, ProtocolMLSTag] + }, + wssTTL = FeatureTTLUnlimited + } + config2 <- liftIO $ generate arbitrary <&> (forgetLock . setTTL FeatureTTLUnlimited) config3 <- liftIO $ generate arbitrary <&> (forgetLock . setTTL FeatureTTLUnlimited) + putTeamFeatureFlagWithGalley @MLSConfig galley owner tid defaultMLSConfig + !!! statusCode + === const 200 + WS.bracketR cannon member $ \ws -> do setForTeam config2 void . liftIO $ @@ -1234,31 +1262,42 @@ testMLS = do getForTeamInternal expected getForUser expected - setForTeam :: HasCallStack => WithStatusNoLock MLSConfig -> TestM () - setForTeam wsnl = + setForTeamWithStatusCode :: HasCallStack => Int -> WithStatusNoLock MLSConfig -> TestM () + setForTeamWithStatusCode resStatusCode wsnl = putTeamFeatureFlagWithGalley @MLSConfig galley owner tid wsnl !!! statusCode - === const 200 + === const resStatusCode + + setForTeam :: HasCallStack => WithStatusNoLock MLSConfig -> TestM () + setForTeam = setForTeamWithStatusCode 200 + + setForTeamInternalWithStatusCode :: HasCallStack => (Request -> Request) -> WithStatusNoLock MLSConfig -> TestM () + setForTeamInternalWithStatusCode expect wsnl = + void $ putTeamFeatureFlagInternal @MLSConfig expect tid wsnl setForTeamInternal :: HasCallStack => WithStatusNoLock MLSConfig -> TestM () - setForTeamInternal wsnl = - void $ putTeamFeatureFlagInternal @MLSConfig expect2xx tid wsnl + setForTeamInternal = setForTeamInternalWithStatusCode expect2xx let cipherSuite = MLS_128_DHKEMX25519_AES128GCM_SHA256_Ed25519 - let defaultConfig = + defaultConfig = WithStatusNoLock FeatureStatusDisabled - (MLSConfig [] ProtocolProteusTag [cipherSuite] cipherSuite) + (MLSConfig [] ProtocolProteusTag [cipherSuite] cipherSuite [ProtocolProteusTag, ProtocolMLSTag]) FeatureTTLUnlimited - let config2 = + config2 = WithStatusNoLock FeatureStatusEnabled - (MLSConfig [member] ProtocolMLSTag [] cipherSuite) + (MLSConfig [member] ProtocolMLSTag [] cipherSuite [ProtocolProteusTag, ProtocolMLSTag]) FeatureTTLUnlimited - let config3 = + config3 = WithStatusNoLock - FeatureStatusDisabled - (MLSConfig [] ProtocolMLSTag [cipherSuite] cipherSuite) + FeatureStatusEnabled + (MLSConfig [] ProtocolMLSTag [cipherSuite] cipherSuite [ProtocolMLSTag]) + FeatureTTLUnlimited + invalidConfig = + WithStatusNoLock + FeatureStatusEnabled + (MLSConfig [] ProtocolMLSTag [cipherSuite] cipherSuite [ProtocolProteusTag]) FeatureTTLUnlimited getViaEndpoints defaultConfig @@ -1270,6 +1309,12 @@ testMLS = do wsAssertFeatureConfigUpdate @MLSConfig config2 LockStatusUnlocked getViaEndpoints config2 + WS.bracketR cannon member $ \ws -> do + setForTeamWithStatusCode 400 invalidConfig + void . liftIO $ + WS.assertNoEvent (2 # Second) [ws] + getViaEndpoints config2 + WS.bracketR cannon member $ \ws -> do setForTeamInternal config3 void . liftIO $ @@ -1277,13 +1322,19 @@ testMLS = do wsAssertFeatureConfigUpdate @MLSConfig config3 LockStatusUnlocked getViaEndpoints config3 + WS.bracketR cannon member $ \ws -> do + setForTeamInternalWithStatusCode expect4xx invalidConfig + void . liftIO $ + WS.assertNoEvent (2 # Second) [ws] + getViaEndpoints config3 + testExposeInvitationURLsToTeamAdminTeamIdInAllowList :: TestM () testExposeInvitationURLsToTeamAdminTeamIdInAllowList = do owner <- randomUser tid <- createBindingTeamInternal "foo" owner assertTeamActivate "create team" tid void $ - withSettingsOverrides (\opts -> opts & optSettings . setExposeInvitationURLsTeamAllowlist ?~ [tid]) $ do + withSettingsOverrides (\opts -> opts & settings . exposeInvitationURLsTeamAllowlist ?~ [tid]) $ do g <- viewGalley assertExposeInvitationURLsToTeamAdminConfigStatus owner tid FeatureStatusDisabled LockStatusUnlocked let enabled = WithStatusNoLock FeatureStatusEnabled ExposeInvitationURLsToTeamAdminConfig FeatureTTLUnlimited @@ -1298,7 +1349,7 @@ testExposeInvitationURLsToTeamAdminEmptyAllowList = do tid <- createBindingTeamInternal "foo" owner assertTeamActivate "create team" tid void $ - withSettingsOverrides (\opts -> opts & optSettings . setExposeInvitationURLsTeamAllowlist .~ Nothing) $ do + withSettingsOverrides (\opts -> opts & settings . exposeInvitationURLsTeamAllowlist .~ Nothing) $ do g <- viewGalley assertExposeInvitationURLsToTeamAdminConfigStatus owner tid FeatureStatusDisabled LockStatusLocked let enabled = WithStatusNoLock FeatureStatusEnabled ExposeInvitationURLsToTeamAdminConfig FeatureTTLUnlimited @@ -1310,7 +1361,7 @@ testExposeInvitationURLsToTeamAdminEmptyAllowList = do -- | Ensure that the server config takes precedence over a saved team config. -- -- In other words: When a team id is no longer in the --- `setExposeInvitationURLsTeamAllowlist` the +-- `exposeInvitationURLsTeamAllowlist` the -- `ExposeInvitationURLsToTeamAdminConfig` is always disabled (even tough it -- might have been enabled before). testExposeInvitationURLsToTeamAdminServerConfigTakesPrecedence :: TestM () @@ -1319,7 +1370,7 @@ testExposeInvitationURLsToTeamAdminServerConfigTakesPrecedence = do tid <- createBindingTeamInternal "foo" owner assertTeamActivate "create team" tid void $ - withSettingsOverrides (\opts -> opts & optSettings . setExposeInvitationURLsTeamAllowlist ?~ [tid]) $ do + withSettingsOverrides (\opts -> opts & settings . exposeInvitationURLsTeamAllowlist ?~ [tid]) $ do g <- viewGalley assertExposeInvitationURLsToTeamAdminConfigStatus owner tid FeatureStatusDisabled LockStatusUnlocked let enabled = WithStatusNoLock FeatureStatusEnabled ExposeInvitationURLsToTeamAdminConfig FeatureTTLUnlimited @@ -1328,7 +1379,7 @@ testExposeInvitationURLsToTeamAdminServerConfigTakesPrecedence = do const 200 === statusCode assertExposeInvitationURLsToTeamAdminConfigStatus owner tid FeatureStatusEnabled LockStatusUnlocked void $ - withSettingsOverrides (\opts -> opts & optSettings . setExposeInvitationURLsTeamAllowlist .~ Nothing) $ do + withSettingsOverrides (\opts -> opts & settings . exposeInvitationURLsTeamAllowlist .~ Nothing) $ do g <- viewGalley assertExposeInvitationURLsToTeamAdminConfigStatus owner tid FeatureStatusDisabled LockStatusLocked let enabled = WithStatusNoLock FeatureStatusEnabled ExposeInvitationURLsToTeamAdminConfig FeatureTTLUnlimited @@ -1458,3 +1509,14 @@ wsAssertFeatureConfigUpdate config lockStatus notification = do FeatureConfig._eventType e @?= FeatureConfig.Update FeatureConfig._eventFeatureName e @?= featureName @cfg FeatureConfig._eventData e @?= Aeson.toJSON (withLockStatus lockStatus config) + +defaultMlsMigrationConfig :: WithStatus MlsMigrationConfig +defaultMlsMigrationConfig = + withStatus + FeatureStatusEnabled + LockStatusLocked + MlsMigrationConfig + { startTime = fmap fromUTCTimeMillis (readUTCTimeMillis "2029-05-16T10:11:12.123Z"), + finaliseRegardlessAfter = fmap fromUTCTimeMillis (readUTCTimeMillis "2029-10-17T00:00:00.000Z") + } + FeatureTTLUnlimited diff --git a/services/galley/test/integration/API/Teams/LegalHold.hs b/services/galley/test/integration/API/Teams/LegalHold.hs index 9f37a4a35bc..d9efba2945c 100644 --- a/services/galley/test/integration/API/Teams/LegalHold.hs +++ b/services/galley/test/integration/API/Teams/LegalHold.hs @@ -50,7 +50,7 @@ import Galley.Cassandra.Client (lookupClients) import Galley.Cassandra.LegalHold import Galley.Cassandra.LegalHold qualified as LegalHoldData import Galley.Env qualified as Galley -import Galley.Options (optSettings, setFeatureFlags) +import Galley.Options (featureFlags, settings) import Galley.Types.Clients qualified as Clients import Galley.Types.Teams import Imports @@ -83,7 +83,7 @@ import Wire.API.User.Client qualified as Client onlyIfLhWhitelisted :: TestM () -> TestM () onlyIfLhWhitelisted action = do - featureLegalHold <- view (tsGConf . optSettings . setFeatureFlags . flagLegalHold) + featureLegalHold <- view (tsGConf . settings . featureFlags . flagLegalHold) case featureLegalHold of FeatureLegalHoldDisabledPermanently -> liftIO $ hPutStrLn stderr errmsg diff --git a/services/galley/test/integration/API/Teams/LegalHold/Util.hs b/services/galley/test/integration/API/Teams/LegalHold/Util.hs index 1a31e38d5ee..835f2100709 100644 --- a/services/galley/test/integration/API/Teams/LegalHold/Util.hs +++ b/services/galley/test/integration/API/Teams/LegalHold/Util.hs @@ -584,8 +584,8 @@ assertMatchChan c match = go [] getLHWhitelistedTeam :: HasCallStack => TeamId -> TestM ResponseLBS getLHWhitelistedTeam tid = do - galley <- viewGalley - getLHWhitelistedTeam' galley tid + galleyCall <- viewGalley + getLHWhitelistedTeam' galleyCall tid getLHWhitelistedTeam' :: (HasCallStack, MonadHttp m) => GalleyR -> TeamId -> m ResponseLBS getLHWhitelistedTeam' g tid = do @@ -596,8 +596,8 @@ getLHWhitelistedTeam' g tid = do putLHWhitelistTeam :: HasCallStack => TeamId -> TestM ResponseLBS putLHWhitelistTeam tid = do - galley <- viewGalley - putLHWhitelistTeam' galley tid + galleyCall <- viewGalley + putLHWhitelistTeam' galleyCall tid putLHWhitelistTeam' :: (HasCallStack, MonadHttp m) => GalleyR -> TeamId -> m ResponseLBS putLHWhitelistTeam' g tid = do @@ -608,8 +608,8 @@ putLHWhitelistTeam' g tid = do _deleteLHWhitelistTeam :: HasCallStack => TeamId -> TestM ResponseLBS _deleteLHWhitelistTeam tid = do - galley <- viewGalley - deleteLHWhitelistTeam' galley tid + galleyCall <- viewGalley + deleteLHWhitelistTeam' galleyCall tid deleteLHWhitelistTeam' :: (HasCallStack, MonadHttp m) => GalleyR -> TeamId -> m ResponseLBS deleteLHWhitelistTeam' g tid = do @@ -642,7 +642,7 @@ instance IsTest LHTest where run :: OptionSet -> LHTest -> (Progress -> IO ()) -> IO Result run _ (LHTest expectedFlag setupAction testAction) _ = do setup <- setupAction - let featureLegalHold = setup ^. tsGConf . optSettings . setFeatureFlags . flagLegalHold + let featureLegalHold = setup ^. tsGConf . settings . featureFlags . flagLegalHold if featureLegalHold == expectedFlag then do hunitResult <- try $ void . flip runReaderT setup . runTestM $ testAction diff --git a/services/galley/test/integration/API/Util.hs b/services/galley/test/integration/API/Util.hs index d260f74d6f6..4b4d4eb28a7 100644 --- a/services/galley/test/integration/API/Util.hs +++ b/services/galley/test/integration/API/Util.hs @@ -25,8 +25,6 @@ import API.SQS qualified as SQS import Bilge hiding (timeout) import Bilge.Assert import Bilge.TestSession -import Brig.Types.Connection -import Brig.Types.Intra import Control.Applicative import Control.Concurrent.Async import Control.Exception (throw) @@ -39,6 +37,7 @@ import Data.Aeson hiding (json) import Data.Aeson qualified as A import Data.Aeson.Lens (key, _String) import Data.ByteString qualified as BS +import Data.ByteString.Base64.URL qualified as B64U import Data.ByteString.Char8 qualified as B8 import Data.ByteString.Char8 qualified as C import Data.ByteString.Conversion @@ -107,6 +106,7 @@ import Util.Options import Web.Cookie import Wire.API.Connection import Wire.API.Conversation +import Wire.API.Conversation qualified as Conv import Wire.API.Conversation.Action import Wire.API.Conversation.Code hiding (Value) import Wire.API.Conversation.Protocol @@ -123,10 +123,11 @@ import Wire.API.Federation.API.Common import Wire.API.Federation.API.Galley import Wire.API.Federation.Domain (originDomainHeaderName) import Wire.API.Internal.Notification hiding (target) -import Wire.API.MLS.KeyPackage +import Wire.API.MLS.LeafNode import Wire.API.MLS.Message import Wire.API.MLS.Proposal import Wire.API.MLS.Serialisation +import Wire.API.MLS.SubConversation import Wire.API.Message import Wire.API.Message.Proto qualified as Proto import Wire.API.Routes.Internal.Brig.Connection @@ -191,13 +192,13 @@ symmPermissions p = let s = Set.fromList p in fromJust (newPermissions s s) createBindingTeam :: HasCallStack => TestM (UserId, TeamId) createBindingTeam = do - first userId <$> createBindingTeam' + first Wire.API.User.userId <$> createBindingTeam' createBindingTeam' :: HasCallStack => TestM (User, TeamId) createBindingTeam' = do owner <- randomTeamCreator' - teams <- getTeams (userId owner) [] - let [team] = view teamListTeams teams + teams <- getTeams owner.userId [] + team <- assertOne $ view teamListTeams teams let tid = view teamId team SQS.assertTeamActivate "create team" tid refreshIndex @@ -365,7 +366,7 @@ getTeamMembersPaginated usr tid n mPs = do . paths ["teams", toByteString' tid, "members"] . zUser usr . queryItem "maxResults" (C.pack $ show n) - . maybe id (queryItem "pagingState" . cs) mPs + . maybe Imports.id (queryItem "pagingState" . cs) mPs ) Maybe Role -> UserId -> TeamId -> TestM addUserToTeamWithRole role inviter tid = do (inv, rsp2) <- addUserToTeamWithRole' role inviter tid let invitee :: User = responseJsonUnsafe rsp2 - inviteeId = userId invitee + inviteeId = invitee.userId let invmeta = Just (inviter, inCreatedAt inv) mem <- getTeamMember inviter tid inviteeId liftIO $ assertEqual "Member has no/wrong invitation metadata" invmeta (mem ^. Team.invitation) @@ -482,8 +483,7 @@ addUserToTeamWithRole' role inviter tid = do addUserToTeamWithSSO :: HasCallStack => Bool -> TeamId -> TestM TeamMember addUserToTeamWithSSO hasEmail tid = do let ssoid = UserSSOId mkSimpleSampleUref - user <- responseJsonError =<< postSSOUser "SSO User" hasEmail ssoid tid - let uid = userId user + uid <- fmap (\(u :: User) -> u.userId) $ responseJsonError =<< postSSOUser "SSO User" hasEmail ssoid tid getTeamMember uid tid uid makeOwner :: HasCallStack => UserId -> TeamMember -> TeamId -> TestM () @@ -624,7 +624,7 @@ createTeamConvAccessRaw u tid us name acc role mtimer convRole = do g <- viewGalley let tinfo = ConvTeamInfo tid let conv = - NewConv us [] (name >>= checked) (fromMaybe (Set.fromList []) acc) role (Just tinfo) mtimer Nothing (fromMaybe roleNameWireAdmin convRole) ProtocolProteusTag + NewConv us [] (name >>= checked) (fromMaybe (Set.fromList []) acc) role (Just tinfo) mtimer Nothing (fromMaybe roleNameWireAdmin convRole) BaseProtocolProteusTag post ( g . path "/conversations" @@ -660,7 +660,7 @@ createMLSTeamConv lusr c tid users name access role timer convRole = do newConvMessageTimer = timer, newConvUsersRole = fromMaybe roleNameWireAdmin convRole, newConvReceiptMode = Nothing, - newConvProtocol = ProtocolMLSTag + newConvProtocol = BaseProtocolMLSTag } r <- post @@ -691,7 +691,7 @@ createOne2OneTeamConv :: UserId -> UserId -> Maybe Text -> TeamId -> TestM Respo createOne2OneTeamConv u1 u2 n tid = do g <- viewGalley let conv = - NewConv [u2] [] (n >>= checked) mempty Nothing (Just $ ConvTeamInfo tid) Nothing Nothing roleNameWireAdmin ProtocolProteusTag + NewConv [u2] [] (n >>= checked) mempty Nothing (Just $ ConvTeamInfo tid) Nothing Nothing roleNameWireAdmin BaseProtocolProteusTag post $ g . path "/conversations/one2one" . zUser u1 . zConn "conn" . zType "access" . json conv postConv :: @@ -705,12 +705,12 @@ postConv :: postConv u us name a r mtimer = postConvWithRole u us name a r mtimer roleNameWireAdmin defNewProteusConv :: NewConv -defNewProteusConv = NewConv [] [] Nothing mempty Nothing Nothing Nothing Nothing roleNameWireAdmin ProtocolProteusTag +defNewProteusConv = NewConv [] [] Nothing mempty Nothing Nothing Nothing Nothing roleNameWireAdmin BaseProtocolProteusTag defNewMLSConv :: NewConv defNewMLSConv = defNewProteusConv - { newConvProtocol = ProtocolMLSTag + { newConvProtocol = BaseProtocolMLSTag } postConvQualified :: @@ -724,7 +724,7 @@ postConvQualified u c n = do g . path "/conversations" . zUser u - . maybe id zClient c + . maybe Imports.id zClient c . zConn "conn" . zType "access" . json n @@ -769,7 +769,7 @@ postConvWithRemoteUsers u c n = postTeamConv :: TeamId -> UserId -> [UserId] -> Maybe Text -> [Access] -> Maybe (Set AccessRole) -> Maybe Milliseconds -> TestM ResponseLBS postTeamConv tid u us name a r mtimer = do g <- viewGalley - let conv = NewConv us [] (name >>= checked) (Set.fromList a) r (Just (ConvTeamInfo tid)) mtimer Nothing roleNameWireAdmin ProtocolProteusTag + let conv = NewConv us [] (name >>= checked) (Set.fromList a) r (Just (ConvTeamInfo tid)) mtimer Nothing roleNameWireAdmin BaseProtocolProteusTag post $ g . path "/conversations" . zUser u . zConn "conn" . zType "access" . json conv deleteTeamConv :: (HasGalley m, MonadIO m, MonadHttp m) => TeamId -> ConvId -> UserId -> m ResponseLBS @@ -807,7 +807,7 @@ postConvWithRole u members name access arole timer role = postConvWithReceipt :: UserId -> [UserId] -> Maybe Text -> [Access] -> Maybe (Set AccessRole) -> Maybe Milliseconds -> ReceiptMode -> TestM ResponseLBS postConvWithReceipt u us name a r mtimer rcpt = do g <- viewGalley - let conv = NewConv us [] (name >>= checked) (Set.fromList a) r Nothing mtimer (Just rcpt) roleNameWireAdmin ProtocolProteusTag + let conv = NewConv us [] (name >>= checked) (Set.fromList a) r Nothing mtimer (Just rcpt) roleNameWireAdmin BaseProtocolProteusTag post $ g . path "/conversations" . zUser u . zConn "conn" . zType "access" . json conv postSelfConv :: UserId -> TestM ResponseLBS @@ -818,7 +818,7 @@ postSelfConv u = do postO2OConv :: UserId -> UserId -> Maybe Text -> TestM ResponseLBS postO2OConv u1 u2 n = do g <- viewGalley - let conv = NewConv [u2] [] (n >>= checked) mempty Nothing Nothing Nothing Nothing roleNameWireAdmin ProtocolProteusTag + let conv = NewConv [u2] [] (n >>= checked) mempty Nothing Nothing Nothing Nothing roleNameWireAdmin BaseProtocolProteusTag post $ g . path "/conversations/one2one" . zUser u1 . zConn "conn" . zType "access" . json conv postConnectConv :: UserId -> UserId -> Text -> Text -> Maybe Text -> TestM ResponseLBS @@ -931,7 +931,7 @@ data Broadcast = Broadcast } instance Default Broadcast where - def = Broadcast BroadcastLegacyQueryParams BroadcastJSON mempty "ZXhhbXBsZQ==" mempty id + def = Broadcast BroadcastLegacyQueryParams BroadcastJSON mempty "ZXhhbXBsZQ==" mempty Imports.id postBroadcast :: (MonadIO m, MonadHttp m, HasGalley m) => @@ -943,8 +943,8 @@ postBroadcast lu c b = do let u = qUnqualified lu g <- viewGalley let (bodyReport, queryReport) = case bAPI b of - BroadcastLegacyQueryParams -> (Nothing, maybe id mkOtrReportMissing (bReport b)) - _ -> (bReport b, id) + BroadcastLegacyQueryParams -> (Nothing, maybe Imports.id mkOtrReportMissing (bReport b)) + _ -> (bReport b, Imports.id) let bdy = case (bAPI b, bType b) of (BroadcastQualified, BroadcastJSON) -> error "JSON not supported for the qualified broadcast API" (BroadcastQualified, BroadcastProto) -> @@ -996,7 +996,7 @@ mkOtrMessage (usr, clt, m) = (fn usr, HashMap.singleton (fn clt) m) fn = fromJust . fromByteString . toByteString' postProtoOtrMessage :: UserId -> ClientId -> ConvId -> OtrRecipients -> TestM ResponseLBS -postProtoOtrMessage = postProtoOtrMessage' Nothing id +postProtoOtrMessage = postProtoOtrMessage' Nothing Imports.id postProtoOtrMessage' :: Maybe [UserId] -> (Request -> Request) -> UserId -> ClientId -> ConvId -> OtrRecipients -> TestM ResponseLBS postProtoOtrMessage' reportMissing modif u d c rec = do @@ -1031,6 +1031,15 @@ getConvs u cids = do . zConn "conn" . json (ListConversations (unsafeRange cids)) +getConvClients :: HasCallStack => GroupId -> TestM ClientList +getConvClients gid = do + g <- viewGalley + responseJsonError + =<< get + ( g + . paths ["i", "group", B64U.encode $ unGroupId gid] + ) + getAllConvs :: HasCallStack => UserId -> TestM [Conversation] getAllConvs u = do g <- viewGalley @@ -1374,7 +1383,8 @@ getJoinCodeConv u k v = do postJoinConv :: UserId -> ConvId -> TestM ResponseLBS postJoinConv u c = do - g <- viewGalley + -- This endpoint is removed from version v5 onwards + g <- fmap (addPrefixAtVersion V4 .) (view tsUnversionedGalley) post $ g . paths ["/conversations", toByteString' c, "join"] @@ -1556,17 +1566,17 @@ registerRemoteConv convId originUser name othMembers = do void $ runFedClient @"on-conversation-created" fedGalleyClient (qDomain convId) $ ConversationCreated - { ccTime = now, - ccOrigUserId = originUser, - ccCnvId = qUnqualified convId, - ccCnvType = RegularConv, - ccCnvAccess = [], - ccCnvAccessRoles = Set.fromList [TeamMemberAccessRole, NonTeamMemberAccessRole], - ccCnvName = name, - ccNonCreatorMembers = othMembers, - ccMessageTimer = Nothing, - ccReceiptMode = Nothing, - ccProtocol = ProtocolProteus + { time = now, + origUserId = originUser, + cnvId = qUnqualified convId, + cnvType = RegularConv, + cnvAccess = [], + cnvAccessRoles = Set.fromList [TeamMemberAccessRole, NonTeamMemberAccessRole], + cnvName = name, + nonCreatorMembers = othMembers, + messageTimer = Nothing, + receiptMode = Nothing, + protocol = ProtocolProteus } getFeatureStatusMulti :: forall cfg. KnownSymbol (FeatureSymbol cfg) => Multi.TeamFeatureNoConfigMultiRequest -> TestM ResponseLBS @@ -1602,15 +1612,15 @@ assertNotConvMember u c = assertConvEquals :: (HasCallStack, MonadIO m) => Conversation -> Conversation -> m () assertConvEquals c1 c2 = liftIO $ do - assertEqual "id" (cnvQualifiedId c1) (cnvQualifiedId c2) - assertEqual "type" (cnvType c1) (cnvType c2) - assertEqual "creator" (cnvCreator c1) (cnvCreator c2) + assertEqual "id" c1.cnvQualifiedId c2.cnvQualifiedId + assertEqual "type" (Conv.cnvType c1) (Conv.cnvType c2) + assertEqual "creator" (Conv.cnvCreator c1) (Conv.cnvCreator c2) assertEqual "access" (accessSet c1) (accessSet c2) - assertEqual "name" (cnvName c1) (cnvName c2) + assertEqual "name" (Conv.cnvName c1) (Conv.cnvName c2) assertEqual "self member" (selfMember c1) (selfMember c2) assertEqual "other members" (otherMembers c1) (otherMembers c2) where - accessSet = Set.fromList . toList . cnvAccess + accessSet = Set.fromList . toList . Conv.cnvAccess selfMember = cmSelf . cnvMembers otherMembers = Set.fromList . cmOthers . cnvMembers @@ -1618,7 +1628,7 @@ assertConv :: HasCallStack => Response (Maybe Lazy.ByteString) -> ConvType -> - UserId -> + Maybe UserId -> Qualified UserId -> [Qualified UserId] -> Maybe Text -> @@ -1630,7 +1640,7 @@ assertConvWithRole :: HasCallStack => Response (Maybe Lazy.ByteString) -> ConvType -> - UserId -> + Maybe UserId -> Qualified UserId -> [Qualified UserId] -> Maybe Text -> @@ -1643,9 +1653,9 @@ assertConvWithRole r t c s us n mt role = do let _self = cmSelf (cnvMembers cnv) let others = cmOthers (cnvMembers cnv) liftIO $ do - assertEqual "id" cId (qUnqualified (cnvQualifiedId cnv)) - assertEqual "name" n (cnvName cnv) - assertEqual "type" t (cnvType cnv) + assertEqual "id" cId (qUnqualified cnv.cnvQualifiedId) + assertEqual "name" n (Conv.cnvName cnv) + assertEqual "type" t (Conv.cnvType cnv) assertEqual "creator" c (cnvCreator cnv) assertEqual "message_timer" mt (cnvMessageTimer cnv) assertEqual "self" s (memId _self) @@ -1656,9 +1666,9 @@ assertConvWithRole r t c s us n mt role = do assertBool "otr archived not false" (not (memOtrArchived _self)) assertBool "otr archived ref not empty" (isNothing (memOtrArchivedRef _self)) case t of - SelfConv -> assertEqual "access" privateAccess (cnvAccess cnv) - ConnectConv -> assertEqual "access" privateAccess (cnvAccess cnv) - One2OneConv -> assertEqual "access" privateAccess (cnvAccess cnv) + SelfConv -> assertEqual "access" privateAccess (Conv.cnvAccess cnv) + ConnectConv -> assertEqual "access" privateAccess (Conv.cnvAccess cnv) + One2OneConv -> assertEqual "access" privateAccess (Conv.cnvAccess cnv) _ -> pure () pure (cnvQualifiedId cnv) @@ -1694,28 +1704,29 @@ wsAssertOtr' evData conv usr from to txt n = do wsAssertMLSWelcome :: HasCallStack => Qualified UserId -> + Qualified ConvId -> ByteString -> Notification -> IO () -wsAssertMLSWelcome u welcome n = do +wsAssertMLSWelcome u cid welcome n = do let e = List1.head (WS.unpackPayload n) ntfTransient n @?= False - evtConv e @?= fmap selfConv u + evtConv e @?= cid evtType e @?= MLSWelcome evtFrom e @?= u evtData e @?= EdMLSWelcome welcome wsAssertMLSMessage :: HasCallStack => - Qualified ConvId -> + Qualified ConvOrSubConvId -> Qualified UserId -> ByteString -> Notification -> IO () -wsAssertMLSMessage conv u message n = do +wsAssertMLSMessage qcs u message n = do let e = List1.head (WS.unpackPayload n) ntfTransient n @?= False - assertMLSMessageEvent conv u message e + assertMLSMessageEvent qcs u message e wsAssertClientRemoved :: HasCallStack => @@ -1743,13 +1754,17 @@ wsAssertClientAdded cid n = do assertMLSMessageEvent :: HasCallStack => - Qualified ConvId -> + Qualified ConvOrSubConvId -> Qualified UserId -> ByteString -> Conv.Event -> IO () -assertMLSMessageEvent conv u message e = do - evtConv e @?= conv +assertMLSMessageEvent qcs u message e = do + evtConv e @?= (.conv) <$> qcs + case qUnqualified qcs of + Conv _ -> pure () + SubConv _ subconvId -> + evtSubConv e @?= Just subconvId evtType e @?= MLSMessageAdd evtFrom e @?= u evtData e @?= EdMLSMessage message @@ -1815,7 +1830,7 @@ assertLeaveEvent conv usr leaving e = do evtConv e @?= conv evtType e @?= Conv.MemberLeave evtFrom e @?= usr - fmap (sort . qualifiedUserIdList) (evtData e ^? _EdMembersLeave) @?= Just (sort leaving) + fmap (sort . qualifiedUserIdList) (evtData e ^? _EdMembersLeave . _2) @?= Just (sort leaving) wsAssertMemberUpdateWithRole :: Qualified ConvId -> Qualified UserId -> UserId -> RoleName -> Notification -> IO () wsAssertMemberUpdateWithRole conv usr target role n = do @@ -1848,16 +1863,16 @@ wsAssertConvMessageTimerUpdate conv usr new n = do evtFrom e @?= usr evtData e @?= EdConvMessageTimerUpdate new -wsAssertMemberLeave :: Qualified ConvId -> Qualified UserId -> [Qualified UserId] -> Notification -> IO () -wsAssertMemberLeave conv usr old n = do +wsAssertMemberLeave :: Qualified ConvId -> Qualified UserId -> [Qualified UserId] -> EdMemberLeftReason -> Notification -> IO () +wsAssertMemberLeave conv usr old reason n = do let e = List1.head (WS.unpackPayload n) ntfTransient n @?= False evtConv e @?= conv evtType e @?= Conv.MemberLeave evtFrom e @?= usr - sorted (evtData e) @?= sorted (EdMembersLeave (QualifiedUserIdList old)) + sorted (evtData e) @?= sorted (EdMembersLeave reason (QualifiedUserIdList old)) where - sorted (EdMembersLeave (QualifiedUserIdList m)) = EdMembersLeave (QualifiedUserIdList (sort m)) + sorted (EdMembersLeave _ (QualifiedUserIdList m)) = EdMembersLeave reason (QualifiedUserIdList (sort m)) sorted x = x wsAssertTyping :: HasCallStack => Qualified ConvId -> Qualified UserId -> TypingStatus -> Notification -> IO () @@ -1880,7 +1895,7 @@ assertRemoveUpdate :: (MonadIO m, HasCallStack) => FederatedRequest -> Qualified assertRemoveUpdate req qconvId remover alreadyPresentUsers victim = liftIO $ do frRPC req @?= "on-conversation-updated" frOriginDomain req @?= qDomain qconvId - let Just cu = decode (frBody req) + cu <- assertJust $ decode (frBody req) cuOrigUserId cu @?= remover cuConvId cu @?= qUnqualified qconvId sort (cuAlreadyPresentUsers cu) @?= sort alreadyPresentUsers @@ -1890,7 +1905,7 @@ assertLeaveUpdate :: (MonadIO m, HasCallStack) => FederatedRequest -> Qualified assertLeaveUpdate req qconvId remover alreadyPresentUsers = liftIO $ do frRPC req @?= "on-conversation-updated" frOriginDomain req @?= qDomain qconvId - let Just cu = decode (frBody req) + cu <- assertJust $ decode (frBody req) cuOrigUserId cu @?= remover cuConvId cu @?= qUnqualified qconvId sort (cuAlreadyPresentUsers cu) @?= sort alreadyPresentUsers @@ -1982,7 +1997,7 @@ connectUsersUnchecked :: UserId -> List1 UserId -> TestM (List1 (Response (Maybe Lazy.ByteString), Response (Maybe Lazy.ByteString))) -connectUsersUnchecked = connectUsersWith id +connectUsersUnchecked = connectUsersWith Imports.id connectUsersWith :: (Request -> Request) -> @@ -2155,7 +2170,7 @@ ephemeralUser = do let p = object ["name" .= name] r <- post (b . path "/register" . json p) UserId -> LastPrekey -> TestM ClientId randomClient uid lk = randomClientWithCaps uid lk Nothing @@ -2222,7 +2237,7 @@ getInternalClientsFull userSet = do ensureClientCaps :: HasCallStack => UserId -> ClientId -> Client.ClientCapabilityList -> TestM () ensureClientCaps uid cid caps = do UserClientsFull (Map.lookup uid -> (Just clnts)) <- getInternalClientsFull (UserSet $ Set.singleton uid) - let [clnt] = filter ((== cid) . clientId) $ Set.toList clnts + clnt <- assertOne . filter ((== cid) . clientId) $ Set.toList clnts liftIO $ assertEqual ("ensureClientCaps: " <> show (uid, cid, caps)) (clientCapabilities clnt) caps -- TODO: Refactor, as used also in brig @@ -2295,11 +2310,11 @@ fromBS bs = convRange :: Maybe (Either [ConvId] ConvId) -> Maybe Int32 -> Request -> Request convRange range size = - maybe id (queryItem "size" . C.pack . show) size + maybe Imports.id (queryItem "size" . C.pack . show) size . case range of Just (Left l) -> queryItem "ids" (C.intercalate "," $ map toByteString' l) Just (Right c) -> queryItem "start" (toByteString' c) - Nothing -> id + Nothing -> Imports.id privateAccess :: [Access] privateAccess = [PrivateAccess] @@ -2347,7 +2362,7 @@ assertMismatchWithMessage mmsg missing redundant deleted = do userClients = UserClients . Map.fromList formatMessage :: String -> String - formatMessage = maybe id (\msg -> ((msg <> "\n") <>)) mmsg + formatMessage = maybe Imports.id (\msg -> ((msg <> "\n") <>)) mmsg assertMismatch :: HasCallStack => @@ -2465,7 +2480,7 @@ mkProteusConv cnvId creator selfRole otherMembers = cnvId ( ConversationMetadata RegularConv - creator + (Just creator) [] (Set.fromList [TeamMemberAccessRole, NonTeamMemberAccessRole]) (Just "federated gossip") @@ -2663,7 +2678,7 @@ withTempMockFederator' resp action = do [("Content-Type", "application/json")] mock $ \mockPort -> do - withSettingsOverrides (\opts -> opts & Opts.optFederator ?~ Endpoint "127.0.0.1" (fromIntegral mockPort)) action + withSettingsOverrides (\opts -> opts & Opts.federator ?~ Endpoint "127.0.0.1" (fromIntegral mockPort)) action -- Starts a servant Application in Network.Wai.Test session and runs the -- FederatedRequest against it. @@ -2828,7 +2843,7 @@ checkConvMemberLeaveEvent cid usr w = WS.assertMatch_ checkTimeout w $ \notif -> evtConv e @?= cid evtType e @?= Conv.MemberLeave case evtData e of - Conv.EdMembersLeave mm -> mm @?= Conv.QualifiedUserIdList [usr] + Conv.EdMembersLeave _ mm -> mm @?= Conv.QualifiedUserIdList [usr] other -> assertFailure $ "Unexpected event data: " <> show other checkTimeout :: WS.Timeout @@ -2899,7 +2914,7 @@ generateRemoteAndConvId = generateRemoteAndConvIdWithDomain (Domain "far-away.ex generateRemoteAndConvIdWithDomain :: Domain -> Bool -> Local UserId -> TestM (Remote UserId, Qualified ConvId) generateRemoteAndConvIdWithDomain remoteDomain shouldBeLocal lUserId = do other <- Qualified <$> randomId <*> pure remoteDomain - let convId = one2OneConvId (tUntagged lUserId) other + let convId = one2OneConvId BaseProtocolProteusTag (tUntagged lUserId) other isLocal = tDomain lUserId == qDomain convId if shouldBeLocal == isLocal then pure (qTagUnsafe other, convId) @@ -2940,32 +2955,33 @@ wsAssertConvReceiptModeUpdate conv usr new n = do evtFrom e @?= usr evtData e @?= EdConvReceiptModeUpdate (ConversationReceiptModeUpdate new) -wsAssertBackendRemoveProposalWithEpoch :: HasCallStack => Qualified UserId -> Qualified ConvId -> KeyPackageRef -> Epoch -> Notification -> IO ByteString -wsAssertBackendRemoveProposalWithEpoch fromUser convId kpref epoch n = do - bs <- wsAssertBackendRemoveProposal fromUser convId kpref n - let msg = fromRight (error "Failed to parse Message 'MLSPlaintext") $ decodeMLS' @(Message 'MLSPlainText) bs - let tbs = rmValue . msgTBS $ msg - tbsMsgEpoch tbs @?= epoch +wsAssertBackendRemoveProposalWithEpoch :: HasCallStack => Qualified UserId -> Qualified ConvId -> LeafIndex -> Epoch -> Notification -> IO ByteString +wsAssertBackendRemoveProposalWithEpoch fromUser convId idx epoch n = do + bs <- wsAssertBackendRemoveProposal fromUser (Conv <$> convId) idx n + let msg = fromRight (error "Failed to parse Message") $ decodeMLS' @Message bs + case msg.content of + MessagePublic pmsg -> liftIO $ pmsg.content.value.epoch @?= epoch + _ -> assertFailure "unexpected message content" pure bs -wsAssertBackendRemoveProposal :: HasCallStack => Qualified UserId -> Qualified ConvId -> KeyPackageRef -> Notification -> IO ByteString -wsAssertBackendRemoveProposal fromUser convId kpref n = do +wsAssertBackendRemoveProposal :: HasCallStack => Qualified UserId -> Qualified ConvOrSubConvId -> LeafIndex -> Notification -> IO ByteString +wsAssertBackendRemoveProposal fromUser cnvOrSubCnv idx n = do let e = List1.head (WS.unpackPayload n) ntfTransient n @?= False - evtConv e @?= convId + evtConv e @?= (.conv) <$> cnvOrSubCnv evtType e @?= MLSMessageAdd evtFrom e @?= fromUser let bs = getMLSMessageData (evtData e) - let msg = fromRight (error "Failed to parse Message 'MLSPlaintext") $ decodeMLS' bs - let tbs = rmValue . msgTBS $ msg - tbsMsgSender tbs @?= PreconfiguredSender 0 - case tbsMsgPayload tbs of - ProposalMessage rp -> - case rmValue rp of - RemoveProposal kpRefRemove -> - kpRefRemove @?= kpref - otherProp -> assertFailure $ "Expected RemoveProposal but got " <> show otherProp - otherPayload -> assertFailure $ "Expected ProposalMessage but got " <> show otherPayload + let msg = fromRight (error "Failed to parse Message") $ decodeMLS' @Message bs + liftIO $ case msg.content of + MessagePublic pmsg -> do + pmsg.content.value.sender @?= SenderExternal 0 + case pmsg.content.value.content of + FramedContentProposal prop -> case prop.value of + RemoveProposal removedIdx -> removedIdx @?= idx + otherProp -> assertFailure $ "Expected RemoveProposal but got " <> show otherProp + otherPayload -> assertFailure $ "Expected ProposalMessage but got " <> show otherPayload + _ -> assertFailure $ "Expected PublicMessage" pure bs where getMLSMessageData :: Conv.EventData -> ByteString @@ -2985,19 +3001,16 @@ wsAssertAddProposal fromUser convId n = do evtType e @?= MLSMessageAdd evtFrom e @?= fromUser let bs = getMLSMessageData (evtData e) - let msg = fromRight (error "Failed to parse Message 'MLSPlaintext") $ decodeMLS' bs - let tbs = rmValue . msgTBS $ msg - tbsMsgSender tbs @?= NewMemberSender - case tbsMsgPayload tbs of - ProposalMessage rp -> - case rmValue rp of - AddProposal _ -> pure () - otherProp -> - assertFailure $ - "Expected AddProposal but got " <> show otherProp - otherPayload -> - assertFailure $ - "Expected ProposalMessage but got " <> show otherPayload + let msg = fromRight (error "Failed to parse Message 'MLSPlaintext") $ decodeMLS' @Message bs + liftIO $ case msg.content of + MessagePublic pmsg -> do + pmsg.content.value.sender @?= SenderNewMemberProposal + case pmsg.content.value.content of + FramedContentProposal prop -> case prop.value of + AddProposal _ -> pure () + otherProp -> assertFailure $ "Expected AddProposal but got " <> show otherProp + otherPayload -> assertFailure $ "Expected ProposalMessage but got " <> show otherPayload + _ -> assertFailure $ "Expected PublicMessage" pure bs where getMLSMessageData :: Conv.EventData -> ByteString @@ -3022,6 +3035,24 @@ createAndConnectUsers domains = do (False, False) -> pure () pure users +putConversationProtocol :: (MonadIO m, MonadHttp m, HasGalley m, HasCallStack) => UserId -> ClientId -> Qualified ConvId -> ProtocolTag -> m ResponseLBS +putConversationProtocol uid client (Qualified conv domain) protocol = do + galley <- viewGalley + put + ( galley + . paths ["conversations", toByteString' domain, toByteString' conv, "protocol"] + . zUser uid + . zConn "conn" + . zClient client + . Bilge.json (object ["protocol" .= protocol]) + ) + +assertMixedProtocol :: (MonadIO m, HasCallStack) => Conversation -> m ConversationMLSData +assertMixedProtocol conv = do + case cnvProtocol conv of + ProtocolMixed mlsData -> pure mlsData + _ -> liftIO $ assertFailure "Unexpected protocol" + connectBackend :: UserId -> Remote Backend -> TestM [Qualified UserId] connectBackend usr (tDomain &&& bUsers . tUnqualified -> (d, c)) = do users <- replicateM (fromIntegral c) (randomQualifiedId d) diff --git a/services/galley/test/integration/API/Util/TeamFeature.hs b/services/galley/test/integration/API/Util/TeamFeature.hs index e2d04068bb7..cb98a9aa9c4 100644 --- a/services/galley/test/integration/API/Util/TeamFeature.hs +++ b/services/galley/test/integration/API/Util/TeamFeature.hs @@ -33,7 +33,7 @@ import Data.ByteString.Conversion (toByteString') import Data.Id (ConvId, TeamId, UserId) import Data.Schema import GHC.TypeLits (KnownSymbol) -import Galley.Options (optSettings, setFeatureFlags) +import Galley.Options (featureFlags, settings) import Galley.Types.Teams import Imports import TestSetup @@ -41,7 +41,7 @@ import Wire.API.Team.Feature qualified as Public withCustomSearchFeature :: FeatureTeamSearchVisibilityAvailability -> TestM () -> TestM () withCustomSearchFeature flag action = do - Util.withSettingsOverrides (\opts -> opts & optSettings . setFeatureFlags . flagTeamSearchVisibility .~ flag) action + Util.withSettingsOverrides (\opts -> opts & settings . featureFlags . flagTeamSearchVisibility .~ flag) action getTeamSearchVisibilityAvailable :: HasCallStack => (Request -> Request) -> UserId -> TeamId -> MonadHttp m => m ResponseLBS getTeamSearchVisibilityAvailable = getTeamFeatureFlagWithGalley @Public.SearchVisibilityAvailableConfig diff --git a/services/galley/test/integration/Federation.hs b/services/galley/test/integration/Federation.hs index 56dd7704dd1..666c557ad7d 100644 --- a/services/galley/test/integration/Federation.hs +++ b/services/galley/test/integration/Federation.hs @@ -2,53 +2,31 @@ module Federation where -import API.Util -import Bilge.Assert -import Bilge.Response import Cassandra qualified as C -import Cassandra.Exec (x1) -import Control.Lens (view, (^.)) +import Control.Lens ((^.)) import Control.Monad.Catch -import Control.Monad.Codensity (lowerCodensity) import Data.ByteString qualified as LBS import Data.Domain import Data.Id -import Data.List.NonEmpty -import Data.List1 qualified as List1 import Data.Qualified import Data.Set qualified as Set -import Data.Singletons -import Data.Time (getCurrentTime) import Data.UUID qualified as UUID -import Federator.MockServer -import Galley.API.Internal import Galley.API.Util import Galley.App -import Galley.Cassandra.Queries import Galley.Data.Conversation.Types qualified as Types -import Galley.Monad import Galley.Options -import Galley.Run -import Galley.Types.Conversations.Members (LocalMember (..), RemoteMember (..), defMemberStatus, localMemberToOther) +import Galley.Types.Conversations.Members (LocalMember (..), RemoteMember (..), defMemberStatus) import Imports -import Test.Tasty.Cannon (TimeoutUnit (..), (#)) -import Test.Tasty.Cannon qualified as WS import Test.Tasty.HUnit import TestSetup import UnliftIO.Retry import Wire.API.Conversation import Wire.API.Conversation qualified as Public -import Wire.API.Conversation.Action import Wire.API.Conversation.Protocol (Protocol (..)) -import Wire.API.Conversation.Role (roleNameWireAdmin, roleNameWireMember) -import Wire.API.Event.Conversation -import Wire.API.Federation.API.Brig (NonConnectedBackends (NonConnectedBackends)) -import Wire.API.Federation.API.Galley (ConversationUpdate (..), GetConversationsResponse (..)) -import Wire.API.Internal.Notification +import Wire.API.Conversation.Role (roleNameWireMember) import Wire.API.Routes.FederationDomainConfig import Wire.API.Routes.MultiTablePaging import Wire.API.Routes.MultiTablePaging qualified as Public -import Wire.API.User.Search x3 :: RetryPolicy x3 = limitRetries 3 <> exponentialBackoff 100000 @@ -57,7 +35,7 @@ isConvMemberLTests :: TestM () isConvMemberLTests = do s <- ask let opts = s ^. tsGConf - localDomain = opts ^. optSettings . setFederationDomain + localDomain = opts ^. settings . federationDomain remoteDomain = Domain "far-away.example.com" convId = Id $ fromJust $ UUID.fromString "8cc34301-6949-46c5-bb93-00a72268e2f5" convLocalMembers = [LocalMember userId defMemberStatus Nothing roleNameWireMember] @@ -69,7 +47,7 @@ isConvMemberLTests = do convLocalMembers convRemoteMembers False - (defConversationMetadata userId) + (defConversationMetadata (Just userId)) ProtocolProteus lUserId :: Local UserId lUserId = toLocalUnsafe localDomain $ Id $ fromJust $ UUID.fromString "217352c0-8b2b-4653-ac76-a88d19490dad" -- A random V4 UUID @@ -82,223 +60,12 @@ isConvMemberLTests = do liftIO $ assertBool "Qualified UserId (local)" $ isConvMemberL lconv $ tUntagged lUserId liftIO $ assertBool "Qualified UserId (remote)" $ isConvMemberL lconv $ tUntagged rUserId -updateFedDomainsTestNoop' :: TestM () -updateFedDomainsTestNoop' = do - s <- ask - let opts = s ^. tsGConf - -- Don't need the actual server, and we certainly don't want it running. - -- But this is how the env is made, so it is what we do - (_, env) <- liftIO $ lowerCodensity $ mkApp opts - -- Common variables. - -- FUTUREWORK, NEWTICKET: These uuid strings side step issues with the tests hanging. - -- FUTUREWORK, NEWTICKET: Figure out the underlying issue as to why these tests occasionally hang. - let interval = (maxBound :: Int) `div` 2 -- Very large values so that we don't have to worry about automatic updates - remoteDomain = Domain "far-away.example.com" - remoteDomain2 = Domain "far-away-two.example.com" - liftIO $ assertBool "remoteDomain is different to local domain" $ remoteDomain /= opts ^. optSettings . setFederationDomain - liftIO $ assertBool "remoteDomain2 is different to local domain" $ remoteDomain2 /= opts ^. optSettings . setFederationDomain - -- Setup a conversation for a known remote domain. - -- Include that domain in the old and new lists so - -- if the function is acting up we know it will be - -- working on the domain. - updateFedDomainsTestNoop env remoteDomain interval - -updateFedDomainsTestAddRemote' :: TestM () -updateFedDomainsTestAddRemote' = do - s <- ask - let opts = s ^. tsGConf - -- Don't need the actual server, and we certainly don't want it running. - -- But this is how the env is made, so it is what we do - (_, env) <- liftIO $ lowerCodensity $ mkApp opts - -- Common variables. - let interval = (maxBound :: Int) `div` 2 -- Very large values so that we don't have to worry about automatic updates - remoteDomain = Domain "far-away.example.com" - remoteDomain2 = Domain "far-away-two.example.com" - liftIO $ assertBool "remoteDomain is different to local domain" $ remoteDomain /= opts ^. optSettings . setFederationDomain - liftIO $ assertBool "remoteDomain2 is different to local domain" $ remoteDomain2 /= opts ^. optSettings . setFederationDomain - - -- Adding a new federation domain, this too should be a no-op - updateFedDomainsAddRemote env remoteDomain remoteDomain2 interval - -updateFedDomainsTestRemoveRemoteFromLocal' :: TestM () -updateFedDomainsTestRemoveRemoteFromLocal' = do - s <- ask - let opts = s ^. tsGConf - -- Don't need the actual server, and we certainly don't want it running. - -- But this is how the env is made, so it is what we do - (_, env) <- liftIO $ lowerCodensity $ mkApp opts - -- Common variables. - let interval = (maxBound :: Int) `div` 2 -- Very large values so that we don't have to worry about automatic updates - remoteDomain = Domain "far-away.example.com" - remoteDomain2 = Domain "far-away-two.example.com" - liftIO $ assertBool "remoteDomain is different to local domain" $ remoteDomain /= opts ^. optSettings . setFederationDomain - liftIO $ assertBool "remoteDomain2 is different to local domain" $ remoteDomain2 /= opts ^. optSettings . setFederationDomain - - -- Remove a remote domain from local conversations - updateFedDomainRemoveRemoteFromLocal env remoteDomain remoteDomain2 interval - -updateFedDomainsTestRemoveLocalFromRemote' :: TestM () -updateFedDomainsTestRemoveLocalFromRemote' = do - s <- ask - let opts = s ^. tsGConf - -- Don't need the actual server, and we certainly don't want it running. - -- But this is how the env is made, so it is what we do - (_, env) <- liftIO $ lowerCodensity $ mkApp opts - -- Common variables. - let interval = (maxBound :: Int) `div` 2 -- Very large values so that we don't have to worry about automatic updates - remoteDomain = Domain "far-away.example.com" - remoteDomain2 = Domain "far-away-two.example.com" - liftIO $ assertBool "remoteDomain is different to local domain" $ remoteDomain /= opts ^. optSettings . setFederationDomain - liftIO $ assertBool "remoteDomain2 is different to local domain" $ remoteDomain2 /= opts ^. optSettings . setFederationDomain - - -- Remove a local domain from remote conversations - updateFedDomainRemoveLocalFromRemote env remoteDomain interval - fromFedList :: FederationDomainConfigs -> Set Domain fromFedList = Set.fromList . fmap domain . remotes -deleteFederationDomains :: FederationDomainConfigs -> FederationDomainConfigs -> App () -deleteFederationDomains old new = do - let prev = fromFedList old - curr = fromFedList new - deletedDomains = Set.difference prev curr - env <- ask - -- Call into the galley code - for_ deletedDomains $ liftIO . evalGalleyToIO env . deleteFederationDomain - constHandlers :: (MonadIO m) => [RetryStatus -> Handler m Bool] constHandlers = [const $ Handler $ (\(_ :: SomeException) -> pure True)] -updateFedDomainRemoveRemoteFromLocal :: Env -> Domain -> Domain -> Int -> TestM () -updateFedDomainRemoveRemoteFromLocal env remoteDomain remoteDomain2 interval = recovering x3 constHandlers $ const $ do - let new = FederationDomainConfigs AllowDynamic [FederationDomainConfig remoteDomain2 FullSearch] interval - old = new {remotes = FederationDomainConfig remoteDomain FullSearch : remotes new} - qalice <- randomQualifiedUser - bobId <- randomId - charlieId <- randomId - let alice = qUnqualified qalice - remoteBob = Qualified bobId remoteDomain - remoteCharlie = Qualified charlieId remoteDomain2 - -- Create a local conversation - conv <- postConv alice [] (Just "remote gossip") [] Nothing Nothing - let qConvId = decodeQualifiedConvId conv - connectWithRemoteUser alice remoteBob - connectWithRemoteUser alice remoteCharlie - _ <- withTempMockFederator' ("get-not-fully-connected-backends" ~> NonConnectedBackends mempty) $ postQualifiedMembers alice (remoteCharlie <| remoteBob :| []) qConvId - -- Remove the remote user from the local domain - liftIO $ runApp env $ deleteFederationDomains old new - -- Check that the conversation still exists. - getConvQualified alice qConvId !!! do - const 200 === statusCode - let findRemote :: Qualified UserId -> Conversation -> Maybe (Qualified UserId) - findRemote u = find (== u) . fmap omQualifiedId . cmOthers . cnvMembers - -- Check that only one remote user was removed. - const (Right Nothing) === (fmap (findRemote remoteBob) <$> responseJsonEither) - const (Right $ pure remoteCharlie) === (fmap (findRemote remoteCharlie) <$> responseJsonEither) - const (Right qalice) === (fmap (memId . cmSelf . cnvMembers) <$> responseJsonEither) - -updateFedDomainRemoveLocalFromRemote :: Env -> Domain -> Int -> TestM () -updateFedDomainRemoveLocalFromRemote env remoteDomain interval = recovering x3 constHandlers $ const $ do - c <- view tsCannon - let new = FederationDomainConfigs AllowDynamic [] interval - old = new {remotes = FederationDomainConfig remoteDomain FullSearch : remotes new} - -- Make our users - qalice <- randomQualifiedUser - qbob <- Qualified <$> randomId <*> pure remoteDomain - let alice = qUnqualified qalice - update = memberUpdate {mupHidden = Just False} - -- Create a remote conversation - -- START: code from putRemoteConvMemberOk - qconv <- Qualified <$> randomId <*> pure remoteDomain - connectWithRemoteUser alice qbob - - fedGalleyClient <- view tsFedGalleyClient - now <- liftIO getCurrentTime - let cu = - ConversationUpdate - { cuTime = now, - cuOrigUserId = qbob, - cuConvId = qUnqualified qconv, - cuAlreadyPresentUsers = [], - cuAction = SomeConversationAction (sing @'ConversationJoinTag) (ConversationJoin (pure qalice) roleNameWireMember) - } - void $ runFedClient @"on-conversation-updated" fedGalleyClient remoteDomain cu - -- Expected member state - let memberAlice = - Member - { memId = qalice, - memService = Nothing, - memOtrMutedStatus = mupOtrMuteStatus update, - memOtrMutedRef = mupOtrMuteRef update, - memOtrArchived = Just True == mupOtrArchive update, - memOtrArchivedRef = mupOtrArchiveRef update, - memHidden = Just True == mupHidden update, - memHiddenRef = mupHiddenRef update, - memConvRoleName = roleNameWireMember - } - -- Update member state & verify push notification - WS.bracketR c alice $ \ws -> do - putMember alice update qconv !!! const 200 === statusCode - void . liftIO . WS.assertMatch (5 # Second) ws $ \n -> do - let e = List1.head (WS.unpackPayload n) - ntfTransient n @?= False - evtConv e @?= qconv - evtType e @?= MemberStateUpdate - evtFrom e @?= qalice - case evtData e of - EdMemberUpdate mis -> do - assertEqual "otr_muted_status" (mupOtrMuteStatus update) (misOtrMutedStatus mis) - assertEqual "otr_muted_ref" (mupOtrMuteRef update) (misOtrMutedRef mis) - assertEqual "otr_archived" (mupOtrArchive update) (misOtrArchived mis) - assertEqual "otr_archived_ref" (mupOtrArchiveRef update) (misOtrArchivedRef mis) - assertEqual "hidden" (mupHidden update) (misHidden mis) - assertEqual "hidden_ref" (mupHiddenRef update) (misHiddenRef mis) - x -> assertFailure $ "Unexpected event data: " ++ show x - - -- Fetch remote conversation - let bobAsLocal = - LocalMember - (qUnqualified qbob) - defMemberStatus - Nothing - roleNameWireAdmin - let mockConversation = - mkProteusConv - (qUnqualified qconv) - (qUnqualified qbob) - roleNameWireMember - [localMemberToOther remoteDomain bobAsLocal] - remoteConversationResponse = GetConversationsResponse [mockConversation] - (rs, _) <- - withTempMockFederator' - (mockReply remoteConversationResponse) - $ getConvQualified alice qconv - responseJsonUnsafe rs - liftIO $ do - assertBool "user" (isJust alice') - let newAlice = fromJust alice' - assertEqual "id" (memId memberAlice) (memId newAlice) - assertEqual "otr_muted_status" (memOtrMutedStatus memberAlice) (memOtrMutedStatus newAlice) - assertEqual "otr_muted_ref" (memOtrMutedRef memberAlice) (memOtrMutedRef newAlice) - assertEqual "otr_archived" (memOtrArchived memberAlice) (memOtrArchived newAlice) - assertEqual "otr_archived_ref" (memOtrArchivedRef memberAlice) (memOtrArchivedRef newAlice) - assertEqual "hidden" (memHidden memberAlice) (memHidden newAlice) - assertEqual "hidden_ref" (memHiddenRef memberAlice) (memHiddenRef newAlice) - -- END: code from putRemoteConvMemberOk - - -- Remove the remote user from the local domain - liftIO $ runApp env $ deleteFederationDomains old new - convIds <- - liftIO $ - C.runClient (env ^. cstate) $ - C.retry x1 $ - C.query selectUserRemoteConvs (C.params C.LocalQuorum (pure alice)) - case find (== qUnqualified qconv) $ snd <$> convIds of - Nothing -> pure () - Just c' -> liftIO $ assertFailure $ "Found conversation where none was expected: " <> show c' - pageToConvIdPage :: Public.LocalOrRemoteTable -> C.PageWithState (Qualified ConvId) -> Public.ConvIdsPage pageToConvIdPage table page@C.PageWithState {..} = Public.MultiTablePage @@ -306,67 +73,3 @@ pageToConvIdPage table page@C.PageWithState {..} = mtpHasMore = C.pwsHasMore page, mtpPagingState = Public.ConversationPagingState table (LBS.toStrict . C.unPagingState <$> pwsState) } - -updateFedDomainsAddRemote :: Env -> Domain -> Domain -> Int -> TestM () -updateFedDomainsAddRemote env remoteDomain remoteDomain2 interval = do - s <- ask - let opts = s ^. tsGConf - localDomain = opts ^. optSettings . setFederationDomain - old = FederationDomainConfigs AllowDynamic [FederationDomainConfig remoteDomain FullSearch] interval - new = old {remotes = FederationDomainConfig remoteDomain2 FullSearch : remotes old} - -- Just check against the domains, as the search - -- strategies are outside of this testing scope - newDoms = domain <$> new.remotes - oldDoms = domain <$> old.remotes - liftIO $ assertBool "old and new are different" $ oldDoms /= newDoms - liftIO $ assertBool "old is shorter than new" $ Imports.length oldDoms < Imports.length newDoms - liftIO $ assertBool "new contains old" $ all (`elem` newDoms) oldDoms - liftIO $ assertBool "new elements not in old" $ any (`notElem` oldDoms) newDoms - qalice <- randomQualifiedUser - bobId <- randomId - let alice = qUnqualified qalice - remoteBob = Qualified bobId remoteDomain - -- Create a conversation - - conv <- postConv alice [] (Just "remote gossip") [] Nothing Nothing - -- liftIO $ assertBool ("conv = " <> show conv) False - let convId = decodeConvId conv - let qConvId = Qualified convId localDomain - connectWithRemoteUser alice remoteBob - _ <- withTempMockFederator' ("get-not-fully-connected-backends" ~> NonConnectedBackends mempty) $ postQualifiedMembers alice (remoteBob :| []) qConvId - - -- No-op - liftIO $ runApp env $ deleteFederationDomains old new - -- Check that the conversation still exists. - getConvQualified (qUnqualified qalice) (Qualified convId localDomain) !!! do - const 200 === statusCode - let findRemote :: Conversation -> Maybe (Qualified UserId) - findRemote = find (== remoteBob) . fmap omQualifiedId . cmOthers . cnvMembers - const (Right $ pure remoteBob) === (fmap findRemote <$> responseJsonEither) - const (Right qalice) === (fmap (memId . cmSelf . cnvMembers) <$> responseJsonEither) - -updateFedDomainsTestNoop :: Env -> Domain -> Int -> TestM () -updateFedDomainsTestNoop env remoteDomain interval = do - s <- ask - let opts = s ^. tsGConf - localDomain = opts ^. optSettings . setFederationDomain - old = FederationDomainConfigs AllowDynamic [FederationDomainConfig remoteDomain FullSearch] interval - new = old - qalice <- randomQualifiedUser - bobId <- randomId - let alice = qUnqualified qalice - remoteBob = Qualified bobId remoteDomain - -- Create a conversation - convId <- decodeConvId <$> postConv alice [] (Just "remote gossip") [] Nothing Nothing - let qConvId = Qualified convId localDomain - connectWithRemoteUser alice remoteBob - _ <- withTempMockFederator' ("get-not-fully-connected-backends" ~> NonConnectedBackends mempty) $ postQualifiedMembers alice (remoteBob :| []) qConvId - -- No-op - liftIO $ runApp env $ deleteFederationDomains old new - -- Check that the conversation still exists. - getConvQualified (qUnqualified qalice) (Qualified convId localDomain) !!! do - const 200 === statusCode - let findRemote :: Conversation -> Maybe (Qualified UserId) - findRemote = find (== remoteBob) . fmap omQualifiedId . cmOthers . cnvMembers - const (Right $ pure remoteBob) === (fmap findRemote <$> responseJsonEither) - const (Right qalice) === (fmap (memId . cmSelf . cnvMembers) <$> responseJsonEither) diff --git a/services/galley/test/integration/Main.hs b/services/galley/test/integration/Run.hs similarity index 76% rename from services/galley/test/integration/Main.hs rename to services/galley/test/integration/Run.hs index 7c211f068a3..e84b12e41ba 100644 --- a/services/galley/test/integration/Main.hs +++ b/services/galley/test/integration/Run.hs @@ -15,14 +15,15 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Main +module Run ( main, ) where import API qualified import API.SQS qualified as SQS -import Bilge hiding (body, header) +import Bilge hiding (body, header, host, port) +import Bilge qualified import Cassandra.Util import Control.Lens import Data.ByteString.Char8 qualified as BS @@ -37,7 +38,8 @@ import Data.Yaml (decodeFileEither) import Federation import Galley.API (sitemap) import Galley.Aws qualified as Aws -import Galley.Options +import Galley.Options hiding (endpoint) +import Galley.Options qualified as O import Imports hiding (local) import Network.HTTP.Client (responseTimeoutMicro) import Network.HTTP.Client.TLS (tlsManagerSettings) @@ -47,7 +49,10 @@ import Options.Applicative import System.Logger.Class qualified as Logger import Test.Tasty import Test.Tasty.HUnit +import Test.Tasty.Ingredients +import Test.Tasty.Ingredients.Basic import Test.Tasty.Options +import Test.Tasty.Runners.AntXML import TestHelpers (test) import TestSetup import Util.Options @@ -82,6 +87,8 @@ runTests run = defaultMainWithIngredients ings $ [ Option (Proxy :: Proxy ServiceConfigFile), Option (Proxy :: Proxy IntegrationConfigFile) ] + : listingTests + : composeReporters antXMLRunner consoleTestReporter : defaultIngredients main :: IO () @@ -96,45 +103,38 @@ main = withOpenSSL $ runTests go mempty (pathsConsistencyCheck . treeToPaths . compile $ Galley.API.sitemap), API.tests setup, - testGroup - "Federation Domains" - [ test setup "No-Op" updateFedDomainsTestNoop', - test setup "Add Remote" updateFedDomainsTestAddRemote', - test setup "Remove Remote From Local" updateFedDomainsTestRemoveRemoteFromLocal', - test setup "Remove Local From Remote" updateFedDomainsTestRemoveLocalFromRemote' - ], test setup "isConvMemberL" isConvMemberLTests ] getOpts gFile iFile = do m <- newManager tlsManagerSettings {managerResponseTimeout = responseTimeoutMicro 300000000} - let local p = Endpoint {_epHost = "127.0.0.1", _epPort = p} + let local p = Endpoint {_host = "127.0.0.1", _port = p} gConf <- handleParseError =<< decodeFileEither gFile iConf <- handleParseError =<< decodeFileEither iFile -- FUTUREWORK: we don't support process env setup any more, so both gconf and iConf -- must be 'Just'. the following code could be simplified a lot, but this should -- probably happen after (or at least while) unifying the integration test suites into -- a single library. - galleyEndpoint <- optOrEnv galley iConf (local . read) "GALLEY_WEB_PORT" + galleyEndpoint <- optOrEnv (.galley) iConf (local . read) "GALLEY_WEB_PORT" let g = mkRequest galleyEndpoint - b <- mkRequest <$> optOrEnv brig iConf (local . read) "BRIG_WEB_PORT" - c <- mkRequest <$> optOrEnv cannon iConf (local . read) "CANNON_WEB_PORT" + b <- mkRequest <$> optOrEnv (.brig) iConf (local . read) "BRIG_WEB_PORT" + c <- mkRequest <$> optOrEnv (.cannon) iConf (local . read) "CANNON_WEB_PORT" -- unset this env variable in galley's config to disable testing SQS team events - q <- join <$> optOrEnvSafe queueName gConf (Just . pack) "GALLEY_SQS_TEAM_EVENTS" - e <- join <$> optOrEnvSafe endpoint gConf (fromByteString . BS.pack) "GALLEY_SQS_ENDPOINT" + q <- join <$> optOrEnvSafe queueName' gConf (Just . pack) "GALLEY_SQS_TEAM_EVENTS" + e <- join <$> optOrEnvSafe endpoint' gConf (fromByteString . BS.pack) "GALLEY_SQS_ENDPOINT" convMaxSize <- optOrEnv maxSize gConf read "CONV_MAX_SIZE" awsEnv <- initAwsEnv e q -- Initialize cassandra - let ch = fromJust gConf ^. optCassandra . casEndpoint . epHost - let cp = fromJust gConf ^. optCassandra . casEndpoint . epPort - let ck = fromJust gConf ^. optCassandra . casKeyspace + let ch = fromJust gConf ^. cassandra . endpoint . host + let cp = fromJust gConf ^. cassandra . endpoint . port + let ck = fromJust gConf ^. cassandra . keyspace lg <- Logger.new Logger.defSettings db <- defInitCassandra ck ch cp lg teamEventWatcher <- sequence $ SQS.watchSQSQueue <$> ((^. Aws.awsEnv) <$> awsEnv) <*> q pure $ TestSetup (fromJust gConf) (fromJust iConf) m g b c awsEnv convMaxSize db (FedClient m galleyEndpoint) teamEventWatcher - queueName = fmap (view awsQueueName) . view optJournal - endpoint = fmap (view awsEndpoint) . view optJournal - maxSize = view (optSettings . setMaxConvSize) + queueName' = fmap (view queueName) . view journal + endpoint' = fmap (view O.endpoint) . view journal + maxSize = view (settings . maxConvSize) initAwsEnv (Just e) (Just q) = Just <$> SQS.mkAWSEnv (JournalOpts q e) initAwsEnv _ _ = pure Nothing releaseOpts _ = pure () - mkRequest (Endpoint h p) = host (encodeUtf8 h) . port p + mkRequest (Endpoint h p) = Bilge.host (encodeUtf8 h) . Bilge.port p diff --git a/services/galley/test/integration/TestHelpers.hs b/services/galley/test/integration/TestHelpers.hs index cad9342ec46..fdfbb8c309a 100644 --- a/services/galley/test/integration/TestHelpers.hs +++ b/services/galley/test/integration/TestHelpers.hs @@ -24,7 +24,7 @@ import Control.Monad.Catch (MonadMask) import Control.Retry import Data.Domain (Domain) import Data.Qualified -import Galley.Options (optSettings, setFederationDomain) +import Galley.Options (federationDomain, settings) import Imports import Test.Tasty (TestName, TestTree) import Test.Tasty.HUnit (Assertion, testCase) @@ -39,7 +39,7 @@ test s n h = testCase n runTest void . flip runReaderT setup . runTestM $ h viewFederationDomain :: TestM Domain -viewFederationDomain = view (tsGConf . optSettings . setFederationDomain) +viewFederationDomain = view (tsGConf . settings . federationDomain) qualifyLocal :: a -> TestM (Local a) qualifyLocal x = do diff --git a/services/galley/test/integration/TestSetup.hs b/services/galley/test/integration/TestSetup.hs index f3869492956..aee84829197 100644 --- a/services/galley/test/integration/TestSetup.hs +++ b/services/galley/test/integration/TestSetup.hs @@ -137,20 +137,21 @@ runFedClient :: forall (name :: Symbol) comp m api. ( HasUnsafeFedEndpoint comp api name, Servant.HasClient Servant.ClientM api, - MonadIO m + MonadIO m, + HasCallStack ) => FedClient comp -> Domain -> Servant.Client m api -runFedClient (FedClient mgr endpoint) domain = +runFedClient (FedClient mgr ep) domain = Servant.hoistClient (Proxy @api) (servantClientMToHttp domain) $ Servant.clientIn (Proxy @api) (Proxy @Servant.ClientM) where servantClientMToHttp :: Domain -> Servant.ClientM a -> m a servantClientMToHttp originDomain action = liftIO $ do - let host = Text.unpack $ endpoint ^. epHost - port = fromInteger . toInteger $ endpoint ^. epPort - baseUrl = Servant.BaseUrl Servant.Http host port "/federation" + let h = Text.unpack $ ep ^. host + p = fromInteger . toInteger $ ep ^. port + baseUrl = Servant.BaseUrl Servant.Http h p "/federation" clientEnv = Servant.ClientEnv mgr baseUrl Nothing (makeClientRequest originDomain) eitherRes <- Servant.runClientM action clientEnv case eitherRes of diff --git a/services/galley/test/unit.hs b/services/galley/test/unit.hs new file mode 100644 index 00000000000..a26473d24ee --- /dev/null +++ b/services/galley/test/unit.hs @@ -0,0 +1 @@ +import Run diff --git a/services/galley/test/unit/Main.hs b/services/galley/test/unit/Run.hs similarity index 93% rename from services/galley/test/unit/Main.hs rename to services/galley/test/unit/Run.hs index 45d4c9f398b..4f28468fadc 100644 --- a/services/galley/test/unit/Main.hs +++ b/services/galley/test/unit/Run.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Main +module Run ( main, ) where @@ -24,6 +24,7 @@ import Imports import Test.Galley.API.Action qualified import Test.Galley.API.Message qualified import Test.Galley.API.One2One qualified +import Test.Galley.Intra.Push qualified import Test.Galley.Intra.User qualified import Test.Galley.Mapping qualified import Test.Tasty @@ -36,6 +37,7 @@ main = [ Test.Galley.API.Message.tests, Test.Galley.API.One2One.tests, Test.Galley.Intra.User.tests, + Test.Galley.Intra.Push.tests, Test.Galley.Mapping.tests, Test.Galley.API.Action.tests ] diff --git a/services/galley/test/unit/Test/Galley/API/One2One.hs b/services/galley/test/unit/Test/Galley/API/One2One.hs index 40580d29146..9a93da22743 100644 --- a/services/galley/test/unit/Test/Galley/API/One2One.hs +++ b/services/galley/test/unit/Test/Galley/API/One2One.hs @@ -26,6 +26,7 @@ import Imports import Test.Tasty import Test.Tasty.HUnit (Assertion, testCase, (@?=)) import Test.Tasty.QuickCheck +import Wire.API.User tests :: TestTree tests = @@ -35,8 +36,8 @@ tests = testCase "non-collision" one2OneConvIdNonCollision ] -one2OneConvIdSymmetry :: Qualified UserId -> Qualified UserId -> Property -one2OneConvIdSymmetry quid1 quid2 = one2OneConvId quid1 quid2 === one2OneConvId quid2 quid1 +one2OneConvIdSymmetry :: BaseProtocolTag -> Qualified UserId -> Qualified UserId -> Property +one2OneConvIdSymmetry proto quid1 quid2 = one2OneConvId proto quid1 quid2 === one2OneConvId proto quid2 quid1 -- | Make sure that we never get the same conversation ID for a pair of -- (assumingly) distinct qualified user IDs @@ -46,5 +47,5 @@ one2OneConvIdNonCollision = do -- A generator of lists of length 'len' of qualified user ID pairs let gen = vectorOf len arbitrary quids <- nubOrd <$> generate gen - let hashes = nubOrd (fmap (uncurry one2OneConvId) quids) + let hashes = nubOrd (fmap (uncurry (one2OneConvId BaseProtocolProteusTag)) quids) length hashes @?= length quids diff --git a/services/galley/test/unit/Test/Galley/Intra/Push.hs b/services/galley/test/unit/Test/Galley/Intra/Push.hs new file mode 100644 index 00000000000..daf35389e63 --- /dev/null +++ b/services/galley/test/unit/Test/Galley/Intra/Push.hs @@ -0,0 +1,50 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Test.Galley.Intra.Push where + +import Data.List1 qualified as List1 +import Data.Monoid +import Galley.Intra.Push.Internal +import Imports +import Test.QuickCheck +import Test.Tasty +import Test.Tasty.QuickCheck + +normalisePush :: PushTo a -> [PushTo a] +normalisePush p = + map + (\r -> p {_pushRecipients = List1.singleton r}) + (toList (_pushRecipients p)) + +chunkSize :: [PushTo a] -> Int +chunkSize = getSum . foldMap (Sum . length . _pushRecipients) + +tests :: TestTree +tests = + testGroup + "chunkPushes" + [ testProperty "empty push" $ \(Positive limit) -> + chunkPushes limit [] === ([] :: [[PushTo ()]]), + testProperty "no empty chunk" $ \(Positive limit) (pushes :: [PushTo Int]) -> + not (any null (chunkPushes limit pushes)), + testProperty "concatenation" $ \(Positive limit) (pushes :: [PushTo Int]) -> + (chunkPushes limit pushes >>= reverse >>= normalisePush) + === (pushes >>= normalisePush), + testProperty "small chunks" $ \(Positive limit) (pushes :: [PushTo Int]) -> + all ((<= limit) . chunkSize) (chunkPushes limit pushes) + ] diff --git a/services/galley/test/unit/Test/Galley/Mapping.hs b/services/galley/test/unit/Test/Galley/Mapping.hs index fa296cc6b72..c18bb63f903 100644 --- a/services/galley/test/unit/Test/Galley/Mapping.hs +++ b/services/galley/test/unit/Test/Galley/Mapping.hs @@ -83,19 +83,19 @@ tests = testProperty "self user role in remote conversation view is correct" $ \(ConvWithRemoteUser c ruid) dom -> qDomain (tUntagged ruid) /= dom ==> - fmap (rcmSelfRole . rcnvMembers) (conversationToRemote dom ruid c) + fmap (selfRole . members) (conversationToRemote dom ruid c) == Just roleNameWireMember, testProperty "remote conversation view metadata is correct" $ \(ConvWithRemoteUser c ruid) dom -> qDomain (tUntagged ruid) /= dom ==> - fmap rcnvMetadata (conversationToRemote dom ruid c) + fmap (.metadata) (conversationToRemote dom ruid c) == Just (Data.convMetadata c), testProperty "remote conversation view does not contain self" $ \(ConvWithRemoteUser c ruid) dom -> case conversationToRemote dom ruid c of Nothing -> False Just rcnv -> tUntagged ruid - `notElem` map omQualifiedId (rcmOthers (rcnvMembers rcnv)) + `notElem` map omQualifiedId rcnv.members.others ] cnvUids :: Conversation -> [Qualified UserId] diff --git a/services/gundeck/default.nix b/services/gundeck/default.nix index 624a143c7ed..ddf49dc9faa 100644 --- a/services/gundeck/default.nix +++ b/services/gundeck/default.nix @@ -59,6 +59,7 @@ , servant-server , tagged , tasty +, tasty-ant-xml , tasty-hunit , tasty-quickcheck , text @@ -119,6 +120,7 @@ mkDerivation { mtl network-uri psqueues + raw-strings-qq resourcet retry safe-exceptions @@ -152,7 +154,6 @@ mkDerivation { cassandra-util containers exceptions - extended gundeck-types HsOpenSSL http-client @@ -166,11 +167,11 @@ mkDerivation { network-uri optparse-applicative random - raw-strings-qq retry safe tagged tasty + tasty-ant-xml tasty-hunit text tinylog diff --git a/services/gundeck/gundeck.cabal b/services/gundeck/gundeck.cabal index 1327735f990..277c81fea78 100644 --- a/services/gundeck/gundeck.cabal +++ b/services/gundeck/gundeck.cabal @@ -16,6 +16,7 @@ flag static default: False library + -- cabal-fmt: expand src exposed-modules: Gundeck.API Gundeck.API.Internal @@ -42,6 +43,17 @@ library Gundeck.Redis Gundeck.Redis.HedisExtensions Gundeck.Run + Gundeck.Schema.Run + Gundeck.Schema.V1 + Gundeck.Schema.V10 + Gundeck.Schema.V2 + Gundeck.Schema.V3 + Gundeck.Schema.V4 + Gundeck.Schema.V5 + Gundeck.Schema.V6 + Gundeck.Schema.V7 + Gundeck.Schema.V8 + Gundeck.Schema.V9 Gundeck.ThreadBudget Gundeck.ThreadBudget.Internal Gundeck.Util @@ -130,6 +142,7 @@ library , mtl >=2.2 , network-uri >=2.6 , psqueues >=0.2.2 + , raw-strings-qq , resourcet >=1.1 , retry >=0.5 , safe-exceptions @@ -303,6 +316,7 @@ executable gundeck-integration , safe , tagged , tasty >=1.0 + , tasty-ant-xml , tasty-hunit >=0.9 , text , tinylog @@ -317,20 +331,7 @@ executable gundeck-integration executable gundeck-schema main-is: Main.hs - other-modules: - Paths_gundeck - V1 - V10 - V2 - V3 - V4 - V5 - V6 - V7 - V8 - V9 - - hs-source-dirs: schema/src + hs-source-dirs: schema default-extensions: NoImplicitPrelude AllowAmbiguousTypes @@ -379,12 +380,8 @@ executable gundeck-schema -threaded -Wredundant-constraints -Wunused-packages build-depends: - base - , cassandra-util - , extended + gundeck , imports - , raw-strings-qq - , types-common if flag(static) ld-options: -static diff --git a/services/gundeck/schema/Main.hs b/services/gundeck/schema/Main.hs new file mode 100644 index 00000000000..bf76183bd5e --- /dev/null +++ b/services/gundeck/schema/Main.hs @@ -0,0 +1,24 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Main where + +import Gundeck.Schema.Run qualified as Run +import Imports + +main :: IO () +main = Run.main diff --git a/services/gundeck/src/Gundeck/API/Public.hs b/services/gundeck/src/Gundeck/API/Public.hs index e2034b3d62e..b74b8e00f44 100644 --- a/services/gundeck/src/Gundeck/API/Public.hs +++ b/services/gundeck/src/Gundeck/API/Public.hs @@ -31,7 +31,7 @@ import Gundeck.Push qualified as Push import Imports import Servant (HasServer (..), (:<|>) (..)) import Wire.API.Notification qualified as Public -import Wire.API.Routes.Named (Named (Named)) +import Wire.API.Routes.Named (UntypedNamed (Named)) import Wire.API.Routes.Public.Gundeck ------------------------------------------------------------------------------- diff --git a/services/gundeck/src/Gundeck/Aws.hs b/services/gundeck/src/Gundeck/Aws.hs index be9caa56973..ab8dd27c69c 100644 --- a/services/gundeck/src/Gundeck/Aws.hs +++ b/services/gundeck/src/Gundeck/Aws.hs @@ -24,6 +24,8 @@ module Gundeck.Aws mkEnv, Amazon, execute, + Gundeck.Aws.region, + Gundeck.Aws.account, -- * Errors Error (..), @@ -79,7 +81,8 @@ import Data.Text.Lazy qualified as LT import Gundeck.Aws.Arn import Gundeck.Aws.Sns import Gundeck.Instances () -import Gundeck.Options +import Gundeck.Options (Opts) +import Gundeck.Options qualified as O import Gundeck.Types.Push hiding (token) import Gundeck.Types.Push qualified as Push import Imports @@ -152,10 +155,10 @@ mkEnv lgr opts mgr = do e <- mkAwsEnv g - (mkEndpoint SQS.defaultService (opts ^. optAws . awsSqsEndpoint)) - (mkEndpoint SNS.defaultService (opts ^. optAws . awsSnsEndpoint)) - q <- getQueueUrl e (opts ^. optAws . awsQueueName) - pure (Env e g q (opts ^. optAws . awsRegion) (opts ^. optAws . awsAccount)) + (mkEndpoint SQS.defaultService (opts ^. O.aws . O.sqsEndpoint)) + (mkEndpoint SNS.defaultService (opts ^. O.aws . O.snsEndpoint)) + q <- getQueueUrl e (opts ^. O.aws . O.queueName) + pure (Env e g q (opts ^. O.aws . O.region) (opts ^. O.aws . O.account)) where mkEndpoint svc e = AWS.setEndpoint (e ^. awsSecure) (e ^. awsHost) (e ^. awsPort) svc mkAwsEnv g sqs sns = do @@ -166,7 +169,7 @@ mkEnv lgr opts mgr = do pure $ baseEnv { AWS.logger = awsLogger g, - AWS.region = opts ^. optAws . awsRegion, + AWS.region = opts ^. O.aws . O.region, AWS.retryCheck = retryCheck, AWS.manager = mgr } @@ -291,7 +294,7 @@ createEndpoint :: UserId -> Push.Transport -> ArnEnv -> AppName -> Push.Token -> createEndpoint u tr arnEnv app token = do env <- ask let top = mkAppTopic arnEnv tr app - let arn = mkSnsArn (env ^. region) (env ^. account) top + let arn = mkSnsArn env._region env._account top let tkn = Push.tokenText token let req = SNS.newCreatePlatformEndpoint (toText arn) tkn diff --git a/services/gundeck/src/Gundeck/Env.hs b/services/gundeck/src/Gundeck/Env.hs index bc2ee1d2676..f6e205e74e5 100644 --- a/services/gundeck/src/Gundeck/Env.hs +++ b/services/gundeck/src/Gundeck/Env.hs @@ -19,7 +19,7 @@ module Gundeck.Env where -import Bilge +import Bilge hiding (host, port) import Cassandra (ClientState, Keyspace (..)) import Cassandra qualified as C import Cassandra.Settings qualified as C @@ -36,7 +36,8 @@ import Data.Time.Clock import Data.Time.Clock.POSIX import Database.Redis qualified as Redis import Gundeck.Aws qualified as Aws -import Gundeck.Options as Opt +import Gundeck.Options as Opt hiding (host, port) +import Gundeck.Options qualified as O import Gundeck.Redis qualified as Redis import Gundeck.Redis.HedisExtensions qualified as Redis import Gundeck.ThreadBudget @@ -68,23 +69,23 @@ schemaVersion = 7 createEnv :: Metrics -> Opts -> IO ([Async ()], Env) createEnv m o = do - l <- Logger.mkLogger (o ^. optLogLevel) (o ^. optLogNetStrings) (o ^. optLogFormat) + l <- Logger.mkLogger (o ^. logLevel) (o ^. logNetStrings) (o ^. logFormat) c <- maybe - (C.initialContactsPlain (o ^. optCassandra . casEndpoint . epHost)) + (C.initialContactsPlain (o ^. cassandra . endpoint . host)) (C.initialContactsDisco "cassandra_gundeck" . unpack) - (o ^. optDiscoUrl) + (o ^. discoUrl) n <- newManager tlsManagerSettings - { managerConnCount = o ^. optSettings . setHttpPoolSize, - managerIdleConnectionCount = 3 * (o ^. optSettings . setHttpPoolSize), + { managerConnCount = o ^. settings . httpPoolSize, + managerIdleConnectionCount = 3 * (o ^. settings . httpPoolSize), managerResponseTimeout = responseTimeoutMicro 5000000 } - (rThread, r) <- createRedisPool l (o ^. optRedis) "main-redis" + (rThread, r) <- createRedisPool l (o ^. redis) "main-redis" - (rAdditionalThreads, rAdditional) <- case o ^. optRedisAdditionalWrite of + (rAdditionalThreads, rAdditional) <- case o ^. redisAdditionalWrite of Nothing -> pure ([], Nothing) Just additionalRedis -> do (rAddThread, rAdd) <- createRedisPool l additionalRedis "additional-write-redis" @@ -94,15 +95,15 @@ createEnv m o = do C.init $ C.setLogger (C.mkLogger (Logger.clone (Just "cassandra.gundeck") l)) . C.setContacts (NE.head c) (NE.tail c) - . C.setPortNumber (fromIntegral $ o ^. optCassandra . casEndpoint . epPort) - . C.setKeyspace (Keyspace (o ^. optCassandra . casKeyspace)) + . C.setPortNumber (fromIntegral $ o ^. cassandra . endpoint . port) + . C.setKeyspace (Keyspace (o ^. cassandra . keyspace)) . C.setMaxConnections 4 . C.setMaxStreams 128 . C.setPoolStripes 4 . C.setSendTimeout 3 . C.setResponseTimeout 10 . C.setProtocolVersion C.V4 - . C.setPolicy (C.dcFilterPolicyIfConfigured l (o ^. optCassandra . casFilterNodesByDatacentre)) + . C.setPolicy (C.dcFilterPolicyIfConfigured l (o ^. cassandra . filterNodesByDatacentre)) $ C.defSettings a <- Aws.mkEnv l o n io <- @@ -110,7 +111,7 @@ createEnv m o = do defaultUpdateSettings { updateAction = Ms . round . (* 1000) <$> getPOSIXTime } - mtbs <- mkThreadBudgetState `mapM` (o ^. optSettings . setMaxConcurrentNativePushes) + mtbs <- mkThreadBudgetState `mapM` (o ^. settings . maxConcurrentNativePushes) pure $! (rThread : rAdditionalThreads,) $! Env def m o l n p r rAdditional a io mtbs reqIdMsg :: RequestId -> Logger.Msg -> Logger.Msg @@ -118,21 +119,21 @@ reqIdMsg = ("request" Logger..=) . unRequestId {-# INLINE reqIdMsg #-} createRedisPool :: Logger.Logger -> RedisEndpoint -> ByteString -> IO (Async (), Redis.RobustConnection) -createRedisPool l endpoint identifier = do +createRedisPool l ep identifier = do let redisConnInfo = Redis.defaultConnectInfo - { Redis.connectHost = unpack $ endpoint ^. rHost, - Redis.connectPort = Redis.PortNumber (fromIntegral $ endpoint ^. rPort), + { Redis.connectHost = unpack $ ep ^. O.host, + Redis.connectPort = Redis.PortNumber (fromIntegral $ ep ^. O.port), Redis.connectTimeout = Just (secondsToNominalDiffTime 5), Redis.connectMaxConnections = 100 } Log.info l $ Log.msg (Log.val $ "starting connection to " <> identifier <> "...") - . Log.field "connectionMode" (show $ endpoint ^. rConnectionMode) + . Log.field "connectionMode" (show $ ep ^. O.connectionMode) . Log.field "connInfo" (show redisConnInfo) let connectWithRetry = Redis.connectRobust l (capDelay 1000000 (exponentialBackoff 50000)) - r <- case endpoint ^. rConnectionMode of + r <- case ep ^. O.connectionMode of Master -> connectWithRetry $ Redis.checkedConnect redisConnInfo Cluster -> connectWithRetry $ Redis.checkedConnectCluster redisConnInfo Log.info l $ Log.msg (Log.val $ "Established connection to " <> identifier <> ".") diff --git a/services/gundeck/src/Gundeck/Monad.hs b/services/gundeck/src/Gundeck/Monad.hs index 4ef03216436..7995b71b17f 100644 --- a/services/gundeck/src/Gundeck/Monad.hs +++ b/services/gundeck/src/Gundeck/Monad.hs @@ -45,9 +45,10 @@ where import Bilge hiding (Request, header, options, statusCode) import Bilge.RPC import Cassandra -import Control.Error hiding (err) +import Control.Concurrent.Async (AsyncCancelled) +import Control.Error import Control.Exception (throwIO) -import Control.Lens hiding ((.=)) +import Control.Lens import Control.Monad.Catch hiding (tryJust) import Data.Aeson (FromJSON) import Data.Default (def) @@ -61,7 +62,7 @@ import Network.Wai import Network.Wai.Utilities import System.Logger qualified as Log import System.Logger qualified as Logger -import System.Logger.Class hiding (Error, info) +import System.Logger.Class import UnliftIO (async) -- | TODO: 'Client' already has an 'Env'. Why do we need two? How does this even work? We should @@ -170,10 +171,13 @@ runDirect :: Env -> Gundeck a -> IO a runDirect e m = runClient (e ^. cstate) (runReaderT (unGundeck m) e) `catch` ( \(exception :: SomeException) -> do - Log.err (e ^. applog) $ - Log.msg ("IO Exception occurred" :: ByteString) - . Log.field "message" (displayException exception) - . Log.field "request" (unRequestId (e ^. reqId)) + case fromException exception of + Nothing -> + Log.err (e ^. applog) $ + Log.msg ("IO Exception occurred" :: ByteString) + . Log.field "message" (displayException exception) + . Log.field "request" (unRequestId (e ^. reqId)) + Just (_ :: AsyncCancelled) -> pure () throwIO exception ) diff --git a/services/gundeck/src/Gundeck/Notification.hs b/services/gundeck/src/Gundeck/Notification.hs index 92136f7bec2..615ed79c29d 100644 --- a/services/gundeck/src/Gundeck/Notification.hs +++ b/services/gundeck/src/Gundeck/Notification.hs @@ -25,6 +25,8 @@ import Bilge.IO hiding (options) import Bilge.Request import Bilge.Response import Control.Lens (view) +import Control.Monad.Catch +import Control.Monad.Except import Data.ByteString.Conversion import Data.Id import Data.Misc (Milliseconds (..)) @@ -33,12 +35,15 @@ import Data.Time.Clock.POSIX import Data.UUID qualified as UUID import Gundeck.Monad import Gundeck.Notification.Data qualified as Data -import Gundeck.Options +import Gundeck.Options hiding (host, port) import Imports hiding (getLast) +import Network.HTTP.Types hiding (statusCode) +import Network.Wai.Utilities.Error import System.Logger.Class import System.Logger.Class qualified as Log -import Util.Options +import Util.Options hiding (host, port) import Wire.API.Internal.Notification +import Wire.API.Notification data PaginateResult = PaginateResult { paginateResultGap :: Bool, @@ -47,6 +52,7 @@ data PaginateResult = PaginateResult paginate :: UserId -> Maybe NotificationId -> Maybe ClientId -> Range 100 10000 Int32 -> Gundeck PaginateResult paginate uid since mclt size = do + traverse_ validateNotificationId since for_ mclt $ \clt -> updateActivity uid clt time <- posixTime @@ -60,11 +66,16 @@ paginate uid since mclt size = do (Just (millisToUTC time)) millisToUTC = posixSecondsToUTCTime . fromIntegral . (`div` 1000) . ms + validateNotificationId :: NotificationId -> Gundeck () + validateNotificationId n = + unless (isValidNotificationId n) $ + throwM (mkError status400 "bad-request" "Invalid Notification ID") + -- | Update last_active property of the given client by making a request to brig. updateActivity :: UserId -> ClientId -> Gundeck () updateActivity uid clt = do r <- do - Endpoint h p <- view $ options . optBrig + Endpoint h p <- view $ options . brig post ( host (toByteString' h) . port p diff --git a/services/gundeck/src/Gundeck/Notification/Data.hs b/services/gundeck/src/Gundeck/Notification/Data.hs index f8bbe165b78..d680e68f2c4 100644 --- a/services/gundeck/src/Gundeck/Notification/Data.hs +++ b/services/gundeck/src/Gundeck/Notification/Data.hs @@ -37,7 +37,7 @@ import Data.Range (Range, fromRange) import Data.Sequence (Seq, ViewL ((:<))) import Data.Sequence qualified as Seq import Gundeck.Env -import Gundeck.Options (NotificationTTL (..), optSettings, setInternalPageSize, setMaxPayloadLoadSize) +import Gundeck.Options (NotificationTTL (..), internalPageSize, maxPayloadLoadSize, settings) import Gundeck.Push.Native.Serialise () import Imports hiding (cs) import UnliftIO (pooledForConcurrentlyN_) @@ -119,7 +119,7 @@ fetchId u n c = runMaybeT $ do fetchLast :: (MonadReader Env m, MonadClient m) => UserId -> Maybe ClientId -> m (Maybe QueuedNotification) fetchLast u c = do - pageSize <- fromMaybe 100 <$> asks (^. options . optSettings . setInternalPageSize) + pageSize <- fromMaybe 100 <$> asks (^. options . settings . internalPageSize) go (Page True [] (firstPage pageSize)) where go page = case result page of @@ -219,12 +219,12 @@ mkResultPage size more ns = fetch :: (MonadReader Env m, MonadClient m, MonadUnliftIO m) => UserId -> Maybe ClientId -> Maybe NotificationId -> Range 100 10000 Int32 -> m ResultPage fetch u c Nothing (fromIntegral . fromRange -> size) = do - pageSize <- fromMaybe 100 <$> asks (^. options . optSettings . setInternalPageSize) + pageSize <- fromMaybe 100 <$> asks (^. options . settings . internalPageSize) let page1 = retry x1 $ paginate cqlStart (paramsP LocalQuorum (Identity u) pageSize) -- We always need to look for one more than requested in order to correctly -- report whether there are more results. - maxPayloadLoadSize <- fromMaybe (5 * 1024 * 1024) <$> asks (^. options . optSettings . setMaxPayloadLoadSize) - (ns, more) <- collect c Seq.empty True (size + 1) maxPayloadLoadSize page1 + maxPayloadSize <- fromMaybe (5 * 1024 * 1024) <$> asks (^. options . settings . maxPayloadLoadSize) + (ns, more) <- collect c Seq.empty True (size + 1) maxPayloadSize page1 -- Drop the extra element at the end if present pure $! mkResultPage size more ns where @@ -235,7 +235,7 @@ fetch u c Nothing (fromIntegral . fromRange -> size) = do \WHERE user = ? \ \ORDER BY id ASC" fetch u c (Just since) (fromIntegral . fromRange -> size) = do - pageSize <- fromMaybe 100 <$> asks (^. options . optSettings . setInternalPageSize) + pageSize <- fromMaybe 100 <$> asks (^. options . settings . internalPageSize) let page1 = retry x1 $ paginate cqlSince (paramsP LocalQuorum (u, TimeUuid (toUUID since)) pageSize) @@ -243,8 +243,8 @@ fetch u c (Just since) (fromIntegral . fromRange -> size) = do -- notification corresponding to the `since` argument itself. The second is -- to get an accurate `hasMore`, just like in the case above. - maxPayloadLoadSize <- fromMaybe (5 * 1024 * 1024) <$> asks (^. options . optSettings . setMaxPayloadLoadSize) - (ns, more) <- collect c Seq.empty True (size + 2) maxPayloadLoadSize page1 + maxPayloadSize <- fromMaybe (5 * 1024 * 1024) <$> asks (^. options . settings . maxPayloadLoadSize) + (ns, more) <- collect c Seq.empty True (size + 2) maxPayloadSize page1 -- Remove notification corresponding to the `since` argument, and record if it is found. let (ns', sinceFound) = case Seq.viewl ns of x :< xs | since == x ^. queuedNotificationId -> (xs, True) diff --git a/services/gundeck/src/Gundeck/Options.hs b/services/gundeck/src/Gundeck/Options.hs index ec90c651c56..ac484ec8331 100644 --- a/services/gundeck/src/Gundeck/Options.hs +++ b/services/gundeck/src/Gundeck/Options.hs @@ -36,15 +36,15 @@ newtype NotificationTTL = NotificationTTL data AWSOpts = AWSOpts { -- | AWS account - _awsAccount :: !Account, + _account :: !Account, -- | AWS region name - _awsRegion :: !Region, + _region :: !Region, -- | Environment name to scope ARNs to - _awsArnEnv :: !ArnEnv, + _arnEnv :: !ArnEnv, -- | SQS queue name - _awsQueueName :: !Text, - _awsSqsEndpoint :: !AWSEndpoint, - _awsSnsEndpoint :: !AWSEndpoint + _queueName :: !Text, + _sqsEndpoint :: !AWSEndpoint, + _snsEndpoint :: !AWSEndpoint } deriving (Show, Generic) @@ -54,18 +54,18 @@ makeLenses ''AWSOpts data Settings = Settings { -- | Number of connections to keep open in the http-client pool - _setHttpPoolSize :: !Int, + _httpPoolSize :: !Int, -- | TTL (seconds) of stored notifications - _setNotificationTTL :: !NotificationTTL, + _notificationTTL :: !NotificationTTL, -- | Use this option to group push notifications and send them in bulk to Cannon, instead -- of in individual requests - _setBulkPush :: !Bool, + _bulkPush :: !Bool, -- | Maximum number of concurrent threads calling SNS. - _setMaxConcurrentNativePushes :: !(Maybe MaxConcurrentNativePushes), + _maxConcurrentNativePushes :: !(Maybe MaxConcurrentNativePushes), -- | Maximum number of parallel requests to SNS and cassandra -- during native push processing (per incoming push request) -- defaults to unbounded, if unset. - _setPerNativePushConcurrency :: !(Maybe Int), + _perNativePushConcurrency :: !(Maybe Int), -- | The amount of time in milliseconds to wait after reading from an SQS queue -- returns no message, before asking for messages from SQS again. -- defaults to 'defSqsThrottleMillis'. @@ -74,25 +74,25 @@ data Settings = Settings -- ensures that there is only one request every 20 seconds. -- However, that parameter is not honoured when using fake-sqs -- (where throttling can thus make sense) - _setSqsThrottleMillis :: !(Maybe Int), - _setDisabledAPIVersions :: !(Maybe (Set Version)), + _sqsThrottleMillis :: !(Maybe Int), + _disabledAPIVersions :: !(Maybe (Set Version)), -- | Maximum number of bytes loaded into memory when fetching (referenced) payloads. -- Gundeck will return a truncated page if the whole page's payload sizes would exceed this limit in total. -- Inlined payloads can cause greater payload sizes to be loaded into memory regardless of this setting. - _setMaxPayloadLoadSize :: Maybe Int32, + _maxPayloadLoadSize :: Maybe Int32, -- | Cassandra page size for fetching notifications. Does not directly -- effect the page size request in the client API. A lower number will -- reduce the amount by which setMaxPayloadLoadSize is exceeded when loading -- notifications from the database if notifications have inlined payloads. - _setInternalPageSize :: Maybe Int32 + _internalPageSize :: Maybe Int32 } deriving (Show, Generic) data MaxConcurrentNativePushes = MaxConcurrentNativePushes { -- | more than this number of threads will not be allowed - _limitHard :: !(Maybe Int), + _hard :: !(Maybe Int), -- | more than this number of threads will be warned about - _limitSoft :: !(Maybe Int) + _soft :: !(Maybe Int) } deriving (Show, Generic) @@ -108,9 +108,9 @@ data RedisConnectionMode deriveJSON defaultOptions {constructorTagModifier = map toLower} ''RedisConnectionMode data RedisEndpoint = RedisEndpoint - { _rHost :: !Text, - _rPort :: !Word16, - _rConnectionMode :: !RedisConnectionMode + { _host :: !Text, + _port :: !Word16, + _connectionMode :: !RedisConnectionMode } deriving (Show, Generic) @@ -124,22 +124,22 @@ deriveFromJSON toOptionFieldName ''Settings data Opts = Opts { -- | Hostname and port to bind to - _optGundeck :: !Endpoint, - _optBrig :: !Endpoint, - _optCassandra :: !CassandraOpts, - _optRedis :: !RedisEndpoint, - _optRedisAdditionalWrite :: !(Maybe RedisEndpoint), - _optAws :: !AWSOpts, - _optDiscoUrl :: !(Maybe Text), - _optSettings :: !Settings, + _gundeck :: !Endpoint, + _brig :: !Endpoint, + _cassandra :: !CassandraOpts, + _redis :: !RedisEndpoint, + _redisAdditionalWrite :: !(Maybe RedisEndpoint), + _aws :: !AWSOpts, + _discoUrl :: !(Maybe Text), + _settings :: !Settings, -- Logging -- | Log level (Debug, Info, etc) - _optLogLevel :: !Level, + _logLevel :: !Level, -- | Use netstrings encoding: -- - _optLogNetStrings :: !(Maybe (Last Bool)), - _optLogFormat :: !(Maybe (Last LogFormat)) + _logNetStrings :: !(Maybe (Last Bool)), + _logFormat :: !(Maybe (Last LogFormat)) } deriving (Show, Generic) diff --git a/services/gundeck/src/Gundeck/Push.hs b/services/gundeck/src/Gundeck/Push.hs index 5b282837ecd..02c6984873b 100644 --- a/services/gundeck/src/Gundeck/Push.hs +++ b/services/gundeck/src/Gundeck/Push.hs @@ -72,7 +72,7 @@ import Wire.API.Push.Token qualified as Public push :: [Push] -> Gundeck () push ps = do - bulk :: Bool <- view (options . optSettings . setBulkPush) + bulk :: Bool <- view (options . settings . bulkPush) rs <- if bulk then (Right <$> pushAll ps) `catch` (pure . Left . Seq.singleton) @@ -95,7 +95,7 @@ class MonadThrow m => MonadPushAll m where mpaRunWithBudget :: Int -> a -> m a -> m a instance MonadPushAll Gundeck where - mpaNotificationTTL = view (options . optSettings . setNotificationTTL) + mpaNotificationTTL = view (options . settings . notificationTTL) mpaMkNotificationId = mkNotificationId mpaListAllPresences = runWithDefaultRedis . Presence.listAll mpaBulkPush = Web.bulkPush @@ -126,7 +126,7 @@ class Monad m => MonadMapAsync m where mntgtPerPushConcurrency :: m (Maybe Int) instance MonadMapAsync Gundeck where - mntgtPerPushConcurrency = view (options . optSettings . setPerNativePushConcurrency) + mntgtPerPushConcurrency = view (options . settings . perNativePushConcurrency) mntgtMapAsync f l = do perPushConcurrency <- mntgtPerPushConcurrency case perPushConcurrency of @@ -451,9 +451,9 @@ addToken uid cid newtok = mpaRunWithBudget 1 (Left Public.AddTokenErrorNoBudget) let trp = t ^. tokenTransport let app = t ^. tokenApp let tok = t ^. token - env <- view (options . optAws . awsArnEnv) - aws <- view awsEnv - ept <- Aws.execute aws (Aws.createEndpoint uid trp env app tok) + env <- view (options . aws . arnEnv) + aws' <- view awsEnv + ept <- Aws.execute aws' (Aws.createEndpoint uid trp env app tok) case ept of Left (Aws.EndpointInUse arn) -> do Log.info $ "arn" .= toText arn ~~ msg (val "ARN in use") @@ -483,8 +483,8 @@ addToken uid cid newtok = mpaRunWithBudget 1 (Left Public.AddTokenErrorNoBudget) when (n >= 3) $ do Log.err $ msg (val "AWS SNS inconsistency w.r.t. " +++ toText arn) throwM (mkError status500 "server-error" "Server Error") - aws <- view awsEnv - ept <- Aws.execute aws (Aws.lookupEndpoint arn) + aws' <- view awsEnv + ept <- Aws.execute aws' (Aws.lookupEndpoint arn) case ept of Nothing -> create (n + 1) t Just ep -> diff --git a/services/gundeck/src/Gundeck/Push/Data.hs b/services/gundeck/src/Gundeck/Push/Data.hs index f00ddc19037..c688f64f4db 100644 --- a/services/gundeck/src/Gundeck/Push/Data.hs +++ b/services/gundeck/src/Gundeck/Push/Data.hs @@ -52,7 +52,7 @@ updateArn :: MonadClient m => UserId -> Transport -> AppName -> Token -> Endpoin updateArn uid transport app token arn = retry x5 $ write q (params LocalQuorum (arn, uid, transport, app, token)) where q :: PrepQuery W (EndpointArn, UserId, Transport, AppName, Token) () - q = "update user_push set arn = ? where usr = ? and transport = ? and app = ? and ptoken = ?" + q = {- `IF EXISTS`, but that requires benchmarking -} "update user_push set arn = ? where usr = ? and transport = ? and app = ? and ptoken = ?" delete :: MonadClient m => UserId -> Transport -> AppName -> Token -> m () delete u t a p = retry x5 $ write q (params LocalQuorum (u, t, a, p)) diff --git a/services/gundeck/src/Gundeck/Push/Native.hs b/services/gundeck/src/Gundeck/Push/Native.hs index 7156463f283..752351340d4 100644 --- a/services/gundeck/src/Gundeck/Push/Native.hs +++ b/services/gundeck/src/Gundeck/Push/Native.hs @@ -52,7 +52,7 @@ push :: NativePush -> [Address] -> Gundeck () push _ [] = pure () push m [a] = push1 m a push m addrs = do - perPushConcurrency <- view (options . optSettings . setPerNativePushConcurrency) + perPushConcurrency <- view (options . settings . perNativePushConcurrency) case perPushConcurrency of -- send all at once Nothing -> void $ mapConcurrently (push1 m) addrs @@ -123,9 +123,9 @@ push1 = push1' 0 let trp = t ^. tokenTransport let app = t ^. tokenApp let tok = t ^. token - env <- view (options . optAws . awsArnEnv) - aws <- view awsEnv - ept <- Aws.execute aws (Aws.createEndpoint uid trp env app tok) + env <- view (options . aws . arnEnv) + aws' <- view awsEnv + ept <- Aws.execute aws' (Aws.createEndpoint uid trp env app tok) case ept of Left (Aws.EndpointInUse arn) -> Log.info $ "arn" .= toText arn ~~ msg (val "ARN in use") @@ -157,7 +157,7 @@ push1 = push1' 0 let r = singleton (target (a ^. addrUser) & targetClients .~ [c]) let t = a ^. addrPushToken let p = singletonPayload (PushRemove t) - Stream.add i r p =<< view (options . optSettings . setNotificationTTL) + Stream.add i r p =<< view (options . settings . notificationTTL) publish :: NativePush -> Address -> Aws.Amazon Result publish m a = flip catches pushException $ do @@ -194,7 +194,7 @@ publish m a = flip catches pushException $ do -- migrated to the token and endpoint of the new address. deleteTokens :: [Address] -> Maybe Address -> Gundeck () deleteTokens tokens new = do - aws <- view awsEnv + aws' <- view awsEnv forM_ tokens $ \a -> do Log.info $ field "user" (UUID.toASCIIBytes (toUUID (a ^. addrUser))) @@ -202,30 +202,30 @@ deleteTokens tokens new = do ~~ field "arn" (toText (a ^. addrEndpoint)) ~~ msg (val "Deleting push token") Data.delete (a ^. addrUser) (a ^. addrTransport) (a ^. addrApp) (a ^. addrToken) - ept <- Aws.execute aws (Aws.lookupEndpoint (a ^. addrEndpoint)) + ept <- Aws.execute aws' (Aws.lookupEndpoint (a ^. addrEndpoint)) for_ ept $ \ep -> let us = Set.delete (a ^. addrUser) (ep ^. Aws.endpointUsers) in if Set.null us - then delete aws a + then delete aws' a else case new of - Nothing -> update aws a us + Nothing -> update aws' a us Just a' -> do mapM_ (migrate a a') us - update aws a' (ep ^. Aws.endpointUsers) - delete aws a + update aws' a' (ep ^. Aws.endpointUsers) + delete aws' a where - delete aws a = do + delete aws' a = do Log.info $ field "user" (UUID.toASCIIBytes (toUUID (a ^. addrUser))) ~~ field "arn" (toText (a ^. addrEndpoint)) ~~ msg (val "Deleting SNS endpoint") - Aws.execute aws (Aws.deleteEndpoint (a ^. addrEndpoint)) - update aws a us = do + Aws.execute aws' (Aws.deleteEndpoint (a ^. addrEndpoint)) + update aws' a us = do Log.info $ field "user" (UUID.toASCIIBytes (toUUID (a ^. addrUser))) ~~ field "arn" (toText (a ^. addrEndpoint)) ~~ msg (val "Updating SNS endpoint") - Aws.execute aws (Aws.updateEndpoint us (a ^. addrToken) (a ^. addrEndpoint)) + Aws.execute aws' (Aws.updateEndpoint us (a ^. addrToken) (a ^. addrEndpoint)) migrate a a' u = do let oldArn = a ^. addrEndpoint let oldTok = a ^. addrToken diff --git a/services/gundeck/src/Gundeck/React.hs b/services/gundeck/src/Gundeck/React.hs index ac34e68e987..d97f4312ccc 100644 --- a/services/gundeck/src/Gundeck/React.hs +++ b/services/gundeck/src/Gundeck/React.hs @@ -37,7 +37,7 @@ import Gundeck.Env import Gundeck.Instances () import Gundeck.Monad import Gundeck.Notification.Data qualified as Stream -import Gundeck.Options (optSettings, setNotificationTTL) +import Gundeck.Options (notificationTTL, settings) import Gundeck.Push.Data qualified as Push import Gundeck.Push.Native.Types import Gundeck.Push.Websocket qualified as Web @@ -162,7 +162,7 @@ deleteToken u ev tk cl = do n = Notification i False p r = singleton (target u & targetClients .~ [cl]) void $ Web.push n r (Just u) Nothing Set.empty - Stream.add i r p =<< view (options . optSettings . setNotificationTTL) + Stream.add i r p =<< view (options . settings . notificationTTL) Push.delete u (t ^. tokenTransport) (t ^. tokenApp) tk mkPushToken :: Event -> Token -> ClientId -> PushToken diff --git a/services/gundeck/src/Gundeck/Run.hs b/services/gundeck/src/Gundeck/Run.hs index 8db0f115f12..3f7c9d1f6f4 100644 --- a/services/gundeck/src/Gundeck/Run.hs +++ b/services/gundeck/src/Gundeck/Run.hs @@ -39,7 +39,7 @@ import Gundeck.Aws qualified as Aws import Gundeck.Env import Gundeck.Env qualified as Env import Gundeck.Monad -import Gundeck.Options +import Gundeck.Options hiding (host, port) import Gundeck.React import Gundeck.ThreadBudget import Imports hiding (head) @@ -62,8 +62,8 @@ run o = do runClient (e ^. cstate) $ versionCheck schemaVersion let l = e ^. applog - s <- newSettings $ defaultServer (unpack $ o ^. optGundeck . epHost) (o ^. optGundeck . epPort) l m - let throttleMillis = fromMaybe defSqsThrottleMillis $ o ^. (optSettings . setSqsThrottleMillis) + s <- newSettings $ defaultServer (unpack $ o ^. gundeck . host) (o ^. gundeck . port) l m + let throttleMillis = fromMaybe defSqsThrottleMillis $ o ^. (settings . sqsThrottleMillis) lst <- Async.async $ Aws.execute (e ^. awsEnv) (Aws.listen throttleMillis (runDirect e . onEvent)) wtbs <- forM (e ^. threadBudgetState) $ \tbs -> Async.async $ runDirect e $ watchThreadBudgetState m tbs 10 @@ -81,7 +81,7 @@ run o = do where middleware :: Env -> Wai.Middleware middleware e = - versionMiddleware (fold (o ^. optSettings . setDisabledAPIVersions)) + versionMiddleware (fold (o ^. settings . disabledAPIVersions)) . waiPrometheusMiddleware sitemap . GZip.gunzip . GZip.gzip GZip.def diff --git a/services/gundeck/schema/src/Main.hs b/services/gundeck/src/Gundeck/Schema/Run.hs similarity index 61% rename from services/gundeck/schema/src/Main.hs rename to services/gundeck/src/Gundeck/Schema/Run.hs index 56ef98f095b..056363c2445 100644 --- a/services/gundeck/schema/src/Main.hs +++ b/services/gundeck/src/Gundeck/Schema/Run.hs @@ -15,23 +15,23 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module Main where +module Gundeck.Schema.Run where import Cassandra.Schema import Control.Exception (finally) +import Gundeck.Schema.V1 qualified as V1 +import Gundeck.Schema.V10 qualified as V10 +import Gundeck.Schema.V2 qualified as V2 +import Gundeck.Schema.V3 qualified as V3 +import Gundeck.Schema.V4 qualified as V4 +import Gundeck.Schema.V5 qualified as V5 +import Gundeck.Schema.V6 qualified as V6 +import Gundeck.Schema.V7 qualified as V7 +import Gundeck.Schema.V8 qualified as V8 +import Gundeck.Schema.V9 qualified as V9 import Imports import System.Logger.Extended qualified as Log import Util.Options -import V1 qualified -import V10 qualified -import V2 qualified -import V3 qualified -import V4 qualified -import V5 qualified -import V6 qualified -import V7 qualified -import V8 qualified -import V9 qualified main :: IO () main = do @@ -40,18 +40,25 @@ main = do migrateSchema l o - [ V1.migration, - V2.migration, - V3.migration, - V4.migration, - V5.migration, - V6.migration, - V7.migration, - V8.migration, - V9.migration, - V10.migration - ] + migrations `finally` Log.close l where desc = "Gundeck Cassandra Schema Migrations" defaultPath = "/etc/wire/gundeck/conf/gundeck-schema.yaml" + +lastSchemaVersion :: Int32 +lastSchemaVersion = migVersion $ last migrations + +migrations :: [Migration] +migrations = + [ V1.migration, + V2.migration, + V3.migration, + V4.migration, + V5.migration, + V6.migration, + V7.migration, + V8.migration, + V9.migration, + V10.migration + ] diff --git a/services/gundeck/schema/src/V1.hs b/services/gundeck/src/Gundeck/Schema/V1.hs similarity index 98% rename from services/gundeck/schema/src/V1.hs rename to services/gundeck/src/Gundeck/Schema/V1.hs index ae1a296ab99..f3b71a70ef1 100644 --- a/services/gundeck/schema/src/V1.hs +++ b/services/gundeck/src/Gundeck/Schema/V1.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V1 +module Gundeck.Schema.V1 ( migration, ) where diff --git a/services/gundeck/schema/src/V10.hs b/services/gundeck/src/Gundeck/Schema/V10.hs similarity index 98% rename from services/gundeck/schema/src/V10.hs rename to services/gundeck/src/Gundeck/Schema/V10.hs index d37ed269744..322c36cd7c6 100644 --- a/services/gundeck/schema/src/V10.hs +++ b/services/gundeck/src/Gundeck/Schema/V10.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V10 +module Gundeck.Schema.V10 ( migration, ) where diff --git a/services/gundeck/schema/src/V2.hs b/services/gundeck/src/Gundeck/Schema/V2.hs similarity index 97% rename from services/gundeck/schema/src/V2.hs rename to services/gundeck/src/Gundeck/Schema/V2.hs index a970bc2a2d8..175aa4d34b4 100644 --- a/services/gundeck/schema/src/V2.hs +++ b/services/gundeck/src/Gundeck/Schema/V2.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V2 +module Gundeck.Schema.V2 ( migration, ) where diff --git a/services/gundeck/schema/src/V3.hs b/services/gundeck/src/Gundeck/Schema/V3.hs similarity index 98% rename from services/gundeck/schema/src/V3.hs rename to services/gundeck/src/Gundeck/Schema/V3.hs index fb6055f8f49..09d94cfbfaf 100644 --- a/services/gundeck/schema/src/V3.hs +++ b/services/gundeck/src/Gundeck/Schema/V3.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V3 +module Gundeck.Schema.V3 ( migration, ) where diff --git a/services/gundeck/schema/src/V4.hs b/services/gundeck/src/Gundeck/Schema/V4.hs similarity index 97% rename from services/gundeck/schema/src/V4.hs rename to services/gundeck/src/Gundeck/Schema/V4.hs index e9f39f51b7d..ee2908219e2 100644 --- a/services/gundeck/schema/src/V4.hs +++ b/services/gundeck/src/Gundeck/Schema/V4.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V4 +module Gundeck.Schema.V4 ( migration, ) where diff --git a/services/gundeck/schema/src/V5.hs b/services/gundeck/src/Gundeck/Schema/V5.hs similarity index 97% rename from services/gundeck/schema/src/V5.hs rename to services/gundeck/src/Gundeck/Schema/V5.hs index 77e7907ea57..97abe2a4b32 100644 --- a/services/gundeck/schema/src/V5.hs +++ b/services/gundeck/src/Gundeck/Schema/V5.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V5 +module Gundeck.Schema.V5 ( migration, ) where diff --git a/services/gundeck/schema/src/V6.hs b/services/gundeck/src/Gundeck/Schema/V6.hs similarity index 98% rename from services/gundeck/schema/src/V6.hs rename to services/gundeck/src/Gundeck/Schema/V6.hs index d9b7ae9518d..9e2785244be 100644 --- a/services/gundeck/schema/src/V6.hs +++ b/services/gundeck/src/Gundeck/Schema/V6.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V6 +module Gundeck.Schema.V6 ( migration, ) where diff --git a/services/gundeck/schema/src/V7.hs b/services/gundeck/src/Gundeck/Schema/V7.hs similarity index 98% rename from services/gundeck/schema/src/V7.hs rename to services/gundeck/src/Gundeck/Schema/V7.hs index a5c1ac1ea90..c44dab3fbab 100644 --- a/services/gundeck/schema/src/V7.hs +++ b/services/gundeck/src/Gundeck/Schema/V7.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V7 +module Gundeck.Schema.V7 ( migration, ) where diff --git a/services/gundeck/schema/src/V8.hs b/services/gundeck/src/Gundeck/Schema/V8.hs similarity index 97% rename from services/gundeck/schema/src/V8.hs rename to services/gundeck/src/Gundeck/Schema/V8.hs index 50812186be3..914557eb6b0 100644 --- a/services/gundeck/schema/src/V8.hs +++ b/services/gundeck/src/Gundeck/Schema/V8.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V8 +module Gundeck.Schema.V8 ( migration, ) where diff --git a/services/gundeck/schema/src/V9.hs b/services/gundeck/src/Gundeck/Schema/V9.hs similarity index 98% rename from services/gundeck/schema/src/V9.hs rename to services/gundeck/src/Gundeck/Schema/V9.hs index 2583384eff0..6ba7b0d61bc 100644 --- a/services/gundeck/schema/src/V9.hs +++ b/services/gundeck/src/Gundeck/Schema/V9.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V9 +module Gundeck.Schema.V9 ( migration, ) where diff --git a/services/gundeck/src/Gundeck/ThreadBudget/Internal.hs b/services/gundeck/src/Gundeck/ThreadBudget/Internal.hs index 4918560c12e..4f311bb072c 100644 --- a/services/gundeck/src/Gundeck/ThreadBudget/Internal.hs +++ b/services/gundeck/src/Gundeck/ThreadBudget/Internal.hs @@ -135,10 +135,10 @@ runWithBudget' metrics (ThreadBudgetState limits ref) spent fallback action = do key <- liftIO nextRandom (`finally` unregister ref key) $ do oldsize <- allocate ref key spent - let softLimitBreached = maybe False (oldsize >=) (limits ^. limitSoft) - hardLimitBreached = maybe False (oldsize >=) (limits ^. limitHard) + let softLimitBreached = maybe False (oldsize >=) (limits ^. soft) + hardLimitBreached = maybe False (oldsize >=) (limits ^. hard) warnNoBudget softLimitBreached hardLimitBreached oldsize - if maybe True (oldsize <) (limits ^. limitHard) + if maybe True (oldsize <) (limits ^. hard) then go key oldsize else pure fallback where @@ -154,14 +154,14 @@ runWithBudget' metrics (ThreadBudgetState limits ref) spent fallback action = do -- iff soft and/or hard limit are breached, log a warning-level message. warnNoBudget :: Bool -> Bool -> Int -> m () warnNoBudget False False _ = pure () - warnNoBudget soft hard oldsize = do - let limit = if hard then "hard" else "soft" + warnNoBudget soft' hard' oldsize = do + let limit = if hard' then "hard" else "soft" metric = "net.nativepush." <> limit <> "_limit_breached" counterIncr (path metric) metrics LC.warn $ "spent" LC..= show oldsize - LC.~~ "soft-breach" LC..= soft - LC.~~ "hard-breach" LC..= hard + LC.~~ "soft-breach" LC..= soft' + LC.~~ "hard-breach" LC..= hard' LC.~~ LC.msg ("runWithBudget: " <> limit <> " limit reached") -- | Fork a thread that checks with the given frequency if any async handles stored in the @@ -194,9 +194,9 @@ recordMetrics :: recordMetrics metrics limits ref = do (BudgetMap spent _) <- readIORef ref gaugeSet (fromIntegral spent) (path "net.nativepush.thread_budget_allocated") metrics - forM_ (limits ^. limitHard) $ \lim -> + forM_ (limits ^. hard) $ \lim -> gaugeSet (fromIntegral lim) (path "net.nativepush.thread_budget_hard_limit") metrics - forM_ (limits ^. limitSoft) $ \lim -> + forM_ (limits ^. soft) $ \lim -> gaugeSet (fromIntegral lim) (path "net.nativepush.thread_budget_soft_limit") metrics threadDelayNominalDiffTime :: NominalDiffTime -> MonadIO m => m () diff --git a/services/gundeck/test/integration/API.hs b/services/gundeck/test/integration/API.hs index 5956d356057..930d83fa1c3 100644 --- a/services/gundeck/test/integration/API.hs +++ b/services/gundeck/test/integration/API.hs @@ -50,7 +50,8 @@ import Data.Set qualified as Set import Data.Text.Encoding qualified as T import Data.UUID qualified as UUID import Data.UUID.V4 -import Gundeck.Options +import Gundeck.Options hiding (bulkPush) +import Gundeck.Options qualified as O import Gundeck.Types import Gundeck.Types.Common qualified import Imports @@ -407,10 +408,10 @@ targetClientPush = do storeNotificationsEvenWhenRedisIsDown :: TestM () storeNotificationsEvenWhenRedisIsDown = do ally <- randomId - origRedisEndpoint <- view $ tsOpts . optRedis + origRedisEndpoint <- view $ tsOpts . redis let proxyPort = 10112 - redisProxyServer <- liftIO . async $ runRedisProxy (origRedisEndpoint ^. rHost) (origRedisEndpoint ^. rPort) proxyPort - withSettingsOverrides (optRedis .~ RedisEndpoint "localhost" proxyPort (origRedisEndpoint ^. rConnectionMode)) $ do + redisProxyServer <- liftIO . async $ runRedisProxy (origRedisEndpoint ^. O.host) (origRedisEndpoint ^. O.port) proxyPort + withSettingsOverrides (redis .~ RedisEndpoint "localhost" proxyPort (origRedisEndpoint ^. connectionMode)) $ do let pload = textPayload "hello" push = buildPush ally [(ally, RecipientClientsAll)] pload gu <- view tsGundeck @@ -912,7 +913,7 @@ testRedisMigration = do let presence = Presence uid con cannonURI Nothing 1 "" redis2 <- view tsRedis2 - withSettingsOverrides (optRedisAdditionalWrite ?~ redis2) $ do + withSettingsOverrides (redisAdditionalWrite ?~ redis2) $ do g <- view tsGundeck setPresence g presence !!! const 201 @@ -921,7 +922,7 @@ testRedisMigration = do map resource . decodePresence <$> (getPresence g (toByteString' uid) (getPresence g (toByteString' uid) ServiceName -> (Socket -> IO a) -> IO b -runTCPServer mhost port server = withSocketsDo $ do - addr <- resolve Stream mhost port True +runTCPServer mhost port' server = withSocketsDo $ do + addr <- resolve Stream mhost port' True clientThreads <- newTVarIO [] E.bracket (open addr) (cleanupClients clientThreads) (loop clientThreads) where @@ -70,8 +70,8 @@ runTCPServer mhost port server = withSocketsDo $ do mapM_ killThread =<< readTVarIO clientThreads resolve :: SocketType -> Maybe HostName -> ServiceName -> Bool -> IO AddrInfo -resolve socketType mhost port passive = - head <$> getAddrInfo (Just hints) mhost (Just port) +resolve socketType mhost port' passive = + head <$> getAddrInfo (Just hints) mhost (Just port') where hints = defaultHints diff --git a/services/gundeck/test/unit/ThreadBudget.hs b/services/gundeck/test/unit/ThreadBudget.hs index 45182fea0a3..bd06c866dea 100644 --- a/services/gundeck/test/unit/ThreadBudget.hs +++ b/services/gundeck/test/unit/ThreadBudget.hs @@ -276,7 +276,7 @@ postcondition model@(Model (Just _)) cmd@Measure {} resp@(MeasureResponse concre Model (Just state) = transition model cmd resp threadLimit :: Int threadLimit = case opaque state of - (tbs, _, _) -> tbs ^?! Control.Lens.to threadBudgetLimits . limitHard . _Just + (tbs, _, _) -> tbs ^?! Control.Lens.to threadBudgetLimits . hard . _Just -- number of running threads is never above the limit. threadLimitExceeded = Annotate "thread limit exceeded" $ concreteRunning .<= threadLimit -- FUTUREWORK: check that the number of running threads matches the model exactly. looks diff --git a/services/integration.yaml b/services/integration.yaml index 08f5fa48476..65543e45f10 100644 --- a/services/integration.yaml +++ b/services/integration.yaml @@ -134,3 +134,11 @@ dynamicBackends: dynamic-backend-3: domain: d3.example.com federatorExternalPort: 12098 + +rabbitmq: + host: localhost + adminPort: 15672 + +cassandra: + host: 127.0.0.1 + port: 9042 diff --git a/services/nginz/integration-test/conf/nginz/nginx.conf b/services/nginz/integration-test/conf/nginz/nginx.conf index d737361a7e4..d0818f3419a 100644 --- a/services/nginz/integration-test/conf/nginz/nginx.conf +++ b/services/nginz/integration-test/conf/nginz/nginx.conf @@ -244,6 +244,11 @@ http { proxy_pass http://brig; } + location ~* /teams/([^/]+)/services { + include common_response_with_zauth.conf; + proxy_pass http://brig; + } + location /connections { include common_response_with_zauth.conf; proxy_pass http://brig; @@ -360,6 +365,11 @@ http { proxy_pass http://galley; } + location ~* ^/conversations/([^/]*)/([^/]*)/protocol { + include common_response_with_zauth.conf; + proxy_pass http://galley; + } + location /broadcast { include common_response_with_zauth.conf; proxy_pass http://galley; diff --git a/services/spar/default.nix b/services/spar/default.nix index 4bd791dfcfe..ffc27016e3c 100644 --- a/services/spar/default.nix +++ b/services/spar/default.nix @@ -5,6 +5,7 @@ { mkDerivation , aeson , aeson-qq +, async , base , base64-bytestring , bilge @@ -27,7 +28,9 @@ , hscim , HsOpenSSL , hspec +, hspec-core , hspec-discover +, hspec-junit-formatter , hspec-wai , http-api-data , http-client @@ -41,6 +44,7 @@ , MonadRandom , mtl , network-uri +, openapi3 , optparse-applicative , polysemy , polysemy-check @@ -53,10 +57,9 @@ , saml2-web-sso , servant , servant-multipart +, servant-openapi3 , servant-server -, servant-swagger , silently -, swagger2 , tasty-hunit , text , text-latin1 @@ -136,6 +139,7 @@ mkDerivation { executableHaskellDepends = [ aeson aeson-qq + async base base64-bytestring bilge @@ -156,6 +160,8 @@ mkDerivation { hscim HsOpenSSL hspec + hspec-core + hspec-junit-formatter hspec-wai http-api-data http-client @@ -211,14 +217,14 @@ mkDerivation { metrics-wai mtl network-uri + openapi3 polysemy polysemy-plugin polysemy-wire-zoo QuickCheck saml2-web-sso servant - servant-swagger - swagger2 + servant-openapi3 time tinylog types-common diff --git a/services/spar/schema/Main.hs b/services/spar/schema/Main.hs new file mode 100644 index 00000000000..014014bde4f --- /dev/null +++ b/services/spar/schema/Main.hs @@ -0,0 +1,7 @@ +module Main where + +import Imports +import qualified Spar.Schema.Run as Run + +main :: IO () +main = Run.main diff --git a/services/spar/schema/src/Run.hs b/services/spar/schema/src/Run.hs deleted file mode 100644 index e82ba618bd5..00000000000 --- a/services/spar/schema/src/Run.hs +++ /dev/null @@ -1,79 +0,0 @@ --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Run where - -import Cassandra.Schema -import Control.Exception (finally) -import Imports -import qualified System.Logger.Extended as Log -import Util.Options -import qualified V0 -import qualified V1 -import qualified V10 -import qualified V11 -import qualified V12 -import qualified V13 -import qualified V14 -import qualified V15 -import qualified V16 -import qualified V17 -import qualified V2 -import qualified V3 -import qualified V4 -import qualified V5 -import qualified V6 -import qualified V7 -import qualified V8 -import qualified V9 - -main :: IO () -main = do - let desc = "Spar Cassandra Schema Migrations" - defaultPath = "/etc/wire/spar/conf/spar-schema.yaml" - o <- getOptions desc (Just migrationOptsParser) defaultPath - l <- Log.mkLogger' - migrateSchema - l - o - [ V0.migration, - V1.migration, - V2.migration, - V3.migration, - V4.migration, - V5.migration, - V6.migration, - V7.migration, - V8.migration, - V9.migration, - V10.migration, - V11.migration, - V12.migration, - V13.migration, - V14.migration, - V15.migration, - V16.migration, - V17.migration - -- When adding migrations here, don't forget to update - -- 'schemaVersion' in Spar.Data - - -- TODO: Add a migration that removes unused fields - -- (we don't want to risk running a migration which would - -- effectively break the currently deployed spar service) - -- see https://github.com/wireapp/wire-server/pull/476. - ] - `finally` Log.close l diff --git a/services/spar/spar.cabal b/services/spar/spar.cabal index 568034cedde..cf40efc54d2 100644 --- a/services/spar/spar.cabal +++ b/services/spar/spar.cabal @@ -28,6 +28,25 @@ library Spar.Options Spar.Orphans Spar.Run + Spar.Schema.Run + Spar.Schema.V0 + Spar.Schema.V1 + Spar.Schema.V10 + Spar.Schema.V11 + Spar.Schema.V12 + Spar.Schema.V13 + Spar.Schema.V14 + Spar.Schema.V15 + Spar.Schema.V16 + Spar.Schema.V17 + Spar.Schema.V2 + Spar.Schema.V3 + Spar.Schema.V4 + Spar.Schema.V5 + Spar.Schema.V6 + Spar.Schema.V7 + Spar.Schema.V8 + Spar.Schema.V9 Spar.Scim Spar.Scim.Auth Spar.Scim.Types @@ -313,6 +332,7 @@ executable spar-integration build-depends: aeson , aeson-qq + , async , base , base64-bytestring , bilge @@ -331,6 +351,8 @@ executable spar-integration , hscim , HsOpenSSL , hspec + , hspec-core + , hspec-junit-formatter , hspec-wai , http-api-data , http-client @@ -452,31 +474,8 @@ executable spar-migrate-data default-language: Haskell2010 executable spar-schema - main-is: spar-schema.hs - - -- cabal-fmt: expand schema/src - other-modules: - Run - V0 - V1 - V10 - V11 - V12 - V13 - V14 - V15 - V16 - V17 - V2 - V3 - V4 - V5 - V6 - V7 - V8 - V9 - - hs-source-dirs: schema/src schema/exe + main-is: Main.hs + hs-source-dirs: schema/ default-extensions: NoImplicitPrelude AllowAmbiguousTypes @@ -526,12 +525,8 @@ executable spar-schema -with-rtsopts=-N -Wredundant-constraints -Wunused-packages build-depends: - base - , cassandra-util - , extended - , imports - , raw-strings-qq - , types-common + imports + , spar default-language: Haskell2010 @@ -618,15 +613,15 @@ test-suite spec , metrics-wai , mtl , network-uri + , openapi3 , polysemy , polysemy-plugin , polysemy-wire-zoo , QuickCheck , saml2-web-sso >=0.19 , servant - , servant-swagger + , servant-openapi3 , spar - , swagger2 , time , tinylog , types-common diff --git a/services/spar/src/Spar/API.hs b/services/spar/src/Spar/API.hs index a6e82976950..4107e44910f 100644 --- a/services/spar/src/Spar/API.hs +++ b/services/spar/src/Spar/API.hs @@ -32,7 +32,7 @@ module Spar.API api, -- * API types - API, + SparAPI, -- ** Individual API pieces APIAuthReqPrecheck, @@ -113,7 +113,7 @@ import qualified Wire.Sem.Random as Random app :: Env -> Application app ctx = SAML.setHttpCachePolicy $ - serve (Proxy @API) (hoistServer (Proxy @API) (runSparToHandler ctx) (api $ sparCtxOpts ctx) :: Server API) + serve (Proxy @SparAPI) (hoistServer (Proxy @SparAPI) (runSparToHandler ctx) (api $ sparCtxOpts ctx) :: Server SparAPI) api :: ( Member GalleyAccess r, @@ -144,7 +144,7 @@ api :: Member (Logger (Msg -> Msg)) r ) => Opts -> - ServerT API (Sem r) + ServerT SparAPI (Sem r) api opts = apiSSO opts :<|> apiIDP @@ -256,9 +256,9 @@ authreq authreqttl msucc merr idpid = do form@(SAML.FormRedirect _ ((^. SAML.rqID) -> reqid)) <- do idp :: IdP <- IdPConfigStore.getConfig idpid let mbtid :: Maybe TeamId - mbtid = case fromMaybe defWireIdPAPIVersion (idp ^. SAML.idpExtraInfo . wiApiVersion) of + mbtid = case fromMaybe defWireIdPAPIVersion (idp ^. SAML.idpExtraInfo . apiVersion) of WireIdPAPIV1 -> Nothing - WireIdPAPIV2 -> Just $ idp ^. SAML.idpExtraInfo . wiTeam + WireIdPAPIV2 -> Just $ idp ^. SAML.idpExtraInfo . team SAML2.authReq authreqttl (SamlProtocolSettings.spIssuer mbtid) idpid VerdictFormatStore.store authreqttl reqid vformat pure form @@ -370,7 +370,7 @@ idpGetAll :: Sem r IdPList idpGetAll zusr = withDebugLog "idpGetAll" (const Nothing) $ do teamid <- Brig.getZUsrCheckPerm zusr ReadIdp - _idplProviders <- IdPConfigStore.getConfigsByTeam teamid + _providers <- IdPConfigStore.getConfigsByTeam teamid pure IdPList {..} -- | Delete empty IdPs, or if @"purge=true"@ in the HTTP query, delete all users @@ -399,18 +399,18 @@ idpDelete :: Sem r NoContent idpDelete mbzusr idpid (fromMaybe False -> purge) = withDebugLog "idpDelete" (const Nothing) $ do idp <- IdPConfigStore.getConfig idpid - (zusr, team) <- authorizeIdP mbzusr idp + (zusr, teamId) <- authorizeIdP mbzusr idp let issuer = idp ^. SAML.idpMetadata . SAML.edIssuer whenM (idpDoesAuthSelf idp zusr) $ throwSparSem SparIdPCannotDeleteOwnIdp - SAMLUserStore.getAllByIssuerPaginated issuer >>= assertEmptyOrPurge team + SAMLUserStore.getAllByIssuerPaginated issuer >>= assertEmptyOrPurge teamId updateOldIssuers idp updateReplacingIdP idp -- Delete tokens associated with given IdP (we rely on the fact that -- each IdP has exactly one team so we can look up all tokens -- associated with the team and then filter them) - tokens <- ScimTokenStore.lookupByTeam team + tokens <- ScimTokenStore.lookupByTeam teamId for_ tokens $ \ScimTokenInfo {..} -> - when (stiIdP == Just idpid) $ ScimTokenStore.delete team stiId + when (stiIdP == Just idpid) $ ScimTokenStore.delete teamId stiId -- Delete IdP config do IdPConfigStore.deleteConfig idp @@ -418,11 +418,11 @@ idpDelete mbzusr idpid (fromMaybe False -> purge) = withDebugLog "idpDelete" (co pure NoContent where assertEmptyOrPurge :: TeamId -> Cas.Page (SAML.UserRef, UserId) -> Sem r () - assertEmptyOrPurge team page = do + assertEmptyOrPurge teamId page = do forM_ (Cas.result page) $ \(uref, uid) -> do mAccount <- BrigAccess.getAccount NoPendingInvitations uid let mUserTeam = userTeam . accountUser =<< mAccount - when (mUserTeam == Just team) $ do + when (mUserTeam == Just teamId) $ do if purge then do SAMLUserStore.delete uid uref @@ -430,7 +430,7 @@ idpDelete mbzusr idpid (fromMaybe False -> purge) = withDebugLog "idpDelete" (co else do throwSparSem SparIdPHasBoundUsers when (Cas.hasMore page) $ - SAMLUserStore.nextPage page >>= assertEmptyOrPurge team + SAMLUserStore.nextPage page >>= assertEmptyOrPurge teamId updateOldIssuers :: IdP -> Sem r () updateOldIssuers _ = pure () @@ -442,11 +442,11 @@ idpDelete mbzusr idpid (fromMaybe False -> purge) = withDebugLog "idpDelete" (co -- leave old issuers dangling for now. updateReplacingIdP :: IdP -> Sem r () - updateReplacingIdP idp = forM_ (idp ^. SAML.idpExtraInfo . wiOldIssuers) $ \oldIssuer -> do + updateReplacingIdP idp = forM_ (idp ^. SAML.idpExtraInfo . oldIssuers) $ \oldIssuer -> do iid <- - view SAML.idpId <$> case fromMaybe defWireIdPAPIVersion $ idp ^. SAML.idpExtraInfo . wiApiVersion of + view SAML.idpId <$> case fromMaybe defWireIdPAPIVersion $ idp ^. SAML.idpExtraInfo . apiVersion of WireIdPAPIV1 -> IdPConfigStore.getIdPByIssuerV1 oldIssuer - WireIdPAPIV2 -> IdPConfigStore.getIdPByIssuerV2 oldIssuer (idp ^. SAML.idpExtraInfo . wiTeam) + WireIdPAPIV2 -> IdPConfigStore.getIdPByIssuerV2 oldIssuer (idp ^. SAML.idpExtraInfo . team) IdPConfigStore.clearReplacedBy $ Replaced iid idpDoesAuthSelf :: IdP -> UserId -> Sem r Bool @@ -497,8 +497,9 @@ idpCreateXML zusr raw idpmeta mReplaces (fromMaybe defWireIdPAPIVersion -> apive teamid <- Brig.getZUsrCheckPerm zusr CreateUpdateDeleteIdp GalleyAccess.assertSSOEnabled teamid assertNoScimOrNoIdP teamid - handle <- maybe (IdPConfigStore.newHandle teamid) (pure . IdPHandle . fromRange) mHandle - idp <- validateNewIdP apiversion idpmeta teamid mReplaces handle + idp <- + maybe (IdPConfigStore.newHandle teamid) (pure . IdPHandle . fromRange) mHandle + >>= validateNewIdP apiversion idpmeta teamid mReplaces IdPRawMetadataStore.store (idp ^. SAML.idpId) raw IdPConfigStore.insertConfig idp forM_ mReplaces $ \replaces -> @@ -558,21 +559,21 @@ validateNewIdP :: Maybe SAML.IdPId -> IdPHandle -> m IdP -validateNewIdP apiversion _idpMetadata teamId mReplaces handle = withDebugLog "validateNewIdP" (Just . show . (^. SAML.idpId)) $ do +validateNewIdP apiversion _idpMetadata teamId mReplaces idHandle = withDebugLog "validateNewIdP" (Just . show . (^. SAML.idpId)) $ do _idpId <- SAML.IdPId <$> Random.uuid - oldIssuers :: [SAML.Issuer] <- case mReplaces of + oldIssuersList :: [SAML.Issuer] <- case mReplaces of Nothing -> pure [] Just replaces -> do idp <- IdPConfigStore.getConfig replaces - pure $ (idp ^. SAML.idpMetadata . SAML.edIssuer) : (idp ^. SAML.idpExtraInfo . wiOldIssuers) + pure $ (idp ^. SAML.idpMetadata . SAML.edIssuer) : (idp ^. SAML.idpExtraInfo . oldIssuers) let requri = _idpMetadata ^. SAML.edRequestURI - _idpExtraInfo = WireIdP teamId (Just apiversion) oldIssuers Nothing handle + _idpExtraInfo = WireIdP teamId (Just apiversion) oldIssuersList Nothing idHandle enforceHttps requri mbIdp <- case apiversion of WireIdPAPIV1 -> IdPConfigStore.getIdPByIssuerV1Maybe (_idpMetadata ^. SAML.edIssuer) WireIdPAPIV2 -> IdPConfigStore.getIdPByIssuerV2Maybe (_idpMetadata ^. SAML.edIssuer) teamId Logger.log Logger.Debug $ show (apiversion, _idpMetadata, teamId, mReplaces) - Logger.log Logger.Debug $ show (_idpId, oldIssuers, mbIdp) + Logger.log Logger.Debug $ show (_idpId, oldIssuersList, mbIdp) let failWithIdPClash :: m () failWithIdPClash = throwSparSem . SparNewIdPAlreadyInUse $ case apiversion of @@ -625,7 +626,7 @@ idpUpdateXML zusr raw idpmeta idpid mHandle = withDebugLog "idpUpdateXML" (Just GalleyAccess.assertSSOEnabled teamid IdPRawMetadataStore.store (idp ^. SAML.idpId) raw let idp' :: IdP = case mHandle of - Just idpHandle -> idp & (SAML.idpExtraInfo . wiHandle) .~ IdPHandle (fromRange idpHandle) + Just idpHandle -> idp & (SAML.idpExtraInfo . handle) .~ IdPHandle (fromRange idpHandle) Nothing -> idp -- (if raw metadata is stored and then spar goes out, raw metadata won't match the -- structured idp config. since this will lead to a 5xx response, the client is expected to @@ -633,10 +634,10 @@ idpUpdateXML zusr raw idpmeta idpid mHandle = withDebugLog "idpUpdateXML" (Just IdPConfigStore.insertConfig idp' -- if the IdP issuer is updated, the old issuer must be removed explicitly. -- if this step is ommitted (due to a crash) resending the update request should fix the inconsistent state. - let mbteamid = case fromMaybe defWireIdPAPIVersion $ idp' ^. SAML.idpExtraInfo . wiApiVersion of + let mbteamid = case fromMaybe defWireIdPAPIVersion $ idp' ^. SAML.idpExtraInfo . apiVersion of WireIdPAPIV1 -> Nothing WireIdPAPIV2 -> Just teamid - forM_ (idp' ^. SAML.idpExtraInfo . wiOldIssuers) (flip IdPConfigStore.deleteIssuer mbteamid) + forM_ (idp' ^. SAML.idpExtraInfo . oldIssuers) (flip IdPConfigStore.deleteIssuer mbteamid) pure idp' -- | Check that: idp id is valid; calling user is admin in that idp's home team; team id in @@ -660,7 +661,7 @@ validateIdPUpdate :: validateIdPUpdate zusr _idpMetadata _idpId = withDebugLog "validateIdPUpdate" (Just . show . (_2 %~ (^. SAML.idpId))) $ do previousIdP <- IdPConfigStore.getConfig _idpId (_, teamId) <- authorizeIdP zusr previousIdP - unless (previousIdP ^. SAML.idpExtraInfo . wiTeam == teamId) $ + unless (previousIdP ^. SAML.idpExtraInfo . team == teamId) $ throw errUnknownIdP _idpExtraInfo <- do let previousIssuer = previousIdP ^. SAML.idpMetadata . SAML.edIssuer @@ -671,7 +672,7 @@ validateIdPUpdate zusr _idpMetadata _idpId = withDebugLog "validateIdPUpdate" (J pure $ previousIdP ^. SAML.idpExtraInfo else do idpIssuerInUse <- - ( case fromMaybe defWireIdPAPIVersion $ previousIdP ^. SAML.idpExtraInfo . wiApiVersion of + ( case fromMaybe defWireIdPAPIVersion $ previousIdP ^. SAML.idpExtraInfo . apiVersion of WireIdPAPIV1 -> IdPConfigStore.getIdPByIssuerV1Maybe newIssuer WireIdPAPIV2 -> IdPConfigStore.getIdPByIssuerV2Maybe newIssuer teamId ) @@ -681,7 +682,7 @@ validateIdPUpdate zusr _idpMetadata _idpId = withDebugLog "validateIdPUpdate" (J ) if idpIssuerInUse then throwSparSem SparIdPIssuerInUse - else pure $ previousIdP ^. SAML.idpExtraInfo & wiOldIssuers %~ nub . (previousIssuer :) + else pure $ previousIdP ^. SAML.idpExtraInfo & oldIssuers %~ nub . (previousIssuer :) let requri = _idpMetadata ^. SAML.edRequestURI enforceHttps requri @@ -712,7 +713,7 @@ authorizeIdP :: Sem r (UserId, TeamId) authorizeIdP Nothing _ = throw (SAML.CustomError $ SparNoPermission (cs $ show CreateUpdateDeleteIdp)) authorizeIdP (Just zusr) idp = do - let teamid = idp ^. SAML.idpExtraInfo . wiTeam + let teamid = idp ^. SAML.idpExtraInfo . team GalleyAccess.assertHasPermission teamid CreateUpdateDeleteIdp zusr pure (zusr, teamid) @@ -736,8 +737,8 @@ internalDeleteTeam :: ) => TeamId -> Sem r NoContent -internalDeleteTeam team = do - deleteTeam team +internalDeleteTeam teamId = do + deleteTeam teamId pure NoContent internalPutSsoSettings :: diff --git a/services/spar/src/Spar/App.hs b/services/spar/src/Spar/App.hs index 2dceac0b0eb..f32f221433d 100644 --- a/services/spar/src/Spar/App.hs +++ b/services/spar/src/Spar/App.hs @@ -205,18 +205,18 @@ autoprovisionSamlUser :: autoprovisionSamlUser idp buid suid = do guardReplacedIdP guardScimTokens - createSamlUserWithId (idp ^. idpExtraInfo . wiTeam) buid suid defaultRole + createSamlUserWithId (idp ^. idpExtraInfo . team) buid suid defaultRole where -- Replaced IdPs are not allowed to create new wire accounts. guardReplacedIdP :: Sem r () guardReplacedIdP = do - unless (isNothing $ idp ^. idpExtraInfo . wiReplacedBy) $ do + unless (isNothing $ idp ^. idpExtraInfo . replacedBy) $ do throwSparSem $ SparCannotCreateUsersOnReplacedIdP (cs . SAML.idPIdToST $ idp ^. idpId) -- IdPs in teams with scim tokens are not allowed to auto-provision. guardScimTokens :: Sem r () guardScimTokens = do - let teamid = idp ^. idpExtraInfo . wiTeam + let teamid = idp ^. idpExtraInfo . team scimtoks <- ScimTokenStore.lookupByTeam teamid unless (null scimtoks) $ do throwSparSem SparSamlCredentialsNotFound @@ -361,7 +361,7 @@ getUserByUrefViaOldIssuerUnsafe idp (SAML.UserRef _ subject) = do where uref = SAML.UserRef oldIssuer subject - foldM tryFind Nothing (idp ^. idpExtraInfo . wiOldIssuers) + foldM tryFind Nothing (idp ^. idpExtraInfo . oldIssuers) -- | After a user has been found using 'findUserWithOldIssuer', update it everywhere so that -- the old IdP is not needed any more next time. @@ -397,18 +397,18 @@ verdictHandlerResultCore idp = \case pure $ VerifyHandlerDenied reasons SAML.AccessGranted uref -> do uid :: UserId <- do - let team = idp ^. idpExtraInfo . wiTeam + let team' = idp ^. idpExtraInfo . team err = SparUserRefInNoOrMultipleTeams . cs . show $ uref getUserByUrefUnsafe uref >>= \case Just usr -> do - if userTeam usr == Just team + if userTeam usr == Just team' then pure (userId usr) else throwSparSem err Nothing -> do getUserByUrefViaOldIssuerUnsafe idp uref >>= \case Just (olduref, usr) -> do let uid = userId usr - if userTeam usr == Just team + if userTeam usr == Just team' then moveUserToNewIssuer olduref uref uid >> pure uid else throwSparSem err Nothing -> do @@ -572,11 +572,11 @@ deleteTeam :: ) => TeamId -> Sem r () -deleteTeam team = do - ScimTokenStore.deleteByTeam team +deleteTeam team' = do + ScimTokenStore.deleteByTeam team' -- Since IdPs are not shared between teams, we can look at the set of IdPs -- used by the team, and remove everything related to those IdPs, too. - idps <- IdPConfigStore.getConfigsByTeam team + idps <- IdPConfigStore.getConfigsByTeam team' for_ idps $ \idp -> do let issuer = idp ^. SAML.idpMetadata . SAML.edIssuer SAMLUserStore.deleteByIssuer issuer diff --git a/services/spar/src/Spar/Data.hs b/services/spar/src/Spar/Data.hs index 683fc05a3ee..ad8915c45c1 100644 --- a/services/spar/src/Spar/Data.hs +++ b/services/spar/src/Spar/Data.hs @@ -44,11 +44,12 @@ import SAML2.Util (renderURI) import qualified SAML2.WebSSO as SAML import qualified SAML2.WebSSO.Types.Email as SAMLEmail import Spar.Options +import qualified Spar.Schema.Run as Migrations import Wire.API.User.Saml -- | A lower bound: @schemaVersion <= whatWeFoundOnCassandra@, not @==@. schemaVersion :: Int32 -schemaVersion = 17 +schemaVersion = Migrations.lastSchemaVersion ---------------------------------------------------------------------- -- helpers diff --git a/services/spar/src/Spar/Run.hs b/services/spar/src/Spar/Run.hs index 8c6df422ab8..5331fddabc9 100644 --- a/services/spar/src/Spar/Run.hs +++ b/services/spar/src/Spar/Run.hs @@ -28,7 +28,7 @@ module Spar.Run ) where -import Bilge +import qualified Bilge import Cassandra as Cas import qualified Cassandra.Schema as Cas import qualified Cassandra.Settings as Cas @@ -44,7 +44,7 @@ import qualified Network.Wai.Handler.Warp as Warp import Network.Wai.Utilities.Request (lookupRequestId) import qualified Network.Wai.Utilities.Server as WU import qualified SAML2.WebSSO as SAML -import Spar.API (API, app) +import Spar.API (SparAPI, app) import Spar.App import qualified Spar.Data as Data import Spar.Data.Instances () @@ -52,7 +52,7 @@ import Spar.Options import Spar.Orphans () import System.Logger.Class (Logger) import qualified System.Logger.Extended as Log -import Util.Options (casEndpoint, casFilterNodesByDatacentre, casKeyspace, epHost, epPort) +import Util.Options (endpoint, filterNodesByDatacentre, host, keyspace, port) import Wire.API.Routes.Version.Wai import Wire.Sem.Logger.TinyLog @@ -64,7 +64,7 @@ initCassandra opts lgr = do let cassOpts = cassandra opts connectString <- maybe - (Cas.initialContactsPlain (cassOpts ^. casEndpoint . epHost)) + (Cas.initialContactsPlain (cassOpts ^. endpoint . host)) (Cas.initialContactsDisco "cassandra_spar" . cs) (discoUrl opts) cas <- @@ -72,15 +72,15 @@ initCassandra opts lgr = do Cas.defSettings & Cas.setLogger (Cas.mkLogger (Log.clone (Just "cassandra.spar") lgr)) & Cas.setContacts (NE.head connectString) (NE.tail connectString) - & Cas.setPortNumber (fromIntegral $ cassOpts ^. casEndpoint . epPort) - & Cas.setKeyspace (Keyspace $ cassOpts ^. casKeyspace) + & Cas.setPortNumber (fromIntegral $ cassOpts ^. endpoint . port) + & Cas.setKeyspace (Keyspace $ cassOpts ^. keyspace) & Cas.setMaxConnections 4 & Cas.setMaxStreams 128 & Cas.setPoolStripes 4 & Cas.setSendTimeout 3 & Cas.setResponseTimeout 10 & Cas.setProtocolVersion V4 - & Cas.setPolicy (Cas.dcFilterPolicyIfConfigured lgr (cassOpts ^. casFilterNodesByDatacentre)) + & Cas.setPolicy (Cas.dcFilterPolicyIfConfigured lgr (cassOpts ^. filterNodesByDatacentre)) runClient cas $ Cas.versionCheck Data.schemaVersion pure cas @@ -104,19 +104,19 @@ mkApp sparCtxOpts = do let logLevel = samlToLevel $ saml sparCtxOpts ^. SAML.cfgLogLevel sparCtxLogger <- Log.mkLogger logLevel (logNetStrings sparCtxOpts) (logFormat sparCtxOpts) sparCtxCas <- initCassandra sparCtxOpts sparCtxLogger - sparCtxHttpManager <- newManager defaultManagerSettings + sparCtxHttpManager <- Bilge.newManager Bilge.defaultManagerSettings let sparCtxHttpBrig = - Bilge.host (sparCtxOpts ^. to brig . epHost . to cs) - . Bilge.port (sparCtxOpts ^. to brig . epPort) + Bilge.host (sparCtxOpts ^. to brig . host . to cs) + . Bilge.port (sparCtxOpts ^. to brig . port) $ Bilge.empty let sparCtxHttpGalley = - Bilge.host (sparCtxOpts ^. to galley . epHost . to cs) - . Bilge.port (sparCtxOpts ^. to galley . epPort) + Bilge.host (sparCtxOpts ^. to galley . host . to cs) + . Bilge.port (sparCtxOpts ^. to galley . port) $ Bilge.empty let wrappedApp = versionMiddleware (fold (disabledAPIVersions sparCtxOpts)) . WU.heavyDebugLogging heavyLogOnly logLevel sparCtxLogger - . servantPrometheusMiddleware (Proxy @API) + . servantPrometheusMiddleware (Proxy @SparAPI) . WU.catchErrors sparCtxLogger [] -- Error 'Response's are usually not thrown as exceptions, but logged in -- 'renderSparErrorWithLogging' before the 'Application' can construct a 'Response' @@ -131,9 +131,9 @@ mkApp sparCtxOpts = do if Wai.requestMethod req == "POST" && Wai.pathInfo req == ["sso", "finalize-login"] then Just out else Nothing - pure (wrappedApp, let sparCtxRequestId = RequestId "N/A" in Env {..}) + pure (wrappedApp, let sparCtxRequestId = Bilge.RequestId "N/A" in Env {..}) -lookupRequestIdMiddleware :: (RequestId -> Application) -> Application +lookupRequestIdMiddleware :: (Bilge.RequestId -> Application) -> Application lookupRequestIdMiddleware mkapp req cont = do - let reqid = maybe def RequestId $ lookupRequestId req + let reqid = maybe def Bilge.RequestId $ lookupRequestId req mkapp reqid req cont diff --git a/services/spar/src/Spar/Schema/Run.hs b/services/spar/src/Spar/Schema/Run.hs new file mode 100644 index 00000000000..4fef2264a34 --- /dev/null +++ b/services/spar/src/Spar/Schema/Run.hs @@ -0,0 +1,83 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Spar.Schema.Run where + +import Cassandra.Schema +import Control.Exception (finally) +import Imports +import qualified Spar.Schema.V0 as V0 +import qualified Spar.Schema.V1 as V1 +import qualified Spar.Schema.V10 as V10 +import qualified Spar.Schema.V11 as V11 +import qualified Spar.Schema.V12 as V12 +import qualified Spar.Schema.V13 as V13 +import qualified Spar.Schema.V14 as V14 +import qualified Spar.Schema.V15 as V15 +import qualified Spar.Schema.V16 as V16 +import qualified Spar.Schema.V17 as V17 +import qualified Spar.Schema.V2 as V2 +import qualified Spar.Schema.V3 as V3 +import qualified Spar.Schema.V4 as V4 +import qualified Spar.Schema.V5 as V5 +import qualified Spar.Schema.V6 as V6 +import qualified Spar.Schema.V7 as V7 +import qualified Spar.Schema.V8 as V8 +import qualified Spar.Schema.V9 as V9 +import qualified System.Logger.Extended as Log +import Util.Options + +main :: IO () +main = do + let desc = "Spar Cassandra Schema Migrations" + defaultPath = "/etc/wire/spar/conf/spar-schema.yaml" + o <- getOptions desc (Just migrationOptsParser) defaultPath + l <- Log.mkLogger' + migrateSchema + l + o + migrations + `finally` Log.close l + +lastSchemaVersion :: Int32 +lastSchemaVersion = migVersion $ last migrations + +migrations :: [Migration] +migrations = + [ V0.migration, + V1.migration, + V2.migration, + V3.migration, + V4.migration, + V5.migration, + V6.migration, + V7.migration, + V8.migration, + V9.migration, + V10.migration, + V11.migration, + V12.migration, + V13.migration, + V14.migration, + V15.migration, + V16.migration, + V17.migration + -- TODO: Add a migration that removes unused fields + -- (we don't want to risk running a migration which would + -- effectively break the currently deployed spar service) + -- see https://github.com/wireapp/wire-server/pull/476. + ] diff --git a/services/spar/schema/src/V0.hs b/services/spar/src/Spar/Schema/V0.hs similarity index 99% rename from services/spar/schema/src/V0.hs rename to services/spar/src/Spar/Schema/V0.hs index b537c16ffa0..104d68b0b78 100644 --- a/services/spar/schema/src/V0.hs +++ b/services/spar/src/Spar/Schema/V0.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V0 +module Spar.Schema.V0 ( migration, ) where diff --git a/services/spar/schema/src/V1.hs b/services/spar/src/Spar/Schema/V1.hs similarity index 98% rename from services/spar/schema/src/V1.hs rename to services/spar/src/Spar/Schema/V1.hs index d0dab374e17..1b1c44ca753 100644 --- a/services/spar/schema/src/V1.hs +++ b/services/spar/src/Spar/Schema/V1.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V1 +module Spar.Schema.V1 ( migration, ) where diff --git a/services/spar/schema/src/V10.hs b/services/spar/src/Spar/Schema/V10.hs similarity index 98% rename from services/spar/schema/src/V10.hs rename to services/spar/src/Spar/Schema/V10.hs index 532102f426a..88513ec0e82 100644 --- a/services/spar/schema/src/V10.hs +++ b/services/spar/src/Spar/Schema/V10.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V10 +module Spar.Schema.V10 ( migration, ) where diff --git a/services/spar/schema/src/V11.hs b/services/spar/src/Spar/Schema/V11.hs similarity index 97% rename from services/spar/schema/src/V11.hs rename to services/spar/src/Spar/Schema/V11.hs index 8e893da948e..6cf2892f882 100644 --- a/services/spar/schema/src/V11.hs +++ b/services/spar/src/Spar/Schema/V11.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V11 +module Spar.Schema.V11 ( migration, ) where diff --git a/services/spar/schema/src/V12.hs b/services/spar/src/Spar/Schema/V12.hs similarity index 98% rename from services/spar/schema/src/V12.hs rename to services/spar/src/Spar/Schema/V12.hs index 59ef5ed0d12..b09d1491371 100644 --- a/services/spar/schema/src/V12.hs +++ b/services/spar/src/Spar/Schema/V12.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V12 +module Spar.Schema.V12 ( migration, ) where diff --git a/services/spar/schema/src/V13.hs b/services/spar/src/Spar/Schema/V13.hs similarity index 98% rename from services/spar/schema/src/V13.hs rename to services/spar/src/Spar/Schema/V13.hs index c0f0248221e..26148eb3af1 100644 --- a/services/spar/schema/src/V13.hs +++ b/services/spar/src/Spar/Schema/V13.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V13 +module Spar.Schema.V13 ( migration, ) where diff --git a/services/spar/schema/src/V14.hs b/services/spar/src/Spar/Schema/V14.hs similarity index 98% rename from services/spar/schema/src/V14.hs rename to services/spar/src/Spar/Schema/V14.hs index 322ee66a3e5..2a682243640 100644 --- a/services/spar/schema/src/V14.hs +++ b/services/spar/src/Spar/Schema/V14.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V14 +module Spar.Schema.V14 ( migration, ) where diff --git a/services/spar/schema/src/V15.hs b/services/spar/src/Spar/Schema/V15.hs similarity index 98% rename from services/spar/schema/src/V15.hs rename to services/spar/src/Spar/Schema/V15.hs index c2d7d43e243..574c8df5b12 100644 --- a/services/spar/schema/src/V15.hs +++ b/services/spar/src/Spar/Schema/V15.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V15 +module Spar.Schema.V15 ( migration, ) where diff --git a/services/spar/schema/src/V16.hs b/services/spar/src/Spar/Schema/V16.hs similarity index 97% rename from services/spar/schema/src/V16.hs rename to services/spar/src/Spar/Schema/V16.hs index 289776c13db..ed52742af2c 100644 --- a/services/spar/schema/src/V16.hs +++ b/services/spar/src/Spar/Schema/V16.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V16 +module Spar.Schema.V16 ( migration, ) where diff --git a/services/spar/schema/src/V17.hs b/services/spar/src/Spar/Schema/V17.hs similarity index 98% rename from services/spar/schema/src/V17.hs rename to services/spar/src/Spar/Schema/V17.hs index bccc4aab7de..10465ee3251 100644 --- a/services/spar/schema/src/V17.hs +++ b/services/spar/src/Spar/Schema/V17.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V17 +module Spar.Schema.V17 ( migration, ) where diff --git a/services/spar/schema/src/V2.hs b/services/spar/src/Spar/Schema/V2.hs similarity index 98% rename from services/spar/schema/src/V2.hs rename to services/spar/src/Spar/Schema/V2.hs index 5ab0a0525ad..ffb4d97416e 100644 --- a/services/spar/schema/src/V2.hs +++ b/services/spar/src/Spar/Schema/V2.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V2 +module Spar.Schema.V2 ( migration, ) where diff --git a/services/spar/schema/src/V3.hs b/services/spar/src/Spar/Schema/V3.hs similarity index 98% rename from services/spar/schema/src/V3.hs rename to services/spar/src/Spar/Schema/V3.hs index 7a7e5441090..0bb754452b8 100644 --- a/services/spar/schema/src/V3.hs +++ b/services/spar/src/Spar/Schema/V3.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V3 +module Spar.Schema.V3 ( migration, ) where diff --git a/services/spar/schema/src/V4.hs b/services/spar/src/Spar/Schema/V4.hs similarity index 99% rename from services/spar/schema/src/V4.hs rename to services/spar/src/Spar/Schema/V4.hs index 9760d377c3a..41f2b5bde20 100644 --- a/services/spar/schema/src/V4.hs +++ b/services/spar/src/Spar/Schema/V4.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V4 +module Spar.Schema.V4 ( migration, ) where diff --git a/services/spar/schema/src/V5.hs b/services/spar/src/Spar/Schema/V5.hs similarity index 98% rename from services/spar/schema/src/V5.hs rename to services/spar/src/Spar/Schema/V5.hs index 7a635cc5283..66f7b22fcc7 100644 --- a/services/spar/schema/src/V5.hs +++ b/services/spar/src/Spar/Schema/V5.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V5 +module Spar.Schema.V5 ( migration, ) where diff --git a/services/spar/schema/src/V6.hs b/services/spar/src/Spar/Schema/V6.hs similarity index 98% rename from services/spar/schema/src/V6.hs rename to services/spar/src/Spar/Schema/V6.hs index 71e13821fdc..7b5d0471b67 100644 --- a/services/spar/schema/src/V6.hs +++ b/services/spar/src/Spar/Schema/V6.hs @@ -18,7 +18,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V6 +module Spar.Schema.V6 ( migration, ) where diff --git a/services/spar/schema/src/V7.hs b/services/spar/src/Spar/Schema/V7.hs similarity index 98% rename from services/spar/schema/src/V7.hs rename to services/spar/src/Spar/Schema/V7.hs index 2a28aea17bb..01a353b2711 100644 --- a/services/spar/schema/src/V7.hs +++ b/services/spar/src/Spar/Schema/V7.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V7 +module Spar.Schema.V7 ( migration, ) where diff --git a/services/spar/schema/src/V8.hs b/services/spar/src/Spar/Schema/V8.hs similarity index 98% rename from services/spar/schema/src/V8.hs rename to services/spar/src/Spar/Schema/V8.hs index d8b795ccc12..f32e3d48d3c 100644 --- a/services/spar/schema/src/V8.hs +++ b/services/spar/src/Spar/Schema/V8.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V8 +module Spar.Schema.V8 ( migration, ) where diff --git a/services/spar/schema/src/V9.hs b/services/spar/src/Spar/Schema/V9.hs similarity index 98% rename from services/spar/schema/src/V9.hs rename to services/spar/src/Spar/Schema/V9.hs index 990d1a15ef2..e0751ce1851 100644 --- a/services/spar/schema/src/V9.hs +++ b/services/spar/src/Spar/Schema/V9.hs @@ -15,7 +15,7 @@ -- You should have received a copy of the GNU Affero General Public License along -- with this program. If not, see . -module V9 +module Spar.Schema.V9 ( migration, ) where diff --git a/services/spar/src/Spar/Sem/IdPConfigStore/Cassandra.hs b/services/spar/src/Spar/Sem/IdPConfigStore/Cassandra.hs index 2b3d347007a..56f60c6c4f3 100644 --- a/services/spar/src/Spar/Sem/IdPConfigStore/Cassandra.hs +++ b/services/spar/src/Spar/Sem/IdPConfigStore/Cassandra.hs @@ -38,7 +38,8 @@ import Spar.Data.Instances () import Spar.Error import Spar.Sem.IdPConfigStore (IdPConfigStore (..), Replaced (..), Replacing (..)) import URI.ByteString -import Wire.API.User.IdentityProvider +import Wire.API.User.IdentityProvider hiding (apiVersion, oldIssuers, replacedBy, team) +import qualified Wire.API.User.IdentityProvider as IP idPToCassandra :: forall m r a. @@ -59,8 +60,8 @@ idPToCassandra = DeleteConfig idp -> let idpid = idp ^. SAML.idpId issuer = idp ^. SAML.idpMetadata . SAML.edIssuer - team = idp ^. SAML.idpExtraInfo . wiTeam - in embed @m $ deleteIdPConfig idpid issuer team + team' = idp ^. SAML.idpExtraInfo . IP.team + in embed @m $ deleteIdPConfig idpid issuer team' SetReplacedBy r r11 -> embed @m $ setReplacedBy r r11 ClearReplacedBy r -> embed @m $ clearReplacedBy r DeleteIssuer i t -> embed @m $ deleteIssuer i t @@ -85,32 +86,32 @@ insertIdPConfig idp = do NL.head (idp ^. SAML.idpMetadata . SAML.edCertAuthnResponse), NL.tail (idp ^. SAML.idpMetadata . SAML.edCertAuthnResponse), -- (the 'List1' is split up into head and tail to make migration from one-element-only easier.) - idp ^. SAML.idpExtraInfo . wiTeam, - idp ^. SAML.idpExtraInfo . wiApiVersion, - idp ^. SAML.idpExtraInfo . wiOldIssuers, - idp ^. SAML.idpExtraInfo . wiReplacedBy, - Just (unIdPHandle $ idp ^. SAML.idpExtraInfo . wiHandle) + idp ^. SAML.idpExtraInfo . IP.team, + idp ^. SAML.idpExtraInfo . IP.apiVersion, + idp ^. SAML.idpExtraInfo . IP.oldIssuers, + idp ^. SAML.idpExtraInfo . IP.replacedBy, + Just (unIdPHandle $ idp ^. SAML.idpExtraInfo . handle) ) addPrepQuery byIssuer ( idp ^. SAML.idpMetadata . SAML.edIssuer, - idp ^. SAML.idpExtraInfo . wiTeam, + idp ^. SAML.idpExtraInfo . IP.team, idp ^. SAML.idpId ) addPrepQuery byTeam ( idp ^. SAML.idpId, - idp ^. SAML.idpExtraInfo . wiTeam + idp ^. SAML.idpExtraInfo . IP.team ) where ensureDoNotMixApiVersions :: m () ensureDoNotMixApiVersions = do - let thisVersion = fromMaybe defWireIdPAPIVersion $ idp ^. SAML.idpExtraInfo . wiApiVersion + let thisVersion = fromMaybe defWireIdPAPIVersion $ idp ^. SAML.idpExtraInfo . IP.apiVersion issuer = idp ^. SAML.idpMetadata . SAML.edIssuer failIfNot :: WireIdPAPIVersion -> IdP -> m () failIfNot expectedVersion idp' = do - let actualVersion = fromMaybe defWireIdPAPIVersion $ idp' ^. SAML.idpExtraInfo . wiApiVersion + let actualVersion = fromMaybe defWireIdPAPIVersion $ idp' ^. SAML.idpExtraInfo . IP.apiVersion unless (actualVersion == expectedVersion) $ throwError InsertIdPConfigCannotMixApiVersions @@ -144,10 +145,10 @@ newUniqueHandle = newUniqueHandle' 1 where newUniqueHandle' :: Int -> [Text] -> Text newUniqueHandle' n handles = - let handle = "IdP " <> pack (show n) - in if handle `elem` handles + let handle' = "IdP " <> pack (show n) + in if handle' `elem` handles then newUniqueHandle' (n + 1) handles - else handle + else handle' getIdPConfig :: forall m. @@ -293,7 +294,7 @@ doNotMixApiVersions :: IdP -> m () doNotMixApiVersions expectVersion idp = do - let actualVersion = fromMaybe defWireIdPAPIVersion (idp ^. SAML.idpExtraInfo . wiApiVersion) + let actualVersion = fromMaybe defWireIdPAPIVersion (idp ^. SAML.idpExtraInfo . IP.apiVersion) unless (actualVersion == expectVersion) $ do throwError $ case expectVersion of WireIdPAPIV1 -> AttemptToGetV1IssuerViaV2API @@ -356,7 +357,7 @@ setReplacedBy (Replaced old) (Replacing new) = do retry x5 . write ins $ params LocalQuorum (new, old) where ins :: PrepQuery W (SAML.IdPId, SAML.IdPId) () - ins = "UPDATE idp SET replaced_by = ? WHERE idp = ?" + ins = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE idp SET replaced_by = ? WHERE idp = ?" -- | See also: 'setReplacedBy'. clearReplacedBy :: @@ -367,7 +368,7 @@ clearReplacedBy (Replaced old) = do retry x5 . write ins $ params LocalQuorum (Identity old) where ins :: PrepQuery W (Identity SAML.IdPId) () - ins = "UPDATE idp SET replaced_by = null WHERE idp = ?" + ins = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE idp SET replaced_by = null WHERE idp = ?" -- | If the IdP is 'WireIdPAPIV1', it must be deleted globally, if it is 'WireIdPAPIV2', it -- must be deleted inside one team. 'V1' can be either in the old table without team index, diff --git a/services/spar/src/Spar/Sem/IdPConfigStore/Mem.hs b/services/spar/src/Spar/Sem/IdPConfigStore/Mem.hs index 2c3a5ad0b02..a7f0ff7c4fb 100644 --- a/services/spar/src/Spar/Sem/IdPConfigStore/Mem.hs +++ b/services/spar/src/Spar/Sem/IdPConfigStore/Mem.hs @@ -75,7 +75,7 @@ insertConfig iw = . M.filter ( \iw' -> (iw' ^. SAML.idpMetadata . SAML.edIssuer /= iw ^. SAML.idpMetadata . SAML.edIssuer) - || (iw' ^. SAML.idpExtraInfo . IP.wiTeam /= iw ^. SAML.idpExtraInfo . IP.wiTeam) + || (iw' ^. SAML.idpExtraInfo . IP.team /= iw ^. SAML.idpExtraInfo . IP.team) ) getConfig :: SAML.IdPId -> TypedState -> IP.IdP @@ -106,14 +106,14 @@ getIdByIssuerWithTeamMaybe iss team mp = fl :: IP.IdP -> Bool fl idp = idp ^. SAML.idpMetadata . SAML.edIssuer == iss - && idp ^. SAML.idpExtraInfo . IP.wiTeam == team + && idp ^. SAML.idpExtraInfo . IP.team == team getConfigsByTeam :: TeamId -> TypedState -> [IP.IdP] getConfigsByTeam team = filter fl . M.elems where fl :: IP.IdP -> Bool - fl idp = idp ^. SAML.idpExtraInfo . IP.wiTeam == team + fl idp = idp ^. SAML.idpExtraInfo . IP.team == team deleteConfig :: IP.IdP -> TypedState -> TypedState deleteConfig idp = @@ -126,7 +126,7 @@ updateReplacedBy :: Maybe SAML.IdPId -> SAML.IdPId -> IP.IdP -> IP.IdP updateReplacedBy mbReplacing replaced idp = idp & if idp ^. SAML.idpId == replaced - then SAML.idpExtraInfo . IP.wiReplacedBy .~ mbReplacing + then SAML.idpExtraInfo . IP.replacedBy .~ mbReplacing else id deleteIssuer :: SAML.Issuer -> TypedState -> TypedState diff --git a/services/spar/test-integration/Main.hs b/services/spar/test-integration/Main.hs index b42bb8c66b2..3eefa283be5 100644 --- a/services/spar/test-integration/Main.hs +++ b/services/spar/test-integration/Main.hs @@ -29,6 +29,7 @@ -- the solution: https://github.com/hspec/hspec/pull/397. module Main where +import Control.Concurrent.Async import Control.Lens ((.~), (^.)) import Data.Text (pack) import Imports @@ -37,6 +38,10 @@ import Spar.Run (mkApp) import System.Environment (withArgs) import System.Random (randomRIO) import Test.Hspec +import Test.Hspec.Core.Format +import Test.Hspec.Core.Runner +import Test.Hspec.JUnit +import Test.Hspec.JUnit.Config.Env import qualified Test.LoggingSpec import qualified Test.MetricsSpec import qualified Test.Spar.APISpec @@ -53,7 +58,8 @@ main :: IO () main = do (wireArgs, hspecArgs) <- partitionArgs <$> getArgs let env = withArgs wireArgs mkEnvFromOptions - withArgs hspecArgs . hspec $ do + cfg <- hspecConfig + withArgs hspecArgs . hspecWith cfg $ do for_ [minBound ..] $ \idpApiVersion -> do describe (show idpApiVersion) . beforeAll (env <&> teWireIdPAPIVersion .~ idpApiVersion) . afterAll destroyEnv $ do mkspecMisc @@ -61,6 +67,24 @@ main = do mkspecScim mkspecHscimAcceptance env destroyEnv +hspecConfig :: IO Config +hspecConfig = do + junitConfig <- envJUnitConfig + pure $ + defaultConfig + { configAvailableFormatters = + ("junit", checksAndJUnitFormatter junitConfig) + : configAvailableFormatters defaultConfig + } + where + checksAndJUnitFormatter :: JUnitConfig -> FormatConfig -> IO Format + checksAndJUnitFormatter junitConfig config = do + junit <- junitFormat junitConfig config + let checksFormatter = fromJust (lookup "checks" $ configAvailableFormatters defaultConfig) + checks <- checksFormatter config + pure $ \event -> do + concurrently_ (junit event) (checks event) + partitionArgs :: [String] -> ([String], [String]) partitionArgs = go [] [] where diff --git a/services/spar/test-integration/Test/Spar/APISpec.hs b/services/spar/test-integration/Test/Spar/APISpec.hs index 3ec850ab8c6..8e10682c2a3 100644 --- a/services/spar/test-integration/Test/Spar/APISpec.hs +++ b/services/spar/test-integration/Test/Spar/APISpec.hs @@ -81,10 +81,11 @@ import Text.XML.DSig (SignPrivCreds, mkSignCredsWithCert) import qualified URI.ByteString as URI import URI.ByteString.QQ (uri) import Util.Core -import Util.Scim (filterBy, listUsers, registerScimToken) +import Util.Scim (createUser, filterBy, listUsers, randomScimUser, randomScimUserWithEmail, registerScimToken) import qualified Util.Scim as ScimT import Util.Types import qualified Web.Cookie as Cky +import qualified Web.Scim.Class.User as Scim import qualified Web.Scim.Schema.User as Scim import Wire.API.Team.Member (newTeamMemberDeleteData) import Wire.API.Team.Permission hiding (self) @@ -258,12 +259,12 @@ specFinalizeLogin = do context "happy flow" $ do it "responds with a very peculiar 'allowed' HTTP response" $ do env <- ask - let apiVersion = env ^. teWireIdPAPIVersion + let apiVer = env ^. teWireIdPAPIVersion (_, tid, idp, (_, privcreds)) <- registerTestIdPWithMeta - liftIO $ fromMaybe defWireIdPAPIVersion (idp ^. idpExtraInfo . wiApiVersion) `shouldBe` apiVersion + liftIO $ fromMaybe defWireIdPAPIVersion (idp ^. idpExtraInfo . apiVersion) `shouldBe` apiVer spmeta <- getTestSPMetadata tid authnreq <- negotiateAuthnRequest idp - let audiencePath = case apiVersion of + let audiencePath = case apiVer of WireIdPAPIV1 -> "/sso/finalize-login" WireIdPAPIV2 -> "/sso/finalize-login/" <> toByteString' tid liftIO $ authnreq ^. rqIssuer . fromIssuer . to URI.uriPath `shouldBe` audiencePath @@ -621,7 +622,7 @@ specCRUDIdentityProvider = do (owner :: UserId, _teamid :: TeamId) <- call $ createUserWithTeam (env ^. teBrig) (env ^. teGalley) callIdpGetAll (env ^. teSpar) (Just owner) - `shouldRespondWith` (null . _idplProviders) + `shouldRespondWith` (null . _providers) context "some idps are registered" $ do context "client is team owner with email" $ do it "returns a non-empty empty list" $ do @@ -629,7 +630,7 @@ specCRUDIdentityProvider = do (SampleIdP metadata _ _ _) <- makeSampleIdPMetadata (owner, _, _) <- registerTestIdPFrom metadata (env ^. teMgr) (env ^. teBrig) (env ^. teGalley) (env ^. teSpar) callIdpGetAll (env ^. teSpar) (Just owner) - `shouldRespondWith` (not . null . _idplProviders) + `shouldRespondWith` (not . null . _providers) context "client is team owner without email" $ do it "returns a non-empty empty list" $ do env <- ask @@ -637,7 +638,7 @@ specCRUDIdentityProvider = do (firstOwner, tid, idp) <- registerTestIdPFrom metadata (env ^. teMgr) (env ^. teBrig) (env ^. teGalley) (env ^. teSpar) ssoOwner <- mkSsoOwner firstOwner tid idp privcreds callIdpGetAll (env ^. teSpar) (Just ssoOwner) - `shouldRespondWith` (not . null . _idplProviders) + `shouldRespondWith` (not . null . _providers) describe "DELETE /identity-providers/:idp" $ do testGetPutDelete (\o t i _ -> callIdpDelete' o t i) context "zuser has wrong team" $ do @@ -722,12 +723,12 @@ specCRUDIdentityProvider = do (SampleIdP metadata _ _ _) <- makeSampleIdPMetadata idp <- call $ callIdpCreate (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner) metadata callIdpGet (env ^. teSpar) (Just owner) (idp ^. idpId) - `shouldRespondWith` ((== IdPHandle "IdP 1") . (\idp' -> idp' ^. (SAML.idpExtraInfo . wiHandle))) + `shouldRespondWith` ((== IdPHandle "IdP 1") . (\idp' -> idp' ^. (SAML.idpExtraInfo . handle))) let expected = IdPHandle "kukku mukku" callIdpUpdateWithHandle (env ^. teSpar) (Just owner) (idp ^. idpId) (IdPMetadataValue (cs $ SAML.encode metadata) undefined) expected `shouldRespondWith` ((== 200) . statusCode) callIdpGet (env ^. teSpar) (Just owner) (idp ^. idpId) - `shouldRespondWith` ((== expected) . (\idp' -> idp' ^. (SAML.idpExtraInfo . wiHandle))) + `shouldRespondWith` ((== expected) . (\idp' -> idp' ^. (SAML.idpExtraInfo . handle))) it "updates IdP metadata and creates a new IdP with the first metadata" $ do env <- ask (owner, _) <- call $ createUserWithTeam (env ^. teBrig) (env ^. teGalley) @@ -785,8 +786,8 @@ specCRUDIdentityProvider = do idp <- runSpar $ IdPEffect.getConfig idpid1 liftIO $ do (idp ^. idpMetadata . edIssuer) `shouldBe` (idpmeta1 ^. edIssuer) - (idp ^. idpExtraInfo . wiOldIssuers) `shouldBe` [] - (idp ^. idpExtraInfo . wiReplacedBy) `shouldBe` Nothing + (idp ^. idpExtraInfo . oldIssuers) `shouldBe` [] + (idp ^. idpExtraInfo . replacedBy) `shouldBe` Nothing let -- change idp metadata (only issuer, to be precise), and look at new issuer and -- old issuers. @@ -798,8 +799,8 @@ specCRUDIdentityProvider = do idp <- runSpar $ IdPEffect.getConfig idpid1 liftIO $ do (idp ^. idpMetadata . edIssuer) `shouldBe` (new ^. edIssuer) - sort (idp ^. idpExtraInfo . wiOldIssuers) `shouldBe` sort (olds <&> (^. edIssuer)) - (idp ^. idpExtraInfo . wiReplacedBy) `shouldBe` Nothing + sort (idp ^. idpExtraInfo . oldIssuers) `shouldBe` sort (olds <&> (^. edIssuer)) + (idp ^. idpExtraInfo . replacedBy) `shouldBe` Nothing -- update the name a few times, ending up with the original one. change idpmeta1' [idpmeta1] @@ -819,7 +820,7 @@ specCRUDIdentityProvider = do liftIO $ do statusCode resp `shouldBe` 200 idp ^. idpMetadata . edIssuer `shouldBe` issuer2 - idp ^. idpExtraInfo . wiOldIssuers `shouldBe` [idpmeta1 ^. edIssuer] + idp ^. idpExtraInfo . oldIssuers `shouldBe` [idpmeta1 ^. edIssuer] it "migrates old users to new idp on their next login (auto-prov)" $ do env <- ask (owner1, _, idp1@((^. idpId) -> idpid1), (IdPMetadataValue _ idpmeta1, privkey1)) <- registerTestIdPWithMeta @@ -916,7 +917,7 @@ specCRUDIdentityProvider = do check :: HasCallStack => Bool -> Int -> String -> Either String () -> TestSpar () check useNewPrivKey expectedStatus expectedHtmlTitle expectedCookie = do (idp, oldPrivKey, newPrivKey) <- initidp - let tid = idp ^. idpExtraInfo . wiTeam + let tid = idp ^. idpExtraInfo . team env <- ask (_, authnreq) <- call $ callAuthnReq (env ^. teSpar) (idp ^. idpId) spmeta <- getTestSPMetadata tid @@ -1024,14 +1025,14 @@ specCRUDIdentityProvider = do liftIO $ do idp1 `shouldBe` idp1' idp2 `shouldBe` idp2' - (idp1 ^. (SAML.idpExtraInfo . wiHandle)) `shouldBe` IdPHandle "IdP 1" - (idp2 ^. (SAML.idpExtraInfo . wiHandle)) `shouldBe` IdPHandle "IdP 2" + (idp1 ^. (SAML.idpExtraInfo . handle)) `shouldBe` IdPHandle "IdP 1" + (idp2 ^. (SAML.idpExtraInfo . handle)) `shouldBe` IdPHandle "IdP 2" it "explicitly set handle on IdP create" $ do env <- ask (owner, _) <- call $ createUserWithTeam (env ^. teBrig) (env ^. teGalley) (SampleIdP metadata _ _ _) <- makeSampleIdPMetadata let expected = IdPHandle "kukku mukku" - actual <- (\idp -> idp ^. (SAML.idpExtraInfo . wiHandle)) <$> call (callIdpCreateWithHandle (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner) metadata expected) + actual <- (\idp -> idp ^. (SAML.idpExtraInfo . handle)) <$> call (callIdpCreateWithHandle (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner) metadata expected) liftIO $ actual `shouldBe` expected context "client is owner without email" $ do it "responds with 2xx; makes IdP available for GET /identity-providers/" $ do @@ -1054,34 +1055,99 @@ specCRUDIdentityProvider = do idp `shouldBe` idp' let prefix = " IdP - erase = - (idpId .~ (idp1 ^. idpId)) - . (idpMetadata . edIssuer .~ (idp1 ^. idpMetadata . edIssuer)) - . (idpExtraInfo . wiOldIssuers .~ (idp1 ^. idpExtraInfo . wiOldIssuers)) - . (idpExtraInfo . wiReplacedBy .~ (idp1 ^. idpExtraInfo . wiReplacedBy)) - . (idpExtraInfo . wiHandle .~ (idp1 ^. idpExtraInfo . wiHandle)) - erase idp1 `shouldBe` erase idp2 + + describe "replaces an existing idp" + $ forM_ + [ (h, u, e) + | h <- [False, True], -- are users scim provisioned or via team management invitations? + u <- [False, True], -- do we use update-by-put or update-by-post? (see below) + (h, u) /= (True, False), -- scim doesn't not work with more than one idp (https://wearezeta.atlassian.net/browse/WPB-689) + e <- [False, True], -- is the externalId an email address? (if not, it's a uuidv4, and the email address is stored in `emails`) + (u, u, e) /= (True, True, False) -- TODO: this combination fails, see https://github.com/wireapp/wire-server/pull/3563) + ] + $ \(haveScim, updateNotReplace, externalIdIsEmail) -> do + it ("creates new idp, setting old_issuer; sets replaced_by in old idp; scim user search still works " <> show (haveScim, updateNotReplace, externalIdIsEmail)) $ do + env <- ask + (owner1, teamid, idp1, (IdPMetadataValue _ idpmeta1, _privCreds)) <- registerTestIdPWithMeta + let idp1id = idp1 ^. idpId + + mbScimStuff :: Maybe (ScimToken, Scim.StoredUser SparTag, Scim.User SparTag) <- + if haveScim + then do + tok <- registerScimToken teamid (Just idp1id) + user <- + if externalIdIsEmail + then fst <$> randomScimUserWithEmail + else randomScimUser + scimStoredUser <- createUser tok user + pure $ Just (tok, scimStoredUser, user) + else pure Nothing + + let checkScimSearch :: + HasCallStack => + (ScimToken, Scim.StoredUser SparTag, Scim.User SparTag) -> + ReaderT TestEnv IO () + checkScimSearch (tok, target, searchKeys) = do + let Just externalId = Scim.externalId searchKeys + handle' = Scim.userName searchKeys + respId <- listUsers tok (Just (filterBy "externalId" externalId)) + respHandle <- listUsers tok (Just (filterBy "userName" handle')) + liftIO $ do + respId `shouldBe` [target] + respHandle `shouldBe` [target] + + checkScimSearch `mapM_` mbScimStuff + + issuer2 <- makeIssuer + idp2 <- do + let idpmeta2 = idpmeta1 & edIssuer .~ issuer2 + in call $ + -- There are two mechanisms for re-aligning your team when your IdP metadata + -- has changed: POST (create a new one, and mark it as replacing the old one), + -- and PUT (updating the existing IdP's metadata). The reason for having two + -- ways to do this has been lost in history, but we're testing both here. + -- + -- FUTUREWORK: deprecate POST! + if updateNotReplace + then callIdpUpdate' (env ^. teSpar) (Just owner1) (idp1 ^. SAML.idpId) (idPMetadataToInfo idpmeta2) + else callIdpCreateReplace (env ^. teWireIdPAPIVersion) (env ^. teSpar) (Just owner1) idpmeta2 (idp1 ^. SAML.idpId) + + idp1' <- call $ callIdpGet (env ^. teSpar) (Just owner1) (idp1 ^. SAML.idpId) + idp2' <- call $ callIdpGet (env ^. teSpar) (Just owner1) (idp2 ^. SAML.idpId) + liftIO $ do + let updateIdp1 = updateCurrentIssuer . updateOldIssuers + where + updateCurrentIssuer = idpMetadata . edIssuer .~ (idp2' ^. idpMetadata . edIssuer) + updateOldIssuers = idpExtraInfo . oldIssuers .~ [idp1 ^. idpMetadata . edIssuer] + replaceIdp1 = + idpExtraInfo . replacedBy .~ idp1' ^. idpExtraInfo . replacedBy + in idp1' `shouldBe` (idp1 & if updateNotReplace then updateIdp1 else replaceIdp1) + + idp2' `shouldBe` idp2 + idp1 ^. idpMetadata . SAML.edIssuer `shouldBe` (idpmeta1 ^. SAML.edIssuer) + idp2 ^. idpMetadata . SAML.edIssuer `shouldBe` issuer2 + + if updateNotReplace + then idp2 ^. idpId `shouldBe` idp1 ^. idpId + else idp2 ^. idpId `shouldNotBe` idp1 ^. idpId + + idp2 ^. idpExtraInfo . oldIssuers `shouldBe` [idpmeta1 ^. edIssuer] + idp1' ^. idpExtraInfo . replacedBy `shouldBe` if updateNotReplace then Nothing else Just (idp2 ^. idpId) + + -- erase everything that is supposed to be different between idp1, idp2, and make + -- sure the result is equal. + let erase :: IdP -> IdP + erase = + (idpId .~ (idp1 ^. idpId)) + . (idpMetadata . edIssuer .~ (idp1 ^. idpMetadata . edIssuer)) + . (idpExtraInfo . oldIssuers .~ (idp1 ^. idpExtraInfo . oldIssuers)) + . (idpExtraInfo . replacedBy .~ (idp1 ^. idpExtraInfo . replacedBy)) + . (idpExtraInfo . handle .~ (idp1 ^. idpExtraInfo . handle)) + in erase idp1 `shouldBe` erase idp2 + + checkScimSearch `mapM_` mbScimStuff + + describe "replaces an existing idp (cont.)" $ do it "users can still login on old idp as before" $ do env <- ask (owner1, _, idp1, (IdPMetadataValue _ idpmeta1, privkey1)) <- registerTestIdPWithMeta @@ -1100,6 +1166,7 @@ specCRUDIdentityProvider = do olduid `shouldBe` newuid (olduref ^. SAML.uidTenant) `shouldBe` issuer1 (newuref ^. SAML.uidTenant) `shouldBe` issuer1 + it "migrates old users to new idp on their next login on new idp; after that, login on old won't work any more" $ do env <- ask (owner1, _, idp1, (IdPMetadataValue _ idpmeta1, privkey1)) <- registerTestIdPWithMeta @@ -1120,6 +1187,7 @@ specCRUDIdentityProvider = do (olduref ^. SAML.uidTenant) `shouldBe` issuer1 (newuref ^. SAML.uidTenant) `shouldBe` issuer2 tryLoginFail privkey1 idp1 userSubject "cannont-provision-on-replaced-idp" + it "creates non-existent users on new idp" $ do env <- ask (owner1, _, idp1, (IdPMetadataValue _ idpmeta1, privkey1)) <- registerTestIdPWithMeta @@ -1211,7 +1279,7 @@ specDeleteCornerCases = describe "delete corner cases" $ do createViaSamlResp :: HasCallStack => IdP -> SignPrivCreds -> SAML.UserRef -> TestSpar ResponseLBS createViaSamlResp idp privCreds (SAML.UserRef _ subj) = do - let tid = idp ^. idpExtraInfo . wiTeam + let tid = idp ^. idpExtraInfo . team authnReq <- negotiateAuthnRequest idp spmeta <- getTestSPMetadata tid authnResp <- runSimpleSP $ mkAuthnResponseWithSubj subj privCreds idp spmeta authnReq True @@ -1297,7 +1365,7 @@ specScimAndSAML = do mkNameID subj (Just "https://federation.foobar.com/nidp/saml2/metadata") (Just "https://prod-nginz-https.wire.com/sso/finalize-login") Nothing authnreq <- negotiateAuthnRequest idp - spmeta <- getTestSPMetadata (idp ^. idpExtraInfo . wiTeam) + spmeta <- getTestSPMetadata (idp ^. idpExtraInfo . team) authnresp :: SignedAuthnResponse <- runSimpleSP $ mkAuthnResponseWithSubj subjectWithQualifier privcreds idp spmeta authnreq True ssoid <- getSsoidViaAuthResp authnresp @@ -1565,8 +1633,8 @@ specReAuthSsoUserWithPassword = if withIdp then do SampleIdP idpmeta _privkey _ _ <- makeSampleIdPMetadata - apiVersion <- view teWireIdPAPIVersion - idp <- call $ callIdpCreate apiVersion (env ^. teSpar) (Just owner) idpmeta + apiVer <- view teWireIdPAPIVersion + idp <- call $ callIdpCreate apiVer (env ^. teSpar) (Just owner) idpmeta pure $ Just (idp ^. idpId) else pure Nothing -- then user gets upgraded to scim with or without SAML diff --git a/services/spar/test-integration/Test/Spar/AppSpec.hs b/services/spar/test-integration/Test/Spar/AppSpec.hs index a0aa834dd0b..6009dd511e5 100644 --- a/services/spar/test-integration/Test/Spar/AppSpec.hs +++ b/services/spar/test-integration/Test/Spar/AppSpec.hs @@ -152,7 +152,7 @@ requestAccessVerdict idp isGranted mkAuthnReq = do raw <- mkAuthnReq (idp ^. SAML.idpId) bdy <- maybe (error "authreq") pure $ responseBody raw either (error . show) pure $ Servant.mimeUnrender (Servant.Proxy @SAML.HTML) bdy - spmeta <- getTestSPMetadata (idp ^. idpExtraInfo . User.wiTeam) + spmeta <- getTestSPMetadata (idp ^. idpExtraInfo . User.team) (privKey, _, _) <- DSig.mkSignCredsWithCert Nothing 96 authnresp :: SAML.AuthnResponse <- do case authnreq of diff --git a/services/spar/test-integration/Test/Spar/DataSpec.hs b/services/spar/test-integration/Test/Spar/DataSpec.hs index 46f7fe88e64..b81715f6f89 100644 --- a/services/spar/test-integration/Test/Spar/DataSpec.hs +++ b/services/spar/test-integration/Test/Spar/DataSpec.hs @@ -154,7 +154,7 @@ spec = do it "getIdPByIssuer works" $ do idp <- makeTestIdP () <- runSpar $ IdPEffect.insertConfig idp - midp <- getIdPByIssuer (idp ^. idpMetadata . edIssuer) (idp ^. SAML.idpExtraInfo . wiTeam) + midp <- getIdPByIssuer (idp ^. idpMetadata . edIssuer) (idp ^. SAML.idpExtraInfo . team) liftIO $ midp `shouldBe` Just idp it "getIdPConfigsByTeam works" $ do skipIdPAPIVersions [WireIdPAPIV1] @@ -176,10 +176,10 @@ spec = do idpOrError <- runSparE $ IdPEffect.getConfig (idp ^. idpId) liftIO $ idpOrError `shouldBe` Left (SAML.CustomError $ IdpDbError IdpNotFound) do - midp <- getIdPByIssuer (idp ^. idpMetadata . edIssuer) (idp ^. SAML.idpExtraInfo . wiTeam) + midp <- getIdPByIssuer (idp ^. idpMetadata . edIssuer) (idp ^. SAML.idpExtraInfo . team) liftIO $ midp `shouldBe` Nothing do - midp <- getIdPByIssuer (idp ^. idpMetadata . edIssuer) (idp ^. SAML.idpExtraInfo . wiTeam) + midp <- getIdPByIssuer (idp ^. idpMetadata . edIssuer) (idp ^. SAML.idpExtraInfo . team) liftIO $ midp `shouldBe` Nothing do idps <- runSpar $ IdPEffect.getConfigsByTeam teamid @@ -274,7 +274,7 @@ testDeleteTeam = it "cleans up all the right tables after deletion" $ do -- The config from 'issuer_idp': do let issuer = idp ^. SAML.idpMetadata . SAML.edIssuer - mbIdp <- getIdPByIssuer issuer (idp ^. SAML.idpExtraInfo . wiTeam) + mbIdp <- getIdPByIssuer issuer (idp ^. SAML.idpExtraInfo . team) liftIO $ mbIdp `shouldBe` Nothing -- The config from 'team_idp': do diff --git a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs index fae49de6ac4..709a659088d 100644 --- a/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs +++ b/services/spar/test-integration/Test/Spar/Scim/UserSpec.hs @@ -1057,7 +1057,7 @@ samlUserShouldSatisfy uref property = do createViaSamlResp :: HasCallStack => IdP -> SAML.SignPrivCreds -> SAML.UserRef -> TestSpar ResponseLBS createViaSamlResp idp privCreds (SAML.UserRef _ subj) = do authnReq <- negotiateAuthnRequest idp - let tid = idp ^. SAML.idpExtraInfo . User.wiTeam + let tid = idp ^. SAML.idpExtraInfo . User.team spmeta <- getTestSPMetadata tid authnResp <- runSimpleSP $ @@ -1135,7 +1135,7 @@ testCreateUserTimeout = do tryquery (filterBy "externalId" $ fromEmail email) waitUserExpiration = do - timeoutSecs <- view (teTstOpts . to cfgBrigSettingsTeamInvitationTimeout) + timeoutSecs <- view (teTstOpts . to brigSettingsTeamInvitationTimeout) Control.Exception.assert (timeoutSecs < 30) $ do threadDelay $ (timeoutSecs + 1) * 1_000_000 diff --git a/services/spar/test-integration/Util/Core.hs b/services/spar/test-integration/Util/Core.hs index bc3342f6e79..3298ca8047f 100644 --- a/services/spar/test-integration/Util/Core.hs +++ b/services/spar/test-integration/Util/Core.hs @@ -111,6 +111,7 @@ module Util.Core callIdpCreateReplace, callIdpCreateReplace', callIdpCreateWithHandle, + callIdpUpdate', callIdpUpdate, callIdpUpdateWithHandle, callIdpDelete, @@ -139,7 +140,7 @@ module Util.Core ) where -import Bilge hiding (getCookie) -- we use Web.Cookie instead of the http-client type +import Bilge hiding (getCookie, host, port) -- we use Web.Cookie instead of the http-client type import qualified Bilge import Bilge.Assert (Assertions, (!!!), ( -- would be a good place to look for code to steal. mkEnv :: HasCallStack => IntegrationConfig -> Opts -> IO TestEnv -mkEnv _teTstOpts _teOpts = do - _teMgr :: Manager <- newManager defaultManagerSettings - sparCtxLogger <- Log.mkLogger (samlToLevel $ saml _teOpts ^. SAML.cfgLogLevel) (logNetStrings _teOpts) (logFormat _teOpts) - _teCql :: ClientState <- initCassandra _teOpts sparCtxLogger - let _teBrig = endpointToReq (cfgBrig _teTstOpts) - _teGalley = endpointToReq (cfgGalley _teTstOpts) - _teSpar = endpointToReq (cfgSpar _teTstOpts) - _teSparEnv = Spar.Env {..} - _teWireIdPAPIVersion = WireIdPAPIV2 - sparCtxOpts = _teOpts - sparCtxCas = _teCql - sparCtxHttpManager = _teMgr - sparCtxHttpBrig = _teBrig empty - sparCtxHttpGalley = _teGalley empty +mkEnv tstOpts opts = do + mgr :: Manager <- newManager defaultManagerSettings + sparCtxLogger <- Log.mkLogger (samlToLevel $ saml opts ^. SAML.cfgLogLevel) (logNetStrings opts) (logFormat opts) + cql :: ClientState <- initCassandra opts sparCtxLogger + let brig = endpointToReq tstOpts.brig + galley = endpointToReq tstOpts.galley + spar = endpointToReq tstOpts.spar + sparEnv = Spar.Env {..} + wireIdPAPIVersion = WireIdPAPIV2 + sparCtxOpts = opts + sparCtxCas = cql + sparCtxHttpManager = mgr + sparCtxHttpBrig = brig empty + sparCtxHttpGalley = galley empty sparCtxRequestId = RequestId "" - pure TestEnv {..} + pure $ + TestEnv + mgr + cql + brig + galley + spar + sparEnv + opts + tstOpts + wireIdPAPIVersion destroyEnv :: HasCallStack => TestEnv -> IO () destroyEnv _ = pure () @@ -377,9 +388,9 @@ createUserWithTeamDisableSSO brg gly = do ] bdy <- selfUser . responseJsonUnsafe <$> post (brg . path "/i/users" . contentJson . body p) let (uid, Just tid) = (userId bdy, userTeam bdy) - (team : _) <- (^. Galley.teamListTeams) <$> getTeams uid gly + (team' : _) <- (^. Galley.teamListTeams) <$> getTeams uid gly () <- - Control.Exception.assert {- "Team ID in registration and team table do not match" -} (tid == team ^. Galley.teamId) $ + Control.Exception.assert {- "Team ID in registration and team table do not match" -} (tid == team' ^. Galley.teamId) $ pure () selfTeam <- userTeam . selfUser <$> getSelfProfile brg uid () <- @@ -728,22 +739,22 @@ zConn :: ByteString -> Request -> Request zConn = header "Z-Connection" endpointToReq :: Endpoint -> (Bilge.Request -> Bilge.Request) -endpointToReq ep = Bilge.host (ep ^. epHost . to cs) . Bilge.port (ep ^. epPort) +endpointToReq ep = Bilge.host (ep ^. host . to cs) . Bilge.port (ep ^. port) endpointToSettings :: Endpoint -> Warp.Settings -endpointToSettings endpoint = +endpointToSettings ep = Warp.defaultSettings - { Warp.settingsHost = Imports.fromString . cs $ endpoint ^. epHost, - Warp.settingsPort = fromIntegral $ endpoint ^. epPort + { Warp.settingsHost = Imports.fromString . cs $ ep ^. host, + Warp.settingsPort = fromIntegral $ ep ^. port } endpointToURL :: MonadIO m => Endpoint -> Text -> m URI -endpointToURL endpoint urlpath = either err pure url +endpointToURL ep urlpath = either err pure url where url = parseURI' ("http://" <> urlhost <> ":" <> urlport) <&> (=/ urlpath) - urlhost = cs $ endpoint ^. epHost - urlport = cs . show $ endpoint ^. epPort - err = liftIO . throwIO . ErrorCall . show . (,(endpoint, url)) + urlhost = cs $ ep ^. host + urlport = cs . show $ ep ^. port + err = liftIO . throwIO . ErrorCall . show . (,(ep, url)) -- spar specifics @@ -821,10 +832,10 @@ registerTestIdPFrom :: SparReq -> m (UserId, TeamId, IdP) registerTestIdPFrom metadata mgr brig galley spar = do - apiVersion <- view teWireIdPAPIVersion + apiVer <- view teWireIdPAPIVersion liftIO . runHttpT mgr $ do (uid, tid) <- createUserWithTeam brig galley - (uid,tid,) <$> callIdpCreate apiVersion spar (Just uid) metadata + (uid,tid,) <$> callIdpCreate apiVer spar (Just uid) metadata getCookie :: KnownSymbol name => proxy name -> ResponseLBS -> Either String (SAML.SimpleSetCookie name) getCookie proxy rsp = do @@ -850,7 +861,7 @@ hasPersistentCookieHeader rsp = do tryLogin :: HasCallStack => SignPrivCreds -> IdP -> NameID -> TestSpar SAML.UserRef tryLogin privkey idp userSubject = do env <- ask - let tid = idp ^. idpExtraInfo . wiTeam + let tid = idp ^. idpExtraInfo . team spmeta <- getTestSPMetadata tid (_, authnreq) <- call $ callAuthnReq (env ^. teSpar) (idp ^. SAML.idpId) idpresp <- runSimpleSP $ mkAuthnResponseWithSubj userSubject privkey idp spmeta authnreq True @@ -865,7 +876,7 @@ tryLogin privkey idp userSubject = do tryLoginFail :: HasCallStack => SignPrivCreds -> IdP -> NameID -> String -> TestSpar () tryLoginFail privkey idp userSubject bodyShouldContain = do env <- ask - let tid = idp ^. idpExtraInfo . wiTeam + let tid = idp ^. idpExtraInfo . team spmeta <- getTestSPMetadata tid (_, authnreq) <- call $ callAuthnReq (env ^. teSpar) (idp ^. SAML.idpId) idpresp <- runSimpleSP $ mkAuthnResponseWithSubj userSubject privkey idp spmeta authnreq True @@ -946,7 +957,7 @@ loginCreatedSsoUser :: m (UserId, Cookie) loginCreatedSsoUser nameid idp privCreds = do env <- ask - let tid = idp ^. idpExtraInfo . wiTeam + let tid = idp ^. idpExtraInfo . team authnReq <- negotiateAuthnRequest idp spmeta <- getTestSPMetadata tid authnResp <- runSimpleSP $ mkAuthnResponseWithSubj nameid privCreds idp spmeta authnReq True @@ -1154,6 +1165,12 @@ callIdpCreateReplace' apiversion sparreq_ muid metadata idpid = do . body (RequestBodyLBS . cs $ SAML.encode metadata) . header "Content-Type" "application/xml" +callIdpUpdate' :: (Monad m, MonadIO m, MonadHttp m) => SparReq -> Maybe UserId -> IdPId -> IdPMetadataInfo -> m IdP +callIdpUpdate' sparreq_ muid idpid metainfo = do + resp <- callIdpUpdate (sparreq_ . expect2xx) muid idpid metainfo + either (liftIO . throwIO . ErrorCall . show) pure $ + responseJsonEither @IdP resp + callIdpUpdate :: MonadHttp m => SparReq -> Maybe UserId -> IdPId -> IdPMetadataInfo -> m ResponseLBS callIdpUpdate sparreq_ muid idpid (IdPMetadataValue metadata _) = do put $ diff --git a/services/spar/test-integration/Util/Scim.hs b/services/spar/test-integration/Util/Scim.hs index e070ff6b7e7..eeac183d19d 100644 --- a/services/spar/test-integration/Util/Scim.hs +++ b/services/spar/test-integration/Util/Scim.hs @@ -62,7 +62,7 @@ import qualified Web.Scim.Schema.User.Phone as Phone import qualified Wire.API.Team.Member as Member import Wire.API.Team.Role (Role, defaultRole) import Wire.API.User -import Wire.API.User.IdentityProvider +import Wire.API.User.IdentityProvider hiding (team) import Wire.API.User.RichInfo import Wire.API.User.Scim @@ -153,6 +153,12 @@ randomScimUserWithSubjectAndRichInfo richInfo = do subj ) +-- | Use the email address as externalId. +-- +-- FUTUREWORK: since https://wearezeta.atlassian.net/browse/SQSERVICES-157 is done, we also +-- support externalIds that are not emails, and storing email addresses in `emails` in the +-- scim schema. `randomScimUserWithEmail` is from a time where non-idp-authenticated users +-- could only be provisioned with email as externalId. we should probably rework all that. randomScimUserWithEmail :: MonadRandom m => m (Scim.User.User SparTag, Email) randomScimUserWithEmail = do suffix <- cs <$> replicateM 7 (getRandomR ('0', '9')) diff --git a/services/spar/test-integration/Util/Types.hs b/services/spar/test-integration/Util/Types.hs index ecdf4db4aff..777470f2bb2 100644 --- a/services/spar/test-integration/Util/Types.hs +++ b/services/spar/test-integration/Util/Types.hs @@ -50,7 +50,6 @@ import Data.Aeson import qualified Data.Aeson as Aeson import Data.Aeson.TH import Imports -import SAML2.WebSSO.Types.TH (deriveJSONOptions) import Spar.API () import qualified Spar.App as Spar import Spar.Options @@ -93,14 +92,14 @@ data TestEnv = TestEnv type Select = TestEnv -> (Request -> Request) data IntegrationConfig = IntegrationConfig - { cfgBrig :: Endpoint, - cfgGalley :: Endpoint, - cfgSpar :: Endpoint, - cfgBrigSettingsTeamInvitationTimeout :: Int + { brig :: Endpoint, + galley :: Endpoint, + spar :: Endpoint, + brigSettingsTeamInvitationTimeout :: Int } deriving (Show, Generic) -deriveFromJSON deriveJSONOptions ''IntegrationConfig +deriveFromJSON Aeson.defaultOptions ''IntegrationConfig makeLenses ''TestEnv diff --git a/services/spar/test/Arbitrary.hs b/services/spar/test/Arbitrary.hs index 7480a1fcdc0..44d8f38ddac 100644 --- a/services/spar/test/Arbitrary.hs +++ b/services/spar/test/Arbitrary.hs @@ -1,5 +1,4 @@ {-# LANGUAGE GeneralizedNewtypeDeriving #-} -{-# LANGUAGE RecordWildCards #-} {-# LANGUAGE TypeSynonymInstances #-} {-# OPTIONS_GHC -Wno-orphans #-} {-# OPTIONS_GHC -Wno-redundant-constraints #-} @@ -25,8 +24,8 @@ module Arbitrary where import Data.Aeson import Data.Id (TeamId, UserId) +import Data.OpenApi hiding (Header (..)) import Data.Proxy -import Data.Swagger hiding (Header (..)) import Imports import SAML2.WebSSO.Test.Arbitrary () import SAML2.WebSSO.Types @@ -39,9 +38,7 @@ import Wire.API.User.IdentityProvider import Wire.API.User.Saml instance Arbitrary IdPList where - arbitrary = do - _idplProviders <- arbitrary - pure $ IdPList {..} + arbitrary = IdPList <$> arbitrary instance Arbitrary WireIdP where arbitrary = WireIdP <$> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary <*> arbitrary diff --git a/services/spar/test/Test/Spar/APISpec.hs b/services/spar/test/Test/Spar/APISpec.hs index ceefb59e489..a82d00c40f6 100644 --- a/services/spar/test/Test/Spar/APISpec.hs +++ b/services/spar/test/Test/Spar/APISpec.hs @@ -27,7 +27,7 @@ import Data.Metrics.Servant (routesToPaths) import Data.Metrics.Test (pathsConsistencyCheck) import Data.Proxy (Proxy (Proxy)) import Imports -import Servant.Swagger (validateEveryToJSON) +import Servant.OpenApi (validateEveryToJSON) import Spar.API as API import Test.Hspec (Spec, describe, it, shouldBe, shouldSatisfy) import Test.QuickCheck (property) @@ -37,9 +37,9 @@ import Wire.API.User.Saml (SsoSettings) spec :: Spec spec = do -- Note: SCIM types are not validated because their content-type is 'SCIM'. - validateEveryToJSON (Proxy @API.API) + validateEveryToJSON (Proxy @API.SparAPI) it "api consistency" $ do - pathsConsistencyCheck (routesToPaths @API.API) `shouldBe` mempty + pathsConsistencyCheck (routesToPaths @API.SparAPI) `shouldBe` mempty it "roundtrip: IdPMetadataInfo" . property $ \(val :: IdPMetadataInfo) -> do let withoutRaw (IdPMetadataValue _ x) = x (withoutRaw <$> (Aeson.eitherDecode . Aeson.encode) val) `shouldBe` Right (withoutRaw val) diff --git a/tools/db/billing-team-member-backfill/README.md b/tools/db/billing-team-member-backfill/README.md deleted file mode 100644 index 5d72bd0e6a6..00000000000 --- a/tools/db/billing-team-member-backfill/README.md +++ /dev/null @@ -1,14 +0,0 @@ -## billing_team_member backfill - -A tool for filling table `billing_team_member` from existing data. - -### How to run this - -```sh -export GALLEY_HOST=... # ip address of galley cassandra DB node -export GALLEY_KEYSPACE=galley - -ssh -v -f ubuntu@${GALLEY_HOST} -L 2021:${GALLEY_HOST}:9042 -N - -./dist/billing-team-member-backfill --cassandra-host-galley=localhost --cassandra-port-galley=2021 --cassandra-keyspace-galley=${GALLEY_KEYSPACE} -``` diff --git a/tools/db/billing-team-member-backfill/src/Work.hs b/tools/db/billing-team-member-backfill/src/Work.hs deleted file mode 100644 index b7d1e9e4083..00000000000 --- a/tools/db/billing-team-member-backfill/src/Work.hs +++ /dev/null @@ -1,81 +0,0 @@ -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE ScopedTypeVariables #-} - --- This file is part of the Wire Server implementation. --- --- Copyright (C) 2022 Wire Swiss GmbH --- --- This program is free software: you can redistribute it and/or modify it under --- the terms of the GNU Affero General Public License as published by the Free --- Software Foundation, either version 3 of the License, or (at your option) any --- later version. --- --- This program is distributed in the hope that it will be useful, but WITHOUT --- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS --- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more --- details. --- --- You should have received a copy of the GNU Affero General Public License along --- with this program. If not, see . - -module Work where - -import Cassandra -import Conduit -import Control.Lens (view) -import Data.Conduit.Internal (zipSources) -import Data.Conduit.List qualified as C -import Data.Id -import Data.Set qualified as Set -import Imports -import System.Logger (Logger) -import System.Logger qualified as Log -import Wire.API.Team.Permission - -runCommand :: Logger -> ClientState -> IO () -runCommand l galley = - runConduit $ - zipSources - (C.sourceList [(1 :: Int32) ..]) - (transPipe (runClient galley) getTeamMembers) - .| C.mapM - ( \(i, p) -> - Log.info l (Log.field "team members" (show (i * pageSize))) - >> pure p - ) - .| C.concatMap (filter isOwner) - .| C.map (\(t, u, _) -> (t, u)) - .| C.chunksOf 50 - .| C.mapM - ( \x -> - Log.info l (Log.field "writing billing team members" (show (length x))) - >> pure x - ) - .| C.mapM_ (runClient galley . createBillingTeamMembers) - -pageSize :: Int32 -pageSize = 1000 - ----------------------------------------------------------------------------- --- Queries - --- | Get team members from Galley -getTeamMembers :: ConduitM () [(TeamId, UserId, Maybe Permissions)] Client () -getTeamMembers = paginateC cql (paramsP LocalQuorum () pageSize) x5 - where - cql :: PrepQuery R () (TeamId, UserId, Maybe Permissions) - cql = "SELECT team, user, perms FROM team_member" - -createBillingTeamMembers :: [(TeamId, UserId)] -> Client () -createBillingTeamMembers pairs = - retry x5 . batch $ do - setType BatchLogged - setConsistency LocalQuorum - mapM_ (addPrepQuery cql) pairs - where - cql :: PrepQuery W (TeamId, UserId) () - cql = "INSERT INTO billing_team_member (team, user) values (?, ?)" - -isOwner :: (TeamId, UserId, Maybe Permissions) -> Bool -isOwner (_, _, Just p) = SetBilling `Set.member` view self p -isOwner _ = False diff --git a/tools/db/migrate-sso-feature-flag/src/Work.hs b/tools/db/migrate-sso-feature-flag/src/Work.hs index ff56a094478..d64570ce438 100644 --- a/tools/db/migrate-sso-feature-flag/src/Work.hs +++ b/tools/db/migrate-sso-feature-flag/src/Work.hs @@ -67,5 +67,6 @@ writeSsoFlags = mapM_ (`setSSOTeamConfig` FeatureStatusEnabled) setSSOTeamConfig :: MonadClient m => TeamId -> FeatureStatus -> m () setSSOTeamConfig tid ssoTeamConfigStatus = do retry x5 $ write updateSSOTeamConfig (params LocalQuorum (ssoTeamConfigStatus, tid)) + updateSSOTeamConfig :: PrepQuery W (FeatureStatus, TeamId) () - updateSSOTeamConfig = "update team_features set sso_status = ? where team_id = ?" + updateSSOTeamConfig = {- `IF EXISTS`, but that requires benchmarking -} "update team_features set sso_status = ? where team_id = ?" diff --git a/tools/db/billing-team-member-backfill/.ormolu b/tools/db/repair-brig-clients-table/.ormolu similarity index 100% rename from tools/db/billing-team-member-backfill/.ormolu rename to tools/db/repair-brig-clients-table/.ormolu diff --git a/tools/db/repair-brig-clients-table/README.md b/tools/db/repair-brig-clients-table/README.md new file mode 100644 index 00000000000..16cb7a126f1 --- /dev/null +++ b/tools/db/repair-brig-clients-table/README.md @@ -0,0 +1,12 @@ +context: +- https://github.com/wireapp/wire-server/pull/3504 +- https://wearezeta.atlassian.net/browse/WPB-3888 + +Connects to brig database. + +Set up port-forwarding to brig database (hacky, slow, maybe dangerous), or run from a machine with access to those databases (preferred approach). Refer to ../service-backfill/ for an example. Then: + +```sh +# assuming local port forwarding cassandra_galley on 2021 and cassandra_spar on 2022: +./dist/repair-brig-clients-table --cassandra-host-brig localhost --cassandra-port-brig 2022 --cassandra-keyspace-brig spar +``` diff --git a/tools/db/billing-team-member-backfill/default.nix b/tools/db/repair-brig-clients-table/default.nix similarity index 75% rename from tools/db/billing-team-member-backfill/default.nix rename to tools/db/repair-brig-clients-table/default.nix index 18f60f04e76..ea9dcfe43c7 100644 --- a/tools/db/billing-team-member-backfill/default.nix +++ b/tools/db/repair-brig-clients-table/default.nix @@ -6,19 +6,17 @@ , base , cassandra-util , conduit -, containers , gitignoreSource , imports , lens , lib , optparse-applicative -, text +, time , tinylog , types-common -, wire-api }: mkDerivation { - pname = "billing-team-member-backfill"; + pname = "repair-brig-clients-table"; version = "1.0.0"; src = gitignoreSource ./.; isLibrary = false; @@ -27,16 +25,14 @@ mkDerivation { base cassandra-util conduit - containers imports lens optparse-applicative - text + time tinylog types-common - wire-api ]; - description = "Backfill billing_team_member table"; + description = "Removes and reports entries from brig.clients that have been accidentally upserted."; license = lib.licenses.agpl3Only; - mainProgram = "billing-team-member-backfill"; + mainProgram = "repair-brig-clients-table"; } diff --git a/tools/db/billing-team-member-backfill/billing-team-member-backfill.cabal b/tools/db/repair-brig-clients-table/repair-brig-clients-table.cabal similarity index 85% rename from tools/db/billing-team-member-backfill/billing-team-member-backfill.cabal rename to tools/db/repair-brig-clients-table/repair-brig-clients-table.cabal index 8d5170c11ef..5347b11d73a 100644 --- a/tools/db/billing-team-member-backfill/billing-team-member-backfill.cabal +++ b/tools/db/repair-brig-clients-table/repair-brig-clients-table.cabal @@ -1,19 +1,21 @@ cabal-version: 1.12 -name: billing-team-member-backfill +name: repair-brig-clients-table version: 1.0.0 -synopsis: Backfill billing_team_member table +synopsis: + Removes and reports entries from brig.clients that have been accidentally upserted. + category: Network author: Wire Swiss GmbH maintainer: Wire Swiss GmbH -copyright: (c) 2020 Wire Swiss GmbH +copyright: (c) 2023 Wire Swiss GmbH license: AGPL-3 build-type: Simple -executable billing-team-member-backfill +executable repair-brig-clients-table main-is: Main.hs other-modules: Options - Paths_billing_team_member_backfill + Paths_repair_brig_clients_table Work hs-source-dirs: src @@ -69,13 +71,11 @@ executable billing-team-member-backfill base , cassandra-util , conduit - , containers , imports , lens , optparse-applicative - , text + , time , tinylog , types-common - , wire-api default-language: GHC2021 diff --git a/tools/db/billing-team-member-backfill/src/Main.hs b/tools/db/repair-brig-clients-table/src/Main.hs similarity index 77% rename from tools/db/billing-team-member-backfill/src/Main.hs rename to tools/db/repair-brig-clients-table/src/Main.hs index 720e33d3c3b..30da3ef880d 100644 --- a/tools/db/billing-team-member-backfill/src/Main.hs +++ b/tools/db/repair-brig-clients-table/src/Main.hs @@ -24,6 +24,7 @@ where import Cassandra as C import Cassandra.Settings as C +import Control.Lens hiding ((.=)) import Imports import Options as O import Options.Applicative @@ -34,12 +35,12 @@ main :: IO () main = do s <- execParser (info (helper <*> settingsParser) desc) lgr <- initLogger - gc <- initCas (setCasGalley s) lgr - runCommand lgr gc + bc <- initCas (s ^. setCasBrig) lgr -- Brig's Cassandra + runCommand (s ^. setDryRun) lgr bc where desc = - header "billing-team-member-backfill" - <> progDesc "Backfill billing_team_member table" + header "repair-brig-clients-table" + <> progDesc "Removes and reports entries from brig.clients that have been accidentally upserted." <> fullDesc initLogger = Log.new @@ -50,8 +51,8 @@ main = do initCas cas l = C.init . C.setLogger (C.mkLogger l) - . C.setContacts (cHosts cas) [] - . C.setPortNumber (fromIntegral $ cPort cas) - . C.setKeyspace (cKeyspace cas) + . C.setContacts (cas ^. cHosts) [] + . C.setPortNumber (fromIntegral $ cas ^. cPort) + . C.setKeyspace (cas ^. cKeyspace) . C.setProtocolVersion C.V4 $ C.defSettings diff --git a/tools/db/billing-team-member-backfill/src/Options.hs b/tools/db/repair-brig-clients-table/src/Options.hs similarity index 74% rename from tools/db/billing-team-member-backfill/src/Options.hs rename to tools/db/repair-brig-clients-table/src/Options.hs index b26a8379e55..123c3943511 100644 --- a/tools/db/billing-team-member-backfill/src/Options.hs +++ b/tools/db/repair-brig-clients-table/src/Options.hs @@ -1,4 +1,4 @@ -{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} -- This file is part of the Wire Server implementation. -- @@ -18,7 +18,8 @@ -- with this program. If not, see . module Options - ( setCasGalley, + ( setCasBrig, + setDryRun, cHosts, cPort, cKeyspace, @@ -27,24 +28,41 @@ module Options where import Cassandra qualified as C -import Data.Text qualified as Text +import Control.Lens +import Data.Text.Strict.Lens import Imports import Options.Applicative -newtype MigratorSettings = MigratorSettings {setCasGalley :: CassandraSettings} +data MigratorSettings = MigratorSettings + { _setCasBrig :: !CassandraSettings, + _setDryRun :: !Bool + } deriving (Show) data CassandraSettings = CassandraSettings - { cHosts :: !String, - cPort :: !Word16, - cKeyspace :: !C.Keyspace + { _cHosts :: !String, + _cPort :: !Word16, + _cKeyspace :: !C.Keyspace } deriving (Show) +makeLenses ''MigratorSettings + +makeLenses ''CassandraSettings + settingsParser :: Parser MigratorSettings settingsParser = MigratorSettings - <$> cassandraSettingsParser "galley" + <$> cassandraSettingsParser "brig" + <*> dryRunParser + +dryRunParser :: Parser Bool +dryRunParser = + flag False True $ + ( long ("dry-run") + <> help ("Just detect offending rows, don't change the db") + <> showDefault + ) cassandraSettingsParser :: String -> Parser CassandraSettings cassandraSettingsParser ks = @@ -64,7 +82,7 @@ cassandraSettingsParser ks = <> value 9042 <> showDefault ) - <*> ( C.Keyspace . Text.pack + <*> ( C.Keyspace . view packed <$> strOption ( long ("cassandra-keyspace-" ++ ks) <> metavar "STRING" diff --git a/tools/db/repair-brig-clients-table/src/Work.hs b/tools/db/repair-brig-clients-table/src/Work.hs new file mode 100644 index 00000000000..41eca357c92 --- /dev/null +++ b/tools/db/repair-brig-clients-table/src/Work.hs @@ -0,0 +1,87 @@ +{-# OPTIONS_GHC -Wno-orphans #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Work where + +import Cassandra +import Data.Conduit +import Data.Conduit.Internal (zipSources) +import Data.Conduit.List qualified as C +import Data.Id +import Data.Time.Clock +import Imports +import System.Logger (Logger) +import System.Logger qualified as Log + +runCommand :: Bool -> Logger -> ClientState -> IO () +runCommand dryRun l brig = + runConduit $ + zipSources + (C.sourceList [(1 :: Int32) ..]) + (transPipe (runClient brig) getClients) + .| C.mapM + ( \(i, rows) -> do + Log.info l (Log.field "number of clients processed: " (show (i * pageSize))) + pure rows + ) + .| C.mapM_ (\rows -> runClient brig (mapM_ (filterReportRemove dryRun l) rows)) + +pageSize :: Int32 +pageSize = 1000 + +type ClientRow = + ( UserId, -- user + Text, -- client + Maybe (Cassandra.Set Int32), -- capabilities + Maybe Int32, -- class + Maybe Text, -- cookie + Maybe Text, -- label + Maybe UTCTime, -- last_active + Maybe Text, -- model + Maybe UTCTime, -- tstamp + Maybe Int32 -- type + ) + +getClients :: ConduitM () [ClientRow] Client () +getClients = paginateC cql (paramsP LocalQuorum () pageSize) x5 + where + cql :: PrepQuery R () ClientRow + cql = "select user, client, capabilities, class, cookie, label, last_active, model, tstamp, type from clients" + +filterReportRemove :: Bool -> Logger -> ClientRow -> Client () +filterReportRemove dryRun l row@(user, client, Nothing, Nothing, Nothing, Nothing, Just _lastActive, Nothing, Nothing, Nothing) = do + Log.info l (Log.msg $ "*** bad row in brig.clients: " <> show row) + if dryRun + then do + Log.info l (Log.msg $ "would run: " <> rmqs) + else do + Log.info l (Log.msg $ "running: " <> rmqs) + rm user client + Log.info l (Log.msg @Text "removed!") + where + rm :: MonadClient m => UserId -> Text -> m () + rm uid cid = + retry x5 $ write rmq (params LocalQuorum (uid, cid)) + + rmq :: PrepQuery W (UserId, Text) () + rmq = fromString rmqs + + rmqs :: String + rmqs = "delete from clients where user = ? and client = ?" +filterReportRemove _ _ _ = pure () diff --git a/tools/db/repair-handles/src/Work.hs b/tools/db/repair-handles/src/Work.hs index 7b91c7c98cf..313c4021179 100644 --- a/tools/db/repair-handles/src/Work.hs +++ b/tools/db/repair-handles/src/Work.hs @@ -142,7 +142,7 @@ executeAction env = \case params LocalQuorum (handle, uid) where updateHandle :: PrepQuery W (Handle, UserId) () - updateHandle = "UPDATE user SET handle = ? WHERE id = ?" + updateHandle = {- `IF EXISTS`, but that requires benchmarking -} "UPDATE user SET handle = ? WHERE id = ?" removeHandle :: Env -> Handle -> IO () removeHandle Env {..} handle = diff --git a/tools/fedcalls/default.nix b/tools/fedcalls/default.nix index 133e6e886bd..2d9d10e326d 100644 --- a/tools/fedcalls/default.nix +++ b/tools/fedcalls/default.nix @@ -3,15 +3,16 @@ # must be regenerated whenever local packages are added or removed, or # dependencies are added or removed. { mkDerivation -, aeson , base , containers , gitignoreSource , imports , insert-ordered-containers , language-dot +, lens , lib -, swagger2 +, openapi3 +, text , wire-api }: mkDerivation { @@ -21,13 +22,14 @@ mkDerivation { isLibrary = false; isExecutable = true; executableHaskellDepends = [ - aeson base containers imports insert-ordered-containers language-dot - swagger2 + lens + openapi3 + text wire-api ]; description = "Generate a dot file from swagger docs representing calls to federated instances"; diff --git a/tools/fedcalls/fedcalls.cabal b/tools/fedcalls/fedcalls.cabal index a7bf9ac1981..615a8bbd151 100644 --- a/tools/fedcalls/fedcalls.cabal +++ b/tools/fedcalls/fedcalls.cabal @@ -63,13 +63,14 @@ executable fedcalls -rtsopts -Wredundant-constraints -Wunused-packages build-depends: - aeson - , base + base , containers , imports , insert-ordered-containers , language-dot - , swagger2 + , lens + , openapi3 + , text , wire-api default-language: GHC2021 diff --git a/tools/fedcalls/src/Main.hs b/tools/fedcalls/src/Main.hs index 79ac0e78878..387424fde9c 100644 --- a/tools/fedcalls/src/Main.hs +++ b/tools/fedcalls/src/Main.hs @@ -23,36 +23,25 @@ module Main where import Control.Exception (assert) -import Data.Aeson as A -import Data.Aeson.Types qualified as A +import Control.Lens import Data.HashMap.Strict.InsOrd qualified as HM +import Data.HashSet.InsOrd (InsOrdHashSet) import Data.Map qualified as M -import Data.Swagger - ( PathItem, - Swagger, - _operationExtensions, - _pathItemDelete, - _pathItemGet, - _pathItemHead, - _pathItemOptions, - _pathItemPatch, - _pathItemPost, - _pathItemPut, - _swaggerPaths, - ) +import Data.OpenApi +import Data.OpenApi.Lens qualified as S +import Data.Text qualified as T import Imports import Language.Dot as D +import Wire.API.Routes.API import Wire.API.Routes.Internal.Brig qualified as BrigIRoutes -import Wire.API.Routes.Public.Brig qualified as BrigRoutes -import Wire.API.Routes.Public.Cannon qualified as CannonRoutes -import Wire.API.Routes.Public.Cargohold qualified as CargoholdRoutes -import Wire.API.Routes.Public.Galley qualified as GalleyRoutes -import Wire.API.Routes.Public.Gundeck qualified as GundeckRoutes -import Wire.API.Routes.Public.Proxy qualified as ProxyRoutes --- import qualified Wire.API.Routes.Internal.Cannon as CannonIRoutes --- import qualified Wire.API.Routes.Internal.Cargohold as CargoholdIRoutes --- import qualified Wire.API.Routes.Internal.LegalHold as LegalHoldIRoutes -import Wire.API.Routes.Public.Spar qualified as SparRoutes +import Wire.API.Routes.Public.Brig +import Wire.API.Routes.Public.Cannon +import Wire.API.Routes.Public.Cargohold +import Wire.API.Routes.Public.Galley +import Wire.API.Routes.Public.Gundeck +import Wire.API.Routes.Public.Proxy +import Wire.API.Routes.Public.Spar +import Wire.API.Routes.Version ------------------------------ @@ -66,19 +55,19 @@ calls = assert (calls' == nub calls') calls' where calls' = mconcat $ parse <$> swaggers -swaggers :: [Swagger] +swaggers :: [OpenApi] swaggers = [ -- TODO: introduce allSwaggerDocs in wire-api that collects these for all -- services, use that in /services/brig/src/Brig/API/Public.hs instead of -- doing it by hand. - BrigRoutes.brigSwagger, -- TODO: s/brigSwagger/swaggerDoc/ like everybody else! - CannonRoutes.swaggerDoc, - CargoholdRoutes.swaggerDoc, - GalleyRoutes.swaggerDoc, - GundeckRoutes.swaggerDoc, - ProxyRoutes.swaggerDoc, - SparRoutes.swaggerDoc, + serviceSwagger @BrigAPITag @'V5, + serviceSwagger @CannonAPITag @'V5, + serviceSwagger @CargoholdAPITag @'V5, + serviceSwagger @GalleyAPITag @'V5, + serviceSwagger @GundeckAPITag @'V5, + serviceSwagger @ProxyAPITag @'V5, + serviceSwagger @SparAPITag @'V5, -- TODO: collect all internal apis somewhere else (brig?), and expose them -- via an internal swagger api end-point. @@ -102,37 +91,63 @@ data MakesCallTo = MakesCallTo ------------------------------ -parse :: Swagger -> [MakesCallTo] -parse = +parse :: OpenApi -> [MakesCallTo] +parse oapi = mconcat - . fmap parseOperationExtensions + . fmap (parseOperationExtensions allTags) . mconcat . fmap flattenPathItems . HM.toList - . _swaggerPaths + $ oapi ^. S.paths + where + allTags = oapi ^. S.tags + +-- Simple aliases to help track which field is what +type RPC = String + +type Component = String -- | extract path, method, and operation extensions -flattenPathItems :: (FilePath, PathItem) -> [((FilePath, String), HM.InsOrdHashMap Text Value)] +flattenPathItems :: (FilePath, PathItem) -> [((FilePath, String), InsOrdHashSet TagName)] flattenPathItems (path, item) = filter ((/= mempty) . snd) $ catMaybes - [ ((path, "get"),) . _operationExtensions <$> _pathItemGet item, - ((path, "put"),) . _operationExtensions <$> _pathItemPut item, - ((path, "post"),) . _operationExtensions <$> _pathItemPost item, - ((path, "delete"),) . _operationExtensions <$> _pathItemDelete item, - ((path, "options"),) . _operationExtensions <$> _pathItemOptions item, - ((path, "head"),) . _operationExtensions <$> _pathItemHead item, - ((path, "patch"),) . _operationExtensions <$> _pathItemPatch item + [ ((path, "get"),) . view S.tags <$> _pathItemGet item, + ((path, "put"),) . view S.tags <$> _pathItemPut item, + ((path, "post"),) . view S.tags <$> _pathItemPost item, + ((path, "delete"),) . view S.tags <$> _pathItemDelete item, + ((path, "options"),) . view S.tags <$> _pathItemOptions item, + ((path, "head"),) . view S.tags <$> _pathItemHead item, + ((path, "patch"),) . view S.tags <$> _pathItemPatch item ] -parseOperationExtensions :: ((FilePath, String), HM.InsOrdHashMap Text Value) -> [MakesCallTo] -parseOperationExtensions ((path, method), hm) = uncurry (MakesCallTo path method) <$> findCallsFedInfo hm +parseOperationExtensions :: InsOrdHashSet Tag -> ((FilePath, String), InsOrdHashSet TagName) -> [MakesCallTo] +parseOperationExtensions allTags ((path, method), hm) = + uncurry (MakesCallTo path method) <$> findCallsFedInfo allTags hm -findCallsFedInfo :: HM.InsOrdHashMap Text Value -> [(String, String)] -findCallsFedInfo hm = case A.parse parseJSON <$> HM.lookup "wire-makes-federated-call-to" hm of - Just (A.Success (fedcalls :: [(String, String)])) -> fedcalls - Just bad -> error $ "invalid extension `wire-makes-federated-call-to`: expected `[(comp, name), ...]`, got " <> show bad - Nothing -> [] +-- Given a set of tags, and a set of tag names for an operation, +-- parse out the RPC calls and their components +findCallsFedInfo :: InsOrdHashSet Tag -> InsOrdHashSet TagName -> [(Component, RPC)] +findCallsFedInfo allTags = mapMaybe extractStrings . toList + where + magicPrefix :: Text + magicPrefix = "wire-makes-federated-call-to-" + extractStrings :: TagName -> Maybe (Component, RPC) + extractStrings tagName = + tag >>= \t -> + (,) + -- Extract the name and description, and drop everything that is empty + -- This gives us the component name, and as a route may call the same component + -- multiple times, it has to go into the description so it isn't dropped by the set. + <$> fmap T.unpack t._tagDescription + -- Strip off the magic string from the tag names, and drop empty results + -- This also implicitly filters for results that start with the prefix. + -- This gives us the RPC name, as that will be unique for each route, and it + -- doesn't matter if it is set multiple times and dropped in the set, as it + -- still describes that Fed call is made. + <*> fmap T.unpack (T.stripPrefix magicPrefix t._tagName) + where + tag = find (\t -> t._tagName == tagName) allTags ------------------------------ @@ -159,7 +174,7 @@ mkDotGraph inbound = Graph StrictGraph DirectedGraph Nothing (mods <> nodes <> e itemSourceNode (MakesCallTo path method _ _) = method <> " " <> path itemTargetNode :: MakesCallTo -> String - itemTargetNode (MakesCallTo _ _ comp name) = "[" <> comp <> "]:" <> name + itemTargetNode (MakesCallTo _ _ comp rpcName) = "[" <> comp <> "]:" <> rpcName callingNodes :: Map String Integer callingNodes = diff --git a/tools/mlsstats/.ormolu b/tools/mlsstats/.ormolu new file mode 120000 index 00000000000..157b212d7cd --- /dev/null +++ b/tools/mlsstats/.ormolu @@ -0,0 +1 @@ +../../.ormolu \ No newline at end of file diff --git a/tools/mlsstats/LICENSE b/tools/mlsstats/LICENSE new file mode 100644 index 00000000000..dba13ed2ddf --- /dev/null +++ b/tools/mlsstats/LICENSE @@ -0,0 +1,661 @@ + GNU AFFERO GENERAL PUBLIC LICENSE + Version 3, 19 November 2007 + + Copyright (C) 2007 Free Software Foundation, Inc. + Everyone is permitted to copy and distribute verbatim copies + of this license document, but changing it is not allowed. + + Preamble + + The GNU Affero General Public License is a free, copyleft license for +software and other kinds of works, specifically designed to ensure +cooperation with the community in the case of network server software. + + The licenses for most software and other practical works are designed +to take away your freedom to share and change the works. By contrast, +our General Public Licenses are intended to guarantee your freedom to +share and change all versions of a program--to make sure it remains free +software for all its users. + + When we speak of free software, we are referring to freedom, not +price. Our General Public Licenses are designed to make sure that you +have the freedom to distribute copies of free software (and charge for +them if you wish), that you receive source code or can get it if you +want it, that you can change the software or use pieces of it in new +free programs, and that you know you can do these things. + + Developers that use our General Public Licenses protect your rights +with two steps: (1) assert copyright on the software, and (2) offer +you this License which gives you legal permission to copy, distribute +and/or modify the software. + + A secondary benefit of defending all users' freedom is that +improvements made in alternate versions of the program, if they +receive widespread use, become available for other developers to +incorporate. Many developers of free software are heartened and +encouraged by the resulting cooperation. However, in the case of +software used on network servers, this result may fail to come about. +The GNU General Public License permits making a modified version and +letting the public access it on a server without ever releasing its +source code to the public. + + The GNU Affero General Public License is designed specifically to +ensure that, in such cases, the modified source code becomes available +to the community. It requires the operator of a network server to +provide the source code of the modified version running there to the +users of that server. Therefore, public use of a modified version, on +a publicly accessible server, gives the public access to the source +code of the modified version. + + An older license, called the Affero General Public License and +published by Affero, was designed to accomplish similar goals. This is +a different license, not a version of the Affero GPL, but Affero has +released a new version of the Affero GPL which permits relicensing under +this license. + + The precise terms and conditions for copying, distribution and +modification follow. + + TERMS AND CONDITIONS + + 0. Definitions. + + "This License" refers to version 3 of the GNU Affero General Public License. + + "Copyright" also means copyright-like laws that apply to other kinds of +works, such as semiconductor masks. + + "The Program" refers to any copyrightable work licensed under this +License. Each licensee is addressed as "you". "Licensees" and +"recipients" may be individuals or organizations. + + To "modify" a work means to copy from or adapt all or part of the work +in a fashion requiring copyright permission, other than the making of an +exact copy. The resulting work is called a "modified version" of the +earlier work or a work "based on" the earlier work. + + A "covered work" means either the unmodified Program or a work based +on the Program. + + To "propagate" a work means to do anything with it that, without +permission, would make you directly or secondarily liable for +infringement under applicable copyright law, except executing it on a +computer or modifying a private copy. Propagation includes copying, +distribution (with or without modification), making available to the +public, and in some countries other activities as well. + + To "convey" a work means any kind of propagation that enables other +parties to make or receive copies. Mere interaction with a user through +a computer network, with no transfer of a copy, is not conveying. + + An interactive user interface displays "Appropriate Legal Notices" +to the extent that it includes a convenient and prominently visible +feature that (1) displays an appropriate copyright notice, and (2) +tells the user that there is no warranty for the work (except to the +extent that warranties are provided), that licensees may convey the +work under this License, and how to view a copy of this License. If +the interface presents a list of user commands or options, such as a +menu, a prominent item in the list meets this criterion. + + 1. Source Code. + + The "source code" for a work means the preferred form of the work +for making modifications to it. "Object code" means any non-source +form of a work. + + A "Standard Interface" means an interface that either is an official +standard defined by a recognized standards body, or, in the case of +interfaces specified for a particular programming language, one that +is widely used among developers working in that language. + + The "System Libraries" of an executable work include anything, other +than the work as a whole, that (a) is included in the normal form of +packaging a Major Component, but which is not part of that Major +Component, and (b) serves only to enable use of the work with that +Major Component, or to implement a Standard Interface for which an +implementation is available to the public in source code form. A +"Major Component", in this context, means a major essential component +(kernel, window system, and so on) of the specific operating system +(if any) on which the executable work runs, or a compiler used to +produce the work, or an object code interpreter used to run it. + + The "Corresponding Source" for a work in object code form means all +the source code needed to generate, install, and (for an executable +work) run the object code and to modify the work, including scripts to +control those activities. However, it does not include the work's +System Libraries, or general-purpose tools or generally available free +programs which are used unmodified in performing those activities but +which are not part of the work. For example, Corresponding Source +includes interface definition files associated with source files for +the work, and the source code for shared libraries and dynamically +linked subprograms that the work is specifically designed to require, +such as by intimate data communication or control flow between those +subprograms and other parts of the work. + + The Corresponding Source need not include anything that users +can regenerate automatically from other parts of the Corresponding +Source. + + The Corresponding Source for a work in source code form is that +same work. + + 2. Basic Permissions. + + All rights granted under this License are granted for the term of +copyright on the Program, and are irrevocable provided the stated +conditions are met. This License explicitly affirms your unlimited +permission to run the unmodified Program. The output from running a +covered work is covered by this License only if the output, given its +content, constitutes a covered work. This License acknowledges your +rights of fair use or other equivalent, as provided by copyright law. + + You may make, run and propagate covered works that you do not +convey, without conditions so long as your license otherwise remains +in force. You may convey covered works to others for the sole purpose +of having them make modifications exclusively for you, or provide you +with facilities for running those works, provided that you comply with +the terms of this License in conveying all material for which you do +not control copyright. Those thus making or running the covered works +for you must do so exclusively on your behalf, under your direction +and control, on terms that prohibit them from making any copies of +your copyrighted material outside their relationship with you. + + Conveying under any other circumstances is permitted solely under +the conditions stated below. Sublicensing is not allowed; section 10 +makes it unnecessary. + + 3. Protecting Users' Legal Rights From Anti-Circumvention Law. + + No covered work shall be deemed part of an effective technological +measure under any applicable law fulfilling obligations under article +11 of the WIPO copyright treaty adopted on 20 December 1996, or +similar laws prohibiting or restricting circumvention of such +measures. + + When you convey a covered work, you waive any legal power to forbid +circumvention of technological measures to the extent such circumvention +is effected by exercising rights under this License with respect to +the covered work, and you disclaim any intention to limit operation or +modification of the work as a means of enforcing, against the work's +users, your or third parties' legal rights to forbid circumvention of +technological measures. + + 4. Conveying Verbatim Copies. + + You may convey verbatim copies of the Program's source code as you +receive it, in any medium, provided that you conspicuously and +appropriately publish on each copy an appropriate copyright notice; +keep intact all notices stating that this License and any +non-permissive terms added in accord with section 7 apply to the code; +keep intact all notices of the absence of any warranty; and give all +recipients a copy of this License along with the Program. + + You may charge any price or no price for each copy that you convey, +and you may offer support or warranty protection for a fee. + + 5. Conveying Modified Source Versions. + + You may convey a work based on the Program, or the modifications to +produce it from the Program, in the form of source code under the +terms of section 4, provided that you also meet all of these conditions: + + a) The work must carry prominent notices stating that you modified + it, and giving a relevant date. + + b) The work must carry prominent notices stating that it is + released under this License and any conditions added under section + 7. This requirement modifies the requirement in section 4 to + "keep intact all notices". + + c) You must license the entire work, as a whole, under this + License to anyone who comes into possession of a copy. This + License will therefore apply, along with any applicable section 7 + additional terms, to the whole of the work, and all its parts, + regardless of how they are packaged. This License gives no + permission to license the work in any other way, but it does not + invalidate such permission if you have separately received it. + + d) If the work has interactive user interfaces, each must display + Appropriate Legal Notices; however, if the Program has interactive + interfaces that do not display Appropriate Legal Notices, your + work need not make them do so. + + A compilation of a covered work with other separate and independent +works, which are not by their nature extensions of the covered work, +and which are not combined with it such as to form a larger program, +in or on a volume of a storage or distribution medium, is called an +"aggregate" if the compilation and its resulting copyright are not +used to limit the access or legal rights of the compilation's users +beyond what the individual works permit. Inclusion of a covered work +in an aggregate does not cause this License to apply to the other +parts of the aggregate. + + 6. Conveying Non-Source Forms. + + You may convey a covered work in object code form under the terms +of sections 4 and 5, provided that you also convey the +machine-readable Corresponding Source under the terms of this License, +in one of these ways: + + a) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by the + Corresponding Source fixed on a durable physical medium + customarily used for software interchange. + + b) Convey the object code in, or embodied in, a physical product + (including a physical distribution medium), accompanied by a + written offer, valid for at least three years and valid for as + long as you offer spare parts or customer support for that product + model, to give anyone who possesses the object code either (1) a + copy of the Corresponding Source for all the software in the + product that is covered by this License, on a durable physical + medium customarily used for software interchange, for a price no + more than your reasonable cost of physically performing this + conveying of source, or (2) access to copy the + Corresponding Source from a network server at no charge. + + c) Convey individual copies of the object code with a copy of the + written offer to provide the Corresponding Source. This + alternative is allowed only occasionally and noncommercially, and + only if you received the object code with such an offer, in accord + with subsection 6b. + + d) Convey the object code by offering access from a designated + place (gratis or for a charge), and offer equivalent access to the + Corresponding Source in the same way through the same place at no + further charge. You need not require recipients to copy the + Corresponding Source along with the object code. If the place to + copy the object code is a network server, the Corresponding Source + may be on a different server (operated by you or a third party) + that supports equivalent copying facilities, provided you maintain + clear directions next to the object code saying where to find the + Corresponding Source. Regardless of what server hosts the + Corresponding Source, you remain obligated to ensure that it is + available for as long as needed to satisfy these requirements. + + e) Convey the object code using peer-to-peer transmission, provided + you inform other peers where the object code and Corresponding + Source of the work are being offered to the general public at no + charge under subsection 6d. + + A separable portion of the object code, whose source code is excluded +from the Corresponding Source as a System Library, need not be +included in conveying the object code work. + + A "User Product" is either (1) a "consumer product", which means any +tangible personal property which is normally used for personal, family, +or household purposes, or (2) anything designed or sold for incorporation +into a dwelling. In determining whether a product is a consumer product, +doubtful cases shall be resolved in favor of coverage. For a particular +product received by a particular user, "normally used" refers to a +typical or common use of that class of product, regardless of the status +of the particular user or of the way in which the particular user +actually uses, or expects or is expected to use, the product. A product +is a consumer product regardless of whether the product has substantial +commercial, industrial or non-consumer uses, unless such uses represent +the only significant mode of use of the product. + + "Installation Information" for a User Product means any methods, +procedures, authorization keys, or other information required to install +and execute modified versions of a covered work in that User Product from +a modified version of its Corresponding Source. The information must +suffice to ensure that the continued functioning of the modified object +code is in no case prevented or interfered with solely because +modification has been made. + + If you convey an object code work under this section in, or with, or +specifically for use in, a User Product, and the conveying occurs as +part of a transaction in which the right of possession and use of the +User Product is transferred to the recipient in perpetuity or for a +fixed term (regardless of how the transaction is characterized), the +Corresponding Source conveyed under this section must be accompanied +by the Installation Information. But this requirement does not apply +if neither you nor any third party retains the ability to install +modified object code on the User Product (for example, the work has +been installed in ROM). + + The requirement to provide Installation Information does not include a +requirement to continue to provide support service, warranty, or updates +for a work that has been modified or installed by the recipient, or for +the User Product in which it has been modified or installed. Access to a +network may be denied when the modification itself materially and +adversely affects the operation of the network or violates the rules and +protocols for communication across the network. + + Corresponding Source conveyed, and Installation Information provided, +in accord with this section must be in a format that is publicly +documented (and with an implementation available to the public in +source code form), and must require no special password or key for +unpacking, reading or copying. + + 7. Additional Terms. + + "Additional permissions" are terms that supplement the terms of this +License by making exceptions from one or more of its conditions. +Additional permissions that are applicable to the entire Program shall +be treated as though they were included in this License, to the extent +that they are valid under applicable law. If additional permissions +apply only to part of the Program, that part may be used separately +under those permissions, but the entire Program remains governed by +this License without regard to the additional permissions. + + When you convey a copy of a covered work, you may at your option +remove any additional permissions from that copy, or from any part of +it. (Additional permissions may be written to require their own +removal in certain cases when you modify the work.) You may place +additional permissions on material, added by you to a covered work, +for which you have or can give appropriate copyright permission. + + Notwithstanding any other provision of this License, for material you +add to a covered work, you may (if authorized by the copyright holders of +that material) supplement the terms of this License with terms: + + a) Disclaiming warranty or limiting liability differently from the + terms of sections 15 and 16 of this License; or + + b) Requiring preservation of specified reasonable legal notices or + author attributions in that material or in the Appropriate Legal + Notices displayed by works containing it; or + + c) Prohibiting misrepresentation of the origin of that material, or + requiring that modified versions of such material be marked in + reasonable ways as different from the original version; or + + d) Limiting the use for publicity purposes of names of licensors or + authors of the material; or + + e) Declining to grant rights under trademark law for use of some + trade names, trademarks, or service marks; or + + f) Requiring indemnification of licensors and authors of that + material by anyone who conveys the material (or modified versions of + it) with contractual assumptions of liability to the recipient, for + any liability that these contractual assumptions directly impose on + those licensors and authors. + + All other non-permissive additional terms are considered "further +restrictions" within the meaning of section 10. If the Program as you +received it, or any part of it, contains a notice stating that it is +governed by this License along with a term that is a further +restriction, you may remove that term. If a license document contains +a further restriction but permits relicensing or conveying under this +License, you may add to a covered work material governed by the terms +of that license document, provided that the further restriction does +not survive such relicensing or conveying. + + If you add terms to a covered work in accord with this section, you +must place, in the relevant source files, a statement of the +additional terms that apply to those files, or a notice indicating +where to find the applicable terms. + + Additional terms, permissive or non-permissive, may be stated in the +form of a separately written license, or stated as exceptions; +the above requirements apply either way. + + 8. Termination. + + You may not propagate or modify a covered work except as expressly +provided under this License. Any attempt otherwise to propagate or +modify it is void, and will automatically terminate your rights under +this License (including any patent licenses granted under the third +paragraph of section 11). + + However, if you cease all violation of this License, then your +license from a particular copyright holder is reinstated (a) +provisionally, unless and until the copyright holder explicitly and +finally terminates your license, and (b) permanently, if the copyright +holder fails to notify you of the violation by some reasonable means +prior to 60 days after the cessation. + + Moreover, your license from a particular copyright holder is +reinstated permanently if the copyright holder notifies you of the +violation by some reasonable means, this is the first time you have +received notice of violation of this License (for any work) from that +copyright holder, and you cure the violation prior to 30 days after +your receipt of the notice. + + Termination of your rights under this section does not terminate the +licenses of parties who have received copies or rights from you under +this License. If your rights have been terminated and not permanently +reinstated, you do not qualify to receive new licenses for the same +material under section 10. + + 9. Acceptance Not Required for Having Copies. + + You are not required to accept this License in order to receive or +run a copy of the Program. Ancillary propagation of a covered work +occurring solely as a consequence of using peer-to-peer transmission +to receive a copy likewise does not require acceptance. However, +nothing other than this License grants you permission to propagate or +modify any covered work. These actions infringe copyright if you do +not accept this License. Therefore, by modifying or propagating a +covered work, you indicate your acceptance of this License to do so. + + 10. Automatic Licensing of Downstream Recipients. + + Each time you convey a covered work, the recipient automatically +receives a license from the original licensors, to run, modify and +propagate that work, subject to this License. You are not responsible +for enforcing compliance by third parties with this License. + + An "entity transaction" is a transaction transferring control of an +organization, or substantially all assets of one, or subdividing an +organization, or merging organizations. If propagation of a covered +work results from an entity transaction, each party to that +transaction who receives a copy of the work also receives whatever +licenses to the work the party's predecessor in interest had or could +give under the previous paragraph, plus a right to possession of the +Corresponding Source of the work from the predecessor in interest, if +the predecessor has it or can get it with reasonable efforts. + + You may not impose any further restrictions on the exercise of the +rights granted or affirmed under this License. For example, you may +not impose a license fee, royalty, or other charge for exercise of +rights granted under this License, and you may not initiate litigation +(including a cross-claim or counterclaim in a lawsuit) alleging that +any patent claim is infringed by making, using, selling, offering for +sale, or importing the Program or any portion of it. + + 11. Patents. + + A "contributor" is a copyright holder who authorizes use under this +License of the Program or a work on which the Program is based. The +work thus licensed is called the contributor's "contributor version". + + A contributor's "essential patent claims" are all patent claims +owned or controlled by the contributor, whether already acquired or +hereafter acquired, that would be infringed by some manner, permitted +by this License, of making, using, or selling its contributor version, +but do not include claims that would be infringed only as a +consequence of further modification of the contributor version. For +purposes of this definition, "control" includes the right to grant +patent sublicenses in a manner consistent with the requirements of +this License. + + Each contributor grants you a non-exclusive, worldwide, royalty-free +patent license under the contributor's essential patent claims, to +make, use, sell, offer for sale, import and otherwise run, modify and +propagate the contents of its contributor version. + + In the following three paragraphs, a "patent license" is any express +agreement or commitment, however denominated, not to enforce a patent +(such as an express permission to practice a patent or covenant not to +sue for patent infringement). To "grant" such a patent license to a +party means to make such an agreement or commitment not to enforce a +patent against the party. + + If you convey a covered work, knowingly relying on a patent license, +and the Corresponding Source of the work is not available for anyone +to copy, free of charge and under the terms of this License, through a +publicly available network server or other readily accessible means, +then you must either (1) cause the Corresponding Source to be so +available, or (2) arrange to deprive yourself of the benefit of the +patent license for this particular work, or (3) arrange, in a manner +consistent with the requirements of this License, to extend the patent +license to downstream recipients. "Knowingly relying" means you have +actual knowledge that, but for the patent license, your conveying the +covered work in a country, or your recipient's use of the covered work +in a country, would infringe one or more identifiable patents in that +country that you have reason to believe are valid. + + If, pursuant to or in connection with a single transaction or +arrangement, you convey, or propagate by procuring conveyance of, a +covered work, and grant a patent license to some of the parties +receiving the covered work authorizing them to use, propagate, modify +or convey a specific copy of the covered work, then the patent license +you grant is automatically extended to all recipients of the covered +work and works based on it. + + A patent license is "discriminatory" if it does not include within +the scope of its coverage, prohibits the exercise of, or is +conditioned on the non-exercise of one or more of the rights that are +specifically granted under this License. You may not convey a covered +work if you are a party to an arrangement with a third party that is +in the business of distributing software, under which you make payment +to the third party based on the extent of your activity of conveying +the work, and under which the third party grants, to any of the +parties who would receive the covered work from you, a discriminatory +patent license (a) in connection with copies of the covered work +conveyed by you (or copies made from those copies), or (b) primarily +for and in connection with specific products or compilations that +contain the covered work, unless you entered into that arrangement, +or that patent license was granted, prior to 28 March 2007. + + Nothing in this License shall be construed as excluding or limiting +any implied license or other defenses to infringement that may +otherwise be available to you under applicable patent law. + + 12. No Surrender of Others' Freedom. + + If conditions are imposed on you (whether by court order, agreement or +otherwise) that contradict the conditions of this License, they do not +excuse you from the conditions of this License. If you cannot convey a +covered work so as to satisfy simultaneously your obligations under this +License and any other pertinent obligations, then as a consequence you may +not convey it at all. For example, if you agree to terms that obligate you +to collect a royalty for further conveying from those to whom you convey +the Program, the only way you could satisfy both those terms and this +License would be to refrain entirely from conveying the Program. + + 13. Remote Network Interaction; Use with the GNU General Public License. + + Notwithstanding any other provision of this License, if you modify the +Program, your modified version must prominently offer all users +interacting with it remotely through a computer network (if your version +supports such interaction) an opportunity to receive the Corresponding +Source of your version by providing access to the Corresponding Source +from a network server at no charge, through some standard or customary +means of facilitating copying of software. This Corresponding Source +shall include the Corresponding Source for any work covered by version 3 +of the GNU General Public License that is incorporated pursuant to the +following paragraph. + + Notwithstanding any other provision of this License, you have +permission to link or combine any covered work with a work licensed +under version 3 of the GNU General Public License into a single +combined work, and to convey the resulting work. The terms of this +License will continue to apply to the part which is the covered work, +but the work with which it is combined will remain governed by version +3 of the GNU General Public License. + + 14. Revised Versions of this License. + + The Free Software Foundation may publish revised and/or new versions of +the GNU Affero General Public License from time to time. Such new versions +will be similar in spirit to the present version, but may differ in detail to +address new problems or concerns. + + Each version is given a distinguishing version number. If the +Program specifies that a certain numbered version of the GNU Affero General +Public License "or any later version" applies to it, you have the +option of following the terms and conditions either of that numbered +version or of any later version published by the Free Software +Foundation. If the Program does not specify a version number of the +GNU Affero General Public License, you may choose any version ever published +by the Free Software Foundation. + + If the Program specifies that a proxy can decide which future +versions of the GNU Affero General Public License can be used, that proxy's +public statement of acceptance of a version permanently authorizes you +to choose that version for the Program. + + Later license versions may give you additional or different +permissions. However, no additional obligations are imposed on any +author or copyright holder as a result of your choosing to follow a +later version. + + 15. Disclaimer of Warranty. + + THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY +APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT +HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY +OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, +THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR +PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM +IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF +ALL NECESSARY SERVICING, REPAIR OR CORRECTION. + + 16. Limitation of Liability. + + IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING +WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS +THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY +GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE +USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF +DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD +PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), +EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF +SUCH DAMAGES. + + 17. Interpretation of Sections 15 and 16. + + If the disclaimer of warranty and limitation of liability provided +above cannot be given local legal effect according to their terms, +reviewing courts shall apply local law that most closely approximates +an absolute waiver of all civil liability in connection with the +Program, unless a warranty or assumption of liability accompanies a +copy of the Program in return for a fee. + + END OF TERMS AND CONDITIONS + + How to Apply These Terms to Your New Programs + + If you develop a new program, and you want it to be of the greatest +possible use to the public, the best way to achieve this is to make it +free software which everyone can redistribute and change under these terms. + + To do so, attach the following notices to the program. It is safest +to attach them to the start of each source file to most effectively +state the exclusion of warranty; and each file should have at least +the "copyright" line and a pointer to where the full notice is found. + + + Copyright (C) + + This program is free software: you can redistribute it and/or modify + it under the terms of the GNU Affero General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + This program is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU Affero General Public License for more details. + + You should have received a copy of the GNU Affero General Public License + along with this program. If not, see . + +Also add information on how to contact you by electronic and paper mail. + + If your software can interact with users remotely through a computer +network, you should also make sure that it provides a way for users to +get its source. For example, if your program is a web application, its +interface could display a "Source" link that leads users to an archive +of the code. There are many ways you could offer source, and different +solutions will be better for different programs; see section 13 for the +specific requirements. + + You should also get your employer (if you work as a programmer) or school, +if any, to sign a "copyright disclaimer" for the program, if necessary. +For more information on this, and how to apply and follow the GNU AGPL, see +. diff --git a/tools/mlsstats/README.md b/tools/mlsstats/README.md new file mode 100644 index 00000000000..f1ed814b000 --- /dev/null +++ b/tools/mlsstats/README.md @@ -0,0 +1,43 @@ +MLSStats - Data for monitoring Proteus to MLS migration +======================================================= + +MLSStats extracts the data relevant to an ongoing migration from Proteus to MLS and stores the data as four files to an S3 bucket, +- `user-client.csv`, +- `conv-group-team-protocol.csv`, +- `domain-user-client-group.csv`, and +- `user-conv.csv`. + +The tool is supposed to run (not more often than) once a day, preferably from a (Kubernetes) cron job. + +## Important note + +This cron job is _not_ meant for general use! It can leak data about one team to other teams. + +## How to interpret the data + +There are two tables with generic data from both protocols, `user-client.cvs` and `user-conv.cvs`, and two tables with MLS-specific data, `conv-group-team-protocol.csv` and `domain-user-client-group.csv`. In order to draw conclusions about the progress of the migration, the generic data has to be related to MLS-specific data. + + +### Use-case: conversation state ratio + +The protocol used in a conversation can be Proteus, Mixed, and MLS. Mixed conversations support Proteus clients as well as MLS clients. All team conversations and their currently supported protocols can be found in `conv-group-team-protocol.csv`. + +An example counting protocols per team and in total is implemented in the function `team_conversations()` in `analysis/mlsstats.py`. + +### Use-case: Proteus vs MLS client ratio + +In MLS, each conversation is additionally represented by a _group_. +- The mapping from group to conversation can be derived from `conv-group-team-protocol.csv`. +- The MLS clients for each user in each conversation (via the group-to-conversation mapping) can be counted in `domain-user-client-group.csv`. The domain in this table can be ignored. +- The total number of clients for each user can be counted in `user-client.csv`. The number of Proteus clients per user is the difference between total number of clients and MLS clients for this user. +- With the Proteus and MLS clients for each user sorted out, `user-conv.csv` can be used to derive the Proteus and MLS clients for each conversation by summing up the clients for each user in the conversation. + +This is implemented in the function `conversation_clients()` in `analysis/mlsstats.py`. + +## How to locally run MLSStats + +MLSStats accepts a number of arguments for configuring the connection to Cassandra and S3. With the local environment set up, the only argument required is `--s3-bucket-name`. + +## How to run MLSStats in the cluster + +MLSStats displays all its command line arguments when called with the `--help` flag. Close to all arguments have to be provided in order to make the tool correctly conntect to the database and S3. diff --git a/tools/mlsstats/analysis/mlsstats.py b/tools/mlsstats/analysis/mlsstats.py new file mode 100755 index 00000000000..900b85d8ffd --- /dev/null +++ b/tools/mlsstats/analysis/mlsstats.py @@ -0,0 +1,106 @@ +#!/usr/bin/env python3 + +import csv + +def team_conversations(): + total = {'proteus': 0, 'mixed': 0, 'mls': 0} + teams = {} + with open('conv-group-team-protocol.csv') as csv_file: + csv_reader = csv.DictReader(csv_file, delimiter=',') + + for row in csv_reader: + team = row['team'] + protocol = row['protocol'] + + if protocol not in ['proteus', 'mixed', 'mls']: + protocol = 'proteus' + if protocol not in total: + total[protocol] = 0 + if team not in teams: + teams[team] = {'proteus': 0, 'mixed': 0, 'mls': 0} + + total[protocol] += 1 + teams[team][protocol] += 1 + + for name, team in teams.items(): + proteus = team['proteus'] + mixed = team['mixed'] + mls = team['mls'] + print(f'In team {name}, conversations ' + + f'in Proteus are {proteus}, ' + + f'in Mixed are {mixed}, and ' + + f'in MLS are {mls}.') + + proteus = total['proteus'] + mixed = total['mixed'] + mls = total['mls'] + print(f'In total, conversations in Proteus are {proteus}, '+ + f'in Mixed are {mixed}, and in MLS are {mls}.') + +def conversation_clients(): + with open('conv-group-team-protocol.csv') as csv_file: + conv = {} + proto = {} + csv_reader = csv.DictReader(csv_file, delimiter=',') + for row in csv_reader: + conversation = row['conversation'] + if row['group'] != '': + conv[row['group']] = conversation + proto[conversation] = row['protocol'] + + with open('domain-user-client-group.csv') as csv_file: + mls_clients = {} + csv_reader = csv.DictReader(csv_file, delimiter=',') + for row in csv_reader: + group = row['group'] + if group in conv: + conversation = conv[group] + user = row['user'] + if conversation not in mls_clients: + mls_clients[conversation] = {user: 0} + if user not in mls_clients[conversation]: + mls_clients[conversation][user] = 0 + mls_clients[conversation][user] += 1 + + with open('user-client.csv') as csv_file: + user_clients = {} + csv_reader = csv.DictReader(csv_file, delimiter=',') + for row in csv_reader: + user = row['user'] + if user not in user_clients: + user_clients[user] = 0 + user_clients[user] += 1 + + with open('user-conv.csv') as csv_file: + conv_clients = {} + csv_reader = csv.DictReader(csv_file, delimiter=',') + for row in csv_reader: + user = row['user'] + conversation = row['conversation'] + if conversation in proto: # only team conversations + if conversation not in conv_clients: + conv_clients[conversation] = {'proteus': 0, 'mls': 0} + if conversation in mls_clients and user in mls_clients[conversation]: + mls = mls_clients[conversation][user] + else: + mls = 0 + if user in user_clients: + proteus = user_clients[user] - mls + else: + proteus = 0 + conv_clients[conversation]['proteus'] += proteus + conv_clients[conversation]['mls'] += mls + + total_proteus = 0 + total_mls = 0 + for name, conversation in conv_clients.items(): + proteus = conversation['proteus'] + mls = conversation['mls'] + protocol = proto[name] + total_proteus += proteus + total_mls += mls + print(f'In conversation {name} ({protocol}), there are ' + + f'{proteus} Proteus clients and {mls} MLS clients.') + + print(f'In total, there are {total_proteus} Proteus clients ' + + f'and {total_mls} MLS clients.') diff --git a/tools/mlsstats/default.nix b/tools/mlsstats/default.nix new file mode 100644 index 00000000000..7c8c9068107 --- /dev/null +++ b/tools/mlsstats/default.nix @@ -0,0 +1,58 @@ +# WARNING: GENERATED FILE, DO NOT EDIT. +# This file is generated by running hack/bin/generate-local-nix-packages.sh and +# must be regenerated whenever local packages are added or removed, or +# dependencies are added or removed. +{ mkDerivation +, aeson +, amazonka +, amazonka-s3 +, base +, base64-bytestring +, bytestring +, cassandra-util +, conduit +, filepath +, gitignoreSource +, http-types +, imports +, lens +, lib +, optparse-applicative +, schema-profunctor +, text +, time +, tinylog +, types-common +, wire-api +}: +mkDerivation { + pname = "mlsstats"; + version = "0.1.0"; + src = gitignoreSource ./.; + isLibrary = true; + isExecutable = true; + libraryHaskellDepends = [ + aeson + amazonka + amazonka-s3 + base + base64-bytestring + bytestring + cassandra-util + conduit + filepath + http-types + imports + lens + optparse-applicative + schema-profunctor + text + time + tinylog + types-common + wire-api + ]; + executableHaskellDepends = [ base imports optparse-applicative ]; + license = lib.licenses.agpl3Only; + mainProgram = "mlsstats"; +} diff --git a/tools/mlsstats/exec/Main.hs b/tools/mlsstats/exec/Main.hs new file mode 100644 index 00000000000..4d495c32062 --- /dev/null +++ b/tools/mlsstats/exec/Main.hs @@ -0,0 +1,36 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Main + ( main, + ) +where + +import Imports +import MlsStats.Options +import MlsStats.Run (run) +import Options.Applicative + +main :: IO () +main = do + opts <- execParser (info (helper <*> optsParser) desc) + run opts + where + desc = + header "mlsstats" + <> progDesc "MLS Stats - Proteus to MLS migration statistics for admins" + <> fullDesc diff --git a/tools/mlsstats/mlsstats.cabal b/tools/mlsstats/mlsstats.cabal new file mode 100644 index 00000000000..eca13c03d34 --- /dev/null +++ b/tools/mlsstats/mlsstats.cabal @@ -0,0 +1,155 @@ +cabal-version: 1.12 +name: mlsstats +version: 0.1.0 +description: collect and provide MLS migration statistics +category: Network +author: Wire Swiss GmbH +maintainer: Wire Swiss GmbH +copyright: (c) 2023 Wire Swiss GmbH +license: AGPL-3 +license-file: LICENSE +build-type: Simple + +flag static + description: Enable static linking + manual: True + default: False + +library + exposed-modules: + MlsStats.Options + MlsStats.Run + + other-modules: Paths_mlsstats + hs-source-dirs: src + default-extensions: + NoImplicitPrelude + AllowAmbiguousTypes + BangPatterns + ConstraintKinds + DataKinds + DefaultSignatures + DeriveFunctor + DeriveGeneric + DeriveLift + DeriveTraversable + DerivingStrategies + DerivingVia + DuplicateRecordFields + EmptyCase + FlexibleContexts + FlexibleInstances + FunctionalDependencies + GADTs + InstanceSigs + KindSignatures + LambdaCase + MultiParamTypeClasses + MultiWayIf + NamedFieldPuns + OverloadedRecordDot + OverloadedStrings + PackageImports + PatternSynonyms + PolyKinds + QuasiQuotes + RankNTypes + ScopedTypeVariables + StandaloneDeriving + TupleSections + TypeApplications + TypeFamilies + TypeFamilyDependencies + TypeOperators + UndecidableInstances + ViewPatterns + + ghc-options: + -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates + -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path + -funbox-strict-fields -Wredundant-constraints -Wunused-packages + + build-depends: + aeson + , amazonka >=1.3.7 + , amazonka-s3 >=1.3.7 + , base >=4.6 && <5 + , base64-bytestring + , bytestring + , cassandra-util + , conduit + , filepath + , http-types + , imports + , lens >=4.11 + , optparse-applicative + , schema-profunctor + , text + , time + , tinylog + , types-common >=0.8 + , wire-api + + default-language: GHC2021 + +executable mlsstats + main-is: exec/Main.hs + other-modules: Paths_mlsstats + default-extensions: + NoImplicitPrelude + AllowAmbiguousTypes + BangPatterns + ConstraintKinds + DataKinds + DefaultSignatures + DeriveFunctor + DeriveGeneric + DeriveLift + DeriveTraversable + DerivingStrategies + DerivingVia + DuplicateRecordFields + EmptyCase + FlexibleContexts + FlexibleInstances + FunctionalDependencies + GADTs + InstanceSigs + KindSignatures + LambdaCase + MultiParamTypeClasses + MultiWayIf + NamedFieldPuns + OverloadedRecordDot + OverloadedStrings + PackageImports + PatternSynonyms + PolyKinds + QuasiQuotes + RankNTypes + ScopedTypeVariables + StandaloneDeriving + TupleSections + TypeApplications + TypeFamilies + TypeFamilyDependencies + TypeOperators + UndecidableInstances + ViewPatterns + + ghc-options: + -O2 -Wall -Wincomplete-uni-patterns -Wincomplete-record-updates + -Wpartial-fields -fwarn-tabs -optP-Wno-nonportable-include-path + -threaded -rtsopts -with-rtsopts=-T -Wredundant-constraints + -Wunused-packages + + build-depends: + base + , imports + , mlsstats + , optparse-applicative + + if flag(static) + ld-options: -static + + default-language: GHC2021 diff --git a/tools/mlsstats/src/MlsStats/Options.hs b/tools/mlsstats/src/MlsStats/Options.hs new file mode 100644 index 00000000000..251d9117eb7 --- /dev/null +++ b/tools/mlsstats/src/MlsStats/Options.hs @@ -0,0 +1,171 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module MlsStats.Options + ( Opts (..), + CassandraSettings (..), + S3Settings (..), + optsParser, + ) +where + +import Amazonka +import Cassandra qualified as C +import Data.Text qualified as Text +import Imports +import Options.Applicative +import Options.Applicative.Types (readerAsk) +import Util.Options + +data Opts = Opts + { cassandraSettings :: CassandraSettings, + s3Settings :: S3Settings + } + deriving (Show, Generic) + +data CassandraSettings = CassandraSettings + { brigHost :: String, + brigPort :: Word16, + brigKeyspace :: C.Keyspace, + galleyHost :: String, + galleyPort :: Word16, + galleyKeyspace :: C.Keyspace, + pageSize :: Int32 + } + deriving (Show) + +data S3Settings = S3Settings + { endpoint :: AWSEndpoint, + region :: Maybe Text, + addressingStyle :: S3AddressingStyle, + bucketName :: Text, + bucketDir :: Maybe String + } + deriving (Show) + +optsParser :: Parser Opts +optsParser = + Opts + <$> cassandraSettingsParser + <*> s3SettingsParser + +cassandraSettingsParser :: Parser CassandraSettings +cassandraSettingsParser = + CassandraSettings + <$> strOption + ( long "brig-cassandra-host" + <> metavar "HOST" + <> help "Cassandra host for Brig" + <> value "localhost" + <> showDefault + ) + <*> option + auto + ( long "brig-cassandra-port" + <> metavar "PORT" + <> help "Cassandra port for Brig" + <> value 9042 + <> showDefault + ) + <*> ( C.Keyspace . Text.pack + <$> strOption + ( long "brig-cassandra-keyspace" + <> metavar "STRING" + <> help "Cassandra Keyspaces for Brig" + <> value ("brig_test") + <> showDefault + ) + ) + <*> strOption + ( long "galley-cassandra-host" + <> metavar "HOST" + <> help "Cassandra host for Galley" + <> value "localhost" + <> showDefault + ) + <*> option + auto + ( long "galley-cassandra-port" + <> metavar "PORT" + <> help "Cassandra port for Brig" + <> value 9042 + <> showDefault + ) + <*> ( C.Keyspace . Text.pack + <$> strOption + ( long "galley-cassandra-keyspace" + <> metavar "STRING" + <> help "Cassandra Keyspaces for Galley" + <> value ("galley_test") + <> showDefault + ) + ) + <*> option + auto + ( long "cassandra-pagesize" + <> metavar "PAGESIZE" + <> help "Cassandra pagesize for queries" + <> value 1024 + <> showDefault + ) + +s3SettingsParser :: Parser S3Settings +s3SettingsParser = + S3Settings + <$> option + parseAWSEndpoint + ( long "s3-endpoint" + <> metavar "URL" + <> help "S3 endpoint" + <> value (AWSEndpoint "localhost" False 4570) + <> showDefault + ) + <*> optional + ( strOption + ( long "s3-region" + <> metavar "S3REGION" + <> help "S3 region" + ) + ) + <*> option + addressingStyleParser + ( long "s3-addressing-style" + <> metavar "ADDRESSINGSTYLE" + <> help "S3 addressing style (path for minio)" + <> value S3AddressingStylePath + <> showDefault + ) + <*> strOption + ( long "s3-bucket-name" + <> metavar "BUCKET" + <> help "S3 bucket" + ) + <*> optional + ( strOption + ( long "s3-bucket-dir" + <> metavar "DIRECTORY" + <> help "S3 bucket directory" + ) + ) + +addressingStyleParser :: ReadM S3AddressingStyle +addressingStyleParser = do + readerAsk >>= \case + "path" -> pure S3AddressingStylePath + "auto" -> pure S3AddressingStyleAuto + "virtual" -> pure S3AddressingStyleVirtual + _ -> readerError "unknown S3 addressing style" diff --git a/tools/mlsstats/src/MlsStats/Run.hs b/tools/mlsstats/src/MlsStats/Run.hs new file mode 100644 index 00000000000..7132d0b8a9c --- /dev/null +++ b/tools/mlsstats/src/MlsStats/Run.hs @@ -0,0 +1,257 @@ +{-# LANGUAGE TupleSections #-} +{-# OPTIONS_GHC -Wno-ambiguous-fields #-} +{-# OPTIONS_GHC -Wno-orphans #-} + +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2022 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module MlsStats.Run + ( run, + ) +where + +import Amazonka hiding (await) +import Amazonka.S3 +import Amazonka.S3.CreateMultipartUpload +import Amazonka.S3.Lens +import Cassandra as C +import Cassandra.Settings as C +import Conduit +import Control.Exception +import Control.Lens ((.~), (?~), (^.)) +import Data.Aeson qualified as A +import Data.ByteString.Base64 qualified as BS64 +import Data.ByteString.Lazy qualified as LBS +import Data.Conduit.Combinators hiding (foldMap, stderr, stdout) +import Data.Domain +import Data.Id +import Data.List.NonEmpty (nonEmpty) +import Data.Schema +import Data.Text qualified as T +import Data.Text.Encoding qualified as T +import Data.Time +import Data.Time.Format.ISO8601 +import Imports hiding (concat, filter, print) +import MlsStats.Options +import Network.HTTP.Types +import System.FilePath.Posix +import System.Logger qualified as Log +import Util.Options +import Wire.API.Conversation.Protocol +import Wire.API.MLS.Group + +run :: Opts -> IO () +run o = do + logger' <- initLogger + let settings = o.cassandraSettings + galleyTables <- initCas settings.galleyHost settings.galleyPort settings.galleyKeyspace logger' + brigTables <- initCas settings.brigHost settings.brigPort settings.brigKeyspace logger' + runCommand o.s3Settings galleyTables brigTables o.cassandraSettings.pageSize + where + initLogger = + Log.new + . Log.setOutput Log.StdOut + . Log.setFormat Nothing + . Log.setBufSize 0 + $ Log.defSettings + initCas casHost casPort casKeyspace l = + C.init + . C.setLogger (C.mkLogger l) + . C.setContacts casHost [] + . C.setPortNumber (fromIntegral casPort) + . C.setProtocolVersion C.V4 + . C.setKeyspace casKeyspace + $ C.defSettings + +runCommand :: S3Settings -> ClientState -> ClientState -> Int32 -> IO () +runCommand s3 galleyTables brigTables queryPageSize = do + logger <- newLogger Debug stderr + let service = + setEndpoint (s3.endpoint ^. awsSecure) (s3.endpoint ^. awsHost) (s3.endpoint ^. awsPort) defaultService + & service_s3AddressingStyle .~ s3.addressingStyle + env <- + maybe id (\reg e -> (e :: Env) {region = Region' reg}) s3.region + . (\e -> e {logger = logger}) + . configureService service + <$> newEnv discover + now <- formatShowM iso8601Format <$> getCurrentTime + let upload = + uploadStream env (BucketName s3.bucketName) + . ObjectKey + . T.pack + . maybe id () s3.bucketDir + . maybe id () now + runResourceT $ do + upload "user-client.csv" (userClient brigTables queryPageSize) + upload "conv-group-team-protocol.csv" (convGroupTeamProtocol galleyTables queryPageSize) + upload "domain-user-client-group.csv" (domainUserClientGroup galleyTables queryPageSize) + upload "user-conv.csv" (userConv galleyTables queryPageSize) + +userClient :: MonadIO m => ClientState -> Int32 -> ConduitT () ByteString m () +userClient cassandra queryPageSize = do + yield "user,client\r\n" + ( transPipe + (runClient cassandra) + (paginateC userClientCql (paramsP LocalQuorum () queryPageSize) x1) + .| concat + .| mapC (\(u, c) -> T.encodeUtf8 $ T.pack (show u) <> "," <> c.client <> "\r\n") + ) + where + userClientCql :: PrepQuery R () (UserId, ClientId) + userClientCql = "SELECT user, client FROM clients" + +convGroupTeamProtocol :: MonadIO m => ClientState -> Int32 -> ConduitT () ByteString m () +convGroupTeamProtocol cassandra queryPageSize = do + yield "conversation,group,team,protocol\r\n" + ( transPipe + (runClient cassandra) + (paginateC convGroupTeamProtocolCql (paramsP LocalQuorum () queryPageSize) x1) + .| concat + .| mapC (\(c, g, mt, p) -> fmap (c,g,,p) mt) -- filter out non-team conversations + .| concat + .| mapC + ( \(c, g, t, p) -> + T.encodeUtf8 (T.pack (show c)) + <> "," + <> foldMap (BS64.encode . unGroupId) g + <> "," + <> T.encodeUtf8 (T.pack (show t)) + <> "," + <> T.encodeUtf8 (convertProtocol p) + <> "\r\n" + ) + ) + where + convGroupTeamProtocolCql :: PrepQuery R () (ConvId, Maybe GroupId, Maybe TeamId, Maybe ProtocolTag) + convGroupTeamProtocolCql = "SELECT conv, group_id, team, protocol FROM conversation" + convertProtocol :: Maybe ProtocolTag -> Text + convertProtocol p = case schemaToJSON (fromMaybe ProtocolProteusTag p) of + A.String s -> s + _ -> "?" + +domainUserClientGroup :: MonadIO m => ClientState -> Int32 -> ConduitT () ByteString m () +domainUserClientGroup cassandra queryPageSize = do + yield "user_domain,user,client,group\r\n" + ( transPipe + (runClient cassandra) + (paginateC domainUserClientGroupCql (paramsP LocalQuorum () queryPageSize) x1) + .| concat + .| mapC + ( \(d, u, c, g) -> + (T.encodeUtf8 (domainText d)) + <> "," + <> T.encodeUtf8 (T.pack (show u)) + <> "," + <> T.encodeUtf8 (client c) + <> "," + <> BS64.encode (unGroupId g) + <> "\r\n" + ) + ) + where + domainUserClientGroupCql :: PrepQuery R () (Domain, UserId, ClientId, GroupId) + domainUserClientGroupCql = "SELECT user_domain, user, client, group_id FROM mls_group_member_client" + +userConv :: MonadIO m => ClientState -> Int32 -> ConduitT () ByteString m () +userConv cassandra queryPageSize = do + yield "user,conversation\r\n" + ( transPipe + (runClient cassandra) + (paginateC userConvCql (paramsP LocalQuorum () queryPageSize) x1) + .| concat + .| mapC (\(u, c) -> T.encodeUtf8 $ T.pack (show u) <> "," <> T.pack (show c) <> "\r\n") + ) + where + userConvCql :: PrepQuery R () (UserId, ConvId) + userConvCql = "SELECT user, conv FROM user" + +uploadStream :: + Env -> + BucketName -> + ObjectKey -> + ConduitT () ByteString (ResourceT IO) () -> + (ResourceT IO) () +uploadStream env bucket key stream = do + createMultipartResp <- + sendEither env (newCreateMultipartUpload bucket key) >>= \case + Left (ServiceError e) | e.status.statusCode == 404 && e.code == ErrorCode "NoSuchBucket" -> do + void $ send env (newCreateBucket bucket) + send env (newCreateMultipartUpload bucket key) + Left e -> liftIO $ throwIO e + Right resp -> pure resp + let uploadId' = createMultipartResp ^. createMultipartUploadResponse_uploadId + parts <- + runConduit $ + stream + .| chunksOfE chunkSize + .| uploadParts env bucket key uploadId' 0 + .| mapC (uncurry newCompletedPart) + .| sinkList + void $ + send env $ + newCompleteMultipartUpload bucket key uploadId' + & completeMultipartUpload_multipartUpload + ?~ ( newCompletedMultipartUpload + & completedMultipartUpload_parts .~ nonEmpty parts + ) + where + chunkSize = 5 * 1024 * 1024 + +uploadParts :: + Env -> + BucketName -> + ObjectKey -> + Text -> + Int -> + ConduitT ByteString (Int, ETag) (ResourceT IO) () +uploadParts env bucket key uploadId partNum = do + chunkM <- await + case chunkM of + Just chunk -> do + let req = newUploadPart bucket key partNum uploadId $ toBody chunk + resp <- send env req + for_ (resp ^. uploadPartResponse_eTag) $ \etag -> + yield (partNum, etag) + uploadParts env bucket key uploadId (partNum + 1) + Nothing -> pure () + +instance Cql ProtocolTag where + ctype = Tagged IntColumn + + toCql = CqlInt . fromIntegral . fromEnum + + fromCql (CqlInt i) = do + let i' = fromIntegral i + if i' < fromEnum @ProtocolTag minBound + || i' > fromEnum @ProtocolTag maxBound + then Left $ "unexpected protocol: " ++ show i + else Right $ toEnum i' + fromCql _ = Left "protocol: int expected" + +instance Cql GroupId where + ctype = Tagged BlobColumn + + toCql = CqlBlob . LBS.fromStrict . unGroupId + + fromCql (CqlBlob b) = Right . GroupId . LBS.toStrict $ b + fromCql _ = Left "group_id: blob expected" + +instance Cql Domain where + ctype = Tagged TextColumn + toCql = CqlText . domainText + fromCql (CqlText txt) = mkDomain txt + fromCql _ = Left "Domain: Text expected" diff --git a/tools/rabbitmq-consumer/README.md b/tools/rabbitmq-consumer/README.md new file mode 100644 index 00000000000..387285946ec --- /dev/null +++ b/tools/rabbitmq-consumer/README.md @@ -0,0 +1,30 @@ +# RabbitMQ Consumer + +```txt +rabbitmq-consumer + +Usage: rabbitmq-consumer [-s|--host HOST] [-p|--port PORT] + [-u|--username USERNAME] [-w|--password PASSWORD] + [-v|--vhost VHOST] [-q|--queue QUEUE] + [-t|--timeout TIMEOUT] COMMAND + + CLI tool to consume messages from a RabbitMQ queue + +Available options: + -h,--help Show this help text + -s,--host HOST RabbitMQ host (default: "localhost") + -p,--port PORT RabbitMQ Port (default: 5672) + -u,--username USERNAME RabbitMQ Username (default: "guest") + -w,--password PASSWORD RabbitMQ Password (default: "alpaca-grapefruit") + -v,--vhost VHOST RabbitMQ VHost (default: "/") + -q,--queue QUEUE RabbitMQ Queue (default: "test") + -t,--timeout TIMEOUT Timeout in seconds. The command will timeout if no + messages are received within this time. This can + happen when the queue is empty, or when we lose the + single active consumer race. (default: 10) + +Available commands: + head Print the first message in the queue + drop-head Drop the first message in the queue + interactive Interactively drop the first message from the queue +``` diff --git a/tools/rabbitmq-consumer/app/Main.hs b/tools/rabbitmq-consumer/app/Main.hs new file mode 100644 index 00000000000..0379be52e30 --- /dev/null +++ b/tools/rabbitmq-consumer/app/Main.hs @@ -0,0 +1,23 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . + +module Main where + +import qualified RabbitMQConsumer.Lib as Lib + +main :: IO () +main = Lib.main diff --git a/tools/rabbitmq-consumer/default.nix b/tools/rabbitmq-consumer/default.nix new file mode 100644 index 00000000000..1da708042e2 --- /dev/null +++ b/tools/rabbitmq-consumer/default.nix @@ -0,0 +1,45 @@ +# WARNING: GENERATED FILE, DO NOT EDIT. +# This file is generated by running hack/bin/generate-local-nix-packages.sh and +# must be regenerated whenever local packages are added or removed, or +# dependencies are added or removed. +{ mkDerivation +, aeson +, aeson-pretty +, amqp +, base +, bytestring +, gitignoreSource +, imports +, lib +, network +, optparse-applicative +, text +, types-common +, wire-api +, wire-api-federation +}: +mkDerivation { + pname = "rabbitmq-consumer"; + version = "1.0.0"; + src = gitignoreSource ./.; + isLibrary = true; + isExecutable = true; + libraryHaskellDepends = [ + aeson + aeson-pretty + amqp + base + bytestring + imports + network + optparse-applicative + text + types-common + wire-api + wire-api-federation + ]; + executableHaskellDepends = [ base ]; + description = "CLI tool to consume messages from a RabbitMQ queue"; + license = lib.licenses.agpl3Only; + mainProgram = "rabbitmq-consumer"; +} diff --git a/tools/rabbitmq-consumer/rabbitmq-consumer.cabal b/tools/rabbitmq-consumer/rabbitmq-consumer.cabal new file mode 100644 index 00000000000..81eb049de12 --- /dev/null +++ b/tools/rabbitmq-consumer/rabbitmq-consumer.cabal @@ -0,0 +1,84 @@ +cabal-version: 3.0 +name: rabbitmq-consumer +version: 1.0.0 +synopsis: CLI tool to consume messages from a RabbitMQ queue +category: Network +author: Wire Swiss GmbH +maintainer: Wire Swiss GmbH +copyright: (c) 2023 Wire Swiss GmbH +license: AGPL-3.0-only +build-type: Simple + +executable rabbitmq-consumer + main-is: Main.hs + build-depends: + , base + , rabbitmq-consumer + + hs-source-dirs: app + +library + hs-source-dirs: src + exposed-modules: RabbitMQConsumer.Lib + default-language: GHC2021 + ghc-options: + -Wall -Wpartial-fields -fwarn-tabs + -optP-Wno-nonportable-include-path + + build-depends: + , aeson + , aeson-pretty + , amqp + , base + , bytestring + , imports + , network + , optparse-applicative + , text + , types-common + , wire-api + , wire-api-federation + + default-extensions: + NoImplicitPrelude + AllowAmbiguousTypes + BangPatterns + ConstraintKinds + DataKinds + DefaultSignatures + DeriveFunctor + DeriveGeneric + DeriveLift + DeriveTraversable + DerivingStrategies + DerivingVia + DuplicateRecordFields + EmptyCase + FlexibleContexts + FlexibleInstances + FunctionalDependencies + GADTs + GeneralizedNewtypeDeriving + InstanceSigs + KindSignatures + LambdaCase + MultiParamTypeClasses + MultiWayIf + NamedFieldPuns + OverloadedLabels + OverloadedRecordDot + PackageImports + PatternSynonyms + PolyKinds + QuasiQuotes + RankNTypes + RecordWildCards + ScopedTypeVariables + StandaloneDeriving + TupleSections + TypeApplications + TypeFamilies + TypeFamilyDependencies + TypeOperators + UndecidableInstances + ViewPatterns diff --git a/tools/rabbitmq-consumer/src/RabbitMQConsumer/Lib.hs b/tools/rabbitmq-consumer/src/RabbitMQConsumer/Lib.hs new file mode 100644 index 00000000000..307d1d30039 --- /dev/null +++ b/tools/rabbitmq-consumer/src/RabbitMQConsumer/Lib.hs @@ -0,0 +1,232 @@ +-- This file is part of the Wire Server implementation. +-- +-- Copyright (C) 2023 Wire Swiss GmbH +-- +-- This program is free software: you can redistribute it and/or modify it under +-- the terms of the GNU Affero General Public License as published by the Free +-- Software Foundation, either version 3 of the License, or (at your option) any +-- later version. +-- +-- This program is distributed in the hope that it will be useful, but WITHOUT +-- ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS +-- FOR A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +-- details. +-- +-- You should have received a copy of the GNU Affero General Public License along +-- with this program. If not, see . +{-# LANGUAGE OverloadedStrings #-} + +module RabbitMQConsumer.Lib where + +import Data.Aeson +import Data.Aeson.Encode.Pretty +import Data.ByteString.Lazy.Char8 qualified as BL +import Data.Domain (Domain) +import Data.Text.Lazy.Encoding qualified as TL +import Imports +import Network.AMQP +import Network.Socket +import Options.Applicative +import Wire.API.Federation.BackendNotifications (BackendNotification (..)) +import Wire.API.MakesFederatedCall (Component) + +main :: IO () +main = do + opts <- execParser (info (helper <*> optsParser) desc) + conn <- openConnection' opts.host opts.port opts.vhost opts.username opts.password + chan <- openChannel conn + qos chan 0 1 False + done <- newEmptyMVar + case opts.cmd of + Interactive -> void $ consumeMsgs chan opts.queue Ack (interactive done opts) + Head -> do + runTimerAsync done opts.timeoutSec + void $ consumeMsgs chan opts.queue Ack (printHead done opts) + DropHead dhOpts -> do + runTimerAsync done opts.timeoutSec + void $ consumeMsgs chan opts.queue Ack (dropHead done opts dhOpts) + takeMVar done + closeConnection conn + putStrLn "connection closed" + where + desc = header "rabbitmq-consumer" <> progDesc "CLI tool to consume messages from a RabbitMQ queue" <> fullDesc + + printHead :: MVar () -> Opts -> (Message, Envelope) -> IO () + printHead done opts (msg, _env) = do + putStrLn $ displayMessage opts msg + void $ tryPutMVar done () + + dropHead :: MVar () -> Opts -> DropHeadOpts -> (Message, Envelope) -> IO () + dropHead done opts dhOpts (msg, env) = do + putStrLn $ displayMessage opts msg + case decode @BackendNotification msg.msgBody of + Nothing -> putStrLn "failed to decode message body" + Just bn -> do + if bn.path == dhOpts.path + then do + putStrLn "dropping message" + nackEnv env + else do + putStrLn "path does not match. keeping message" + void $ tryPutMVar done () + + interactive :: MVar () -> Opts -> (Message, Envelope) -> IO () + interactive done opts (msg, env) = do + putStrLn $ displayMessage opts msg + putStrLn $ "type 'drop' to drop the message and terminate, or press enter to terminate without dropping the message" + input <- getLine + if input == "drop" + then do + ackEnv env + putStrLn "message dropped" + else putStrLn "message not dropped" + void $ tryPutMVar done () + + displayMessage :: Opts -> Message -> String + displayMessage opts msg = + intercalate + "\n" + [ "vhost: " <> cs opts.vhost, + "queue: " <> cs opts.queue, + "timestamp: " <> show msg.msgTimestamp, + "received message: \n" <> BL.unpack (maybe msg.msgBody encodePretty (decode @BackendNotification' msg.msgBody)) + ] + + runTimerAsync :: MVar () -> Int -> IO () + runTimerAsync done sec = void $ forkIO $ do + threadDelay (sec * 1000000) + putStrLn $ "timeout after " <> show sec <> " seconds" + void $ tryPutMVar done () + +data Opts = Opts + { host :: String, + port :: PortNumber, + username :: Text, + password :: Text, + vhost :: Text, + queue :: Text, + timeoutSec :: Int, + cmd :: Command + } + +data DropHeadOpts = DropHeadOpts + { path :: Text + } + +data Command = Head | DropHead DropHeadOpts | Interactive + +optsParser :: Parser Opts +optsParser = + Opts + <$> strOption + ( long "host" + <> short 's' + <> metavar "HOST" + <> help "RabbitMQ host" + <> value "localhost" + <> showDefault + ) + <*> option + auto + ( long "port" + <> short 'p' + <> metavar "PORT" + <> help "RabbitMQ Port" + <> value 5672 + <> showDefault + ) + <*> strOption + ( long "username" + <> short 'u' + <> metavar "USERNAME" + <> help "RabbitMQ Username" + <> value "guest" + <> showDefault + ) + <*> strOption + ( long "password" + <> short 'w' + <> metavar "PASSWORD" + <> help "RabbitMQ Password" + <> value "alpaca-grapefruit" + <> showDefault + ) + <*> strOption + ( long "vhost" + <> short 'v' + <> metavar "VHOST" + <> help "RabbitMQ VHost" + <> value "/" + <> showDefault + ) + <*> strOption + ( long "queue" + <> short 'q' + <> metavar "QUEUE" + <> help "RabbitMQ Queue" + <> value "test" + <> showDefault + ) + <*> option + auto + ( long "timeout" + <> short 't' + <> metavar "TIMEOUT" + <> help + "Timeout in seconds. The command will timeout if no messages are received within this time. \ + \This can happen when the queue is empty, \ + \or when we lose the single active consumer race." + <> value 10 + <> showDefault + ) + <*> hsubparser (headCommand <> dropHeadCommand <> interactiveCommand) + +headCommand :: Mod CommandFields Command +headCommand = + (command "head" (info (pure Head) (progDesc "Print the first message in the queue"))) + +dropHeadCommand :: Mod CommandFields Command +dropHeadCommand = + (command "drop-head" (info p (progDesc "Drop the first message in the queue"))) + where + p :: Parser Command + p = + DropHead + . DropHeadOpts + <$> strOption + ( long "path" + <> short 'a' + <> metavar "PATH" + <> help "only drop the first message if the path matches" + ) + +interactiveCommand :: Mod CommandFields Command +interactiveCommand = + (command "interactive" (info (pure Interactive) (progDesc "Interactively drop the first message from the queue"))) + +newtype Body = Body {unBody :: Value} + deriving (Show, Eq, Generic) + +instance ToJSON Body where + toJSON (Body v) = v + +instance FromJSON Body where + parseJSON v = + Body . bodyToValue . TL.encodeUtf8 <$> parseJSON v + where + bodyToValue :: BL.ByteString -> Value + bodyToValue bs = fromMaybe (String $ cs $ TL.decodeUtf8 bs) $ decode @Value bs + +-- | A variant of 'BackendNotification' with a FromJSON instance for the body field +-- that converts its BL.ByteString content to a JSON value so that it can be pretty printed +data BackendNotification' = BackendNotification' + { ownDomain :: Domain, + targetComponent :: Component, + path :: Text, + body :: Body + } + deriving (Show, Eq, Generic) + +instance ToJSON BackendNotification' + +instance FromJSON BackendNotification' diff --git a/tools/stern/default.nix b/tools/stern/default.nix index c8c64c0d784..2c5867d329a 100644 --- a/tools/stern/default.nix +++ b/tools/stern/default.nix @@ -28,18 +28,19 @@ , lib , metrics-wai , mtl +, openapi3 , optparse-applicative , random , retry , schema-profunctor , servant +, servant-openapi3 , servant-server -, servant-swagger , servant-swagger-ui , split -, swagger2 , tagged , tasty +, tasty-ant-xml , tasty-hunit , text , tinylog @@ -78,13 +79,13 @@ mkDerivation { lens metrics-wai mtl + openapi3 schema-profunctor servant + servant-openapi3 servant-server - servant-swagger servant-swagger-ui split - swagger2 text tinylog transformers @@ -119,6 +120,7 @@ mkDerivation { schema-profunctor tagged tasty + tasty-ant-xml tasty-hunit text tinylog diff --git a/tools/stern/src/Stern/API.hs b/tools/stern/src/Stern/API.hs index 4da3fe838dd..03832056ac0 100644 --- a/tools/stern/src/Stern/API.hs +++ b/tools/stern/src/Stern/API.hs @@ -63,7 +63,7 @@ import Wire.API.Internal.Notification (QueuedNotification) import Wire.API.Routes.Internal.Brig.Connection (ConnectionStatus) import Wire.API.Routes.Internal.Brig.EJPD qualified as EJPD import Wire.API.Routes.Internal.Galley.TeamsIntra qualified as Team -import Wire.API.Routes.Named (Named (Named)) +import Wire.API.Routes.Named (UntypedNamed (Named)) import Wire.API.Team.Feature hiding (setStatus) import Wire.API.Team.SearchVisibility import Wire.API.User @@ -78,7 +78,7 @@ start o = do Server.runSettingsWithShutdown s (servantApp e) Nothing where server :: Env -> Server.Server - server e = Server.defaultServer (unpack $ stern o ^. epHost) (stern o ^. epPort) (e ^. applog) (e ^. metrics) + server e = Server.defaultServer (unpack $ stern o ^. host) (stern o ^. port) (e ^. applog) (e ^. metrics) servantApp :: Env -> Application servantApp e = @@ -402,9 +402,9 @@ getUserData :: UserId -> Maybe Int -> Maybe Int -> Handler UserMetaInfo getUserData uid mMaxConvs mMaxNotifs = do account <- Intra.getUserProfiles (Left [uid]) >>= noSuchUser . listToMaybe conns <- Intra.getUserConnections uid - convs <- Intra.getUserConversations uid <&> take (fromMaybe 1 mMaxConvs) + convs <- Intra.getUserConversations uid (fromMaybe 1 mMaxConvs) clts <- Intra.getUserClients uid - notfs <- (Intra.getUserNotifications uid <&> take (fromMaybe 10 mMaxNotifs) <&> toJSON @[QueuedNotification]) `catchE` (pure . String . cs . show) + notfs <- (Intra.getUserNotifications uid (fromMaybe 10 mMaxNotifs) <&> toJSON @[QueuedNotification]) `catchE` (pure . String . cs . show) consent <- (Intra.getUserConsentValue uid <&> toJSON @ConsentValue) `catchE` (pure . String . cs . show) consentLog <- (Intra.getUserConsentLog uid <&> toJSON @ConsentLog) `catchE` (pure . String . cs . show) cookies <- Intra.getUserCookies uid diff --git a/tools/stern/src/Stern/API/Routes.hs b/tools/stern/src/Stern/API/Routes.hs index f3e7116d514..e54540adb94 100644 --- a/tools/stern/src/Stern/API/Routes.hs +++ b/tools/stern/src/Stern/API/Routes.hs @@ -32,14 +32,14 @@ import Data.Aeson qualified as A import Data.Handle import Data.Id import Data.Kind +import Data.OpenApi qualified as S import Data.Schema qualified as Schema -import Data.Swagger qualified as S import Imports hiding (head) import Network.HTTP.Types.Status import Network.Wai.Utilities import Servant hiding (Handler, WithStatus (..), addHeader, respond) -import Servant.Swagger (HasSwagger (toSwagger)) -import Servant.Swagger.Internal.Orphans () +import Servant.OpenApi (HasOpenApi (toOpenApi)) +import Servant.OpenApi.Internal.Orphans () import Servant.Swagger.UI import Stern.Types import Wire.API.CustomBackend @@ -233,7 +233,7 @@ type SternAPI = :> "blacklist" :> QueryParam' [Optional, Strict, Description "A verified email address"] "email" Email :> QueryParam' [Optional, Strict, Description "A verified phone number (E.164 format)."] "phone" Phone - :> Verb 'HEAD 200 '[JSON] NoContent + :> Verb 'GET 200 '[JSON] NoContent ) :<|> Named "post-user-blacklist" @@ -455,7 +455,7 @@ type SwaggerDocsAPI = SwaggerSchemaUI "swagger-ui" "swagger.json" swaggerDocs :: Servant.Server SwaggerDocsAPI swaggerDocs = swaggerSchemaUIServer $ - toSwagger (Proxy @SternAPI) + toOpenApi (Proxy @SternAPI) & S.info . S.title .~ "Stern API" & cleanupSwagger diff --git a/tools/stern/src/Stern/App.hs b/tools/stern/src/Stern/App.hs index a2fb31b6ba9..1e4a1f2bfde 100644 --- a/tools/stern/src/Stern/App.hs +++ b/tools/stern/src/Stern/App.hs @@ -74,7 +74,7 @@ newEnv o = do Env (mkRequest $ O.brig o) (mkRequest $ O.galley o) (mkRequest $ O.gundeck o) (mkRequest $ O.ibis o) (mkRequest $ O.galeb o) l mt def <$> newManager where - mkRequest s = Bilge.host (encodeUtf8 (s ^. epHost)) . Bilge.port (s ^. epPort) $ Bilge.empty + mkRequest s = Bilge.host (encodeUtf8 (s ^. host)) . Bilge.port (s ^. port) $ Bilge.empty newManager = Bilge.newManager (Bilge.defaultManagerSettings {Bilge.managerResponseTimeout = responseTimeoutMicro 10000000}) -- Monads diff --git a/tools/stern/src/Stern/Intra.hs b/tools/stern/src/Stern/Intra.hs index f064691c51f..b68821eba8c 100644 --- a/tools/stern/src/Stern/Intra.hs +++ b/tools/stern/src/Stern/Intra.hs @@ -90,11 +90,12 @@ import Data.Qualified (qUnqualified) import Data.Text (strip) import Data.Text.Encoding (decodeUtf8, encodeUtf8) import Data.Text.Lazy (pack) +import Data.Text.Lazy.Encoding qualified as TL import GHC.TypeLits (KnownSymbol) import Imports import Network.HTTP.Types (urlEncode) import Network.HTTP.Types.Method -import Network.HTTP.Types.Status hiding (statusCode) +import Network.HTTP.Types.Status hiding (statusCode, statusMessage) import Network.Wai.Utilities (Error (..), mkError) import Servant.API (toUrlPiece) import Stern.App @@ -454,7 +455,7 @@ getTeamBillingInfo :: TeamId -> Handler (Maybe TeamBillingInfo) getTeamBillingInfo tid = do info $ msg "Getting team billing info" i <- view ibis - r <- + resp <- catchRpcErrors $ rpc' "ibis" @@ -462,10 +463,10 @@ getTeamBillingInfo tid = do ( method GET . Bilge.paths ["i", "team", toByteString' tid, "billing"] ) - case Bilge.statusCode r of - 200 -> Just <$> parseResponse (mkError status502 "bad-upstream") r + case Bilge.statusCode resp of + 200 -> Just <$> parseResponse (mkError status502 "bad-upstream") resp 404 -> pure Nothing - _ -> throwE (mkError status502 "bad-upstream" "bad response") + _ -> throwE (mkError status502 "bad-upstream" (errorMessage resp)) setTeamBillingInfo :: TeamId -> TeamBillingInfo -> Handler () setTeamBillingInfo tid tbu = do @@ -486,19 +487,19 @@ isBlacklisted :: Either Email Phone -> Handler Bool isBlacklisted emailOrPhone = do info $ msg "Checking blacklist" b <- view brig - r <- + resp <- catchRpcErrors $ rpc' "brig" b - ( method HEAD + ( method GET . Bilge.path "i/users/blacklist" . userKeyToParam emailOrPhone ) - case Bilge.statusCode r of + case Bilge.statusCode resp of 200 -> pure True 404 -> pure False - _ -> throwE (mkError status502 "bad-upstream" "bad response") + _ -> throwE (mkError status502 "bad-upstream" (errorMessage resp)) setBlacklistStatus :: Bool -> Either Email Phone -> Handler () setBlacklistStatus status emailOrPhone = do @@ -535,7 +536,7 @@ getTeamFeatureFlag tid = do case Bilge.statusCode resp of 200 -> pure $ responseJsonUnsafe @(Public.WithStatus cfg) resp 404 -> throwE (mkError status404 "bad-upstream" "team doesnt exist") - _ -> throwE (mkError status502 "bad-upstream" "bad response") + _ -> throwE (mkError status502 "bad-upstream" (errorMessage resp)) setTeamFeatureFlag :: forall cfg. @@ -559,7 +560,7 @@ setTeamFeatureFlag tid status = do 200 -> pure () 404 -> throwE (mkError status404 "bad-upstream" "team doesnt exist") 403 -> throwE (mkError status403 "bad-upstream" "legal hold config cannot be changed") - _ -> throwE (mkError status502 "bad-upstream" "bad response") + _ -> throwE (mkError status502 "bad-upstream" (errorMessage resp)) where checkDaysLimit :: FeatureTTL -> Handler () checkDaysLimit = \case @@ -620,6 +621,9 @@ userKeyToParam :: Either Email Phone -> Request -> Request userKeyToParam (Left e) = queryItem "email" (stripBS $ toByteString' e) userKeyToParam (Right p) = queryItem "phone" (stripBS $ toByteString' p) +errorMessage :: Response (Maybe LByteString) -> LText +errorMessage = maybe "" TL.decodeUtf8 . responseBody + -- | Run an App and catch any RPCException's which may occur, lifting them to ExceptT -- This isn't an ideal set-up; but is required in certain cases because 'ExceptT' isn't -- an instance of 'MonadUnliftIO' @@ -749,25 +753,27 @@ getUserCookies uid = do ) parseResponse (mkError status502 "bad-upstream") r -getUserConversations :: UserId -> Handler [Conversation] -getUserConversations uid = do +getUserConversations :: UserId -> Int -> Handler [Conversation] +getUserConversations uid maxConvs = do info $ msg "Getting user conversations" - fetchAll [] Nothing + fetchAll [] Nothing maxConvs where - fetchAll xs start = do - userConversationList <- fetchBatch start + fetchAll :: [Conversation] -> Maybe ConvId -> Int -> Handler [Conversation] + fetchAll xs start remaining = do + userConversationList <- fetchBatch start (min 100 remaining) let batch = convList userConversationList - if (not . null) batch && convHasMore userConversationList - then fetchAll (batch ++ xs) (Just . qUnqualified . cnvQualifiedId $ last batch) + remaining' = remaining - length batch + if (not . null) batch && convHasMore userConversationList && remaining' > 0 + then fetchAll (batch ++ xs) (Just . qUnqualified . cnvQualifiedId $ last batch) remaining' else pure (batch ++ xs) - fetchBatch :: Maybe ConvId -> Handler (ConversationList Conversation) - fetchBatch start = do - b <- view galley + fetchBatch :: Maybe ConvId -> Int -> Handler (ConversationList Conversation) + fetchBatch start batchSize = do + baseReq <- view galley r <- catchRpcErrors $ rpc' "galley" - b + baseReq ( method GET . header "Z-User" (toByteString' uid) . versionedPath "conversations" @@ -776,7 +782,6 @@ getUserConversations uid = do . expect2xx ) unVersioned @'V2 <$> parseResponse (mkError status502 "bad-upstream") r - batchSize = 100 :: Int getUserClients :: UserId -> Handler [Client] getUserClients uid = do @@ -829,25 +834,27 @@ getUserProperties uid = do value <- parseResponse (mkError status502 "bad-upstream") r fetchProperty b xs (Map.insert x value acc) -getUserNotifications :: UserId -> Handler [QueuedNotification] -getUserNotifications uid = do +getUserNotifications :: UserId -> Int -> Handler [QueuedNotification] +getUserNotifications uid maxNotifs = do info $ msg "Getting user notifications" - fetchAll [] Nothing + fetchAll [] Nothing maxNotifs where - fetchAll xs start = do - userNotificationList <- fetchBatch start + fetchAll :: [QueuedNotification] -> Maybe NotificationId -> Int -> ExceptT Error App [QueuedNotification] + fetchAll xs start remaining = do + userNotificationList <- fetchBatch start (min 100 remaining) let batch = view queuedNotifications userNotificationList - if (not . null) batch && view queuedHasMore userNotificationList - then fetchAll (batch ++ xs) (Just . view queuedNotificationId $ last batch) + remaining' = remaining - length batch + if (not . null) batch && view queuedHasMore userNotificationList && remaining' > 0 + then fetchAll (batch ++ xs) (Just . view queuedNotificationId $ last batch) remaining' else pure (batch ++ xs) - fetchBatch :: Maybe NotificationId -> Handler QueuedNotificationList - fetchBatch start = do - b <- view gundeck + fetchBatch :: Maybe NotificationId -> Int -> Handler QueuedNotificationList + fetchBatch start batchSize = do + baseReq <- view gundeck r <- catchRpcErrors $ rpc' - "galley" - b + "gundeck" + baseReq ( method GET . header "Z-User" (toByteString' uid) . versionedPath "notifications" @@ -861,7 +868,6 @@ getUserNotifications uid = do 200 -> parseResponse (mkError status502 "bad-upstream") r 404 -> parseResponse (mkError status502 "bad-upstream") r _ -> throwE (mkError status502 "bad-upstream" "") - batchSize = 100 :: Int getSsoDomainRedirect :: Text -> Handler (Maybe CustomBackend) getSsoDomainRedirect domain = do diff --git a/tools/stern/src/Stern/Types.hs b/tools/stern/src/Stern/Types.hs index aa3e9af280a..f8ed807492a 100644 --- a/tools/stern/src/Stern/Types.hs +++ b/tools/stern/src/Stern/Types.hs @@ -30,10 +30,10 @@ import Data.Aeson import Data.Aeson.TH import Data.ByteString.Conversion import Data.Json.Util +import Data.OpenApi qualified as Swagger import Data.Proxy import Data.Range import Data.Schema qualified as S -import Data.Swagger qualified as Swagger import Galley.Types.Teams import Imports import Servant.API @@ -127,7 +127,7 @@ instance Swagger.ToSchema ConsentLog where declareNamedSchema _ = pure . Swagger.NamedSchema (Just "ConsentLog") $ mempty - & Swagger.type_ ?~ Swagger.SwaggerObject + & Swagger.type_ ?~ Swagger.OpenApiObject & Swagger.description ?~ "(object structure is not specified in this schema)" newtype ConsentValue = ConsentValue @@ -141,8 +141,8 @@ newtype MarketoResult = MarketoResult deriving (Eq, Show, ToJSON, FromJSON) data ConsentLogAndMarketo = ConsentLogAndMarketo - { clamConsentLog :: ConsentLog, - clamMarketo :: MarketoResult + { consentLog :: ConsentLog, + marketo :: MarketoResult } deriving (Eq, Show) @@ -152,7 +152,7 @@ instance Swagger.ToSchema ConsentLogAndMarketo where declareNamedSchema _ = pure . Swagger.NamedSchema (Just "ConsentLogAndMarketo") $ mempty - & Swagger.type_ ?~ Swagger.SwaggerObject + & Swagger.type_ ?~ Swagger.OpenApiObject & Swagger.description ?~ "(object structure is not specified in this schema)" newtype UserMetaInfo = UserMetaInfo @@ -164,7 +164,7 @@ instance Swagger.ToSchema UserMetaInfo where declareNamedSchema _ = pure . Swagger.NamedSchema (Just "UserMetaInfo") $ mempty - & Swagger.type_ ?~ Swagger.SwaggerObject + & Swagger.type_ ?~ Swagger.OpenApiObject & Swagger.description ?~ "(object structure is not specified in this schema)" newtype InvoiceId = InvoiceId {unInvoiceId :: Text} diff --git a/tools/stern/stern.cabal b/tools/stern/stern.cabal index 0a4be042c59..aedd9ca5883 100644 --- a/tools/stern/stern.cabal +++ b/tools/stern/stern.cabal @@ -91,13 +91,13 @@ library , lens >=4.4 , metrics-wai >=0.3 , mtl >=2.1 + , openapi3 , schema-profunctor , servant + , servant-openapi3 , servant-server - , servant-swagger , servant-swagger-ui , split >=0.2 - , swagger2 , text >=1.1 , tinylog >=0.10 , transformers >=0.3 @@ -270,6 +270,7 @@ executable stern-integration , stern , tagged , tasty >=0.8 + , tasty-ant-xml , tasty-hunit >=0.9 , text , tinylog diff --git a/tools/stern/test/integration/API.hs b/tools/stern/test/integration/API.hs index 5edaf92362c..744481eda6f 100644 --- a/tools/stern/test/integration/API.hs +++ b/tools/stern/test/integration/API.hs @@ -563,7 +563,7 @@ ejpdInfo includeContacts handles = do userBlacklistHead :: Either Email Phone -> TestM ResponseLBS userBlacklistHead emailOrPhone = do s <- view tsStern - Bilge.head (s . paths ["users", "blacklist"] . mkQueryParam emailOrPhone) + Bilge.get (s . paths ["users", "blacklist"] . mkQueryParam emailOrPhone) postUserBlacklist :: Either Email Phone -> TestM () postUserBlacklist emailOrPhone = do diff --git a/tools/stern/test/integration/Main.hs b/tools/stern/test/integration/Main.hs index 08fb5b41e07..3acef76603c 100644 --- a/tools/stern/test/integration/Main.hs +++ b/tools/stern/test/integration/Main.hs @@ -34,9 +34,12 @@ import OpenSSL (withOpenSSL) import Options.Applicative import System.Logger qualified as Logger import Test.Tasty +import Test.Tasty.Ingredients import Test.Tasty.Options +import Test.Tasty.Runners +import Test.Tasty.Runners.AntXML import TestSetup -import Util.Options +import Util.Options (Endpoint (Endpoint)) import Util.Test data IntegrationConfig = IntegrationConfig @@ -74,6 +77,8 @@ runTests run = defaultMainWithIngredients ings $ [ Option (Proxy :: Proxy ServiceConfigFile), Option (Proxy :: Proxy IntegrationConfigFile) ] + : listingTests + : composeReporters antXMLRunner consoleTestReporter : defaultIngredients main :: IO ()