11import { Prisma , prisma } from "~/db.server" ;
22import { logger } from "~/services/logger.server" ;
33import { rbac } from "~/services/rbac.server" ;
4+ import {
5+ getValidPersonalAccessTokens ,
6+ revokePersonalAccessToken ,
7+ } from "~/services/personalAccessToken.server" ;
48
59export type EnsureOrgMemberParams = {
610 userId : string ;
@@ -9,7 +13,7 @@ export type EnsureOrgMemberParams = {
913 // value is an RBAC role id; when an RBAC plugin is installed it gets
1014 // attached after the OrgMember row is created.
1115 roleId : string | null ;
12- source : "sso_jit" | "invite" | "manual" ;
16+ source : "sso_jit" | "invite" | "manual" | "directory_sync" ;
1317} ;
1418
1519export type EnsureOrgMemberResult = { created : boolean ; orgMemberId : string } ;
@@ -132,3 +136,154 @@ export async function ensureOrgMember(
132136
133137 return { created : true , orgMemberId : member . id } ;
134138}
139+
140+ // Find-or-create a User for a directory-provisioned member. Directory Sync
141+ // can provision a user before they have ever logged in, so the User row may
142+ // not exist yet. Email is the natural key (lowercased). New rows are marked
143+ // SSO since the user will authenticate via the org's IdP.
144+ export async function ensureUserForDirectory ( params : {
145+ email : string ;
146+ firstName : string | null ;
147+ lastName : string | null ;
148+ } ) : Promise < { userId : string } > {
149+ const email = params . email . toLowerCase ( ) . trim ( ) ;
150+ const existing = await prisma . user . findFirst ( { where : { email } , select : { id : true } } ) ;
151+ if ( existing ) return { userId : existing . id } ;
152+
153+ const name = [ params . firstName , params . lastName ] . filter ( Boolean ) . join ( " " ) . trim ( ) || null ;
154+ // `User.email` is unique, so two concurrent directory events for the same
155+ // email can both miss the lookup above and race on create; the loser gets
156+ // P2002. Treat that as the idempotent "already exists" case (same pattern as
157+ // `ensureOrgMember`) rather than throwing and burning a webhook retry.
158+ try {
159+ const created = await prisma . user . create ( {
160+ data : {
161+ email,
162+ authenticationMethod : "SSO" ,
163+ name,
164+ displayName : name ,
165+ } ,
166+ select : { id : true } ,
167+ } ) ;
168+ return { userId : created . id } ;
169+ } catch ( error ) {
170+ if ( error instanceof Prisma . PrismaClientKnownRequestError && error . code === "P2002" ) {
171+ const existingAfterConflict = await prisma . user . findFirst ( {
172+ where : { email } ,
173+ select : { id : true } ,
174+ } ) ;
175+ if ( existingAfterConflict ) return { userId : existingAfterConflict . id } ;
176+ }
177+ throw error ;
178+ }
179+ }
180+
181+ // Whether the user holds the Owner system role in this org. Owner is the one
182+ // role Directory Sync must never strip (it can't be auto-granted and is the
183+ // org's recovery anchor), so deprovision is guarded against removing the last
184+ // one. Identified by the RBAC system role; OSS-safe (no plugin → not Owner).
185+ function isOwnerRole ( role : { name : string ; isSystem : boolean } | null ) : boolean {
186+ return ! ! role && role . isSystem && role . name === "Owner" ;
187+ }
188+
189+ export type RemoveOrgMemberForDirectoryResult =
190+ | { removed : true }
191+ | { removed : false ; reason : "not_a_member" | "last_owner_protected" } ;
192+
193+ // Deprovision a directory-removed user from an org: hard-delete the
194+ // OrgMember, drop the RBAC role, force-logout (nextSessionEnd), and revoke
195+ // the user's personal access tokens ONLY when this was their last org (PATs
196+ // are user-global, so revoking on a single-org removal would break their CLI
197+ // access to other orgs). Refuses to remove the org's last Owner.
198+ export async function removeOrgMemberForDirectory ( params : {
199+ userId : string ;
200+ organizationId : string ;
201+ } ) : Promise < RemoveOrgMemberForDirectoryResult > {
202+ const { userId, organizationId } = params ;
203+
204+ const member = await prisma . orgMember . findFirst ( {
205+ where : { userId, organizationId } ,
206+ select : { id : true } ,
207+ } ) ;
208+ if ( ! member ) return { removed : false , reason : "not_a_member" } ;
209+
210+ // Last-Owner guard: never leave the org without an Owner. Resolve every
211+ // member's RBAC role and bail if this user is the only Owner.
212+ const members = await prisma . orgMember . findMany ( {
213+ where : { organizationId } ,
214+ select : { userId : true } ,
215+ } ) ;
216+ const roles = await rbac . getUserRoles (
217+ members . map ( ( m ) => m . userId ) ,
218+ organizationId
219+ ) ;
220+ if ( isOwnerRole ( roles . get ( userId ) ?? null ) ) {
221+ const otherOwners = members . filter (
222+ ( m ) => m . userId !== userId && isOwnerRole ( roles . get ( m . userId ) ?? null )
223+ ) ;
224+ if ( otherOwners . length === 0 ) {
225+ logger . warn ( "removeOrgMemberForDirectory: refusing to remove last Owner" , {
226+ userId,
227+ organizationId,
228+ } ) ;
229+ return { removed : false , reason : "last_owner_protected" } ;
230+ }
231+ }
232+
233+ await prisma . orgMember . delete ( { where : { id : member . id } } ) ;
234+ const removeRole = await rbac . removeUserRole ( { userId, organizationId } ) ;
235+ if ( ! removeRole . ok ) {
236+ logger . warn ( "removeOrgMemberForDirectory: failed to remove RBAC role" , {
237+ userId,
238+ organizationId,
239+ error : removeRole . error ,
240+ } ) ;
241+ }
242+
243+ // Post-delete cleanup is best-effort: the membership (the critical state) is
244+ // already gone, so any throw here must not propagate. If it did, the webhook
245+ // worker would retry, hit the `not_a_member` guard above, and skip the rest
246+ // of the cleanup entirely — leaving sessions or PATs behind. Swallowing lets
247+ // this single pass finish force-logout + PAT revocation.
248+
249+ // Force logout everywhere.
250+ try {
251+ await prisma . user . update ( { where : { id : userId } , data : { nextSessionEnd : new Date ( ) } } ) ;
252+ } catch ( error ) {
253+ logger . warn ( "removeOrgMemberForDirectory: failed to force logout" , {
254+ userId,
255+ organizationId,
256+ error,
257+ } ) ;
258+ }
259+
260+ // Revoke PATs only if the user no longer belongs to ANY org — PATs are
261+ // user-global and used by the CLI across every org the user is in. Each
262+ // revoke is guarded so a concurrent self-revoke (which would throw) or one
263+ // bad token doesn't abort the rest.
264+ try {
265+ const remainingMemberships = await prisma . orgMember . count ( { where : { userId } } ) ;
266+ if ( remainingMemberships === 0 ) {
267+ const tokens = await getValidPersonalAccessTokens ( userId ) ;
268+ for ( const token of tokens ) {
269+ try {
270+ await revokePersonalAccessToken ( token . id , userId ) ;
271+ } catch ( error ) {
272+ logger . warn ( "removeOrgMemberForDirectory: failed to revoke PAT" , {
273+ userId,
274+ tokenId : token . id ,
275+ error,
276+ } ) ;
277+ }
278+ }
279+ }
280+ } catch ( error ) {
281+ logger . warn ( "removeOrgMemberForDirectory: PAT cleanup failed" , {
282+ userId,
283+ organizationId,
284+ error,
285+ } ) ;
286+ }
287+
288+ return { removed : true } ;
289+ }
0 commit comments