Docs Reference

Architecture

Two independent auth planes

The design keeps who you are and what the bridge may do in Microsoft's cloud completely separate:

PlaneWhoHow
Operator plane Your technicians using the SPA + API OIDC (JWT bearer) against your own identity provider, or self-registered local accounts with passkeys/TOTP and per-tenant sharing -- no IdP required. See Authentication for the full local-account model. The operator's name from the token is what lands in workflow run history either way.
Microsoft plane The bridge acting in customer tenants On Windows, direct Microsoft tenant sign-in uses the bundled public-client registration and protected per-operator/tenant MSAL caches. Graph access follows the signed-in admin's roles and consent. Optional partner connections use a separate Entra app under the Secure Application Model: SamTokenService exchanges its encrypted, rotating refresh token, scoped by each customer's GDAP roles.

Module layout

web/ (React+Vite+TS)  -->  src/PartnerCenterBridge.Api  -->  Core (contracts / desired state / reconcile / workflows)
                                    |- Graph          (tenant client factory, Intune Win32, users, Identity workflows)
                                    |- Exchange       (EXO PowerShell V3 out-of-process, Mailbox workflows)
                                    |- PartnerCenter  (SamTokenService, PartnerCenterClient)
                                    |- Data           (EF Core + Postgres, encrypted secrets, run history)
ProjectResponsibility
Core Domain entities, reconcile engine, the IWorkflow contract and catalog. No external SDK dependencies - everything else depends on Core, never the reverse.
Data EF Core BridgeDbContext + migrations. Secrets (like the SAM token) are encrypted at rest via ASP.NET Data Protection. Workflow runs and deployments live here.
PartnerCenter SamTokenService (MSAL SAM flow + token rotation), Partner Center REST client (customer list).
Graph Per-tenant Graph clients, the full Intune Win32 upload state machine, hire/offboard user service, and the Identity workflows.
Exchange What Graph can't do: mailbox configuration through the Exchange Online PowerShell V3 module (app-only certificate), run out-of-process, plus the Mailbox workflows.
Api Controllers, operator auth (OIDC, Local, or Dev), the MCP server, DI wiring, deploy + provisioning orchestration, workflow dispatch + run recording.
Desktop Windows WPF/WebView2 shell: starts the API in-process, opens the workbench window, and handles desktop lifecycle and single-instance activation.
Data.Sqlite SQLite provider and migrations for the desktop workbench's shared EF Core model.
web/ React SPA: Home, People, Tenants, Operations (Access Parity, onboard/offboard, deploy, workflows, contracts, templates), Activity, Settings, plus sign-in, registration and account security for Local mode.

Hosting profiles: Server vs. Local

Hosting:Profile is Server (default; today's container deployment, Postgres, nginx in front, configured auth) or Local (the single-user Local Workbench: SQLite, loopback-only Kestrel, embedded SPA, self-registered Local accounts). Local is selected by --local on the command line or baked into the Local Workbench build's assembly metadata at publish time (the container build never gets it); nothing else in the codebase branches on the profile directly -- it only changes what HostingExtensions.AddBridgeHosting configures before the rest of startup runs: the persistence provider, the auth mode default, the Data Protection key ring path, Kestrel's bind address, and (Local only) a generated signing key and a background service that prints a startup banner and opens the browser in CLI/browser mode. The Windows Desktop Workbench (PartnerCenterBridge.Desktop) starts the same API in-process, then loads that loopback origin in WebView2. Its WPF code owns only window and WebView lifecycle; it does not implement a separate data, authentication, or operation layer. See Local Workbench for the full CLI, data layout, and how secrets are protected in that mode.

Persistence: two providers, one model

Persistence:Provider selects Postgres (default) or Sqlite (Local Workbench). Both configure the same BridgeDbContext model from PartnerCenterBridge.Data, but each provider owns its own EF Core migrations project and design-time factory -- PartnerCenterBridge.Data for Postgres, PartnerCenterBridge.Data.Sqlite for SQLite -- so a model change needs a migration generated for both. Migrations for whichever provider is active apply automatically at startup. The SQLite provider sets PRAGMA journal_mode=WAL, synchronous=NORMAL, foreign_keys=ON, and a 5-second busy timeout on every connection to reduce lock contention in desktop use. A sufficiently long competing write can still time out with SQLITE_BUSY.

Operations and evidence model

IPlannedOperation extends the existing IWorkflow contract with an explicit two-step shape: PlanAsync(tenant, inputs) returns an OperationPlan (every item considered, including ineligible ones with the reason, plus preflight findings, warnings and limitations) for the operator to review, and ApplyAsync(tenant, inputs, selectedItemIds) re-plans server-side, applies only the still-eligible selected items, and re-queries to verify, returning an OperationEvidence. Access Parity and offboarding are both implemented this way. Every persisted WorkflowRun -- including runs from the older diagnose/remediate workflows, adapted on read -- carries a nullable Evidence JSON column: preflight, plan, changes, verification, warnings, limitations, failures, and a server-generated ticketNotes narrative, plus a strict outcome (Succeeded, PartiallySucceeded, Failed, NoChangeNeeded, VerificationFailed, or Planned) -- success is only ever claimed for a change a post-apply re-read actually confirmed. GET /api/workflows/runs/{runId}/evidence returns it as JSON, or as a downloadable Markdown ticket note with ?format=markdown. See Workflows for Access Parity and offboarding policy v2 specifically.

The MCP surface

Alongside the REST API, Api exposes a Model Context Protocol server at /mcp (Streamable HTTP) so an LLM client can call the same operations a technician would through the SPA -- ITenantAccessService, the workflow catalog, and the audit trail are all shared with the REST controllers, not a parallel implementation. A mutating tool call is staged as a PendingAction for human approval by default, rather than executing directly; see MCP Server for the full model.

The contract model

A contract is a named desired state shared by a set of tenants: which Win32 apps they should have (at which version), and how new users are provisioned. The reconcile engine compares desired state against what each tenant actually has and produces a plan - deploy, update, retry, or nothing - as a dry run the operator reviews. When a template gets a new version, the plan marks every tenant on the contract that needs the update, and the Deploy wizard pushes it to the tenants you select. An update replaces the package and the fields the template owns (command lines, detection rules) and leaves the tenant's assignments alone - see the Win32 deploy flow in the README.

The workflow engine

Workflows are the other half: not desired state, but known fixes for the problems that land in MSP queues every week. Each implements one interface:

public interface IWorkflow
{
    string Id { get; }           // "mfa-reset"
    string Name { get; }
    string Description { get; }
    string Category { get; }     // "Identity", "Mailbox"
    IReadOnlyList<WorkflowInput> Inputs { get; }   // rendered generically by the UI

    Task<DiagnosisResult> DiagnoseAsync(Tenant tenant, IReadOnlyDictionary<string,string> inputs, CancellationToken ct);
    Task<WorkflowRunResult> RemediateAsync(Tenant tenant, IReadOnlyDictionary<string,string> inputs, CancellationToken ct);
}

Implementations live in the backend project that owns the API they call (Graph or Exchange) and register through DI. The API lists and dispatches them uniformly and records every run; the UI renders each workflow's input schema. Adding a workflow requires zero controller or frontend changes. See Workflows for the catalog.

The tenant audit engine

Tenant audits are the read-only counterpart to workflows: many small checks, each producing structured findings, run together against one tenant. A check implements ITenantAuditCheck: a descriptor (stable id, category, business impact, severity rules, requirements, parameters, limitations) and one RunAsync.

  • Core holds the logic. Checks, the catalog, the engine, the summary and the CSV/JSON/Markdown exporters live in PartnerCenterBridge.Core/TenantAudits. Checks read only through data-provider interfaces (IAuditDirectoryData, IAuditDeviceData, IAuditSecurityData, IAuditMailboxData), so they are unit-tested with in-memory fakes.
  • Providers live with their API. The Graph providers get a GET-only reader, so audits cannot write. The Exchange provider runs one read-only auditMailboxes operation in the existing EXO script. Each dataset is fetched once per tenant per run and cached on the run context, so a full audit reads the user list once.
  • Unavailable is a result, not a crash. Providers turn 401/403 (permission or license) and Intune's "not applicable" into AuditUnavailableException, and Exchange availability comes from the same IExchangeCapability probe the rest of the app uses. The engine records that check as Unavailable with the reason and runs the rest.
  • Runs are records. TenantAuditRun stores summary columns plus the full versioned report as JSON (insert-only, like workflow runs), with an optional batch id so estate-wide runs need no schema change.
  • Discovery by type. Every concrete ITenantAuditCheck in the Core and API assemblies is registered, so adding a check is adding a class. Catalog tests enforce unique ids and complete metadata.

Audit trail

Every diagnose and remediate run is persisted: workflow, tenant, operator identity, inputs, every finding, every step, outcome, duration. Failures can additionally push to a webhook (Teams Adaptive Card or plain JSON - Notifications config section). Show-once secrets like generated temporary passwords are deliberately excluded from both.