From 2cffcc15cfc10b394fdda978f277365030168984 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Helgi=20P=C3=A1ll=20Helgason?= Date: Thu, 8 Oct 2026 22:22:52 +0000 Subject: [PATCH] fix(auth): close team-ownership takeover and cross-team datasource inserts - invite_team_member read the team with the caller's token. A non-member got nothing back, so the owner check was skipped and the member + any role (incl. owner) was inserted with the admin secret. The team is now read with the admin secret and the caller must be its owner or a portal admin. - create_team (the GraphQL action) made any caller OWNER of a new team with the default datasources. User-invoked calls now require a portal admin; the users-insert event trigger (no session) is unchanged. - datasources insert check was always true because user_id is preset to the caller, so anyone could insert a datasource into any team. A row must now belong to a team the caller is a member of, or be personal (team_id null). Co-Authored-By: Claude Opus 5.5 --- .../src/rpc/__tests__/teamAuthz.test.js | 65 +++++++++++++++++++ services/actions/src/rpc/createTeam.js | 18 +++++ services/actions/src/rpc/inviteTeamMember.js | 24 +++++-- services/hasura/metadata/tables.yaml | 7 +- 4 files changed, 107 insertions(+), 7 deletions(-) create mode 100644 services/actions/src/rpc/__tests__/teamAuthz.test.js diff --git a/services/actions/src/rpc/__tests__/teamAuthz.test.js b/services/actions/src/rpc/__tests__/teamAuthz.test.js new file mode 100644 index 00000000..c406fe1f --- /dev/null +++ b/services/actions/src/rpc/__tests__/teamAuthz.test.js @@ -0,0 +1,65 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { canInvite } from "../inviteTeamMember.js"; +import { canCreateTeam } from "../createTeam.js"; + +const OWNER = "11111111-1111-4111-8111-111111111111"; +const MEMBER = "22222222-2222-4222-8222-222222222222"; +const OUTSIDER = "33333333-3333-4333-8333-333333333333"; + +const team = { + id: "team-1", + members: [ + { id: "m1", user_id: OWNER, member_roles: [{ team_role: "owner" }] }, + { id: "m2", user_id: MEMBER, member_roles: [{ team_role: "member" }] }, + ], +}; + +const notAdmin = { portalAdmin: async () => false }; +const admin = { portalAdmin: async () => true }; + +describe("invite_team_member authorization", () => { + it("allows the team owner", async () => { + assert.equal(await canInvite({ team, userId: OWNER }, notAdmin), true); + }); + + it("refuses a plain member", async () => { + assert.equal(await canInvite({ team, userId: MEMBER }, notAdmin), false); + }); + + it("refuses a non-member (previously skipped the check)", async () => { + assert.equal(await canInvite({ team, userId: OUTSIDER }, notAdmin), false); + }); + + it("refuses when the team does not exist", async () => { + assert.equal(await canInvite({ team: null, userId: OWNER }, admin), false); + }); + + it("refuses a team with no members unless portal admin", async () => { + const empty = { id: "team-2", members: [] }; + assert.equal(await canInvite({ team: empty, userId: OUTSIDER }, notAdmin), false); + assert.equal(await canInvite({ team: empty, userId: OUTSIDER }, admin), true); + }); + + it("allows a portal admin who is not a member", async () => { + assert.equal(await canInvite({ team, userId: OUTSIDER }, admin), true); + }); +}); + +describe("create_team authorization", () => { + it("refuses a user-invoked action from a non-admin", async () => { + assert.equal( + await canCreateTeam({ "x-hasura-user-id": OUTSIDER }, notAdmin), + false + ); + }); + + it("allows a portal admin", async () => { + assert.equal(await canCreateTeam({ "x-hasura-user-id": OWNER }, admin), true); + }); + + it("allows the users-insert event trigger (no session)", async () => { + assert.equal(await canCreateTeam(undefined, notAdmin), true); + }); +}); diff --git a/services/actions/src/rpc/createTeam.js b/services/actions/src/rpc/createTeam.js index 10402a1f..58616f20 100644 --- a/services/actions/src/rpc/createTeam.js +++ b/services/actions/src/rpc/createTeam.js @@ -2,6 +2,7 @@ import { createTeamMember, OWNER_ROLE } from "./inviteTeamMember.js"; import apiError from "../utils/apiError.js"; import { fetchGraphQL } from "../utils/graphql.js"; +import { isPortalAdmin } from "../utils/portalAdmin.js"; import { provisionDefaultDatasources } from "../utils/provisionDefaultDatasources.js"; import { fireTeamReconcileHook } from "../utils/defaultModels/shared.js"; @@ -38,6 +39,19 @@ const createTeam = async ({ userId, name }) => { return res?.data?.insert_teams_one; }; +/** + * The `create_team` action makes the caller OWNER of a team with the default + * datasources, so only portal admins may call it. The users-insert event + * trigger arrives without session variables and is unaffected. + */ +export const canCreateTeam = async ( + session, + { portalAdmin = isPortalAdmin } = {} +) => { + if (!session) return true; + return portalAdmin(session["x-hasura-user-id"]); +}; + export default async (session, input) => { const { name = "Default team" } = input || {}; const userId = session?.["x-hasura-user-id"] || input?.event?.data?.new?.id; @@ -45,6 +59,10 @@ export default async (session, input) => { let newTeam; try { + if (!(await canCreateTeam(session))) { + throw new Error("Only portal admins can create teams"); + } + newTeam = await createTeam({ userId, name }); const { id: teamId } = newTeam; diff --git a/services/actions/src/rpc/inviteTeamMember.js b/services/actions/src/rpc/inviteTeamMember.js index 3a8d91a9..e875d3a4 100644 --- a/services/actions/src/rpc/inviteTeamMember.js +++ b/services/actions/src/rpc/inviteTeamMember.js @@ -2,6 +2,7 @@ import fetch from "node-fetch"; import apiError from "../utils/apiError.js"; import { fetchGraphQL, parseResponse } from "../utils/graphql.js"; +import { isPortalAdmin } from "../utils/portalAdmin.js"; const { HASURA_PLUS_ENDPOINT } = process.env; @@ -100,20 +101,33 @@ const hasAccess = (members, userId) => { ); }; -export default async (session, input, headers) => { +/** + * Only an owner of the team, or a portal admin, may invite. The team is read + * with the admin secret: a caller-token read returns nothing for a + * non-member, which used to skip this check entirely. + */ +export const canInvite = async ( + { team, userId }, + { portalAdmin = isPortalAdmin } = {} +) => { + if (!team || !userId) return false; + if (hasAccess(team.members || [], userId)) return true; + return portalAdmin(userId); +}; + +export default async (session, input) => { const { email, teamId, role = "member", magicLink = true } = input || {}; - const { authorization: authToken } = headers || {}; const userId = session?.["x-hasura-user-id"]; let userAccount = {}; let teamMember = {}; try { - const team = await fetchGraphQL(teamQuery, { id: teamId }, authToken); - const members = team.data?.teams_by_pk?.members; + const res = await fetchGraphQL(teamQuery, { id: teamId }); + const team = res?.data?.teams_by_pk; - if (members?.length && !hasAccess(members, userId)) { + if (!(await canInvite({ team, userId }))) { throw new Error("You have no permissions to invite users"); } diff --git a/services/hasura/metadata/tables.yaml b/services/hasura/metadata/tables.yaml index 1e04c531..29b64c8c 100644 --- a/services/hasura/metadata/tables.yaml +++ b/services/hasura/metadata/tables.yaml @@ -730,8 +730,11 @@ permission: check: _or: - - user_id: - _eq: X-Hasura-User-Id + - _and: + - team_id: + _is_null: true + - user_id: + _eq: X-Hasura-User-Id - team: members: user_id: