Skip to content

feat(halo): link CIPP-generated PSA tickets to the affected user#2

Open
renada-jacob wants to merge 1991 commits into
devfrom
feat/halo-link-tickets-to-users
Open

feat(halo): link CIPP-generated PSA tickets to the affected user#2
renada-jacob wants to merge 1991 commits into
devfrom
feat/halo-link-tickets-to-users

Conversation

@renada-jacob
Copy link
Copy Markdown
Owner

Summary

Today, every CIPP-generated HaloPSA ticket lands on the client's General User contact (userlookup.id = -1), regardless of which end-user the alert is actually about. This PR adds a single integration toggle that, when enabled, splits per-user alerts into one ticket each and links them to the matching HaloPSA contact - or falls back cleanly to the General User when no match exists.

Pairs with the companion CIPP UI changes in https://github.com/renada-jacob/CIPP/pull/feat/halo-link-tickets-to-users.

What's new

  • New integration setting HaloPSA.LinkTicketsToUsers (default off, no behavioural change for existing installs until opted in).
  • New helper Get-HaloUser that looks up a HaloPSA contact by Azure Object ID first (via ?advanced_search= against azureoid/aaduserid), falling back to email/UPN against emailaddress/networklogin/aaduserid. Cast to [int] so PowerShell doesn't serialise IDs back as 95.0.
  • Per-user splitter in Send-CIPPScheduledTaskAlert - when the toggle is on and a scheduled-alert task returns rows containing a UPN-like field (UserPrincipalName/userPrincipalName/UPN/userId/Userkey), groups by user and emits one PSA call per user with an AffectedUser payload.
  • AffectedUser parameter on Send-CIPPAlert -Type 'psa' that threads through to New-CippExtAlert and New-HaloPSATicket.
  • Audit-log path (Invoke-CippWebhookProcessing) now extracts the affected user from ObjectId/UserId/Userkey (and their CIPP-mapped variants) so role-change, password-reset, sessions-revoked, MFA-disabled, inbox-rule etc. tickets land on the right contact too.
  • New-HaloPSATicket populates userlookup.id, user_name and site_id from the matched contact when found, and casts client_id to [int] so storage-resident IDs (which are stored as strings) don't break Halo's payload validation.
  • Unmatched users keep today's General User behaviour but get an italic notice appended to the description and a Warning logged for follow-up.
  • Recovery from note-add failures in the consolidation path: when adding a note to an existing ticket fails (permission on the configured outcome, ticket type mismatch, etc.) the function now falls through to creating a new ticket so the alert isn't silently lost.
  • Diagnostics in the PSA branch of Send-CIPPAlert: explicit "PSA delivery skipped" message when sendtoIntegration is off, plus surfaces the result string from the Halo call.

Out of scope

  • The legacy logbook/notification path in Push-SchedulerCIPPNotifications (Send-CIPPAlert -Type 'psa' lines 140-168) is intentionally left unchanged - its Username column is the CIPP operator, not the affected M365 user, so there's no useful identifier to link on.

Test plan

  • Enable Link Tickets to affected Users on the HaloPSA integration page; configure a Ticket Type and Outcome (the latter only required if Consolidate Tickets is on).
  • Smoke test: New-HaloPSATicket -Title test -Description '<p>x</p>' -Client <id> -UserUPN <upn> -AzureOID <oid> -DisplayName <name> against a Halo contact that has azureoid populated. Expect ticket linked to that contact at the right site.
  • Same call with a UPN that doesn't exist in Halo. Expect ticket on General User with the unmatched-user italic notice in the description and a Warning log entry.
  • Splitter test: Send-CIPPScheduledTaskAlert -Results <array of 2 different UPN rows> -TaskInfo <stub with PostExecution='psa'> -TenantFilter <domain> -TaskType Alert. Expect two separate Halo tickets, one per user, each linked to the right contact.
  • Audit-log smoke: Invoke-CippWebhookProcessing with a fake $Data containing ObjectId='<upn>', Operation='Disable Strong Authentication.', CIPPAction='{"value":["generatePSA"]}'. Expect ticket linked to that user.
  • Real alert: schedule Get-CIPPAlertMFAAlertUsers (or any per-user alert) with PSA post-execution. Expect one ticket per user without MFA.
  • Real audit log: trigger a password reset / sessions revoked on a test user. Wait one alert cycle. Expect ticket on the affected user's contact, not General User.
  • Toggle off, repeat any of the above. Expect today's behaviour - one consolidated ticket per tenant on General User.

🤖 Generated with Claude Code

JohnDuprey and others added 30 commits April 24, 2026 16:59
Added 10x configurable fields (background colour, logo URL, introduction text, read button text, email text, privacy statement URL, disclaimer text, portal text, OTP enabled, social ID sign-in) and a helpText link to the Microsoft Purview branding documentation.

Signed-off-by: Chris Dewey <142454021+chris-dewey-1991@users.noreply.github.com>
Stores the entire MDE mobileThreatDefenseConnector object (heartbeat,
per-platform enable/MAM/block flags, MDE attach, iOS metadata flags) in
the reporting DB instead of only partnerState. Live HTTP endpoint mirrors
the same shape so cached and live responses are interchangeable. Failed
Graph calls still write a partnerState=unavailable row so AllTenants
reports retain the tenant.
Co-authored-by: Copilot <copilot@github.com>
…Client

Co-authored-by: Copilot <copilot@github.com>
Three new standards: Invoke-CIPPStandardDisableEWS (disable Exchange Web Services org-wide), Invoke-CIPPStandardSPDisableCustomScripts (disable custom scripts on SharePoint/OneDrive), and Invoke-CIPPStandardSPDisableStoreAccess (disable SharePoint Store access).
Zacgoose and others added 30 commits May 13, 2026 15:52
Adds a HaloPSA user lookup helper that resolves a Microsoft 365 end-user to
their HaloPSA contact id, scoped to a specific client. Matches by Azure Object
ID first (azureoid field on the contact), then falls back to email address.
Returns $null when no match is found so callers can decide how to handle
unmatched users.

Foundation for linking CIPP-generated alert tickets to the affected end-user
instead of the client's General User contact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds optional UserUPN/AzureOID/DisplayName parameters to New-HaloPSATicket.
When the HaloPSA.LinkTicketsToUsers setting is enabled and a user identifier
is supplied, looks up the matching HaloPSA contact (via Get-HaloUser) and
populates userlookup.id and user_name on the ticket payload so the ticket
lands directly on the end-user's contact record rather than the client's
General User.

Unmatched users fall back to the existing userlookup.id = -1 (General User)
behaviour, with the affected UPN appended to the ticket description and a
warning logged to the CIPP logbook for follow-up.

Consolidation hash now includes the UPN when user-linking is active so that
per-user tickets for the same alert title don't collapse onto each other.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When an alert payload includes an AffectedUser (UPN, optional AzureOID,
optional DisplayName) and HaloPSA.LinkTicketsToUsers is enabled, pass it
through to New-HaloPSATicket. Best-effort resolves the user's Azure Object
ID via Graph when only the UPN was supplied, so the HaloPSA contact lookup
can prefer the more reliable azureoid match.

Existing behaviour is unchanged when AffectedUser is absent or the toggle
is off.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Lets callers attach an affected-user object (UPN, AzureOID, DisplayName) to
PSA alerts. The user is added to the Alert hashtable handed to
New-CippExtAlert, which uses it to link the resulting HaloPSA ticket to
the matching contact.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
When the HaloPSA integration has LinkTicketsToUsers enabled and a scheduled
alert returns rows containing a UPN-like field (UserPrincipalName, UPN,
userId, Userkey), Send-CIPPScheduledTaskAlert now groups the result rows by
that field and emits one PSA call per affected user. Each call carries an
AffectedUser payload so the resulting HaloPSA ticket lands on the correct
contact.

Rows with no UPN value still produce a single tenant-scoped ticket, and any
unexpected error in the split path falls back to today's consolidated-ticket
behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
PowerShell parses '$ClientId:' as a scope-qualified variable reference,
breaking the script's parser. Wrap the variable in ${} so it's interpolated
correctly inside the warning message.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
HaloPSA rejects tickets with a specific user (userlookup.id != -1) when
site_id is null - "Please select a valid Client/Site/User". The General
User fallback (id = -1) auto-resolves the site, but a real contact does
not.

Get-HaloUser now returns both the matched user's id and site_id. New-
HaloPSATicket pulls the site_id from that record onto the payload so
the ticket can be created against the correct site under the client.
Unmatched users still produce site_id = null, preserving today's General
User behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Invoke-RestMethod deserialises JSON numbers as [double] by default, so a
site_id of 95 round-trips as "95.0" - which Halo's ticket endpoint rejects
("Input string '95.0' is not a valid integer"). Cast both id and site_id
to [int] when building the result so ConvertTo-Json emits them as plain
integers.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Brings the feature branch up to date with ~300 dev commits. Both files
modified by this feature (Send-CIPPAlert.ps1, Send-CIPPScheduledTaskAlert.ps1)
auto-merged with no conflicts. AffectedUser parameter and the per-user PSA
splitter are intact alongside the upstream alert pipeline fixes (KelvinTegelaar#2006 et al.).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Audit-log driven alerts (new inbox rule, role change, MFA disabled, sessions
revoked, etc.) now extract the affected user from the audit record and pass
it through to the PSA pipeline as AffectedUser. When HaloPSA.LinkTicketsToUsers
is enabled, the resulting Halo ticket lands on the matching contact instead
of the client's General User.

