yavsc/src/Yavsc.Blogs/Controllers/BlogAclApiController.cs
Paul Schneider e376aed887
fix(blogacl): restrict Circle + BlogAcl reads and writes to caller's own data
Closes the data-leak holes that survived the move of these controllers
from Yavsc.Api to Yavsc.Blogs. Circles are personal — a circle and its
membership should never be visible, modifiable, or deletable by anyone
other than its owner.

BlogAclApiController:
- GetBlogACL() was returning the full table; now filters by
  Allowed.OwnerId == caller's uid, with an Include(a => a.Allowed)
  so EF Core can push the filter into SQL instead of materialising
  the whole table.
- Other endpoints (GetById, Put, Post, Delete) already enforced
  ownership; left as is.

CircleApiController:
- GetCircle() (no id) now filters by OwnerId.
- GetCircle(id) now requires c.Id == id && c.OwnerId == uid;
  returns 404 (not 403) on miss to avoid leaking the existence of
  someone else's circle.
- PutCircle verifies the existing record is owned by the caller,
  then forces circle.OwnerId = uid on the body (the client's value
  is ignored). Returns ChallengeResult when the caller doesn't own
  the record.
- PostCircle forces circle.OwnerId = uid (was trusting the body).
- DeleteCircle now filters by OwnerId; 404 on miss.

All checks use the same source of truth (User.FindFirstValue(
ClaimTypes.NameIdentifier)) that the existing BlogAclApiController
authz code already relies on.
2026-08-17 23:36:20 +01:00

173 lines
5.8 KiB
C#

using System.Linq;
using System.Security.Claims;
using Microsoft.AspNetCore.Mvc;
using Microsoft.EntityFrameworkCore;
using Yavsc.Models;
using Yavsc.Models.Access;
using Yavsc.Server.Helpers;
namespace Yavsc.Blogs.Controllers
{
[Produces("application/json")]
[Route("api/blogacl")]
public class BlogAclApiController : Controller
{
private readonly ApplicationDbContext _context;
public BlogAclApiController(ApplicationDbContext context)
{
_context = context;
}
/// <summary>
/// Returns the ACL entries for the caller's own blog posts.
/// Blog posts (and therefore their ACLs) are private to their
/// author — the API never exposes another user's ACL.
/// </summary>
// GET: api/blogacl
[HttpGet]
public IEnumerable<CircleAuthorizationToBlogPost> GetBlogACL()
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
return _context.CircleAuthorizationToBlogPost
.Include(a => a.Allowed)
.Where(a => a.Allowed.OwnerId == uid);
}
// GET: api/BlogAclApi/5
[HttpGet("{id}", Name = "GetCircleAuthorizationToBlogPost")]
public async Task<IActionResult> GetCircleAuthorizationToBlogPost([FromRoute] long id)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
CircleAuthorizationToBlogPost circleAuthorizationToBlogPost = await _context.CircleAuthorizationToBlogPost.SingleAsync(
m => m.CircleId == id && m.Allowed.OwnerId == uid );
if (circleAuthorizationToBlogPost == null)
{
return NotFound();
}
return Ok(circleAuthorizationToBlogPost);
}
// PUT: api/BlogAclApi/5
[HttpPut("{id}")]
public async Task<IActionResult> PutCircleAuthorizationToBlogPost([FromRoute] long id, [FromBody] CircleAuthorizationToBlogPost circleAuthorizationToBlogPost)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (id != circleAuthorizationToBlogPost.CircleId)
{
return BadRequest();
}
if (!CheckOwner(circleAuthorizationToBlogPost.CircleId))
{
return new ChallengeResult();
}
_context.Entry(circleAuthorizationToBlogPost).State = EntityState.Modified;
try
{
await _context.SaveChangesAsync(User.GetUserId());
}
catch (DbUpdateConcurrencyException)
{
if (!CircleAuthorizationToBlogPostExists(id))
{
return NotFound();
}
else
{
throw;
}
}
return new StatusCodeResult(StatusCodes.Status204NoContent);
}
private bool CheckOwner (long circleId)
{
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
var circle = _context.Circle.First(c=>c.Id==circleId);
_context.Entry(circle).State = EntityState.Detached;
return (circle.OwnerId == uid);
}
// POST: api/BlogAclApi
[HttpPost]
public async Task<IActionResult> PostCircleAuthorizationToBlogPost([FromBody] CircleAuthorizationToBlogPost circleAuthorizationToBlogPost)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
if (!CheckOwner(circleAuthorizationToBlogPost.CircleId))
{
return new ChallengeResult();
}
_context.CircleAuthorizationToBlogPost.Add(circleAuthorizationToBlogPost);
try
{
await _context.SaveChangesAsync(User.GetUserId());
}
catch (DbUpdateException)
{
if (CircleAuthorizationToBlogPostExists(circleAuthorizationToBlogPost.CircleId))
{
return new StatusCodeResult(StatusCodes.Status409Conflict);
}
else
{
throw;
}
}
return CreatedAtRoute("GetCircleAuthorizationToBlogPost", new { id = circleAuthorizationToBlogPost.CircleId }, circleAuthorizationToBlogPost);
}
// DELETE: api/BlogAclApi/5
[HttpDelete("{id}")]
public async Task<IActionResult> DeleteCircleAuthorizationToBlogPost([FromRoute] long id)
{
if (!ModelState.IsValid)
{
return BadRequest(ModelState);
}
var uid = User.FindFirstValue(ClaimTypes.NameIdentifier);
CircleAuthorizationToBlogPost circleAuthorizationToBlogPost = await _context.CircleAuthorizationToBlogPost.Include(
a=>a.Allowed
).SingleAsync(m => m.CircleId == id
&& m.Allowed.OwnerId == uid);
if (circleAuthorizationToBlogPost == null)
{
return NotFound();
}
_context.CircleAuthorizationToBlogPost.Remove(circleAuthorizationToBlogPost);
await _context.SaveChangesAsync(User.GetUserId());
return Ok(circleAuthorizationToBlogPost);
}
protected override void Dispose(bool disposing)
{
if (disposing)
{
_context.Dispose();
}
base.Dispose(disposing);
}
private bool CircleAuthorizationToBlogPostExists(long id)
{
return _context.CircleAuthorizationToBlogPost.Count(e => e.CircleId == id) > 0;
}
}
}