Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
65 changes: 65 additions & 0 deletions services/actions/src/rpc/__tests__/teamAuthz.test.js
Original file line number Diff line number Diff line change
@@ -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);
});
});
18 changes: 18 additions & 0 deletions services/actions/src/rpc/createTeam.js
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -38,13 +39,30 @@ 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;

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;

Expand Down
24 changes: 19 additions & 5 deletions services/actions/src/rpc/inviteTeamMember.js
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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");
}

Expand Down
7 changes: 5 additions & 2 deletions services/hasura/metadata/tables.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
Loading