Skip to content
Binary file added docs/screenshots/azure/member-number-email.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added docs/screenshots/azure/welcome-email.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
12 changes: 11 additions & 1 deletion src/azure/directory.ts
Original file line number Diff line number Diff line change
@@ -1,12 +1,22 @@
import { logger } from "@/logger"
import { azureCredentials } from "./config"
import { addGroupMember, getAllGroups, removeGroupMember } from "./functions/groups"
import { createMember, getMembers, setMemberNumber } from "./functions/members"
import {
createMember,
findByMail,
findByMemberNumber,
getMember,
getMembers,
setMemberNumber,
} from "./functions/members"
import { createMockAzureDirectory } from "./mock-directory"
import type { AzureDirectory } from "./types"

const graphAzureDirectory: AzureDirectory = {
getMembers,
getMember,
findByMemberNumber,
findByMail,
setMemberNumber,
createMember,
getAllGroups,
Expand Down
14 changes: 14 additions & 0 deletions src/azure/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
import type { MemberSummary } from "./types"

export class IncompleteMemberError extends Error {
constructor(
readonly member: MemberSummary,
cause: unknown
) {
super(
`Account ${member.mail} (${member.id}) was created, but setup and cleanup failed. Contact IT before trying again.`,
{ cause }
)
this.name = "IncompleteMemberError"
}
}
159 changes: 119 additions & 40 deletions src/azure/functions/members.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,9 @@ import { logger } from "@/logger"
import { generatePassword } from "@/utils/password"
import { withRetry } from "@/utils/wait"
import { getAzureClient } from "../client"
import type { CreatedMember, CreateMemberInput, ParsedUser, User } from "../types"
import { IncompleteMemberError } from "../errors"
import { MAIL_DOMAIN } from "../mailbox"
import type { CreatedMember, CreateMemberInput, MemberSummary, ParsedUser, User } from "../types"

const GruppoSociID = "1c68dbb8-4ac3-4569-a886-283b5a825cbd"
const Licenses = {
Expand All @@ -13,39 +15,103 @@ const Licenses = {
} as const
const FlippedLicenses = Object.fromEntries(Object.entries(Licenses).map(([key, value]) => [value, key]))

type Page<T> = { value: T[]; "@odata.nextLink"?: string }

async function allPages<T>(client: ReturnType<typeof getAzureClient>, first: Page<T>, advanced = false): Promise<T[]> {
const items = [...first.value]
let next = first["@odata.nextLink"]
while (next) {
const request = client.api(next)
if (advanced) request.header("ConsistencyLevel", "eventual")
const page: Page<T> = await request.get()
items.push(...page.value)
next = page["@odata.nextLink"]
}
return items
}

export async function getMembers(): Promise<ParsedUser[]> {
try {
const client = getAzureClient()
const allPolinetworkUsers: User[] = await client
.api(`/users`)
.header("ConsistencyLevel", "eventual")
.select(["displayName", "mail", "givenName", "surname", "id", "assignedLicenses", "employeeId"])
.filter("endswith(mail, '@polinetwork.org')")
.get()
.then<User[]>((r) => r.value)
const allPolinetworkUsers = await allPages<User>(
client,
await client
.api(`/users`)
.header("ConsistencyLevel", "eventual")
.count(true)
.select(["displayName", "mail", "givenName", "surname", "id", "assignedLicenses", "employeeId"])
.filter("endswith(mail, '@polinetwork.org') or endswith(userPrincipalName, '@polinetwork.org')")
.get(),
true
)

const members: { id: string }[] = await client
.api(`/groups/${GruppoSociID}/members`)
.select(["id"])
.get()
.then((r) => r.value)
const members = await allPages<{ id: string }>(
client,
await client.api(`/groups/${GruppoSociID}/members`).select(["id"]).get()
)
const memberIds = new Set(members.map((member) => member.id))

logger.debug({ count: members.length }, "[Azure Graph API] Get members")

return allPolinetworkUsers.map<ParsedUser>(
({ assignedLicenses, ...u }) =>
({
...u,
isMember: members.findIndex((m) => m.id === u.id) !== -1,
isMember: memberIds.has(u.id),
assignedLicensesIds: assignedLicenses.map((al) => (al.skuId ? FlippedLicenses[al.skuId] : "UNKNOWN")),
}) satisfies ParsedUser
)
} catch (error) {
logger.error({ error }, "[Azure Graph API] Could not get users")
return []
throw error
}
}

const SUMMARY_FIELDS = ["id", "displayName", "givenName", "mail", "employeeId"]

function toSummary(user: Partial<MemberSummary> & { id: string }): MemberSummary {
return {
id: user.id,
displayName: user.displayName ?? null,
givenName: user.givenName ?? null,
mail: user.mail ?? null,
employeeId: user.employeeId ?? null,
}
}

export async function getMember(userId: string): Promise<MemberSummary | null> {
try {
const user = await getAzureClient()
.api(`/users/${encodeURIComponent(userId)}`)
.select(SUMMARY_FIELDS)
.get()
return toSummary(user)
} catch (error) {
if ((error as { statusCode?: number }).statusCode === 404) return null
throw error
}
}

/** Callers pass only validated values (integers, `localPartSchema` addresses), so the filter needs no escaping. */
async function findOne(filter: string): Promise<MemberSummary | null> {
const users: MemberSummary[] = await getAzureClient()
.api("/users")
.filter(filter)
.select(SUMMARY_FIELDS)
.top(1)
.get()
.then((r) => r.value)
return users[0] ? toSummary(users[0]) : null
}

export async function findByMemberNumber(assocNumber: number) {
return findOne(`employeeId eq '${assocNumber}'`)
}

export async function findByMail(mail: string) {
return findOne(`userPrincipalName eq '${mail}' or mail eq '${mail}'`)
}

export async function setMemberNumber(userId: string, assocNumber: number) {
try {
const client = getAzureClient()
Expand All @@ -58,44 +124,57 @@ export async function setMemberNumber(userId: string, assocNumber: number) {
}
}

export async function createMember({ firstName, lastName, assocNumber }: CreateMemberInput): Promise<CreatedMember> {
export async function createMember({
firstName,
lastName,
assocNumber,
localPart,
}: CreateMemberInput): Promise<CreatedMember> {
const client = getAzureClient()
// TODO: separate steps and add better error handling, maybe with neverthrow
const password = generatePassword()
const mailNickname = `${firstName.replaceAll(" ", "")}.${lastName.replaceAll(" ", "")}`.toLowerCase()
const mail = `${mailNickname}@polinetwork.org`
const mail = `${localPart}@${MAIL_DOMAIN}`

// create user
const { id }: { id: string } = await client.api("/users").post({
accountEnabled: true,
displayName: `${firstName} ${lastName}`,
mailNickname,
givenName: firstName,
surname: lastName,
usageLocation: "IT",
employeeId: assocNumber.toString(),
mailNickname: localPart,
Comment thread
coderabbitai[bot] marked this conversation as resolved.
userPrincipalName: mail,
passwordProfile: {
forceChangePasswordNextSignIn: true,
password,
},
})

// set firstName and lastName
await withRetry(() =>
client.api(`/users/${id}`).patch({
givenName: firstName,
surname: lastName,
usageLocation: "IT",
employeeId: assocNumber.toString(),
})
)

// add to group Soci
await withRetry(() =>
client.api(`/groups/${GruppoSociID}/members/$ref`).post({
"@odata.id": `https://graph.microsoft.com/v1.0/directoryObjects/${id}`,
})
)

// assign licenses
await manageLicenses(id, ["OFFICE_365", "POWER_AUTOMATE"], [])
try {
await withRetry(() =>
client.api(`/groups/${GruppoSociID}/members/$ref`).post({
"@odata.id": `https://graph.microsoft.com/v1.0/directoryObjects/${id}`,
})
)
await manageLicenses(id, ["OFFICE_365", "POWER_AUTOMATE"], [])
} catch (error) {
try {
await withRetry(async () => {
try {
await client.api(`/users/${id}`).delete()
} catch (cleanupError) {
if ((cleanupError as { statusCode?: number }).statusCode !== 404) throw cleanupError
}
})
} catch (cleanupError) {
logger.error({ error: cleanupError, memberId: id }, "[AZURE] incomplete account cleanup failed")
throw new IncompleteMemberError(
{ id, displayName: `${firstName} ${lastName}`, givenName: firstName, mail, employeeId: assocNumber.toString() },
error
)
}
throw error
}

return {
firstName,
Expand Down Expand Up @@ -135,6 +214,6 @@ export async function manageLicenses(
})),
removeLicenses: removeLicenses.map((l) => Licenses[l]),
})
).catch((e) => logger.error({ error: e }, "[Azure Graph API] members:manageLicenses ERROR"))
)
logger.info("[Azure Graph API] members:manageLicenses OK")
}
35 changes: 35 additions & 0 deletions src/azure/mailbox.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import z from "zod"

