From bdce1ed75c74ea29cf9ecba3d4523704fe066d94 Mon Sep 17 00:00:00 2001 From: Vasili Pascal Date: Sun, 11 Oct 2026 12:59:01 +0200 Subject: [PATCH] feat(team): share projects with members and invite by email A project owner on the Team plan can invite people by email or add them by user id. A project has 5 seats: the owner, every member and every pending invitation that has not expired. Invitations live 7 days, store only a token hash, and are accepted by the signed-in account whose confirmed email is the invited one. The plan is checked when inviting, so members keep access if the owner later leaves Team; an unknown plan is refused, never treated as Team. Roles: a member works on the stack like the owner (view, change apps, deploy, roll back, run agent commands). A viewer sees the project in the shared list and its deployments. Only the owner manages secrets, deletion, protection, members and invitations. No access answers exactly like a missing project (404); not enough role answers 403. A member deploys on the owner's behalf: the owner's saved cloud, servers, SSH keys and quota, with everything recorded under the owner. The owner's address comes from the internal user lookup; if it cannot be read, the deploy is refused. Tests: project_team_seats covers the feature scenarios, the project route sweep now checks every route per role, and both security sweeps include the new routes. Two viewer expectations change from 404 to 403 because a viewer has access to the project but not to the stack. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_012TKZy6f5shPzM9WLdswH6f --- ...ca5b6d5445cfba98d9215649b5d8156cfb385.json | 14 + ...29cbdcbf07809f700e396c4b627e1e889c3d7.json | 15 + ...4bc0516323e93d6169ccfab4bedc4dcec3de3.json | 88 ++ ...af0e18acf31714e4655a87770918d82c17619.json | 16 + ...ced8a4289b589e71a107fa30cc483ccd4e1f7.json | 88 ++ ...3cfb2511c7b83c469cb01e7b92586912fdb44.json | 17 + ...777ad56fbc0399a3cb43a5c6a2154aba6a0d3.json | 22 + ...d1c78ed189fb2bd4ea973b0fdd09748930210.json | 52 + ...657ea7715be5b41d1f281abb90540fb844d55.json | 88 ++ ...412ed6c773c266abbcb153992c5cc71c4bf4b.json | 95 ++ ...c6e597af9fe75edfcb52ee17e32de5c11031c.json | 23 + ...75f71a9eaa84d903c3888031c02d41be92aa4.json | 23 + ...2daaee5ae492e922f3cf9c77c3568e59bbb91.json | 58 + ...1090000_create_project_invitation.down.sql | 1 + ...011090000_create_project_invitation.up.sql | 30 + ...0_casbin_project_invitation_rules.down.sql | 15 + ...100_casbin_project_invitation_rules.up.sql | 33 + src/db/mod.rs | 1 + src/db/project_invitation.rs | 275 ++++ src/db/project_member.rs | 29 + src/models/mod.rs | 2 + src/models/project_invitation.rs | 47 + src/models/project_member.rs | 13 + src/routes/command/cancel.rs | 5 +- src/routes/command/create.rs | 17 +- src/routes/command/get.rs | 5 +- src/routes/command/list.rs | 5 +- src/routes/project/access.rs | 229 +++ src/routes/project/app.rs | 111 +- src/routes/project/compose.rs | 12 +- src/routes/project/delete.rs | 5 +- src/routes/project/deploy.rs | 45 +- src/routes/project/discover.rs | 23 +- src/routes/project/get.rs | 22 +- src/routes/project/invitation.rs | 560 +++++++ src/routes/project/member.rs | 54 +- src/routes/project/mod.rs | 2 + src/routes/project/protection.rs | 5 +- src/routes/project/secret.rs | 5 +- src/routes/project/sync.rs | 12 +- src/routes/project/update.rs | 12 +- src/startup.rs | 8 + tests/common/mod.rs | 43 + tests/project_team_projects.rs | 35 +- tests/project_team_seats.rs | 1283 +++++++++++++++++ tests/security_project_routes.rs | 282 ++++ tests/security_route_matrix.rs | 36 + tests/steps/common.rs | 41 +- 48 files changed, 3788 insertions(+), 114 deletions(-) create mode 100644 .sqlx/query-06362a5b1466c812b5f930e1285ca5b6d5445cfba98d9215649b5d8156cfb385.json create mode 100644 .sqlx/query-21d155be152fc09e77d35f66d8a29cbdcbf07809f700e396c4b627e1e889c3d7.json create mode 100644 .sqlx/query-265bfdc6f6d6be725cd75afd5aa4bc0516323e93d6169ccfab4bedc4dcec3de3.json create mode 100644 .sqlx/query-44ff1877c9f64fe348ea0022c27af0e18acf31714e4655a87770918d82c17619.json create mode 100644 .sqlx/query-4eb30b1f81447575767eae6c5b7ced8a4289b589e71a107fa30cc483ccd4e1f7.json create mode 100644 .sqlx/query-73cf3a994e4d3535c23ea362d3f3cfb2511c7b83c469cb01e7b92586912fdb44.json create mode 100644 .sqlx/query-7f242bf2ee7cf0a592afed4c430777ad56fbc0399a3cb43a5c6a2154aba6a0d3.json create mode 100644 .sqlx/query-8560ac9eba30604c9d34ce4a3c4d1c78ed189fb2bd4ea973b0fdd09748930210.json create mode 100644 .sqlx/query-98f4146c807f8deb32963b26df7657ea7715be5b41d1f281abb90540fb844d55.json create mode 100644 .sqlx/query-c4673a383e0057256c548d124e3412ed6c773c266abbcb153992c5cc71c4bf4b.json create mode 100644 .sqlx/query-ef46dc613ee7906371d1de1b102c6e597af9fe75edfcb52ee17e32de5c11031c.json create mode 100644 .sqlx/query-f7faaaad787d26f7cdc4da8904e75f71a9eaa84d903c3888031c02d41be92aa4.json create mode 100644 .sqlx/query-fad6408439b068a05a1cdafecd42daaee5ae492e922f3cf9c77c3568e59bbb91.json create mode 100644 migrations/20261011090000_create_project_invitation.down.sql create mode 100644 migrations/20261011090000_create_project_invitation.up.sql create mode 100644 migrations/20261011090100_casbin_project_invitation_rules.down.sql create mode 100644 migrations/20261011090100_casbin_project_invitation_rules.up.sql create mode 100644 src/db/project_invitation.rs create mode 100644 src/models/project_invitation.rs create mode 100644 src/routes/project/access.rs create mode 100644 src/routes/project/invitation.rs create mode 100644 tests/project_team_seats.rs diff --git a/.sqlx/query-06362a5b1466c812b5f930e1285ca5b6d5445cfba98d9215649b5d8156cfb385.json b/.sqlx/query-06362a5b1466c812b5f930e1285ca5b6d5445cfba98d9215649b5d8156cfb385.json new file mode 100644 index 00000000..d8a2a2ef --- /dev/null +++ b/.sqlx/query-06362a5b1466c812b5f930e1285ca5b6d5445cfba98d9215649b5d8156cfb385.json @@ -0,0 +1,14 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE project_invitation SET status = 'expired'\n WHERE project_id = $1 AND status = 'pending' AND expires_at <= NOW()\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [] + }, + "hash": "06362a5b1466c812b5f930e1285ca5b6d5445cfba98d9215649b5d8156cfb385" +} diff --git a/.sqlx/query-21d155be152fc09e77d35f66d8a29cbdcbf07809f700e396c4b627e1e889c3d7.json b/.sqlx/query-21d155be152fc09e77d35f66d8a29cbdcbf07809f700e396c4b627e1e889c3d7.json new file mode 100644 index 00000000..b8bee8e1 --- /dev/null +++ b/.sqlx/query-21d155be152fc09e77d35f66d8a29cbdcbf07809f700e396c4b627e1e889c3d7.json @@ -0,0 +1,15 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE project_invitation SET status = 'revoked'\n WHERE id = $1 AND project_id = $2 AND status = 'pending'\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Int4" + ] + }, + "nullable": [] + }, + "hash": "21d155be152fc09e77d35f66d8a29cbdcbf07809f700e396c4b627e1e889c3d7" +} diff --git a/.sqlx/query-265bfdc6f6d6be725cd75afd5aa4bc0516323e93d6169ccfab4bedc4dcec3de3.json b/.sqlx/query-265bfdc6f6d6be725cd75afd5aa4bc0516323e93d6169ccfab4bedc4dcec3de3.json new file mode 100644 index 00000000..3027e780 --- /dev/null +++ b/.sqlx/query-265bfdc6f6d6be725cd75afd5aa4bc0516323e93d6169ccfab4bedc4dcec3de3.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, project_id, email, role, token_hash, invited_by, invited_by_email,\n status, expires_at, accepted_by, accepted_at, created_at\n FROM project_invitation\n WHERE project_id = $1 AND status = 'pending' AND expires_at > NOW()\n ORDER BY created_at ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "token_hash", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "invited_by", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "invited_by_email", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "accepted_by", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "accepted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "265bfdc6f6d6be725cd75afd5aa4bc0516323e93d6169ccfab4bedc4dcec3de3" +} diff --git a/.sqlx/query-44ff1877c9f64fe348ea0022c27af0e18acf31714e4655a87770918d82c17619.json b/.sqlx/query-44ff1877c9f64fe348ea0022c27af0e18acf31714e4655a87770918d82c17619.json new file mode 100644 index 00000000..8d8e9718 --- /dev/null +++ b/.sqlx/query-44ff1877c9f64fe348ea0022c27af0e18acf31714e4655a87770918d82c17619.json @@ -0,0 +1,16 @@ +{ + "db_name": "PostgreSQL", + "query": "\n UPDATE project_invitation\n SET status = $2::VARCHAR,\n accepted_by = $3,\n accepted_at = CASE WHEN $2::VARCHAR = 'accepted' THEN NOW() ELSE accepted_at END\n WHERE id = $1\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Uuid", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "44ff1877c9f64fe348ea0022c27af0e18acf31714e4655a87770918d82c17619" +} diff --git a/.sqlx/query-4eb30b1f81447575767eae6c5b7ced8a4289b589e71a107fa30cc483ccd4e1f7.json b/.sqlx/query-4eb30b1f81447575767eae6c5b7ced8a4289b589e71a107fa30cc483ccd4e1f7.json new file mode 100644 index 00000000..f6e67737 --- /dev/null +++ b/.sqlx/query-4eb30b1f81447575767eae6c5b7ced8a4289b589e71a107fa30cc483ccd4e1f7.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, project_id, email, role, token_hash, invited_by, invited_by_email,\n status, expires_at, accepted_by, accepted_at, created_at\n FROM project_invitation\n WHERE id = $1\n FOR UPDATE\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "token_hash", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "invited_by", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "invited_by_email", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "accepted_by", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "accepted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "4eb30b1f81447575767eae6c5b7ced8a4289b589e71a107fa30cc483ccd4e1f7" +} diff --git a/.sqlx/query-73cf3a994e4d3535c23ea362d3f3cfb2511c7b83c469cb01e7b92586912fdb44.json b/.sqlx/query-73cf3a994e4d3535c23ea362d3f3cfb2511c7b83c469cb01e7b92586912fdb44.json new file mode 100644 index 00000000..25824f8b --- /dev/null +++ b/.sqlx/query-73cf3a994e4d3535c23ea362d3f3cfb2511c7b83c469cb01e7b92586912fdb44.json @@ -0,0 +1,17 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO project_member (project_id, user_id, role, created_by, created_at, updated_at)\n VALUES ($1, $2, $3, $4, NOW() at time zone 'utc', NOW() at time zone 'utc')\n ON CONFLICT (project_id, user_id) DO NOTHING\n ", + "describe": { + "columns": [], + "parameters": { + "Left": [ + "Int4", + "Text", + "Varchar", + "Text" + ] + }, + "nullable": [] + }, + "hash": "73cf3a994e4d3535c23ea362d3f3cfb2511c7b83c469cb01e7b92586912fdb44" +} diff --git a/.sqlx/query-7f242bf2ee7cf0a592afed4c430777ad56fbc0399a3cb43a5c6a2154aba6a0d3.json b/.sqlx/query-7f242bf2ee7cf0a592afed4c430777ad56fbc0399a3cb43a5c6a2154aba6a0d3.json new file mode 100644 index 00000000..27339dca --- /dev/null +++ b/.sqlx/query-7f242bf2ee7cf0a592afed4c430777ad56fbc0399a3cb43a5c6a2154aba6a0d3.json @@ -0,0 +1,22 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT (\n 1\n + (SELECT COUNT(*) FROM project_member WHERE project_id = $1)\n + (SELECT COUNT(*) FROM project_invitation\n WHERE project_id = $1 AND status = 'pending' AND expires_at > NOW())\n )::BIGINT AS \"taken!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "taken!", + "type_info": "Int8" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + null + ] + }, + "hash": "7f242bf2ee7cf0a592afed4c430777ad56fbc0399a3cb43a5c6a2154aba6a0d3" +} diff --git a/.sqlx/query-8560ac9eba30604c9d34ce4a3c4d1c78ed189fb2bd4ea973b0fdd09748930210.json b/.sqlx/query-8560ac9eba30604c9d34ce4a3c4d1c78ed189fb2bd4ea973b0fdd09748930210.json new file mode 100644 index 00000000..f3bc55f1 --- /dev/null +++ b/.sqlx/query-8560ac9eba30604c9d34ce4a3c4d1c78ed189fb2bd4ea973b0fdd09748930210.json @@ -0,0 +1,52 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT i.id, i.project_id, p.name AS project_name, i.role,\n i.invited_by_email AS invited_by, i.expires_at\n FROM project_invitation i\n JOIN project p ON p.id = i.project_id\n WHERE i.email = $1 AND i.status = 'pending' AND i.expires_at > NOW()\n ORDER BY i.created_at DESC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "project_name", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "invited_by", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "expires_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false + ] + }, + "hash": "8560ac9eba30604c9d34ce4a3c4d1c78ed189fb2bd4ea973b0fdd09748930210" +} diff --git a/.sqlx/query-98f4146c807f8deb32963b26df7657ea7715be5b41d1f281abb90540fb844d55.json b/.sqlx/query-98f4146c807f8deb32963b26df7657ea7715be5b41d1f281abb90540fb844d55.json new file mode 100644 index 00000000..21d84555 --- /dev/null +++ b/.sqlx/query-98f4146c807f8deb32963b26df7657ea7715be5b41d1f281abb90540fb844d55.json @@ -0,0 +1,88 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT id, project_id, email, role, token_hash, invited_by, invited_by_email,\n status, expires_at, accepted_by, accepted_at, created_at\n FROM project_invitation\n WHERE token_hash = $1\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "token_hash", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "invited_by", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "invited_by_email", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "accepted_by", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "accepted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Text" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "98f4146c807f8deb32963b26df7657ea7715be5b41d1f281abb90540fb844d55" +} diff --git a/.sqlx/query-c4673a383e0057256c548d124e3412ed6c773c266abbcb153992c5cc71c4bf4b.json b/.sqlx/query-c4673a383e0057256c548d124e3412ed6c773c266abbcb153992c5cc71c4bf4b.json new file mode 100644 index 00000000..833829ff --- /dev/null +++ b/.sqlx/query-c4673a383e0057256c548d124e3412ed6c773c266abbcb153992c5cc71c4bf4b.json @@ -0,0 +1,95 @@ +{ + "db_name": "PostgreSQL", + "query": "\n INSERT INTO project_invitation\n (id, project_id, email, role, token_hash, invited_by, invited_by_email,\n status, expires_at, created_at)\n VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending',\n NOW() + make_interval(days => $8), NOW())\n RETURNING id, project_id, email, role, token_hash, invited_by, invited_by_email,\n status, expires_at, accepted_by, accepted_at, created_at\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "id", + "type_info": "Uuid" + }, + { + "ordinal": 1, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 2, + "name": "email", + "type_info": "Text" + }, + { + "ordinal": 3, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 4, + "name": "token_hash", + "type_info": "Text" + }, + { + "ordinal": 5, + "name": "invited_by", + "type_info": "Text" + }, + { + "ordinal": 6, + "name": "invited_by_email", + "type_info": "Text" + }, + { + "ordinal": 7, + "name": "status", + "type_info": "Varchar" + }, + { + "ordinal": 8, + "name": "expires_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 9, + "name": "accepted_by", + "type_info": "Text" + }, + { + "ordinal": 10, + "name": "accepted_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 11, + "name": "created_at", + "type_info": "Timestamptz" + } + ], + "parameters": { + "Left": [ + "Uuid", + "Int4", + "Text", + "Varchar", + "Text", + "Text", + "Text", + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + false, + false, + false, + true, + true, + false + ] + }, + "hash": "c4673a383e0057256c548d124e3412ed6c773c266abbcb153992c5cc71c4bf4b" +} diff --git a/.sqlx/query-ef46dc613ee7906371d1de1b102c6e597af9fe75edfcb52ee17e32de5c11031c.json b/.sqlx/query-ef46dc613ee7906371d1de1b102c6e597af9fe75edfcb52ee17e32de5c11031c.json new file mode 100644 index 00000000..a27a79ff --- /dev/null +++ b/.sqlx/query-ef46dc613ee7906371d1de1b102c6e597af9fe75edfcb52ee17e32de5c11031c.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT EXISTS (\n SELECT 1 FROM project_invitation\n WHERE project_id = $1 AND email = $2\n AND status = 'pending' AND expires_at > NOW()\n ) AS \"exists!\"\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "exists!", + "type_info": "Bool" + } + ], + "parameters": { + "Left": [ + "Int4", + "Text" + ] + }, + "nullable": [ + null + ] + }, + "hash": "ef46dc613ee7906371d1de1b102c6e597af9fe75edfcb52ee17e32de5c11031c" +} diff --git a/.sqlx/query-f7faaaad787d26f7cdc4da8904e75f71a9eaa84d903c3888031c02d41be92aa4.json b/.sqlx/query-f7faaaad787d26f7cdc4da8904e75f71a9eaa84d903c3888031c02d41be92aa4.json new file mode 100644 index 00000000..5353b0b4 --- /dev/null +++ b/.sqlx/query-f7faaaad787d26f7cdc4da8904e75f71a9eaa84d903c3888031c02d41be92aa4.json @@ -0,0 +1,23 @@ +{ + "db_name": "PostgreSQL", + "query": "SELECT pg_advisory_xact_lock($1, $2)", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "pg_advisory_xact_lock", + "type_info": "Void" + } + ], + "parameters": { + "Left": [ + "Int4", + "Int4" + ] + }, + "nullable": [ + null + ] + }, + "hash": "f7faaaad787d26f7cdc4da8904e75f71a9eaa84d903c3888031c02d41be92aa4" +} diff --git a/.sqlx/query-fad6408439b068a05a1cdafecd42daaee5ae492e922f3cf9c77c3568e59bbb91.json b/.sqlx/query-fad6408439b068a05a1cdafecd42daaee5ae492e922f3cf9c77c3568e59bbb91.json new file mode 100644 index 00000000..7ca22743 --- /dev/null +++ b/.sqlx/query-fad6408439b068a05a1cdafecd42daaee5ae492e922f3cf9c77c3568e59bbb91.json @@ -0,0 +1,58 @@ +{ + "db_name": "PostgreSQL", + "query": "\n SELECT m.project_id, m.user_id, m.role, m.created_by, m.created_at, m.updated_at,\n (SELECT i.email FROM project_invitation i\n WHERE i.project_id = m.project_id AND i.accepted_by = m.user_id\n AND i.status = 'accepted'\n ORDER BY i.accepted_at DESC\n LIMIT 1) AS email\n FROM project_member m\n WHERE m.project_id = $1\n ORDER BY m.created_at ASC, m.user_id ASC\n ", + "describe": { + "columns": [ + { + "ordinal": 0, + "name": "project_id", + "type_info": "Int4" + }, + { + "ordinal": 1, + "name": "user_id", + "type_info": "Text" + }, + { + "ordinal": 2, + "name": "role", + "type_info": "Varchar" + }, + { + "ordinal": 3, + "name": "created_by", + "type_info": "Text" + }, + { + "ordinal": 4, + "name": "created_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 5, + "name": "updated_at", + "type_info": "Timestamptz" + }, + { + "ordinal": 6, + "name": "email", + "type_info": "Text" + } + ], + "parameters": { + "Left": [ + "Int4" + ] + }, + "nullable": [ + false, + false, + false, + false, + false, + false, + null + ] + }, + "hash": "fad6408439b068a05a1cdafecd42daaee5ae492e922f3cf9c77c3568e59bbb91" +} diff --git a/migrations/20261011090000_create_project_invitation.down.sql b/migrations/20261011090000_create_project_invitation.down.sql new file mode 100644 index 00000000..94e93308 --- /dev/null +++ b/migrations/20261011090000_create_project_invitation.down.sql @@ -0,0 +1 @@ +DROP TABLE IF EXISTS project_invitation; diff --git a/migrations/20261011090000_create_project_invitation.up.sql b/migrations/20261011090000_create_project_invitation.up.sql new file mode 100644 index 00000000..db00f34f --- /dev/null +++ b/migrations/20261011090000_create_project_invitation.up.sql @@ -0,0 +1,30 @@ +-- Invitations to join a project as a member or viewer (Team seats). +-- +-- A pending invitation holds a seat until it is accepted, revoked or expires. +-- Only the hash of the invitation token is stored; the token itself goes to +-- the invited address in the email link. +CREATE TABLE IF NOT EXISTS project_invitation ( + id UUID PRIMARY KEY, + project_id INTEGER NOT NULL REFERENCES project(id) ON DELETE CASCADE, + email TEXT NOT NULL, + role VARCHAR(32) NOT NULL, + token_hash TEXT NOT NULL UNIQUE, + invited_by TEXT NOT NULL, + invited_by_email TEXT NOT NULL, + status VARCHAR(16) NOT NULL DEFAULT 'pending', + expires_at TIMESTAMPTZ NOT NULL, + accepted_by TEXT NULL, + accepted_at TIMESTAMPTZ NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT project_invitation_email_lower CHECK (email = lower(email)), + CONSTRAINT project_invitation_status CHECK (status IN ('pending', 'accepted', 'revoked', 'expired')) +); + +-- One live invitation per address per project. +CREATE UNIQUE INDEX IF NOT EXISTS idx_project_invitation_pending_email + ON project_invitation (project_id, email) + WHERE status = 'pending'; + +CREATE INDEX IF NOT EXISTS idx_project_invitation_email_pending + ON project_invitation (email) + WHERE status = 'pending'; diff --git a/migrations/20261011090100_casbin_project_invitation_rules.down.sql b/migrations/20261011090100_casbin_project_invitation_rules.down.sql new file mode 100644 index 00000000..4fd175c2 --- /dev/null +++ b/migrations/20261011090100_casbin_project_invitation_rules.down.sql @@ -0,0 +1,15 @@ +DELETE FROM public.casbin_rule WHERE ptype = 'p' AND v1 IN ( + '/project/:id/invitations', + '/project/:id/invitations/:invitation_id', + '/project/:id/seats', + '/project/invitations/pending', + '/project/invitations/:key/accept', + '/project/invitations/:token', + '/api/v1/project/:id/invitations', + '/api/v1/project/:id/invitations/:invitation_id', + '/api/v1/project/:id/seats', + '/api/v1/project/invitations/pending', + '/api/v1/project/invitations/:key/accept', + '/api/v1/project/invitations/:token'); +-- The POST .../members rules stay: /project/:id/members POST predates this +-- migration, and removing it would break adding members by id. diff --git a/migrations/20261011090100_casbin_project_invitation_rules.up.sql b/migrations/20261011090100_casbin_project_invitation_rules.up.sql new file mode 100644 index 00000000..44559340 --- /dev/null +++ b/migrations/20261011090100_casbin_project_invitation_rules.up.sql @@ -0,0 +1,33 @@ +-- Team seats: invitations, seat counts and adding members. +-- The invitation lookup by token is public so the invitation page works +-- before the invitee has an account; it reveals only a masked address. +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id/members', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id/invitations', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id/invitations', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id/invitations/:invitation_id', 'DELETE', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/:id/seats', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/invitations/pending', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/project/invitations/:key/accept', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_anonymous', '/project/invitations/:token', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/project/:id/members', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/project/:id/invitations', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/project/:id/invitations', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/project/:id/invitations/:invitation_id', 'DELETE', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/project/:id/seats', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/project/invitations/pending', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/project/invitations/:key/accept', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/project/:id/members', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/project/:id/invitations', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/project/:id/invitations', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/project/:id/invitations/:invitation_id', 'DELETE', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/project/:id/seats', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/project/invitations/pending', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_user', '/api/v1/project/invitations/:key/accept', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_anonymous', '/api/v1/project/invitations/:token', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/project/:id/members', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/project/:id/invitations', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/project/:id/invitations', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/project/:id/invitations/:invitation_id', 'DELETE', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/project/:id/seats', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/project/invitations/pending', 'GET', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; +INSERT INTO public.casbin_rule (ptype, v0, v1, v2, v3, v4, v5) VALUES ('p', 'group_admin', '/api/v1/project/invitations/:key/accept', 'POST', '', '', '') ON CONFLICT ON CONSTRAINT unique_key_sqlx_adapter DO NOTHING; diff --git a/src/db/mod.rs b/src/db/mod.rs index 480ec8c4..66d6488a 100644 --- a/src/db/mod.rs +++ b/src/db/mod.rs @@ -16,6 +16,7 @@ pub mod pipe; pub mod product; pub mod project; pub mod project_app; +pub mod project_invitation; pub mod project_member; pub mod rating; pub mod remote_secret; diff --git a/src/db/project_invitation.rs b/src/db/project_invitation.rs new file mode 100644 index 00000000..39fa8b58 --- /dev/null +++ b/src/db/project_invitation.rs @@ -0,0 +1,275 @@ +use crate::models; +use sqlx::{PgExecutor, PgPool}; +use uuid::Uuid; + +/// Advisory lock class for "seats of one project". Taken for the length of a +/// transaction so two invitations cannot both take the last free seat. +const SEAT_LOCK_CLASS: i32 = 0x5345_4154; // "SEAT" + +fn db_error(context: &str) -> impl Fn(sqlx::Error) -> String + '_ { + move |err| { + tracing::error!("{}: {:?}", context, err); + context.to_string() + } +} + +pub async fn lock_project_seats<'e, E: PgExecutor<'e>>( + executor: E, + project_id: i32, +) -> Result<(), String> { + sqlx::query!( + "SELECT pg_advisory_xact_lock($1, $2)", + SEAT_LOCK_CLASS, + project_id + ) + .fetch_one(executor) + .await + .map(|_| ()) + .map_err(db_error("Failed to lock project seats")) +} + +/// Mark pending invitations past their expiry as expired, so they stop +/// holding a seat and the address can be invited again. +pub async fn expire_stale<'e, E: PgExecutor<'e>>( + executor: E, + project_id: i32, +) -> Result<(), String> { + sqlx::query!( + r#" + UPDATE project_invitation SET status = 'expired' + WHERE project_id = $1 AND status = 'pending' AND expires_at <= NOW() + "#, + project_id + ) + .execute(executor) + .await + .map(|_| ()) + .map_err(db_error("Failed to expire stale invitations")) +} + +/// Seats in use: the owner, every member, and every live pending invitation. +pub async fn seats_taken<'e, E: PgExecutor<'e>>( + executor: E, + project_id: i32, +) -> Result { + sqlx::query_scalar!( + r#" + SELECT ( + 1 + + (SELECT COUNT(*) FROM project_member WHERE project_id = $1) + + (SELECT COUNT(*) FROM project_invitation + WHERE project_id = $1 AND status = 'pending' AND expires_at > NOW()) + )::BIGINT AS "taken!" + "#, + project_id + ) + .fetch_one(executor) + .await + .map_err(db_error("Failed to count project seats")) +} + +pub async fn live_pending_exists<'e, E: PgExecutor<'e>>( + executor: E, + project_id: i32, + email: &str, +) -> Result { + sqlx::query_scalar!( + r#" + SELECT EXISTS ( + SELECT 1 FROM project_invitation + WHERE project_id = $1 AND email = $2 + AND status = 'pending' AND expires_at > NOW() + ) AS "exists!" + "#, + project_id, + email + ) + .fetch_one(executor) + .await + .map_err(db_error("Failed to check pending invitations")) +} + +pub struct NewInvitation<'a> { + pub project_id: i32, + pub email: &'a str, + pub role: &'a str, + pub token_hash: &'a str, + pub invited_by: &'a str, + pub invited_by_email: &'a str, + pub valid_for_days: i32, +} + +pub async fn insert<'e, E: PgExecutor<'e>>( + executor: E, + new: NewInvitation<'_>, +) -> Result { + sqlx::query_as!( + models::ProjectInvitation, + r#" + INSERT INTO project_invitation + (id, project_id, email, role, token_hash, invited_by, invited_by_email, + status, expires_at, created_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, 'pending', + NOW() + make_interval(days => $8), NOW()) + RETURNING id, project_id, email, role, token_hash, invited_by, invited_by_email, + status, expires_at, accepted_by, accepted_at, created_at + "#, + Uuid::new_v4(), + new.project_id, + new.email, + new.role, + new.token_hash, + new.invited_by, + new.invited_by_email, + new.valid_for_days + ) + .fetch_one(executor) + .await + .map_err(db_error("Failed to save invitation")) +} + +pub async fn list_live_pending( + pool: &PgPool, + project_id: i32, +) -> Result, String> { + sqlx::query_as!( + models::ProjectInvitation, + r#" + SELECT id, project_id, email, role, token_hash, invited_by, invited_by_email, + status, expires_at, accepted_by, accepted_at, created_at + FROM project_invitation + WHERE project_id = $1 AND status = 'pending' AND expires_at > NOW() + ORDER BY created_at ASC + "#, + project_id + ) + .fetch_all(pool) + .await + .map_err(db_error("Failed to list invitations")) +} + +pub async fn fetch_by_token_hash( + pool: &PgPool, + token_hash: &str, +) -> Result, String> { + sqlx::query_as!( + models::ProjectInvitation, + r#" + SELECT id, project_id, email, role, token_hash, invited_by, invited_by_email, + status, expires_at, accepted_by, accepted_at, created_at + FROM project_invitation + WHERE token_hash = $1 + "#, + token_hash + ) + .fetch_optional(pool) + .await + .map_err(db_error("Failed to fetch invitation")) +} + +/// Fetch and lock one invitation for the rest of the transaction. +pub async fn lock_by_id<'e, E: PgExecutor<'e>>( + executor: E, + id: Uuid, +) -> Result, String> { + sqlx::query_as!( + models::ProjectInvitation, + r#" + SELECT id, project_id, email, role, token_hash, invited_by, invited_by_email, + status, expires_at, accepted_by, accepted_at, created_at + FROM project_invitation + WHERE id = $1 + FOR UPDATE + "#, + id + ) + .fetch_optional(executor) + .await + .map_err(db_error("Failed to fetch invitation")) +} + +/// Revoke a pending invitation of this project. False when there is none. +pub async fn revoke(pool: &PgPool, project_id: i32, id: Uuid) -> Result { + sqlx::query!( + r#" + UPDATE project_invitation SET status = 'revoked' + WHERE id = $1 AND project_id = $2 AND status = 'pending' + "#, + id, + project_id + ) + .execute(pool) + .await + .map(|result| result.rows_affected() > 0) + .map_err(db_error("Failed to revoke invitation")) +} + +pub async fn set_status<'e, E: PgExecutor<'e>>( + executor: E, + id: Uuid, + status: &str, + accepted_by: Option<&str>, +) -> Result<(), String> { + sqlx::query!( + r#" + UPDATE project_invitation + SET status = $2::VARCHAR, + accepted_by = $3, + accepted_at = CASE WHEN $2::VARCHAR = 'accepted' THEN NOW() ELSE accepted_at END + WHERE id = $1 + "#, + id, + status, + accepted_by + ) + .execute(executor) + .await + .map(|_| ()) + .map_err(db_error("Failed to update invitation")) +} + +pub async fn pending_for_email( + pool: &PgPool, + email: &str, +) -> Result, String> { + sqlx::query_as!( + models::PendingProjectInvitation, + r#" + SELECT i.id, i.project_id, p.name AS project_name, i.role, + i.invited_by_email AS invited_by, i.expires_at + FROM project_invitation i + JOIN project p ON p.id = i.project_id + WHERE i.email = $1 AND i.status = 'pending' AND i.expires_at > NOW() + ORDER BY i.created_at DESC + "#, + email + ) + .fetch_all(pool) + .await + .map_err(db_error("Failed to list pending invitations")) +} + +/// Add a member unless they already are one. True when a row was added. +pub async fn insert_member<'e, E: PgExecutor<'e>>( + executor: E, + project_id: i32, + user_id: &str, + role: &str, + created_by: &str, +) -> Result { + sqlx::query!( + r#" + INSERT INTO project_member (project_id, user_id, role, created_by, created_at, updated_at) + VALUES ($1, $2, $3, $4, NOW() at time zone 'utc', NOW() at time zone 'utc') + ON CONFLICT (project_id, user_id) DO NOTHING + "#, + project_id, + user_id, + role, + created_by + ) + .execute(executor) + .await + .map(|result| result.rows_affected() > 0) + .map_err(db_error("Failed to add project member")) +} diff --git a/src/db/project_member.rs b/src/db/project_member.rs index 54ae8c0b..330d69cc 100644 --- a/src/db/project_member.rs +++ b/src/db/project_member.rs @@ -102,3 +102,32 @@ pub async fn delete(pool: &PgPool, project_id: i32, user_id: &str) -> Result Result, String> { + let query_span = tracing::info_span!("Fetch project members with email", project_id); + sqlx::query_as!( + models::ProjectMemberWithEmail, + r#" + SELECT m.project_id, m.user_id, m.role, m.created_by, m.created_at, m.updated_at, + (SELECT i.email FROM project_invitation i + WHERE i.project_id = m.project_id AND i.accepted_by = m.user_id + AND i.status = 'accepted' + ORDER BY i.accepted_at DESC + LIMIT 1) AS email + FROM project_member m + WHERE m.project_id = $1 + ORDER BY m.created_at ASC, m.user_id ASC + "#, + project_id + ) + .fetch_all(pool) + .instrument(query_span) + .await + .map_err(|err| { + tracing::error!("Failed to fetch project members: {:?}", err); + "Failed to fetch project members".to_string() + }) +} diff --git a/src/models/mod.rs b/src/models/mod.rs index 8b7d06d6..b99d3855 100644 --- a/src/models/mod.rs +++ b/src/models/mod.rs @@ -17,6 +17,7 @@ pub mod pipe_config; mod product; pub mod project; pub mod project_app; +mod project_invitation; mod project_member; mod ratecategory; pub mod rating; @@ -44,6 +45,7 @@ pub use pipe_config::*; pub use product::*; pub use project::*; pub use project_app::*; +pub use project_invitation::*; pub use project_member::*; pub use ratecategory::*; pub use rating::*; diff --git a/src/models/project_invitation.rs b/src/models/project_invitation.rs new file mode 100644 index 00000000..b823ccf5 --- /dev/null +++ b/src/models/project_invitation.rs @@ -0,0 +1,47 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +/// An invitation to join a project (Team seats). +/// +/// `token_hash` never leaves the server: the token itself is only in the +/// invitation link sent to the invited address. +#[derive(Debug, Clone, PartialEq, Serialize, Deserialize, sqlx::FromRow)] +pub struct ProjectInvitation { + pub id: Uuid, + pub project_id: i32, + pub email: String, + pub role: String, + #[serde(skip)] + pub token_hash: String, + pub invited_by: String, + pub invited_by_email: String, + pub status: String, + pub expires_at: DateTime, + pub accepted_by: Option, + pub accepted_at: Option>, + pub created_at: DateTime, +} + +impl ProjectInvitation { + /// The status a caller should see: a pending invitation past its expiry + /// reads as expired even before anything rewrites the row. + pub fn effective_status(&self) -> &str { + if self.status == "pending" && self.expires_at <= Utc::now() { + "expired" + } else { + &self.status + } + } +} + +/// A pending invitation as the invited person sees it. +#[derive(Debug, Clone, Serialize, sqlx::FromRow)] +pub struct PendingProjectInvitation { + pub id: Uuid, + pub project_id: i32, + pub project_name: String, + pub role: String, + pub invited_by: String, + pub expires_at: DateTime, +} diff --git a/src/models/project_member.rs b/src/models/project_member.rs index ea747244..e3f03a12 100644 --- a/src/models/project_member.rs +++ b/src/models/project_member.rs @@ -10,3 +10,16 @@ pub struct ProjectMember { pub created_at: DateTime, pub updated_at: DateTime, } + +/// A member as the owner sees them: the address comes from the invitation +/// they accepted; members added by user id before invitations have none. +#[derive(Debug, Clone, PartialEq, Serialize, sqlx::FromRow)] +pub struct ProjectMemberWithEmail { + pub project_id: i32, + pub user_id: String, + pub role: String, + pub created_by: String, + pub created_at: DateTime, + pub updated_at: DateTime, + pub email: Option, +} diff --git a/src/routes/command/cancel.rs b/src/routes/command/cancel.rs index 78447f9c..9ad662c5 100644 --- a/src/routes/command/cancel.rs +++ b/src/routes/command/cancel.rs @@ -2,7 +2,7 @@ use crate::configuration::Settings; use crate::db; use crate::helpers::JsonResponse; use crate::models::User; -use crate::routes::legacy_installations::resolve_owned_deployment_by_hash; +use crate::routes::project::access::{self, Need}; use actix_web::{post, web, Responder, Result}; use sqlx::PgPool; use std::sync::Arc; @@ -17,11 +17,12 @@ pub async fn cancel_handler( ) -> Result { let (deployment_hash, command_id) = path.into_inner(); - resolve_owned_deployment_by_hash( + access::resolve_team_deployment( pg_pool.get_ref(), settings.get_ref(), user.as_ref(), &deployment_hash, + Need::Manage, ) .await?; diff --git a/src/routes/command/create.rs b/src/routes/command/create.rs index 71f52e26..507b780f 100644 --- a/src/routes/command/create.rs +++ b/src/routes/command/create.rs @@ -9,7 +9,7 @@ use crate::project_app::{ store_configs_to_vault_from_params, store_registry_auth_command_to_vault, upsert_app_config_for_deploy, REGISTRY_AUTH_VAULT_KEY, }; -use crate::routes::legacy_installations::resolve_owned_deployment_by_hash; +use crate::routes::project::access::{self, Need}; use crate::services::env_model::reconcile_env_file_content; use crate::services::{AppConfig, ConfigRenderer, ProjectAppService, VaultService}; use actix_web::{post, web, Responder, Result}; @@ -63,11 +63,12 @@ pub async fn create_handler( } // A queued command runs on the deployment's server: only its owner may queue one. - resolve_owned_deployment_by_hash( + access::resolve_team_deployment( pg_pool.get_ref(), settings.get_ref(), user.as_ref(), &req.deployment_hash, + Need::Manage, ) .await?; @@ -99,9 +100,17 @@ pub async fn create_handler( .and_then(|v| v.as_i64()) .map(|v| v as i32) { - // The app config is written into this project: it must be the caller's. + // The app config is written into this project: the caller must + // own it or be a member allowed to change it. Some(id) => match db::project::fetch(pg_pool.get_ref(), id).await { - Ok(Some(project)) if project.user_id == user.id => Some(id), + Ok(Some(project)) + if matches!( + access::access_of(pg_pool.get_ref(), &project, &user.id).await, + Ok(Some(found)) if found.allows(Need::Manage) + ) => + { + Some(id) + } Ok(_) => { return Err(JsonResponse::<()>::build().not_found("Project not found")); } diff --git a/src/routes/command/get.rs b/src/routes/command/get.rs index 041b7743..ecfbdb07 100644 --- a/src/routes/command/get.rs +++ b/src/routes/command/get.rs @@ -2,7 +2,7 @@ use crate::configuration::Settings; use crate::db; use crate::helpers::JsonResponse; use crate::models::User; -use crate::routes::legacy_installations::resolve_owned_deployment_by_hash; +use crate::routes::project::access::{self, Need}; use actix_web::{get, web, Responder, Result}; use sqlx::PgPool; use std::sync::Arc; @@ -17,11 +17,12 @@ pub async fn get_handler( ) -> Result { let (deployment_hash, command_id) = path.into_inner(); - resolve_owned_deployment_by_hash( + access::resolve_team_deployment( pg_pool.get_ref(), settings.get_ref(), user.as_ref(), &deployment_hash, + Need::View, ) .await?; diff --git a/src/routes/command/list.rs b/src/routes/command/list.rs index 08c0f9e9..3133b68c 100644 --- a/src/routes/command/list.rs +++ b/src/routes/command/list.rs @@ -2,7 +2,7 @@ use crate::configuration::Settings; use crate::db; use crate::helpers::JsonResponse; use crate::models::User; -use crate::routes::legacy_installations::resolve_owned_deployment_by_hash; +use crate::routes::project::access::{self, Need}; use actix_web::{get, web, Responder, Result}; use chrono::{DateTime, Utc}; use serde::Deserialize; @@ -31,11 +31,12 @@ pub async fn list_handler( let deployment_hash = path.into_inner(); let limit = query.limit.unwrap_or(50).max(1).min(500); - resolve_owned_deployment_by_hash( + access::resolve_team_deployment( pg_pool.get_ref(), settings.get_ref(), user.as_ref(), &deployment_hash, + Need::View, ) .await?; diff --git a/src/routes/project/access.rs b/src/routes/project/access.rs new file mode 100644 index 00000000..fda29557 --- /dev/null +++ b/src/routes/project/access.rs @@ -0,0 +1,229 @@ +//! Who may do what on a project. +//! +//! A project has one owner and any number of members (`project_member`). A +//! member with role "member" works on the stack; a "viewer" only looks. +//! Someone with no access gets the same 404 as for a missing project, so a +//! project id reveals nothing. Someone with access who asks for more than +//! their role allows gets 403. + +use crate::db; +use crate::helpers::JsonResponse; +use crate::models; +use sqlx::PgPool; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Access { + Owner, + Member, + Viewer, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Need { + /// Read the stack: payload, compose, apps, env. Owner and member. A + /// viewer sees only the shared list and deployments (deployment routes + /// check membership themselves). + View, + /// Change the stack and deploy it. + Manage, + /// Secrets, deletion, protection, members and invitations. + Owner, +} + +impl Access { + pub fn allows(self, need: Need) -> bool { + match (self, need) { + (Access::Owner, _) => true, + (Access::Member, Need::View | Need::Manage) => true, + _ => false, + } + } +} + +/// The caller's access to a project, or None when they have none. +pub async fn access_of( + pool: &PgPool, + project: &models::Project, + user_id: &str, +) -> Result, String> { + if project.user_id == user_id { + return Ok(Some(Access::Owner)); + } + let member = db::project_member::fetch(pool, project.id, user_id).await?; + Ok(member.and_then(|m| match m.role.as_str() { + "member" => Some(Access::Member), + "viewer" => Some(Access::Viewer), + _ => None, + })) +} + +/// Load a project the caller may act on as `need` requires. +pub async fn require( + pool: &PgPool, + project_id: i32, + user_id: &str, + need: Need, +) -> Result<(models::Project, Access), actix_web::Error> { + let project = db::project::fetch(pool, project_id) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("not found"))?; + + let access = access_of(pool, &project, user_id) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("not found"))?; + + if !access.allows(need) { + return Err(JsonResponse::forbidden("forbidden")); + } + Ok((project, access)) +} + +/// Check access to a project the handler has already loaded. +/// +/// `not_found` is the message the handler answers for a missing project, so +/// someone with no access gets exactly the same response as for a project +/// that does not exist. +pub async fn check( + pool: &PgPool, + project: &models::Project, + user_id: &str, + need: Need, + not_found: &str, +) -> Result { + let access = access_of(pool, project, user_id) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found(not_found))?; + if !access.allows(need) { + return Err(JsonResponse::forbidden("forbidden")); + } + Ok(access) +} + +/// Resolve a deployment the caller may act on as `need` requires. +/// +/// The owner of a native deployment always may. A member of the project the +/// deployment belongs to may when their role allows it; a viewer gets 403. +/// Anyone else, and every legacy User Service installation that is not the +/// caller's, gets the owner-only lookup's 404. +pub async fn resolve_team_deployment( + pool: &PgPool, + settings: &crate::configuration::Settings, + user: &models::User, + deployment_hash: &str, + need: Need, +) -> Result { + use crate::routes::legacy_installations::{resolve_owned_deployment_by_hash, OwnedDeployment}; + + let deployment = db::deployment::fetch_by_deployment_hash(pool, deployment_hash) + .await + .map_err(JsonResponse::internal_server_error)?; + if let Some(deployment) = deployment { + if deployment.user_id.as_deref() != Some(user.id.as_str()) { + let project = db::project::fetch(pool, deployment.project_id) + .await + .map_err(JsonResponse::internal_server_error)?; + let access = match project { + Some(project) => access_of(pool, &project, &user.id) + .await + .map_err(JsonResponse::internal_server_error)?, + None => None, + }; + return match access { + Some(access) if access.allows(need) => Ok(OwnedDeployment::Native(deployment)), + Some(_) => Err(JsonResponse::forbidden("forbidden")), + None => Err(JsonResponse::not_found("Deployment not found")), + }; + } + } + resolve_owned_deployment_by_hash(pool, settings, user, deployment_hash).await +} + +/// Who a deployment runs as. +/// +/// A member deploys on the owner's behalf: the owner's saved clouds, servers, +/// SSH keys and deployment quota are used, and everything is recorded under +/// the owner. The member's own token stays for calls made on their behalf. +/// The owner's email comes from the User Service, because the install +/// payload carries id and email together; if it cannot be found, the deploy +/// is refused rather than sent with a mismatched address. +pub async fn acting_user( + project: &models::Project, + user: &models::User, + access: Access, + user_service_url: &str, +) -> Result { + if access == Access::Owner { + return Ok(user.clone()); + } + let email = owner_email(user_service_url, &project.user_id) + .await + .map_err(|err| { + tracing::warn!("Cannot deploy as the project owner: {}", err); + actix_web::error::ErrorBadGateway( + JsonResponse::<()>::build() + .set_msg("owner_unavailable") + .to_string(), + ) + })?; + Ok(models::User { + id: project.user_id.clone(), + first_name: String::new(), + last_name: String::new(), + email, + role: user.role.clone(), + email_confirmed: true, + mfa_verified: user.mfa_verified, + access_token: user.access_token.clone(), + }) +} + +/// The owner's address from the User Service internal user lookup. +async fn owner_email(user_service_url: &str, owner_id: &str) -> Result { + let key = std::env::var("INTERNAL_SERVICES_ACCESS_KEY").unwrap_or_default(); + if key.is_empty() { + return Err("INTERNAL_SERVICES_ACCESS_KEY is not set".to_string()); + } + let url = format!( + "{}/api/internal/users/{}", + user_service_url.trim_end_matches('/'), + urlencoding::encode(owner_id) + ); + let response = reqwest::Client::new() + .get(&url) + .header("X-Internal-Service-Key", key) + .timeout(std::time::Duration::from_secs(10)) + .send() + .await + .map_err(|err| format!("owner lookup failed: {err}"))?; + if !response.status().is_success() { + return Err(format!("owner lookup answered {}", response.status())); + } + let body: serde_json::Value = response + .json() + .await + .map_err(|err| format!("owner lookup returned bad JSON: {err}"))?; + body.get("email") + .and_then(|v| v.as_str()) + .map(str::trim) + .filter(|email| !email.is_empty()) + .map(str::to_string) + .ok_or_else(|| "owner lookup returned no email".to_string()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roles_allow_what_the_team_decisions_say() { + use Access::*; + use Need::View as V; + assert!(Owner.allows(V) && Owner.allows(Need::Manage) && Owner.allows(Need::Owner)); + assert!(Member.allows(V) && Member.allows(Need::Manage)); + assert!(!Member.allows(Need::Owner)); + assert!(!Viewer.allows(V) && !Viewer.allows(Need::Manage) && !Viewer.allows(Need::Owner)); + } +} diff --git a/src/routes/project/app.rs b/src/routes/project/app.rs index 8aaccb7b..7677ec45 100644 --- a/src/routes/project/app.rs +++ b/src/routes/project/app.rs @@ -16,6 +16,7 @@ use crate::db; use crate::helpers::JsonResponse; use crate::models::{self, Project}; +use crate::routes::project::access::{self, Need}; use crate::services::{ runtime_env_contract_response, ProjectAppService, RuntimeEnvContractResponse, }; @@ -217,9 +218,14 @@ pub async fn list_apps( .map_err(|e| JsonResponse::internal_server_error(e))? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::View, + "Project not found", + ) + .await?; // Fetch apps for project let apps = db::project_app::fetch_by_project(pg_pool.get_ref(), project_id) @@ -249,9 +255,14 @@ pub async fn create_app( .map_err(|e| JsonResponse::internal_server_error(e))? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::Manage, + "Project not found", + ) + .await?; let code = payload.code.trim(); if code.is_empty() { @@ -358,9 +369,14 @@ pub async fn get_app( .map_err(|e| JsonResponse::internal_server_error(e))? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::View, + "Project not found", + ) + .await?; // Fetch app let app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) @@ -389,9 +405,14 @@ pub async fn delete_app( .map_err(|e| JsonResponse::internal_server_error(e))? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::Manage, + "Project not found", + ) + .await?; let app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) .await @@ -446,9 +467,14 @@ pub async fn get_app_config( .map_err(|e| JsonResponse::internal_server_error(e))? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::View, + "Project not found", + ) + .await?; // Fetch app let app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) @@ -508,9 +534,14 @@ pub async fn get_env_vars( .map_err(|e| JsonResponse::internal_server_error(e))? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::View, + "Project not found", + ) + .await?; // Fetch app let app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) @@ -558,9 +589,14 @@ pub async fn update_env_vars( .map_err(|e| JsonResponse::internal_server_error(e))? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::Manage, + "Project not found", + ) + .await?; // Fetch and update app let mut app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) @@ -624,9 +660,14 @@ pub async fn delete_env_var( .map_err(|e| JsonResponse::internal_server_error(e))? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::Manage, + "Project not found", + ) + .await?; // Fetch and update app let mut app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) @@ -685,9 +726,14 @@ pub async fn update_ports( .map_err(|e| JsonResponse::internal_server_error(e))? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::Manage, + "Project not found", + ) + .await?; // Fetch and update app let mut app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) @@ -738,9 +784,14 @@ pub async fn update_domain( .map_err(|e| JsonResponse::internal_server_error(e))? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::Manage, + "Project not found", + ) + .await?; // Fetch and update app let mut app = db::project_app::fetch_by_project_and_code(pg_pool.get_ref(), project_id, &code) diff --git a/src/routes/project/compose.rs b/src/routes/project/compose.rs index 80cf6367..ec7fe2ec 100644 --- a/src/routes/project/compose.rs +++ b/src/routes/project/compose.rs @@ -2,6 +2,7 @@ use crate::db; use crate::helpers::project::builder::DcBuilder; use crate::helpers::JsonResponse; use crate::models; +use crate::routes::project::access::{self, Need}; use actix_web::{get, web, web::Data, Responder, Result}; use sqlx::PgPool; use std::sync::Arc; @@ -18,12 +19,17 @@ pub async fn add( .await .map_err(|err| JsonResponse::::build().internal_server_error(err)) .and_then(|project| match project { - Some(project) if project.user_id != user.id => { - Err(JsonResponse::::build().not_found("not found")) - } Some(project) => Ok(project), None => Err(JsonResponse::::build().not_found("not found")), })?; + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::View, + "not found", + ) + .await?; DcBuilder::new(project) .build() diff --git a/src/routes/project/delete.rs b/src/routes/project/delete.rs index dee3272a..22d248b0 100644 --- a/src/routes/project/delete.rs +++ b/src/routes/project/delete.rs @@ -2,6 +2,7 @@ use crate::db; use crate::helpers::JsonResponse; use crate::models; use crate::models::Project; +use crate::routes::project::access::{self, Need}; use actix_web::{delete, web, HttpResponse, Responder, Result}; use sqlx::PgPool; use std::sync::Arc; @@ -19,12 +20,10 @@ pub async fn item( .await .map_err(|err| JsonResponse::::build().internal_server_error(err)) .and_then(|project| match project { - Some(project) if project.user_id != user.id => { - Err(JsonResponse::::build().not_found("")) - } Some(project) => Ok(project), None => Err(JsonResponse::::build().not_found("")), })?; + access::check(pg_pool.get_ref(), &project, &user.id, Need::Owner, "").await?; if project.is_protected { let blockers = db::project::check_deletion_blockers(pg_pool.get_ref(), project.id) diff --git a/src/routes/project/deploy.rs b/src/routes/project/deploy.rs index 74ace0b7..0665694b 100644 --- a/src/routes/project/deploy.rs +++ b/src/routes/project/deploy.rs @@ -8,6 +8,7 @@ use crate::forms; use crate::helpers::project::builder::DcBuilder; use crate::helpers::{JsonResponse, MqManager, VaultClient}; use crate::models; +use crate::routes::project::access::{self, Need}; use crate::services; use actix_web::{post, web, web::Data, Responder, Result}; use serde::Deserialize; @@ -1844,9 +1845,20 @@ pub async fn item( .await .map_err(|err| JsonResponse::::build().internal_server_error(err)) .and_then(|project| match project { - Some(project) if project.user_id == user.id => Ok(project), - _ => Err(JsonResponse::::build().not_found("not found")), + Some(project) => Ok(project), + None => Err(JsonResponse::::build().not_found("not found")), })?; + let access = access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::Manage, + "not found", + ) + .await?; + let user = Arc::new( + access::acting_user(&project, user.as_ref(), access, &sets.user_service_url).await?, + ); let (project_id, deployment_id) = deploy_project( user.as_ref(), @@ -1894,9 +1906,20 @@ pub async fn saved_item( .await .map_err(|err| JsonResponse::::build().internal_server_error(err)) .and_then(|project| match project { - Some(project) if project.user_id == user.id => Ok(project), - _ => Err(JsonResponse::::build().not_found("Project not found")), + Some(project) => Ok(project), + None => Err(JsonResponse::::build().not_found("Project not found")), })?; + let access = access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::Manage, + "Project not found", + ) + .await?; + let user = Arc::new( + access::acting_user(&project, user.as_ref(), access, &sets.user_service_url).await?, + ); let marketplace_template = if let Some(template_id) = project.source_template_id { let template = db::marketplace::get_by_id(pg_pool.get_ref(), template_id) @@ -2121,12 +2144,20 @@ pub async fn rollback( .await .map_err(|err| JsonResponse::::build().internal_server_error(err)) .and_then(|project| match project { - Some(project) if project.user_id != user.id => { - Err(JsonResponse::::build().not_found("Project not found")) - } Some(project) => Ok(project), None => Err(JsonResponse::::build().not_found("Project not found")), })?; + let access = access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::Manage, + "Project not found", + ) + .await?; + let user = Arc::new( + access::acting_user(&project, user.as_ref(), access, &sets.user_service_url).await?, + ); let template_id = project.source_template_id.ok_or_else(|| { JsonResponse::::build() diff --git a/src/routes/project/discover.rs b/src/routes/project/discover.rs index 1296a868..6727ff52 100644 --- a/src/routes/project/discover.rs +++ b/src/routes/project/discover.rs @@ -7,6 +7,7 @@ use crate::db; use crate::helpers::JsonResponse; use crate::models::{self, ProjectApp}; use crate::project_app::{is_platform_managed_app_code, normalize_app_code}; +use crate::routes::project::access::{self, Need}; use actix_web::{get, post, web, Responder, Result}; use serde::{Deserialize, Serialize}; use serde_json::json; @@ -104,9 +105,14 @@ pub async fn discover_containers( .map_err(|e| JsonResponse::internal_server_error(e))? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::View, + "Project not found", + ) + .await?; // Get deployment_hash from query, the active project agent, or the latest // deployment record. Active agent state is preferred because command @@ -342,9 +348,14 @@ pub async fn import_containers( .map_err(|e| JsonResponse::internal_server_error(e))? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::Manage, + "Project not found", + ) + .await?; let mut imported = Vec::new(); let mut errors = Vec::new(); diff --git a/src/routes/project/get.rs b/src/routes/project/get.rs index 7c5aa9e8..d070216f 100644 --- a/src/routes/project/get.rs +++ b/src/routes/project/get.rs @@ -1,6 +1,7 @@ use crate::db; use crate::helpers::JsonResponse; use crate::models; +use crate::routes::project::access::{self, Need}; use actix_web::{get, web, Responder, Result}; use sqlx::PgPool; use std::sync::Arc; @@ -15,16 +16,19 @@ pub async fn item( // Get project apps of logged user only let id = path.0; - db::project::fetch(pg_pool.get_ref(), id) + let project = db::project::fetch(pg_pool.get_ref(), id) .await - .map_err(|err| JsonResponse::internal_server_error(err.to_string())) - .and_then(|project| match project { - Some(project) if project.user_id != user.id => { - Err(JsonResponse::not_found("not found")) - } - Some(project) => Ok(JsonResponse::build().set_item(Some(project)).ok("OK")), - None => Err(JsonResponse::not_found("not found")), - }) + .map_err(|err| JsonResponse::internal_server_error(err.to_string()))? + .ok_or_else(|| JsonResponse::not_found("not found"))?; + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::View, + "not found", + ) + .await?; + Ok(JsonResponse::build().set_item(Some(project)).ok("OK")) } #[tracing::instrument(name = "Get shared project list.", skip_all)] diff --git a/src/routes/project/invitation.rs b/src/routes/project/invitation.rs new file mode 100644 index 00000000..83d7656a --- /dev/null +++ b/src/routes/project/invitation.rs @@ -0,0 +1,560 @@ +//! Team seats: inviting people to a project by email. +//! +//! A project has `PROJECT_SEATS` seats: the owner, every member, and every +//! pending invitation that has not expired. Only an owner on the Team plan may +//! fill a seat, by invitation or by adding a member directly. The plan is +//! checked when inviting, not when accepting, so members keep their access if +//! the owner later leaves Team. + +use crate::configuration::Settings; +use crate::connectors::user_service::UserServiceClient; +use crate::db; +use crate::helpers::{agent_token, JsonResponse, MqManager}; +use crate::models; +use crate::routes::project::access::{self, Need}; +use actix_web::{delete, get, post, web, HttpResponse, Responder, Result}; +use serde::{Deserialize, Serialize}; +use sqlx::PgPool; +use std::sync::Arc; +use std::time::Duration; +use uuid::Uuid; + +pub const PROJECT_SEATS: i64 = 5; +pub const INVITATION_VALID_DAYS: i32 = 7; +pub const INVITABLE_ROLES: &[&str] = &["member", "viewer"]; +const EMAIL_ROUTING_KEY: &str = "notify.start.email.project_invitation.all"; +const EMAIL_PUBLISH_TIMEOUT: Duration = Duration::from_secs(5); + +/// Team plan codes start with "plan-individual-". Free, Starter and Pro +/// cannot share projects. +pub fn can_share_projects(plan_code: &str) -> bool { + plan_code.starts_with("plan-individual-") +} + +/// The caller's current plan code, from the User Service with their own token. +async fn plan_code(settings: &Settings, user: &models::User) -> Result, String> { + let token = user + .access_token + .as_deref() + .ok_or_else(|| "no user token to check the plan with".to_string())?; + UserServiceClient::new_public(&settings.user_service_url) + .get_subscription_plan(token) + .await + .map(|plan| plan.code) + .map_err(|err| err.to_string()) +} + +fn service_unavailable(msg: &str) -> actix_web::Error { + actix_web::error::ErrorServiceUnavailable(JsonResponse::<()>::build().set_msg(msg).to_string()) +} + +/// Refuse unless the caller is on the Team plan. An unknown plan is refused +/// too: a User Service outage must not open sharing to everyone. +pub(crate) async fn require_team_plan( + settings: &Settings, + user: &models::User, +) -> Result<(), actix_web::Error> { + match plan_code(settings, user).await { + Ok(Some(code)) if can_share_projects(&code) => Ok(()), + Ok(_) => Err(JsonResponse::forbidden("plan_required")), + Err(err) => { + tracing::warn!("Plan check failed: {}", err); + Err(service_unavailable("plan_check_unavailable")) + } + } +} + +/// Take the project's seat lock, drop expired invitations, and refuse when +/// every seat is taken. Call inside the transaction that fills the seat. +pub(crate) async fn reserve_seat( + tx: &mut sqlx::Transaction<'_, sqlx::Postgres>, + project_id: i32, +) -> Result<(), actix_web::Error> { + db::project_invitation::lock_project_seats(&mut **tx, project_id) + .await + .map_err(JsonResponse::internal_server_error)?; + db::project_invitation::expire_stale(&mut **tx, project_id) + .await + .map_err(JsonResponse::internal_server_error)?; + let taken = db::project_invitation::seats_taken(&mut **tx, project_id) + .await + .map_err(JsonResponse::internal_server_error)?; + if taken >= PROJECT_SEATS { + return Err(JsonResponse::<()>::build().conflict("seat_limit")); + } + Ok(()) +} + +fn frontend_url() -> String { + std::env::var("STACKER_FRONTEND_URL") + .ok() + .filter(|url| !url.trim().is_empty()) + .unwrap_or_else(|| "https://try.direct".to_string()) + .trim_end_matches('/') + .to_string() +} + +fn invite_link(token: &str) -> String { + format!("{}/invitations/{}", frontend_url(), token) +} + +fn normalize_email(raw: &str) -> Option { + let email = raw.trim().to_lowercase(); + let (local, domain) = email.split_once('@')?; + let valid = !local.is_empty() + && domain.contains('.') + && !domain.starts_with('.') + && !domain.ends_with('.') + && !email.chars().any(char::is_whitespace) + && email.len() <= 254; + valid.then_some(email) +} + +/// "colleague@try.direct" -> "c********@try.direct": enough to recognise +/// your own address, not enough to learn someone else's from a link. +fn mask_email(email: &str) -> String { + match email.split_once('@') { + Some((local, domain)) => { + let first: String = local.chars().take(1).collect(); + let stars = "*".repeat(local.chars().count().saturating_sub(1).max(3)); + format!("{first}{stars}@{domain}") + } + None => "***".to_string(), + } +} + +#[derive(Debug, Serialize)] +pub(crate) struct InvitationEmail { + to: String, + subject: String, + message: String, + html: Option, +} + +pub(crate) fn invitation_email( + to: &str, + project_name: &str, + invited_by: &str, + link: &str, +) -> InvitationEmail { + InvitationEmail { + to: to.to_string(), + subject: format!("{invited_by} invited you to {project_name} on TryDirect"), + message: format!( + "{invited_by} invited you to work on the project \"{project_name}\" on TryDirect.\n\n\ + Accept the invitation: {link}\n\n\ + The link is valid for {INVITATION_VALID_DAYS} days. If you do not have an account \ + yet, you can sign up from the link with this email address." + ), + html: Some(format!( + r#"

{invited_by} invited you to work on the project {project_name} on TryDirect.

+

Accept invitation

+

The link is valid for {INVITATION_VALID_DAYS} days. If you do not have an account yet, you can sign up from the link with this email address.

"#, + invited_by = html_escape(invited_by), + project_name = html_escape(project_name), + link = html_escape(link), + )), + } +} + +fn html_escape(raw: &str) -> String { + raw.replace('&', "&") + .replace('<', "<") + .replace('>', ">") + .replace('"', """) +} + +/// Publish the invitation email. The invitation stands either way: the owner +/// gets the link back and can pass it on if the email does not arrive. +async fn send_invitation_email(mq: &MqManager, email: &InvitationEmail) -> bool { + let publish = mq.publish("notify".to_string(), EMAIL_ROUTING_KEY.to_string(), email); + match tokio::time::timeout(EMAIL_PUBLISH_TIMEOUT, publish).await { + Ok(Ok(_)) => true, + Ok(Err(err)) => { + tracing::warn!("Invitation email not published: {}", err); + false + } + Err(_) => { + tracing::warn!("Invitation email not published: timed out"); + false + } + } +} + +#[derive(Debug, Deserialize)] +pub struct CreateInvitationRequest { + pub email: String, + pub role: String, +} + +#[derive(Debug, Serialize)] +struct CreatedInvitation { + id: Uuid, + email: String, + role: String, + status: String, + expires_at: chrono::DateTime, + invite_link: String, + email_sent: bool, +} + +#[tracing::instrument(name = "Invite to project", skip_all)] +#[post("/{id}/invitations")] +pub async fn create( + user: web::ReqData>, + path: web::Path<(i32,)>, + payload: web::Json, + pg_pool: web::Data, + settings: web::Data, + mq_manager: web::Data, +) -> Result { + let (project, _) = access::require(pg_pool.get_ref(), path.0, &user.id, Need::Owner).await?; + + let email = normalize_email(&payload.email) + .ok_or_else(|| JsonResponse::bad_request("invalid_email"))?; + if !INVITABLE_ROLES.contains(&payload.role.as_str()) { + return Err(JsonResponse::bad_request("invalid_role")); + } + require_team_plan(settings.get_ref(), &user).await?; + if email == user.email.trim().to_lowercase() { + return Err(JsonResponse::<()>::build().conflict("cannot_invite_self")); + } + + let token = agent_token::generate(); + let mut tx = pg_pool + .begin() + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string()))?; + reserve_seat(&mut tx, project.id).await?; + let already = db::project_invitation::live_pending_exists(&mut *tx, project.id, &email) + .await + .map_err(JsonResponse::internal_server_error)?; + if already { + return Err(JsonResponse::<()>::build().conflict("already_invited")); + } + let invitation = db::project_invitation::insert( + &mut *tx, + db::project_invitation::NewInvitation { + project_id: project.id, + email: &email, + role: &payload.role, + token_hash: &agent_token::hash(&token), + invited_by: &user.id, + invited_by_email: &user.email, + valid_for_days: INVITATION_VALID_DAYS, + }, + ) + .await + .map_err(JsonResponse::internal_server_error)?; + tx.commit() + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string()))?; + + let link = invite_link(&token); + let email_sent = send_invitation_email( + mq_manager.get_ref(), + &invitation_email(&email, &project.name, &user.email, &link), + ) + .await; + + Ok(JsonResponse::build() + .set_item(CreatedInvitation { + id: invitation.id, + email: invitation.email, + role: invitation.role, + status: invitation.status, + expires_at: invitation.expires_at, + invite_link: link, + email_sent, + }) + .created("OK")) +} + +#[tracing::instrument(name = "List project invitations", skip_all)] +#[get("/{id}/invitations")] +pub async fn list( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, +) -> Result { + let (project, _) = access::require(pg_pool.get_ref(), path.0, &user.id, Need::Owner).await?; + let invitations = db::project_invitation::list_live_pending(pg_pool.get_ref(), project.id) + .await + .map_err(JsonResponse::internal_server_error)?; + Ok(JsonResponse::build().set_list(invitations).ok("OK")) +} + +#[tracing::instrument(name = "Revoke project invitation", skip_all)] +#[delete("/{id}/invitations/{invitation_id}")] +pub async fn revoke( + user: web::ReqData>, + path: web::Path<(i32, String)>, + pg_pool: web::Data, +) -> Result { + let (project_id, invitation_id) = path.into_inner(); + let (project, _) = + access::require(pg_pool.get_ref(), project_id, &user.id, Need::Owner).await?; + let invitation_id = + Uuid::parse_str(&invitation_id).map_err(|_| JsonResponse::not_found("not found"))?; + let revoked = db::project_invitation::revoke(pg_pool.get_ref(), project.id, invitation_id) + .await + .map_err(JsonResponse::internal_server_error)?; + if !revoked { + return Err(JsonResponse::not_found("not found")); + } + Ok(HttpResponse::NoContent().finish()) +} + +#[derive(Debug, Serialize)] +struct Seats { + seats_total: i64, + seats_used: i64, + seats_free: i64, + can_invite: bool, +} + +#[tracing::instrument(name = "Project seats", skip_all)] +#[get("/{id}/seats")] +pub async fn seats( + user: web::ReqData>, + path: web::Path<(i32,)>, + pg_pool: web::Data, + settings: web::Data, +) -> Result { + let (project, _) = access::require(pg_pool.get_ref(), path.0, &user.id, Need::Owner).await?; + let mut tx = pg_pool + .begin() + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string()))?; + db::project_invitation::expire_stale(&mut *tx, project.id) + .await + .map_err(JsonResponse::internal_server_error)?; + let used = db::project_invitation::seats_taken(&mut *tx, project.id) + .await + .map_err(JsonResponse::internal_server_error)?; + tx.commit() + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string()))?; + + let free = (PROJECT_SEATS - used).max(0); + let on_team = matches!(plan_code(settings.get_ref(), &user).await, Ok(Some(code)) if can_share_projects(&code)); + Ok(JsonResponse::build() + .set_item(Seats { + seats_total: PROJECT_SEATS, + seats_used: used, + seats_free: free, + can_invite: on_team && free > 0, + }) + .ok("OK")) +} + +#[derive(Debug, Serialize)] +struct PublicInvitation { + project_name: String, + invited_by: String, + email: String, + role: String, + status: String, + expires_at: chrono::DateTime, +} + +/// What the invitation page shows before the invitee signs in. Looked up by +/// token only, so knowing an invitation id is not enough. +#[tracing::instrument(name = "Show invitation", skip_all)] +#[get("/invitations/{token}")] +pub async fn show( + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result { + let invitation = + db::project_invitation::fetch_by_token_hash(pg_pool.get_ref(), &agent_token::hash(&path.0)) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("not found"))?; + let project = db::project::fetch(pg_pool.get_ref(), invitation.project_id) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("not found"))?; + + Ok(JsonResponse::build() + .set_item(PublicInvitation { + project_name: project.name, + invited_by: invitation.invited_by_email.clone(), + email: mask_email(&invitation.email), + role: invitation.role.clone(), + status: invitation.effective_status().to_string(), + expires_at: invitation.expires_at, + }) + .ok("OK")) +} + +#[tracing::instrument(name = "My pending invitations", skip_all)] +#[get("/invitations/pending")] +pub async fn pending( + user: web::ReqData>, + pg_pool: web::Data, +) -> Result { + let email = user.email.trim().to_lowercase(); + let invitations = db::project_invitation::pending_for_email(pg_pool.get_ref(), &email) + .await + .map_err(JsonResponse::internal_server_error)?; + Ok(JsonResponse::build().set_list(invitations).ok("OK")) +} + +#[derive(Debug, Serialize)] +struct Accepted { + project_id: i32, + role: String, +} + +/// Accept an invitation by its token (from the email link) or by its id (from +/// the pending list). Either way the signed-in account's confirmed email must +/// be the invited one: that, not knowing the token, is what protects it. +#[tracing::instrument(name = "Accept invitation", skip_all)] +#[post("/invitations/{key}/accept")] +pub async fn accept( + user: web::ReqData>, + path: web::Path<(String,)>, + pg_pool: web::Data, +) -> Result { + let key = path.into_inner().0; + let id = match Uuid::parse_str(&key) { + Ok(id) => id, + Err(_) => { + db::project_invitation::fetch_by_token_hash(pg_pool.get_ref(), &agent_token::hash(&key)) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("not found"))? + .id + } + }; + + let mut tx = pg_pool + .begin() + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string()))?; + let invitation = db::project_invitation::lock_by_id(&mut *tx, id) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("not found"))?; + + if invitation.email != user.email.trim().to_lowercase() { + return Err(JsonResponse::forbidden("email_mismatch")); + } + if !user.email_confirmed { + return Err(JsonResponse::forbidden("email_not_confirmed")); + } + if invitation.effective_status() != "pending" { + return Err(actix_web::error::ErrorGone( + JsonResponse::<()>::build() + .set_msg(format!("invitation_{}", invitation.effective_status())) + .to_string(), + )); + } + + let project = db::project::fetch(pg_pool.get_ref(), invitation.project_id) + .await + .map_err(JsonResponse::internal_server_error)? + .ok_or_else(|| JsonResponse::not_found("not found"))?; + let added = project.user_id != user.id + && db::project_invitation::insert_member( + &mut *tx, + invitation.project_id, + &user.id, + &invitation.role, + &invitation.invited_by, + ) + .await + .map_err(JsonResponse::internal_server_error)?; + if !added { + // Already the owner or a member. The invitation is pointless now; + // release its seat. + db::project_invitation::set_status(&mut *tx, invitation.id, "revoked", None) + .await + .map_err(JsonResponse::internal_server_error)?; + tx.commit() + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string()))?; + return Err(JsonResponse::<()>::build().conflict("already_member")); + } + db::project_invitation::set_status(&mut *tx, invitation.id, "accepted", Some(&user.id)) + .await + .map_err(JsonResponse::internal_server_error)?; + tx.commit() + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string()))?; + + Ok(JsonResponse::build() + .set_item(Accepted { + project_id: invitation.project_id, + role: invitation.role, + }) + .ok("OK")) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn only_team_plan_codes_can_share() { + assert!(can_share_projects("plan-individual-monthly")); + assert!(can_share_projects("plan-individual-yearly")); + for code in [ + "plan-plus-monthly", + "plan-basic-monthly", + "plan-free-periodically", + "", + "individual", + ] { + assert!(!can_share_projects(code), "{code}"); + } + } + + #[test] + fn emails_are_normalised_and_junk_refused() { + assert_eq!( + normalize_email(" Colleague@Try.Direct ").as_deref(), + Some("colleague@try.direct") + ); + for junk in [ + "", + "not-an-email", + "@try.direct", + "a@b", + "a@.b", + "a b@try.direct", + ] { + assert_eq!(normalize_email(junk), None, "{junk}"); + } + } + + #[test] + fn masked_email_keeps_the_domain_and_hides_the_name() { + assert_eq!(mask_email("colleague@try.direct"), "c********@try.direct"); + assert_eq!(mask_email("ab@x.io"), "a***@x.io"); + } + + #[test] + fn invitation_email_carries_the_link_and_escapes_html() { + let email = invitation_email( + "colleague@try.direct", + "", + "team@try.direct", + "https://try.direct/invitations/abc", + ); + assert_eq!(email.to, "colleague@try.direct"); + assert!(email.message.contains("https://try.direct/invitations/abc")); + let html = email.html.unwrap(); + assert!(html.contains("<acme>") && !html.contains("")); + assert!(html.contains("https://try.direct/invitations/abc")); + } + + #[test] + fn invitation_email_goes_to_the_notify_email_channel() { + // notify consumes `notify.start.email.*.*`. + let parts: Vec<&str> = EMAIL_ROUTING_KEY.split('.').collect(); + assert_eq!(parts.len(), 5); + assert_eq!(&parts[..3], ["notify", "start", "email"]); + } +} diff --git a/src/routes/project/member.rs b/src/routes/project/member.rs index e9e538a2..01ed8835 100644 --- a/src/routes/project/member.rs +++ b/src/routes/project/member.rs @@ -1,6 +1,9 @@ +use crate::configuration::Settings; use crate::db; use crate::helpers::JsonResponse; use crate::models; +use crate::routes::project::access::{self, Need}; +use crate::routes::project::invitation::{require_team_plan, reserve_seat, INVITABLE_ROLES}; use actix_web::{delete, get, post, web, HttpResponse, Responder, Result}; use serde::Deserialize; use sqlx::PgPool; @@ -12,21 +15,15 @@ pub struct AddProjectMemberRequest { pub role: String, } +/// Members and invitations are the owner's: a member gets 403, anyone else 404. async fn fetch_owned_project( pool: &PgPool, project_id: i32, user_id: &str, ) -> Result { - let project = db::project::fetch(pool, project_id) + access::require(pool, project_id, user_id, Need::Owner) .await - .map_err(|err| JsonResponse::::build().internal_server_error(err))? - .ok_or_else(|| JsonResponse::::build().not_found("not found"))?; - - if project.user_id != user_id { - return Err(JsonResponse::::build().not_found("not found")); - } - - Ok(project) + .map(|(project, _)| project) } #[tracing::instrument(name = "Share project with member", skip_all)] @@ -36,14 +33,45 @@ pub async fn add( path: web::Path<(i32,)>, payload: web::Json, pg_pool: web::Data, + settings: web::Data, ) -> Result { let project_id = path.0; - let _project = fetch_owned_project(pg_pool.get_ref(), project_id, &user.id).await?; + let project = fetch_owned_project(pg_pool.get_ref(), project_id, &user.id).await?; - if payload.role != "viewer" { + if !INVITABLE_ROLES.contains(&payload.role.as_str()) { return Err(JsonResponse::::build() - .bad_request("Only viewer role is supported")); + .bad_request("Only member and viewer roles are supported")); + } + if payload.user_id == project.user_id { + return Err(JsonResponse::::build().conflict("cannot_invite_self")); + } + require_team_plan(settings.get_ref(), &user).await?; + + // Changing an existing member's role takes no new seat. + let existing = db::project_member::fetch(pg_pool.get_ref(), project_id, &payload.user_id) + .await + .map_err(|err| JsonResponse::::build().internal_server_error(err))?; + if existing.is_none() { + // Insert under the seat lock so two requests cannot both take the + // last seat; the upsert below then only settles the role. + let mut tx = pg_pool + .begin() + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string()))?; + reserve_seat(&mut tx, project_id).await?; + db::project_invitation::insert_member( + &mut *tx, + project_id, + &payload.user_id, + &payload.role, + &user.id, + ) + .await + .map_err(JsonResponse::internal_server_error)?; + tx.commit() + .await + .map_err(|err| JsonResponse::internal_server_error(err.to_string()))?; } let member = db::project_member::upsert( @@ -70,7 +98,7 @@ pub async fn list( let _project = fetch_owned_project(pg_pool.get_ref(), project_id, &user.id).await?; - let members = db::project_member::fetch_by_project(pg_pool.get_ref(), project_id) + let members = db::project_member::fetch_by_project_with_email(pg_pool.get_ref(), project_id) .await .map_err(|err| JsonResponse::::build().internal_server_error(err))?; diff --git a/src/routes/project/mod.rs b/src/routes/project/mod.rs index 750e2b94..585947db 100644 --- a/src/routes/project/mod.rs +++ b/src/routes/project/mod.rs @@ -1,3 +1,4 @@ +pub mod access; pub mod add; pub mod app; pub(crate) mod compose; @@ -5,6 +6,7 @@ pub(crate) mod delete; pub mod deploy; pub mod discover; pub mod get; +pub mod invitation; pub mod member; pub mod protection; pub mod secret; diff --git a/src/routes/project/protection.rs b/src/routes/project/protection.rs index fb2adba0..ad1e69e0 100644 --- a/src/routes/project/protection.rs +++ b/src/routes/project/protection.rs @@ -1,6 +1,7 @@ use crate::db; use crate::helpers::JsonResponse; use crate::models; +use crate::routes::project::access::{self, Need}; use actix_web::{patch, web, Responder, Result}; use serde::Deserialize; use sqlx::PgPool; @@ -26,12 +27,10 @@ pub async fn toggle( .await .map_err(|err| JsonResponse::::build().internal_server_error(err)) .and_then(|project| match project { - Some(project) if project.user_id != user.id => { - Err(JsonResponse::::build().not_found("")) - } Some(project) => Ok(project), None => Err(JsonResponse::::build().not_found("")), })?; + access::check(pg_pool.get_ref(), &project, &user.id, Need::Owner, "").await?; if !form.is_protected { let confirmation = form.confirmation_name.as_deref().unwrap_or(""); diff --git a/src/routes/project/secret.rs b/src/routes/project/secret.rs index 8ee94c4b..a6744141 100644 --- a/src/routes/project/secret.rs +++ b/src/routes/project/secret.rs @@ -2,6 +2,7 @@ use crate::db; use crate::forms::{RemoteSecretMetadataResponse, UpsertRemoteSecretRequest}; use crate::helpers::JsonResponse; use crate::models; +use crate::routes::project::access::{self, Need}; use crate::services::VaultService; use actix_web::{delete, get, put, web, Responder, Result}; use serde_json::json; @@ -20,9 +21,7 @@ async fn fetch_owned_project_and_app( .map_err(JsonResponse::internal_server_error)? .ok_or_else(|| JsonResponse::not_found("Project not found"))?; - if project.user_id != user.id { - return Err(JsonResponse::not_found("Project not found")); - } + access::check(pool, &project, &user.id, Need::Owner, "Project not found").await?; let app = db::project_app::fetch_by_project_and_code(pool, project_id, app_code) .await diff --git a/src/routes/project/sync.rs b/src/routes/project/sync.rs index 8af4081a..e998f187 100644 --- a/src/routes/project/sync.rs +++ b/src/routes/project/sync.rs @@ -8,6 +8,7 @@ use crate::db; use crate::helpers::JsonResponse; use crate::models; use crate::project_app; +use crate::routes::project::access::{self, Need}; use actix_web::{put, web, Responder, Result}; use serde_json::Value; use serde_valid::Validate; @@ -157,12 +158,17 @@ pub async fn item( .await .map_err(JsonResponse::internal_server_error) .and_then(|project| match project { - Some(project) if project.user_id != user.id => { - Err(JsonResponse::not_found("Project not found")) - } Some(project) => Ok(project), None => Err(JsonResponse::not_found("Project not found")), })?; + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::Manage, + "Project not found", + ) + .await?; let existing_apps = db::project_app::fetch_by_project(pg_pool.get_ref(), id) .await diff --git a/src/routes/project/update.rs b/src/routes/project/update.rs index de101b99..5381c590 100644 --- a/src/routes/project/update.rs +++ b/src/routes/project/update.rs @@ -3,6 +3,7 @@ use crate::forms::project::{DockerImageReadResult, ProjectForm}; use crate::helpers::JsonResponse; use crate::models; use crate::project_app; +use crate::routes::project::access::{self, Need}; use actix_web::{put, web, Responder, Result}; use serde_json::Value; use serde_valid::Validate; @@ -22,12 +23,17 @@ pub async fn item( .await .map_err(JsonResponse::internal_server_error) .and_then(|project| match project { - Some(project) if project.user_id != user.id => { - Err(JsonResponse::not_found("Project not found")) - } Some(project) => Ok(project), None => Err(JsonResponse::not_found("Project not found")), })?; + access::check( + pg_pool.get_ref(), + &project, + &user.id, + Need::Manage, + "Project not found", + ) + .await?; // @todo ACL let form: ProjectForm = serde_json::from_value(request_json.clone()) diff --git a/src/startup.rs b/src/startup.rs index febfdd7c..25077947 100644 --- a/src/startup.rs +++ b/src/startup.rs @@ -24,6 +24,14 @@ fn project_scope(path: &str) -> actix_web::Scope { .service(crate::routes::project::member::add) .service(crate::routes::project::member::list) .service(crate::routes::project::member::delete) + // Literal /invitations/... paths before the /{id} routes below. + .service(crate::routes::project::invitation::pending) + .service(crate::routes::project::invitation::show) + .service(crate::routes::project::invitation::accept) + .service(crate::routes::project::invitation::create) + .service(crate::routes::project::invitation::list) + .service(crate::routes::project::invitation::revoke) + .service(crate::routes::project::invitation::seats) .service(crate::routes::project::compose::add) .service(crate::routes::project::get::list) .service(crate::routes::project::get::shared_list) diff --git a/tests/common/mod.rs b/tests/common/mod.rs index a7a8e240..ed9fe341 100644 --- a/tests/common/mod.rs +++ b/tests/common/mod.rs @@ -272,6 +272,49 @@ pub async fn spawn_app_two_users_with_user_service( spawn_app_two_users_with_configuration(configuration).await } +/// Two users where User A is on the Team plan (may share projects) and +/// User B is on the free plan. Sharing a project checks the owner's plan with +/// the User Service, which the plain two-user harness does not answer. +pub async fn spawn_app_two_users_on_team_plan() -> Option { + let user_service = MockServer::start().await; + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path("/oauth_server/api/me")) + .respond_with(|req: &wiremock::Request| { + let is_user_b = req.headers.iter().any(|(name, values)| { + name.as_str().eq_ignore_ascii_case("authorization") + && values.last().as_str().contains("user-b") + }); + let code = if is_user_b { + "plan-free-periodically" + } else { + "plan-individual-monthly" + }; + wiremock::ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "user": { "plan": { "code": code } } })) + }) + .mount(&user_service) + .await; + // A member deploys as the owner, whose address comes from the internal + // user lookup. + wiremock::Mock::given(wiremock::matchers::method("GET")) + .and(wiremock::matchers::path(format!( + "/api/internal/users/{USER_A_ID}" + ))) + .respond_with( + wiremock::ResponseTemplate::new(200) + .set_body_json(serde_json::json!({ "email": USER_A_EMAIL })), + ) + .mount(&user_service) + .await; + std::env::set_var("INTERNAL_SERVICES_ACCESS_KEY", "test-internal-key"); + + let mut configuration = get_configuration().expect("Failed to get configuration"); + configuration.user_service_url = user_service.uri(); + // The mock must outlive the app, which lives for the rest of the test. + std::mem::forget(user_service); + spawn_app_two_users_with_configuration(configuration).await +} + pub async fn spawn_app_two_users_with_configuration( mut configuration: Settings, ) -> Option { diff --git a/tests/project_team_projects.rs b/tests/project_team_projects.rs index 0f8103ad..808987c0 100644 --- a/tests/project_team_projects.rs +++ b/tests/project_team_projects.rs @@ -102,7 +102,7 @@ async fn create_test_project_with_payload( #[tokio::test] async fn owner_can_share_project_with_viewer_by_user_id() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; @@ -124,7 +124,7 @@ async fn owner_can_share_project_with_viewer_by_user_id() { #[tokio::test] async fn non_owner_cannot_share_project() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; @@ -143,7 +143,7 @@ async fn non_owner_cannot_share_project() { #[tokio::test] async fn viewer_can_get_shared_project_latest_deployment() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; @@ -178,7 +178,7 @@ async fn viewer_can_get_shared_project_latest_deployment() { #[tokio::test] async fn viewer_can_list_shared_project_deployments_with_project_filter() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; @@ -215,7 +215,7 @@ async fn viewer_can_list_shared_project_deployments_with_project_filter() { #[tokio::test] async fn viewer_cannot_get_raw_project_payload() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; @@ -236,12 +236,15 @@ async fn viewer_cannot_get_raw_project_payload() { .await .expect("project get request failed"); - assert_eq!(response.status(), StatusCode::NOT_FOUND); + // A viewer sees the project in their shared list and its deployments, not + // the stack payload (it carries app env, which can hold passwords). They + // do have access to the project, so the refusal is 403, not 404. + assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn viewer_can_list_projects_shared_with_them() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; @@ -269,7 +272,7 @@ async fn viewer_can_list_projects_shared_with_them() { #[tokio::test] async fn shared_projects_list_excludes_sensitive_fields() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; @@ -303,7 +306,7 @@ async fn shared_projects_list_excludes_sensitive_fields() { #[tokio::test] async fn shared_projects_list_only_returns_projects_shared_to_requesting_user() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; @@ -345,7 +348,7 @@ async fn shared_projects_list_only_returns_projects_shared_to_requesting_user() #[tokio::test] async fn shared_project_does_not_change_existing_project_list_contract() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; @@ -374,7 +377,7 @@ async fn shared_project_does_not_change_existing_project_list_contract() { #[tokio::test] async fn owner_can_list_project_members() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; @@ -400,7 +403,7 @@ async fn owner_can_list_project_members() { #[tokio::test] async fn non_owner_cannot_list_project_members() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; @@ -416,12 +419,14 @@ async fn non_owner_cannot_list_project_members() { let response = list_project_members(&app.address, project_id, common::USER_B_TOKEN).await; - assert_eq!(response.status(), StatusCode::NOT_FOUND); + // B is a viewer: they may know the project exists, but members are the + // owner's to manage, so this is 403. Someone with no access gets 404. + assert_eq!(response.status(), StatusCode::FORBIDDEN); } #[tokio::test] async fn owner_can_remove_project_member() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; @@ -454,7 +459,7 @@ async fn owner_can_remove_project_member() { #[tokio::test] async fn removed_viewer_loses_shared_project_access() { - let Some(app) = common::spawn_app_two_users().await else { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { return; }; diff --git a/tests/project_team_seats.rs b/tests/project_team_seats.rs new file mode 100644 index 00000000..6e36d592 --- /dev/null +++ b/tests/project_team_seats.rs @@ -0,0 +1,1283 @@ +//! Team seats: an owner on the Team plan invites people to a project by email. +//! +//! Each test is named after a scenario in +//! `config/shared-fixtures/features/project-team-members.feature`. +//! +//! The harness gives the app its own set of accounts: a mock OAuth `/me` maps +//! a bearer token to a user (id, email, confirmed or not), and a mock User +//! Service answers `/oauth_server/api/me` with that user's plan code. Plan +//! codes sit behind a mutex so a test can move an owner to another plan. + +mod common; + +use serde_json::{json, Value}; +use std::collections::HashMap; +use std::sync::{Arc, Mutex}; +use wiremock::matchers::{method, path}; +use wiremock::{Mock, MockServer, Request, Respond, ResponseTemplate}; + +#[derive(Clone, Copy)] +struct Account { + token: &'static str, + id: &'static str, + email: &'static str, + plan: &'static str, + confirmed: bool, +} + +const TEAM: Account = Account { + token: "tok-team", + id: "team_owner_id", + email: "team@try.direct", + plan: "plan-individual-monthly", + confirmed: true, +}; +const PRO: Account = Account { + token: "tok-pro", + id: "pro_user_id", + email: "pro@try.direct", + plan: "plan-plus-monthly", + confirmed: true, +}; +const STARTER: Account = Account { + token: "tok-starter", + id: "starter_user_id", + email: "starter@try.direct", + plan: "plan-basic-monthly", + confirmed: true, +}; +const FREE: Account = Account { + token: "tok-free", + id: "free_user_id", + email: "free@try.direct", + plan: "plan-free-periodically", + confirmed: true, +}; +const COLLEAGUE: Account = Account { + token: "tok-colleague", + id: "colleague_id", + email: "colleague@try.direct", + plan: "plan-free-periodically", + confirmed: true, +}; +/// The same person as COLLEAGUE before they confirmed their email. +const COLLEAGUE_UNCONFIRMED: Account = Account { + token: "tok-colleague-unconfirmed", // pragma: allowlist secret (fake bearer for the mock auth server) + id: "colleague_id", + email: "colleague@try.direct", + plan: "plan-free-periodically", + confirmed: false, +}; +const OTHER: Account = Account { + token: "tok-other", + id: "other_id", + email: "other@try.direct", + plan: "plan-free-periodically", + confirmed: true, +}; + +const ACCOUNTS: &[Account] = &[ + TEAM, + PRO, + STARTER, + FREE, + COLLEAGUE, + COLLEAGUE_UNCONFIRMED, + OTHER, +]; + +fn account_for(req: &Request) -> Option { + let header = req + .headers + .iter() + .find(|(name, _)| name.as_str().eq_ignore_ascii_case("authorization")) + .map(|(_, values)| values.last().as_str().to_string())?; + let token = header.strip_prefix("Bearer ")?; + ACCOUNTS.iter().copied().find(|a| a.token == token) +} + +struct AuthResponder; + +impl Respond for AuthResponder { + fn respond(&self, req: &Request) -> ResponseTemplate { + let Some(account) = account_for(req) else { + return ResponseTemplate::new(401); + }; + let user = stacker::forms::user::User { + id: account.id.to_string(), + email: account.email.to_string(), + role: "group_user".to_string(), + email_confirmed: account.confirmed, + ..Default::default() + }; + ResponseTemplate::new(200).set_body_json(stacker::forms::user::UserForm { user }) + } +} + +struct PlanResponder { + plans: Arc>>, +} + +impl Respond for PlanResponder { + fn respond(&self, req: &Request) -> ResponseTemplate { + let Some(account) = account_for(req) else { + return ResponseTemplate::new(401); + }; + let code = self.plans.lock().unwrap()[account.id].clone(); + ResponseTemplate::new(200).set_body_json(json!({ + "user": { "email": account.email, "plan": { "code": code, "name": code } } + })) + } +} + +struct App { + address: String, + pool: sqlx::PgPool, + client: reqwest::Client, + plans: Arc>>, + user_service: MockServer, + _auth: MockServer, +} + +impl App { + async fn spawn() -> Option { + let auth = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/me")) + .respond_with(AuthResponder) + .mount(&auth) + .await; + + let plans = Arc::new(Mutex::new( + ACCOUNTS + .iter() + .map(|a| (a.id, a.plan.to_string())) + .collect::>(), + )); + let user_service = MockServer::start().await; + Mock::given(method("GET")) + .and(path("/oauth_server/api/me")) + .respond_with(PlanResponder { + plans: plans.clone(), + }) + .mount(&user_service) + .await; + + let mut configuration = + stacker::configuration::get_configuration().expect("Failed to get configuration"); + if let Some(ref mut cfg) = configuration.connectors.dockerhub_service { + cfg.enabled = false; + } + configuration.auth_url = format!("{}/me", auth.uri()); + configuration.user_service_url = user_service.uri(); + configuration.database.database_name = uuid::Uuid::new_v4().to_string(); + + let pool = match common::configure_database(&configuration.database).await { + Ok(pool) => pool, + Err(err) => { + eprintln!("Skipping tests: failed to connect to postgres: {}", err); + return None; + } + }; + + let listener = std::net::TcpListener::bind("127.0.0.1:0").expect("Failed to bind port"); + let address = format!("http://127.0.0.1:{}", listener.local_addr().unwrap().port()); + let agent_pool = stacker::helpers::AgentPgPool::new(pool.clone()); + let server = stacker::startup::run(listener, pool.clone(), agent_pool, configuration) + .await + .expect("Failed to start app"); + tokio::spawn(server); + + Some(App { + address, + pool, + client: reqwest::Client::builder() + .pool_max_idle_per_host(0) + .build() + .unwrap(), + plans, + user_service, + _auth: auth, + }) + } + + fn set_plan(&self, account: Account, code: &str) { + self.plans + .lock() + .unwrap() + .insert(account.id, code.to_string()); + } + + async fn project(&self, owner: Account) -> i32 { + common::create_test_project(&self.pool, owner.id).await + } + + async fn call( + &self, + as_user: Option, + verb: &str, + route: &str, + body: Option, + ) -> (u16, Value) { + let url = format!("{}{}", self.address, route); + let mut req = match verb { + "GET" => self.client.get(&url), + "POST" => self.client.post(&url), + "PUT" => self.client.put(&url), + "DELETE" => self.client.delete(&url), + other => panic!("unsupported verb {other}"), + }; + if let Some(account) = as_user { + req = req.bearer_auth(account.token); + } + if let Some(body) = body { + req = req.json(&body); + } + let resp = req.send().await.expect("request failed"); + let status = resp.status().as_u16(); + let text = resp.text().await.unwrap_or_default(); + ( + status, + serde_json::from_str(&text).unwrap_or(Value::String(text)), + ) + } + + async fn invite(&self, owner: Account, project_id: i32, email: &str) -> (u16, Value) { + self.call( + Some(owner), + "POST", + &format!("/project/{project_id}/invitations"), + Some(json!({ "email": email, "role": "member" })), + ) + .await + } + + /// Invite and return the token from the invite link the owner gets back. + async fn invite_token(&self, owner: Account, project_id: i32, email: &str) -> String { + let (status, body) = self.invite(owner, project_id, email).await; + assert!( + (200..300).contains(&status), + "invite {email} failed: {status} {body}" + ); + let link = body["item"]["invite_link"] + .as_str() + .unwrap_or_else(|| panic!("no invite_link in {body}")); + link.rsplit('/').next().unwrap().to_string() + } + + async fn accept(&self, account: Account, key: &str) -> (u16, Value) { + self.call( + Some(account), + "POST", + &format!("/project/invitations/{key}/accept"), + None, + ) + .await + } + + async fn seats(&self, owner: Account, project_id: i32) -> (i64, i64) { + let (status, body) = self + .call( + Some(owner), + "GET", + &format!("/project/{project_id}/seats"), + None, + ) + .await; + assert_eq!(status, 200, "seats: {body}"); + ( + body["item"]["seats_used"].as_i64().unwrap(), + body["item"]["seats_total"].as_i64().unwrap(), + ) + } + + async fn first_invitation_id(&self, owner: Account, project_id: i32) -> String { + let (status, list) = self + .call( + Some(owner), + "GET", + &format!("/project/{project_id}/invitations"), + None, + ) + .await; + assert_eq!(status, 200, "{list}"); + list["list"][0]["id"].as_str().unwrap().to_string() + } + + async fn member_role(&self, project_id: i32, account: Account) -> Option { + sqlx::query_scalar::<_, String>( + "SELECT role FROM project_member WHERE project_id = $1 AND user_id = $2", + ) + .bind(project_id) + .bind(account.id) + .fetch_optional(&self.pool) + .await + .unwrap() + } + + async fn add_member(&self, project_id: i32, user_id: &str, role: &str) { + sqlx::query( + "INSERT INTO project_member (project_id, user_id, role, created_by) VALUES ($1, $2, $3, $4)", + ) + .bind(project_id) + .bind(user_id) + .bind(role) + .bind(TEAM.id) + .execute(&self.pool) + .await + .unwrap(); + } + + async fn invitation_count(&self, project_id: i32) -> i64 { + sqlx::query_scalar::<_, i64>( + "SELECT COUNT(*) FROM project_invitation WHERE project_id = $1", + ) + .bind(project_id) + .fetch_one(&self.pool) + .await + .unwrap() + } +} + +fn message(body: &Value) -> String { + body["message"].as_str().unwrap_or_default().to_string() +} + +fn ok(status: u16) -> bool { + (200..300).contains(&status) +} + +// ── Inviting ── + +#[tokio::test] +async fn a_team_owner_invites_a_colleague_by_email() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + + // Mixed case on purpose: the invitation is stored and matched lowercased. + let (status, body) = app.invite(TEAM, project, "Colleague@Try.Direct").await; + assert!(ok(status), "{status} {body}"); + assert_eq!(body["item"]["email"], "colleague@try.direct"); + assert_eq!(body["item"]["role"], "member"); + assert_eq!(body["item"]["status"], "pending"); + assert!(body["item"]["invite_link"] + .as_str() + .unwrap_or_default() + .contains("/invitations/")); + + let (status, list) = app + .call( + Some(TEAM), + "GET", + &format!("/project/{project}/invitations"), + None, + ) + .await; + assert_eq!(status, 200, "{list}"); + let emails: Vec<&str> = list["list"] + .as_array() + .unwrap() + .iter() + .filter_map(|i| i["email"].as_str()) + .collect(); + assert_eq!(emails, vec!["colleague@try.direct"]); + // The owner's list never carries the token or its hash. + assert!(!list.to_string().contains("token"), "{list}"); + + assert_eq!(app.seats(TEAM, project).await, (2, 5)); +} + +#[tokio::test] +async fn owners_below_the_team_plan_cannot_invite() { + let Some(app) = App::spawn().await else { + return; + }; + for owner in [FREE, STARTER, PRO] { + let project = app.project(owner).await; + let (status, body) = app.invite(owner, project, COLLEAGUE.email).await; + assert_eq!(status, 403, "{} got {status} {body}", owner.email); + assert_eq!(message(&body), "plan_required", "{}", owner.email); + assert_eq!(app.invitation_count(project).await, 0, "{}", owner.email); + } +} + +#[tokio::test] +async fn the_fifth_seat_cannot_be_filled() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + app.add_member(project, "m1", "member").await; + app.add_member(project, "m2", "member").await; + app.invite_token(TEAM, project, "p1@try.direct").await; + app.invite_token(TEAM, project, "p2@try.direct").await; + assert_eq!(app.seats(TEAM, project).await, (5, 5)); + + let (status, body) = app.invite(TEAM, project, "one-more@try.direct").await; + assert_eq!(status, 409, "{body}"); + assert_eq!(message(&body), "seat_limit"); +} + +#[tokio::test] +async fn an_expired_invitation_frees_its_seat() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + app.add_member(project, "m1", "member").await; + app.add_member(project, "m2", "member").await; + app.invite_token(TEAM, project, "p1@try.direct").await; + app.invite_token(TEAM, project, "p2@try.direct").await; + sqlx::query( + "UPDATE project_invitation SET expires_at = NOW() - INTERVAL '1 minute' WHERE email = 'p2@try.direct'", + ) + .execute(&app.pool) + .await + .unwrap(); + + assert_eq!(app.seats(TEAM, project).await, (4, 5)); + let (status, body) = app.invite(TEAM, project, "one-more@try.direct").await; + assert!(ok(status), "{status} {body}"); + // The expired one can be invited again too. + let (status, body) = app.invite(TEAM, project, "p2@try.direct").await; + assert_eq!(status, 409, "seats are full again: {body}"); + assert_eq!(message(&body), "seat_limit"); +} + +#[tokio::test] +async fn adding_a_member_directly_by_user_id_obeys_the_same_plan_and_seat_rules() { + let Some(app) = App::spawn().await else { + return; + }; + + let full = app.project(TEAM).await; + app.add_member(full, "m1", "member").await; + app.add_member(full, "m2", "member").await; + app.invite_token(TEAM, full, "p1@try.direct").await; + app.invite_token(TEAM, full, "p2@try.direct").await; + let (status, body) = app + .call( + Some(TEAM), + "POST", + &format!("/project/{full}/members"), + Some(json!({ "user_id": OTHER.id, "role": "member" })), + ) + .await; + assert_eq!(status, 409, "{body}"); + assert_eq!(message(&body), "seat_limit"); + + let pro_project = app.project(PRO).await; + let (status, body) = app + .call( + Some(PRO), + "POST", + &format!("/project/{pro_project}/members"), + Some(json!({ "user_id": OTHER.id, "role": "viewer" })), + ) + .await; + assert_eq!(status, 403, "{body}"); + assert_eq!(message(&body), "plan_required"); + assert_eq!(app.member_role(pro_project, OTHER).await, None); + + // A Team owner with free seats can add a member directly, as member or viewer. + let open = app.project(TEAM).await; + for (who, role) in [(OTHER, "member"), (COLLEAGUE, "viewer")] { + let (status, body) = app + .call( + Some(TEAM), + "POST", + &format!("/project/{open}/members"), + Some(json!({ "user_id": who.id, "role": role })), + ) + .await; + assert!(ok(status), "{role}: {status} {body}"); + assert_eq!(app.member_role(open, who).await.as_deref(), Some(role)); + } + // The owner is not a member of their own project. + let (status, _) = app + .call( + Some(TEAM), + "POST", + &format!("/project/{open}/members"), + Some(json!({ "user_id": TEAM.id, "role": "member" })), + ) + .await; + assert_eq!(status, 409); +} + +#[tokio::test] +async fn the_owner_cannot_invite_themselves_an_existing_member_or_an_already_invited_email() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + app.add_member(project, COLLEAGUE.id, "member").await; + app.invite_token(TEAM, project, OTHER.email).await; + + let (status, body) = app.invite(TEAM, project, "TEAM@try.direct").await; + assert_eq!(status, 409, "self: {body}"); + let (status, body) = app.invite(TEAM, project, "other@TRY.direct").await; + assert_eq!(status, 409, "already invited: {body}"); + assert_eq!(message(&body), "already_invited"); + + // Members are stored by user id, not email, so an invitation to a + // member's address can be created; accepting it is refused. + let token = app.invite_token(TEAM, project, COLLEAGUE.email).await; + let (status, body) = app.accept(COLLEAGUE, &token).await; + assert_eq!(status, 409, "{body}"); + assert_eq!(message(&body), "already_member"); + assert_eq!( + app.member_role(project, COLLEAGUE).await.as_deref(), + Some("member") + ); + + let (status, _) = app.invite(TEAM, project, "not-an-email").await; + assert_eq!(status, 400); + let (status, _) = app + .call( + Some(TEAM), + "POST", + &format!("/project/{project}/invitations"), + Some(json!({ "email": "x@try.direct", "role": "owner" })), + ) + .await; + assert_eq!(status, 400, "only member and viewer can be invited"); +} + +#[tokio::test] +async fn only_the_owner_manages_invitations() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + app.add_member(project, COLLEAGUE.id, "member").await; + app.invite_token(TEAM, project, "someone@try.direct").await; + + let (status, body) = app.invite(COLLEAGUE, project, "friend@try.direct").await; + assert_eq!(status, 403, "member invite: {body}"); + for route in ["invitations", "seats", "members"] { + let (status, _) = app + .call( + Some(COLLEAGUE), + "GET", + &format!("/project/{project}/{route}"), + None, + ) + .await; + assert_eq!(status, 403, "member GET {route}"); + } + + // Someone with no access gets the same answer as for a missing project. + let (status, _) = app.invite(PRO, project, "friend@try.direct").await; + assert_eq!(status, 404); + let (status, _) = app.invite(PRO, 999_999, "friend@try.direct").await; + assert_eq!(status, 404); +} + +// ── Accepting ── + +#[tokio::test] +async fn an_existing_user_accepts_an_invitation_and_becomes_a_member() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + let token = app.invite_token(TEAM, project, COLLEAGUE.email).await; + + let (status, body) = app.accept(COLLEAGUE, &token).await; + assert!(ok(status), "{status} {body}"); + assert_eq!(body["item"]["project_id"], project); + assert_eq!( + app.member_role(project, COLLEAGUE).await.as_deref(), + Some("member") + ); + + let (_, list) = app + .call( + Some(TEAM), + "GET", + &format!("/project/{project}/invitations"), + None, + ) + .await; + assert_eq!(list["list"].as_array().map(Vec::len), Some(0), "{list}"); + + let (status, shared) = app + .call(Some(COLLEAGUE), "GET", "/project/shared", None) + .await; + assert_eq!(status, 200); + let shared_here: Vec<&Value> = shared["list"] + .as_array() + .unwrap() + .iter() + .filter(|p| p["id"].as_i64() == Some(project as i64)) + .collect(); + assert_eq!(shared_here.len(), 1, "{shared}"); + assert_eq!(shared_here[0]["role"], "member"); +} + +#[tokio::test] +async fn someone_without_an_account_signs_up_from_the_invitation_and_comes_back_to_accept_it() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + let token = app.invite_token(TEAM, project, COLLEAGUE.email).await; + + // Anonymous: the invitation page can show what it is without signing in. + let (status, body) = app + .call(None, "GET", &format!("/project/invitations/{token}"), None) + .await; + assert_eq!(status, 200, "{body}"); + assert_eq!(body["item"]["project_name"], "Test Project"); + assert_eq!(body["item"]["invited_by"], TEAM.email); + assert_eq!(body["item"]["status"], "pending"); + // The invited address is masked: the link alone must not reveal it. + let shown = body["item"]["email"].as_str().unwrap_or_default(); + assert!( + shown.contains('*') && !shown.contains("colleague"), + "{shown}" + ); + assert!(!body.to_string().contains("token"), "{body}"); + + // A made-up token, or an invitation id in place of a token, is a plain 404 + // for anonymous callers. + let (status, _) = app + .call(None, "GET", "/project/invitations/not-a-real-token", None) + .await; + assert_eq!(status, 404); + let invitation_id = app.first_invitation_id(TEAM, project).await; + let (status, _) = app + .call( + None, + "GET", + &format!("/project/invitations/{invitation_id}"), + None, + ) + .await; + assert_eq!(status, 404); + + // Accepting needs a signed-in account. + let (status, _) = app + .call( + None, + "POST", + &format!("/project/invitations/{token}/accept"), + None, + ) + .await; + assert!(status == 401 || status == 403, "anonymous accept: {status}"); + + // After signing up and confirming the email, accepting works. + let (status, body) = app.accept(COLLEAGUE, &token).await; + assert!(ok(status), "{status} {body}"); + assert_eq!( + app.member_role(project, COLLEAGUE).await.as_deref(), + Some("member") + ); +} + +#[tokio::test] +async fn a_user_who_lost_the_invitation_link_sees_pending_invitations_after_signing_in() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + app.invite_token(TEAM, project, "COLLEAGUE@try.direct") + .await; + let other_project = app.project(TEAM).await; + app.invite_token(TEAM, other_project, OTHER.email).await; + + let (status, body) = app + .call(Some(COLLEAGUE), "GET", "/project/invitations/pending", None) + .await; + assert_eq!(status, 200, "{body}"); + let items = body["list"].as_array().unwrap(); + assert_eq!(items.len(), 1, "{body}"); + assert_eq!(items[0]["project_id"], project); + assert_eq!(items[0]["project_name"], "Test Project"); + assert_eq!(items[0]["invited_by"], TEAM.email); + + // The list carries the invitation id, and the invitee can accept by it: + // the email check is what protects acceptance, not knowing the token. + let id = items[0]["id"].as_str().unwrap().to_string(); + let (status, body) = app.accept(COLLEAGUE, &id).await; + assert!(ok(status), "{status} {body}"); + assert_eq!( + app.member_role(project, COLLEAGUE).await.as_deref(), + Some("member") + ); + + // Someone else cannot accept OTHER's invitation by its id either. + let (_, pending) = app + .call(Some(OTHER), "GET", "/project/invitations/pending", None) + .await; + let other_id = pending["list"][0]["id"].as_str().unwrap().to_string(); + let (status, _) = app.accept(COLLEAGUE, &other_id).await; + assert_eq!(status, 403); +} + +#[tokio::test] +async fn an_invitation_can_only_be_accepted_by_the_invited_email() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + let token = app.invite_token(TEAM, project, COLLEAGUE.email).await; + + let (status, body) = app.accept(PRO, &token).await; + assert_eq!(status, 403, "{body}"); + assert_eq!(app.member_role(project, PRO).await, None); + + // The invitation is still there for the right person. + let (status, _) = app.accept(COLLEAGUE, &token).await; + assert!(ok(status)); +} + +#[tokio::test] +async fn an_account_with_an_unconfirmed_email_cannot_accept() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + let token = app.invite_token(TEAM, project, COLLEAGUE.email).await; + + let (status, body) = app.accept(COLLEAGUE_UNCONFIRMED, &token).await; + assert_eq!(status, 403, "{body}"); + assert_eq!(message(&body), "email_not_confirmed"); + assert_eq!(app.member_role(project, COLLEAGUE).await, None); +} + +#[tokio::test] +async fn expired_revoked_and_used_invitations_cannot_be_accepted() { + let Some(app) = App::spawn().await else { + return; + }; + + // older than 7 days + let project = app.project(TEAM).await; + let token = app.invite_token(TEAM, project, COLLEAGUE.email).await; + let days: f64 = sqlx::query_scalar( + "SELECT (EXTRACT(EPOCH FROM (expires_at - created_at)) / 86400)::FLOAT8 FROM project_invitation WHERE project_id = $1", + ) + .bind(project) + .fetch_one(&app.pool) + .await + .unwrap(); + assert!( + (days - 7.0).abs() < 0.01, + "invitations live 7 days, got {days}" + ); + sqlx::query("UPDATE project_invitation SET expires_at = NOW() - INTERVAL '1 second' WHERE project_id = $1") + .bind(project) + .execute(&app.pool) + .await + .unwrap(); + let (status, body) = app.accept(COLLEAGUE, &token).await; + assert_eq!(status, 410, "expired: {body}"); + assert_eq!(app.member_role(project, COLLEAGUE).await, None); + + // revoked by the owner + let project = app.project(TEAM).await; + let token = app.invite_token(TEAM, project, COLLEAGUE.email).await; + let invitation_id = app.first_invitation_id(TEAM, project).await; + let (status, _) = app + .call( + Some(TEAM), + "DELETE", + &format!("/project/{project}/invitations/{invitation_id}"), + None, + ) + .await; + assert_eq!(status, 204); + let (status, body) = app.accept(COLLEAGUE, &token).await; + assert_eq!(status, 410, "revoked: {body}"); + assert_eq!(app.member_role(project, COLLEAGUE).await, None); + + // already accepted + let project = app.project(TEAM).await; + let token = app.invite_token(TEAM, project, COLLEAGUE.email).await; + let (status, _) = app.accept(COLLEAGUE, &token).await; + assert!(ok(status)); + let (status, body) = app.accept(COLLEAGUE, &token).await; + assert_eq!(status, 410, "used: {body}"); +} + +#[tokio::test] +async fn only_the_owner_can_revoke_and_only_their_own_projects_invitations() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + app.invite_token(TEAM, project, COLLEAGUE.email).await; + let invitation_id = app.first_invitation_id(TEAM, project).await; + + // Another owner, using their own project id with this invitation id. + let other = app.project(PRO).await; + let (status, _) = app + .call( + Some(PRO), + "DELETE", + &format!("/project/{other}/invitations/{invitation_id}"), + None, + ) + .await; + assert_eq!(status, 404); + let (status, _) = app + .call( + Some(PRO), + "DELETE", + &format!("/project/{project}/invitations/{invitation_id}"), + None, + ) + .await; + assert_eq!(status, 404); + assert_eq!(app.seats(TEAM, project).await, (2, 5)); +} + +// ── Removing access and plan changes ── + +#[tokio::test] +async fn removing_a_member_or_revoking_an_invitation_takes_effect_at_once() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + let token = app.invite_token(TEAM, project, COLLEAGUE.email).await; + let (status, _) = app.accept(COLLEAGUE, &token).await; + assert!(ok(status)); + let other_token = app.invite_token(TEAM, project, OTHER.email).await; + assert_eq!(app.seats(TEAM, project).await, (3, 5)); + + let (status, _) = app + .call( + Some(TEAM), + "DELETE", + &format!("/project/{project}/members/{}", COLLEAGUE.id), + None, + ) + .await; + assert_eq!(status, 204); + let invitation_id = app.first_invitation_id(TEAM, project).await; + let (status, _) = app + .call( + Some(TEAM), + "DELETE", + &format!("/project/{project}/invitations/{invitation_id}"), + None, + ) + .await; + assert_eq!(status, 204); + + let (status, _) = app + .call(Some(COLLEAGUE), "GET", &format!("/project/{project}"), None) + .await; + assert_eq!(status, 404); + let (status, _) = app.accept(OTHER, &other_token).await; + assert_eq!(status, 410); + assert_eq!(app.seats(TEAM, project).await, (1, 5)); +} + +#[tokio::test] +async fn when_the_owner_leaves_the_team_plan_members_keep_access_but_no_one_new_can_be_invited() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + let token = app.invite_token(TEAM, project, COLLEAGUE.email).await; + let pending_token = app.invite_token(TEAM, project, OTHER.email).await; + let (status, _) = app.accept(COLLEAGUE, &token).await; + assert!(ok(status)); + + app.set_plan(TEAM, "plan-plus-monthly"); + + assert_eq!( + app.member_role(project, COLLEAGUE).await.as_deref(), + Some("member") + ); + let (status, body) = app.invite(TEAM, project, "friend@try.direct").await; + assert_eq!(status, 403, "{body}"); + assert_eq!(message(&body), "plan_required"); + // An invitation sent while on Team still works: the plan is checked when + // inviting, not when accepting. + let (status, body) = app.accept(OTHER, &pending_token).await; + assert!(ok(status), "{status} {body}"); +} + +#[tokio::test] +async fn a_plan_check_that_cannot_reach_the_user_service_refuses_the_invitation() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + app.user_service.reset().await; + Mock::given(method("GET")) + .and(path("/oauth_server/api/me")) + .respond_with(ResponseTemplate::new(502)) + .mount(&app.user_service) + .await; + + let (status, body) = app.invite(TEAM, project, COLLEAGUE.email).await; + assert!( + status >= 500, + "an unknown plan must not be treated as Team: {status} {body}" + ); + assert_eq!(app.invitation_count(project).await, 0); +} + +// ── Working as a member ── + +fn deploy_body() -> Value { + json!({ + "stack": { + "vars": [], + "integrated_features": [], + "extended_features": [], + "subscriptions": [], + "form_app": [] + }, + "cloud": { "provider": "aws", "save_token": false }, + "server": { "region": "us-east-1", "server": "t3.small", "os": "ubuntu-24.04", "disk_type": "gp3" } + }) +} + +async fn mount_owner_lookup(app: &App) { + Mock::given(method("GET")) + .and(path(format!("/api/internal/users/{}", TEAM.id))) + .respond_with( + ResponseTemplate::new(200) + .set_body_json(json!({ "email": TEAM.email, "email_confirmed": true })), + ) + .mount(&app.user_service) + .await; +} + +async fn owners_of(app: &App, table: &str, project_id: i32) -> Vec { + sqlx::query_scalar::<_, String>(&format!( + "SELECT user_id FROM {table} WHERE project_id = $1" + )) + .bind(project_id) + .fetch_all(&app.pool) + .await + .unwrap() +} + +#[tokio::test] +async fn a_member_deploys_with_the_owners_saved_cloud_and_the_deployment_belongs_to_the_owner() { + std::env::set_var("INTERNAL_SERVICES_ACCESS_KEY", "test-internal-key"); + let Some(app) = App::spawn().await else { + return; + }; + mount_owner_lookup(&app).await; + let project = app.project(TEAM).await; + let cloud = common::create_test_cloud(&app.pool, TEAM.id, "acme-aws", "aws").await; + app.add_member(project, COLLEAGUE.id, "member").await; + + let (status, body) = app + .call( + Some(COLLEAGUE), + "POST", + &format!("/project/{project}/deploy/{cloud}"), + Some(deploy_body()), + ) + .await; + let text = body.to_string(); + assert!( + status != 403 && status != 404 && !text.contains("No cloud configured"), + "the member must be able to use the owner's saved cloud: {status} {text}" + ); + // The saved credentials never come back to the member. + assert!(!text.contains("test-cloud-token"), "{text}"); + + let servers = owners_of(&app, "server", project).await; + assert!(!servers.is_empty(), "no server recorded: {status} {text}"); + assert!( + servers.iter().all(|owner| owner == TEAM.id), + "servers must be recorded under the owner: {servers:?}" + ); + let deployments = owners_of(&app, "deployment", project).await; + assert!( + deployments.iter().all(|owner| owner == TEAM.id), + "deployments must be recorded under the owner: {deployments:?}" + ); + + // The member still cannot use a cloud that is not the owner's. + let own_cloud = common::create_test_cloud(&app.pool, COLLEAGUE.id, "mine", "aws").await; + let (status, body) = app + .call( + Some(COLLEAGUE), + "POST", + &format!("/project/{project}/deploy/{own_cloud}"), + Some(deploy_body()), + ) + .await; + assert_eq!(status, 404, "{body}"); +} + +#[tokio::test] +async fn a_member_deploy_is_refused_when_the_owner_cannot_be_looked_up() { + std::env::set_var("INTERNAL_SERVICES_ACCESS_KEY", "test-internal-key"); + let Some(app) = App::spawn().await else { + return; + }; + // No owner lookup mounted: the User Service answers 404 for it. + let project = app.project(TEAM).await; + let cloud = common::create_test_cloud(&app.pool, TEAM.id, "acme-aws", "aws").await; + app.add_member(project, COLLEAGUE.id, "member").await; + + let (status, body) = app + .call( + Some(COLLEAGUE), + "POST", + &format!("/project/{project}/deploy/{cloud}"), + Some(deploy_body()), + ) + .await; + assert_eq!(status, 502, "{body}"); + assert_eq!(message(&body), "owner_unavailable"); + assert!(owners_of(&app, "server", project).await.is_empty()); +} + +#[tokio::test] +async fn a_viewer_cannot_deploy() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + let cloud = common::create_test_cloud(&app.pool, TEAM.id, "acme-aws", "aws").await; + app.add_member(project, COLLEAGUE.id, "viewer").await; + + let (status, _) = app + .call( + Some(COLLEAGUE), + "POST", + &format!("/project/{project}/deploy/{cloud}"), + Some(deploy_body()), + ) + .await; + assert_eq!(status, 403); + assert!(owners_of(&app, "server", project).await.is_empty()); +} + +// ── Commands on the project's deployments ── + +/// The owner's deployment of `project`, with one queued command of theirs. +async fn owner_deployment_with_command(app: &App, project: i32) -> (String, String) { + let hash = format!("team-{}", uuid::Uuid::new_v4()); + common::create_test_deployment(&app.pool, TEAM.id, project, &hash).await; + let command_id = format!("cmd_{}", uuid::Uuid::new_v4()); + sqlx::query( + r#"INSERT INTO commands (id, command_id, deployment_hash, type, status, priority, created_by, created_at, updated_at) + VALUES (gen_random_uuid(), $1, $2, 'health', 'queued', 'normal', $3, NOW(), NOW())"#, + ) + .bind(&command_id) + .bind(&hash) + .bind(TEAM.id) + .execute(&app.pool) + .await + .expect("Failed to insert command"); + (hash, command_id) +} + +fn health_command(hash: &str) -> Value { + json!({ "deployment_hash": hash, "command_type": "health", "parameters": { "app_code": "all" } }) +} + +#[tokio::test] +async fn a_member_runs_commands_on_the_projects_deployments() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + app.add_member(project, COLLEAGUE.id, "member").await; + let (hash, owner_command) = owner_deployment_with_command(&app, project).await; + + let (status, body) = app + .call( + Some(COLLEAGUE), + "POST", + "/api/v1/commands", + Some(health_command(&hash)), + ) + .await; + assert!(ok(status), "member queues a command: {status} {body}"); + let queued_by: Vec = sqlx::query_scalar( + "SELECT created_by FROM commands WHERE deployment_hash = $1 ORDER BY created_at", + ) + .bind(&hash) + .fetch_all(&app.pool) + .await + .unwrap(); + // The audit trail says who asked for it. + assert_eq!( + queued_by, + vec![TEAM.id.to_string(), COLLEAGUE.id.to_string()] + ); + + let (status, body) = app + .call( + Some(COLLEAGUE), + "GET", + &format!("/api/v1/commands/{hash}"), + None, + ) + .await; + assert_eq!(status, 200, "member lists commands: {body}"); + let (status, body) = app + .call( + Some(COLLEAGUE), + "GET", + &format!("/api/v1/commands/{hash}/{owner_command}"), + None, + ) + .await; + assert_eq!(status, 200, "member reads a command: {body}"); + let (status, body) = app + .call( + Some(COLLEAGUE), + "POST", + &format!("/api/v1/commands/{hash}/{owner_command}/cancel"), + None, + ) + .await; + assert_eq!(status, 200, "member cancels a command: {body}"); +} + +#[tokio::test] +async fn a_viewer_cannot_run_or_read_commands_and_an_outsider_gets_not_found() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + app.add_member(project, COLLEAGUE.id, "viewer").await; + let (hash, owner_command) = owner_deployment_with_command(&app, project).await; + + for (who, expected) in [(COLLEAGUE, 403), (PRO, 404)] { + let (status, _) = app + .call( + Some(who), + "POST", + "/api/v1/commands", + Some(health_command(&hash)), + ) + .await; + assert_eq!(status, expected, "{} create", who.email); + let (status, _) = app + .call(Some(who), "GET", &format!("/api/v1/commands/{hash}"), None) + .await; + assert_eq!(status, expected, "{} list", who.email); + let (status, _) = app + .call( + Some(who), + "GET", + &format!("/api/v1/commands/{hash}/{owner_command}"), + None, + ) + .await; + assert_eq!(status, expected, "{} get", who.email); + let (status, _) = app + .call( + Some(who), + "POST", + &format!("/api/v1/commands/{hash}/{owner_command}/cancel"), + None, + ) + .await; + assert_eq!(status, expected, "{} cancel", who.email); + } + let count: i64 = sqlx::query_scalar("SELECT COUNT(*) FROM commands WHERE deployment_hash = $1") + .bind(&hash) + .fetch_one(&app.pool) + .await + .unwrap(); + assert_eq!(count, 1, "nothing was queued"); + let status: String = sqlx::query_scalar("SELECT status FROM commands WHERE command_id = $1") + .bind(&owner_command) + .fetch_one(&app.pool) + .await + .unwrap(); + assert_eq!(status, "queued", "nothing was cancelled"); +} + +#[tokio::test] +async fn a_member_can_point_deploy_app_at_the_shared_project() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + app.add_member(project, COLLEAGUE.id, "member").await; + let (hash, _) = owner_deployment_with_command(&app, project).await; + let other = app.project(PRO).await; + + // Into the shared project: allowed past the project check. + let (status, body) = app + .call( + Some(COLLEAGUE), + "POST", + "/api/v1/commands", + Some(json!({ + "deployment_hash": hash, + "command_type": "deploy_app", + "parameters": { "app_code": "web", "deployment_id": project } + })), + ) + .await; + assert!( + status != 403 && status != 404, + "the shared project must be accepted: {status} {body}" + ); + + // Into someone else's project: refused. + let (status, body) = app + .call( + Some(COLLEAGUE), + "POST", + "/api/v1/commands", + Some(json!({ + "deployment_hash": hash, + "command_type": "deploy_app", + "parameters": { "app_code": "web", "deployment_id": other } + })), + ) + .await; + assert_eq!(status, 404, "{body}"); +} + +// ── Member list for the owner ── + +#[tokio::test] +async fn the_owner_sees_who_the_members_are() { + let Some(app) = App::spawn().await else { + return; + }; + let project = app.project(TEAM).await; + let token = app + .invite_token(TEAM, project, "Colleague@Try.Direct") + .await; + let (status, _) = app.accept(COLLEAGUE, &token).await; + assert!(ok(status)); + // Added by id, before invitations existed: no address known. + app.add_member(project, "legacy_viewer_id", "viewer").await; + + let (status, body) = app + .call( + Some(TEAM), + "GET", + &format!("/project/{project}/members"), + None, + ) + .await; + assert_eq!(status, 200, "{body}"); + let members = body["list"].as_array().unwrap(); + let email_of = |id: &str| { + members + .iter() + .find(|m| m["user_id"] == id) + .unwrap_or_else(|| panic!("{id} not listed: {body}"))["email"] + .clone() + }; + assert_eq!(email_of(COLLEAGUE.id), json!("colleague@try.direct")); + assert_eq!(email_of("legacy_viewer_id"), Value::Null); +} diff --git a/tests/security_project_routes.rs b/tests/security_project_routes.rs index 09363785..6b7fb152 100644 --- a/tests/security_project_routes.rs +++ b/tests/security_project_routes.rs @@ -54,15 +54,25 @@ const ROUTES: &[(&str, &str)] = &[ ("PUT", "/{id}"), ("PUT", "/{id}/sync"), ("PATCH", "/{id}/protection"), + ("GET", "/{id}/invitations"), + ("POST", "/{id}/invitations"), + ("GET", "/{id}/seats"), // Destructive ones last, so a missing check cannot hide the others. ("DELETE", "/{id}/apps/{code}"), + ("DELETE", "/{id}/invitations/{invitation_id}"), ("DELETE", "/{id}"), ]; +/// A pending invitation on User A's project, so revoking has a real target. +const SWEEP_INVITATION_ID: &str = "5eed0000-0000-4000-8000-000000000001"; + fn body_for(method: &str, path: &str) -> Option { use serde_json::json; let body = match (method, path) { ("POST", "/{id}/members") => json!({ "user_id": common::USER_B_ID, "role": "viewer" }), + ("POST", "/{id}/invitations") => { + json!({ "email": "intruder@example.com", "role": "member" }) + } ("POST", "/{id}/apps") => json!({ "code": "intruder", "image": "nginx:alpine" }), ("PUT", "/{id}/apps/{code}/env") => json!({ "variables": { "INJECTED": "1" } }), ("PUT", "/{id}/apps/{code}/ports") => { @@ -107,6 +117,7 @@ fn fill(path: &str, project_id: i32, cloud_id: i32) -> String { .replace("{cloud_id}", &cloud_id.to_string()) .replace("{code}", APP_CODE) .replace("{member_user_id}", common::USER_B_ID) + .replace("{invitation_id}", SWEEP_INVITATION_ID) .replace("{name}", "SOME_NAME"); if path.ends_with("/containers/discover") || path == "/{id}/apps/{code}" { url.push_str(&format!("?deployment_hash={}", DEPLOYMENT_HASH)); @@ -159,6 +170,18 @@ async fn other_user_gets_the_same_answer_as_for_a_missing_project() { }; common::create_test_deployment(pool, common::USER_A_ID, project_id, DEPLOYMENT_HASH).await; let cloud_id = common::create_test_cloud(pool, common::USER_A_ID, "a-sweep", "aws").await; + sqlx::query( + r#"INSERT INTO project_invitation + (id, project_id, email, role, token_hash, invited_by, invited_by_email, expires_at) + VALUES ($1::uuid, $2, 'invitee@example.com', 'member', 'sweep-token-hash', $3, + 'a@example.com', NOW() + INTERVAL '7 days')"#, + ) + .bind(SWEEP_INVITATION_ID) + .bind(project_id) + .bind(common::USER_A_ID) + .execute(pool) + .await + .expect("Failed to insert invitation"); let client = reqwest::Client::new(); let created = client @@ -225,6 +248,19 @@ async fn other_user_gets_the_same_answer_as_for_a_missing_project() { if members.0 != 0 { failures.push("DATA User B added a member to User A's project".to_string()); } + let invitations: Vec<(String, String)> = sqlx::query_as( + "SELECT email, status FROM project_invitation WHERE project_id = $1 ORDER BY email", + ) + .bind(project_id) + .fetch_all(pool) + .await + .unwrap(); + if invitations != vec![("invitee@example.com".to_string(), "pending".to_string())] { + failures.push(format!( + "DATA User B changed User A's invitations: {:?}", + invitations + )); + } assert!( failures.is_empty(), @@ -312,3 +348,249 @@ fn every_project_route_is_in_the_sweep() { stale ); } + +// ── Team roles ── +// +// A project member works on the stack like the owner; a viewer only sees the +// project in their shared list and its deployments. Owner-only: secrets, +// deletion, protection, members, invitations and seats. + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +enum Need { + /// Read the stack (payload, compose, apps, env): owner and member. + View, + /// Change or deploy the stack: owner and member. + Manage, + /// Owner only. + Owner, +} + +/// The role each project route needs. Every entry of ROUTES must be here. +const NEEDS: &[(&str, &str, Need)] = &[ + ("GET", "/{id}", Need::View), + ("GET", "/{id}/compose", Need::View), + ("GET", "/{id}/members", Need::Owner), + ("POST", "/{id}/members", Need::Owner), + ("DELETE", "/{id}/members/{member_user_id}", Need::Owner), + ("GET", "/{id}/apps", Need::View), + ("POST", "/{id}/apps", Need::Manage), + ("GET", "/{id}/apps/{code}", Need::View), + ("GET", "/{id}/apps/{code}/config", Need::View), + ("GET", "/{id}/apps/{code}/env", Need::View), + ("PUT", "/{id}/apps/{code}/env", Need::Manage), + ("DELETE", "/{id}/apps/{code}/env/{name}", Need::Manage), + ("PUT", "/{id}/apps/{code}/ports", Need::Manage), + ("PUT", "/{id}/apps/{code}/domain", Need::Manage), + ("GET", "/{id}/apps/{code}/secrets", Need::Owner), + ("GET", "/{id}/apps/{code}/secrets/{name}", Need::Owner), + ("PUT", "/{id}/apps/{code}/secrets/{name}", Need::Owner), + ("DELETE", "/{id}/apps/{code}/secrets/{name}", Need::Owner), + ("GET", "/{id}/containers/discover", Need::View), + ("POST", "/{id}/containers/import", Need::Manage), + ("POST", "/{id}/deploy", Need::Manage), + ("POST", "/{id}/deploy/{cloud_id}", Need::Manage), + ("POST", "/{id}/rollback", Need::Manage), + ("PUT", "/{id}", Need::Manage), + ("PUT", "/{id}/sync", Need::Manage), + ("PATCH", "/{id}/protection", Need::Owner), + ("GET", "/{id}/invitations", Need::Owner), + ("POST", "/{id}/invitations", Need::Owner), + ("GET", "/{id}/seats", Need::Owner), + ("DELETE", "/{id}/apps/{code}", Need::Manage), + ("DELETE", "/{id}/invitations/{invitation_id}", Need::Owner), + ("DELETE", "/{id}", Need::Owner), +]; + +#[test] +fn every_project_route_has_a_role() { + let classified: BTreeSet<(&str, &str)> = NEEDS.iter().map(|(m, p, _)| (*m, *p)).collect(); + let missing: Vec<_> = ROUTES + .iter() + .filter(|(m, p)| !classified.contains(&(*m, *p))) + .collect(); + assert!( + missing.is_empty(), + "project routes with no role in NEEDS: {missing:?}" + ); +} + +/// User A's project with an app, a deployment (by `hash`) and a pending +/// invitation, like the IDOR sweep's fixture. +async fn role_fixture(app: &common::TwoUserTestApp, hash: &str) -> i32 { + use sqlx::Row; + let pool = &app.db_pool; + let project_id = sqlx::query( + r#"INSERT INTO project (stack_id, user_id, name, metadata, request_json, created_at, updated_at) + VALUES (gen_random_uuid(), $1, 'Role Project', '{}'::jsonb, $2, NOW(), NOW()) + RETURNING id"#, + ) + .bind(common::USER_A_ID) + .bind(serde_json::json!({ "custom": { "locked_cloud_provider": "htz" } })) + .fetch_one(pool) + .await + .expect("Failed to insert project") + .get::("id"); + common::create_test_deployment(pool, common::USER_A_ID, project_id, hash).await; + sqlx::query( + r#"INSERT INTO project_invitation + (id, project_id, email, role, token_hash, invited_by, invited_by_email, expires_at) + VALUES (gen_random_uuid(), $1, 'invitee@example.com', 'member', $2, $3, + 'a@example.com', NOW() + INTERVAL '7 days')"#, + ) + .bind(project_id) + .bind(format!("role-token-{hash}")) + .bind(common::USER_A_ID) + .execute(pool) + .await + .unwrap(); + let created = reqwest::Client::new() + .post(format!("{}/project/{}/apps", &app.address, project_id)) + .header("Authorization", format!("Bearer {}", common::USER_A_TOKEN)) + .json(&serde_json::json!({ "code": APP_CODE, "image": "nginx:alpine" })) + .send() + .await + .expect("request failed"); + assert!( + created.status().is_success(), + "fixture app: {}", + created.status() + ); + project_id +} + +#[allow(clippy::too_many_arguments)] +async fn call_as( + client: &reqwest::Client, + base: &str, + token: &str, + method: &str, + path: &str, + project_id: i32, + cloud_id: i32, + hash: &str, +) -> (u16, String) { + let url = format!( + "{}{}", + base, + fill(path, project_id, cloud_id).replace(DEPLOYMENT_HASH, hash) + ); + let mut req = client + .request(reqwest::Method::from_bytes(method.as_bytes()).unwrap(), url) + .header("Authorization", format!("Bearer {token}")); + if let Some(body) = body_for(method, path) { + req = req.json(&body); + } + let resp = req.send().await.expect("request failed"); + let status = resp.status().as_u16(); + (status, resp.text().await.unwrap_or_default()) +} + +#[tokio::test] +async fn members_and_viewers_get_what_their_role_allows() { + let Some(app) = common::spawn_app_two_users_on_team_plan().await else { + return; + }; + let pool = &app.db_pool; + let client = reqwest::Client::builder() + .pool_max_idle_per_host(0) + .build() + .unwrap(); + let base = format!("{}/project", &app.address); + let cloud_id = common::create_test_cloud(pool, common::USER_A_ID, "a-roles", "aws").await; + let mut failures = Vec::new(); + + // A viewer may not read or change anything in the stack: 403 everywhere. + let viewed = role_fixture(&app, "role-viewer").await; + sqlx::query("INSERT INTO project_member (project_id, user_id, role, created_by) VALUES ($1, $2, 'viewer', $3)") + .bind(viewed) + .bind(common::USER_B_ID) + .bind(common::USER_A_ID) + .execute(pool) + .await + .unwrap(); + for (method, path) in ROUTES { + let (status, body) = call_as( + &client, + &base, + common::USER_B_TOKEN, + method, + path, + viewed, + cloud_id, + "role-viewer", + ) + .await; + if status != 403 { + failures.push(format!( + "VIEWER {method} {path}: expected 403, got {status} {body}" + )); + } + } + + // A member is treated exactly like the owner on stack routes: same status + // as the owner gets on an identical project. Owner-only routes are 403. + let mine = role_fixture(&app, "role-owner").await; + let shared = role_fixture(&app, "role-member").await; + sqlx::query("INSERT INTO project_member (project_id, user_id, role, created_by) VALUES ($1, $2, 'member', $3)") + .bind(shared) + .bind(common::USER_B_ID) + .bind(common::USER_A_ID) + .execute(pool) + .await + .unwrap(); + for (method, path, need) in NEEDS { + let (member_status, member_body) = call_as( + &client, + &base, + common::USER_B_TOKEN, + method, + path, + shared, + cloud_id, + "role-member", + ) + .await; + if *need == Need::Owner { + if member_status != 403 { + failures.push(format!( + "MEMBER {method} {path}: owner-only, expected 403, got {member_status} {member_body}" + )); + } + continue; + } + let (owner_status, owner_body) = call_as( + &client, + &base, + common::USER_A_TOKEN, + method, + path, + mine, + cloud_id, + "role-owner", + ) + .await; + if member_status != owner_status { + failures.push(format!( + "MEMBER {method} {path}: member got {member_status} {member_body}, \ + owner got {owner_status} {owner_body}" + )); + } + } + + // The owner-only refusals left the member's project intact. + let still: Option<(String,)> = sqlx::query_as("SELECT user_id FROM project WHERE id = $1") + .bind(shared) + .fetch_optional(pool) + .await + .unwrap(); + if still.map(|r| r.0) != Some(common::USER_A_ID.to_string()) { + failures.push("DATA the member deleted or took over the project".to_string()); + } + + assert!( + failures.is_empty(), + "{} role check(s) failed:\n{}", + failures.len(), + failures.join("\n") + ); +} diff --git a/tests/security_route_matrix.rs b/tests/security_route_matrix.rs index f11f38f7..c1931675 100644 --- a/tests/security_route_matrix.rs +++ b/tests/security_route_matrix.rs @@ -111,9 +111,45 @@ const ROUTES: &[(&str, &str, Kind, &str)] = &[ ("GET", "/project", Own, ""), ("POST", "/project", Own, ""), ("GET", "/project/shared", Own, ""), + ( + "GET", + "/project/invitations/pending", + Own, + "invitations to the caller's own email", + ), + ( + "POST", + "/project/invitations/{key}/accept", + Own, + "joins only if the caller's confirmed email is the invited one", + ), + ( + "GET", + "/project/invitations/{token}", + Token, + "invitation token from the email link; shows a masked address", + ), ("GET", "/api/v1/project", Own, ""), ("POST", "/api/v1/project", Own, ""), ("GET", "/api/v1/project/shared", Own, ""), + ( + "GET", + "/api/v1/project/invitations/pending", + Own, + "invitations to the caller's own email", + ), + ( + "POST", + "/api/v1/project/invitations/{key}/accept", + Own, + "joins only if the caller's confirmed email is the invited one", + ), + ( + "GET", + "/api/v1/project/invitations/{token}", + Token, + "invitation token from the email link; shows a masked address", + ), ("GET", "/cloud", Own, ""), ("POST", "/cloud", Own, ""), ("GET", "/server", Own, ""), diff --git a/tests/steps/common.rs b/tests/steps/common.rs index 9882add8..9d2a0a7f 100644 --- a/tests/steps/common.rs +++ b/tests/steps/common.rs @@ -38,6 +38,12 @@ pub async fn spawn_bdd_app() -> Option { "http://127.0.0.1:{}/me", auth_listener.local_addr().unwrap().port() ); + // The same mock answers the User Service plan lookup: sharing a project + // needs the owner on the Team plan. Every other User Service path is 404. + configuration.user_service_url = format!( + "http://127.0.0.1:{}", + auth_listener.local_addr().unwrap().port() + ); let _ = tokio::spawn(mock_auth_server(auth_listener)); tokio::time::sleep(std::time::Duration::from_millis(500)).await; @@ -209,13 +215,36 @@ async fn mock_auth(req: actix_web::HttpRequest) -> actix_web::Result actix_web::Result { + let is_user_b = req + .headers() + .get("Authorization") + .and_then(|v| v.to_str().ok()) + .unwrap_or("") + .contains("user-b"); + let code = if is_user_b { + "plan-free-periodically" + } else { + "plan-individual-monthly" + }; + Ok(web::Json( + serde_json::json!({ "user": { "plan": { "code": code } } }), + )) +} + async fn mock_auth_server(listener: TcpListener) { - HttpServer::new(|| App::new().service(web::scope("/me").service(mock_auth))) - .listen(listener) - .unwrap() - .run() - .await - .unwrap(); + HttpServer::new(|| { + App::new() + .service(web::scope("/me").service(mock_auth)) + .service(mock_plan) + }) + .listen(listener) + .unwrap() + .run() + .await + .unwrap(); } // ─── Test data helpers ───────────────────────────────────────────