Make pending invites expire via TTL index (#568)

* Make invites expire after configurable window

The value can be set in EXPIRE_AFTER_SECONDS env var and via
helm chart values, and defaults to 7 days.

* Create nightly test CI and add invite expiration test to it

* Update 404 error message for missing or expired invite

---------

Co-authored-by: sua yoo <sua@suayoo.com>
This commit is contained in:
Tessa Walsh 2023-02-14 16:07:14 -05:00 committed by GitHub
parent baa2214c9f
commit 14b349443f
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
17 changed files with 217 additions and 41 deletions

View File

@ -68,7 +68,7 @@ jobs:
- name: Start Cluster with Helm - name: Start Cluster with Helm
run: | run: |
helm upgrade --install -f ./chart/values.yaml -f ./chart/examples/k3d-ci.yaml btrix ./chart/ helm upgrade --install -f ./chart/values.yaml -f ./chart/test/test.yaml btrix ./chart/
- name: Install Python - name: Install Python
uses: actions/setup-python@v3 uses: actions/setup-python@v3

83
.github/workflows/k3d-nightly-ci.yaml vendored Normal file
View File

@ -0,0 +1,83 @@
name: Nightly tests (K3d)
on:
schedule:
# Run daily at 8am UTC
- cron: '0 8 1 * *'
jobs:
btrix-k3d-nightly-test:
runs-on: ubuntu-latest
steps:
- name: Create k3d Cluster
uses: AbsaOSS/k3d-action@v2
with:
cluster-name: btrix-nightly
args: >-
-p "30870:30870@agent:0:direct"
--agents 1
--no-lb
--k3s-arg "--no-deploy=traefik,servicelb,metrics-server@server:*"
- name: Checkout
uses: actions/checkout@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
with:
driver-opts: network=host
- name: Build Backend
uses: docker/build-push-action@v3
with:
context: backend
load: true
#outputs: type=tar,dest=backend.tar
tags: webrecorder/browsertrix-backend:latest
cache-from: type=gha,scope=backend
cache-to: type=gha,scope=backend,mode=max
- name: Build Frontend
uses: docker/build-push-action@v3
with:
context: frontend
load: true
#outputs: type=tar,dest=frontend.tar
tags: webrecorder/browsertrix-frontend:latest
cache-from: type=gha,scope=frontend
cache-to: type=gha,scope=frontend,mode=max
- name: 'Import Images'
run: |
k3d image import webrecorder/browsertrix-backend:latest -m direct -c btrix-nightly --verbose
k3d image import webrecorder/browsertrix-frontend:latest -m direct -c btrix-nightly --verbose
- name: Install Kubectl
uses: azure/setup-kubectl@v3
- name: Install Helm
uses: azure/setup-helm@v3
with:
version: 3.10.2
- name: Start Cluster with Helm
run: |
helm upgrade --install -f ./chart/values.yaml -f ./chart/test/test.yaml --set invite_expire_seconds=10 btrix ./chart/
- name: Install Python
uses: actions/setup-python@v3
with:
python-version: '3.9'
- name: Install Python Libs
run: pip install pytest requests
- name: Wait for all pods to be ready
run: kubectl wait --for=condition=ready pod --all --timeout=240s
- name: Run Tests
run: pytest -vv ./backend/test_nightly/*.py
- name: Print Backend Logs
if: ${{ failure() }}
run: kubectl logs svc/browsertrix-cloud-backend

View File

@ -46,7 +46,7 @@ jobs:
- name: Start Cluster with Helm - name: Start Cluster with Helm
run: | run: |
sudo microk8s helm3 upgrade --install -f ./chart/values.yaml -f ./chart/examples/microk8s-ci.yaml btrix ./chart/ sudo microk8s helm3 upgrade --install -f ./chart/values.yaml -f ./chart/test/test.yaml -f ./chart/test/microk8s-ci.yaml btrix ./chart/
- name: Install Python - name: Install Python
uses: actions/setup-python@v3 uses: actions/setup-python@v3

View File

@ -56,6 +56,7 @@ async def update_and_prepare_db(
crawl_config_ops, crawl_config_ops,
crawls_ops, crawls_ops,
coll_ops, coll_ops,
invite_ops,
): ):
"""Prepare database for application. """Prepare database for application.
@ -68,7 +69,7 @@ async def update_and_prepare_db(
""" """
if await run_db_migrations(mdb): if await run_db_migrations(mdb):
await drop_indexes(mdb) await drop_indexes(mdb)
await create_indexes(org_ops, crawl_config_ops, crawls_ops, coll_ops) await create_indexes(org_ops, crawl_config_ops, crawls_ops, coll_ops, invite_ops)
await user_manager.create_super_user() await user_manager.create_super_user()
await org_ops.create_default_org() await org_ops.create_default_org()
print("Database updated and ready", flush=True) print("Database updated and ready", flush=True)
@ -121,13 +122,14 @@ async def drop_indexes(mdb):
# ============================================================================ # ============================================================================
async def create_indexes(org_ops, crawl_config_ops, crawls_ops, coll_ops): async def create_indexes(org_ops, crawl_config_ops, crawls_ops, coll_ops, invite_ops):
"""Create database indexes.""" """Create database indexes."""
print("Creating database indexes", flush=True) print("Creating database indexes", flush=True)
await org_ops.init_index() await org_ops.init_index()
await crawl_config_ops.init_index() await crawl_config_ops.init_index()
await crawls_ops.init_index() await crawls_ops.init_index()
await coll_ops.init_index() await coll_ops.init_index()
await invite_ops.init_index()
# ============================================================================ # ============================================================================

View File

@ -5,8 +5,10 @@ from enum import IntEnum
from typing import Optional from typing import Optional
import os import os
import urllib.parse import urllib.parse
import time
import uuid import uuid
from pymongo.errors import AutoReconnect
from pydantic import BaseModel, UUID4 from pydantic import BaseModel, UUID4
from fastapi import HTTPException from fastapi import HTTPException
@ -67,6 +69,25 @@ class InviteOps:
self.email = email self.email = email
self.allow_dupe_invites = os.environ.get("ALLOW_DUPE_INVITES", "0") == "1" self.allow_dupe_invites = os.environ.get("ALLOW_DUPE_INVITES", "0") == "1"
async def init_index(self):
"""Create TTL index so that invites auto-expire"""
while True:
try:
# Default to 7 days
expire_after_seconds = int(
os.environ.get("INVITE_EXPIRE_SECONDS", "604800")
)
return await self.invites.create_index(
"created", expireAfterSeconds=expire_after_seconds
)
# pylint: disable=duplicate-code
except AutoReconnect:
print(
"Database connection unavailable to create index. Will try again in 5 scconds",
flush=True,
)
time.sleep(5)
async def add_new_user_invite( async def add_new_user_invite(
self, self,
new_user_invite: InvitePending, new_user_invite: InvitePending,

View File

@ -108,7 +108,7 @@ def main():
asyncio.create_task( asyncio.create_task(
update_and_prepare_db( update_and_prepare_db(
mdb, user_manager, org_ops, crawl_config_ops, crawls, coll_ops mdb, user_manager, org_ops, crawl_config_ops, crawls, coll_ops, invites
) )
) )

View File

@ -167,6 +167,7 @@ class OrgOps:
while True: while True:
try: try:
return await self.orgs.create_index("name", unique=True) return await self.orgs.create_index("name", unique=True)
# pylint: disable=duplicate-code
except AutoReconnect: except AutoReconnect:
print( print(
"Database connection unavailable to create index. Will try again in 5 scconds", "Database connection unavailable to create index. Will try again in 5 scconds",

View File

@ -1,4 +1,5 @@
import requests import requests
import time
import pytest import pytest

View File

View File

@ -0,0 +1,43 @@
import pytest
import requests
import time
HOST_PREFIX = "http://127.0.0.1:30870"
API_PREFIX = HOST_PREFIX + "/api"
ADMIN_USERNAME = "admin@example.com"
ADMIN_PW = "PASSW0RD!"
@pytest.fixture(scope="session")
def admin_auth_headers():
while True:
r = requests.post(
f"{API_PREFIX}/auth/jwt/login",
data={
"username": ADMIN_USERNAME,
"password": ADMIN_PW,
"grant_type": "password",
},
)
data = r.json()
try:
return {"Authorization": f"Bearer {data['access_token']}"}
except:
print("Waiting for admin_auth_headers")
time.sleep(5)
@pytest.fixture(scope="session")
def default_org_id(admin_auth_headers):
while True:
r = requests.get(f"{API_PREFIX}/orgs", headers=admin_auth_headers)
data = r.json()
try:
for org in data["orgs"]:
if org["default"] is True:
return org["id"]
except:
print("Waiting for default org id")
time.sleep(5)

View File

@ -0,0 +1,44 @@
import requests
from .conftest import API_PREFIX
def test_invites_expire(admin_auth_headers, default_org_id):
# Send invite
INVITE_EMAIL = "invite-expires@example.com"
r = requests.post(
f"{API_PREFIX}/orgs/{default_org_id}/invite",
headers=admin_auth_headers,
json={"email": INVITE_EMAIL, "role": 10},
)
assert r.status_code == 200
data = r.json()
assert data["invited"] == "new_user"
# Verify invite exists
r = requests.get(
f"{API_PREFIX}/orgs/{default_org_id}/invites",
headers=admin_auth_headers,
)
assert r.status_code == 200
data = r.json()
invites_matching_email = [
invite for invite in data["pending_invites"] if invite["email"] == INVITE_EMAIL
]
assert len(invites_matching_email) == 1
# Wait two minutes to give Mongo sufficient time to delete the invite
# See: https://www.mongodb.com/docs/manual/core/index-ttl/#timing-of-the-delete-operation
time.sleep(120)
# Check invites again and verify invite has been removed
r = requests.get(
f"{API_PREFIX}/orgs/{default_org_id}/invites",
headers=admin_auth_headers,
)
assert r.status_code == 200
data = r.json()
invites_matching_email = [
invite for invite in data["pending_invites"] if invite["email"] == INVITE_EMAIL
]
assert len(invites_matching_email) == 0

View File

@ -1,30 +0,0 @@
# microk8s overrides for ci
# -------------------------
# use local images
backend_image: "localhost:32000/webrecorder/browsertrix-backend:latest"
frontend_image: "localhost:32000/webrecorder/browsertrix-frontend:latest"
# don't pull use, existing images
backend_pull_policy: "IfNotPresent"
frontend_pull_policy: "IfNotPresent"
mongo_auth:
# specify either username + password (for local mongo)
username: root
password: PASSWORD@
superuser:
# set this to enable a superuser admin
email: admin@example.com
# optional: if not set, automatically generated
# change or remove this
password: PASSW0RD!
local_service_port: 30870

View File

@ -27,6 +27,8 @@ data:
DEFAULT_ORG: "{{ .Values.default_org }}" DEFAULT_ORG: "{{ .Values.default_org }}"
INVITE_EXPIRE_SECONDS: "{{ .Values.invite_expire_seconds }}"
JOB_IMAGE: "{{ .Values.backend_image }}" JOB_IMAGE: "{{ .Values.backend_image }}"
JOB_PULL_POLICY: "{{ .Values.backend_pull_policy }}" JOB_PULL_POLICY: "{{ .Values.backend_pull_policy }}"

View File

@ -0,0 +1,6 @@
# microk8s overrides for ci
# -------------------------
# use local images
backend_image: "localhost:32000/webrecorder/browsertrix-backend:latest"
frontend_image: "localhost:32000/webrecorder/browsertrix-frontend:latest"

View File

@ -1,12 +1,9 @@
# k3s overrides for ci # test overrides
# -------------------- # --------------
# don't pull use, existing images
backend_pull_policy: "Never" backend_pull_policy: "Never"
frontend_pull_policy: "Never" frontend_pull_policy: "Never"
mongo_auth: mongo_auth:
# specify either username + password (for local mongo) # specify either username + password (for local mongo)
username: root username: root
@ -23,4 +20,3 @@ superuser:
local_service_port: 30870 local_service_port: 30870

View File

@ -24,6 +24,9 @@ allow_dupe_invites: "0"
# number of workers for backend api # number of workers for backend api
backend_workers: 4 backend_workers: 4
# number of seconds before pending invites expire - default is 7 days
invite_expire_seconds: 604800
# base url for replayweb.page # base url for replayweb.page
rwp_base_url: "https://replayweb.page/" rwp_base_url: "https://replayweb.page/"

View File

@ -100,6 +100,10 @@ export class Join extends LiteElement {
inviterName: body.inviterName, inviterName: body.inviterName,
orgName: body.orgName, orgName: body.orgName,
}; };
} else if (resp.status === 404) {
this.serverError = msg(
"This invite doesn't exist or has expired. Please ask the organization administrator to resend an invitation."
);
} else { } else {
this.serverError = msg("This invitation is not valid"); this.serverError = msg("This invitation is not valid");
} }