export const MAIL_DOMAIN = "polinetwork.org"

const LOCAL_PART = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/

/** The part before `@polinetwork.org`: lowercase letters, digits, and single dots or hyphens between them. */
export const localPartSchema = z
.string()
.trim()
.toLowerCase()
.max(64)
.regex(LOCAL_PART, "Only lowercase letters, digits, dots and hyphens")

export const mailboxSchema = z.discriminatedUnion("kind", [
z.object({ kind: z.literal("standard") }),
z.object({ kind: z.literal("custom"), localPart: localPartSchema }),
])

export type MailboxChoice = z.infer<typeof mailboxSchema>

/** Accents and apostrophes are dropped and spaces removed: "Niccolò", "D'Amico" -> "niccolo.damico". */
function namePart(name: string) {
return name.normalize("NFD").replace(/\p{M}/gu, "").replace(/['\s]/g, "").toLowerCase()
}

/** `firstname.lastname`, or null when the name has letters an address cannot hold (e.g. "Łukasz"). */
export function standardLocalPart(firstName: string, lastName: string): string | null {
const localPart = `${namePart(firstName)}.${namePart(lastName)}`
return LOCAL_PART.test(localPart) && localPart.length <= 64 ? localPart : null
}

export function mailboxLocalPart(firstName: string, lastName: string, mailbox: MailboxChoice) {
return mailbox.kind === "custom" ? mailbox.localPart : standardLocalPart(firstName, lastName)
}
27 changes: 23 additions & 4 deletions src/azure/mock-directory.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { randomUUID } from "node:crypto"
import { generatePassword } from "@/utils/password"
import type { AzureDirectory, ParsedGroup, ParsedUser } from "./types"
import { MAIL_DOMAIN } from "./mailbox"
import type { AzureDirectory, MemberSummary, ParsedGroup, ParsedUser } from "./types"

const ASSOCIATION_GROUP_ID = "mock-group-association-members"

Expand Down Expand Up @@ -65,6 +66,10 @@ function cloneMembers(members: ParsedUser[]): ParsedUser[] {
return members.map((member) => ({ ...member, assignedLicensesIds: [...member.assignedLicensesIds] }))
}

function summary({ id, displayName, givenName, mail, employeeId }: ParsedUser): MemberSummary {
return { id, displayName, givenName, mail, employeeId }
}

function cloneGroups(groups: ParsedGroup[]): ParsedGroup[] {
return groups.map((group) => ({ ...group, members: group.members.map((member) => ({ ...member })) }))
}
Expand All @@ -78,6 +83,21 @@ export function createMockAzureDirectory(): AzureDirectory {
return cloneMembers(members)
},

async getMember(userId) {
const member = members.find((candidate) => candidate.id === userId)
return member ? summary(member) : null
},

async findByMemberNumber(assocNumber) {
const member = members.find((candidate) => candidate.employeeId === assocNumber.toString())
return member ? summary(member) : null
},

async findByMail(mail) {
const member = members.find((candidate) => candidate.mail === mail)
return member ? summary(member) : null
},

async setMemberNumber(userId, assocNumber) {
const member = members.find((candidate) => candidate.id === userId)
if (!member) return { error: `Azure member ${userId} was not found in the local directory` }
Expand All @@ -86,9 +106,8 @@ export function createMockAzureDirectory(): AzureDirectory {
return { error: null }
},

async createMember({ firstName, lastName, assocNumber }) {
const mailNickname = `${firstName.replaceAll(" ", "")}.${lastName.replaceAll(" ", "")}`.toLowerCase()
const mail = `${mailNickname}@polinetwork.org`
async createMember({ firstName, lastName, assocNumber, localPart }) {
const mail = `${localPart}@${MAIL_DOMAIN}`
if (members.some((member) => member.mail === mail)) {
throw new Error(`Azure member ${mail} already exists in the local directory`)
}
Expand Down
11 changes: 11 additions & 0 deletions src/azure/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,13 @@ export type CreateMemberInput = {
firstName: string
lastName: string
assocNumber: number
/** The part before `@polinetwork.org`, already validated. */
localPart: string
}

/** The directory fields the member number workflow reads. */
export type MemberSummary = Pick<ParsedUser, "id" | "displayName" | "givenName" | "mail" | "employeeId">

export type CreatedMember = {
firstName: string
lastName: string
Expand All @@ -59,6 +64,12 @@ export type CreatedMember = {

export type AzureDirectory = {
getMembers(): Promise<ParsedUser[]>
/** Null when no user has this id. */
getMember(userId: string): Promise<MemberSummary | null>
/** Any user whose `employeeId` is this number. */
findByMemberNumber(assocNumber: number): Promise<MemberSummary | null>
/** Any user whose sign-in name or mail is this address. */
findByMail(mail: string): Promise<MemberSummary | null>
setMemberNumber(userId: string, assocNumber: number): Promise<{ error: string | null }>
createMember(input: CreateMemberInput): Promise<CreatedMember>
getAllGroups(): Promise<ParsedGroup[]>
Expand Down
Loading
Loading