Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
cf8e76b
feat: Add permission_ids support to team member profiles and invitations
bootssecurity Aug 31, 2025
1701d2d
Fix member list roles by adding permission_ids to team-member-profile…
bootssecurity Aug 31, 2025
6940ffe
Update StackClientInterface to return JSON response from API call
bootssecurity Sep 1, 2025
631b831
Merge upstream/dev into dev - sync with main repository
bootssecurity Sep 13, 2025
3bdf720
Merge branch 'dev' into dev
bootssecurity Nov 17, 2025
866cfaf
Enhance team invitation and member profile tests to include permissio…
Nov 17, 2025
2af2c86
Update apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts
bootssecurity Nov 17, 2025
a27b1f4
Update apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts
bootssecurity Nov 17, 2025
e63c0cc
Add edge case and permission escalation tests for team invitations
Nov 17, 2025
729f46e
Refine permission handling in team member invitation section
Nov 17, 2025
7d71fc3
Update apps/e2e/tests/backend/endpoints/api/v1/team-invitations.test.ts
bootssecurity Nov 17, 2025
35b8460
Refactor team invitation tests to improve error response validation
Nov 17, 2025
a40c521
Merge branch 'dev' of https://github.com/bootssecurity/stack-auth int…
Nov 17, 2025
708fe14
Merge branch 'stack-auth:dev' into dev
bootssecurity Nov 18, 2025
6bf8977
Merge remote-tracking branch 'origin/dev' into bootsecurity-dev
BilalG1 Nov 19, 2025
38dfd88
fix permissions
BilalG1 Nov 19, 2025
38664c9
fix tests
BilalG1 Nov 20, 2025
75f1416
fix tests
BilalG1 Nov 20, 2025
d697810
Merge dev into bootsecurity-dev
N2D4 Nov 20, 2025
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
Original file line number Diff line number Diff line change
@@ -1,12 +1,13 @@
import { teamMembershipsCrudHandlers } from "@/app/api/latest/team-memberships/crud";
import { sendEmailFromTemplate } from "@/lib/emails";
import { getItemQuantityForCustomer } from "@/lib/payments";
import { grantTeamPermission } from "@/lib/permissions";
import { getSoleTenancyFromProjectBranch } from "@/lib/tenancies";
import { getPrismaClientForTenancy } from "@/prisma-client";
import { createVerificationCodeHandler } from "@/route-handlers/verification-code-handler";
import { VerificationCodeType } from "@prisma/client";
import { KnownErrors } from "@stackframe/stack-shared";
import { emailSchema, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { emailSchema, permissionDefinitionIdSchema, yupArray, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { teamsCrudHandlers } from "../../teams/crud";

export const teamInvitationCodeHandler = createVerificationCodeHandler({
Expand All @@ -30,6 +31,7 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
type: VerificationCodeType.TEAM_INVITATION,
data: yupObject({
team_id: yupString().defined(),
permission_ids: yupArray(permissionDefinitionIdSchema.defined()).optional(),
}).defined(),
method: yupObject({
email: emailSchema.defined(),
Expand Down Expand Up @@ -67,7 +69,7 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({

return codeObj;
},
async handler(tenancy, {}, data, body, user) {
async handler(tenancy, { }, data, body, user) {
if (!user) throw new KnownErrors.UserAuthenticationRequired;
const prisma = await getPrismaClientForTenancy(tenancy);

Expand Down Expand Up @@ -112,6 +114,19 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
user_id: user.id,
data: {},
});

// Apply additional specific permissions if provided (with deduplication)
if (data.permission_ids && data.permission_ids.length > 0) {
const uniquePermissionIds = [...new Set(data.permission_ids)];
for (const permissionId of uniquePermissionIds) {
await grantTeamPermission(prisma, {
tenancy,
teamId: data.team_id,
userId: user.id,
permissionId,
});
}
}
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Bug: Permissions not granted for existing team members

When an existing team member accepts an invitation with permission_ids, those permissions are not granted because the permission granting logic is inside the if (!oldMembership) block. This means invitations with specific permissions only work for new members, not for re-inviting existing members with different permissions. If re-inviting existing members is intended to update their permissions, this logic prevents that from working.

Fix in Cursor Fix in Web

}

return {
Expand All @@ -120,7 +135,7 @@ export const teamInvitationCodeHandler = createVerificationCodeHandler({
body: {}
};
},
async details(tenancy, {}, data, body, user) {
async details(tenancy, { }, data, body, user) {
if (!user) throw new KnownErrors.UserAuthenticationRequired;

const team = await teamsCrudHandlers.adminRead({
Expand Down
1 change: 1 addition & 0 deletions apps/backend/src/app/api/latest/team-invitations/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ export const teamInvitationsCrudHandlers = createLazyProxy(() => createCrudHandl
team_id: code.data.team_id,
expires_at_millis: code.expiresAt.getTime(),
recipient_email: code.method.email,
permission_ids: code.data.permission_ids || [],
})),
is_paginated: false,
};
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { listPermissionDefinitions } from "@/lib/permissions";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { adaptSchema, clientOrHigherAuthTypeSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";

export const GET = createSmartRouteHandler({
metadata: {
summary: "Get role-based permissions for team invitations",
description: "Fetch available role-based permissions that can be assigned to team members during invitations. Only returns role-based permissions, not system permissions.",
tags: ["Teams"],
},
request: yupObject({
auth: yupObject({
type: clientOrHigherAuthTypeSchema,
tenancy: adaptSchema.defined(),
user: adaptSchema.optional(),
}).defined(),
}),
response: yupObject({
statusCode: yupNumber().oneOf([200]).defined(),
bodyType: yupString().oneOf(["json"]).defined(),
body: yupObject({
items: yupArray(yupObject({
id: yupString().defined(),
description: yupString().optional(),
contained_permission_ids: yupArray(yupString().defined()).defined(),
}).defined()).defined(),
is_paginated: yupBoolean().oneOf([false]).defined(),
}).defined(),
}),
async handler({ auth }) {
const allPermissions = await listPermissionDefinitions({
scope: "team",
tenancy: auth.tenancy,
});

// Return all permissions including system permissions (starting with $)
return {
statusCode: 200,
bodyType: "json",
body: {
items: allPermissions,
is_paginated: false,
},
};
},
});
Comment on lines +5 to +46
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Clarify whether system permissions should be included.

There's an inconsistency between the metadata description and the implementation:

  • Line 8 states: "Only returns role-based permissions, not system permissions"
  • Line 36 comment states: "Return all permissions including system permissions"
  • The code returns allPermissions without filtering

Please clarify the intended behavior and update either:

  1. The description (line 8) if system permissions should be included, or
  2. The implementation (lines 36-41) to filter out system permissions (those starting with $) if they should be excluded
🤖 Prompt for AI Agents
In apps/backend/src/app/api/latest/team-invitations/role-permissions/route.tsx
around lines 5 to 46: the metadata says this endpoint should return only
role-based permissions (no system permissions) but the handler returns
allPermissions and the inline comment says it returns system permissions—either
update the metadata to state system permissions are included, or filter out
permissions whose ids start with "$" before returning; to fix, decide desired
behavior and then either change the metadata/description/tags to reflect
inclusion, or modify the handler to filter allPermissions (remove items with id
starting with "$") and ensure the response body and response schema still match.

Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@ import { ensureUserTeamPermissionExists } from "@/lib/request-checks";
import { getPrismaClientForTenancy, retryTransaction } from "@/prisma-client";
import { createSmartRouteHandler } from "@/route-handlers/smart-route-handler";
import { KnownErrors } from "@stackframe/stack-shared";
import { adaptSchema, clientOrHigherAuthTypeSchema, teamIdSchema, teamInvitationCallbackUrlSchema, teamInvitationEmailSchema, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { adaptSchema, clientOrHigherAuthTypeSchema, permissionDefinitionIdSchema, teamIdSchema, teamInvitationCallbackUrlSchema, teamInvitationEmailSchema, yupArray, yupBoolean, yupNumber, yupObject, yupString } from "@stackframe/stack-shared/dist/schema-fields";
import { teamInvitationCodeHandler } from "../accept/verification-code-handler";
import { StatusError } from "@stackframe/stack-shared/dist/utils/errors";
import { listPermissionDefinitionsFromConfig } from "@/lib/permissions";

export const POST = createSmartRouteHandler({
metadata: {
Expand All @@ -21,6 +23,7 @@ export const POST = createSmartRouteHandler({
team_id: teamIdSchema.defined(),
email: teamInvitationEmailSchema.defined(),
callback_url: teamInvitationCallbackUrlSchema.defined(),
permission_ids: yupArray(permissionDefinitionIdSchema.defined()).optional(),
}).defined(),
}),
response: yupObject({
Expand All @@ -36,6 +39,9 @@ export const POST = createSmartRouteHandler({
await retryTransaction(prisma, async (tx) => {
if (auth.type === "client") {
if (!auth.user) throw new KnownErrors.UserAuthenticationRequired();
if (body.permission_ids !== undefined) {
throw new StatusError(StatusError.Forbidden, "permission_ids can only be set from server-side requests.");
}

await ensureUserTeamPermissionExists(tx, {
tenancy: auth.tenancy,
Expand All @@ -48,10 +54,25 @@ export const POST = createSmartRouteHandler({
}
});

if (body.permission_ids !== undefined) {
const validPermissionIds = new Set(
listPermissionDefinitionsFromConfig({
config: auth.tenancy.config,
scope: "team",
}).map((permission) => permission.id),
);
for (const permissionId of body.permission_ids) {
if (!validPermissionIds.has(permissionId)) {
throw new KnownErrors.PermissionNotFound(permissionId);
}
}
}

const codeObj = await teamInvitationCodeHandler.sendCode({
tenancy: auth.tenancy,
data: {
team_id: body.team_id,
permission_ids: body.permission_ids || [],
},
method: {
email: body.email,
Expand Down
65 changes: 61 additions & 4 deletions apps/backend/src/app/api/latest/team-member-profiles/crud.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,36 @@ import { getUserLastActiveAtMillis, getUsersLastActiveAtMillis, userFullInclude,

const fullInclude = { projectUser: { include: userFullInclude } };

function prismaToCrud(prisma: Prisma.TeamMemberGetPayload<{ include: typeof fullInclude }>, lastActiveAtMillis: number) {
// Helper function to fetch permissions for team members
async function fetchTeamMemberPermissions(tx: any, tenancyId: string, teamId: string, projectUserIds: string[]) {
const permissions = await tx.teamMemberDirectPermission.findMany({
where: {
tenancyId,
teamId,
projectUserId: { in: projectUserIds },
},
select: { projectUserId: true, permissionId: true },
});

// Group permissions by projectUserId
const permissionMap = new Map<string, string[]>();
for (const perm of permissions) {
if (!permissionMap.has(perm.projectUserId)) {
permissionMap.set(perm.projectUserId, []);
}
permissionMap.get(perm.projectUserId)!.push(perm.permissionId);
}

return permissionMap;
}

function prismaToCrud(prisma: Prisma.TeamMemberGetPayload<{ include: typeof fullInclude }>, lastActiveAtMillis: number, permissionIds: string[]) {
return {
team_id: prisma.teamId,
user_id: prisma.projectUserId,
display_name: prisma.displayName ?? prisma.projectUser.displayName,
profile_image_url: prisma.profileImageUrl ?? prisma.projectUser.profileImageUrl,
permission_ids: permissionIds,
user: userPrismaToCrud(prisma.projectUser, lastActiveAtMillis),
};
}
Expand Down Expand Up @@ -78,10 +102,22 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa
include: fullInclude,
});

// Fetch all permissions in a single query to avoid N+1 pattern
const permissionMap = await fetchTeamMemberPermissions(
tx,
auth.tenancy.id,
query.team_id!,
db.map(member => member.projectUserId)
);

const lastActiveAtMillis = await getUsersLastActiveAtMillis(auth.project.id, auth.branchId, db.map(user => user.projectUserId), db.map(user => user.createdAt));

return {
items: db.map((user, index) => prismaToCrud(user, lastActiveAtMillis[index])),
items: db.map((user, index) => prismaToCrud(
user,
lastActiveAtMillis[index],
permissionMap.get(user.projectUserId) || []
)),
is_paginated: false,
};
});
Expand Down Expand Up @@ -121,7 +157,19 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa
throw new KnownErrors.TeamMembershipNotFound(params.team_id, params.user_id);
}

return prismaToCrud(db, await getUserLastActiveAtMillis(auth.project.id, auth.branchId, db.projectUser.projectUserId) ?? db.projectUser.createdAt.getTime());
// Use helper function to fetch permissions
const permissionMap = await fetchTeamMemberPermissions(
tx,
auth.tenancy.id,
db.teamId,
[db.projectUserId]
);

return prismaToCrud(
db,
await getUserLastActiveAtMillis(auth.project.id, auth.branchId, db.projectUserId) ?? db.projectUser.createdAt.getTime(),
permissionMap.get(db.projectUserId) || []
);
});
},
onUpdate: async ({ auth, data, params }) => {
Expand Down Expand Up @@ -155,7 +203,16 @@ export const teamMemberProfilesCrudHandlers = createLazyProxy(() => createCrudHa
include: fullInclude,
});

return prismaToCrud(db, await getUserLastActiveAtMillis(auth.project.id, auth.branchId, db.projectUser.projectUserId) ?? db.projectUser.createdAt.getTime());
const perms = await tx.teamMemberDirectPermission.findMany({
where: {
tenancyId: auth.tenancy.id,
projectUserId: db.projectUserId,
teamId: db.teamId,
},
select: { permissionId: true },
});

return prismaToCrud(db, await getUserLastActiveAtMillis(auth.project.id, auth.branchId, db.projectUser.projectUserId) ?? db.projectUser.createdAt.getTime(), perms.map(p => p.permissionId));
});
},
}));
Loading
Loading