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.
Originally published at theadnansaleem.com.
Top comments (0)