Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: user management #118

Merged
merged 25 commits into from
Oct 14, 2024
Merged
Show file tree
Hide file tree
Changes from 16 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8a1e915
chore: release branch [ci skip] (#116)
Ani1357 Sep 18, 2024
1647ce6
feat: update readme and launch&tasks.json files for keycloak operator
ferruhcihan Sep 18, 2024
b26edab
feat: rm unnecessary lines from launch.json and package.json
ferruhcihan Sep 18, 2024
b7f14f7
feat: add createTeamUser in the keycloak operator
ferruhcihan Sep 19, 2024
dbff66a
Merge branch 'main' into APL-221
ferruhcihan Sep 24, 2024
b9d112e
feat: update groups
ferruhcihan Sep 25, 2024
21a7ada
feat: update create user
ferruhcihan Sep 25, 2024
86a9234
feat: update harbor operator
ferruhcihan Sep 25, 2024
3d769bd
feat: update create user
ferruhcihan Sep 26, 2024
e72ac42
feat: add delete keycloak users function
ferruhcihan Sep 26, 2024
1bb4d4e
test: delete keycloak users
ferruhcihan Sep 27, 2024
9dae385
feat: update create keycloak user
ferruhcihan Sep 27, 2024
b720b98
feat: update keycloak user without credentials
ferruhcihan Sep 27, 2024
008fb62
test: update user
ferruhcihan Sep 27, 2024
7d3f173
feat: update keycloak createUpdateUser
ferruhcihan Sep 27, 2024
e6e8fc4
feat: update keycloak.ts
ferruhcihan Sep 30, 2024
a89275d
test: users
ferruhcihan Oct 2, 2024
efcd26c
feat: keycloak userCreateUpdate
ferruhcihan Oct 2, 2024
5575ba9
feat: update user creation
ferruhcihan Oct 4, 2024
a6bae9e
feat: update keycloak manageUsers flow
ferruhcihan Oct 7, 2024
368bc63
Merge branch 'main' into APL-221
ferruhcihan Oct 8, 2024
2479e86
feat: update keycloak.ts and add keycloak.test.ts
ferruhcihan Oct 8, 2024
4c3fd7f
feat: update manageUsers
ferruhcihan Oct 9, 2024
1341489
feat: update existing otomi-admin user groups
ferruhcihan Oct 10, 2024
df3f196
fix: update platformAdminUser variable name
ferruhcihan Oct 10, 2024
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
1 change: 1 addition & 0 deletions .cspell.json
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
"camelcase",
"creds",
"gitea",
"keycloak",
"kubernetes",
"oidc",
"openid",
Expand Down
34 changes: 5 additions & 29 deletions .vscode/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -113,41 +113,17 @@
{
"type": "node",
"request": "launch",
"name": "Debug Harbor task",
"runtimeExecutable": "npm",
"runtimeArgs": ["run-script", "tasks:harbor-dev"],
"cwd": "${workspaceRoot}",
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env"
// "env": {
// "NODE_EXTRA_CA_CERTS": "${workspaceFolder}/.env.ca"
// }
},
{
"type": "node",
"request": "launch",
"name": "Debug Keycloak task",
"runtimeExecutable": "npm",
"runtimeArgs": ["run-script", "tasks:keycloak-dev"],
"cwd": "${workspaceRoot}",
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env",
"env": {
"NODE_EXTRA_CA_CERTS": "${workspaceFolder}/.env.ca"
}
},
{
"type": "node",
"request": "launch",
"name": "Debug Keycloak operator",
"name": "Debug keycloak operator",
"runtimeExecutable": "npm",
"runtimeArgs": ["run-script", "operator:keycloak-dev"],
"cwd": "${workspaceRoot}",
"console": "integratedTerminal",
"envFile": "${workspaceFolder}/.env",
"env": {
"NODE_EXTRA_CA_CERTS": "${workspaceFolder}/.env.ca"
}
"NODE_EXTRA_CA_CERTS": "${workspaceFolder}/.env.ca",
"KUBECONFIG": "/path/to/your/kubeconfig.yaml",
},
"preLaunchTask": "port-forward-keycloak"
},
{
"type": "node",
Expand Down
11 changes: 11 additions & 0 deletions .vscode/tasks.json
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,17 @@
"reveal": "always",
"panel": "new"
}
},
{
"label": "port-forward-keycloak",
"type": "shell",
"command": "export KUBECONFIG=/path/to/your/kubeconfig.yaml && kubectl -n keycloak port-forward svc/keycloak-operator 8084:80",
"problemMatcher": [],
"isBackground": true,
"presentation": {
"reveal": "always",
"panel": "new"
}
}
]
}
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,7 @@ Then start a proxy to the api you wish to target:

