adnansaleem

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

Authorization decides who may call an endpoint. Redaction decides what they get back. How to do both in ASP.NET Core, and where data still leaks.

By M. Adnan Saleem · · 3 min read

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"));
});

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);
}
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,
    };
}

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.

The project behind this

Read the case study

What I built, where, and the numbers that came out of it.

↑ ↓ to move · Enter to run · Esc to close