Skip to content

ITProvisioning — Spec ​

Onboards new starters, and offboards leavers (AB#2045), from FreshDesk tickets. See "Offboarding" below.

Flow ​

FreshDesk ticket created
  -> WebhookReceiver -> queue -> WebAPI FreshDeskWebhookConsumer
  -> OnboardingProducer: subject + sender check, parse the form, send OnboardingTask
  -> sbq-itprovisioning-prod-australiasoutheast-001
  -> ITProvisioning OnboardingConsumer: Graph + Exchange runbook, private note on the ticket

WebAPI side (WebAPI/Services/OnboardingProducer.cs, called from FreshDeskWebhookConsumer for created events only). A ticket is onboarding when the subject starts New employee requirements and the requester is ITProvisioning:AllowedSender (blank = nothing is queued). The form is parsed by CommonFreshDesk.OnboardingTicket; if a required field is missing the ticket gets a private note and nothing is queued. MessageId is onboard-{ticketId}. Settings: ITProvisioning:ProducerEnabled (default false), ITProvisioning:AllowedSender, ServiceBus:Queues:ITProvisioning. A failure is logged, not retried (the webhook row is already stored).

Re-running a ticket. An agent adds a private note containing @itprovisioning reprocess. FreshDesk's "Ticket Updates" rule (note added) already sends event=updated; for those the producer re-checks the subject and sender, and queues the ticket again only if the newest conversation on it is a private note with that text. Each note is its own message (onboard-{ticketId}-{noteId}), so a repeated webhook for one note is dropped (if the queue has duplicate detection) but a second note runs again. Once queued, the producer adds a "command received" private note so the command is no longer the newest and a later edit to the ticket (e.g. a status change while the work runs) doesn't queue it again. Use it to rehearse in dry-run on an old ticket instead of waiting for a new starter. Each update on an onboarding-subject ticket costs one extra FreshDesk call (two when it is from the allowed sender).

Hardware and services summary. After the provisioning note, the worker posts a second private note listing what the starter needs set up, read from the form's "Internal IT setup" answers (OnboardingTicket.Requirements, which travel on the queue): Hardware (laptop, iPad, iPhone, VOIP phone, accessories) and Services (Prime account, Google account, Navan, printer, security card, bookings, OptimoRoute). Items the form answers No / N/A / blank are left out, as are phone apps, calendar, SharePoint, Teams/groups and email signature. An agent's private note @itprovisioning hardwarerequirements re-posts only this summary (queued with SummaryOnly, nothing is provisioned).

Mailbox delegation (AB#2046). The last onboarding step gives ITProvisioning:DelegateTo (Aimee) Full Access, Send As and Send on Behalf on the new mailbox (Exchange runbook action DelegateMailbox; each right is checked first, a mailbox not in Exchange yet retries like the groups) and emails her the starter's name, email, start date and job title in the PDP form style (ITProvisioning/Templates/DelegateAccessEmail.html), queued with IEmailMessageData.InsertHtmlMessage from noreply@ethosbuilding.com.au (a constant in OnboardingConsumer); the n8n "Email Queue - Send" flow sends it. Blank DelegateTo skips both. An agent's private note @itprovisioning delegatetoaimee runs only this step (queued with DelegateOnly) for tickets onboarded before it existed. Dry-run grants nothing and sends nothing.

The form is label/value pairs, read from the ticket's HTML description (the form is a table; the plain-text version is the fallback); the parser reads: Employee name, Job Title, Commencement date (dd mm , yyyy), Reporting to (name; the phone number is dropped), Email address required (becomes the UPN; must be firstname.lastname@ethosbuilding.com.au, else the ticket gets a note and nothing is queued), Security access details. Only these travel on the queue — the rest of the form holds personal details and portal credentials and is never copied, logged or echoed into a note.

ITProvisioning (own App Service, no public endpoint beyond /health, references CommonServiceBus, CommonFreshDesk and CommonDataAccess; the DB (ConnectionStrings:Default) is used only to queue the delegate email). OnboardingConsumer is a QueueConsumer; each step checks what already exists, so a retry repeats nothing:

StepHow
Create the user (enabled, temporary password, job title, hire date, usageLocation AU)Graph. An existing UPN is left as it is
Set managerGraph, by display name; not exactly one match is left for a person
Assign Microsoft 365 Business Standard (O365_BUSINESS_PREMIUM)Graph; no free licence is left for a person
Add to Team and Office groupsExchange runbook — they are mail-enabled security groups, which Graph can't change

The temporary password is posted as a private note straight after the account is created (a retry would otherwise lose it). A second private note lists what was done and what is left by hand. A mailbox that isn't in Exchange yet makes the runbook answer "retry"; the message retries at 5, 15, 45, 135 minutes, then the note says it gave up.

Group names. The form has no Team or Office field, so:

  • Team comes from the job title: ITProvisioning/Services/TeamGroups.cs is a keyword table in code (e.g. a title containing "construction" joins Team Construction). First matching row wins; add a team by adding a row.
  • Office comes from the form's Security access details address (Services/OfficeGroups.cs, also in code): QLD joins Office Murarrie, VIC joins Office Docklands. The state is an explicit QLD/VIC in the address, else the postcode's first digit (3/8 VIC, 4/9 QLD). Another state, or no address, leaves the Office group to a person.

A title or answer with no mapping is named in the ticket note and left for a person, never guessed.

Hudu (AB#2051) ​

Services/HuduClient.cs records each new starter in the production Hudu (https://ethos.huducloud.com; there is no test instance), when Hudu:Enabled is true (default false). Three linked records in the Service company (found by name, Hudu:CompanyName, once and cached; Hudu:CompanyId skips the lookup. The id is not the number in the company's page link, which is a slug), each found by name before it is created so a retry repeats nothing:

RecordHow
Staff asset (layout Hudu:StaffLayoutName)Status Active, Job Title, Primary Office (Murarrie / Docklands, from the security access address), primary email = the UPN. An existing asset is left as it is. Personal contact details, access card number and the OTP secret are not on the queue and are set by hand
Child password {Name} EntraIDThe temporary password, username = UPN, parent = the asset. Only when the Entra account was created in this run (an existing account's password is not ours). An existing record is never changed
Onboarding run Staff Onboarding - {Name}Kicked off from the company process Hudu:OnboardingProcessName, assigned to the asset

The first ticket note links to the Hudu password instead of showing it. If Hudu is off or the asset call fails the note carries the password as before, since a retry would otherwise lose it. A password or run that fails is named in the note's "still to do by hand".

Change log. Each change the worker makes (account created, manager, licence, each group, mailbox delegation) is appended to the asset's Notes as 28Oct2026 - Added to group Team Construction (AEST date), skipping a line already there. Best effort: a failure is noted on the ticket, never fails the message. Dry-run makes no Hudu calls.

Smoke test. An agent's private note @itprovisioning documentinhudu (queued with HuduOnly) records only the asset and run (no password, nothing else provisioned) and posts their links. It honours DryRun and Hudu:Enabled.

API key: Hudu:ApiKey, sent as x-api-key; user secrets locally, App Service settings in Azure. Never logged, and the password calls' response bodies are left out of error text. 429s are retried (Retry-After, else 2/4/8 s).

To confirm at the first live run: that POST /companies/{id}/assets accepts the wrapped body (it falls back to the flat one on a 422), that the Primary Office list holds Murarrie and Docklands, and that the key can see passwords in the Service company.

SimpleKPI (AB#2074) ​

Services/SimpleKpiClient.cs (https://ethos.simplekpi.com/api/, Basic auth SimpleKpi:Username + SimpleKpi:ApiToken; token in user secrets / App Service settings only) runs when SimpleKpi:Enabled is true (default false) and honours DryRun.

  • Onboarding (after the licence, before the groups): find the SimpleKPI user by email (the UPN); if missing, create it as User, or Manager (with can_manage_users) when the job title says so, with a random password nobody sees (the starter uses "forgot password" - the API can't change a password later); then assign the KPIs they don't already have. Role and KPIs come from the job title in Services/KpiMap.cs, a table in code derived from the SimpleKPI export: only people with an unblocked Entra account, only KPIs named 26-27..., only KPIs held by more than half of that title's people. Only User / Manager are set automatically; Admin / Director / Owner titles and titles with no common KPIs are not in the table: the user is still created, as a User with no KPIs, and the note says "no SimpleKPI role or KPIs are mapped" so a person finishes it. If listing/assigning KPIs fails after the user exists, the user stays and the note says KPIs were not fully assigned. An existing locked user is left alone (KPIs go to active users only). A failure goes in the note's "still to do by hand" and never retries the message.
  • Offboarding (Offboard message, right after sign-in is disabled): the user is locked (user_status_id L; the whole user is PUT back). KPIs are not unassigned. No SimpleKPI user is just a note line.
  • @itprovisioning setupkpis (private note, queued with KpiOnly): runs only the onboarding step for a ticket onboarded before it existed.

To confirm at the first live run: that POST /users returns the created user (its id), that PUT /users/{id} accepts the object as GET returns it, and that the KPI ids in KpiMap are current.

Safety switches ​

  • ITProvisioning:ConsumerEnabled — off unless true (production only).
  • ITProvisioning:DryRun — default true: changes nothing and posts a note saying what it would have done. Read a few of those notes against real tickets before turning it off.

Exchange runbook ​

ITProvisioning/Runbooks/Exchange.ps1, PowerShell 7.2 in Azure Automation under the account's managed identity (Connect-ExchangeOnline -ManagedIdentity). The app starts it through the Automation REST API (job per call) and reads the JSON verdict on its last output line. Actions: AddToGroup (refuses any group that isn't a mail-enabled security group. The tenant is read from the Automation variable EXCHANGE_ORGANIZATION, and the member is added with -BypassSecurityGroupManagerCheck (security groups only accept changes from their owners, and the identity is an Exchange admin, not an owner), and DelegateMailbox (Full Access, Send As, Send on Behalf; needs a role that can manage mailbox permissions). The module ExchangeOnlineManagement must be 3.5.0 on the 7.2 runtime: later versions need PowerShell 7.4 and fail to load on 7.2 ("The given assembly name or codebase was invalid"). The portal imports only the latest from the gallery; to pin 3.5.0, PUT .../automationAccounts/<account>/[redacted-blob] with {"properties":{"contentLink":{"uri":"https://www.powershellgallery.[redacted-blob].5.0"}}}.

Deployment ​

The web app's public network access must be enabled (at least the deployment/SCM endpoint): the GitHub runner is refused with Ip Forbidden (403) when it is disabled. The app listens to nothing inbound apart from /health.

.github/workflows/prod-itprovisioning-deploy.yml, manual, not in the release chain: an app job (publish profile PROD_ITPROVISIONINGPUBLISHPROFILE) and a runbook job (az automation runbook replace-content + publish, using AZURE_CREDENTIALS and repo variables ITPROVISIONING_AUTOMATION_RESOURCE_GROUP / ITPROVISIONING_AUTOMATION_ACCOUNT). One-time setup, not in a workflow: the Automation account and its managed identity, ExchangeOnlineManagement module, EXCHANGE_ORGANIZATION variable (<tenant>.onmicrosoft.com), Exchange.ManageAsApp + an Exchange recipient role for that identity, Graph User.ReadWrite.All + Organization.Read.All for the app's identity, Automation Job Operator on the account for the app, and Service Bus Data Receiver/Sender on the namespace.

Offboarding (AB#2045) ​

IT FreshDesk instance only (the Claims webhook never reaches it). A ticket whose subject contains "Employee Offboarding Notice" (so FW:/RE: and the form's "New form response notification - " prefixes still match; case-insensitive; the ticket is then renamed to Employee Offboarding Notice - <Name> <ddMMMyyyy>, a failed rename only logs) from ITProvisioning:AllowedSender is handled by WebAPI/Services/OffboardingProducer.cs, called from OnboardingProducer (it already fetches the ticket). The form (CommonFreshDesk.OffboardingTicket) gives the person's display name only (no email), the offboarding date and time (local, ITProvisioning:TimeZone, default Australia/Melbourne; no time = 5:00 PM) and the Line Manager.

Scheduling. On the first webhook the producer schedules two Service Bus messages on the ITProvisioning queue (ScheduleMessageAsync, application property task = Offboard / Archive): Offboard at the form's date and time, Archive six months after it (a time already past runs straight away). Each sequence number is stored in ethFreshDeskScheduledTask (migration 0060), because Service Bus can't be queried and a sequence number is the only thing that cancels a message. A task of a kind already waiting (future dueUTC, not cancelled) is not scheduled again. The producer then adds a private note listing each action with its date and what it does. The note must not quote any @itprovisioning command: the webhook reads every private note, so a quoted command would run. A scheduled message's TTL starts when it activates, so the six-month wait doesn't expire it; the queue's own default TTL (and dead-lettering on expiry) still applies once it is active.

Agent commands (a private note, the newest thing on the ticket):

NoteEffect
@itprovisioning processOffboard now (not recorded in the table) and make sure the archive is scheduled
@itprovisioning offboardingarchiveSchedule the archive if none is waiting; on an old ticket the six months have passed so it runs now
@itprovisioning offboardingarchivecancelCancel every message still waiting for the ticket (Offboard and Archive)
@itprovisioning huduassetsummaryEmail the leaver's Hudu device list now (queued as an AssetSummary message, not recorded; nothing else runs and no archive is scheduled)

A newest note that mentions @itprovisioning but isn't run gets a private "command not run - <reason>" note: it was a public reply, the ticket isn't from the form's sender, or it isn't an offboarding command (a typo, or an onboarding command). Updates that don't mention @itprovisioning are ignored silently. The rejection never quotes a command, for the reason above.

Worker (ITProvisioning/Services/Offboarding.cs, reached from OnboardingConsumer by the task property; same DryRun / ConsumerEnabled switches). Offboard, in order: find the user by display name (exactly one match, and not in ITProvisioning:ProtectedUsers); disable sign-in; revoke sessions; remove MFA methods; clear the manager; leave the Team* / Office* mail-enabled security groups; join ITProvisioning:FormerStaffGroup; convert the mailbox to shared; give the Line Manager Full Access / Send As / Send on Behalf; remove the licences (last, so the mailbox is already shared). Archive: only if the account is still disabled and unlicensed, delete the shared mailbox (runbook refuses anything that isn't a shared mailbox), then the EntraID user (a 404 counts as done, Exchange can delete the user with the mailbox). Each step runs on its own: one that fails is logged and named in the ticket note under "Still to do by hand", the rest still run, and the message is never retried (@itprovisioning process runs it again). The exceptions are deliberate guards: licences are kept if the mailbox did not convert (removing a licence from a user mailbox starts its deletion), and the Archive deletes the user only if the mailbox went.

Device summary (AB#2134). The last thing an Offboard does (and all the huduassetsummary command does) is email the leaver's Hudu devices to the leaver's Line Manager (looked up by name, as for the mailbox access), cc ITProvisioning:AssetSummaryTo (default andrew.badge@ethosbuilding.com.au); with no manager found it goes to that address alone and the note says so. Sent from noreply@, in the PDP form style (Templates/AssetSummaryEmail.html, queued with InsertHtmlMessage, subject Devices to collect: <Name> (ticket #n)), and adds a private ticket note that says it was sent and repeats the list (type, name, tag, Hudu link). The email's last column links to the asset in Hudu. Status is a Hudu list field ({"list_ids":[7]}), resolved to words through lists/{id} so Decomissioned/Inactive devices are dropped; some link labels end in a space ("Responsible "), so labels are matched trimmed. HuduClient.GetDevicesAsync finds the Staff asset by display name, then lists each device layout in the Service company and keeps the assets whose link field points at that Staff asset. The link is on the device, not on the Staff asset, and the label differs per layout, so it is a table in code (HuduClient.DeviceLayouts):

LayoutLink fieldShown as Asset Tag / ID
PC, iPhone, iPad, AccessoryResponsibleAsset Tag
Keyboard+MouseResponsibleKeyboard and Mouse tags
GPS TrackerAssigned to StaffSerial Number
SIMStaffNumber

Archived assets and any whose Status is Decomissioned or Inactive (as a whole value or one of a comma-joined list) are left out; To be Decomissioned, Spare, Damaged, Lost, Stolen and the rest are listed, with the Status column, since IT still has to chase them. A SIM counts only when its own Staff field is the leaver (not through the phone it is installed in). Honours DryRun (says what it would send, no Hudu call) and Hudu:Enabled; a failure, or no Staff asset of that name, is named under "Still to do by hand" and never stops or retries the offboarding. No devices found still sends the email, saying so.

To confirm at the first live run: that the list-assets rows carry fields with label/value, and that an asset-link value is a list of {id, name} (a JSON string or an array; both are read); that the SIM Status list uses the same words.

New app permission: Graph UserAuthenticationMethod.ReadWrite.All (MFA methods). Runbook actions RemoveFromGroups, ConvertToShared, DeleteSharedMailbox need an Exchange role that can manage mailboxes and group membership.

To confirm at the first live run: the Get-DistributionGroup -Filter "Members -eq '<dn>'" lookup in RemoveFromGroups; that "Former Staff with Autoreply" is a mail-enabled security group (the runbook refuses any other type, the note then says so); the time zone; and that the queue's TTL / dead-lettering suit a six-month schedule.

Not done (on the form but not in the story): mail redirect and auto-reply text, Prime login, Xero, iPhone reset, email rules, the All Staff distribution list.