Skip to content

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Latest commit

 

History

2,874 Commits

Folders and files

NameName
Last commit message
Last commit date
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

Repository files navigation

ExchangeAdminWeb

ASP.NET Core 10 Blazor Server application for Exchange Online administration, mailbox/calendar permissions, group management, MFA operations, conference room setup, named locations, and Active Directory tasks through a self-service web interface. Provides 22 modules (21 operational + 1 config-only) covering EXO, Graph API, and on-prem AD operations.

Features

Mailbox Permissions

  • Full Access - Grant/revoke full mailbox access with optional AutoMapping
  • Send As - Grant/revoke Send As permissions
  • Single operations and bulk CSV upload support
  • On-premises mailbox support for authorized power users (see below)

Calendar Permissions

  • Set calendar sharing permissions (Owner, Editor, Reviewer, Limited Details, etc.)
  • Remove calendar permissions
  • Automatic calendar folder detection (supports international/localized folder names)
  • Single operations and bulk CSV upload support
  • On-premises mailbox support for authorized power users (see below)

Exchange Migration (/migration)

Check migration eligibility and create move batches between Exchange Online and on-premises Exchange.

  • Validates current mailbox location before eligibility decisions
  • Checks on-prem mailbox and archive size before cloud migrations. The CloudQuotaGB limit applies to the primary mailbox and the archive separately; their combined size is not a criterion
  • Move-back batches pass all configured databases from OnPremTargetDatabases in the Migration module config to Exchange for distribution
  • Built-in move-back database defaults match the approved 2019 database list; no DAG lookup or space balancing is performed
  • Migration Status supports multi-select, with three bulk actions that have three different targets. One ticket covers the run and each batch is audited individually. A selection that mixes statuses is never refused: the eligible batches are acted on and every skipped batch is named with the status that disqualified it
    • Delete removes every selected batch whatever its status. Exchange decides what it will accept; a refusal comes back as that batch's own reported failure. The confirmation states the count and a per-status breakdown before the ticket is accepted, because this is the only action that takes in-flight batches
    • Remove Completed removes only the selected batches whose status is exactly Completed. CompletedWithErrors is deliberately excluded -- a batch that finished with errors is not a batch that finished, and sweeping it would destroy the evidence
    • Resume/Retry restarts the selected batches that are idle but restartable. Defined by exclusion rather than an allowlist: everything except the actively-working statuses and Completed, so a batch status this app has not seen before still gets a button and Exchange refuses it if invalid
  • Batch actions report what was QUEUED, not what is finished. Remove-MigrationBatch and Start-MigrationBatch return when Exchange accepts the request; the batch then sits at Removing or Starting until Exchange completes it, so the row stays visible for a while. Batches Exchange accepted are unticked automatically; ones it refused stay ticked so they can be retried
  • The ticket field for a single-batch or single-user action appears directly beneath that row, not at the top of the table
  • The per-user Report button is available on every user row, not only rows that already look broken
  • Migration Status results can be downloaded as CSV (BatchName,Status,Direction,Created,Started,Completed,Total,Synced,Finalized,Failed,TargetEndpoint)
  • Section access keys: MigrationCheck, MigrationCreate, MigrationManage

Message Analysis (/message-analysis)

Analyze message headers and search mail flow across Exchange Online and on-premises transport logs.

  • Trace queries both Exchange Online message trace and on-premises message tracking logs

  • Any range up to 90 days is searched in the app and rendered on the page. Exchange Online accepts a maximum span of 10 days per call, so a wider range is split into contiguous windows and queried in sequence; on-premises is queried once for the whole range. The operator never sees the limit. 90 days is the hard ceiling because that is Exchange Online's retention

  • If one window fails, the whole Exchange Online result is reported as failed rather than returned with an invisible hole in the middle of the range

  • Optional ticket number is recorded in audit logs only; it does not call ServiceNow

  • Header Analysis is the primary workflow and supports pasted headers plus .eml and .msg uploads

  • Extracts Message-ID, sender, recipient, subject, routing hops, authentication details, spam/filtering clues, delivery-failure evidence, and all parsed header values

  • Header analysis can populate or immediately run a trace using the parsed Message-ID and date window

  • The results table renders the first 50 rows and states the true total. This is a fixed boundary, not a setting: Blazor Server renders rows server-side and holds them in circuit memory, so a large table degrades other operators' sessions too

  • Two different exports, and they are not interchangeable:

    Control Covers Contents Delivery
    Export CSV every result, however many one summary line per message - the table columns downloads immediately
    Download details (up to 10) the selected messages the full per-hop delivery trail downloads immediately
    Export details as a job the selected messages, max 50 the full per-hop delivery trail background job, listed on Downloadable Reports

    Delivery detail costs one Exchange lookup per message, so the 50 is a ceiling rather than a page: there is no way to obtain detail for every result in one export, and narrowing the search is how to cover more. Live download is capped lower still, at 10

  • Subject and Message ID filters work on every range, including a 90-day one

  • Per-message delivery-detail exports above the live threshold run as background jobs. The notification email carries a link to the Downloadable Reports page (/message-analysis/reports), never the export itself, so the data stays behind the login gate

  • The notification recipient box is pre-filled with the operator's own address and is freely editable: any address may be added, and clearing it entirely is valid and means no email is sent. The export is still produced and still listed on the Downloadable Reports page. Administrators are never added to the recipient set

  • Downloadable Reports lists those exports with who requested what; downloading requires Message Analysis access and a ticket number, which is recorded with the download for audit

  • Exports are kept for 30 days. The app deletes expired ones itself at startup, in the same pass that prunes old job records — there is nothing to install and no scheduled task. A row whose export could not be saved shows as Failed, distinct from an expired one

  • Leaving the page during a search, a delivery detail or a detail download cancels it at its next step: while it waits for a free Exchange Online connection or for the on-premises server, between a wide search's windows, as its on-premises session opens, between connect attempts, or between its on-premises reads. It is filed Cancelled, "Cancelled by the operator". A read already sent to Exchange finishes first.

  • Section access key: MessageTrace

Recipient Lookup (/recipient-lookup)

Look up any recipient - by its address, another of its addresses, its alias or its name - and see what it is, where its mailbox is, and that mailbox's size, quotas, archive and settings. Read-only; every lookup is written to the audit log. The exact field list per recipient kind, with each value's source, is in docs/RecipientLookupAccuracy-Plan.md, "Displayed fields per recipient kind".

  • What it is. Recipient Type names the recipient in plain words - "Mailbox", "Shared mailbox", "Room mailbox", "Equipment mailbox", "Distribution list", "Mail-enabled security group", "Dynamic distribution list", "Room list", "Microsoft 365 group", "Contact", "Guest", "Mail user", "Mail-enabled public folder" - with Exchange's own type words in small print beneath. A type is named only when Exchange reports it from the side that holds the object: an on-premises mailbox, which Exchange Online sees as a mail user, is named from on-premises Exchange's own answer.
  • Where its mailbox is. Mailbox Location reads "Cloud", "On-Premises", "Not applicable" (it has no mailbox: lists, mail-enabled security groups, room lists, contacts, guests and mail users; a note beneath says so) or "Could not be determined". A Microsoft 365 group reads "Cloud": Exchange Online holds its mailbox, which this module does not read.
  • "Could not be determined" means Exchange Online and on-premises Exchange disagree about the recipient, or a side that had to be asked could not be: on-premises Exchange is not set up for this module, its credentials could not be retrieved, its session or read failed, or the account comes from a directory that server does not serve. The warning box at the top says which, and the small print gives each side's words. The page never guesses a type.
  • Primary Email is the recipient's real primary address; when you typed anything else it adds "(looked up as ...)". Every figure on the page is about that recipient.
  • On-premises Exchange is asked only when it has to be: for a mail user Exchange Online does not report as cloud-only, and for a type the app does not name when Exchange Online holds no mailbox for it. Cloud mailboxes, lists, groups, contacts, guests and cloud-only objects open no on-premises session.

The page, top to bottom. The error box (the lookup failed) or the warning box first. Then two tables, the same rows for every kind:

  • Left: Display Name, Primary Email, Recipient Type, Mailbox Location, Created, Last Logon, Last User Activity, Synced from On-Premises, Entra Object ID, Alias, Hidden from Address Book, External Address, Last Changed.
  • Right: Mailbox Size, Warning Quota, Prohibit Send Quota, Prohibit Send/Receive Quota, Send Quota Used, Deleted Item Size, Total Size, Item Count, Deleted Items, Archive, Archive Status; when an archive was found, Archive Size, Archive Quota, Archive Warning Quota, Archive Deleted, Archive Items; then Forwarding, Send on Behalf, Account Disabled.

Then the Show all details switch and Email Addresses (the first 20; every one with the switch on).

What each kind shows ("shown": the value Exchange returned):

Row Cloud mailbox On-premises mailbox No mailbox: mail user, list, security group, dynamic list, room list, contact, guest, public folder Microsoft 365 group Could not be determined
Mailbox Location Cloud On-Premises Not applicable, with a note Cloud Could not be determined
Display Name, Primary Email, Created, Synced from On-Premises, Entra Object ID, Alias, Hidden from Address Book, Last Changed, Email Addresses shown shown shown shown shown
External Address Not applicable shown: where Exchange Online sends its mail shown for a mail user, contact or guest; Not applicable otherwise Not applicable shown when Exchange Online holds it as a mail user; otherwise Not applicable
Last Logon, Last User Activity shown Not read for on-premises mailboxes Not applicable Not read for this recipient type Not read
Mailbox Size shown shown (read on-premises) Not applicable Not read for this recipient type Not read
Quotas, Send Quota Used shown Not read for on-premises mailboxes Not applicable Not read for this recipient type Not read
Deleted Item Size, Total Size, Item Count, Deleted Items shown Not read for on-premises mailboxes Not applicable Not read for this recipient type Not read
Archive Enabled / Disabled Enabled / Disabled (read on-premises) Not applicable Not read for this recipient type Not read
Archive Status shown Not read for on-premises mailboxes Not applicable Not read for this recipient type Not read
Archive Size (archive found) shown shown (read on-premises) hidden hidden hidden
Archive Quota, Archive Warning Quota, Archive Deleted, Archive Items (archive found) shown Not read for on-premises mailboxes hidden hidden hidden
Forwarding, Send on Behalf, Account Disabled shown Not read for on-premises mailboxes Not applicable Not read for this recipient type Not read

Site, public folder and system mailboxes read as a Microsoft 365 group does. A type the app does not name reads "Not a type this app names: ..." and is placed by what Exchange's own mailbox reads say.

The words. "Not applicable": it has no mailbox, so the row can have no value. "Not read for on-premises mailboxes" and "Not read for this recipient type": the value exists, and this module does not ask for it. "Not read": what the recipient is could not be settled, so its mailbox was not asked about. "Could not be read": the read was made and failed; the warning box says why. "Not reported by Exchange": Exchange's answer did not include it. "N/A": Exchange's answer carried no value for it. "None": Exchange said there is none. Created and Last Changed add "(Exchange Online's copy)" for a recipient synced from on-premises: the date is that of Exchange Online's copy, not of the account.

Show all details (off by default) lists every property each read returned and every email address. Legal and retention holds are left out.

  • Audit: each lookup writes a Lookup row: target is what was typed, and resolvedAddress the primary address it found. A cancelled lookup is filed Cancelled, in the words "Cancelled by the operator".
  • Leaving the page during a lookup cancels it at its next step: while it waits for a free Exchange Online connection, or between its Exchange Online reads; on-premises, while it waits for the server, as its session opens, between connect attempts, or between its reads. A read already sent to Exchange is never interrupted; it finishes first.
  • Requires: the Exchange Online connection; for on-premises mailboxes, OnPremExchange:ServerUri and the module's own on-premises Exchange credential (DelineaSecretId). Without them an on-premises mailbox reads "Could not be determined", saying why.
  • Section access key: RecipientLookup

On-Premises Permission Operations

When a target mailbox is detected as on-premises:

  • Regular users see an escalation message directing them to contact the Exchange team.
  • Power users (members of the MailboxPermissionsOnPrem or CalendarPermissionsOnPrem section-access groups) are prompted with a confirmation dialog and can proceed.
  • Execution uses an on-prem PowerShell remoting session (New-PSSession) authenticated with credentials retrieved from Delinea Secret Server at runtime.
  • Concurrency is throttled to 2 simultaneous on-prem operations.

Admin Settings Page (/admin-settings)

Module enablement controls, gated by Security:AdminGroups.

Per-Module Config Pages (/module-config/{ModuleId})

Each module has its own config page (linked in the sidebar) with:

  • Module Status — enable/disable the module (global admin only)
  • Section Access — which AD groups can use the module (global admin only)
  • Configuration — module-specific settings (config fields)
  • Module Admins — AD groups that can configure this module without global admin

Changes are stored in the config database - the module_enablement, section_access, module_config and module_admins tables (docs/ConfigDbOperations.md) - and take effect immediately. The legacy config/*.json files are imported once and archived; a tab whose store cannot be read says which table, or which legacy file was not imported, and does not offer Save. Global admins see all module config links; module admins see only their delegated modules.

Admin Event Log Page (/admin-event-log)

A read-only JSONL audit log viewer, also gated by Security:AdminGroups.

  • Browse audit events by date (one file per rotation period).
  • Filter by category, user, IP address, and result (Success/Failed).
  • Displays full event detail in an expandable JSON view.
  • Download CSV includes a Ticket column carrying the stored audit/trace ticket field.
  • A line in a log file that cannot be parsed is not shown, and the page says how many such lines each log had. One example is the cut line a writer that stopped mid-write leaves.

Usage telemetry. An "Events | Usage" toggle above the filters switches the page to an anonymous usage view: per module, how many times it was opened, how many audited actions ran, how many failed and how many visits touched it (every module is listed, so a module nobody opens shows zeros); per theme, how many visits ended on it; and a one-line visit summary. The rows behind it come from config/exchangeadmin-usage.db, a separate database that is never promoted between instances and is not part of the config backup. What is recorded: a throwaway per-visit id, a timestamp, the route or module opened, the audit category and action name of a completed action with its success flag, and the selected theme id. What is NOT recorded: the user, the IP address, the target of the action, the ticket number, or any operator-typed text. Rows older than 90 days are deleted at startup. Turn it off with the "Record anonymous usage" setting on the Event Log module in Admin Settings; the Home page notice announcing collection appears only while it is on, and the Usage view keeps showing rows already recorded.

Cloud Password Reset (/cloud-password-reset)

Reset the password of an Entra ID cloud-only account - one with no on-premises AD object, and therefore unreachable from the AD tooling L2 already has. Almost every target is an admin or tactical account; resetting a Global Administrator is the module's normal operating mode.

  • The operator does not choose where the password goes. The module reads the employeeId off the target account, finds the one directory user carrying it, and emails the new password to that person's mailbox. There is no address field on the page.
  • The operator normally never sees the password. It is generated by the app, written to Entra and mailed. A separate CloudPasswordResetReveal permission shows it on screen instead, for an account whose owner cannot be determined - the only route by which an operator can learn one.
  • A missing employee ID can be filled in (module 1.2.0, off until "Fill in a missing employee ID" is switched on in the module's settings). The owner is looked up from the account's name: the part before the @, less one of the configured endings, as a logon name across the server's forest. Only when exactly one person answers to it, their employee ID leads back to them alone and they have a mailbox, the preflight shows that person, the confirm saves their employee ID on the account, and the password is reset and mailed to them; the next reset of that account takes the ordinary route. Anyone who can reset passwords here can trigger the save - there is no new permission. When the lookup finds nobody suitable, a person who can open the module but cannot see passwords gets the same refusal as before, word for word, and a person who can see passwords sees it once, as before; nothing is saved. A save Entra refuses, or does not answer, never stops the reset, and the administrators are told the number was not saved. The endings are the deployment's to type in; the module ships with none. Detail: docs/CloudPasswordResetEmployeeId-Plan.md.
  • Refuses, with its reason audited, when the account is synced or a guest, when the employee ID is missing (and not filled in) or matches nobody, when it matches more than one person, when the owner has no mailbox, or when the directory could not be searched. None falls back to another destination.
  • Generated passwords are 18-32 character passphrases with a measured 60-bit entropy floor; after 100 attempts that fail the floor the generator refuses rather than issuing something weaker.
  • Force password change at next sign-in is checked by default - the password travels by email, so forcing a change makes it a one-time handover. Clearable per reset for an account whose sign-in path cannot service a change prompt.
  • A send failure after the write discards the password rather than showing it, logs at Critical and audits CloudPasswordReset_DeliveryFailed. Running the reset again issues a new one.
  • A saved employee ID is audited as CloudPasswordReset_EmployeeIdSet with its own administrator alert, and every event of the module carries employeeIdFill, employeeIdFillValue and employeeIdFillSource.
  • Requires: its own Graph app registration with User.Read.All, User-PasswordProfile.ReadWrite.All and RoleManagement.Read.Directory, and its own Delinea record. Ships disabled. Filling in an employee ID also needs the registration to be allowed to write user details - assumed to be User.ReadWrite.All (application), not yet confirmed: the first live save settles it. Without it, resets still go out and every save is reported not saved.
  • Section access keys: CloudPasswordReset, CloudPasswordResetReveal
  • Full detail: docs/CloudPasswordReset.md

MFA Reset (/mfa-reset)

Reset user MFA authentication methods via Microsoft Graph API.

  • Lists all registered authentication methods for a user (phone, FIDO2, authenticator app, etc.)
  • Confirmation step before deletion
  • Deletes all non-password authentication methods in one operation
  • Requires: Separate Graph app registration with UserAuthenticationMethod.ReadWrite.All application permission (per-module registration pattern)
  • Section access key: MfaReset

Group Management (/group-management)

Search and manage distribution lists, mail-enabled security groups, and Microsoft 365 groups.

  • Three backends depending on group type:
    • EXO — Cloud-only distribution and mail-enabled security groups
    • On-prem AD — Synced groups modified via Delinea-authenticated AD operations
    • Graph API — Microsoft 365 (Unified) groups
  • Add/remove members, view group details
  • On-prem AD members can be users or nested groups: the member list shows each member's kind, adds are guarded against self-nesting and membership cycles in the service, writes are confirmed by a post-write read-back, and a protected group member is refused (the ProtectedServicer:GroupManagement override applies)
  • Bulk actions (on-prem AD): tick members (select-all covers only removable rows) and remove them in one confirmed batch under one ticket; paste a list of usernames, emails, UPNs or group names, Resolve checks every line against the forest in one pass, and "Add resolved" adds the lines that resolved to exactly one object. Every member still runs the single-member path (per-row authorization, protection, read-back, audit), a per-row outcome table shows Done / Not done with the reason, and each batch writes one summary audit event (success only when every row succeeded) and one summary admin email
  • Requires: GroupManagementOnPrem section access for on-premises group modifications
  • Section access key: GroupManagement

Self-Service Groups (/self-service-groups)

On-prem AD group membership management for group OWNERS (managedBy / co-managers holding member-write rights on the group DACL), without admin access. (Section added 2026-08-27; the module previously had no README entry.)

  • Lists the groups the signed-in user owns; write eligibility is checked at list time against the group's DACL and re-checked at every write
  • Add or remove USER members by username, email, or UPN; every change is confirmed by a post-write read-back, audited, and notifies the affected member for security groups
  • Nested groups: adding a group is never possible here (the page says so up front and a typed group gets a refusal directing to the IT Support Desk); a nested group CAN be removed, behind an inline warning that re-adding it will require a ticket
  • Protected principals - including protected groups and groups nested under one - are refused; the ProtectedServicer:SelfServiceGroups override applies
  • Bulk actions: tick members (select-all covers only removable rows) and remove them in one confirmed batch - a nested group in the batch carries its one-way warning inside the confirmation; paste a list of usernames, emails or UPNs, Resolve checks every line in one pass (users only - a line naming a group is reported, not added), and "Add resolved" adds the lines that resolved to exactly one user. Every member still runs the single-member path (per-row authorization, eligibility, protection, read-back, audit, affected-member notification), a per-row outcome table shows Done / Not done with the reason, and each batch writes one summary audit event (success only when every row succeeded) and one summary admin email
  • Section access key: SelfServiceGroups

M365 Group Management (/m365-group-management)

Create, modify, and delete Microsoft 365 groups via Graph API.

  • Full lifecycle management for M365 (Unified) groups
  • A ticket number is required before any create, update, delete or member/owner change runs: a blank one, or one ServiceNow rejects where that integration is enabled, is refused with an audited denial and nothing is sent to Graph
  • Requires: Graph app registration with Group.ReadWrite.All permission
  • Section access key: M365GroupManagement

Comms-10k (/comms-10k)

Dedicated bulk member replacement for broadcast distribution lists.

  • CSV upload of new member list
  • Resolves all entries before applying changes
  • Confirmation step stating the number of members the list will be set to. Not an add/remove diff: the write never reads the prior membership, so no removal count exists to show
  • Clear, then fill, then read back. The directory refuses a single-operation swap at this module's size, so the write empties the member attribute, adds the resolved list in batches of 2,000, and reports from a read-back of what the group actually holds -- never from which batches were believed to have run. This gives up atomicity: the list is briefly empty and then partial during the sequence (about eight seconds at ten thousand members), and a failure in that window leaves it incomplete. The module reports that state explicitly rather than as a generic failure, and re-running the same CSV repairs it. Replaces are not serialised (owner ruling 2026-09-30): two run at the same time on the same group can interleave and leave the list holding neither uploaded file. Each run's read-back compares against its own list, so an interleaved run reports as incomplete rather than claiming success, and re-running one file on its own repairs it
  • Runs no protected-principal check of either kind -- see the Protected Principals section of docs/ProjectConstitution.md, which carries it as a named scoped exception
  • Uses Delinea credentials for Active Directory operations
  • Replacements run as durable background jobs. A separate Submitted notice confirms enqueue; Your jobs on this page shows all your active replacements and paged retained history, including job messages, ticket/times and recorded Resolve/Replace stage outcomes. A Completed job may contain failures or no recorded stages. Missing AD credentials do not hide history; current Comms-10k permission and enablement still gate access.
  • Request cancellation of your queued/running jobs or delete your finished job records. Cancellation cannot undo or abort an in-flight clear/refill. Deleting a record retains its audit history; job controls are scoped to your full signed-in identity and are audited.
  • Section access key: Comms10k

AD Attribute Editor (/ad-attribute-editor)

View and edit allowlisted Active Directory attributes for on-premises user accounts.

  • Tiered access control with three granular permission levels (Level1, Level2, Level3)
  • Configurable attribute allowlist, edited in the Editable Attributes table on the module's Module Configuration page and stored in the config database (editable_attribute; repairs per docs/ConfigDbOperations.md, with both application pools stopped). A legacy config/ad-editable-attributes.json is imported once and archived; one the import refuses is left in place and named, with its reason, on the Module Configuration page
  • Optional search base boundary restrictions
  • Uses Delinea credentials for Active Directory operations
  • Section access keys: ADAttributeEditor, ADAttributeEditorLevel1, ADAttributeEditorLevel2, ADAttributeEditorLevel3

Licensing Updates (/licensing-updates)

Bulk update Exchange licensing SKU assignments (extensionAttribute11) via CSV upload.

  • CSV upload with validation against allowed license types
  • Configurable allowed license values (default: E5, EOP2+SOP2, F3, F3+EOP1)
  • Uses Delinea credentials for Active Directory operations
  • Section access key: LicensingUpdates

Conference Rooms (/conference-rooms)

Room mailbox metadata configuration and booking policy management.

  • Set room properties: city, building, capacity, floor label, timezone
  • Apply booking policy templates (the RoomType enum in Models/ConferenceRoomModels.cs):
    • Standard — Open auto-booking (AllBookInPolicy), 180-day window
    • Video — Like Standard plus a video-room additional response
    • Restricted — Delegate/group-gated booking (BookInPolicy), 180-day window, site-aware
    • Exception — Restricted to an exception group, 1080-day window
    • CEO — CEO-group only; always clears existing calendar permissions, 1080-day window
    • Executive — Exec VP/admin groups, 1080-day window
  • CSV bulk upload for multi-room setup
  • Room list management (add/remove rooms from room lists)
  • Leaving the page during a single-room setup cancels it at its next step before Set-Place - the room read, the room list's resolution, a preflight's Active Directory step, or the wait for a free Exchange Online connection: nothing is written, and it is filed Cancelled, "Cancelled by the operator. Nothing was written.". Once Set-Place is sent the setup runs to its end, so the room is never left half-configured.
  • Cancelling a Room Finder job on the Bulk Jobs page stops the row in hand at the same steps: nothing is written for it, its audit entry is Cancelled, "Cancelled by the operator. Nothing was written.", under the submitter's name and ticket, and it is not listed among the job's row outcomes. A row already past Set-Place finishes and is recorded. A row stopped by the service shutting down is filed Failed, "The service stopped before this row was written. Nothing was written.".
  • Section access key: ConferenceRooms

Emergency Disable (/emergency-disable)

Rapidly disable a compromised user account across on-prem AD and Entra ID with session revocation.

  • Disables the AD account and revokes Entra ID sessions in a single workflow
  • Security team notification on every disable action
  • Requires: AD Delinea secret for account disable and Graph Delinea secret for session revocation
  • Section access key: EmergencyDisable

Risky Users (/risky-users)

Review and act on Microsoft Entra ID Protection risky users via Microsoft Graph API.

  • Filtered read of risky users by risk level, risk state, and UPN contains; results are capped at 500 rows (Graph's own $top maximum) and a truncated result is shown as such, never silently cut off
  • Per-row risk history expander
  • Remediation sits behind the RiskyUsersRemediate granular permission, fail-closed like the main module permission. Each action is one Graph call per user, never a batch, so a refusal on one row cannot be reported as success or failure for another. Button wording is deliberately plainer than Microsoft's own vocabulary (owner ruling 2026-09-02, for L2 support desk staff), each with a tooltip and confirmation line stating the consequence before it commits:
    • Close as handled -- Graph's dismiss. Clears the alert; nothing is reported as right or wrong, and it can fire again
    • This was the real user -- Graph's confirmSafe. Clears the alert and tells the risk engine this activity is normal for them
    • Account was breached -- Graph's confirmCompromised. Raises the user to high risk; their sign-in is blocked or forced to reset their password, immediately
  • A ticket number is required before any remediation action runs
  • A protected-principal check runs before every write, using the two-branch model (resolved and unresolved) and the risky user's Entra object id, since risky users are cloud identities with no on-prem AD object; the ProtectedServicer:RiskyUsers override applies on both branches
  • Every remediation action produces a named per-user outcome and an admin notification
  • Reads are audited but never alert-emailed (owner ruling 2026-08-31): a triage session that refreshes the list repeatedly does not generate an alert per refresh
  • Requires: Microsoft Entra ID P2, and a dedicated Graph app registration (admin-consented IdentityRiskyUser.Read.All for reads, plus IdentityRiskyUser.ReadWrite.All for remediation) with its own Delinea secret. Outstanding as of this writing -- blocks the first live Graph call, not the code
  • Section access key: RiskyUsers

Intune Devices (/intune-devices)

Search Microsoft Intune managed devices, view device detail, and delete, retire or wipe a device via Microsoft Graph API.

  • Search by device name, user principal name, or serial number, capped at 500 rows (SearchResultLimit, defaults to 50); a truncated result is shown as such, never silently cut off. Device name and UPN match from the start of the value (startswith); a serial number must be exact. Typing the middle of a device name finds nothing, which the search box says on screen. Intune does not support a substring (contains) filter on managed devices
  • Each search sends one Graph request per field and merges the three result sets by device id: a single filter combining the three properties with or returns an empty result on this tenant instead of matching. Any one of the three requests failing fails the whole search, and a device Graph returns that does not match the term is hidden with a count shown, never rendered as a match
  • What the actions do -- the same wording appears on the page in a collapsible "What do these actions do?" panel, closed by default, plus a tooltip on each button and the first two sentences above each confirmation's ticket box:
    • Delete record -- Removes the device from Intune only. Company data and apps stay on the device until it next checks in, and if it never checks in, they stay forever. The device's Entra ID entry is not touched. Use this to clean up a device that is already gone
    • Retire -- Tells the device to remove company data, apps and settings, and to leave Intune management. Personal data stays. Happens the next time the device checks in, which for a powered-off device may be never. Use this for a device leaving the company that the person keeps
    • Wipe -- Factory reset. Everything on the device is erased, personal and company, the next time it checks in. Cannot be undone. Use this for a lost, stolen, or reassigned device
    • Also remove Entra ID entry (checkbox) -- Also deletes the device's entry in Entra ID, which is separate from Intune. Do this only when the device is gone for good; a device still in use will lose its sign-in and may need to be re-joined
    • Email the user (checkbox) -- Sends the device's primary user a plain notice that the action was taken and which ticket authorized it
  • Full device detail, including compliance state, encryption state, and enrollment info; the activation lock bypass code is excluded at the request boundary and never rendered, audited, or logged
  • Three destructive actions at two permission tiers:
    • Delete (IntuneDevicesDelete) removes the Intune management record only. Company data stays on the device until its next check-in -- forever if it never checks in again -- and the device's Entra ID object survives
    • Retire and Wipe (IntuneDevicesPrivileged) queue the action at Intune; Graph returns 204 when the request is accepted, not when the device carries it out, so results read "queued", never "done". Every parameter Graph's wipe endpoint accepts (keep user data, keep enrollment state, macOS recovery PIN and obliteration behaviour, keep eSIM data plan) is an operator control, defaulting to a full reset
  • Optional removal of the device's Entra ID object (IntuneDevicesEntraDelete), offered beside each action and standalone, always starting unticked so it is opted into by the operator confirming the action. Addressed by its alternate key (azureADDeviceId), captured before the Intune action runs since a deleted Intune record cannot be read afterward; the Intune and Entra outcomes are reported and audited independently, so a half-finished result is never shown as a plain success
  • Per-action "email the affected user" checkbox, decided by the operator running the action rather than by a deployment-wide setting -- neither this nor the Entra removal is a Module Config field. The box starts from a fixed state per action (off for Delete, on for Retire and Wipe) and is changeable at the moment of acting, which is what the lost-or-stolen case needs; if the deployment has user notifications disabled app-wide, the suppression is stated on screen and recorded in the audit event rather than silently sending nothing
  • A protected-principal check runs before every write, against the device's primary user, with the ProtectedServicer:IntuneDevices override
  • A ticket number is required before any action runs
  • Requires: a dedicated Entra app registration, admin-consented, with DeviceManagementManagedDevices.Read.All, .ReadWrite.All, and .PrivilegedOperations.All, plus Device.ReadWrite.All for the Entra removal -- that last one is a directory scope covering every device object in the tenant, the widest grant in the module, worth weighing before consenting -- and its own Delinea secret. Outstanding as of this writing -- blocks the first live Graph call, not the code
  • Section access key: IntuneDevices

DHCP Authorization (/dhcp-authorization)

Authorize or deauthorize DHCP servers in Active Directory.

  • Authorize a server by IP/hostname to allow DHCP service in the domain
  • Deauthorize a server to revoke DHCP serving rights
  • Confirmation dialog (high-privilege operation)
  • Results can be downloaded as CSV (DnsName,IpAddress)
  • Requires: Module-specific Delinea secret (Enterprise Admin credentials)
  • Section access key: DhcpAuthorization

BitLocker Recovery (/bitlocker-recovery)

Look up BitLocker recovery keys for a caller locked out at the pre-boot screen. Read-only: the module never writes to Active Directory or to the archive. Disabled by default and fail-closed, because a recovery key decrypts a whole disk.

  • Search by computer name fragment, full key ID GUID, short key ID prefix, or a pasted 48-digit recovery key. The key ID and recovery key are the only identifiers that survive a machine rename or reimage
  • Reads a local SQLite archive written by the scheduled Export-BitLockerKey.ps1 task. Deleting a computer object from AD deletes its msFVE-RecoveryInformation children, so for retired hardware the archive is the only surviving copy
  • Optionally also searches live Active Directory, for a key created since the latest export. Noticeably slower, so it is off by default
  • A ticket number is required before any search runs. It is written on the search and reveal audit events (and the Event Log CSV Ticket column), and the ValidateTickets module switch controls whether it is merely required (default) or validated against ServiceNow -- On while that integration is dormant refuses every search rather than silently validating nothing
  • Results are masked until an operator reveals one. The reveal is the audited security event, not the search; the recovery key itself never enters an audit record, log line, or error message
  • An unreachable source is an error, never an empty result: on a recovery call "no key exists" and "I could not look" must not look alike. If live AD fails but the archive succeeds, archive rows are shown with a warning
  • Results can be downloaded as CSV (Computer,RecoveryKey,Created,KeyId,Source,LastSeenInAd,Ticket), with the recovery keys included in the file -- the export's whole purpose is bulk key retrieval. The download is itself an audited disclosure event (ExportRecoveryKeysCsv, recording the search ticket and row count), and as with every other BitLocker audit event the key material itself never enters the audit log
  • Requires: ArchiveDatabasePath pointing at a local path (SQLite WAL needs shared memory that SMB does not provide, so UNC paths are refused). Live AD fallback additionally needs a module-specific Delinea secret for an account that can read msFVE-RecoveryPassword, plus the ActiveDirectory PowerShell module on the web server
  • Section access key: BitLockerRecovery
  • Full operator and maintainer notes: docs/BitLockerRecovery.md

Named Locations (/named-locations)

Manage Entra ID Conditional Access named locations via Microsoft Graph API.

  • Create, edit, and delete IP range locations (IPv4/IPv6 CIDR)
  • Create, edit, and delete country/region locations
  • Mark locations as trusted
  • A ticket number is required before any create, edit or delete runs: a blank one, or one ServiceNow rejects where that integration is enabled, is refused with an audited denial and nothing is sent to Graph
  • Full pagination support for large tenants
  • Results can be downloaded as CSV (Name,Type,Trusted,IpRanges,CountryCodes,IncludeUnknownCountries,Created,Modified)
  • Requires: Graph app registration with Policy.ReadWrite.ConditionalAccess permission
  • Section access key: NamedLocations

Account Lockout Remediation (/account-lockout-remediation)

Identify the source machine of an account lockout and log the affected accounts off the implicated or scoped domain computers. Disabled by default; both permissions fail-closed.

  • Discover lockout sources from Security 4740 events on the PDC Emulator (or named DCs)
  • Log selected accounts off the implicated source machines
  • Scoped sweep: log accounts off every machine in an OU/computer list (with a MaxSweepTargets cap and a WinRM fan-out throttle)
  • Dry-run by default; execution requires a ticket number and typed "LOG OFF" confirmation
  • Targets are re-resolved through Protected Principals with an immutable-GUID re-check immediately before logoff; all paths audited and traced
  • Granular Logoff permission gates execution separately from Access
  • Requires: module-scoped AD credential (DelineaSecretId) with rights to read lockout events, query sessions, and log off sessions; WinRM to target machines
  • Section access keys: AccountLockoutRemediation, AccountLockoutRemediationLogoff

Blocked Senders (/blocked-senders)

View and unblock Exchange Online accounts blocked from sending mail for outbound spam.

  • Lists all currently blocked senders with reason and blocked date
  • Unblock a sender; the target is re-checked through the protected-principal check before the unblock runs
  • A configurable protected-principal servicer group can be granted access to unblock otherwise-protected senders for this module only (see Protected Users / Excluded Users)
  • Results can be downloaded as CSV (SenderAddress,Reason,Blocked)
  • Requires: Exchange Online connection (no module-specific Delinea secret)
  • Section access key: BlockedSenders (granular: BlockedSendersUnblock)

Defender for Endpoint Devices (/defender-endpoint-devices)

List and export Microsoft Defender for Endpoint devices, including the devices Defender has discovered on the network that can be onboarded and are not. Read-only: the module cannot onboard, isolate, scan or offboard anything, and the app registration behind it holds no permission that could. Disabled by default and fail-closed.

  • Filters on onboarding status (defaults to "can be onboarded") and a Windows-only toggle. The Windows rule is applied client-side after the fetch: osPlatform carries Windows10, Windows11 and the server variants as separate values, so there is no single server-side "Windows" filter to ask for
  • A run returns the complete matching set or it refuses in words. No table and no export button when completeness cannot be proved: more devices match than MaxDevices (default 20000), the API returned exactly the page it was asked for with no continuation link, or any request in the chain failed. A partial CSV that looks whole is the one output this module must never produce. A 404 on the first request is the documented empty inventory, not a failure
  • Discovery sources, device type, category, vendor and model come from a second call: one constant DeviceInfo advanced hunting query on Microsoft Graph. Its failure never takes the page down -- those five columns read (unavailable) with the reason named above the table, never merely blank
  • The detail panel renders from the row the list already returned. The module never calls get-machine-by-id, which Microsoft documents only under Machine.ReadWrite.All
  • Results can be downloaded as CSV (27 columns: device identity and DNS name, OS, health, IP and MAC addresses, first/last seen in UTC, risk and exposure, machine tags and group, Entra join state, and the five discovery columns), audited with the row count
  • Requires: a module-specific Delinea secret holding Tenant ID, Application ID and Client Secret for an app registration with Machine.Read.All on WindowsDefenderATP and, for discovery sources, ThreatHunting.Read.All on Microsoft Graph. Both need admin consent, and the Graph one must be consented by a Privileged Role Administrator or Global Administrator. No redirect URI; a client secret, not a certificate
  • Section access key: DefenderEndpointDevices
  • Full operator and maintainer notes, including the app-registration steps and the manual validation checklist that has not yet been run: docs/DefenderEndpointDevices.md

Security & Compliance

  • Windows Authentication — Seamless SSO with Active Directory
  • Group-based Authorization — Restrict access to specific AD groups per module
  • Self-grant Prevention — Users cannot grant themselves permissions
    • Mailbox Permissions and Calendar Permissions check the grantee against Exchange Online and the on-premises directory, including every group that holds the operator - nested, through their primary group, or, for a group only Exchange Online holds, through its members. A check that cannot be completed refuses; it never clears a grant
    • Needs the Directory Read Credential (Admin Settings, Protected principals) - the credential the protected-principal check reads with. While the check is on (Prevent Self-Grant, the default), every grant needs it: without it, every grant is refused as incomplete, naming the setting - to a person or to a group, whichever directory holds the grantee - and nothing is written. A removal is not checked and does not need it. With it configured, each check fetches it once (a bulk file once for the whole file)
    • A dynamic distribution group is checked against the members Exchange Online last calculated for it - Exchange refreshes them about every 24 hours, and within about two hours of a change to the group's filter - so a recipient its filter matches only since then is not compared. One Exchange reports no member of is refused as incomplete, naming it: an empty calculated membership cannot be told from one Exchange has not calculated yet
  • Protected User Lists — Block modifications to C-suite/executive mailboxes (with AD group expansion)
  • Audit Logging — JSONL format logs with full operation details (always active)
  • Extended Diagnostic Logging — Configurable log level (None/Error/Warning/Info/Debug) with separate log file, viewable in Event Log page
  • Email Notifications — Admin notifications on all operations, optional user notifications
  • Fail-closed Design — Corrupt config or credential failures block operations rather than bypassing security

Requirements

Server Prerequisites

  • Windows Server 2016+ (IIS with Windows Authentication)
  • .NET 10 SDK and ASP.NET Core 10 Runtime (Windows Hosting Bundle)
  • PowerShell 7.4+
  • ExchangeOnlineManagement PowerShell Module (v3.7.0+ required for Get-MessageTraceV2)
  • No SQLite tooling. The deploy/promote/installer scripts make the verified, consistent online backup of the runtime config database (the shared exchangeadmin.db, see "Shared config database" below) by running the app's own db command-line mode through <ProgramFiles>\dotnet\dotnet.exe, using the build they are about to deploy. The scripts fail fast if that build is missing or predates the db mode (app 2.34.0) rather than fall back to an unverified file copy. sqlite3.exe is not used by any script. The sqlite3 shell is only needed for the manual ad-hoc SQL and .recover recipes in docs/ConfigDbOperations.md, which says how to install it safely.

Exchange Online Prerequisites

  • Azure App Registration with:
    • Exchange.ManageAsApp API permission (application permission, admin consented)
    • Certificate authentication configured
    • Service principal assigned Exchange Administrator role or scoped RBAC role
  • Certificate installed in server's certificate store (LocalMachine\My or CurrentUser\My)
    • Must match CertificateSubject in appsettings.json
    • Private key must be accessible to IIS app pool identity

Active Directory

  • AD Security Groups for authorization (e.g., IT-Helpdesk, Exchange-Admins)
  • Users must be authenticated via Windows Authentication (domain-joined)
  • ActiveDirectory PowerShell Module (RSAT-AD-PowerShell) required for migration eligibility checks
  • IIS App Pool Identity must have AD read permissions to query user group memberships
    • The deployment script requires -ServiceAccount on fresh install (prompts interactively if omitted); on upgrade it retains the existing app pool identity
    • Configure IIS manually if using ApplicationPoolIdentity or a gMSA instead

Installation

1. Install Prerequisites

Install .NET 10:

# Download and install .NET 10 SDK
# https://dotnet.microsoft.com/download/dotnet/10.0

# Download and install ASP.NET Core Windows Hosting Bundle
# https://dotnet.microsoft.com/download/dotnet/10.0

Install IIS Features:

Enable-WindowsOptionalFeature -Online -FeatureName IIS-WebServer -All
Enable-WindowsOptionalFeature -Online -FeatureName IIS-WindowsAuthentication -All
Enable-WindowsOptionalFeature -Online -FeatureName IIS-ASPNET45 -All

Install PowerShell Modules:

# Exchange Online Management module
Install-Module -Name ExchangeOnlineManagement -Force -AllowClobber -Scope AllUsers

# Active Directory module (required for migration eligibility checks)
Install-WindowsFeature RSAT-AD-PowerShell

2. Configure Exchange Online App Registration

  1. Create Azure App Registration with certificate authentication
  2. Grant Exchange.ManageAsApp API permission (admin consent required)
  3. Assign service principal Exchange Administrator role in Entra ID
  4. Install certificate on web server

3. Configure Application Settings

Copy appsettings.json.sample to appsettings.json and configure:

{
  "ExchangeOnline": {
    "AppId": "your-app-id-here",
    "Organization": "yourorg.onmicrosoft.com",
    "CertificateSubject": "CN=EXO-Automation"
  },
  "Audit": {
    "LogRoot": "D:\\Logs\\ExchangeAdminWeb",
    "RotationPeriod": "daily",
    "MaxFileMB": 50,
    "MaxFilesPerPeriod": 5
  },
  "OperationTrace": {
    "Enabled": true
  },
  "Email": {
    "SmtpHost": "smtp.yourcompany.com",
    "SmtpPort": 25,
    "SmtpUseSsl": false,
    "FromAddress": "exchangeadmin@yourcompany.com",
    "FromName": "Exchange Admin",
    "AdminNotificationEmail": "itadmin@yourcompany.com",
    "NotifyUsersOnPermissionGrant": false
  },
  "Security": {
    "AllowedGroups": [
      "DOMAIN\\IT-Helpdesk",
      "DOMAIN\\Exchange-Admins",
      "DOMAIN\\Migration-Team"
    ],
    "AdminGroups": [
      "DOMAIN\\Exchange-Admins"
    ]
  }
}

Important: appsettings.json is excluded from git for security. Never commit production credentials.

Required: Audit:LogRoot must be set to an absolute path outside the deploy folder (the app deploys to D:\inetpub\<app name>, which is the content root; logs must not live under it or under wwwroot). There is no baked-in default: the app will not start if Audit:LogRoot is unset or blank, so audit logs are never silently misplaced. Each environment sets its own value.

Number settings and the trace switch: Audit:MaxFileMB (default 50), Audit:MaxFileBytes (not set), Audit:MaxFilesPerPeriod (5), ExtendedLog:MaxFileMB (10), ExtendedLog:MaxFileBytes (not set), ExtendedLog:MaxFilesPerDay (5), Email:SmtpPort (25), BulkJobs:StaleHeartbeatMinutes (5), BulkJobs:RecentJobLimit (25) and BulkJobs:RetentionDays (30) take a number, and OperationTrace:Enabled (true) takes true or false. Left out or blank, each is its default. A value that cannot be read - 50MB, five, yes - is logged once as a warning naming the setting and the value, and is read as the default until it is corrected; a MaxFileBytes that cannot be read is not set, so its MaxFileMB applies. Before, such a value stopped the audit and trace writer, the trace, the extended log, the mail service or the bulk job runner from being built: every page that needs one failed, and for the job runner the app did not start.

The three bulk-job settings also have a range, and a number outside it is read as the default and named the same way: BulkJobs:StaleHeartbeatMinutes 1 to 1440 (a minute to a day before a running job with no heartbeat shows as Stalled), BulkJobs:RecentJobLimit 1 to 1000 (finished jobs listed) and BulkJobs:RetentionDays 1 to 3650 (a day to ten years of finished job records kept). Before, any finite number was taken: a very large one threw out of the runner's constructor so the app did not start, a negative or zero retention made the start-up prune delete every finished job record, and a negative limit became no limit at all.

4. Deploy to IIS

Generic / new environments — use the standalone product installer in elevated PowerShell. It is environment-neutral, prompts for environment-specific values, and preserves existing appsettings.json and config/ fragments on update:

.\tools\Install-ExchangeAdminWeb.ps1            # interactive fresh install or update
.\tools\Install-ExchangeAdminWeb.ps1 -PlanOnly  # dry run, no changes

ADI environment only — deploy.ps1 is the ADI-specific deployment helper (it targets ADI aliases/app pools and the ExchangeAdminWebDev dev path). Do not use it in other environments; it is intentionally separate from the standalone installer.

.\deploy.ps1

deploy.ps1 updates in place when the publish folder already holds appsettings.json. -Force (which regenerates appsettings.json) and -ConfirmFreshInstall take the fresh-install path, which points the IIS application at the run's -PublishPath and -AppPoolName: if the application already exists with another folder or pool, the run is refused before anything is configured, unless -AllowRepoint says the move is intended. An upgrade is checked the same way, before it configures the pool or grants any folder access, and is refused, -AllowRepoint or not: an upgrade never moves the application. A -AllowRepoint move to another folder takes its backups from the folder the application is leaving and carries that configuration's ConfigStore:Path into the new appsettings.json; it is refused if that folder keeps its own config database (move the database to a shared path first with tools\Move-ConfigDbToShared.ps1).

The deploy/install scripts will:

  • Stop the application pool
  • Build and publish the application
  • Create/configure the IIS application pool
  • Create the web application under Default Web Site
  • Enable Windows Authentication and disable Anonymous Authentication
  • Grant app pool access to certificate private key
  • Grant app pool write access to audit log folder
  • Restart the application pool

Default URL: http://yourserver/ExchangeAdminWeb

Shared config database (dev and prod on one server)

All runtime settings that admins change in the app (which modules are on, who can see which section, module settings, protected principals, editable attributes) live in one SQLite file, exchangeadmin.db. When a server hosts both a dev and a prod instance, they open the same file: each instance's appsettings.json names it under ConfigStore:Path (on the ADI host: D:\inetpub\ExchangeAdminWebShared\config\exchangeadmin.db). What this means day to day:

  • A setting saved on dev is live on prod within a couple of seconds. Dev is not a place to try a setting first; there is one set of settings, on purpose.
  • Promotion copies binaries only. It never copies, replaces or merges the config database, and its rollback never restores the database either (that would undo dev's changes too). Every deploy of either instance first takes a verified backup of the shared file into <LogRoot>\ExchangeAdminWeb\ConfigBackups\config.<timestamp>.bak\.
  • New database tables reach prod through the shared file: whichever instance starts first adds them, and the other accepts a database newer than itself. Schema changes are therefore additive-only.
  • If ConfigStore:Path names a file that does not exist, the app refuses to start and the deploy/promote scripts stop before changing anything. Nothing ever creates the shared database silently.
  • The bulk-job database (config\exchangeadmin-jobs.db) stays per instance.

A single instance without ConfigStore:Path keeps using its own config\exchangeadmin.db, created on first start, exactly as before.

Moving to the shared file (one-time, elevated, both instances already on a build that accepts a newer database - 2.19.0 or later):

.\tools\Move-ConfigDbToShared.ps1 -PlanOnly   # prints the eight steps, changes nothing
.\tools\Move-ConfigDbToShared.ps1 -Apply      # stops both pools, backs up both databases,
                                              # copies dev's to the shared path, writes the key
                                              # into both appsettings.json, starts both pools

Dev's database becomes the shared one (prod's only ever held copies of it). The script prints the commands that return to two databases, both after success and on any failure. A new instance that should join an existing shared database is installed with .\tools\Install-ExchangeAdminWeb.ps1 -ConfigStorePath <path>; the file must already exist.

Shared log root (dev and prod on one server)

Two instances on one server may write under the same Audit:LogRoot; the ADI deploy scripts default both to one root. They then append to the same daily audit, trace and extended diagnostic files. That is safe only because every writer serialises across processes:

  • Before it touches a log file, each writer takes that file's lock: <file>.lock beside it, opened exclusively. The open handle is the lock, not the file. After the write the file is removed if the folder allows it, and otherwise left in place, empty and harmless.
  • It holds the lock through rotation, the write, and the take-back of a write that fails part-way.
  • A write that cannot take the lock within 2 seconds, because the other instance holds it, is dropped and logged as a failed audit write: its trace step says the row was not written. It is never written over the other instance's row.
  • A lock file the folder refuses - this instance's account may not create it, or may not read and write the one already there - is not a wait. The write is dropped at once and logged as a failed audit write, and the error says the log folder refused the lock file.
  • The lock is the file system's, so it holds whichever account each app pool runs as.
  • Readers never take it, and a .lock file is not a log.

What you need to do:

  • Give each instance's account these rights on <LogRoot>\ExchangeAdminWeb: create files, and read and write the files there - the other instance's too, its log files and lock files alike. The lock does not need delete, but rotation does: without it, a file that reaches its size limit cannot be rotated. The writes to it still land, in that file past its limit, and the failure is logged as a warning (see Rotation below). deploy.ps1 and the installer grant each instance Modify on the folder, which covers all of it.
  • Both instances must run the same build. A build older than the lock writes without it, and while one does, the two can still overwrite each other's rows. The first build with the lock also needs delete rights to open a lock file that is already there.
  • To avoid the question altogether, give each instance its own Audit:LogRoot. That is a deploy-config change: -LogRoot on deploy.ps1 and tools/deploy-pipeline.ps1, or the installer's prompt.

5. Configure IIS Windows Authentication (if needed)

The deploy script configures Windows Authentication automatically. If authentication issues occur:

# Ensure only NTLM provider is enabled
Import-Module WebAdministration
Clear-WebConfiguration -Filter '/system.webServer/security/authentication/windowsAuthentication/providers' -PSPath 'IIS:\' -Location 'Default Web Site/ExchangeAdminWeb'
Add-WebConfigurationProperty -Filter '/system.webServer/security/authentication/windowsAuthentication/providers' -Name '.' -Value @{value='NTLM'} -PSPath 'IIS:\' -Location 'Default Web Site/ExchangeAdminWeb'
Set-WebConfigurationProperty -Filter '/system.webServer/security/authentication/windowsAuthentication' -Name 'useAppPoolCredentials' -Value 'True' -PSPath 'IIS:\' -Location 'Default Web Site/ExchangeAdminWeb'

Architecture

  • EXO Connection Pool: 5 pooled PowerShell runspaces for Exchange Online operations; connections idle longer than 20 minutes are automatically recycled. A call whose caller has cancelled - the operator leaving the page - stops waiting for a free connection (up to two minutes) or for its turn to connect (up to ninety seconds) at once, and gives back a connection it has not used yet. No command already sent to Exchange is ever stopped: it runs to its end. A connection whose command was ever stopped is discarded, never lent to the next caller.
  • Bulk CSV Optimization: Bulk operations borrow a single pooled session for the entire batch (not per-row), reducing 50-row CSV processing from minutes to seconds.
  • On-Prem Throttle: On-premises Exchange operations limited to 2 concurrent PSSession connections with retry (3 attempts, exponential backoff). The on-premises work that takes the operator's cancel - Recipient Lookup's reads, the permission pages' mailbox location read, both permission pages' on-premises changes and Message Analysis' on-premises search and detail - stops waiting for one of the two (up to two minutes) at once, and looks at the cancel again as the session opens, before each connect attempt, during the pause between attempts, between its reads and, for a change, immediately before its first command is sent; a connect attempt or a command already sent runs to its end, and the session is still removed.
  • Blazor Server: Interactive Server rendering mode with per-circuit state.
  • Leaving a page while work runs: the app asks first, and the prompt says what leaving does to each operation still running. Work that takes the cancel - today Recipient Lookup's lookup, a single Mailbox or Calendar Permissions change, an on-premises Mailbox or Calendar Permissions change, a Conference Rooms single-room setup, a Message Analysis search, delivery detail or detail download, and a Blocked Senders unblock - is cancelled on OK, and the prompt says what that does: work still waiting its turn is cancelled; a command already sent to Exchange or the directory finishes and is recorded, and anything already changed stays changed; a bulk job is not affected, because it never depended on the page. For any other work it says "Leaving will not stop the operation in progress; it will complete and be recorded."
  • Audit + Operation Trace: Append-only JSONL files rotated daily/weekly/monthly; every audit transaction includes a correlated operationId plus structured operation.* records for SIEM ingestion.
  • Extended Logging: Separate JSONL diagnostic log with runtime-configurable level (Admin Settings). Viewable from Event Log page.
  • Credential Management: Delinea Secret Server SDK auth via Windows PasswordVault. Per-module secrets isolated in Secret Server.
  • Thread Safety: PermissionValidator uses immutable collections with atomic swap; SectionAccessService uses in-memory cache invalidated on save.

Configuration Details

Group-Based Authorization

Each module is gated by its own section access groups. Security:AllowedGroups is only a backward-compatibility fallback for modules that are not fail-closed while section access has never been saved to the config database and no legacy Security:SectionAccess block exists:

"AllowedGroups": [
  "DOMAIN\\IT-Helpdesk",
  "DOMAIN\\Exchange-Admins",
  "DOMAIN\\Migration-Team"
]
  • Groups are checked using Windows role claims
  • Both simple names (IT-Helpdesk) and domain-qualified names (DOMAIN\IT-Helpdesk) are supported
  • Once section access is configured, the section's groups are the gate; users do not also need to be in AllowedGroups
  • Fail-closed modules deny access until explicit section access groups are configured

Admin Groups

Members of Security:AdminGroups can access Admin Settings. Admin Event Log is controlled by its own EventLog section access policy:

"AdminGroups": ["DOMAIN\\Exchange-Admins"]
  • Empty or missing = Admin Settings is inaccessible to everyone (fail-closed).

Section-Level Access (SectionAccess)

Each application feature is independently gated by AD group membership via its section access groups, managed on each module's config page (/module-config/{ModuleId}) and stored, as group SIDs, in the section_access table of the config database (docs/ConfigDbOperations.md). Until the first save, the legacy Security:SectionAccess block in appsettings.json applies, in this shape:

"SectionAccess": {
  "MailboxPermissions": ["DOMAIN\\Exchange-Admins"],
  "CalendarPermissions": ["DOMAIN\\Exchange-Admins"],
  "MailboxPermissionsOnPrem": ["DOMAIN\\Exchange-Admins"],
  "CalendarPermissionsOnPrem": ["DOMAIN\\Exchange-Admins"],
  "MigrationCheck": ["DOMAIN\\Exchange-Admins", "DOMAIN\\Migration-Team"],
  "MigrationCreate": ["DOMAIN\\Exchange-Admins", "DOMAIN\\Migration-Team"],
  "MigrationManage": ["DOMAIN\\Exchange-Admins"],
  "DelegationReport": ["DOMAIN\\Exchange-Admins", "DOMAIN\\IT-Helpdesk"],
  "MessageTrace": ["DOMAIN\\Exchange-Admins", "DOMAIN\\IT-Helpdesk"],
  "RecipientLookup": ["DOMAIN\\Exchange-Admins", "DOMAIN\\IT-Helpdesk"],
  "OutOfOffice": ["DOMAIN\\Exchange-Admins"],
  "MfaReset": ["DOMAIN\\Exchange-Admins"],
  "NamedLocations": ["DOMAIN\\Exchange-Admins"],
  "GroupManagement": ["DOMAIN\\Exchange-Admins"],
  "GroupManagementOnPrem": ["DOMAIN\\Exchange-Admins"],
  "Comms10k": ["DOMAIN\\Exchange-Admins"],
  "ConferenceRooms": ["DOMAIN\\Exchange-Admins"],
  "DhcpAuthorization": ["DOMAIN\\Exchange-Admins"],
  "EventLog": ["DOMAIN\\Exchange-Admins"]
}
  • Fail-closed: when section access is configured, missing or empty section keys deny access to that feature for all users
  • On-prem sections (MailboxPermissionsOnPrem, CalendarPermissionsOnPrem) are always fail-closed even when no SectionAccess configuration exists at all
  • Migration hierarchy: MigrationCreate requires MigrationCheck groups AND MigrationCreate groups; MigrationManage requires MigrationCheck groups AND MigrationManage groups
  • NavMenu links and Home page cards are hidden for unauthorized sections
  • Section access is managed per-module via /module-config/{ModuleId} (collapsible tree in sidebar)

Session Revalidation

An open tab keeps its Windows sign-in for as long as it stays connected, so the app re-checks it against Active Directory: once when the tab connects, then every Authentication:RevalidationInterval (aud-a29b) - and every page request before the page is rendered (aud-a29g).

"Authentication": {
  "RevalidationInterval": "00:05:00",
  "RevalidationEnabled": true
}
  • Default 00:05:00 when the key is absent. The app refuses to start on a value that is not a time span, is below 00:00:30, or is above 01:00:00 - a bare 5 is read as five days.
  • Off-switch: RevalidationEnabled is true when absent. Setting it to false (and restarting) stops every re-check and every revocation. Nothing then re-checks an operator's group membership against the directory - not an open tab, not a reload, not a page request - so an operator removed from an AD group, or disabled, keeps the access their Windows sign-in carries: under Kerberos every reload and page request brings back the same groups until their ticket is renewed or expires, and a tab already open keeps its access until it is closed or the app restarts. Use it only while a wrong revocation is being fixed. It is never silent: at startup the app logs a warning and writes one audit line (action SessionRevalidation, reason Disabled). Anything other than true or false stops startup. The interval is still validated while the switch is off.
  • Static groups must be domain groups while revalidation is on. An entry in Security:AllowedGroups or Security:AdminGroups that is, or translates to, a built-in or well-known group - for example BUILTIN\Administrators, S-1-5-32-544, Everyone - can grant access through Windows but cannot be re-checked against Active Directory, so the app refuses to start and the startup error names the entry. Replace it with a domain group, or set RevalidationEnabled to false. A name that cannot be translated at startup is logged as a warning instead; until it translates, every check fails closed.
  • What is checked: the groups the operator's sign-in carries that some access rule uses (section access, module admins, Security:AllowedGroups, Security:AdminGroups), compared by SID with the account's current group membership (tokenGroups), and whether the account is enabled. Gaining a group never ends a session.
  • The app's own access settings are checked too. Each open session remembers the grants it was confirmed through - a group in a module's section access or module admins, or one of the static groups. If an administrator removes one of them, even from one section while the group keeps another, the session ends at its next check, although nothing changed in Active Directory. A grant added later is remembered from the first check after it appears. If the access settings cannot be read, the check fails closed and the session ends.
  • The session ends when one of those groups is gone, the account is disabled, or the check cannot be completed within 60 seconds (fail closed). The tab stops responding and shows "This session has ended" with a Reload link; reloading signs in again with the access the operator has now. Each ended session writes one audit line (action SessionRevalidation, category Authentication).
  • When a tab connects it does not respond until that first check has answered - normally the time of one directory read, at most 60 seconds. The page is shown meanwhile, but nothing on it runs. If the check ends the session there, nothing in the tab was ever started: it shows the app's error banner ("An unhandled error has occurred") with its Reload link instead of the notice, and the audit line says why.
  • Before a page is shown the request itself is checked the same way (aud-a29g), because a page's first, server-side render runs its initialization before the tab connects. A refused request gets a short "This page was not shown" page (HTTP 403) instead of the page - nothing of the page, its menu or its initialization runs - with the reason and a Reload link, and writes one audit line (trigger PageRequest, path the page asked for - path base and path, never the query string, at most 256 characters; aud-a29i). If the check cannot be completed the page is refused (fail closed); during a directory outage a page load waits for the lookup, at most 60 seconds, and the status frame shows it loading. The directory is not read for every page: an answer read for the same account within RevalidationInterval - by an earlier page request or by one of its open tabs' checks - confirms the request, while a refusal is always decided by a fresh read, so restored access works at the next request. So page loads cost at most one extra directory read per account per interval, made by the first page load after a quiet interval. An operator removed from a group within one interval of their account's last check can therefore still have one page rendered; the tab it opens is ended by its own check on connect, and every later page request is refused. The off-switch turns this check off too.
  • Under Kerberos a reload brings back the same group list until Windows renews its ticket, so an operator who keeps being signed out after their access was restored should sign out of Windows and back in.

Protected Users / Excluded Users

Prevent modifications to specific mailboxes. Configure via Module Config page under Mailbox Permissions → Excluded Users field (comma-separated). Applies to both mailbox and calendar permission operations:

C-Suite, Board of Directors, ceo@example.com
  • Supports distribution groups (auto-expanded to members on first use, cached 30 min)
  • Supports individual users (SMTP, UPN, or SamAccountName)
  • All formats are intelligently matched (case-insensitive)
  • Read only from the MailboxPermissions module config; the legacy Security:ExcludedUsers appsettings fallback was retired 2026-07-28 (it was invisible to the Protected Principals admin UI)
  • Cache is invalidated immediately when module config is saved via UI

Protected principal servicing (authorised bypass)

Named groups can be allowed to act on protected principals within one specific module — built for the executive support team, who service VIP mailboxes as their ordinary job. It is routine authorised work, not an emergency override, so there is no per-operation confirmation and no alert on use; the actions are audited like every other action, and additionally record which group authorised them.

  • Configured per module, on Module Config → Access → Protected principal servicing
  • Offered only for modules whose code consults the servicer service. Today that is Blocked Senders alone; a grant elsewhere would confer nothing, so no editor is shown
  • Separate from the module's own access groups: being able to use a module never implies being able to act on protected principals in it
  • Fail-closed and inert by default. With no group configured — the shipped state — the capability does not exist anywhere
  • Membership in a servicing group for one module confers nothing in any other

Audit Logging

All operations are logged as JSON Lines (.jsonl). Business audit records and diagnostic operation trace records are separate files:

Audit location: <Audit:LogRoot>\ExchangeAdminWeb\exchangeadmin_YYYYMMDD.jsonl

Trace location: <Audit:LogRoot>\ExchangeAdminWeb\exchangeadmin_YYYYMMDD_trace.jsonl

(Audit:LogRoot is a required setting; see the configuration section above. Two instances may share one root: see Shared log root.)

Common audit fields: eventType, operationId, ts, user, ip, action, category, result, ticket

Category-specific fields:

  • MailboxPermission: target, affectedUser, permissionType, autoMapping
  • CalendarPermission: target, affectedUser, accessRight
  • MigrationCheck: target, status, reasons
  • MigrationBatch: batchName, direction, userCount, autoStart, autoComplete
  • MigrationAction: target
  • Lookup: target; Recipient Lookup also resolvedAddress, the primary address the lookup found, when it found one
  • AdminSettings: section, added, removed
  • MfaReset: target, methodsRemoved
  • GroupManagement: target, member, operation, backend
  • Comms10k: target (carries the members requested, the outcome, and the observed final count). There is no removal count: clear-then-fill never reads the prior membership, so the earlier membersAdded / membersRemoved fields documented here were never emitted and could not be
  • ConferenceRooms: target, properties, policyTemplate
  • DhcpAuthorization: target, operation

Null fields are omitted. error appears only on failure.

Operation trace records: Each audit transaction writes structured trace events with the same operationId:

  • operation.start — transaction accepted by the logging pipeline
  • operation.step — significant step such as AuditWritten, plus shared backend steps where available
  • operation.complete — final transaction result and elapsed duration

Trace fields are SIEM-friendly: eventType, operationId, parentOperationId, module, action, stage, backend, command, target, ticket, result, durationMs, details, and errorType. Raw exception messages are not written to trace records. Secret-like detail keys (password, secret, token, apiKey, clientSecret) are masked as ***. Shared backend services also emit standalone operation trace records when no operation scope is active, so vault and EXO connection failures are still visible in the trace stream. Disable trace records with OperationTrace:Enabled = false if needed; audit records remain active.

Rotation:

  • daily (default): New file each day
  • weekly: New file each week
  • monthly: New file each month
  • Audit:MaxFileMB and Audit:MaxFilesPerPeriod cap both audit and trace file growth
  • A file that reaches its size limit is rotated before the next write. A rotation that fails - the folder does not let this instance's account delete, or another program holds the file open without delete sharing - does not lose the write: it goes to the full file, past its limit, and every later write tries the rotation again. The failure is logged as a warning naming the file, the reason, its size and how far past its limit it is. While the rotation keeps failing it is logged again each time the file has grown by another limit, or once an hour, whichever comes first, until a write finds the file rotated or under its limit. The Admin Event Log shows each file this instance cannot rotate, since when, its size and how far over it is, with diagnostics on or off. A retried rotation deletes nothing a completed one would have kept. The same holds for the extended diagnostic log. Nothing bounds the file while its rotation fails: release whatever holds it open, or let this instance's account delete in the log folder.
  • The log folder is created before every write, so a <LogRoot>\ExchangeAdminWeb deleted while the app runs is created again by the next write. A folder that cannot be created is reported the way a lock file the folder refuses is (see Shared log root).

Extended diagnostics: Admin Settings can enable a separate UI-viewable diagnostic stream for troubleshooting Delinea/on-prem connectivity. It defaults to off, writes to exchangeadmin_YYYYMMDD_extended.jsonl, rotates at 10 MB by default, and keeps 5 files per day including the active file. Tune with ExtendedLog:MaxFileMB and ExtendedLog:MaxFilesPerDay if needed. The queue is bounded and drops oldest entries under sustained bursts, so this is best-effort diagnostics, not an audit trail. Every entry it drops is counted - one it could not write, or one the full queue discarded - and with Show diagnostics on, the Admin Event Log shows that count and names any trace or extended log file it could not read. At shutdown it waits for its queue in proportion to the queue's length - about 26 seconds for a full one, and never less than 5 - and counts as dropped whatever it still could not write. Keep Admin Event Log access tightly scoped.

Delinea Secret Server

The app uses one shared Delinea API bootstrap credential, then each module retrieves its own privileged secret by module config. Do not put on-prem Exchange, AD, DHCP, or Graph application credentials in global appsettings.

"Delinea": {
  "SecretServerUrl": "https://secretserver.yourcompany.com/secretserver",
  "CredentialTarget": "Delinea_Client"
}
Key Purpose
SecretServerUrl Base URL of Delinea/Thycotic Secret Server
CredentialTarget Windows Credential Manager entry name storing Delinea API client credentials

Modules that need privileged on-prem or AD access expose their own DelineaSecretId field on that module's config page. Those secrets must contain fields named Username, Password, and Domain. Graph-backed modules use their own Delinea secret containing Tenant ID, Application ID, and Client Secret.

Module secrets must be directly readable by the Delinea API bootstrap credential and must not require checkout, approval, or another interactive Secret Server workflow. The web app cannot complete a checkout prompt during background API calls.

Application Settings

"Application": {
  "PathBase": "/ExchangeAdminWeb",
  "ContactEmail": "exchangeadmin@yourcompany.com",
  "PublicBaseUrl": "https://yourserver/ExchangeAdminWeb"
}
Key Purpose
Application:PathBase IIS sub-application path
Application:ContactEmail Displayed in the UI as the support contact (nav footer)
Application:PublicBaseUrl Absolute external URL of the app, used to build links in notification emails. Optional: when unset or not an absolute URL, emails describe the page in prose instead of linking to it (an email client cannot resolve a relative path, so a partial URL would be a dead link). Set it per environment - dev and prod must not share one value.

Config Store

"ConfigStore": {
  "Path": "D:\\inetpub\\ExchangeAdminWebShared\\config\\exchangeadmin.db"
}
Key Purpose
ConfigStore:Path Absolute local path of the SQLite config database this instance opens. Set the same value on every instance that should share settings (see "Shared config database" under Deploy to IIS). The file must already exist - a missing file, a network (UNC) path or a relative path stops the app at startup with a message naming this key. Leave the key out for a standalone instance: it then uses config\exchangeadmin.db under its own folder, created on first start.

Email Notifications

Admin Notifications (always sent):

  • Every permission operation (success or failure)
  • Includes full details: who, what, when, IP address, ticket number

User Notifications (optional):

  • Set NotifyUsersOnPermissionGrant: true to enable
  • Sent when permissions are granted or removed
  • Friendly email with mailbox details and security notice

Boolean settings: Email:SmtpUseSsl, Email:NotifyUsersOnPermissionGrant and ServiceNow:Enabled take true or false, in any case. Left out or blank, each is false. A value that is neither - yes, 1 - is logged once as a warning naming the setting and the value, and until it is corrected it is read as:

  • Email:SmtpUseSsl: true, TLS when connecting, so a mistyped value never sends mail in clear text. A relay that does not offer TLS on connect then refuses the mail, and each failure is logged.
  • ServiceNow:Enabled: true, so a mistyped value never passes every ticket unchecked. Without a reachable ServiceNow instance, each ticket's check then fails.
  • Email:NotifyUsersOnPermissionGrant: false, as when it is left out, and the Home page then does not say that users are notified. Until a follow-up lands, the Mailbox Permissions and Calendar Permissions pages still fail to render on such a value.

Before, such a value stopped the Home page, and every page that sends mail or checks a ticket.

Bulk Operations

CSV Format - Mailbox Permissions

Add/Remove:

Target,User,FullAccess,SendAs,AutoMapping
helpdesk@company.com,john.doe@company.com,True,True,True
finance@company.com,jane.smith@company.com,True,False,False

See docs/CSV_FORMAT.md for complete documentation.

CSV Format - Calendar Permissions

Set:

Target,User,AccessRight
exec@company.com,assistant@company.com,Editor
manager@company.com,team@company.com,Reviewer

Remove:

Target,User
exec@company.com,old-assistant@company.com

Security Features

Configure email notifications, group-based access control, self-grant prevention, protected user lists, and audit logging in appsettings.json.

Troubleshooting

"401 Unauthorized" when accessing the app

  • Verify Windows Authentication is enabled in IIS for the application
  • Check user is member of an allowed AD group
  • Verify AllowedGroups configuration in appsettings.json
  • Check IIS logs: C:\inetpub\logs\LogFiles\W3SVC1\

"Cannot connect to Exchange Online"

  • Verify certificate is installed and accessible to app pool identity
  • Check certificate subject matches CertificateSubject in appsettings.json
  • Verify app registration has Exchange.ManageAsApp permission
  • Verify service principal has Exchange Administrator role
  • Check application logs: D:\inetpub\ExchangeAdminWeb\logs\app-*.log

"User not found" errors

  • Ensure ExchangeOnlineManagement module is installed
  • Verify service principal permissions in Exchange Online
  • Check user exists in Exchange Online (Get-Mailbox)

Emails not sending

  • Check SMTP host/port is accessible from web server
  • Verify firewall allows outbound SMTP traffic
  • Check application logs for SMTP errors
  • Test SMTP credentials if using authenticated SMTP

Migration eligibility checks show AD group warning

  • Ensure ActiveDirectory PowerShell module is installed: Install-WindowsFeature RSAT-AD-PowerShell
  • Verify IIS app pool identity has AD read permissions
  • If using ApplicationPoolIdentity, ensure server computer account has domain access
  • To use domain service account:
    Import-Module WebAdministration
    Set-ItemProperty "IIS:\AppPools\ExchangeAdminWeb" -Name processModel.identityType -Value 3
    Set-ItemProperty "IIS:\AppPools\ExchangeAdminWeb" -Name processModel.userName -Value "DOMAIN\svc_exchangeadmin"
    Set-ItemProperty "IIS:\AppPools\ExchangeAdminWeb" -Name processModel.password -Value "<secure-password>"
  • Check application logs for detailed AD errors: D:\inetpub\ExchangeAdminWeb\logs\app-*.log
  • Test AD access: Run Get-ADUser -Identity username in PowerShell as the app pool identity

Development

Local Development

  1. Clone repository
  2. Copy appsettings.json.sample to appsettings.json
  3. Configure settings (or use appsettings.Development.json)
  4. Run: dotnet run
  5. Access: http://localhost:5226

Note: Windows Authentication works automatically on domain-joined dev machines using IIS Express or Kestrel with NegotiateDefaults.

Project Structure

ExchangeAdminWeb/
├── Modules/           # Descriptor-based module system (the architecture)
│   ├── ModuleCatalog.cs        # All module descriptors (nav, auth, enablement)
│   └── AdminModuleDescriptor.cs # Descriptor record contract
├── Components/         # Blazor components
│   ├── Pages/         # One Razor page per module (MailboxPermissions, ConferenceRooms, ...)
│   └── Layout/        # Layout components
├── Authorization/     # Policy handlers and authorization wiring
├── Middleware/        # Request pipeline middleware
├── Models/            # Data models and enums
├── Services/          # Per-feature services (one per module + shared infra)
│   ├── AuditService.cs          # JSONL business audit records
│   ├── OperationTraceService.cs # Correlated operation trace records
│   ├── JsonlLogService.cs       # Shared JSONL writer
│   ├── ExoConnectionPool.cs     # Pooled Exchange Online connections
│   ├── EmailService.cs          # SMTP notifications
│   └── PermissionValidator.cs   # Security validation
├── config/            # Runtime config fragments (preserved across deploys)
├── tools/             # Standalone installer + ops scripts (Install-ExchangeAdminWeb.ps1)
├── tests/ps/          # Pester tests for PowerShell
├── docs/              # ProjectConstitution, specs, plans
├── .agents/           # Agent guidance (state.md, decisions.md, repo-map.json)
├── ExchangeAdminWeb.Tests/  # xUnit test project
├── Program.cs         # Application entry point
├── web.config         # IIS configuration
└── deploy.ps1         # ADI-specific deploy helper (generic install: tools/Install-ExchangeAdminWeb.ps1)

Tech Stack

  • ASP.NET Core 10 - Blazor Server with Interactive Server rendering
  • Authentication - Windows Authentication (Negotiate/NTLM)
  • Authorization - Role-based with AD groups
  • Exchange Operations - PowerShell SDK with ExchangeOnlineManagement module
  • Logging - Serilog (file + console)
  • Email - MailKit/MimeKit
  • CSV - CsvHelper

License

For internal administrative use. Configure all environment-specific settings before deployment.

Support

For issues or questions, contact the IT Service Desk or create an issue in the repository.

About

No description, website, or topics provided.

Resources

Stars

2 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages