DEV Community

Adnan Saleem
Adnan Saleem

Posted on Originally published at theadnansaleem.com AI-assisted

Role-based access control with data redaction in ASP.NET Core

On a vehicle cybersecurity platform I secured sensitive security data with four-tier role-based access control, secure .NET API integrations, and data redaction pipelines filtered by caller tier. The idea underneath is simple and often skipped: deciding who may call an endpoint is only half of access control. The other half is deciding what each caller gets back.

Hiding a field in the UI is not security

If the API returns a field and the frontend chooses not to show it, the field is one browser tool away from anyone who is logged in. Whatever a user must not see has to be removed on the server, before the response is serialized.

Two questions, two mechanisms

  • May this caller use this endpoint at all? That is authorization, and ASP.NET Core policies answer it.
  • Which parts of the answer may this caller see? That is redaction, and it needs its own step.

Tiers as policies

Give each user a tier claim at sign-in and express every rule as a named policy. Controllers then state what they need, and the rule itself lives in one place:

// Program.cs: each policy names the lowest tier that passes
builder.Services.AddAuthorization(options =>
{
    options.AddPolicy("Tier2", policy => policy.RequireClaim("tier", "2", "3", "4"));
    options.AddPolicy("Tier3", policy => policy.RequireClaim("tier", "3", "4"));
    options.AddPolicy("Tier4", policy => policy.RequireClaim("tier", "4"));
});
Enter fullscreen mode Exit fullscreen mode

One redaction step, used everywhere

The endpoint loads the full record and hands it to a single function that builds the response for the caller's tier:

[Authorize(Policy = "Tier2")]
[HttpGet("findings/{id}")]
public async Task<ActionResult<FindingDto>> Get(Guid id)
{
    var finding = await _findings.Find(id);
    if (finding is null) return NotFound();

    var tier = int.Parse(User.FindFirstValue("tier")!);
    return FindingRedactor.For(tier, finding);
}
Enter fullscreen mode Exit fullscreen mode
public static class FindingRedactor
{
    public static FindingDto For(int tier, Finding f) => new()
    {
        Id = f.Id,
        Title = f.Title,
        Severity = f.Severity,
        // Each sensitive field names the tier that may see it; everyone else gets null
        AffectedComponent = tier >= 3 ? f.AffectedComponent : null,
        ReproductionSteps = tier >= 4 ? f.ReproductionSteps : null,
    };
}
Enter fullscreen mode Exit fullscreen mode

Three properties make this hold up over time. The domain object never leaves the server; only the response object does. Every sensitive field states its own rule, so a reviewer can read the whole policy in one file. And a new field is absent from the response until someone adds it to the redactor, which makes the default safe.

Where data leaks anyway

  • Lists and search. A redacted detail page is pointless if the list endpoint returns the full record. Send every response for that type through the same redactor.
  • Exports and reports. CSV and PDF generation often bypasses the API layer. Give them the same step.
  • Filters and counts. If a low tier can filter by a hidden field, the result count reveals it. Reject filters on fields the caller cannot read.
  • Errors and logs. A validation message that quotes a hidden value leaks it. Keep sensitive values out of error text.
  • Real-time channels. A WebSocket that broadcasts full records to every subscriber undoes all of the above. Redact per connection.

Test it as a table

Access rules are a grid of tiers against fields, so test them as one: for every tier, request the same record and assert exactly which fields are present. When someone adds a field, the test for the lowest tier fails until they decide who may see it. That failing test is the review.


Originally published at theadnansaleem.com.

Top comments (0)