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
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
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
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
uses: actions/setup-python@v3

View File

@ -56,6 +56,7 @@ async def update_and_prepare_db(
crawl_config_ops,
crawls_ops,
coll_ops,
invite_ops,
):
"""Prepare database for application.
@ -68,7 +69,7 @@ async def update_and_prepare_db(
"""
if await run_db_migrations(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 org_ops.create_default_org()
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."""
print("Creating database indexes", flush=True)
await org_ops.init_index()
await crawl_config_ops.init_index()
await crawls_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
import os
import urllib.parse
import time
import uuid
from pymongo.errors import AutoReconnect
from pydantic import BaseModel, UUID4
from fastapi import HTTPException
@ -67,6 +69,25 @@ class InviteOps:
self.email = email
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(
self,
new_user_invite: InvitePending,

View File

@ -108,7 +108,7 @@ def main():
asyncio.create_task(
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:
try:
return await self.orgs.create_index("name", unique=True)
# pylint: disable=duplicate-code
except AutoReconnect:
print(
"Database connection unavailable to create index. Will try again in 5 scconds",

View File

@ -1,4 +1,5 @@
import requests
import time
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 }}"
INVITE_EXPIRE_SECONDS: "{{ .Values.invite_expire_seconds }}"
JOB_IMAGE: "{{ .Values.backend_image }}"
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"
frontend_pull_policy: "Never"
mongo_auth:
# specify either username + password (for local mongo)
username: root
@ -23,4 +20,3 @@ superuser:
local_service_port: 30870

View File

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

View File

@ -100,6 +100,10 @@ export class Join extends LiteElement {
inviterName: body.inviterName,
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 {
this.serverError = msg("This invitation is not valid");
}