From de6fc65dbdf02a9fc8f4745c712d95216108619e Mon Sep 17 00:00:00 2001 From: shreddedbacon Date: Mon, 25 Aug 2025 06:58:17 +1000 Subject: [PATCH 1/3] feat: show if user has 2fa or is federated user in api --- Makefile | 2 +- services/api/src/models/group.ts | 25 +++++++-- services/api/src/models/user.ts | 93 +++++++++++++++++++++++++++----- services/api/src/typeDefs.js | 4 ++ 4 files changed, 107 insertions(+), 17 deletions(-) diff --git a/Makefile b/Makefile index 182a28864b..a89693772f 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ BUILD_DEPLOY_IMAGE_TAG ?= edge # UI_IMAGE_REPO and UI_IMAGE_TAG are an easy way to override the UI image used # only works for installations where INSTALL_STABLE_CORE=false UI_IMAGE_REPO = uselagoon/ui -UI_IMAGE_TAG = main +UI_IMAGE_TAG = pr-340 # The two variables below are an easy way to override the insights-handler image used in the local stack lagoon-core # only works for installations where ENABLE_INSIGHTS=true and INSTALL_STABLE_CORE=false diff --git a/services/api/src/models/group.ts b/services/api/src/models/group.ts index d3d5d19793..07cc3e6833 100644 --- a/services/api/src/models/group.ts +++ b/services/api/src/models/group.ts @@ -362,13 +362,32 @@ export const Group = (clients: { } }; + // load all keycloak groups using pagination + const loadAllKeycloakGroups = async (): Promise => { + let groups = []; + let first = 0; + let pageSize = 200; + let fetchedGroups; + while (true) { + fetchedGroups = await keycloakAdminClient.groups.find({ + briefRepresentation: false, + first: first, + max: pageSize + }); + groups = groups.concat(fetchedGroups) + first += pageSize; + if (fetchedGroups.length < pageSize) { + break; + } + } + return groups; + }; + const loadAllGroups = async (): Promise => { // briefRepresentation pulls all the group information from keycloak // including the attributes this means we don't need to iterate over all the // groups one by one anymore to get the full group information - const keycloakGroups = await keycloakAdminClient.groups.find({ - briefRepresentation: false, - }); + const keycloakGroups = await loadAllKeycloakGroups(); let fullGroups: KeycloakLagoonGroup[] = []; for (const group of keycloakGroups) { diff --git a/services/api/src/models/user.ts b/services/api/src/models/user.ts index a9f6a23a7c..a8a720d86f 100644 --- a/services/api/src/models/user.ts +++ b/services/api/src/models/user.ts @@ -37,6 +37,15 @@ export interface User { organizationRole?: string; platformRoles?: [string]; emailNotifications?: IEmailNotifications; + totp?: boolean; + has2faEnabled?: boolean; + isFederatedUser?: boolean; +} + + +interface FederatedIdentity { + hasIdentities?: boolean; + gitlabId?: string; } interface UserEdit { @@ -166,18 +175,46 @@ export const User = (clients: { )(user.attributes) )(users); - const fetchGitlabId = async (user: User): Promise => { + + const fetchUserTwoFactor = async (user: User): Promise => { + const credTypes = await keycloakAdminClient.users.getCredentials({ + id: user.id + }); + let hasTwoFactor = false + for(const cred of credTypes) { + switch (cred.type.replace(/"/g, "")) { + case "webauthn": + case "otp": + hasTwoFactor = true + break; + default: + break; + } + } + return hasTwoFactor; + }; + + + const fetchFederatedIdentities = async (user: User): Promise => { + let userFed: FederatedIdentity = { + hasIdentities: false, + gitlabId: null, + }; const identities = await keycloakAdminClient.users.listFederatedIdentities({ id: user.id }); + if (identities.length > 0) { + userFed.hasIdentities = true; + } + const gitlabIdentity = R.find( R.propEq('identityProvider', 'gitlab'), identities ); - // @ts-ignore - return R.defaultTo(undefined, R.prop('userId', gitlabIdentity)); + userFed.gitlabId = R.defaultTo(undefined, R.prop('userId', gitlabIdentity)) + return userFed; }; const transformKeycloakUsers = async ( @@ -206,9 +243,21 @@ export const User = (clients: { if (userdate.length) { user.lastAccessed = userdate[0].lastAccessed } + user.has2faEnabled = await fetchUserTwoFactor(user) + let userFed = await fetchFederatedIdentities(user); + if (userFed.hasIdentities) { + // if a federated user sets up 2fa or passkeys in their account after they have logged in + // it will never be used unless the user unlinks themselves from the identity provider + // if that happens, then the user will have no federated user and their 2fa status will show correctly + // if a user is federated though, then their 2fa status is managed/enforced in the external provider + // and lagoon has no way to verify if 2fa is enabled or enforced in that provider so just set that the user + // is a federated user + user.has2faEnabled = false + user.isFederatedUser = userFed.hasIdentities + } usersWithGitlabIdFetch.push({ ...user, - gitlabId: await fetchGitlabId(user) + gitlabId: userFed.gitlabId }); } @@ -300,6 +349,7 @@ export const User = (clients: { // from various people with issues with this email = email.toLocaleLowerCase(); const keycloakUsers = await keycloakAdminClient.users.find({ + briefRepresentation: false, email }); @@ -332,6 +382,27 @@ export const User = (clients: { throw new Error('You must provide a user id or email'); }; + // load all keycloak users using pagination + const loadAllKeycloakUsers = async (): Promise => { + let users = []; + let first = 0; + let pageSize = 200; + let fetchedUsers; + while (true) { + fetchedUsers = await keycloakAdminClient.users.find({ + briefRepresentation: false, + first: first, + max: pageSize + }); + users = users.concat(fetchedUsers) + first += pageSize; + if (fetchedUsers.length < pageSize) { + break; + } + } + return users; + }; + // used to list onwers of organizations const loadUsersByOrganizationId = async (organizationId: number): Promise => { const ownerFilter = attribute => { @@ -365,7 +436,7 @@ export const User = (clients: { return false; }; - const keycloakUsers = await keycloakAdminClient.users.find({briefRepresentation: false, max: -1}); + let keycloakUsers = await loadAllKeycloakUsers() let filteredOwners = filterUsersByAttribute(keycloakUsers, ownerFilter); let filteredAdmins = filterUsersByAttribute(keycloakUsers, adminFilter); @@ -389,13 +460,9 @@ export const User = (clients: { }; const loadAllUsers = async (): Promise => { - const keycloakUsers = await keycloakAdminClient.users.find({ - max: -1 - }); - - const users = await transformKeycloakUsers(keycloakUsers); - - return users; + let users = await loadAllKeycloakUsers() + const keycloakUsers = await transformKeycloakUsers(users) + return keycloakUsers; }; const loadAllPlatformUsers = async (): Promise => { @@ -804,7 +871,7 @@ const getAllProjectsIdsForUser = async ( } } - // Purge the Group cache for all groups the user belongs to, + // Purge the Group cache for all groups the user belongs to, // so it does not have out of date information. const GroupModel = Group(clients); const userGroups = await getAllGroupsForUser(userInput.id); diff --git a/services/api/src/typeDefs.js b/services/api/src/typeDefs.js index 0bff241d5c..cbabc0732c 100644 --- a/services/api/src/typeDefs.js +++ b/services/api/src/typeDefs.js @@ -436,6 +436,8 @@ const typeDefs = gql` created: String lastAccessed: String emailNotifications: UserEmailNotification + has2faEnabled: Boolean + isFederatedUser: Boolean } enum PlatformRole { @@ -1066,6 +1068,8 @@ const typeDefs = gql` admin: Boolean @deprecated(reason: "use organizationRole") owner: Boolean @deprecated(reason: "use organizationRole") comment: String + has2faEnabled: Boolean + isFederatedUser: Boolean groupRoles: [GroupRoleInterface] organizationRole: OrganizationRole } From d79f3499ce5bb6b0903739c901e9362a732068f2 Mon Sep 17 00:00:00 2001 From: shreddedbacon Date: Tue, 9 Sep 2025 13:09:00 +1000 Subject: [PATCH 2/3] refactor: move user2fa check to separate resolver --- services/api/src/models/user.ts | 12 +++--------- services/api/src/resolvers.js | 3 +++ services/api/src/resources/user/resolvers.ts | 18 ++++++++++++++++-- 3 files changed, 22 insertions(+), 11 deletions(-) diff --git a/services/api/src/models/user.ts b/services/api/src/models/user.ts index a8a720d86f..131b35abfe 100644 --- a/services/api/src/models/user.ts +++ b/services/api/src/models/user.ts @@ -85,6 +85,7 @@ export interface UserModel { userLastAccessed: (userInput: User) => Promise; transformKeycloakUsers: (keycloakUsers: UserRepresentation[]) => Promise; getFullUserDetails: (userInput: User) => Promise; + fetchUserTwoFactor: (user: User) => Promise; } // these match the names of the roles created in keycloak @@ -243,16 +244,8 @@ export const User = (clients: { if (userdate.length) { user.lastAccessed = userdate[0].lastAccessed } - user.has2faEnabled = await fetchUserTwoFactor(user) let userFed = await fetchFederatedIdentities(user); if (userFed.hasIdentities) { - // if a federated user sets up 2fa or passkeys in their account after they have logged in - // it will never be used unless the user unlinks themselves from the identity provider - // if that happens, then the user will have no federated user and their 2fa status will show correctly - // if a user is federated though, then their 2fa status is managed/enforced in the external provider - // and lagoon has no way to verify if 2fa is enabled or enforced in that provider so just set that the user - // is a federated user - user.has2faEnabled = false user.isFederatedUser = userFed.hasIdentities } usersWithGitlabIdFetch.push({ @@ -1033,6 +1026,7 @@ const getAllProjectsIdsForUser = async ( deleteUser, resetUserPassword, transformKeycloakUsers, - getFullUserDetails + getFullUserDetails, + fetchUserTwoFactor, }; }; diff --git a/services/api/src/resolvers.js b/services/api/src/resolvers.js index b8b538082a..4883d1445b 100644 --- a/services/api/src/resolvers.js +++ b/services/api/src/resolvers.js @@ -218,6 +218,7 @@ const { getAllPlatformUsers, addPlatformRoleToUser, removePlatformRoleFromUser, + getUser2fa, } = require('./resources/user/resolvers'); const { @@ -609,11 +610,13 @@ async function getResolvers() { } }, User: { + has2faEnabled: getUser2fa, sshKeys: getUserSshKeys, groups: getGroupsByUserId, groupRoles: getGroupRolesByUserId, }, OrgUser: { + has2faEnabled: getUser2fa, groupRoles: getGroupRolesByUserIdAndOrganization, }, Backup: { diff --git a/services/api/src/resources/user/resolvers.ts b/services/api/src/resources/user/resolvers.ts index c47419c989..5ddc6781b1 100644 --- a/services/api/src/resources/user/resolvers.ts +++ b/services/api/src/resources/user/resolvers.ts @@ -5,9 +5,7 @@ import { Helpers as organizationHelpers } from '../organization/helpers'; import { Sql } from './sql'; import { AuditType } from '@lagoon/commons/dist/types'; import { AuditLog } from '../audit/types'; -import { get } from 'http'; import { sendToLagoonLogs } from '@lagoon/commons/dist/logs/lagoon-logger'; -import { send } from 'process'; import { UserModel, User } from '../../models/user'; export const getMe: ResolverFn = async (_root, args, { models, keycloakGrant: grant }) => { @@ -786,3 +784,19 @@ async function getUserOrgEmailDetails(action, models: { UserModel: UserModel; }, return emailDetails; } +export const getUser2fa: ResolverFn = async ( + user, + _, + { models } +) => { + if (user.isFederatedUser !== undefined && user.isFederatedUser === true ) { + // if a federated user sets up 2fa or passkeys in their account after they have logged in + // it will never be used unless the user unlinks themselves from the identity provider + // if that happens, then the user will have no federated user and their 2fa status will show correctly + // if a user is federated though, then their 2fa status is managed/enforced in the external provider + // and lagoon has no way to verify if 2fa is enabled or enforced in that provider so just set that the user + // is a federated user + return false + } + return models.UserModel.fetchUserTwoFactor(user) +}; \ No newline at end of file From 90289cc5055e46e71b4d3efb56ee89dcdf9345a0 Mon Sep 17 00:00:00 2001 From: shreddedbacon Date: Wed, 10 Sep 2025 09:24:41 +1000 Subject: [PATCH 3/3] chore: revert ui image tag override --- Makefile | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Makefile b/Makefile index a89693772f..182a28864b 100644 --- a/Makefile +++ b/Makefile @@ -56,7 +56,7 @@ BUILD_DEPLOY_IMAGE_TAG ?= edge # UI_IMAGE_REPO and UI_IMAGE_TAG are an easy way to override the UI image used # only works for installations where INSTALL_STABLE_CORE=false UI_IMAGE_REPO = uselagoon/ui -UI_IMAGE_TAG = pr-340 +UI_IMAGE_TAG = main # The two variables below are an easy way to override the insights-handler image used in the local stack lagoon-core # only works for installations where ENABLE_INSIGHTS=true and INSTALL_STABLE_CORE=false