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
@@ -0,0 +1,15 @@
-- AlterTable
ALTER TABLE "organization_members" ADD COLUMN "deactivated_at" TIMESTAMP(3);

-- Reconcile role drift BEFORE the application starts reading the membership
-- role as authoritative. Until this release, PUT /api/users/:id/role wrote
-- users.role only, so for a user's ACTIVE organization the cached column holds
-- the admin's intent and the membership row may be stale. Role sync — the one
-- writer that updates the membership only — is self-hosted-only and re-asserts
-- itself on the next SSO sign-in, so "cache wins" is safe there too.
UPDATE "organization_members" m
SET "role" = u."role"
FROM "users" u
WHERE m."user_id" = u."id"
AND m."organization_id" = u."organization_id"
AND m."role" <> u."role";
6 changes: 6 additions & 0 deletions packages/backend/prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,12 @@ model OrganizationMember {
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
role UserRole @default(EDITOR)
joinedAt DateTime @default(now()) @map("joined_at")
/// null = active. Set by `UserLifecycleService.deactivateInOrganization`
/// (an admin's Deactivate, or `active: false` pushed by SCIM). The row is
/// KEPT rather than deleted so that role and joinedAt survive a
/// reactivation, and so the member list can say "deactivated" instead of
/// making a leaver indistinguishable from someone who was never here.
deactivatedAt DateTime? @map("deactivated_at")

@@unique([userId, organizationId])
@@index([userId])
Expand Down
5 changes: 5 additions & 0 deletions packages/backend/src/audit/security-event.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,11 @@ export const SecurityEvents = {
ROLE_CHANGED: 'ROLE_CHANGED',
LAST_ADMIN_PROTECTION_TRIGGERED: 'LAST_ADMIN_PROTECTION_TRIGGERED',
MEMBERSHIP_REMOVED_BY_SYNC: 'MEMBERSHIP_REMOVED_BY_SYNC',
/** An admin or a directory push removed this member's access to one workspace. */
USER_DEACTIVATED: 'USER_DEACTIVATED',
USER_REACTIVATED: 'USER_REACTIVATED',
/** An admin removed a multi-workspace user from ONE workspace (account kept). */
MEMBERSHIP_REMOVED: 'MEMBERSHIP_REMOVED',
/** A sign-in rewrote the user's roles from the directory's claims. */
ROLE_SYNC_APPLIED: 'ROLE_SYNC_APPLIED',
/** Claims were incomplete, so roles were deliberately left untouched. */
Expand Down
17 changes: 15 additions & 2 deletions packages/backend/src/auth/auth.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -570,7 +570,14 @@ export class AuthController {
if (existing) {
const membership = await this.organizationsService.getMembership(existing.id, req.user.organizationId);
if (membership) {
throw new ConflictException('This user is already a member of your organization');
// A deactivated member is still a member: inviting them again must
// not become a back door that re-creates access. Reactivation is an
// explicit action under Users.
throw new ConflictException(
membership.deactivatedAt
? 'This user is deactivated in your organization. Reactivate them from Users instead of inviting them again.'
: 'This user is already a member of your organization',
);
}
// User exists but not in this org — allow invitation for multi-org membership
}
Expand Down Expand Up @@ -697,7 +704,13 @@ export class AuthController {
// Existing user — add to the new organization (multi-org)
const alreadyMember = await this.organizationsService.getMembership(existing.id, invite.organizationId);
if (alreadyMember) {
throw new ConflictException('You are already a member of this organization');
// Includes deactivated memberships: accepting an invite must never
// silently reactivate someone an admin or the directory removed.
throw new ConflictException(
alreadyMember.deactivatedAt
? 'Your access to this organization was deactivated. Ask an administrator to reactivate it.'
: 'You are already a member of this organization',
);
}
await this.organizationsService.addMember(existing.id, invite.organizationId, invite.role);
// Switch their active org to the newly joined one
Expand Down
49 changes: 49 additions & 0 deletions packages/backend/src/auth/jwt.strategy.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,11 +18,15 @@ describe('JwtStrategy', () => {
let strategy: JwtStrategy;
let prisma: { user: { findUnique: jest.Mock; update: jest.Mock }; organizationMember: { findFirst: jest.Mock } };

// The strategy now loads the user WITH their active memberships; the
// membership is the authoritative role.
const dbUser = {
id: 'user-cuid-1',
email: 'alice@example.com',
role: 'EDITOR',
organizationId: 'org-1',
sessionsValidFrom: null,
memberships: [{ organizationId: 'org-1', role: 'EDITOR' }],
};

beforeEach(() => {
Expand Down Expand Up @@ -172,4 +176,49 @@ describe('JwtStrategy', () => {
).rejects.toThrow('User no longer exists');
});
});

describe('membership is authoritative', () => {
const dashboard = { sub: 'user-cuid-1', email: 'alice@example.com', tokenUse: 'dashboard' } as any;

it('returns the MEMBERSHIP role and repairs a stale cache', async () => {
prisma.user.findUnique.mockResolvedValue({
...dbUser,
role: 'ADMIN', // stale cache
memberships: [{ organizationId: 'org-1', role: 'VIEWER' }],
});
const result = await strategy.validate(dashboard);
expect(result.role).toBe('VIEWER');
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-cuid-1' },
data: { role: 'VIEWER' },
});
});

// A deactivated member must not keep a working session for that workspace.
it('repoints to another ACTIVE membership when the cached one is deactivated', async () => {
prisma.user.findUnique.mockResolvedValue({
...dbUser,
organizationId: 'org-1',
// org-1 is absent from the active list: deactivated there
memberships: [{ organizationId: 'org-2', role: 'VIEWER' }],
});
const result = await strategy.validate(dashboard);
expect(result).toMatchObject({ organizationId: 'org-2', role: 'VIEWER' });
expect(prisma.user.update).toHaveBeenCalledWith({
where: { id: 'user-cuid-1' },
data: { organizationId: 'org-2', role: 'VIEWER' },
});
});

it('fails closed when no active membership remains', async () => {
prisma.user.findUnique.mockResolvedValue({ ...dbUser, memberships: [] });
await expect(strategy.validate(dashboard)).rejects.toThrow('No active workspace');
});

it('only queries ACTIVE memberships', async () => {
await strategy.validate(dashboard);
const arg = prisma.user.findUnique.mock.calls[0][0];
expect(arg.select.memberships.where).toEqual({ deactivatedAt: null });
});
});
});
54 changes: 40 additions & 14 deletions packages/backend/src/auth/jwt.strategy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,37 +31,63 @@ export class JwtStrategy extends PassportStrategy(Strategy) {
throw new UnauthorizedException('Token is not valid for this API');
}

// One query: the user row plus their ACTIVE memberships. The membership
// is the authoritative role and the only proof the user may still act in
// the organization; `users.role` / `users.organizationId` are caches.
const user = await this.prisma.user.findUnique({
where: { id: payload.sub },
select: {
id: true,
email: true,
role: true,
organizationId: true,
sessionsValidFrom: true,
memberships: {
where: { deactivatedAt: null },
orderBy: { joinedAt: 'asc' },
select: { organizationId: true, role: true },
},
},
});
if (!user) {
throw new UnauthorizedException('User no longer exists');
}

// Revocation: refuse tokens minted before the user's cutover instant (set
// on password change, demotion, deprovisioning or an SSO config change).
// Free to check — the user row is already loaded.
// on password change, demotion and deactivation). Free to check — the
// user row is already loaded.
if (isTokenRevoked(payload, user.sessionsValidFrom)) {
throw new UnauthorizedException('Session has been revoked');
}

// Self-heal a NULL active org by snapping to the oldest remaining membership.
// This happens when an organization the user was active in was deleted while
// they were a member of others — schema's onDelete:SetNull leaves them dangling.
if (user.organizationId === null) {
const fallback = await this.prisma.organizationMember.findFirst({
where: { userId: user.id },
orderBy: { joinedAt: 'asc' },
});
if (fallback) {
const active = user.organizationId
? user.memberships.find((m) => m.organizationId === user.organizationId)
: undefined;

if (active) {
// Repair cache drift in place. Rare: only after a role sync wrote the
// membership, or a pre-fix role change wrote the cache alone.
if (user.role !== active.role) {
await this.prisma.user.update({
where: { id: user.id },
data: { organizationId: fallback.organizationId, role: fallback.role },
data: { role: active.role },
});
return { sub: user.id, email: user.email, role: fallback.role, organizationId: fallback.organizationId };
}
return { sub: user.id, email: user.email, role: active.role, organizationId: active.organizationId };
}

return { sub: user.id, email: user.email, role: user.role, organizationId: user.organizationId };
// The cached org is null, deleted, or the membership in it was deactivated:
// snap to the oldest remaining ACTIVE membership. Nothing left means the
// user may not act anywhere — fail closed rather than serve a workspace
// they were removed from.
const fallback = user.memberships[0];
if (!fallback) {
throw new UnauthorizedException('No active workspace');
}
await this.prisma.user.update({
where: { id: user.id },
data: { organizationId: fallback.organizationId, role: fallback.role },
});
return { sub: user.id, email: user.email, role: fallback.role, organizationId: fallback.organizationId };
}
}
4 changes: 3 additions & 1 deletion packages/backend/src/auth/sso-enforcement.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,8 +25,10 @@ export class SsoEnforcementService {
// password sign-in in the deployment.
if (this.deployment.isCloud()) return [];

// Deactivated memberships do not count: an inert membership should
// neither grant access nor constrain how the user signs in elsewhere.
const memberships = await this.prisma.organizationMember.findMany({
where: { userId },
where: { userId, deactivatedAt: null },
select: { organizationId: true },
});
if (memberships.length === 0) return [];
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/ee/cloud/onboarding-cron.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -213,7 +213,7 @@ export class OnboardingCronService {

// Recipients: org admins (authoritative membership).
const admins = await this.prisma.organizationMember.findMany({
where: { organizationId, role: 'ADMIN' },
where: { organizationId, role: 'ADMIN', deactivatedAt: null },
select: { user: { select: { email: true, name: true } } },
});
if (admins.length === 0) {
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/identity-providers/sso.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -729,7 +729,7 @@ export class SsoService {
/** Fails closed: a non-member is treated exactly like an unknown provider. */
private async assertMember(userId: string, organizationId: string) {
const member = await this.prisma.organizationMember.findFirst({
where: { userId, organizationId },
where: { userId, organizationId, deactivatedAt: null },
select: { id: true },
});
if (!member) throw new SsoError('not_a_member');
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -166,7 +166,14 @@ export class McpEndpointController {
// explicitly enabled anonymous mode. Both are operator credentials on a
// single-tenant self-hosted box, so the pre-existing "everything" answer
// is the correct one and narrowing it here would break those deployments.
if (!user?.sub || !user.organizationId) return null;
if (!user?.sub) return null;

// An identified user with NO organization is a deactivated one whose
// active org was cleared — not an operator. They see nothing.
if (!user.organizationId) {
user.roles = Array.isArray(user.roles) ? user.roles : [];
return new Set<string>();
}

const orgTools = this.toolRegistry
.getAllTools()
Expand Down
2 changes: 1 addition & 1 deletion packages/backend/src/mcp-servers/mcp-servers.service.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,7 +53,7 @@ export class McpServersService {
): Promise<boolean> {
if (!userId || !organizationId) return false;
const count = await this.prisma.organizationMember.count({
where: { userId, organizationId },
where: { userId, organizationId, deactivatedAt: null },
});
return count > 0;
}
Expand Down
20 changes: 20 additions & 0 deletions packages/backend/src/organizations/last-admin.exception.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { ConflictException } from '@nestjs/common';

/**
* Thrown when an action would leave an organization with no ACTIVE admin.
*
* Lives in its own file so that `UsersModule` and `IdentityProvidersModule`
* can both import it without pulling in `OrganizationsService` and creating a
* module cycle. The `code` lets non-JSON callers (SCIM) map it to their own
* error shape.
*/
export class LastAdminConflictException extends ConflictException {
constructor(organizationId: string) {
super({
code: 'LAST_ADMIN',
error:
'Cannot remove the only administrator of this organization. Promote another member first.',
organizationId,
});
}
}
Loading
Loading