Skip to content

Commit 526162b

Browse files
jordaneclaude
andauthored
feat(permissions): fix user metadata lookup flow for email-based user… (#118)
* feat(permissions): fix user metadata lookup flow for email-based user management Update project permission system to use correct NATS endpoints for user information retrieval: - Add USER_METADATA_READ NATS subject for lfx.auth-service.user_metadata.read - Fix getUserInfo to use email_to_username result for user metadata lookup - Maintain email_to_sub for backend storage consistency (auditors/writers) - Handle proper response format from user metadata service - Map picture field to avatar and construct names from metadata 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Jordan Evans <jevans@linuxfoundation.org> * style: apply code formatting to project permission services Run yarn format to ensure consistent code style across the project. 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com> Signed-off-by: Jordan Evans <jevans@linuxfoundation.org> --------- Signed-off-by: Jordan Evans <jevans@linuxfoundation.org> Co-authored-by: Claude <noreply@anthropic.com>
1 parent 6b7f43d commit 526162b

File tree

3 files changed

+158
-65
lines changed

3 files changed

+158
-65
lines changed

apps/lfx-one/src/server/controllers/project.controller.ts

Lines changed: 6 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -261,51 +261,30 @@ export class ProjectController {
261261

262262
// Detect if input is email or username
263263
const isEmail = userData.username.includes('@');
264-
let username = userData.username;
265-
266-
if (isEmail) {
267-
req.log.info(
268-
{
269-
email: userData.username,
270-
operation: 'add_user_project_permissions',
271-
},
272-
'Email detected, resolving to username via NATS'
273-
);
274-
275-
// Resolve email to username via NATS
276-
username = await this.projectService.resolveEmailToUsername(req, userData.username);
277-
278-
req.log.info(
279-
{
280-
email: userData.username,
281-
username,
282-
operation: 'add_user_project_permissions',
283-
},
284-
'Successfully resolved email to username'
285-
);
286-
}
287264

288265
// Check if manual user data is provided (for users not found in directory)
289266
let manualUserInfo: { name: string; email: string; username: string; avatar?: string } | undefined;
290267
if (userData.name || userData.email || userData.avatar) {
291268
manualUserInfo = {
292269
name: userData.name || '',
293270
email: userData.email || '',
294-
username,
271+
username: userData.username, // Keep the original input for manual user info
295272
};
296273
// Only include avatar if it's not empty
297274
if (userData.avatar) {
298275
manualUserInfo.avatar = userData.avatar;
299276
}
300277
}
301278

302-
const result = await this.projectService.updateProjectPermissions(req, uid, 'add', username, userData.role, manualUserInfo);
279+
// Pass the original input (email or username) to updateProjectPermissions
280+
// The service will handle the email_to_sub -> email_to_username flow internally
281+
const result = await this.projectService.updateProjectPermissions(req, uid, 'add', userData.username, userData.role, manualUserInfo);
303282

304283
Logger.success(req, 'add_user_project_permissions', startTime, {
305284
uid,
306-
username,
285+
username: userData.username,
307286
role: userData.role,
308-
resolved_from_email: isEmail,
287+
is_email: isEmail,
309288
});
310289

311290
res.status(201).json(result);

apps/lfx-one/src/server/services/project.service.ts

Lines changed: 151 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -148,19 +148,27 @@ export class ProjectService {
148148
req: Request,
149149
projectId: string,
150150
operation: 'add' | 'update' | 'remove',
151-
username: string,
151+
usernameOrEmail: string,
152152
role?: 'view' | 'manage',
153153
manualUserInfo?: { name: string; email: string; username: string; avatar?: string }
154154
): Promise<ProjectSettings> {
155-
// Step 1: Fetch current settings with ETag
155+
// Step 1: Determine the appropriate identifier for backend operations
156+
// For emails, use the sub; for usernames, use the username directly
157+
let backendIdentifier = usernameOrEmail.trim();
158+
if (usernameOrEmail.includes('@')) {
159+
// For emails, get the sub for backend operations
160+
backendIdentifier = await this.resolveEmailToSub(req, usernameOrEmail);
161+
}
162+
163+
// Step 2: Fetch current settings with ETag
156164
const { data: settings, etag } = await this.etagService.fetchWithETag<ProjectSettings>(
157165
req,
158166
'LFX_V2_SERVICE',
159167
`/projects/${projectId}/settings`,
160168
`${operation}_user_project_permissions`
161169
);
162170

163-
// Step 2: Update the settings based on operation
171+
// Step 3: Update the settings based on operation
164172
const updatedSettings = { ...settings };
165173

166174
// Initialize arrays if they don't exist
@@ -169,8 +177,9 @@ export class ProjectService {
169177

170178
// Remove user from both arrays first (for all operations)
171179
// Compare by username property since writers/auditors are now UserInfo objects
172-
updatedSettings.writers = updatedSettings.writers.filter((u) => u.username !== username);
173-
updatedSettings.auditors = updatedSettings.auditors.filter((u) => u.username !== username);
180+
// Use backendIdentifier (sub) for comparison to ensure proper removal
181+
updatedSettings.writers = updatedSettings.writers.filter((u) => u.username !== backendIdentifier);
182+
updatedSettings.auditors = updatedSettings.auditors.filter((u) => u.username !== backendIdentifier);
174183

175184
// For 'add' or 'update', we need to add the user back with full UserInfo
176185
if (operation === 'add' || operation === 'update') {
@@ -183,23 +192,28 @@ export class ProjectService {
183192
if (manualUserInfo) {
184193
req.log.info(
185194
{
186-
username,
195+
username: backendIdentifier,
187196
operation: `${operation}_user_project_permissions`,
188197
},
189198
'Using manually provided user info'
190199
);
191200
userInfo = {
192201
name: manualUserInfo.name,
193202
email: manualUserInfo.email,
194-
username: manualUserInfo.username,
203+
username: backendIdentifier, // Use the sub for backend consistency
195204
};
196205
// Only include avatar if it's provided and not empty
197206
if (manualUserInfo.avatar) {
198207
userInfo.avatar = manualUserInfo.avatar;
199208
}
200209
} else {
201-
// Fetch user info from user service via NATS
202-
userInfo = await this.getUserInfo(req, username);
210+
// Fetch user info from user service via NATS using the original input
211+
const fetchedUserInfo = await this.getUserInfo(req, usernameOrEmail);
212+
// Use the backend identifier (sub) for the username in the stored UserInfo for backend consistency
213+
userInfo = {
214+
...fetchedUserInfo,
215+
username: backendIdentifier, // Use the sub for backend consistency
216+
};
203217
}
204218

205219
if (role === 'manage') {
@@ -250,7 +264,7 @@ export class ProjectService {
250264
{
251265
operation: `${operation}_user_project_permissions`,
252266
project_id: projectId,
253-
username,
267+
username: backendIdentifier,
254268
role: role || 'N/A',
255269
},
256270
`User ${operation} operation completed successfully`
@@ -260,13 +274,13 @@ export class ProjectService {
260274
}
261275

262276
/**
263-
* Resolve email address to username using NATS request-reply pattern
277+
* Resolve email address to sub using NATS request-reply pattern
264278
* @param req - Express request object for logging
265279
* @param email - Email address to lookup
266-
* @returns Username (sub) associated with the email
280+
* @returns Sub associated with the email (used for backend auditors/writers)
267281
* @throws ResourceNotFoundError if user not found
268282
*/
269-
public async resolveEmailToUsername(req: Request, email: string): Promise<string> {
283+
public async resolveEmailToSub(req: Request, email: string): Promise<string> {
270284
const codec = this.natsService.getCodec();
271285

272286
// Normalize email input
@@ -345,8 +359,93 @@ export class ProjectService {
345359
}
346360

347361
/**
348-
* Fetch user information by username using NATS request-reply pattern
349-
* The username can be an email or actual username - if email, it will be resolved first
362+
* Resolve email address to username using NATS request-reply pattern
363+
* @param req - Express request object for logging
364+
* @param email - Email address to lookup
365+
* @returns Username associated with the email (used for display purposes)
366+
* @throws ResourceNotFoundError if user not found
367+
*/
368+
public async resolveEmailToUsername(req: Request, email: string): Promise<string> {
369+
const codec = this.natsService.getCodec();
370+
371+
// Normalize email input
372+
const normalizedEmail = email.trim().toLowerCase();
373+
374+
try {
375+
req.log.info({ email: normalizedEmail }, 'Resolving email to username via NATS');
376+
377+
const response = await this.natsService.request(NatsSubjects.EMAIL_TO_USERNAME, codec.encode(normalizedEmail), { timeout: NATS_CONFIG.REQUEST_TIMEOUT });
378+
379+
const responseText = codec.decode(response.data);
380+
381+
// Parse once and branch on the result shape
382+
let username: string;
383+
try {
384+
const parsed = JSON.parse(responseText);
385+
386+
// Check if it's an error response
387+
if (typeof parsed === 'object' && parsed !== null && parsed.success === false) {
388+
req.log.info({ email: normalizedEmail, error: parsed.error }, 'User email not found via NATS');
389+
390+
throw new ResourceNotFoundError('User', normalizedEmail, {
391+
operation: 'resolve_email_to_username',
392+
service: 'project_service',
393+
path: '/nats/email-to-username',
394+
});
395+
}
396+
397+
// Extract username from JSON success response or JSON string
398+
username = typeof parsed === 'string' ? parsed : parsed.username;
399+
} catch (parseError) {
400+
// Re-throw ResourceNotFoundError as-is
401+
if (parseError instanceof ResourceNotFoundError) {
402+
throw parseError;
403+
}
404+
405+
// JSON parsing failed - use raw text as username
406+
username = responseText;
407+
}
408+
409+
// Trim and validate username
410+
username = username.trim();
411+
412+
if (!username || username === '') {
413+
req.log.info({ email: normalizedEmail }, 'Empty username returned from NATS');
414+
415+
throw new ResourceNotFoundError('User', normalizedEmail, {
416+
operation: 'resolve_email_to_username',
417+
service: 'project_service',
418+
path: '/nats/email-to-username',
419+
});
420+
}
421+
422+
req.log.info({ email: normalizedEmail, username }, 'Successfully resolved email to username');
423+
424+
return username;
425+
} catch (error) {
426+
// Re-throw ResourceNotFoundError as-is
427+
if (error instanceof ResourceNotFoundError) {
428+
throw error;
429+
}
430+
431+
req.log.error({ error: error instanceof Error ? error.message : error, email: normalizedEmail }, 'Failed to resolve email to username via NATS');
432+
433+
// If it's a timeout or no responder error, treat as not found
434+
if (error instanceof Error && (error.message.includes('timeout') || error.message.includes('503'))) {
435+
throw new ResourceNotFoundError('User', normalizedEmail, {
436+
operation: 'resolve_email_to_username',
437+
service: 'project_service',
438+
path: '/nats/email-to-username',
439+
});
440+
}
441+
442+
throw error;
443+
}
444+
}
445+
446+
/**
447+
* Fetch user information by username or email using NATS request-reply pattern
448+
* For emails, it resolves to username first, then uses the username for user metadata lookup
350449
* @param req - Express request object for logging
351450
* @param usernameOrEmail - Username or email to lookup
352451
* @returns UserInfo object with name, email, username, and optional avatar
@@ -355,51 +454,65 @@ export class ProjectService {
355454
public async getUserInfo(req: Request, usernameOrEmail: string): Promise<{ name: string; email: string; username: string; avatar?: string }> {
356455
const codec = this.natsService.getCodec();
357456

358-
// First, check if input is an email and resolve to username if needed
359-
let username = usernameOrEmail.trim();
457+
// For emails, resolve to username for the NATS lookup
458+
// For usernames, use them directly
459+
let usernameForLookup = usernameOrEmail.trim();
460+
let originalEmail = '';
461+
360462
if (usernameOrEmail.includes('@')) {
361-
username = await this.resolveEmailToUsername(req, usernameOrEmail);
463+
originalEmail = usernameOrEmail;
464+
// First confirm the user exists with email_to_sub
465+
await this.resolveEmailToSub(req, usernameOrEmail);
466+
// Then get the username for user metadata lookup
467+
usernameForLookup = await this.resolveEmailToUsername(req, usernameOrEmail);
468+
req.log.info({ email: originalEmail, resolvedUsername: usernameForLookup }, 'Resolved email to username for user metadata lookup');
362469
}
363470

364471
try {
365-
req.log.info({ username }, 'Fetching user info via NATS');
472+
req.log.info({ username: usernameForLookup }, 'Fetching user metadata via NATS');
366473

367-
const response = await this.natsService.request(NatsSubjects.USERNAME_TO_USER_INFO, codec.encode(username), { timeout: NATS_CONFIG.REQUEST_TIMEOUT });
474+
const response = await this.natsService.request(NatsSubjects.USER_METADATA_READ, codec.encode(usernameForLookup), {
475+
timeout: NATS_CONFIG.REQUEST_TIMEOUT,
476+
});
368477

369478
const responseText = codec.decode(response.data);
370-
const userInfo = JSON.parse(responseText);
479+
const userMetadata = JSON.parse(responseText);
371480

372481
// Validate response structure
373-
if (!userInfo || typeof userInfo !== 'object') {
374-
throw new ResourceNotFoundError('User', username, {
482+
if (!userMetadata || typeof userMetadata !== 'object') {
483+
throw new ResourceNotFoundError('User', usernameForLookup, {
375484
operation: 'get_user_info',
376485
service: 'project_service',
377-
path: '/nats/username-to-user-info',
486+
path: '/nats/user-metadata-read',
378487
});
379488
}
380489

381490
// Check if it's an error response
382-
if (userInfo.success === false) {
383-
req.log.info({ username, error: userInfo.error }, 'User not found via NATS');
491+
if (userMetadata.success === false) {
492+
req.log.info({ username: usernameForLookup, error: userMetadata.error }, 'User metadata not found via NATS');
384493

385-
throw new ResourceNotFoundError('User', username, {
494+
throw new ResourceNotFoundError('User', usernameForLookup, {
386495
operation: 'get_user_info',
387496
service: 'project_service',
388-
path: '/nats/username-to-user-info',
497+
path: '/nats/user-metadata-read',
389498
});
390499
}
391500

392-
req.log.info({ username }, 'Successfully fetched user info');
501+
req.log.info({ username: usernameForLookup }, 'Successfully fetched user metadata');
502+
503+
const userData = userMetadata.data || {};
393504

394505
const result: { name: string; email: string; username: string; avatar?: string } = {
395-
name: userInfo.name || '',
396-
email: userInfo.email || '',
397-
username: userInfo.username || username,
506+
// Use the name from metadata, fallback to constructed name from given_name/family_name
507+
name: userData.name || `${userData.given_name || ''} ${userData.family_name || ''}`.trim() || usernameForLookup,
508+
// Use the original email if we had one, otherwise leave empty
509+
email: originalEmail || '',
510+
username: usernameForLookup,
398511
};
399512

400-
// Only include avatar if it exists and is not empty
401-
if (userInfo.avatar && userInfo.avatar.trim() !== '') {
402-
result.avatar = userInfo.avatar;
513+
// Use picture field as avatar if available
514+
if (userData.picture && userData.picture.trim() !== '') {
515+
result.avatar = userData.picture;
403516
}
404517

405518
return result;
@@ -409,14 +522,14 @@ export class ProjectService {
409522
throw error;
410523
}
411524

412-
req.log.error({ error: error instanceof Error ? error.message : error, username }, 'Failed to fetch user info via NATS');
525+
req.log.error({ error: error instanceof Error ? error.message : error, username: usernameForLookup }, 'Failed to fetch user metadata via NATS');
413526

414527
// If it's a timeout or no responder error, treat as not found
415528
if (error instanceof Error && (error.message.includes('timeout') || error.message.includes('503'))) {
416-
throw new ResourceNotFoundError('User', username, {
529+
throw new ResourceNotFoundError('User', usernameForLookup, {
417530
operation: 'get_user_info',
418531
service: 'project_service',
419-
path: '/nats/username-to-user-info',
532+
path: '/nats/user-metadata-read',
420533
});
421534
}
422535

packages/shared/src/enums/nats.enum.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@
77
export enum NatsSubjects {
88
PROJECT_SLUG_TO_UID = 'lfx.projects-api.slug_to_uid',
99
USER_METADATA_UPDATE = 'lfx.auth-service.user_metadata.update',
10+
USER_METADATA_READ = 'lfx.auth-service.user_metadata.read',
1011
EMAIL_TO_USERNAME = 'lfx.auth-service.email_to_username',
1112
EMAIL_TO_SUB = 'lfx.auth-service.email_to_sub',
1213
USERNAME_TO_USER_INFO = 'lfx.auth-service.username_to_user_info',

0 commit comments

Comments
 (0)