Detection prefers ObjectId (target of the action) over UserId/Userkey, taking
the resolved UPN from the upstream GUID-mapped CIPP* property when available
and using the raw GUID directly as the AzureOID for the Halo lookup. Service
principal events naturally fall through with no AffectedUser (no @ in any
candidate), preserving the original General User behaviour.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Adds visibility to the PSA branch of Send-CIPPAlert so callers can see what
actually happened:

- When sendtoIntegration is disabled in CippNotifications config, log a
  clear skip reason instead of silently doing nothing.
- When an AffectedUser is attached, log which UPN/OID is being targeted.
- Capture and surface the result string returned by New-CippExtAlert
  (which carries through "Ticket created in HaloPSA: <id>" or the
  failure message).

No behavioural change to delivery - just diagnostics.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
The HaloPSA mapping table stores IntegrationId as a string, which then
threads through New-CippExtAlert -> New-HaloPSATicket and lands in the
Tickets payload as e.g. "client_id":"57". Halo rejects this with the
generic "Please select a valid Client/Site/User" error - the same class
of bug we already fixed for site_id.

Direct PowerShell test calls used integer literals so they didn't hit
this; only the audit-log and scheduled-alert paths (which read the
mapping from storage) tripped it.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Real-world HaloPSA contacts populated by AD/Azure AD sync often store the
user's UPN in 'networklogin' or 'aaduserid' rather than (or in addition to)
'emailaddress'. The previous lookup only checked 'emailaddress', so a
contact like Bob whose UPN was synced into 'networklogin' wouldn't match
even though the data was right there.

Now matches against:
  AzureOID -> azureoid, aaduserid
  Email    -> emailaddress, networklogin, aaduserid

Field lists are defined once at the top so adding more is a one-line change.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
HaloPSA's /Users?search=<term> parameter does not search the azureoid
field, so an OID-only search returns zero rows even when a contact has
that exact OID populated. Bob's contact in the test sandbox proved this:
azureoid was set correctly but emailaddress/networklogin/aaduserid were
all blank, so neither the OID search nor the email-field filter found
him.

Restructured to:
1. Run a search for each supplied term (AzureOID, Email) and merge the
   results into a deduped candidate pool keyed by user id.
2. Filter the pool preferring AzureOID matches against azureoid/aaduserid,
   then fall back to email matches against emailaddress/networklogin/
   aaduserid.

This way the email search brings the contact into scope and the OID
filter catches it, even when none of the email-shaped fields are
populated on the Halo record.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
Halo's basic ?search= parameter only searches a fixed set of indexed
fields (name, email, logins...) and notably NOT azureoid. So an OID-only
lookup returned zero results even when a contact had that exact OID
populated.

Switch the AzureOID path to ?advanced_search= with filter_type=2
(equality) against azureoid and aaduserid in turn. This queries the
underlying field directly and short-circuits as soon as we find a match,
avoiding the previous fetch-and-filter dance for the common case.

The email path still uses the basic search but now also checks the
azureoid field on returned records, so contacts with azureoid set and
blank email fields still match when both terms are supplied.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…ilures

Two fixes for issues surfaced by real audit-log alerts:

1. Get-HaloUser: some Halo instances don't whitelist azureoid/aaduserid as
   filterable advanced_search fields, returning "Invalid advanced search
   parameter(s)". The email-search fallback already handles this case
   correctly, but every alert was emitting two Warning-level logbook
   entries. Now only log when the failure is something other than the
   expected "field not whitelisted" rejection.

2. New-HaloPSATicket: when ConsolidateTickets is on and a note-add to an
   existing ticket fails (permission error on the configured outcome,
   ticket type that doesn't accept the action, etc.), the function used
   to return the error and never create a new ticket. The alert was
   effectively lost. Now log the failure and fall through to creating a
   new ticket so the alert reaches the technician one way or another.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
…itter

Adds a per-scheduled-alert setting that can override the global
HaloPSA.LinkTicketsToUsers toggle on a single alert basis. Lets MSPs
keep wide alerts like 'users without MFA' as one consolidated ticket
per tenant when individual tickets would flood their PSA, while still
splitting per-user for the alerts where granularity matters.

Stored as a new column 'PsaTicketStrategy' on the ScheduledTasks row
with values:
- 'split'        - always one ticket per affected user
- 'consolidated' - always one ticket per tenant
- '' / null      - inherit the integration's global setting (default)

Send-CIPPScheduledTaskAlert resolves the per-task strategy first and
only falls back to the global toggle when the task hasn't expressed a
preference, so existing alerts created before this change keep working
exactly as before.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

10 participants