From e1ef89427562844d3b5d965d58e0ac955fcebab6 Mon Sep 17 00:00:00 2001 From: Ilya Kreymer Date: Mon, 1 Jul 2024 09:37:02 -0700 Subject: [PATCH] Extends Org Create endpont + shared secret auth (#1897) Updates the /api/orgs/create endpoint to: - not have name / slug be required, will be renamed on first user via #1870 - support optional quotas - support optional first admin user email, who will receive an invite to join the org. Also supports a new shared secret mechanism, to allow an external automation to access the /api/orgs/create endpoint (and only that endpoint thus far) via a shared secret instead of normal login. --- backend/btrixcloud/auth.py | 19 ++++- backend/btrixcloud/invites.py | 6 +- backend/btrixcloud/main.py | 13 ++- backend/btrixcloud/models.py | 22 +++-- backend/btrixcloud/orgs.py | 62 ++++++++++---- backend/btrixcloud/users.py | 8 +- backend/test/test_org.py | 148 +++++++++++++++++++++++++++++++++ chart/templates/backend.yaml | 7 ++ chart/templates/configmap.yaml | 2 +- 9 files changed, 253 insertions(+), 34 deletions(-) diff --git a/backend/btrixcloud/auth.py b/backend/btrixcloud/auth.py index d2c2fcbf..3c9aa015 100644 --- a/backend/btrixcloud/auth.py +++ b/backend/btrixcloud/auth.py @@ -29,6 +29,8 @@ PASSWORD_SECRET = os.environ.get("PASSWORD_SECRET", uuid4().hex) JWT_TOKEN_LIFETIME = int(os.environ.get("JWT_TOKEN_LIFETIME_MINUTES", 60)) * 60 +BTRIX_SUBS_APP_API_KEY = os.environ.get("BTRIX_SUBS_APP_API_KEY", "") + ALGORITHM = "HS256" RESET_VERIFY_TOKEN_LIFETIME_MINUTES = 60 @@ -143,7 +145,9 @@ def init_jwt_auth(user_manager): """init jwt auth router + current_active_user dependency""" oauth2_scheme = OA2BearerOrQuery(tokenUrl="/api/auth/jwt/login", auto_error=False) - async def get_current_user(token: str = Depends(oauth2_scheme)) -> User: + async def get_current_user( + token: str = Depends(oauth2_scheme), + ) -> User: try: payload = decode_jwt(token, AUTH_ALLOW_AUD) uid: Optional[str] = payload.get("sub") or payload.get("user_id") @@ -157,6 +161,17 @@ def init_jwt_auth(user_manager): headers={"WWW-Authenticate": "Bearer"}, ) + async def shared_secret_or_active_user( + token: str = Depends(oauth2_scheme), + ) -> User: + # allow superadmin access if token matches the known shared secret + # if the shared secret is set + # ensure using a long shared secret (eg. uuid4) + if BTRIX_SUBS_APP_API_KEY and token == BTRIX_SUBS_APP_API_KEY: + return await user_manager.get_superuser() + + return await get_current_user(token) + current_active_user = get_current_user auth_jwt_router = APIRouter() @@ -232,4 +247,4 @@ def init_jwt_auth(user_manager): async def refresh_jwt(user=Depends(current_active_user)): return get_bearer_response(user) - return auth_jwt_router, current_active_user + return auth_jwt_router, current_active_user, shared_secret_or_active_user diff --git a/backend/btrixcloud/invites.py b/backend/btrixcloud/invites.py index 00c9f6fc..71a95983 100644 --- a/backend/btrixcloud/invites.py +++ b/backend/btrixcloud/invites.py @@ -118,7 +118,7 @@ class InviteOps: org=None, allow_existing=False, headers: Optional[dict] = None, - ): + ) -> tuple[bool, str]: """Invite user to org (if not specified, to default org). If allow_existing is false, don't allow invites to existing users. @@ -156,7 +156,7 @@ class InviteOps: org_name, headers, ) - return True + return True, str(invite_pending.id) if not allow_existing: raise HTTPException(status_code=400, detail="User already registered") @@ -180,7 +180,7 @@ class InviteOps: invite_pending, org_name, invitee_user.email, invite_code, headers ) - return False + return False, invite_code async def get_pending_invites( self, org=None, page_size: int = DEFAULT_PAGE_SIZE, page: int = 1 diff --git a/backend/btrixcloud/main.py b/backend/btrixcloud/main.py index bdc3e747..501c7ce0 100644 --- a/backend/btrixcloud/main.py +++ b/backend/btrixcloud/main.py @@ -76,9 +76,18 @@ def main(): user_manager = init_user_manager(mdb, email, invites) - current_active_user = init_users_api(app, user_manager) + current_active_user, shared_secret_or_active_user = init_users_api( + app, user_manager + ) - org_ops = init_orgs_api(app, mdb, user_manager, invites, current_active_user) + org_ops = init_orgs_api( + app, + mdb, + user_manager, + invites, + current_active_user, + shared_secret_or_active_user, + ) event_webhook_ops = init_event_webhooks_api(mdb, org_ops, app_root) diff --git a/backend/btrixcloud/models.py b/backend/btrixcloud/models.py index e0005967..d576e88a 100644 --- a/backend/btrixcloud/models.py +++ b/backend/btrixcloud/models.py @@ -906,11 +906,6 @@ class RenameOrg(BaseModel): slug: Optional[str] = None -# ============================================================================ -class CreateOrg(RenameOrg): - """Create a new org""" - - # ============================================================================ class OrgStorageRefs(BaseModel): """Input model for setting primary storage + optional replicas""" @@ -963,6 +958,19 @@ class OrgQuotas(BaseModel): giftedExecMinutes: Optional[int] = 0 +# ============================================================================ +class OrgCreate(BaseModel): + """Create a new org""" + + name: Optional[str] = None + slug: Optional[str] = None + + firstAdminInviteEmail: Optional[str] = None + quotas: Optional[OrgQuotas] = None + + subData: Optional[Dict[str, Any]] = None + + # ============================================================================ class OrgQuotaUpdate(BaseModel): """Organization quota update (to track changes over time)""" @@ -1083,6 +1091,8 @@ class Organization(BaseMongoModel): readOnly: Optional[bool] = False readOnlyReason: Optional[str] = None + subData: Optional[Dict[str, Any]] = None + def is_owner(self, user): """Check if user is owner""" return self._is_auth(user, UserRole.OWNER) @@ -1281,7 +1291,7 @@ class UserCreateIn(BaseModel): inviteToken: Optional[UUID] = None - newOrg: bool + newOrg: Optional[bool] = False newOrgName: Optional[str] = "" diff --git a/backend/btrixcloud/orgs.py b/backend/btrixcloud/orgs.py index 02eec3de..2da2d526 100644 --- a/backend/btrixcloud/orgs.py +++ b/backend/btrixcloud/orgs.py @@ -28,7 +28,7 @@ from .models import ( OrgReadOnlyUpdate, OrgMetrics, OrgWebhookUrls, - CreateOrg, + OrgCreate, RenameOrg, UpdateRole, RemovePendingInvite, @@ -43,6 +43,7 @@ from .models import ( from .pagination import DEFAULT_PAGE_SIZE, paginated_format from .utils import slug_from_name, validate_slug + if TYPE_CHECKING: from .invites import InviteOps else: @@ -357,15 +358,12 @@ class OrgOps: async def add_user_by_invite( self, invite: InvitePending, user: User - ) -> Optional[Organization]: + ) -> Organization: """Add user to an org from an InvitePending, if any. - If there's no org to add to (eg. superuser invite), just return. + If there's no org to add to, raise exception """ - if not invite.oid: - return None - - org = await self.get_org_by_id(invite.oid) + org = invite.oid and await self.get_org_by_id(invite.oid) if not org: raise HTTPException( status_code=400, detail="Invalid Invite Code, No Such Organization" @@ -726,8 +724,8 @@ class OrgOps: # ============================================================================ -# pylint: disable=too-many-statements -def init_orgs_api(app, mdb, user_manager, invites, user_dep): +# pylint: disable=too-many-statements, too-many-arguments +def init_orgs_api(app, mdb, user_manager, invites, user_dep, user_or_shared_secret_dep): """Init organizations api router for /orgs""" # pylint: disable=too-many-locals,invalid-name @@ -801,27 +799,55 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep): @app.post("/orgs/create", tags=["organizations"]) async def create_org( - new_org: CreateOrg, - user: User = Depends(user_dep), + new_org: OrgCreate, + request: Request, + user: User = Depends(user_or_shared_secret_dep), ): if not user.is_superuser: raise HTTPException(status_code=403, detail="Not Allowed") id_ = uuid4() + name = new_org.name or str(id_) + if new_org.slug: validate_slug(new_org.slug) slug = new_org.slug else: - slug = slug_from_name(new_org.name) + slug = slug_from_name(name) org = Organization( - id=id_, name=new_org.name, slug=slug, users={}, storage=ops.default_primary + id=id_, + name=name, + slug=slug, + users={}, + storage=ops.default_primary, + quotas=new_org.quotas or OrgQuotas(), + subData=new_org.subData, ) if not await ops.add_org(org): return {"added": False, "error": "already_exists"} - return {"id": id_, "added": True} + result = {"added": True, "id": id_} + + if new_org.firstAdminInviteEmail: + new_user, token = await invites.invite_user( + InviteToOrgRequest( + email=new_org.firstAdminInviteEmail, role=UserRole.OWNER + ), + user, + user_manager, + org=org, + allow_existing=True, + headers=request.headers, + ) + if new_user: + result["invited"] = "new_user" + else: + result["invited"] = "existing_user" + result["token"] = token + + return result @router.get("", tags=["organizations"]) async def get_org( @@ -918,14 +944,15 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep): org: Organization = Depends(org_owner_dep), user: User = Depends(user_dep), ): - if await invites.invite_user( + new_user, _ = await invites.invite_user( invite, user, user_manager, org=org, allow_existing=True, headers=request.headers, - ): + ) + if new_user: return {"invited": "new_user"} return {"invited": "existing_user"} @@ -935,7 +962,8 @@ def init_orgs_api(app, mdb, user_manager, invites, user_dep): invite = await invites.accept_user_invite(user, token, user_manager) org = await ops.add_user_by_invite(invite, user) - return {"added": True, "org": org} + org_out = await org.serialize_for_user(user, user_manager) + return {"added": True, "org": org_out} @router.get("/invites", tags=["invites"]) async def get_pending_org_invites( diff --git a/backend/btrixcloud/users.py b/backend/btrixcloud/users.py index 589fbe78..994ca5db 100644 --- a/backend/btrixcloud/users.py +++ b/backend/btrixcloud/users.py @@ -643,10 +643,12 @@ def init_user_manager(mdb, emailsender, invites): # ============================================================================ # pylint: disable=too-many-locals, raise-missing-from -def init_users_api(app, user_manager: UserManager) -> APIRouter: +def init_users_api(app, user_manager: UserManager): """init fastapi_users""" - auth_jwt_router, current_active_user = init_jwt_auth(user_manager) + auth_jwt_router, current_active_user, shared_secret_or_active_user = init_jwt_auth( + user_manager + ) app.include_router( auth_jwt_router, @@ -666,7 +668,7 @@ def init_users_api(app, user_manager: UserManager) -> APIRouter: tags=["users"], ) - return current_active_user + return current_active_user, shared_secret_or_active_user # ============================================================================ diff --git a/backend/test/test_org.py b/backend/test/test_org.py index e5327865..ade1605a 100644 --- a/backend/test/test_org.py +++ b/backend/test/test_org.py @@ -7,6 +7,10 @@ from .conftest import API_PREFIX new_oid = None +new_subs_oid = None + +VALID_PASSWORD = "ValidPassW0rd!" + def test_ensure_only_one_default_org(admin_auth_headers): r = requests.get(f"{API_PREFIX}/orgs", headers=admin_auth_headers) @@ -555,3 +559,147 @@ def test_update_read_only(admin_auth_headers, default_org_id): assert data["readOnly"] is False # Test that reason is unset when readOnly is set to false, even implicitly assert data["readOnlyReason"] == "" + + +def test_create_org_and_invite_new_user(admin_auth_headers): + invite_email = "new-user@example.com" + r = requests.post( + f"{API_PREFIX}/orgs/create", + headers=admin_auth_headers, + json={ + "firstAdminInviteEmail": invite_email, + "quotas": { + "maxPagesPerCrawl": 100, + "maxConcurrentCrawls": 1, + "storageQuota": 1000000, + "maxExecMinutesPerMonth": 1000, + }, + "subData": {"extra": "data", "sub": {"id": 123}}, + }, + ) + + assert r.status_code == 200 + data = r.json() + assert data["added"] + + org_id = data["id"] + + assert data["invited"] == "new_user" + token = data["token"] + + # Look up token + r = requests.get( + f"{API_PREFIX}/orgs/{org_id}/invites", + headers=admin_auth_headers, + ) + assert r.status_code == 200 + data = r.json() + + assert data["total"] == 1 + + invites = data["items"] + assert len(invites) == 1 + invite = invites[0] + + assert invite["email"] == invite_email + assert token == invite["id"] + + global new_subs_oid + new_subs_oid = org_id + + # Create user with invite + r = requests.post( + f"{API_PREFIX}/auth/register", + headers=admin_auth_headers, + json={ + "name": "Test User", + "email": invite_email, + "password": VALID_PASSWORD, + "inviteToken": token, + }, + ) + assert r.status_code == 201 + + +def test_validate_new_org_with_quotas_and_user(admin_auth_headers): + r = requests.get(f"{API_PREFIX}/orgs/{new_subs_oid}", headers=admin_auth_headers) + assert r.status_code == 200 + + data = r.json() + assert data["slug"] == "test-users-archive" + assert data["name"] == "Test User's Archive" + + assert data["quotas"] == { + "maxPagesPerCrawl": 100, + "maxConcurrentCrawls": 1, + "storageQuota": 1000000, + "maxExecMinutesPerMonth": 1000, + "extraExecMinutes": 0, + "giftedExecMinutes": 0, + } + assert "subData" not in data + + +def test_create_org_and_invite_existing_user(admin_auth_headers): + invite_email = "new-user@example.com" + r = requests.post( + f"{API_PREFIX}/orgs/create", + headers=admin_auth_headers, + json={ + "firstAdminInviteEmail": invite_email, + "quotas": { + "maxPagesPerCrawl": 100, + "maxConcurrentCrawls": 1, + "storageQuota": 1000000, + "maxExecMinutesPerMonth": 1000, + }, + "subData": {"extra": "data", "sub": {"id": 123}}, + }, + ) + + assert r.status_code == 200 + data = r.json() + assert data["added"] + + org_id = data["id"] + + assert data["invited"] == "existing_user" + token = data["token"] + + r = requests.post( + f"{API_PREFIX}/auth/jwt/login", + data={ + "username": invite_email, + "password": VALID_PASSWORD, + "grant_type": "password", + }, + ) + data = r.json() + assert r.status_code == 200 + login_token = data["access_token"] + + auth_headers = {"Authorization": "bearer " + login_token} + + # Accept existing user invite + r = requests.post( + f"{API_PREFIX}/orgs/invite-accept/{token}", + headers=auth_headers, + ) + assert r.status_code == 200 + data = r.json() + assert data["added"] + org = data["org"] + + assert org["id"] == org_id + assert org["name"] == "Test User's Archive 2" + assert org["slug"] == "test-users-archive-2" + + assert org["quotas"] == { + "maxPagesPerCrawl": 100, + "maxConcurrentCrawls": 1, + "storageQuota": 1000000, + "maxExecMinutesPerMonth": 1000, + "extraExecMinutes": 0, + "giftedExecMinutes": 0, + } + assert "subData" not in org diff --git a/chart/templates/backend.yaml b/chart/templates/backend.yaml index ea9c7441..5e369454 100644 --- a/chart/templates/backend.yaml +++ b/chart/templates/backend.yaml @@ -79,6 +79,13 @@ spec: - name: MOTOR_MAX_WORKERS value: "{{ .Values.backend_mongodb_workers | default 1 }}" + - name: BTRIX_SUBS_APP_API_KEY + valueFrom: + secretKeyRef: + name: btrix-subs-app-secret + key: BTRIX_SUBS_APP_API_KEY + optional: true + volumeMounts: - name: ops-configs mountPath: /ops-configs/ diff --git a/chart/templates/configmap.yaml b/chart/templates/configmap.yaml index 754b09dd..79c973a1 100644 --- a/chart/templates/configmap.yaml +++ b/chart/templates/configmap.yaml @@ -6,7 +6,7 @@ metadata: namespace: {{ .Release.Namespace }} data: - APP_ORIGIN: {{ .Values.ingress.tls | ternary "https" "http" }}://{{ .Values.ingress.host | default "localhost:9870" }} + APP_ORIGIN: {{ .Values.ingress.tls | ternary "https" "http" }}://{{ or .Values.ingress.host ( print "localhost:" ( .Values.local_service_port | default 9870 )) }} CRAWLER_NAMESPACE: {{ .Values.crawler_namespace }}