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
Original file line number Diff line number Diff line change
Expand Up @@ -490,7 +490,12 @@ export function OrganizationAdminMembers({
// Show "Add Member" button only for Kilo admins
const showAddMemberButton = isKiloAdmin;

const showInviteMemberButton = canInviteMembers(currentUserRole, isKiloAdmin);
// Child organizations manage membership through their parent organization,
// so the self-serve invite flow is hidden for them.
const isChildOrganization = organizationData.parent_organization_id !== null;

const showInviteMemberButton =
canInviteMembers(currentUserRole, isKiloAdmin) && !isChildOrganization;

return (
<>
Expand Down
99 changes: 15 additions & 84 deletions apps/web/src/lib/organizations/organizations.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -897,52 +897,18 @@ describe('Organizations', () => {
).rejects.toThrow('User must join this organization through SSO');
});

test('creates a WorkOS-bound invitation for a same-domain child user', async () => {
test('rejects invitations into a child organization', async () => {
const owner = await insertTestUser();
const parent = await createOrganization('Parent SSO Org', owner.id);
await db
.update(organizations)
.set({ sso_domain: 'example.com' })
.where(eq(organizations.id, parent.id));
const child = await createOrganization('Child Org', owner.id);
await db
.update(organizations)
.set({ parent_organization_id: parent.id })
.where(eq(organizations.id, child.id));

const invitation = await inviteUserToOrganization(
child.id,
owner.id,
'member@example.com',
'member'
);

expect(invitation.authentication_requirement).toBe('workos');
expect(invitation.sso_source_organization_id).toBe(parent.id);
});

test('keeps external child invitations on default authentication', async () => {
const owner = await insertTestUser();
const parent = await createOrganization('Parent SSO Org', owner.id);
await db
.update(organizations)
.set({ sso_domain: 'example.com' })
.where(eq(organizations.id, parent.id));
const parent = await createOrganization('Parent Org', owner.id);
const child = await createOrganization('Child Org', owner.id);
await db
.update(organizations)
.set({ parent_organization_id: parent.id })
.where(eq(organizations.id, child.id));

const invitation = await inviteUserToOrganization(
child.id,
owner.id,
'contractor@external.test',
'member'
);

expect(invitation.authentication_requirement).toBe('default');
expect(invitation.sso_source_organization_id).toBeNull();
await expect(
inviteUserToOrganization(child.id, owner.id, 'member@example.com', 'member')
).rejects.toThrow('Child organizations cannot invite members');
});
});

Expand Down Expand Up @@ -1450,49 +1416,14 @@ describe('Organizations', () => {
expect(storedInvitation?.accepted_at).not.toBeNull();
});

test('requires matching WorkOS authentication for a child SSO invitation', async () => {
const owner = await insertTestUser();
const invitee = await insertTestUser({ google_user_email: 'member@example.com' });
const parent = await createOrganization('Parent SSO Org', owner.id);
await db
.update(organizations)
.set({ sso_domain: 'example.com' })
.where(eq(organizations.id, parent.id));
const child = await createOrganization('Child Org', owner.id);
await db
.update(organizations)
.set({ parent_organization_id: parent.id })
.where(eq(organizations.id, child.id));
const invitation = await inviteUserToOrganization(
child.id,
owner.id,
invitee.google_user_email,
'member'
);

const rejected = await acceptOrganizationInvite(invitee.id, invitation.token);
expect(rejected).toEqual({
success: false,
error: 'Invitation requires authentication through organization SSO',
});

const accepted = await acceptOrganizationInvite(invitee.id, invitation.token, {
provider: 'workos',
ssoSourceOrganizationId: parent.id,
});
expect(accepted.success).toBe(true);
});

test('rejects a legacy ordinary invitation after child SSO becomes effective', async () => {
test('rejects accepting a pre-existing invitation into a child organization', async () => {
const owner = await insertTestUser();
const invitee = await insertTestUser({ google_user_email: 'legacy@example.com' });
const parent = await createOrganization('Parent SSO Org', owner.id);
await db
.update(organizations)
.set({ sso_domain: 'example.com' })
.where(eq(organizations.id, parent.id));
const parent = await createOrganization('Parent Org', owner.id);
const child = await createOrganization('Child Org', owner.id);
const token = 'legacy-sso-invitation';
// Invites can no longer be created for child orgs through the normal flow,
// so insert a pending invite directly, then reparent the org afterwards.
const token = 'legacy-child-invitation';
await db.insert(organization_invitations).values({
organization_id: child.id,
email: invitee.google_user_email,
Expand All @@ -1506,15 +1437,15 @@ describe('Organizations', () => {
.set({ parent_organization_id: parent.id })
.where(eq(organizations.id, child.id));

const result = await acceptOrganizationInvite(invitee.id, token, {
provider: 'workos',
ssoSourceOrganizationId: parent.id,
});
const result = await acceptOrganizationInvite(invitee.id, token);

expect(result).toEqual({
success: false,
error: 'Invitation requires authentication through organization SSO',
error: 'Child organizations cannot accept invitations',
});

const userOrgs = await getUserOrganizationsWithSeats(invitee.id);
expect(userOrgs).toHaveLength(0);
});

test('should accept invitation with owner role', async () => {
Expand Down
16 changes: 6 additions & 10 deletions apps/web/src/lib/organizations/organizations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -435,12 +435,10 @@ export async function inviteUserToOrganization(
if (!organization) {
throw new Error('Organization SSO policy is misconfigured');
}
// Child organizations manage membership through their parent organization,
// so direct invitations into a child org are not allowed.
if (organization.parentOrganizationId) {
await tx
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.id, organization.parentOrganizationId))
.for('update');
throw new Error('Child organizations cannot invite members');
}

const policy = await resolveEffectiveOrganizationSsoPolicy(organizationId, tx);
Expand Down Expand Up @@ -663,12 +661,10 @@ export async function acceptOrganizationInvite(
if (!organization) {
return failureResult('Organization not found');
}
// Child organizations manage membership through their parent organization.
// Reject any invitation (including pre-existing ones) into a child org.
if (organization.parent_organization_id) {
await tx
.select({ id: organizations.id })
.from(organizations)
.where(eq(organizations.id, organization.parent_organization_id))
.for('update');
return failureResult('Child organizations cannot accept invitations');
}

const ssoPolicy = await resolveEffectiveOrganizationSsoPolicy(organization.id, tx);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -568,6 +568,24 @@ describe('organizations members trpc router', () => {
expect(result.acceptInviteUrl).toMatch(/^https?:\/\/.+\/users\/accept-invite\/.+$/);
});

it('should reject inviting members into a child organization', async () => {
const childOrg = await createOrganization('Child Members Organization', regularUser.id);
await db
.update(organizations)
.set({ parent_organization_id: testOrganization.id })
.where(eq(organizations.id, childOrg.id));

const caller = await createCallerForUser(regularUser.id);

await expect(
caller.organizations.members.invite({
organizationId: childOrg.id,
email: 'child-org-invite@example.com',
role: 'member',
})
).rejects.toThrow('Child organizations manage membership through their parent organization.');
});

it('should reject billing managers inviting owners', async () => {
const caller = await createCallerForUser(billingManagerUser.id);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,12 @@ export const organizationsMembersRouter = createTRPCRouter({
message: 'This user is already a member of this organization',
});
}
if (error.message === 'Child organizations cannot invite members') {
throw new TRPCError({
code: 'PRECONDITION_FAILED',
message: 'Child organizations manage membership through their parent organization.',
});
}
if (error.message === 'User must join this organization through SSO') {
throw new TRPCError({
code: 'FORBIDDEN',
Expand Down