VPR-59 [4/4] CMS: delegated block editing via per-block edit permissions#255
VPR-59 [4/4] CMS: delegated block editing via per-block edit permissions#255rlorenzo wants to merge 3 commits into
Conversation
There was a problem hiding this comment.
Pull request overview
Adds “delegated block editing” to the CMS migration stack by introducing per-block edit-permission lists (separate from view permissions) and a content-scoped editing/file workflow that allows non-managers to edit only content + attachments for blocks they’re delegated.
Changes:
- Introduces
ContentBlockToEditPermission(entity + EF mapping) and edit-authorization inCmsContentBlockService(CanEditAsync, editable-block listing, attachable-file search, rollback-delete eligibility). - Expands the CMS content API with content-scoped endpoints for delegated editors (editable blocks list, attachable-files search, block-scoped file check-name/upload/rollback-delete) and adds attachment deltas to the content-only PATCH.
- Updates the Vue CMS editor and hub to support “capability mode” (manager vs delegated UI) and adds/updates frontend + backend tests for delegated scenarios.
Reviewed changes
Copilot reviewed 20 out of 20 changed files in this pull request and generated 3 comments.
Show a summary per file
| File | Description |
|---|---|
| web/Models/VIPER/ContentBlockToEditPermission.cs | New EF entity representing a block → edit-permission row. |
| web/Models/VIPER/ContentBlock.cs | Adds navigation collection for edit-permission rows. |
| web/Classes/SQLContext/VIPERContext.cs | Registers DbSet + model configuration for the new table. |
| web/Areas/CMS/Services/CmsContentBlockService.cs | Core authorization + delegated editing behaviors (content-only update with attachment deltas, editable listing, attachable-file search, rollback-delete checks). |
| web/Areas/CMS/Models/DTOs/ContentBlockDto.cs | Adds EditPermissions to block DTO; adds minimal CmsAttachableFileDto. |
| web/Areas/CMS/Models/CmsContentBlockMapper.cs | Maps edit-permission list into DTO. |
| web/Areas/CMS/Models/CMSBlockAddEdit.cs | Adds EditPermissions to create/update request model. |
| web/Areas/CMS/Controllers/CMSContentController.cs | Widens selected endpoints to SVMSecure + CanEditAsync gate; adds content-scoped endpoints for delegated file attach/upload/rollback-delete. |
| VueApp/src/CMS/types/index.ts | Adds editPermissions, plus types for editable blocks and attachable file search results. |
| VueApp/src/CMS/router/routes.ts | Broadens content-block edit route permission to SVMSecure (server is source of truth). |
| VueApp/src/CMS/pages/ContentBlockEdit.vue | Implements manager vs delegated capability UI; uses PATCH for delegated saves; switches attach/search and rollback paths to content-scoped APIs. |
| VueApp/src/CMS/pages/CmsHome.vue | Adds “Blocks you can edit” hub card (delegated editors). |
| VueApp/src/CMS/components/PermissionSelector.vue | Makes hint configurable to reuse for edit-permissions selector. |
| VueApp/src/CMS/components/InlineFileUpload.vue | Adds block-scoped upload mode (check-name/upload/rollback-delete) for delegated editors. |
| VueApp/src/CMS/tests/inline-file-upload.test.ts | Tests block-scoped uploader behavior (no folder/permission fields; no use-existing; POST overwrite). |
| VueApp/src/CMS/tests/content-block-edit-save.test.ts | Updates save payload expectations; tests edit-access selector; updates rollback URL expectations; updates attachable-files behavior. |
| VueApp/src/CMS/tests/content-block-edit-delegated.test.ts | New delegated-mode editor tests (read-only settings, PATCH save, conflict handling). |
| VueApp/src/CMS/tests/cms-home.test.ts | New tests for delegated editable-blocks hub card behavior. |
| test/CMS/CMSContentControllerTests.cs | Extends controller tests for CanEditAsync gating + content-scoped file ops. |
| test/CMS/CmsContentBlockServiceTests.cs | Adds service-level tests for delegated authorization, attachment deltas, editable listing, attachable search, and rollback-delete eligibility. |
📝 WalkthroughWalkthroughDelegated CMS editors can discover assigned blocks, edit content without manager settings access, replace attachments, and use block-scoped file APIs. Backend permission storage, authorization, controller endpoints, frontend flows, and tests were updated accordingly. ChangesDelegated CMS editing
Estimated code review effort: 4 (Complex) | ~60 minutes Suggested reviewers: 🚥 Pre-merge checks | ✅ 4 | ❌ 1❌ Failed checks (1 warning)
✅ Passed checks (4 passed)
✨ Finishing Touches📝 Generate docstrings
🧪 Generate unit tests (beta)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
f1ca704 to
c7bd327
Compare
26628ea to
45255eb
Compare
c7bd327 to
bdacb1a
Compare
f18972b to
889a28f
Compare
| public async Task<bool> RollbackDeleteFileAsync(Guid fileGuid, int contentBlockId, CancellationToken ct = default) | ||
| { | ||
| var entity = await LoadFileAsync(fileGuid, tracking: true, ct); | ||
| if (entity == null || entity.DeletedOn != null) | ||
| { | ||
| return false; | ||
| } | ||
| // Recheck immediately before deleting, under Serializable isolation, so the check and | ||
| // the delete commit as one atomic unit: SQL Server range-locks what this transaction | ||
| // read, so a concurrent attach to a different block either commits first (and this | ||
| // check then sees it) or blocks until this transaction finishes — it can no longer | ||
| // slip in between the check and the save and have its attachment deleted from under it. | ||
| return await ExecuteInTransactionAsync(async token => | ||
| { | ||
| bool attachedElsewhere = await _context.ContentBlockToFiles | ||
| .AnyAsync(cbf => cbf.FileGuid == fileGuid && cbf.ContentBlockId != contentBlockId, token); | ||
| if (attachedElsewhere) | ||
| { | ||
| return false; | ||
| } | ||
| entity.DeletedOn = DateTime.Now; | ||
| entity.ModifiedOn = DateTime.Now; | ||
| entity.ModifiedBy = CurrentLoginId(); | ||
| _audit.Audit(entity, CmsFileAuditActions.DeleteFile, "File Marked for Deletion"); | ||
| await _context.SaveChangesAsync(token); | ||
| return true; | ||
| }, IsolationLevel.Serializable, ct); | ||
| } |
7d080e4 to
aae003d
Compare
889a28f to
870f619
Compare
aae003d to
0c04207
Compare
870f619 to
80050b5
Compare
| bool attachedElsewhere = await _context.ContentBlockToFiles | ||
| .AnyAsync(cbf => cbf.FileGuid == fileGuid && cbf.ContentBlockId != contentBlockId, token); | ||
| if (attachedElsewhere) | ||
| { | ||
| return false; | ||
| } |
| // Never trash a file another block still attaches — that would break the other block. | ||
| bool attachedElsewhere = await _context.ContentBlockToFiles | ||
| .AnyAsync(cbf => cbf.FileGuid == fileGuid && cbf.ContentBlockId != contentBlockId, ct); | ||
| return !attachedElsewhere; |
Admins attach RAPS permissions to a content block as its EDIT permissions; holders of any of them can edit that block without SVMSecure.CMS.ManageContentBlocks. - New ContentBlockToEditPermission entity/table (schema applied manually per environment; DDL in the PR description). An empty edit list means manager-only: delegation is explicit, deliberately not the view list "empty means all SVMSecure" rule - CanEditAsync (manager OR edit-permission intersection, fail-closed, single-resolve HashSet) gates block load, history, diff, and the content-only PATCH, which now also carries attachment deltas; the delegated DTO has no settings fields, so field whitelisting is server-side by construction - Content-scoped file endpoints (attachable search capped at 25 with a minimal DTO, per-block check-name/upload, rollback-only delete constrained to uploader+folder+not-attached-elsewhere) give the editor one code path for managers and delegates alike - Editor renders a capability mode: read-only settings summary for delegates, full editor plus a new "Edit access" selector for managers; the hub gains a "Blocks you can edit" card; the editor route relies on server enforcement - Delegates can view and load version history (restoring is a content save); soft-deleted blocks are not delegate-editable; the anonymous fn endpoint discloses nothing new - 48 new tests (authorization matrix, field scoping, attachment deltas, rollback constraints, capability-mode rendering, hub card)
…of the default hint - PermissionSelector's hint prop now accepts null to suppress the default hint text entirely (no reserved bottom space), instead of every consumer being stuck with "Users need any one of the selected permissions" - Left nav edit hoists that copy to a single explanatory line above the item list instead of repeating it under every item's selector
0c04207 to
faa3eb1
Compare
80050b5 to
192b05e
Compare
| // snapshot taken before the transaction and the save at the end of it. | ||
| return await ExecuteInTransactionAsync(async token => | ||
| { | ||
| var entity = await LoadFileAsync(fileGuid, tracking: true, token); |
| var file = await _context.Files | ||
| .AsNoTracking() | ||
| .Where(f => f.FileGuid == fileGuid) | ||
| .Select(f => new { f.ModifiedBy, f.Folder }) | ||
| .FirstOrDefaultAsync(ct); |
| .ToListAsync(ct); | ||
| if (files.Count != fileGuids.Count) | ||
| { | ||
| throw new ArgumentException("One or more files cannot be attached because they are deleted."); |
| // deleted out from under it. A false result means it became shared/gone in that window. | ||
| return await _fileService.RollbackDeleteFileAsync(fileGuid, contentBlockId, ct) | ||
| ? NoContent() | ||
| : Conflict("The file could not be rolled back; it may now be attached to another block."); |
192b05e to
cb5b979
Compare
| // Full manage overrides everything (managers edit any block via the list page). | ||
| if (_userHelper.HasPermission(_rapsContext, currentUser, CmsPermissions.ManageContentBlocks)) | ||
| { | ||
| return true; | ||
| } | ||
|
|
||
| // Delegated editing is explicit: an empty edit list is manager-only (NOT the view-list's | ||
| // empty-means-all rule), and a soft-deleted block is never editable by a delegate. | ||
| if (block.DeletedOn != null || block.EditPermissions.Count == 0) | ||
| { | ||
| return false; | ||
| } | ||
|
|
||
| // Resolve the user's permissions once into a case-insensitive set, mirroring | ||
| // Data.CMS.CheckFilePermission, then intersect with the block's edit permissions. | ||
| var userPermissions = _userHelper.GetAllPermissions(_rapsContext, currentUser) | ||
| .Select(p => p.Permission) | ||
| .ToHashSet(StringComparer.OrdinalIgnoreCase); | ||
| return block.EditPermissions.Any(p => userPermissions.Contains(p)); |
| // Delegated matches only, as documented: managers work from the full list page, and | ||
| // the hub's "Blocks you can edit" card is manager-hidden and expects empty here. | ||
| if (_userHelper.HasPermission(_rapsContext, currentUser, CmsPermissions.ManageContentBlocks)) | ||
| { | ||
| return new List<ContentBlockDto>(); | ||
| } | ||
|
|
||
| // Single-resolve: the user's permissions are read once and reused for every block. | ||
| var userPermissions = _userHelper.GetAllPermissions(_rapsContext, currentUser) | ||
| .Select(p => p.Permission) | ||
| .ToHashSet(StringComparer.OrdinalIgnoreCase); | ||
| if (userPermissions.Count == 0) | ||
| { | ||
| return new List<ContentBlockDto>(); | ||
| } |
| var block = await _blockService.GetContentBlockAsync(contentBlockId, ct); | ||
| if (block == null) | ||
| { | ||
| return NotFound(); | ||
| } | ||
| // The section path IS the upload folder; refuse the upload when the block has none | ||
| // (mirrors CheckBlockFileName) rather than falling back to the storage root. | ||
| if (string.IsNullOrEmpty(block.ViperSectionPath)) | ||
| { | ||
| return BadRequest("The content block has no VIPER section path to upload into."); | ||
| } | ||
|
|
||
| var request = new CmsFileCreateRequest | ||
| { | ||
| Folder = block.ViperSectionPath, | ||
| AllowPublicAccess = block.AllowPublicAccess, | ||
| Permissions = block.Permissions, | ||
| FileName = form.FileName, | ||
| Overwrite = form.Overwrite | ||
| }; |
…nt attach - RollbackDeleteFileAsync's "not attached elsewhere" recheck and the soft-delete it guards were two separate statements with no transactional isolation, leaving a race window where a concurrent attach to a different block could land in the gap and get stranded by the delete anyway - Wrap both in a Serializable transaction via a shared TransactionHelper (also usable by other services with the same needs); tests override the new virtual ExecuteInTransactionAsync hook to bypass transactions, since the EF in-memory provider doesn't support them
cb5b979 to
87cecc9
Compare
Slice 4 of the CMS migration stack (stacks on #253; the diff shows only this feature). Adds delegated block editing: admins attach RAPS permissions to a block as its edit permissions, and holders of any of them can edit that block's content and attached files — without
SVMSecure.CMS.ManageContentBlocksand without access to anything else.Apply to dev → TEST → PROD ahead of the corresponding deploy (same manual-DDL process as VPR-143). Additive only; invisible to legacy ColdFusion (which reads only the view-permission table), so coexistence is unaffected.
Semantics
ManageContentBlocksor any of B's edit permissions (ANY-OF).Implementation
ContentBlockToEditPermissionentity/table +CanEditAsyncauthorization (single-resolve permission HashSet, fail-closed).GET /editable,GET /attachable-files, per-blockfiles/check-name,POST {id}/files, rollback-onlyDELETE {id}/files/{guid}(owner + folder + not-attached-elsewhere constrained).fnendpoint DTO unchanged (no edit-permission disclosure).