UNPKG

smc-hub

Version:

CoCalc: Backend webserver component

218 lines (205 loc) 7.39 kB
/* * This file is part of CoCalc: Copyright © 2020 Sagemath, Inc. * License: AGPLv3 s.t. "Commons Clause" – see LICENSE.md for details */ import { PostgreSQL } from "./types"; import { is_array, is_valid_uuid_string } from "smc-util/misc"; import { callback2 } from "smc-util/async-utils"; const GROUPS = ["owner", "collaborator"] as const; export async function add_collaborators_to_projects( db: PostgreSQL, account_id: string, accounts: string[], projects: string[], // can be empty strings if tokens specified (since they determine project_id) tokens?: string[] // must be all specified or none ): Promise<void> { try { // In case of project tokens, this mutates the projects array. await verify_write_access_to_projects(db, account_id, projects, tokens); } catch (err) { // There is one case where a user can add themselve to a project that they // are not a collaborator on, which is a TA can add themself to a course project. // Technically this is the case when accounts[0] == account_id and // projects[0] points to a course in project_id where account_id is a // collaborator on project_id. We only support one accounts/projects // and no use of tokens for this. if (accounts.length == 1 && account_id == accounts[0] && tokens == null) { await verify_course_access_to_project(db, account_id, projects[0]); } else { throw err; } } /* Right now this function is called from outside typescript (e.g., api from user), so we have to do extra type checking. Also, the input is uuid's, which typescript can't check. */ verify_types(account_id, accounts, projects); // We now know that account_id is allowed to add users to all of the projects, // *OR* at that there are valid tokens to permit adding users. // Now we just need to do the actual collab add. This could be done in many // ways that are more parallel, or via a single transaction, etc... but for // now let's just do it one at a time. If any fail, then nothing further // will happen and the client gets an error. This should result in minimal // load given that it's one at a time, and the server and db are a ms from // each other. for (const i in projects) { const project_id: string = projects[i]; const account_id: string = accounts[i]; const token_id: string | undefined = tokens?.[i]; if (await callback2(db.user_is_collaborator, { project_id, account_id })) { // nothing to do since user is already on the given project -- won't use up token. continue; } await callback2(db.add_user_to_project, { project_id, account_id, }); if (token_id != null) { await increment_project_invite_token_counter(db, token_id); } } } async function verify_write_access_to_projects( db: PostgreSQL, account_id: string, projects: string[], tokens?: string[] ): Promise<void> { // Also, we are not doing this in parallel, but could. Let's not // put undue load on the server for this. if (tokens != null) { // Using tokens for adding users to projects... for (let i = 0; i < projects.length; i++) { if (tokens[i] == null) { throw Error("If tokens are specified, they must all be non-null."); } const { project_id, error } = await project_invite_token_project_id( db, tokens[i] ); if (error || !project_id) { throw Error(`Project invite token is not valid - ${error}`); } projects[i] = project_id; } return; } // Not using tokens: // Note that projects are likely to be repeated, so we use a Set. for (const project_id of new Set(projects)) { if ( !(await callback2(db.user_is_in_project_group, { project_id, account_id, groups: GROUPS, })) ) { throw Error( `user ${account_id} does not have write access to project ${project_id}` ); } } } function verify_types( account_id: string, accounts: string[], projects: string[] ) { if (!is_valid_uuid_string(account_id)) throw Error( `account_id (="${account_id}") must be a valid uuid string (type=${typeof account_id})` ); if (!is_array(accounts)) { throw Error("accounts must be an array"); } if (!is_array(projects)) { throw Error("projects must be an array"); } if (accounts.length != projects.length) { throw Error( `accounts (of length ${accounts.length}) and projects (of length ${projects.length}) must be arrays of the same length` ); } for (const x of accounts) { if (!is_valid_uuid_string(x)) throw Error(`all account id's must be valid uuid's, but "${x}" is not`); } for (const x of projects) { if (x != "" && !is_valid_uuid_string(x)) throw Error( `all project id's must be valid uuid's (or empty), but "${x}" is not` ); } } // Returns {error:"..."} if token is not valid. // Returns {project_id:"...."} with project_id of the project if the token is valid. async function project_invite_token_project_id( db: PostgreSQL, token: string ): Promise<{ project_id?: string; error?: string }> { let v; try { v = await db.async_query({ table: "project_invite_tokens", select: ["expires", "counter", "usage_limit", "project_id"], where: { token }, }); } catch (err) { return { error: `problem querying the database -- ${err}` }; } if (v.rows.length == 0) return { error: "no such token" }; const { expires, counter, usage_limit, project_id } = v.rows[0]; if (expires != null && expires <= new Date()) { return { error: "the token already expired" }; } if (usage_limit != null && counter >= usage_limit) { return { error: `the token can only be used ${usage_limit} times` }; } return { project_id }; } async function increment_project_invite_token_counter( db: PostgreSQL, token: string ): Promise<void> { await db.async_query({ query: "UPDATE project_invite_tokens SET counter=coalesce(counter, 0)+1 WHERE token=$1", params: [token], }); } async function verify_course_access_to_project( db: PostgreSQL, account_id: string, project_id: string ): Promise<void> { /* Raise an exception unless: - project_id is associated to a course in another project course_id - account_id is a collaborator on course_id. */ // Get the course field of project_id const v = await db.async_query({ query: "SELECT course FROM projects WHERE project_id=$1", params: [project_id], }); if (v.rows.length == 0) { throw Error(`no project with id "${project_id}"`); } const course_id = v.rows[0].course?.project_id; if (!is_valid_uuid_string(course_id)) { throw Error(`cannot add self to "${project_id}" -- must be an admin`); } if (!is_valid_uuid_string(account_id)) { // be extra careful since we directly put account_id in the query string. throw Error(`account_id ${account_id} must be a valid uuid`); } const w = await db.async_query({ query: `SELECT users#>'{${account_id},group}' AS group FROM projects WHERE project_id=\$1`, params: [course_id], }); const group = w.rows[0]?.group; if (group != "owner" && group != "collaborator") { throw Error( `cannot add self to "${project_id}" -- must be owner or collaborator on course project` ); } }