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
33 changes: 33 additions & 0 deletions __tests__/enterprise/invitation-accept.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,9 @@
* predicates do the right thing.
*/

import { readFileSync } from "fs";
import { join } from "path";

import { isOnboardingBlocked } from "@/lib/enterprise/org-status";

describe("invitation-accept guards", () => {
Expand Down Expand Up @@ -42,3 +45,33 @@ describe("invitation-accept guards", () => {
// A full unit test would require mocking the entire $transaction
// chain, which the integration smoke covers more robustly.
});

// #819 — who-is-acting identity rule, pinned at the source level (the
// route handlers need the full auth/tx harness, which the integration
// smoke owns; these pins stop the gates from drifting between surfaces).
// Rule: identity creation requires the user's OWN action. Invitation
// accept is the user's consenting click, so LEARNER lazy-creates the
// lightweight ConsulteeProfile there (lib/auth.ts sanctions exactly this)
// while EXPERT stays strict. Admin direct-add is strict for BOTH roles.
describe("who-is-acting identity gates (#819)", () => {
// __dirname-relative so the pins survive jest being invoked from any cwd.
const read = (p: string) =>
readFileSync(join(__dirname, "..", "..", p), "utf8");

it("invitation-accept keeps the EXPERT strict gate but NOT a LEARNER gate", () => {
const src = read("app/api/organizations/invitations/accept/route.ts");
expect(src).toContain("NOT_A_CONSULTANT");
expect(src).not.toContain("NOT_A_CONSULTEE");
});

it("admin direct-add (POST /members) keeps strict gates for BOTH roles", () => {
const src = read("app/api/organizations/[orgId]/members/route.ts");
expect(src).toContain("NOT_A_CONSULTANT");
expect(src).toContain("NOT_A_CONSULTEE");
});

it("the lazy-create sanction for invite-accept-as-LEARNER still stands in lib/auth.ts", () => {
const src = read("lib/auth.ts");
expect(src).toContain("invite-accept as LEARNER");
});
});
21 changes: 12 additions & 9 deletions app/api/organizations/[orgId]/members/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -265,15 +265,18 @@ export async function POST(
}
const userId = user.id;

// #729 §AC4/AC5 — strict identity gate. The shared
// `applyMembershipRoleEffects` helper lazy-creates the matching
// profile when one is missing, which silently promotes strangers
// to consultant / consumer identities. For the dashboard
// add-member surface we want the safer interpretation: the target
// must already have the correct profile before being added under
// that role. SSO JIT auto-join keeps the lazy-create path because
// that is a separate provisioning channel with its own
// authorization layer.
// #729 §AC4/AC5 + #819 — who-is-acting identity rule. Direct-add is an
// ADMIN acting on someone else, so BOTH roles require a pre-existing
// profile: the shared `applyMembershipRoleEffects` helper would
// otherwise lazy-create one and silently promote a stranger to a
// consultant / consumer identity they never asked for. Contrast
// invitation-accept, where the LEARNER gate is deliberately absent —
// accepting is the user's OWN consenting click, so the lightweight
// ConsulteeProfile lazy-creates there (EXPERT stays strict on every
// surface). SSO JIT auto-join keeps its lazy path as a separate
// provisioning channel with its own authorization layer. The
// who-is-acting pins in __tests__/enterprise/invitation-accept.test.ts
// keep the three surfaces from drifting apart.
if (role === "EXPERT") {
const existingConsultant = await prisma.consultantProfile.findUnique({
where: { userId },
Expand Down
46 changes: 15 additions & 31 deletions app/api/organizations/invitations/accept/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -214,14 +214,15 @@ export async function POST(req: NextRequest) {
return { membership: existing, organization: org, alreadyMember: true };
}

// #729 §AC4/AC5 — strict identity gate. `applyMembershipRoleEffects`
// lazy-creates a missing profile, which would silently promote
// an invited user to consultant / consumer identity. For the
// dashboard-driven invitation accept flow we refuse instead:
// the invitee must already have the matching profile on
// Familiarise. SSO JIT auto-join keeps the lazy-create path
// because that is a separate provisioning channel with its own
// authorization layer.
// #729 §AC4/AC5 + #819 — who-is-acting identity rule. Accepting an
// invitation is the USER'S OWN consenting action, so the lightweight
// ConsulteeProfile may be lazy-created here (lib/auth.ts names
// "invite-accept as LEARNER" as a sanctioned creation point — gating
// it broke sponsored-employee onboarding). EXPERT stays strict: a
// consultant identity carries domain/rates/verification/payout
// prerequisites that no invite click can substitute for. Admin
// direct-add (POST /members) stays strict for BOTH roles, and SSO
// JIT keeps its own lazy path.
if (normalizedRole === "EXPERT") {
const existingConsultant = await tx.consultantProfile.findUnique({
where: { userId },
Expand All @@ -234,32 +235,15 @@ export async function POST(req: NextRequest) {
);
}
}
if (normalizedRole === "LEARNER") {
const existingConsultee = await tx.consulteeProfile.findUnique({
where: { userId },
select: { id: true },
});
if (!existingConsultee) {
throw Object.assign(
new Error("NOT_A_CONSULTEE"),
{ httpStatus: 400 },
);
}
}

// Profile FK + payoutRecipient defaults are computed by the
// shared helper (see lib/api/organizations/membership-transitions.ts).
// LEARNER lazy-creates ConsulteeProfile; EXPERT lazy-creates
// ConsultantProfile with the "General" placeholder domain +
// WEEKLY schedule + PENDING_VERIFICATION (hidden from
// /explore/experts until a platform admin reviews). Operator
// roles (OWNER/MAINTAINER/MANAGER/SUPPORT) leave both FKs null.
//
// The EXPERT pre-check above means we'll never reach the
// lazy-create branch for EXPERT here — but we keep the call
// unchanged so LEARNER's lazy-create stays consistent with
// the rest of the flow. Multi-org experts are still
// first-class; see docs/enterprise/60-scenarios-and-verdicts/01-scenarios-and-examples.md.
// LEARNER lazy-creates ConsulteeProfile (first consumer action).
// Operator roles (OWNER/MAINTAINER/MANAGER/SUPPORT) leave both FKs
// null. The EXPERT pre-check above means the helper never reaches
// its EXPERT lazy-create branch from this surface. Multi-org experts
// and learners stay first-class; see
// docs/enterprise/60-scenarios-and-verdicts/01-scenarios-and-examples.md.
const roleEffects = await applyMembershipRoleEffects(tx, {
userId,
role: normalizedRole,
Expand Down