- gitea: `k -n gitea port-forward svc/gitea-http 8082:3000 &`
- harbor: `k -n harbor port-forward svc/harbor-core 8083:80 &`
- keycloak: `k -n keycloak port-forward svc/keycloak-http 8084:80 &`
- keycloak: `k -n keycloak port-forward svc/keycloak-operator 8084:80 &`

Or start them all with `bin/start-proxies.sh`

Expand Down
4 changes: 0 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -122,10 +122,6 @@
"tasks:copy-certs-dev": "ts-node-dev ./src/tasks/otomi/copy-certs.ts",
"tasks:copy-certs": "node dist/tasks/otomi/copy-certs.js",
"tasks:copy-certs-argo": "node dist/tasks/otomi/copy-certs-argo.js",
"tasks:harbor-dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 ts-node-dev ./src/tasks/harbor/harbor.ts",
"tasks:harbor": "node dist/tasks/harbor/harbor.js",
"tasks:keycloak-dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 ts-node-dev ./src/tasks/keycloak/keycloak.ts",
"tasks:keycloak": "node dist/tasks/keycloak/keycloak.js",
"tasks:keycloak-users-dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 ts-node-dev ./src/tasks/keycloak/users.ts",
"tasks:otomi-chart-dev": "NODE_TLS_REJECT_UNAUTHORIZED=0 ts-node-dev ./src/tasks/otomi/otomi-chart.ts",
"tasks:otomi-chart": "node dist/tasks/otomi/otomi-chart.js",
Expand Down
8 changes: 4 additions & 4 deletions src/operator/harbor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -257,7 +257,7 @@ async function setupHarbor() {

const config: any = {
auth_mode: 'oidc_auth',
oidc_admin_group: 'admin',
oidc_admin_group: 'platform-admin',
j-zimnowoda marked this conversation as resolved.
Show resolved Hide resolved
oidc_client_id: 'otomi',
oidc_client_secret: env.oidcClientSecret,
oidc_endpoint: env.oidcEndpoint,
Expand Down Expand Up @@ -309,7 +309,7 @@ async function getBearerToken(): Promise<HttpBearerAuth> {
// unauthenticated, so remove and recreate secret
await k8sApi.deleteNamespacedSecret(systemSecretName, systemNamespace)
// now, the next call might throw IF:
// - authMode oidc was already turned on and an otomi admin accidentally removed the secret
// - authMode oidc was already turned on and an platform admin accidentally removed the secret
// but that is very unlikely, an unresolvable problem and needs a manual db fix
robotSecret = await createSystemRobotSecret()
}
Expand Down Expand Up @@ -366,7 +366,7 @@ async function processNamespace(namespace: string) {
const projAdminMember: ProjectMember = {
roleId: HarborRole.admin,
memberGroup: {
groupName: 'team-admin',
groupName: 'all-teams-admin',
j-zimnowoda marked this conversation as resolved.
Show resolved Hide resolved
groupType: HarborGroupType.http,
},
}
Expand All @@ -377,7 +377,7 @@ async function processNamespace(namespace: string) {
)
await doApiCall(
errors,
`Associating "project-admin" role for "team-admin" with harbor project "${projectName}"`,
`Associating "project-admin" role for "all-teams-admin" with harbor project "${projectName}"`,
() => memberApi.createProjectMember(projectId, undefined, undefined, projAdminMember),
)

Expand Down
91 changes: 77 additions & 14 deletions src/operator/keycloak.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import {
UserRepresentation,
UsersApi,
} from '@linode/keycloak-client-node'
import { forEach } from 'lodash'
import { forEach, omit } from 'lodash'
import { custom, Issuer, TokenSet } from 'openid-client'
import { keycloakRealm } from '../tasks/keycloak/config'
import {
Expand All @@ -36,9 +36,10 @@ import {
createIdProvider,
createLoginThemeConfig,
createRealm,
createTeamUser,
mapTeamsToRoles,
} from '../tasks/keycloak/realm-factory'
import { doApiCall, waitTillAvailable } from '../utils'
import { doApiCall } from '../utils'
import {
cleanEnv,
KEYCLOAK_TOKEN_OFFLINE_MAX_TTL_ENABLED,
Expand Down Expand Up @@ -79,8 +80,9 @@ const env = {
IDP_OIDC_URL: '',
IDP_CLIENT_ID: '',
IDP_CLIENT_SECRET: '',
IDP_GROUP_OTOMI_ADMIN: '',
IDP_GROUP_TEAM_ADMIN: '',
IDP_GROUP_ALL_TEAMS_ADMIN: '',
IDP_GROUP_PLATFORM_ADMIN: '',
IDP_GROUP_MAPPINGS_TEAMS: {} || undefined,
IDP_SUB_CLAIM_MAPPER: '',
IDP_USERNAME_CLAIM_MAPPER: '',
Expand All @@ -96,6 +98,7 @@ const env = {
REDIRECT_URIS: [] as string[],
TEAM_IDS: [] as string[],
WAIT_OPTIONS: {},
USERS: [],
}

const kc = new KubeConfig()
Expand All @@ -115,8 +118,8 @@ async function runKeycloakUpdater(key: string) {
!env.IDP_OIDC_URL ||
!env.IDP_CLIENT_ID ||
!env.IDP_CLIENT_SECRET ||
!env.IDP_GROUP_OTOMI_ADMIN ||
!env.IDP_GROUP_TEAM_ADMIN ||
!env.IDP_GROUP_PLATFORM_ADMIN ||
!env.IDP_GROUP_ALL_TEAMS_ADMIN ||
!env.IDP_GROUP_MAPPINGS_TEAMS ||
!env.IDP_SUB_CLAIM_MAPPER ||
!env.IDP_USERNAME_CLAIM_MAPPER
Expand Down Expand Up @@ -168,6 +171,7 @@ async function runKeycloakUpdater(key: string) {
await keycloakConfigMapChanges().then(async () => {
await runKeycloakUpdater('addTeam')
})
await manageUsers(env.USERS)
break
} catch (error) {
console.debug('Error could not update configMap', error)
Expand Down Expand Up @@ -206,6 +210,7 @@ export default class MyOperator extends Operator {
if (data!.IDP_CLIENT_ID) env.IDP_CLIENT_ID = Buffer.from(data!.IDP_CLIENT_ID, 'base64').toString()
if (data!.IDP_CLIENT_SECRET)
env.IDP_CLIENT_SECRET = Buffer.from(data!.IDP_CLIENT_SECRET, 'base64').toString()
env.USERS = JSON.parse(Buffer.from(data!.USERS, 'base64').toString())
await runKeycloakUpdater('updateConfig').then(() => {
console.log('Updated Config')
})
Expand Down Expand Up @@ -250,7 +255,8 @@ export default class MyOperator extends Operator {
if (env.FEAT_EXTERNAL_IDP === 'true') {
env.IDP_ALIAS = data!.IDP_ALIAS
env.IDP_OIDC_URL = data!.IDP_OIDC_URL
env.IDP_GROUP_OTOMI_ADMIN = data!.IDP_GROUP_OTOMI_ADMIN
env.IDP_GROUP_PLATFORM_ADMIN = data!.IDP_GROUP_PLATFORM_ADMIN
env.IDP_GROUP_ALL_TEAMS_ADMIN = data!.IDP_GROUP_ALL_TEAMS_ADMIN
env.IDP_GROUP_TEAM_ADMIN = data!.IDP_GROUP_TEAM_ADMIN
env.IDP_GROUP_MAPPINGS_TEAMS =
Object.keys(data!.IDP_GROUP_MAPPINGS_TEAMS).length === 0
Expand Down Expand Up @@ -358,7 +364,6 @@ async function keycloakTeamDeleted() {
}

async function createKeycloakConnection(): Promise<KeycloakConnection> {
await waitTillAvailable(env.KEYCLOAK_HOSTNAME_URL, undefined, env.WAIT_OPTIONS)
const keycloakAddress = env.KEYCLOAK_HOSTNAME_URL
const basePath = `${keycloakAddress}/admin/realms`
let token: TokenSet
Expand Down Expand Up @@ -446,7 +451,8 @@ async function keycloakRealmProviderConfigurer(api: KeycloakApi) {
env.TEAM_IDS,
env.IDP_GROUP_MAPPINGS_TEAMS,
env.IDP_GROUP_TEAM_ADMIN,
env.IDP_GROUP_OTOMI_ADMIN,
env.IDP_GROUP_ALL_TEAMS_ADMIN,
env.IDP_GROUP_PLATFORM_ADMIN,
env.KEYCLOAK_REALM,
)
const existingRealmRoles = ((await doApiCall(errors, `Getting all roles from realm ${keycloakRealm}`, async () =>
Expand Down Expand Up @@ -516,7 +522,8 @@ async function externalIDP(api: KeycloakApi) {
const idpMappers = createIdpMappers(
env.IDP_ALIAS,
env.IDP_GROUP_MAPPINGS_TEAMS,
env.IDP_GROUP_OTOMI_ADMIN,
env.IDP_GROUP_PLATFORM_ADMIN,
env.IDP_GROUP_ALL_TEAMS_ADMIN,
env.IDP_GROUP_TEAM_ADMIN,
env.IDP_USERNAME_CLAIM_MAPPER,
env.IDP_SUB_CLAIM_MAPPER,
Expand Down Expand Up @@ -608,7 +615,7 @@ async function internalIdp(api: KeycloakApi, connection: KeycloakConnection) {
// set realm roles
const roles: Array<RoleRepresentation> = []
const existingRole = updatedExistingRealmRoles.find(
(el) => el.name === (groupName === 'otomi-admin' ? 'admin' : groupName),
(el) => el.name === (groupName === 'otomi-admin' ? 'platform-admin' : groupName),
) as RoleRepresentation
roles.push(existingRole)
await doApiCall(errors, `Creating role mapping for group ${groupName}`, async () =>
Expand All @@ -630,10 +637,10 @@ async function internalIdp(api: KeycloakApi, connection: KeycloakConnection) {
if (!existingClientRoleMapping) {
// let team members see other users
const accessRoles: Array<RoleRepresentation> = [userViewerRole]
// both otomi-admin and team-admin role will get access to manage users
// so the otomi-admin can login to the 'otomi' realm just like team-admin and see the same
if (groupName === 'team-admin') accessRoles.push(userManagementRole)
if (groupName === 'otomi-admin') accessRoles.push(realmManagementRole)
// both platform-admin and all-teams-admin role will get access to manage users
// so the platform-admin can login to the 'otomi' realm just like all-teams-admin and see the same
if (groupName === 'all-teams-admin') accessRoles.push(userManagementRole)
if (groupName === 'platform-admin') accessRoles.push(realmManagementRole)
await doApiCall(
errors,
`Creating access roles [${accessRoles.map((r) => r.name).join(',')}] mapping for group ${groupName}`,
Expand All @@ -655,6 +662,7 @@ async function internalIdp(api: KeycloakApi, connection: KeycloakConnection) {
api.users.realmUsersGet(keycloakRealm, false, userConf.email),
)) as UserRepresentation[]
const existingUser: UserRepresentation = existingUsersByAdminEmail?.[0]

try {
if (existingUser) {
await doApiCall(errors, `Updating user ${env.KEYCLOAK_ADMIN}`, async () =>
Expand Down Expand Up @@ -700,3 +708,58 @@ async function manageGroups(connection: KeycloakConnection) {
console.error('Error in manageGroups: ', error)
}
}

async function createUpdateUser(api: any, user: any) {
const { username, email, firstName, lastName, isPlatformAdmin, isTeamAdmin, teams, teamId } = user
const userConf = createTeamUser(username, email, firstName, lastName, isPlatformAdmin, isTeamAdmin, teams, teamId)
const existingUsersByUserEmail = (await doApiCall([], `Getting users`, () =>
api.users.realmUsersGet(keycloakRealm, false, `${email}`),
)) as UserRepresentation[]
const existingUser: UserRepresentation = existingUsersByUserEmail?.[0]

try {
if (existingUser) {
console.debug(`User with email ${email} already exists, updating user`)
const updatedUserConf = existingUser.requiredActions?.includes('UPDATE_PASSWORD')
? userConf
: omit(userConf, ['credentials'])
await doApiCall(errors, `Updating user ${username}`, async () =>
api.users.realmUsersIdPut(keycloakRealm, existingUser.id as string, updatedUserConf),
)
} else {
await doApiCall(errors, `Creating user ${username}`, () => api.users.realmUsersPost(keycloakRealm, userConf))
}
} catch (error) {
console.error('Error in internalIDP: ', error)
}
}

async function deleteUsers(api: any, users: any[]) {
try {
const { body: keycloakUsers } = await api.users.realmUsersGet(keycloakRealm)
const filteredUsers = keycloakUsers.filter((user) => user.username !== 'otomi-admin')
const usersToDelete = filteredUsers.filter((user) => !users.some((u) => u.email === user.email))

await Promise.all(
usersToDelete.map(async (user) => {
try {
await api.users.realmUsersIdDelete(keycloakRealm, user.id)
console.debug(`Deleted user ${user.email}`)
} catch (error) {
console.error(`Error deleting user ${user.email}:`, error)
}
}),
)
} catch (error) {
console.error('Error fetching users from Keycloak:', error)
}
}

async function manageUsers(users: any[]) {
const connection = await createKeycloakConnection()
const api = setupKeycloakApi(connection)
// Create/Update users in realm 'otomi'
await Promise.all(users.map((user) => createUpdateUser(api, user)))
// Delete users not in users list
await deleteUsers(api, users)
}
37 changes: 35 additions & 2 deletions src/tasks/keycloak/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,8 +58,8 @@ export const adminUserCfgTpl = (username: string, password: string): Record<stri
email: '[email protected]',
emailVerified: true,
enabled: true,
realmRoles: ['admin'],
groups: ['otomi-admin'],
realmRoles: ['platformAdmin'],
groups: ['platform-admin'],
credentials: [
{
type: 'password',
Expand All @@ -70,6 +70,39 @@ export const adminUserCfgTpl = (username: string, password: string): Record<stri
requiredActions: [],
})

export const teamUserCfgTpl = (
username: string,
email: string,
firstName: string,
lastName: string,
isPlatformAdmin: boolean,
isTeamAdmin: boolean,
teams: string[],
teamId: string,
): Record<string, unknown> => ({
username,
enabled: true,
email,
emailVerified: true,
firstName,
lastName,
realmRoles: [...(isPlatformAdmin ? ['platformAdmin'] : []), ...(isTeamAdmin ? ['teamAdmin'] : []), 'teamMember'],
groups: [
...(isPlatformAdmin ? ['platform-admin'] : []),
...(isTeamAdmin ? ['team-admin'] : []),
`team-${teamId}`,
...(teams.length > 0 ? teams.map((team) => `team-${team}`) : []),
],
credentials: [
{
type: 'password',
value: `${username}@APL`,
temporary: true,
},
],
requiredActions: [],
})

export const realmCfgTpl = (realm: string): Record<string, unknown> => ({
id: realm,
realm,
Expand Down
Loading