From c39fcebc44e753563715fea1c172e1494987d3ba Mon Sep 17 00:00:00 2001 From: Gavinok <34443260+Gavinok@users.noreply.github.com> Date: Thu, 29 Aug 2024 14:52:13 -0700 Subject: [PATCH] Merge pull request #1330 from bcgov/block-api-for-deleted-tenant Improvements to Tenant Deletion --- .../traction/templates/acapy/deployment.yaml | 6 +-- plugins/traction_innkeeper/poetry.lock | 40 ++++++++++++++++- plugins/traction_innkeeper/pyproject.toml | 1 + .../v1_0/innkeeper/models.py | 10 ++++- .../v1_0/innkeeper/routes.py | 43 +++++++++++++++++++ .../v1_0/innkeeper/utils.py | 11 +++-- services/aca-py/ngrok-wait.sh | 2 +- .../components/innkeeper/tenants/Tenants.vue | 1 + .../deleteTenant/ConfirmTenantDeletion.vue | 15 +++++-- .../tenants/deleteTenant/DeleteTenant.vue | 2 + .../frontend/src/plugins/i18n/locales/en.json | 6 ++- 11 files changed, 121 insertions(+), 16 deletions(-) diff --git a/charts/traction/templates/acapy/deployment.yaml b/charts/traction/templates/acapy/deployment.yaml index a2bc962a7..48c41b8b0 100644 --- a/charts/traction/templates/acapy/deployment.yaml +++ b/charts/traction/templates/acapy/deployment.yaml @@ -51,6 +51,9 @@ spec: --endpoint https://{{ include "acapy.host" . }} \ --arg-file '/home/aries/argfile.yml' \ --plugin 'aries_cloudagent.messaging.jsonld' \ + {{- if .Values.acapy.plugins.multitenantProvider }} + --plugin multitenant_provider.v1_0 \ + {{- end }} {{- if .Values.acapy.plugins.tractionInnkeeper }} --plugin traction_plugins.traction_innkeeper.v1_0 \ --plugin-config-value traction_innkeeper.innkeeper_wallet.tenant_id=\"$(INNKEEPER_WALLET_TENANT_ID)\" \ @@ -62,9 +65,6 @@ spec: {{- if .Values.acapy.plugins.connectionUpdate }} --plugin connection_update.v1_0 \ {{- end }} - {{- if .Values.acapy.plugins.multitenantProvider }} - --plugin multitenant_provider.v1_0 \ - {{- end }} {{- if .Values.acapy.plugins.rpc }} --plugin rpc.v1_0 \ {{- end }} diff --git a/plugins/traction_innkeeper/poetry.lock b/plugins/traction_innkeeper/poetry.lock index 0b01c4b6d..abfbfc7cb 100644 --- a/plugins/traction_innkeeper/poetry.lock +++ b/plugins/traction_innkeeper/poetry.lock @@ -1188,9 +1188,13 @@ files = [ {file = "lxml-5.2.2-cp36-cp36m-win_amd64.whl", hash = "sha256:edcfa83e03370032a489430215c1e7783128808fd3e2e0a3225deee278585196"}, {file = "lxml-5.2.2-cp37-cp37m-macosx_10_9_x86_64.whl", hash = "sha256:28bf95177400066596cdbcfc933312493799382879da504633d16cf60bba735b"}, {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_12_i686.manylinux2010_i686.manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:3a745cc98d504d5bd2c19b10c79c61c7c3df9222629f1b6210c0368177589fb8"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1b590b39ef90c6b22ec0be925b211298e810b4856909c8ca60d27ffbca6c12e6"}, {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:b336b0416828022bfd5a2e3083e7f5ba54b96242159f83c7e3eebaec752f1716"}, + {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_aarch64.whl", hash = "sha256:c2faf60c583af0d135e853c86ac2735ce178f0e338a3c7f9ae8f622fd2eb788c"}, {file = "lxml-5.2.2-cp37-cp37m-manylinux_2_28_x86_64.whl", hash = "sha256:4bc6cb140a7a0ad1f7bc37e018d0ed690b7b6520ade518285dc3171f7a117905"}, + {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_aarch64.whl", hash = "sha256:7ff762670cada8e05b32bf1e4dc50b140790909caa8303cfddc4d702b71ea184"}, {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_1_x86_64.whl", hash = "sha256:57f0a0bbc9868e10ebe874e9f129d2917750adf008fe7b9c1598c0fbbfdde6a6"}, + {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_aarch64.whl", hash = "sha256:a6d2092797b388342c1bc932077ad232f914351932353e2e8706851c870bca1f"}, {file = "lxml-5.2.2-cp37-cp37m-musllinux_1_2_x86_64.whl", hash = "sha256:60499fe961b21264e17a471ec296dcbf4365fbea611bf9e303ab69db7159ce61"}, {file = "lxml-5.2.2-cp37-cp37m-win32.whl", hash = "sha256:d9b342c76003c6b9336a80efcc766748a333573abf9350f4094ee46b006ec18f"}, {file = "lxml-5.2.2-cp37-cp37m-win_amd64.whl", hash = "sha256:b16db2770517b8799c79aa80f4053cd6f8b716f21f8aca962725a9565ce3ee40"}, @@ -1498,6 +1502,30 @@ files = [ {file = "multidict-6.0.5.tar.gz", hash = "sha256:f7e301075edaf50500f0b341543c41194d8df3ae5caf4702f2095f3ca73dd8da"}, ] +[[package]] +name = "multitenant-provider" +version = "0.1.0" +description = " (Supported aries-cloudagent version: 0.12.2) " +optional = false +python-versions = "^3.9" +files = [] +develop = false + +[package.dependencies] +bcrypt = "^4.1.3" +mergedeep = "^1.3.4" +python-dateutil = "^2.8.2" + +[package.extras] +aca-py = ["aries-cloudagent (>=0.10.3,<1.0.0)"] + +[package.source] +type = "git" +url = "https://github.com/hyperledger/aries-acapy-plugins" +reference = "0.12.2" +resolved_reference = "e9ddf9da53b84949cc94f419a92ee423812db190" +subdirectory = "multitenant_provider" + [[package]] name = "mypy-extensions" version = "1.0.0" @@ -1945,6 +1973,7 @@ files = [ {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:69b023b2b4daa7548bcfbd4aa3da05b3a74b772db9e23b982788168117739938"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:81e0b275a9ecc9c0c0c07b4b90ba548307583c125f54d5b6946cfee6360c733d"}, {file = "PyYAML-6.0.1-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ba336e390cd8e4d1739f42dfe9bb83a3cc2e80f567d8805e11b46f4a943f5515"}, + {file = "PyYAML-6.0.1-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:326c013efe8048858a6d312ddd31d56e468118ad4cdeda36c719bf5bb6192290"}, {file = "PyYAML-6.0.1-cp310-cp310-win32.whl", hash = "sha256:bd4af7373a854424dabd882decdc5579653d7868b8fb26dc7d0e99f823aa5924"}, {file = "PyYAML-6.0.1-cp310-cp310-win_amd64.whl", hash = "sha256:fd1592b3fdf65fff2ad0004b5e363300ef59ced41c2e6b3a99d4089fa8c5435d"}, {file = "PyYAML-6.0.1-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:6965a7bc3cf88e5a1c3bd2e0b5c22f8d677dc88a455344035f03399034eb3007"}, @@ -1952,8 +1981,15 @@ files = [ {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:42f8152b8dbc4fe7d96729ec2b99c7097d656dc1213a3229ca5383f973a5ed6d"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:062582fca9fabdd2c8b54a3ef1c978d786e0f6b3a1510e0ac93ef59e0ddae2bc"}, {file = "PyYAML-6.0.1-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:d2b04aac4d386b172d5b9692e2d2da8de7bfb6c387fa4f801fbf6fb2e6ba4673"}, + {file = "PyYAML-6.0.1-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:e7d73685e87afe9f3b36c799222440d6cf362062f78be1013661b00c5c6f678b"}, {file = "PyYAML-6.0.1-cp311-cp311-win32.whl", hash = "sha256:1635fd110e8d85d55237ab316b5b011de701ea0f29d07611174a1b42f1444741"}, {file = "PyYAML-6.0.1-cp311-cp311-win_amd64.whl", hash = "sha256:bf07ee2fef7014951eeb99f56f39c9bb4af143d8aa3c21b1677805985307da34"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_10_9_x86_64.whl", hash = "sha256:855fb52b0dc35af121542a76b9a84f8d1cd886ea97c84703eaa6d88e37a2ad28"}, + {file = "PyYAML-6.0.1-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:40df9b996c2b73138957fe23a16a4f0ba614f4c0efce1e9406a184b6d07fa3a9"}, + {file = "PyYAML-6.0.1-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:6c22bec3fbe2524cde73d7ada88f6566758a8f7227bfbf93a408a9d86bcc12a0"}, + {file = "PyYAML-6.0.1-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:8d4e9c88387b0f5c7d5f281e55304de64cf7f9c0021a3525bd3b1c542da3b0e4"}, + {file = "PyYAML-6.0.1-cp312-cp312-win32.whl", hash = "sha256:d483d2cdf104e7c9fa60c544d92981f12ad66a457afae824d146093b8c294c54"}, + {file = "PyYAML-6.0.1-cp312-cp312-win_amd64.whl", hash = "sha256:0d3304d8c0adc42be59c5f8a4d9e3d7379e6955ad754aa9d6ab7a398b59dd1df"}, {file = "PyYAML-6.0.1-cp36-cp36m-macosx_10_9_x86_64.whl", hash = "sha256:50550eb667afee136e9a77d6dc71ae76a44df8b3e51e41b77f6de2932bfe0f47"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:1fe35611261b29bd1de0070f0b2f47cb6ff71fa6595c077e42bd0c419fa27b98"}, {file = "PyYAML-6.0.1-cp36-cp36m-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:704219a11b772aea0d8ecd7058d0082713c3562b4e271b849ad7dc4a5c90c13c"}, @@ -1970,6 +2006,7 @@ files = [ {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:a0cd17c15d3bb3fa06978b4e8958dcdc6e0174ccea823003a106c7d4d7899ac5"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:28c119d996beec18c05208a8bd78cbe4007878c6dd15091efb73a30e90539696"}, {file = "PyYAML-6.0.1-cp38-cp38-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7e07cbde391ba96ab58e532ff4803f79c4129397514e1413a7dc761ccd755735"}, + {file = "PyYAML-6.0.1-cp38-cp38-musllinux_1_1_x86_64.whl", hash = "sha256:49a183be227561de579b4a36efbb21b3eab9651dd81b1858589f796549873dd6"}, {file = "PyYAML-6.0.1-cp38-cp38-win32.whl", hash = "sha256:184c5108a2aca3c5b3d3bf9395d50893a7ab82a38004c8f61c258d4428e80206"}, {file = "PyYAML-6.0.1-cp38-cp38-win_amd64.whl", hash = "sha256:1e2722cc9fbb45d9b87631ac70924c11d3a401b2d7f410cc0e3bbf249f2dca62"}, {file = "PyYAML-6.0.1-cp39-cp39-macosx_10_9_x86_64.whl", hash = "sha256:9eb6caa9a297fc2c2fb8862bc5370d0303ddba53ba97e71f08023b6cd73d16a8"}, @@ -1977,6 +2014,7 @@ files = [ {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:5773183b6446b2c99bb77e77595dd486303b4faab2b086e7b17bc6bef28865f6"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:b786eecbdf8499b9ca1d697215862083bd6d2a99965554781d0d8d1ad31e13a0"}, {file = "PyYAML-6.0.1-cp39-cp39-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:bc1bf2925a1ecd43da378f4db9e4f799775d6367bdb94671027b73b393a7c42c"}, + {file = "PyYAML-6.0.1-cp39-cp39-musllinux_1_1_x86_64.whl", hash = "sha256:04ac92ad1925b2cff1db0cfebffb6ffc43457495c9b3c39d3fcae417d7125dc5"}, {file = "PyYAML-6.0.1-cp39-cp39-win32.whl", hash = "sha256:faca3bdcf85b2fc05d06ff3fbc1f83e1391b3e724afa3feba7d13eeab355484c"}, {file = "PyYAML-6.0.1-cp39-cp39-win_amd64.whl", hash = "sha256:510c9deebc5c0225e8c96813043e62b680ba2f9c50a08d3724c7f28a747d1486"}, {file = "PyYAML-6.0.1.tar.gz", hash = "sha256:bfdf460b1736c775f2ba9f6a92bca30bc2095067b8a9d77876d1fad6cc3b4a43"}, @@ -2394,4 +2432,4 @@ test = ["big-O", "importlib-resources", "jaraco.functools", "jaraco.itertools", [metadata] lock-version = "2.0" python-versions = "^3.9" -content-hash = "c079927241e9effcf416cfcaf9cd722b181646a5e1d9011d28a1e342b64f1e34" +content-hash = "80b930aab481989c533d6293f8461ce762869520df9a80977e37347120ea56da" diff --git a/plugins/traction_innkeeper/pyproject.toml b/plugins/traction_innkeeper/pyproject.toml index bf9998c9d..6f3100039 100644 --- a/plugins/traction_innkeeper/pyproject.toml +++ b/plugins/traction_innkeeper/pyproject.toml @@ -15,6 +15,7 @@ bcrypt = "^4.2.0" mergedeep = "^1.3.4" typing-extensions = "4.8.0" anoncreds = "^0.2.0" +multitenant-provider = {git = "https://github.com/hyperledger/aries-acapy-plugins", rev = "0.12.2", subdirectory = "multitenant_provider"} [tool.poetry.dev-dependencies] black = "^24.8.0" diff --git a/plugins/traction_innkeeper/traction_innkeeper/v1_0/innkeeper/models.py b/plugins/traction_innkeeper/traction_innkeeper/v1_0/innkeeper/models.py index f16eb5be6..d414ee6ab 100644 --- a/plugins/traction_innkeeper/traction_innkeeper/v1_0/innkeeper/models.py +++ b/plugins/traction_innkeeper/traction_innkeeper/v1_0/innkeeper/models.py @@ -4,7 +4,6 @@ from typing import Optional, Union, List from aries_cloudagent.core.profile import ProfileSession -from aries_cloudagent.ledger.base import LOGGER from aries_cloudagent.messaging.models.base_record import BaseRecord, BaseRecordSchema from aries_cloudagent.messaging.util import datetime_to_str, str_to_datetime from aries_cloudagent.messaging.valid import UUIDFour @@ -357,12 +356,19 @@ async def soft_delete(self, session: ProfileSession): Soft delete the tenant record by setting its state to 'deleted'. Note: This method should be called on an instance of the TenantRecord. """ + # Delete api records + recs = await TenantAuthenticationApiRecord.query_by_tenant_id( + session, self.tenant_id + ) + for rec in recs: + if rec.tenant_id == self.tenant_id: + await rec.delete_record(session) + if self.state != self.STATE_DELETED: self.state = self.STATE_DELETED self.deleted_at = datetime_to_str(datetime.utcnow()) await self.save(session, reason="Soft delete") - async def restore_deleted(self, session: ProfileSession): """ Un-soft-delete the tenant record by setting its state to 'active'. diff --git a/plugins/traction_innkeeper/traction_innkeeper/v1_0/innkeeper/routes.py b/plugins/traction_innkeeper/traction_innkeeper/v1_0/innkeeper/routes.py index 89eac03c2..30281f022 100644 --- a/plugins/traction_innkeeper/traction_innkeeper/v1_0/innkeeper/routes.py +++ b/plugins/traction_innkeeper/traction_innkeeper/v1_0/innkeeper/routes.py @@ -25,6 +25,7 @@ from aries_cloudagent.version import __version__ from aries_cloudagent.wallet.error import WalletSettingsError from aries_cloudagent.wallet.models.wallet_record import WalletRecord +from multitenant_provider.v1_0.routes import plugin_wallet_create_token from marshmallow import fields, validate from . import TenantManager @@ -522,6 +523,30 @@ async def tenant_create_token(request: web.BaseRequest): return web.json_response({"token": token}) +@docs( + tags=["multitenancy"], + summary="Get auth token for a subwallet (innkeeper plugin override)", +) +@request_schema(CreateWalletTokenRequestSchema) +@response_schema(CreateWalletTokenResponseSchema(), 200, description="") +@error_handler +async def tenant_wallet_create_token(request: web.BaseRequest): + context: AdminRequestContext = request["context"] + wallet_id = request.match_info["wallet_id"] + + mgr = context.inject(TenantManager) + profile = mgr.profile + + # Tenants must always be fetch by their wallet id. + async with profile.session() as session: + rec = await TenantRecord.query_by_wallet_id(session, wallet_id) + LOGGER.debug("when creating token ", rec) + if rec.state == TenantRecord.STATE_DELETED: + raise web.HTTPUnauthorized(reason="Tenant is disabled") + + return await plugin_wallet_create_token(request) + + @docs( tags=[SWAGGER_CATEGORY], ) @@ -1029,8 +1054,26 @@ async def register(app: web.Application): "/multitenancy/reservations/{reservation_id}/check-in", tenant_checkin ), web.post("/multitenancy/tenant/{tenant_id}/token", tenant_create_token), + web.post( + "/multitenancy/wallet/{wallet_id}/token", tenant_wallet_create_token + ), ] ) + # Find the endpoint for token creation that already exists and + # override it + for r in app.router.routes(): + if r.method == "POST": + if ( + r.resource + and r.resource.canonical == "/multitenancy/wallet/{wallet_id}/token" + ): + LOGGER.info( + f"found route: {r.method} {r.resource.canonical} ({r.handler})" + ) + LOGGER.info(f"... replacing current handler: {r.handler}") + r._handler = tenant_wallet_create_token + LOGGER.info(f"... with new handler: {r.handler}") + has_wallet_create_token = True # routes that require a tenant token for the innkeeper wallet/tenant/agent. # these require not only a tenant, but it has to be the innkeeper tenant! app.add_routes( diff --git a/plugins/traction_innkeeper/traction_innkeeper/v1_0/innkeeper/utils.py b/plugins/traction_innkeeper/traction_innkeeper/v1_0/innkeeper/utils.py index ed6a4741a..065cb010f 100644 --- a/plugins/traction_innkeeper/traction_innkeeper/v1_0/innkeeper/utils.py +++ b/plugins/traction_innkeeper/traction_innkeeper/v1_0/innkeeper/utils.py @@ -7,7 +7,7 @@ from aries_cloudagent.messaging.models.openapi import OpenAPISchema from marshmallow import fields -from .models import ReservationRecord, TenantAuthenticationApiRecord +from .models import ReservationRecord, TenantAuthenticationApiRecord, TenantRecord from . import TenantManager @@ -114,7 +114,9 @@ async def refresh_registration_token(reservation_id: str, manager: TenantManager ) except Exception as err: LOGGER.error("Failed to retrieve reservation: %s", err) - raise ReservationException("Could not retrieve reservation record.") from err + raise ReservationException( + "Could not retrieve reservation record." + ) from err if reservation.state != ReservationRecord.STATE_APPROVED: raise ReservationException("Only approved reservations can refresh tokens.") @@ -140,7 +142,8 @@ async def refresh_registration_token(reservation_id: str, manager: TenantManager LOGGER.info("Refreshed token for reservation %s", reservation_id) - return _pwd + return _pwd + def generate_api_key_data(): _key = str(uuid.uuid4().hex) @@ -156,6 +159,8 @@ def generate_api_key_data(): async def create_api_key(rec: TenantAuthenticationApiRecord, manager: TenantManager): + if rec.state == TenantRecord.STATE_DELETED: + raise ValueError("Tenant is disabled") async with manager.profile.session() as session: _key, _salt, _hash = generate_api_key_data() rec.api_key_token_salt = _salt.decode("utf-8") diff --git a/services/aca-py/ngrok-wait.sh b/services/aca-py/ngrok-wait.sh index a9db533f7..f55bed2f8 100755 --- a/services/aca-py/ngrok-wait.sh +++ b/services/aca-py/ngrok-wait.sh @@ -37,8 +37,8 @@ exec aca-py start \ --wallet-storage-config "{\"url\":\"${POSTGRESQL_HOST}:5432\",\"max_connections\":5, \"wallet_scheme\":\"${TRACTION_ACAPY_WALLET_SCHEME}\"}" \ --wallet-storage-creds "{\"account\":\"${POSTGRESQL_USER}\",\"password\":\"${POSTGRESQL_PASSWORD}\",\"admin_account\":\"${POSTGRESQL_USER}\",\"admin_password\":\"${POSTGRESQL_PASSWORD}\"}" \ --admin "0.0.0.0" ${TRACTION_ACAPY_ADMIN_PORT} \ + --plugin multitenant_provider.v1_0 \ --plugin traction_plugins.traction_innkeeper.v1_0 \ --plugin basicmessage_storage.v1_0 \ --plugin connection_update.v1_0 \ - --plugin multitenant_provider.v1_0 \ --plugin rpc.v1_0 \ diff --git a/services/tenant-ui/frontend/src/components/innkeeper/tenants/Tenants.vue b/services/tenant-ui/frontend/src/components/innkeeper/tenants/Tenants.vue index 8929890f6..89691cc86 100644 --- a/services/tenant-ui/frontend/src/components/innkeeper/tenants/Tenants.vue +++ b/services/tenant-ui/frontend/src/components/innkeeper/tenants/Tenants.vue @@ -52,6 +52,7 @@ {{ $t('common.deleted') }} + diff --git a/services/tenant-ui/frontend/src/components/innkeeper/tenants/deleteTenant/ConfirmTenantDeletion.vue b/services/tenant-ui/frontend/src/components/innkeeper/tenants/deleteTenant/ConfirmTenantDeletion.vue index 4b20e2930..c051460e4 100644 --- a/services/tenant-ui/frontend/src/components/innkeeper/tenants/deleteTenant/ConfirmTenantDeletion.vue +++ b/services/tenant-ui/frontend/src/components/innkeeper/tenants/deleteTenant/ConfirmTenantDeletion.vue @@ -21,7 +21,7 @@ {{ $t('tenants.settings.permanentDelete') }} -
+