Research covers all five v2.3 features: automated app registration, app removal, auto-take ownership, group expansion in HTML reports, and report consolidation toggle. No new NuGet packages required. Build order and phase implications documented. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
439 lines
21 KiB
Markdown
439 lines
21 KiB
Markdown
# Architecture Patterns
|
|
|
|
**Project:** SharePoint Toolbox v2.3 — Tenant Management & Report Enhancements
|
|
**Researched:** 2026-04-09
|
|
**Scope:** Integration of four new features into the existing MVVM/DI architecture
|
|
|
|
---
|
|
|
|
## Existing Architecture (Baseline)
|
|
|
|
The app uses a clean layered architecture. Understanding the layers is prerequisite to placing new features correctly.
|
|
|
|
```
|
|
Core/
|
|
Models/ — Pure data records and enums (no dependencies)
|
|
Helpers/ — Static utility methods
|
|
Messages/ — WeakReferenceMessenger message types
|
|
|
|
Infrastructure/
|
|
Auth/ — MsalClientFactory, GraphClientFactory, SessionManager wiring
|
|
Persistence/ — JSON-backed repositories (ProfileRepository, BrandingRepository, etc.)
|
|
|
|
Services/
|
|
*.cs — Interface + implementation pairs (feature business logic)
|
|
Export/ — HTML and CSV export services per feature area
|
|
|
|
ViewModels/
|
|
FeatureViewModelBase — Abstract base: RunCommand, CancelCommand, progress, WeakReferenceMessenger
|
|
Tabs/ — One ViewModel per tab
|
|
ProfileManagementViewModel — Tenant profile CRUD + logo management
|
|
|
|
Views/
|
|
Tabs/ — XAML views, pure DataBinding
|
|
Dialogs/ — Modal dialogs (ProfileManagementDialog, SitePickerDialog, etc.)
|
|
```
|
|
|
|
### Key Architectural Invariants (must not be broken)
|
|
|
|
1. **SessionManager is the sole holder of ClientContext.** All services receive it via constructor injection; none store it.
|
|
2. **GraphClientFactory.CreateClientAsync(clientId)** produces a GraphServiceClient scoped to a specific tenant's PCA from MsalClientFactory.
|
|
3. **FeatureViewModelBase** provides RunCommand/CancelCommand/progress wiring. All tab VMs extend it.
|
|
4. **WeakReferenceMessenger** carries cross-cutting signals: `TenantSwitchedMessage`, `GlobalSitesChangedMessage`. VMs react in `OnTenantSwitched` / `OnGlobalSitesChanged`.
|
|
5. **BulkOperationRunner.RunAsync** is the shared continue-on-error runner for all multi-item operations.
|
|
6. **HTML export services** are independent per-feature classes under `Services/Export/`; they receive `ReportBranding?` and call `BrandingHtmlHelper.BuildBrandingHeader()`.
|
|
7. **DI registration** is in `App.xaml.cs → RegisterServices`. New services register there.
|
|
|
|
---
|
|
|
|
## Feature 1: App Registration via Graph API
|
|
|
|
### What It Does
|
|
During profile create/edit, attempt to register a new Azure AD app on the target tenant (auto path), or instruct the user through manual steps (guided fallback path).
|
|
|
|
### Graph API Constraint (HIGH confidence)
|
|
Creating an application registration via `POST /applications` requires the caller to hold `Application.ReadWrite.All`. This is an admin-consent-required delegated permission. The existing GraphClientFactory uses `.default` scope, which only acquires permissions already pre-consented on the PCA's app registration. This means:
|
|
|
|
- **The Toolbox's own client app registration (the one the MSP registered to run this tool) must have `Application.ReadWrite.All` delegated and admin-consented** before the auto path can work.
|
|
- If that permission is absent, the Graph call returns 403. The auto path must catch `ODataError` with status 403 and fall through to guided fallback automatically.
|
|
- The guided fallback shows the MSP admin step-by-step instructions for creating the app registration manually in the Azure portal and entering the resulting ClientId.
|
|
|
|
### New Service: `IAppRegistrationService` / `AppRegistrationService`
|
|
|
|
**Location:** `Services/AppRegistrationService.cs` + `Services/IAppRegistrationService.cs`
|
|
|
|
**Responsibilities:**
|
|
- `RegisterAppAsync(GraphServiceClient, string tenantName, CancellationToken)` — Creates the app registration and optional service principal on the target tenant. Returns `AppRegistrationResult` (success + new ClientId, or failure reason).
|
|
- `RemoveAppAsync(GraphServiceClient, string clientId, CancellationToken)` — Deletes the app object by clientId. Also cleans up service principal.
|
|
|
|
**Required Graph calls (inside `AppRegistrationService`):**
|
|
1. `POST /applications` — create the app with required `requiredResourceAccess` (SharePoint delegated scopes)
|
|
2. `POST /servicePrincipals` — create service principal for the new app so it can receive admin consent
|
|
3. `DELETE /applications/{id}` for removal
|
|
4. `DELETE /servicePrincipals/{id}` for service principal cleanup
|
|
|
|
### New Model: `AppRegistrationResult`
|
|
|
|
```csharp
|
|
// Core/Models/AppRegistrationResult.cs
|
|
public record AppRegistrationResult(
|
|
bool Success,
|
|
string? ClientId, // set when Success=true
|
|
string? ApplicationId, // object ID, needed for deletion
|
|
string? FailureReason // set when Success=false
|
|
);
|
|
```
|
|
|
|
### Integration Point: `ProfileManagementViewModel`
|
|
|
|
This is the only ViewModel that changes. `ProfileManagementViewModel` already receives `GraphClientFactory`. Add:
|
|
|
|
- `IAppRegistrationService` injected via constructor
|
|
- `RegisterAppCommand` (IAsyncRelayCommand) — triggers auto-registration, falls back to guided mode on 403
|
|
- `RemoveAppCommand` (IAsyncRelayCommand) — available when `SelectedProfile != null && SelectedProfile.ClientId != null`
|
|
- `IsRegistering` observable bool for busy state
|
|
- `AppRegistrationStatus` observable string for feedback
|
|
|
|
**Data flow:**
|
|
```
|
|
ProfileManagementViewModel.RegisterAppCommand
|
|
→ GraphClientFactory.CreateClientAsync(currentMspClientId) // uses MSP's own clientId
|
|
→ AppRegistrationService.RegisterAppAsync(graphClient, tenantName)
|
|
→ POST /applications, POST /servicePrincipals
|
|
→ returns AppRegistrationResult
|
|
→ on success: populate NewClientId, surface "Copy ClientId" affordance
|
|
→ on 403: set guided fallback mode (show instructions panel)
|
|
→ on other error: set ValidationMessage
|
|
```
|
|
|
|
No new ViewModel is needed. The guided fallback is a conditional UI panel in `ProfileManagementDialog.xaml` controlled by a new `IsGuidedFallbackVisible` bool property on `ProfileManagementViewModel`.
|
|
|
|
### DI Registration (App.xaml.cs)
|
|
|
|
```csharp
|
|
services.AddTransient<IAppRegistrationService, AppRegistrationService>();
|
|
```
|
|
|
|
`ProfileManagementViewModel` registration remains `AddTransient`; the new interface is added to its constructor.
|
|
|
|
---
|
|
|
|
## Feature 2: Auto-Take Ownership on Access Denied
|
|
|
|
### What It Does
|
|
A global toggle in Settings. When enabled, if any SharePoint operation returns an access-denied error, the app automatically adds the authenticated account as a site collection administrator using the tenant admin API, then retries the operation.
|
|
|
|
### Tenant Admin API Mechanism (HIGH confidence from PnP Framework source)
|
|
PnP Framework's `Tenant` class (in `Microsoft.Online.SharePoint.TenantAdministration`) exposes site management. The pattern already used in `SiteListService` (which clones to the `-admin` URL) is exactly the right entry point.
|
|
|
|
To add self as admin:
|
|
```csharp
|
|
var tenant = new Tenant(adminCtx);
|
|
tenant.SetSiteAdmin(siteUrl, loginName, isAdmin: true);
|
|
adminCtx.ExecuteQueryAsync();
|
|
```
|
|
This does NOT require having access to the site — only SharePoint Admin role on the tenant, which the interactive login flow already acquires.
|
|
|
|
### New Setting Property: `AppSettings.AutoTakeOwnership`
|
|
|
|
```csharp
|
|
// Core/Models/AppSettings.cs — ADD property
|
|
public bool AutoTakeOwnership { get; set; } = false;
|
|
```
|
|
|
|
This persists in `settings.json` automatically via `SettingsRepository`.
|
|
|
|
### New Service: `ISiteOwnershipService` / `SiteOwnershipService`
|
|
|
|
**Location:** `Services/SiteOwnershipService.cs` + `Services/ISiteOwnershipService.cs`
|
|
|
|
**Responsibility:** One method:
|
|
```csharp
|
|
Task AddCurrentUserAsSiteAdminAsync(
|
|
TenantProfile profile,
|
|
string siteUrl,
|
|
CancellationToken ct);
|
|
```
|
|
|
|
Uses `SessionManager` to get the authenticated context, clones to the admin URL (same pattern as `SiteListService.DeriveAdminUrl`), constructs `Tenant`, and calls `SetSiteAdmin`.
|
|
|
|
### Integration Point: `ExecuteQueryRetryHelper` or Caller Wrap
|
|
|
|
Rather than modifying `ExecuteQueryRetryHelper` (which is stateless and generic), the retry-with-ownership logic belongs in a per-operation wrapper:
|
|
|
|
1. Calls the operation
|
|
2. Catches `ServerException` with "Access Denied" message
|
|
3. If `AppSettings.AutoTakeOwnership == true`, calls `SiteOwnershipService.AddCurrentUserAsSiteAdminAsync`
|
|
4. Retries exactly once
|
|
5. If retry also fails, propagates the error with a message indicating ownership was attempted
|
|
|
|
**Recommended placement:** A new static helper `SiteAccessRetryHelper` in `Core/Helpers/`, wrapping CSOM executeQuery invocations in `PermissionsService`, `UserAccessAuditService`, and `SiteListService`. Each of these services already has an `IProgress<OperationProgress>` parameter and `CancellationToken` — the helper signature matches naturally.
|
|
|
|
### SettingsViewModel Changes
|
|
|
|
- Add `AutoTakeOwnership` observable bool property
|
|
- Wire to new `SettingsService.SetAutoTakeOwnershipAsync(bool)` method
|
|
- Bind to a checkbox in `SettingsView.xaml`
|
|
|
|
### DI Registration
|
|
|
|
```csharp
|
|
services.AddTransient<ISiteOwnershipService, SiteOwnershipService>();
|
|
```
|
|
|
|
---
|
|
|
|
## Feature 3: Expand Groups in HTML Reports
|
|
|
|
### What It Does
|
|
In the permissions HTML report, SharePoint group entries (where `PrincipalType == "SharePointGroup"`) currently show the group name as a single user pill. When expanded (click on the group), the report shows the individual group members.
|
|
|
|
### Data Model Change
|
|
|
|
`PermissionEntry` is a `record`. Group member data must be captured at scan time because the HTML report is self-contained offline — no live API calls from the browser are possible.
|
|
|
|
**Approach: Resolve at scan time.** During `PermissionsService.ExtractPermissionsAsync`, when `principalType == "SharePointGroup"`, load group members via CSOM and store them in a new optional field on `PermissionEntry`.
|
|
|
|
**Model change — additive, backward-compatible:**
|
|
```csharp
|
|
public record PermissionEntry(
|
|
// ... all existing parameters unchanged ...
|
|
string? GroupMembers = null // semicolon-joined login names; null when not a group or not expanded
|
|
);
|
|
```
|
|
Using a default parameter keeps all existing constructors and test data valid without changes.
|
|
|
|
### New Scan Option
|
|
|
|
```csharp
|
|
// Core/Models/ScanOptions.cs — ADD parameter with default
|
|
public record ScanOptions(
|
|
bool IncludeInherited,
|
|
bool ScanFolders,
|
|
int FolderDepth,
|
|
bool IncludeSubsites,
|
|
bool ExpandGroupMembers = false // NEW — defaults off
|
|
);
|
|
```
|
|
|
|
### Service Changes: `PermissionsService`
|
|
|
|
In `ExtractPermissionsAsync`, when `principalType == "SharePointGroup"` and `options.ExpandGroupMembers == true`:
|
|
```csharp
|
|
ctx.Load(ra.Member, m => m.Users.Include(u => u.LoginName, u => u.Title));
|
|
await ExecuteQueryRetryHelper.ExecuteQueryRetryAsync(ctx, progress, ct);
|
|
var groupMembers = string.Join(";", ra.Member.Users.Select(u => u.LoginName));
|
|
```
|
|
This adds one CSOM round-trip per SharePoint group entry. Performance note: default is `false`.
|
|
|
|
### HTML Export Changes: `HtmlExportService`
|
|
|
|
When rendering user pills for an entry with `GroupMembers != null`, render the group name as an HTML5 `<details>/<summary>` expandable block. The `<details>/<summary>` element requires zero JavaScript, is self-contained, and is universally supported in all modern browsers (Chrome, Edge, Firefox, Safari) since 2016.
|
|
|
|
```html
|
|
<details class="group-expand">
|
|
<summary class="user-pill group-pill">Members Group Name</summary>
|
|
<div class="group-members">
|
|
<span class="user-pill">alice@contoso.com</span>
|
|
<span class="user-pill">bob@contoso.com</span>
|
|
</div>
|
|
</details>
|
|
```
|
|
|
|
`UserAccessHtmlExportService` gets the same treatment in the "Granted Through" column where group access is reported.
|
|
|
|
### ViewModel Changes: `PermissionsViewModel`
|
|
|
|
Add `ExpandGroupMembers` observable bool. Include in `ScanOptions` construction in `RunOperationAsync`. Add checkbox to `PermissionsView.xaml`.
|
|
|
|
---
|
|
|
|
## Feature 4: Report Entry Consolidation Toggle
|
|
|
|
### What It Does
|
|
When a user appears in multiple SharePoint groups that all have access to the same object, they generate multiple `PermissionEntry` rows. The consolidation toggle merges rows for the same (Object, User) combination, joining permission levels and grant sources.
|
|
|
|
### Where Consolidation Lives
|
|
|
|
This is a pure post-processing transformation on the already-collected `IReadOnlyList<PermissionEntry>`. It requires no new service, no CSOM calls, no Graph calls.
|
|
|
|
**Location:** New static helper class in `Core/Helpers/`:
|
|
|
|
```csharp
|
|
// Core/Helpers/PermissionConsolidator.cs
|
|
public static class PermissionConsolidator
|
|
{
|
|
public static IReadOnlyList<PermissionEntry> Consolidate(
|
|
IReadOnlyList<PermissionEntry> entries);
|
|
}
|
|
```
|
|
|
|
**Consolidation key:** `(ObjectType, Title, Url, UserLogin)` — one row per (object, user) pair across all login tokens in a semicolon-delimited `UserLogins` field.
|
|
|
|
**Merge logic:**
|
|
- `PermissionLevels`: union of distinct values (semicolon-joined)
|
|
- `GrantedThrough`: all distinct grant sources joined (e.g., "Direct Permissions; SharePoint Group: X")
|
|
- `HasUniquePermissions`: true if any source entry has it true
|
|
- `Users`, `UserLogins`: from the first occurrence (same person)
|
|
- `PrincipalType`: from the first occurrence
|
|
|
|
`PermissionEntry` is a `record` — `PermissionConsolidator.Consolidate()` produces new instances, never mutates. Consistent with the existing pattern in `PermissionsViewModel` where `Results` is replaced wholesale.
|
|
|
|
**For `SimplifiedPermissionEntry`:** Consolidation applies to `PermissionEntry` first; `SimplifiedPermissionEntry.WrapAll()` then operates on the consolidated list. No changes to `SimplifiedPermissionEntry` needed.
|
|
|
|
### ViewModel Changes: `PermissionsViewModel`
|
|
|
|
Add `ConsolidateEntries` observable bool property. In `RunOperationAsync`, after collecting `allEntries`:
|
|
|
|
```csharp
|
|
if (ConsolidateEntries)
|
|
allEntries = PermissionConsolidator.Consolidate(allEntries).ToList();
|
|
```
|
|
|
|
The export commands (`ExportCsvCommand`, `ExportHtmlCommand`) already consume `Results`, so consolidated data flows into all export formats automatically. No export service changes required for this feature.
|
|
|
|
---
|
|
|
|
## Component Dependency Map
|
|
|
|
```
|
|
NEW COMPONENT DEPENDS ON (existing unless marked new)
|
|
──────────────────────────────────────────────────────────────────────────
|
|
AppRegistrationResult (model) — none
|
|
AppSettings.AutoTakeOwnership AppSettings (existing model)
|
|
ScanOptions.ExpandGroupMembers ScanOptions (existing model)
|
|
PermissionEntry.GroupMembers PermissionEntry (existing record)
|
|
|
|
PermissionConsolidator PermissionEntry (existing)
|
|
|
|
IAppRegistrationService —
|
|
AppRegistrationService GraphServiceClient (existing via GraphClientFactory)
|
|
Microsoft.Graph SDK (existing)
|
|
|
|
ISiteOwnershipService —
|
|
SiteOwnershipService SessionManager (existing)
|
|
TenantProfile (existing)
|
|
Tenant CSOM class (existing via PnP Framework)
|
|
SiteListService.DeriveAdminUrl pattern (existing)
|
|
|
|
SettingsService (modified) AppSettings (existing + new field)
|
|
|
|
PermissionsService (modified) ScanOptions.ExpandGroupMembers (new field)
|
|
ExecuteQueryRetryHelper (existing)
|
|
|
|
HtmlExportService (modified) PermissionEntry.GroupMembers (new field)
|
|
BrandingHtmlHelper (existing)
|
|
|
|
ProfileManagementViewModel (mod) IAppRegistrationService (new)
|
|
PermissionsViewModel (modified) ExpandGroupMembers, ConsolidateEntries, PermissionConsolidator
|
|
SettingsViewModel (modified) AutoTakeOwnership, SettingsService new method
|
|
```
|
|
|
|
---
|
|
|
|
## Suggested Build Order
|
|
|
|
Dependencies flow upward; each step can be tested before the next begins.
|
|
|
|
### Step 1: Model additions
|
|
No external dependencies. All existing tests continue to pass.
|
|
- `AppRegistrationResult` record (new file)
|
|
- `AppSettings.AutoTakeOwnership` bool property (default false)
|
|
- `ScanOptions.ExpandGroupMembers` bool parameter (default false)
|
|
- `PermissionEntry.GroupMembers` optional string parameter (default null)
|
|
|
|
### Step 2: Pure-logic helper
|
|
Fully unit-testable with no services.
|
|
- `PermissionConsolidator` in `Core/Helpers/`
|
|
|
|
### Step 3: New services
|
|
Depend only on existing infrastructure (SessionManager, GraphClientFactory).
|
|
- `ISiteOwnershipService` + `SiteOwnershipService`
|
|
- `IAppRegistrationService` + `AppRegistrationService`
|
|
|
|
### Step 4: SettingsService extension
|
|
Thin method addition, no structural change.
|
|
- `SetAutoTakeOwnershipAsync(bool)` on existing `SettingsService`
|
|
|
|
### Step 5: PermissionsService modification
|
|
- Group member CSOM load in `ExtractPermissionsAsync` (guarded by `ExpandGroupMembers`)
|
|
- Access-denied retry using `SiteOwnershipService` (guarded by `AutoTakeOwnership`)
|
|
|
|
### Step 6: Export service modifications
|
|
- `HtmlExportService.BuildHtml`: `<details>/<summary>` rendering for `GroupMembers`
|
|
- `UserAccessHtmlExportService.BuildHtml`: same for group access entries
|
|
|
|
### Step 7: ViewModel modifications
|
|
- `SettingsViewModel`: `AutoTakeOwnership` property wired to `SettingsService`
|
|
- `PermissionsViewModel`: `ExpandGroupMembers`, `ConsolidateEntries`, updated `ScanOptions`
|
|
- `ProfileManagementViewModel`: `IAppRegistrationService` injection, `RegisterAppCommand`, `RemoveAppCommand`, guided fallback state
|
|
|
|
### Step 8: View/XAML additions
|
|
- `SettingsView.xaml`: AutoTakeOwnership checkbox
|
|
- `PermissionsView.xaml`: ExpandGroupMembers checkbox, ConsolidateEntries checkbox
|
|
- `ProfileManagementDialog.xaml`: Register App button, Remove App button, guided fallback panel
|
|
|
|
### Step 9: DI wiring (App.xaml.cs)
|
|
- Register `IAppRegistrationService`, `ISiteOwnershipService`
|
|
- `ProfileManagementViewModel` constructor change is picked up automatically (AddTransient)
|
|
|
|
---
|
|
|
|
## New vs. Modified Summary
|
|
|
|
| Component | Status | Layer |
|
|
|-----------|--------|-------|
|
|
| `AppRegistrationResult` | NEW | Core/Models |
|
|
| `AppSettings.AutoTakeOwnership` | MODIFIED | Core/Models |
|
|
| `ScanOptions.ExpandGroupMembers` | MODIFIED | Core/Models |
|
|
| `PermissionEntry.GroupMembers` | MODIFIED | Core/Models |
|
|
| `PermissionConsolidator` | NEW | Core/Helpers |
|
|
| `IAppRegistrationService` | NEW | Services |
|
|
| `AppRegistrationService` | NEW | Services |
|
|
| `ISiteOwnershipService` | NEW | Services |
|
|
| `SiteOwnershipService` | NEW | Services |
|
|
| `SettingsService.SetAutoTakeOwnershipAsync` | MODIFIED | Services |
|
|
| `PermissionsService.ExtractPermissionsAsync` | MODIFIED | Services |
|
|
| `HtmlExportService.BuildHtml` | MODIFIED | Services/Export |
|
|
| `UserAccessHtmlExportService.BuildHtml` | MODIFIED | Services/Export |
|
|
| `ProfileManagementViewModel` | MODIFIED | ViewModels |
|
|
| `PermissionsViewModel` | MODIFIED | ViewModels/Tabs |
|
|
| `SettingsViewModel` | MODIFIED | ViewModels/Tabs |
|
|
| `ProfileManagementDialog.xaml` | MODIFIED | Views/Dialogs |
|
|
| `PermissionsView.xaml` | MODIFIED | Views/Tabs |
|
|
| `SettingsView.xaml` | MODIFIED | Views/Tabs |
|
|
| `App.xaml.cs RegisterServices` | MODIFIED | Root |
|
|
|
|
**No new tabs. No new XAML files. No new dialog windows required.** All four features extend existing surfaces.
|
|
|
|
---
|
|
|
|
## Critical Integration Notes
|
|
|
|
### App Registration: Permission Prerequisite
|
|
The auto-registration path requires `Application.ReadWrite.All` to be granted and admin-consented on the MSP's own client app registration. The tool cannot bootstrap this permission itself. The guided fallback path is the safe default — auto path is an enhancement for pre-prepared deployments. Catch `ODataError` with `ResponseStatusCode == 403` to trigger the fallback automatically.
|
|
|
|
### Auto-Ownership: Retry Once, Not Infinitely
|
|
Retry exactly once per site. If the second attempt fails (account lacks tenant admin rights), propagate the original error with a clear message indicating that ownership take-over was attempted. Log both attempts via `ILogger`.
|
|
|
|
### Group Expansion: Scan Performance Impact
|
|
Loading group members adds one CSOM round-trip per unique SharePoint group encountered. The `ExpandGroupMembers` toggle must default to `false` and be labeled clearly in the UI (e.g., "Expand group members in report (slower scan)"). On tenants with many groups across many sites, this could multiply scan time significantly.
|
|
|
|
### Consolidation: Records Are Immutable
|
|
`PermissionEntry` is a `record`. `PermissionConsolidator.Consolidate()` produces new record instances — no mutation. Consistent with how `Results` is already replaced wholesale in `PermissionsViewModel`.
|
|
|
|
### HTML `<details>/<summary>` Compatibility
|
|
Self-contained HTML reports target any modern browser. `<details>/<summary>` is fully supported without JavaScript since 2016 across all major browsers. This is the correct choice over adding onclick JS toggle logic.
|
|
|
|
### No Breaking Changes to Existing Tests
|
|
All model changes use optional parameters with defaults. Existing test data and constructors remain valid. `PermissionConsolidator` and `SiteOwnershipService` are new testable units that can use the existing `InternalsVisibleTo` pattern for test access.
|
|
|
|
---
|
|
|
|
## Sources
|
|
|
|
- Microsoft Graph permissions reference: https://learn.microsoft.com/en-us/graph/permissions-reference
|
|
- Graph API grant permissions programmatically: https://learn.microsoft.com/en-us/graph/permissions-grant-via-msgraph
|
|
- PnP Core SDK site security (SetSiteCollectionAdmins): https://pnp.github.io/pnpcore/using-the-sdk/admin-sharepoint-security.html
|
|
- PnP Framework TenantExtensions source: https://github.com/pnp/PnP-Sites-Core/blob/master/Core/OfficeDevPnP.Core/Extensions/TenantExtensions.cs
|