Library & File Plan Manager
SharePoint permissions map (file-plan style, grouped by team) + an admin screen that creates document libraries correctly and registers them automatically, so nothing gets forgotten again.
- SharePoint lists — create
LibraryFilePlanandPermissionIndexvia the CSV templates (fastest — creates every column for you), or manually per the schema tables. Column isPrettyName, notDisplayName— SharePoint silently drops writes to that name on this list. - Flow field fix — in Create Library (App), edit
Create_FilePlan_Row's Body so it writesPrettyNameinstead ofDisplayName. One line, no re-import. - Flow field fix (missing libraries) — in Rebuild Permission Index, add
&$top=5000toGet_Unique_Perm_Libraries's Uri so it stops capping out around 20-30 libraries. One line, no re-import. - Import all four flows — Create Library (App), Backfill File Plan, Rebuild Permission Index, Manage Library Permissions. Test Create Library (App) manually before touching Power Apps.
- Data connections + App.OnStart — add both SharePoint lists and the flow as data sources, paste App.OnStart in full.
- All six screens — delete and paste fresh (screens have had controls added since first built, so a partial paste over the top won't apply cleanly).
- Navigation bar — select each screen in the Tree view, paste that screen's snippet as an add-on (not a full replacement). Nudge existing top controls down afterward to clear the bar.
1. SharePoint List: LibraryFilePlan
Location: your main site — https://modgovuk.sharepoint.com/teams/1598/.
LibraryFilePlan_list_template.csv — download this, then Site Contents → New → List → From CSV (or Excel) → upload it. SharePoint reads the embedded schema and creates every column with the correct type and Team's choices already filled in — no manual column-by-column setup. Delete the single placeholder row afterward, and rename the list to exactly LibraryFilePlan if it doesn't pick that up from the filename.
| Column name (exact, no spaces) | Type | Notes |
|---|---|---|
| Title | Single line of text | Built-in — the flow writes the library's pretty display name into it automatically |
| LibraryUrl | Single line of text | The real join key — a library's server-relative URL, survives display-name renames |
| Team | Choice | Top-level file-plan grouping. Fixed set — type in your known team names as the choices, no "Allow fill-in values" |
| SubTeam | Choice | Optional nested grouping. Tick "Allow fill-in values" — same pattern as the CAT column, so new sub-teams can be added on the fly |
| PrettyName | Single line of text | Optional override; falls back to live library Title if blank. Not "DisplayName" — SharePoint silently drops writes to a column with that exact name on this list (collides with a reserved sub-property on the two Person-or-Group columns, Created By/Modified By) |
| SiteUrl | Single line of text | Which of your sites the library is on |
| SortOrder | Number | Optional — controls order within a group |
| IsLimited | Yes/No | True if the library was created as Limited — lets you set conditional formatting on the list view to colour-code Limited rows |
_x0020_ internally, and the flows' requests need the plain names to match exactly.SharePoint List: PermissionIndex
Location: your main site, same as LibraryFilePlan.
PermissionIndex_list_template.csv — same idea: Site Contents → New → List → From CSV → upload, delete the placeholder row, rename to exactly PermissionIndex if needed.
| Column name (exact, no spaces) | Type | Notes |
|---|---|---|
| Title | Single line of text | Built-in — set to the same value as GroupName, so the list is readable at a glance |
| LibraryUrl | Single line of text | Which library this permission row belongs to — joins back to LibraryFilePlan.LibraryUrl |
| LibraryTitle | Single line of text | Display name, so search results don't need a lookup back to LibraryFilePlan |
| GroupName | Single line of text | The actual SG_/DG_ group name (or username) this row represents — what the group-search screen filters on |
| PrincipalType | Single line of text | "SharePoint Group", "Security Group", or "User" |
| PermissionLevel | Single line of text | e.g. "Full Control", "Contribute", "Read" |
| NestedUnderGroup | Single line of text | Blank for a group/user granted directly on the library. If this row is a DG_ AD group found nested inside an SG_ group, this holds that SG_ group's name |
LibraryFilePlan.2. Flow Field Fix: Create_FilePlan_Row
In Power Automate, open Create Library (App) → Edit → click Create_FilePlan_Row to expand it → click into its Body box → select all, delete → paste this in as the replacement → Save. Existing action, existing field, no re-import needed.
{
"__metadata": { "type": "SP.Data.LibraryFilePlanListItem" },
"Title": "@{triggerBody()?['DisplayName']}",
"LibraryUrl": "@{body('Get_New_Library_Url')?['d']?['RootFolder']?['ServerRelativeUrl']}",
"Team": "@{triggerBody()?['Team']}",
"SubTeam": "@{triggerBody()?['SubTeam']}",
"PrettyName": "@{triggerBody()?['DisplayName']}",
"SiteUrl": "@{triggerBody()?['SiteUrl']}",
"IsLimited": @{if(equals(triggerBody()?['IsLimited'], true), 'true', 'false')}
}
DisplayName → PrettyName). The value is still populated from triggerBody()?['DisplayName'] — that's the Power Apps trigger's own parameter name, unrelated to the SharePoint column, and it hasn't changed.2b. Flow Field Fix: Get_Unique_Perm_Libraries (missing libraries bug)
Cause: this action asks SharePoint for every library with unique permissions, but never told it how many to return. SharePoint's _api/web/lists endpoint defaults to a page size of around 100 items per response and doesn't automatically continue to the next page — with enough lists on the site (including hidden/system ones counted before the filter narrows them down), the real Limited libraries near the end of that list never got reached, so the index silently stopped at whatever fit in the first page.
In Power Automate, open Rebuild Permission Index → Edit → click Get_Unique_Perm_Libraries to expand it → click into its Uri box → add &$top=5000 to the end → Save. Existing action, one field, no re-import needed.
_api/web/lists?$filter=BaseTemplate eq 101 and Hidden eq false and HasUniqueRoleAssignments eq true&$select=Title,Id,RootFolder/ServerRelativeUrl&$expand=RootFolder&$top=5000
3. Download & Import Flows
CreateLibraryApp_flow_import.zip — creates a library, registers it into LibraryFilePlan. The core flow everything else builds on.
BackfillFilePlan_flow_import.zip — one-time scan of every existing library on the site, registers anything not already tracked.
RebuildPermissionIndex_flow_import.zip — scans libraries with broken permissions and builds a fast, searchable index of their groups.
ManageLibraryPermissions_flow_import.zip — lists, adds, or removes a group directly on a chosen library, called live from the Admin screen.
DeleteLibrariesApp_flow_import.zip — checks each library for files, deletes the ones that are safe to delete (or all of them if overridden), and cleans up their LibraryFilePlan/PermissionIndex rows. Called live from the Manage Libraries screen.
All five: Power Automate → My flows → Import → Import Package (Legacy).
What Create Library (App) does
Triggered from Power Apps (or manually, for testing). Per library:
- Creates the library (
BaseTemplate 101) with the correct sensitivity label for Normal vs Limited - Adds a CAT choice column (fill-in-your-own enabled) and a Status choice column (Draft / Under Review / Reviewed / Published / Final / Superseded / Expired)
- Creates WiP view (items without a retention label) and Records view (items with one), both grouped by CAT
- Renames the library's Title to the pretty display name
- If Limited: breaks role inheritance completely, grants SG_Cadets-Admin-Ltd_MEM and SG_IHUB Full Control (fixed — nothing to fill in on the form, since a separate team owns creating/adding any other group later), then removes the flow-runner's own account (SharePoint auto-adds whoever breaks inheritance as a lockout safety net; the flow strips that back out)
- Registers the new library into
LibraryFilePlan
Site URL defaults to the main site if left blank — every action falls back to it via coalesce().
Testing Create Library (App)
Power Automate → My flows → Create Library (App) → Test → Manually.
| Field | First test — plain | Second test — Limited |
|---|---|---|
| Site URL | leave blank | leave blank |
| Technical Name | ZZTest_Library | ZZTest_LTD_Library |
| Display Name | ZZ Test Library | ZZ Test LTD Library |
| Is Limited | false / off | true / on |
| Team | Test | Test |
| SubTeam, Categories | leave blank | leave blank |
First test: check the library exists with CAT + Status columns and WiP/Records views, its Title shows the pretty name, and a new row appeared in LibraryFilePlan with PrettyName populated.
SG_Cadets-Admin-Ltd_MEM and SG_IHUB are there — no inherited groups, no extra groups, no direct user entry for whoever ran the flow.LibraryFilePlan once both tests pass.Backfill Existing Libraries
LibraryFilePlan only tracks libraries created after this system existed. This flow seeds it from what already exists: scans every real document library (skipping SharePoint's own system libraries), and for anything not already tracked, adds a row with LibraryUrl, PrettyName, Title, SiteUrl filled in automatically, and IsLimited guessed from whether the library already has broken permission inheritance. Team, SubTeam are left blank — fill those in by hand afterward via SharePoint's grid/quick-edit view. Safe to run more than once — only adds what's missing.
Rebuild Permission Index
Power Apps can't read SharePoint permissions directly, and a search screen can't afford to live-query every library's permissions on every keystroke. This flow scans permissions once and writes a flat, fast-searchable copy into PermissionIndex; the group-search screen just filters that list.
Only scans libraries with broken/unique permissions (mostly your Limited libraries) — Normal libraries all inherit the exact same site-wide permissions, so indexing each individually would be pure noise. If a group is a native SharePoint Group, it also looks one level inside that group's own membership — this is how a nested DG_... AD group shows up in the index too, without needing Microsoft Graph.
PermissionIndex and rebuilds it from scratch every run — intentional, avoids stale/duplicate rows.Manage Library Permissions
This is the write counterpart to Rebuild Permission Index / Group Search, which are both read-only snapshots. This flow is called live, on demand, from the Admin screen — one flow, one trigger, an Action field ("List" / "Add" / "Remove") decides what it does:
- List — live role assignments for one library, straight from SharePoint (not the cached index), so it's always current
- Add — grants a SharePoint group (
SG_...) a permission level on that library, reusing the exact sameGet_Group_Id → Get_Role_Def_Id → Add_Role_Assignmentpattern already proven in Create Library (App) - Remove — removes a group's entire access to that library in one call (
roleassignments/getbyprincipalid(id)with an HTTP DELETE), rather than needing to know which specific permission level to unwind
SG_...) directly on a library — matches how every Limited library in this system is actually set up: a DG_... AD group is never granted directly, it's always nested inside an SG_... wrapper group. If a genuinely new AD group needs adding, the other team creates it and its SG_ wrapper first, same as always — this screen doesn't change that workflow, just removes the "someone has to do it by hand in native SharePoint" step.Delete Libraries (App)
Called from the Manage Libraries screen with a JSON array of library URLs, an Action (currently always "Delete" from the app — "Check" is accepted too but every branch other than Delete just reports counts and changes nothing, so it's there if you ever want a preview-only call), and a Force flag.
Per library: gets its live ItemCount via _api/web/GetList(url) (server-relative URL, not title — the one join key in this whole system guaranteed not to drift if someone renames a library directly in SharePoint). If Force is false and ItemCount > 0, that library is skipped and reported back, untouched. Otherwise it deletes the document library itself, its LibraryFilePlan row, and every PermissionIndex row that pointed at it.
4. Building the Power App
Create a new blank canvas app in Power Apps Studio.
Data connections
Data panel → Add data → add these before pasting any screen YAML:
'LibraryFilePlan' (SharePoint → your main site → LibraryFilePlan) 'PermissionIndex' (SharePoint → your main site → PermissionIndex) 'Create Library (App)' (search Power Automate flows in Add data) 'Rebuild Permission Index' (search Power Automate flows in Add data) 'Manage Library Permissions' (search Power Automate flows in Add data) 'Delete Libraries (App)' (search Power Automate flows in Add data)
App.OnStart
Select the App object (top of the tree, not any screen) → OnStart property → paste this in full:
Set(varTenantHost, "https://modgovuk.sharepoint.com");
ClearCollect(colFilePlan, 'LibraryFilePlan');
ClearCollect(
colFilePlanFiltered,
AddColumns(colFilePlan, TeamName, Team.Value, SubTeamName, Coalesce(SubTeam.Value, ""), EffectiveName, If(IsBlank(PrettyName) || PrettyName = "", Title, PrettyName))
);
ClearCollect(
colTeamGroups,
Sort(
AddColumns(
GroupBy(colFilePlanFiltered, TeamName, GroupedItems),
ItemCount, CountRows(GroupedItems)
),
TeamName,
SortOrder.Ascending
)
);
Clear(colFlatSource);
Collect(colFlatSource, AddColumns(colTeamGroups, RowType, "Team", SortKey, TeamName, RankInGroup, 0));
Collect(colFlatSource, AddColumns(colFilePlanFiltered, RowType, "Library", SortKey, TeamName, RankInGroup, 1));
ClearCollect(colExpandedKeys, Filter(Table({TeamName: "seed"}), false));
Set(varIsAdmin, Lower(User().Email) in ["allan.hamilton775@mod.gov.uk", "dan.smith391@mod.gov.uk"]);
ClearCollect(colQueue, Filter(Table({TechnicalName: "seed", DisplayName: "", Team: "", SubTeam: "", IsLimited: false, Categories: ""}), false));
Set(varQueueRunning, false);
Set(varQueueTotal, 0);
Set(varQueueDone, 0);
Set(varIndexRunError, false);
ClearCollect(colPermissionIndex, 'PermissionIndex');
ClearCollect(colPermissionFiltered, colPermissionIndex);
ClearCollect(colLibraryGroups, Sort(AddColumns(GroupBy(colPermissionFiltered, LibraryUrl, LibraryTitle, GroupedItems), ItemCount, CountRows(GroupedItems)), LibraryTitle, SortOrder.Ascending));
Clear(colPermFlatSource);
Collect(colPermFlatSource, AddColumns(colLibraryGroups, RowType, "Library", SortKey, LibraryUrl, RankInGroup, 0, GroupSortKey, "", DirectGroupCount, With({_lu: LibraryUrl}, CountRows(Filter(colPermissionFiltered, LibraryUrl = _lu && (IsBlank(NestedUnderGroup) || NestedUnderGroup = ""))))));
Collect(colPermFlatSource, AddColumns(Filter(colPermissionFiltered, IsBlank(NestedUnderGroup) || NestedUnderGroup = ""), RowType, "Group", SortKey, LibraryUrl, RankInGroup, 1, GroupSortKey, GroupName, MemberCount, With({_gn: GroupName, _lu: LibraryUrl}, CountRows(Filter(colPermissionFiltered, NestedUnderGroup = _gn && LibraryUrl = _lu)))));
Collect(colPermFlatSource, AddColumns(Filter(colPermissionFiltered, !IsBlank(NestedUnderGroup) && NestedUnderGroup <> ""), RowType, "Member", SortKey, LibraryUrl, RankInGroup, 2, GroupSortKey, NestedUnderGroup));
ClearCollect(colExpandedLibs, Filter(Table({LibraryUrl: "seed"}), false));
ClearCollect(colExpandedGroups, Filter(Table({GroupKey: "seed"}), false));
ClearCollect(colSelectedLibraries, Filter(Table({LibraryUrl: "seed"}), false));
ClearCollect(colManageFiltered, colFilePlanFiltered);
ClearCollect(colEditQueue, Filter(colFilePlanFiltered, false));
ClearCollect(colDeleteSkipped, Filter(Table({LibraryUrl: "seed", Title: "seed", ItemCount: 0, Deleted: false}), false));
Set(varDeleteBusy, false);
Set(varDeleteStatus, "");
Set(varConfirmOverrideVisible, false);
Set(varDeletedCount, 0);
Set(varEditSaving, false);
Set(varEditSaveStatus, "")
EffectiveName is PrettyName when filled in, otherwise falls back to Title — self-heals every library missing a pretty name, no data migration needed. varIsAdmin gates the Admin screen — edit the email list directly in this line if who's authorized ever changes. colPermissionIndex/colPermissionFiltered feed the Group Search screen — make sure you've run Rebuild Permission Index at least once first, or this loads empty.5. Screens
For each: delete the existing screen if you already have one pasted, New screen (blank) → open the link → Copy YAML → paste. Rename the screen to match the name shown.
Read-only browse screen — search box, grouped-by-team collapsible list, Open and Copy link buttons per library, LTD badge on Limited libraries.
Restricted to the admin email list. Fill in a library's details, Add to Queue as many times as needed, then Run Queue creates them all sequentially, with a progress overlay. Also has a Rebuild Permission Index button with a staleness indicator.
Search by SG_/DG_ group name or library name. Results grouped by library in a collapsible accordion, same pattern as scrFilePlan. Reads the precomputed PermissionIndex list — instant, no live SharePoint crawl.
Restricted to admins. Pick a Limited library, see its live current permissions, add or remove a SharePoint group directly — no more doing this by hand in native SharePoint.
Restricted to admins. Tick libraries, bulk-delete them (empty ones go straight away, non-empty ones get an override prompt) or send the selection to the edit cart.
Restricted to admins. The edit cart — one stacked form per library sent over from Manage Libraries, change whatever's needed on each, Save All applies every change in one pass.
.Run() call has no true non-blocking mode (a slow response is caught and shown as a friendly message rather than a raw error, but the flow itself always runs to completion on its own regardless). The status line above it shows how long ago the index was last rebuilt, and flags how many Limited libraries have been created since — both computed live from data already loaded, no extra flow or list needed.RowType and a second expand-tracking collection (colExpandedGroups) rather than nested galleries. v3.1 fixed a real sort-order bug where member rows sorted after every group in a library instead of clustering under their own parent group — the sort's column priority had rank ahead of the group-clustering key instead of after it. v3.2 moved the counts directly into each chevron instead of a separate pill: 4 > on a Library row (how many groups directly granted), (2) > on a Group row (how many nested members) — visible whether the row is expanded or collapsed.ThisItem.Title (the library's real current SharePoint Title) when calling the flow, not PrettyName/EffectiveName — SharePoint's getbytitle() needs the actual current Title, and Create Library (App) renames every library's Title to the pretty name as its last real step, so by the time a library shows up here, its Title already is the pretty name.Force: false on the first call — any empty library in your selection is deleted immediately, any non-empty one is skipped and reported back in one round trip, no separate "check" step needed first. If anything was skipped, an override panel lists exactly which ones and their item counts; "Delete Anyway" re-sends only those, this time with Force: true. First-time use of two controls new to this app: CheckBox@0.0.30 (per-row tick, read/write via its Checked property) and ModernButton's BasePaletteColor for the red delete buttons — Modern buttons don't have a direct Fill property, unlike Classic buttons used everywhere else in this app; light seed colours get overridden by Fluent 2's contrast rules, but the dark red used here renders correctly.colEditQueue), shown here as one stacked card per library, all editable at once. Every field edit patches straight into that local collection as you type (no flow, no round trip); "Save All" is the only point anything actually writes to SharePoint, via a single ForAll + Patch. Library URL is deliberately not editable here — it's the join key this entire app relies on to survive renames, so changing it needs to stay a rare, manual, direct-in-SharePoint action, not a button.Issues
Found a problem? Report it here.