Quando se fala em autorização em APIs, a primeira solução que vem à cabeça geralmente é usar roles: o usuário é "admin", então pode tudo; é "user", então pode menos. Isso funciona bem até a regra de negócio ficar mais específica, como "o usuário pode ler este documento se ele for o autor ou se for do mesmo departamento", ou "o admin só pode acessar a área administrativa em horário comercial". Nesses cenários, roles não são suficientes, e é aí que entra o ABAC (Attribute-Based Access Control). Neste artigo vamos construir uma API em ASP.NET Core que aplica regras ABAC sobre documentos, usando somente os recursos nativos de autorização do framework.
Pré-requisitos:
- .NET 10 SDK instalado.
- Docker instalado (o banco é um SQL Server em container).
- Ferramenta do EF Core:
dotnet tool install --global dotnet-ef. - Conhecimento básico de C# e de APIs REST.
RBAC vs ABAC
No RBAC (Role-Based Access Control), a decisão de acesso é tomada com base no papel do usuário. A pergunta que o sistema faz é: "esse usuário tem a role X?". No ASP.NET Core isso é o clássico [Authorize(Roles = "admin")].
No ABAC, a decisão é tomada avaliando atributos. O guia do NIST sobre o tema (SP 800-162) descreve o ABAC como um método em que a autorização para executar operações é determinada avaliando atributos do sujeito (quem está pedindo), do objeto (o recurso), da operação solicitada e, em alguns casos, do ambiente.
Imagine o seguinte cenário: uma empresa guarda documentos internos por departamento. As regras são:
| Operação | Regra |
|---|---|
| Criar | O usuário só pode criar documentos no próprio departamento. |
| Ler | O usuário pode ler se for o autor ou se for do mesmo departamento do documento. |
| Atualizar | Apenas o autor, e o documento precisa continuar no departamento do usuário. |
| Excluir | Apenas o autor. |
| Leitura administrativa | Usuário com role admin e somente entre 07:00 e 23:00 (horário de Brasília). |
Tente modelar isso só com roles. Você precisaria de uma role por departamento ("finance-reader", "marketing-reader"...), e ainda assim não resolveria a regra do autor nem a do horário. Com ABAC, as regras ficam exatamente como estão escritas na tabela: comparações entre atributos.
Mapeando os atributos do nosso cenário:
-
Sujeito: id do usuário (
sub), departamento (claimDepartment) e role. -
Recurso: autor (
Author) e departamento (Department) do documento. - Operação: Create, Read, Update, Delete e ReadPrivate.
- Ambiente: horário atual.
O projeto
É uma Minimal API com ASP.NET Core Identity para cadastro de usuários, autenticação via JWT e Entity Framework Core com SQL Server. O código completo está no GitHub (link no final). A entidade que vamos proteger é bem simples:
public class Document
{
public Guid Id { get; set; }
public string Title { get; set; } = string.Empty;
public string Author { get; set; } = string.Empty;
public string Department { get; set; } = string.Empty;
public byte[] Content { get; set; } = [];
}
Repare que Author e Department são exatamente os atributos do recurso que as regras vão usar.
Atributos do usuário viram claims
Para o handler conseguir avaliar os atributos do usuário, eles precisam estar no ClaimsPrincipal da requisição. Como a API usa JWT, o caminho é: guardar os atributos no Identity e copiá-los para o token no login.
No cadastro, o departamento é salvo como uma claim do usuário (e a role é associada com userManager.AddToRoleAsync):
await userManager.AddClaimAsync(user, new Claim("Department", request.Department));
No login, o JwtTokenGenerator monta o token com o id do usuário (sub), o e-mail, as claims salvas (incluindo Department) e as roles:
var claims = new List<Claim>
{
new(JwtRegisteredClaimNames.Sub, user.Id),
new(JwtRegisteredClaimNames.Email, user.Email!),
new(JwtRegisteredClaimNames.Jti, Guid.NewGuid().ToString())
};
claims.AddRange(userClaims);
claims.AddRange(userRoles.Select(role => new Claim(ClaimTypes.Role, role)));
Um detalhe importante: os atributos do usuário ficam "congelados" dentro do token. Se o departamento de alguém mudar, a regra só vai enxergar o novo valor depois que essa pessoa fizer login de novo e receber um token novo.
Definindo as operações
O ASP.NET Core tem uma classe pronta para representar operações do tipo CRUD: a OperationAuthorizationRequirement. Com ela, você escreve um único handler para todas as operações, em vez de uma classe de requirement para cada uma.
public static class DocumentOperations
{
public static readonly OperationAuthorizationRequirement Create = new() { Name = nameof(Create) };
public static readonly OperationAuthorizationRequirement Read = new() { Name = nameof(Read) };
public static readonly OperationAuthorizationRequirement Update = new() { Name = nameof(Update) };
public static readonly OperationAuthorizationRequirement Delete = new() { Name = nameof(Delete) };
public static readonly OperationAuthorizationRequirement ReadPrivate = new() { Name = nameof(ReadPrivate) };
}
Além das quatro operações do CRUD, criamos a ReadPrivate, que representa a leitura pela rota administrativa.
O handler: onde as regras ABAC vivem
Esse é o coração do projeto. O handler herda de AuthorizationHandler<OperationAuthorizationRequirement, Document>, ou seja, ele é chamado sempre que alguém pedir autorização para uma operação sobre um Document. Dentro dele, recebemos três coisas: o contexto (com o usuário), a operação solicitada e o próprio documento.
public class DocumentAuthorizationHandler(TimeProvider timeProvider, IOptions<BusinessHoursOptions> businessHours) :
AuthorizationHandler<OperationAuthorizationRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OperationAuthorizationRequirement requirement,
Document resource)
{
var isAuthorized = requirement.Name switch
{
// Criar: o usuário só pode criar documentos no próprio departamento
nameof(DocumentOperations.Create) =>
IsSameDepartment(context, resource),
// Ler: o usuário deve ser o autor OU pertencer ao mesmo departamento do documento
nameof(DocumentOperations.Read) =>
IsSameAuthor(context, resource) || IsSameDepartment(context, resource),
// Atualizar: o usuário deve ser o autor e manter o documento em seu departamento
nameof(DocumentOperations.Update) =>
IsSameAuthor(context, resource) && IsSameDepartment(context, resource),
// Deletar: apenas o próprio autor pode excluir o documento
nameof(DocumentOperations.Delete) =>
IsSameAuthor(context, resource),
// Leitura privada/administrativa: requer role "admin" e horário comercial configurado
nameof(DocumentOperations.ReadPrivate) =>
context.User.IsInRole("admin") && IsWithinAllowedTime(),
_ => false
};
if (isAuthorized)
{
context.Succeed(requirement);
}
return Task.CompletedTask;
}
}
Compare o switch com a tabela de regras do começo do artigo: cada linha da tabela virou um case. Esse é um dos grandes ganhos do ABAC: a regra de negócio fica legível no código.
Alguns pontos para observar:
-
O handler só chama
Succeed. Ele não precisa chamarFailpara negar acesso: se nenhum handler marcar o requirement como atendido, a autorização falha. Ocontext.Failserve para garantir a falha mesmo que outro handler tenha aprovado. -
O
_ => falsenega por padrão. Se alguém criar uma operação nova e esquecer de tratá-la aqui, o acesso é negado, e não liberado. -
Por que
Createnão verifica o autor? Porque oAuthordo documento é preenchido no endpoint a partir do próprio token. Comparar o autor com o usuário seria comparar o valor com ele mesmo, então a única regra que faz sentido é a do departamento.
Os métodos auxiliares fazem as comparações entre atributos do usuário e do recurso:
private static bool IsSameAuthor(
AuthorizationHandlerContext context,
Document resource)
{
var subjectId = context.User.FindFirst(ClaimTypes.NameIdentifier)?.Value
?? context.User.FindFirst(JwtRegisteredClaimNames.Sub)?.Value;
return !string.IsNullOrEmpty(subjectId) && subjectId == resource.Author;
}
private static bool IsSameDepartment(
AuthorizationHandlerContext context,
Document resource)
{
var userDepartment = context.User.FindFirst("Department")?.Value;
return !string.IsNullOrEmpty(userDepartment) &&
string.Equals(userDepartment, resource.Department, StringComparison.OrdinalIgnoreCase);
}
O IsSameAuthor procura primeiro por ClaimTypes.NameIdentifier e depois por sub porque, por padrão, o middleware de JWT Bearer converte o sub do token em NameIdentifier.
Atributo de ambiente: horário comercial
A regra da leitura administrativa depende de um atributo que não pertence nem ao usuário nem ao documento: o horário atual.
Em vez de DateTime.Now, o handler recebe um TimeProvider injetado, o que permite controlar o relógio nos testes (vamos ver isso na dica bônus). E, para não depender do fuso do servidor (um container em produção geralmente está em UTC), o fuso e a janela de horário ficam na configuração:
"BusinessHours": {
"TimeZoneId": "America/Sao_Paulo",
"Start": "07:00:00",
"End": "23:00:00"
}
O handler converte o horário UTC para o fuso configurado antes de comparar:
private bool IsWithinAllowedTime()
{
var options = businessHours.Value;
var timeZone = TimeZoneInfo.FindSystemTimeZoneById(options.TimeZoneId);
var now = TimeZoneInfo.ConvertTime(timeProvider.GetUtcNow(), timeZone).TimeOfDay;
return now >= options.Start && now < options.End;
}
Registrando tudo no Program.cs
builder.Services.AddAuthorizationBuilder()
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.Configure<BusinessHoursOptions>(builder.Configuration.GetSection("BusinessHours"));
builder.Services.AddSingleton<IAuthorizationHandler, DocumentAuthorizationHandler>();
A fallback policy exige usuário autenticado em todos os endpoints que não declaram nenhuma regra de autorização. É uma abordagem "seguro por padrão": um endpoint novo nasce protegido. Os endpoints de registro e login usam .AllowAnonymous() para ficarem públicos.
Aplicando nos endpoints
Aqui está um ponto que confunde muita gente. Por que não usar simplesmente um [Authorize(Policy = "...")] no endpoint?
Porque a regra depende do documento, e o atributo é avaliado antes de o endpoint executar, ou seja, antes de o documento ser carregado do banco. A documentação de autorização baseada em recurso explica exatamente isso: nesses casos, o app deve chamar a autorização de forma imperativa, usando o IAuthorizationService.
O padrão nos endpoints é sempre o mesmo: carregar o recurso, pedir autorização, e só então executar a operação.
app.MapGet("/documents/{id:guid}", async (Guid id,
ClaimsPrincipal user, IAuthorizationService authorizationService, ApplicationDbContext dbContext) =>
{
var document = await dbContext.Documents.FindAsync(id);
if (document is null)
{
return Results.NotFound();
}
var authResult = await authorizationService.AuthorizeAsync(user, document, DocumentOperations.Read);
if (!authResult.Succeeded)
{
return Results.Forbid();
}
return Results.Ok(document);
});
O AuthorizeAsync(user, document, DocumentOperations.Read) é o que dispara o nosso handler, passando o usuário, o recurso e a operação. Se falhar, Results.Forbid() retorna 403.
O caso do PUT: autorizar o estado atual e o estado proposto
A atualização merece atenção especial. A regra diz que o usuário precisa ser o autor e o documento precisa continuar no departamento dele. Isso tem dois lados:
- O estado atual: esse usuário pode editar esse documento?
- O estado proposto: depois da edição, o documento continua respeitando a regra? (Ele não pode, por exemplo, "mover" o documento para o departamento de Marketing.)
E as duas verificações acontecem antes de alterar a entidade rastreada pelo EF Core, para que nenhum SaveChanges acabe salvando uma alteração não autorizada:
app.MapPut("/documents/{id:guid}", async (Guid id, [FromBody] CreateDocumentRequest request,
ClaimsPrincipal user, IAuthorizationService authorizationService, ApplicationDbContext dbContext) =>
{
var document = await dbContext.Documents.FindAsync(id);
if (document is null)
{
return Results.NotFound();
}
// Autoriza o estado atual do documento (quem pode editá-lo hoje)
var authResult = await authorizationService.AuthorizeAsync(user, document, DocumentOperations.Update);
if (!authResult.Succeeded)
{
return Results.Forbid();
}
// Autoriza o estado proposto (o documento continua no departamento do usuário?)
var proposed = new Document
{
Id = document.Id,
Author = document.Author,
Title = request.Title,
Department = request.Department,
Content = Encoding.UTF8.GetBytes(request.Content)
};
authResult = await authorizationService.AuthorizeAsync(user, proposed, DocumentOperations.Update);
if (!authResult.Succeeded)
{
return Results.Forbid();
}
document.Title = proposed.Title;
document.Department = proposed.Department;
document.Content = proposed.Content;
await dbContext.SaveChangesAsync();
return Results.Ok(document);
});
Repare que a mesma regra (Update) é reaproveitada para as duas verificações. Não foi preciso criar uma regra nova para "mudança de departamento".
Testando
Dentro da pasta AbacAuthorization, suba o banco, aplique as migrations e rode a API:
docker compose up -d
dotnet ef database update
dotnet run --launch-profile http
A API sobe em http://localhost:5088, e o arquivo AbacAuthorization.http do projeto já tem as requisições prontas, incluindo o cadastro dos quatro usuários que vamos usar:
| Usuário | Departamento | Role |
|---|---|---|
| Alice | Finance | user |
| Bob | Finance | user |
| Carol | Marketing | user |
| Admin | IT | admin |
O fluxo é:
- Faça login com a Alice (
POST /auth/login), copie otokenda resposta e cole na variável@Tokendo arquivo. - Crie um documento no departamento
Finance(POST /documents). A resposta traz o id como uma string JSON ("69f0ce92-..."); cole na variável@DocumentIdsem as aspas. - Repita as requisições fazendo login com cada usuário e trocando o
@Token. Deixe oDELETEcom a Alice para o final: depois dele o documento deixa de existir e tudo passa a responder 404.
O resultado esperado é:
| Requisição | Alice (Finance, autora) | Bob (Finance) | Carol (Marketing) | Admin (IT, admin) |
|---|---|---|---|---|
GET /documents/{id} |
200 | 200 | 403 | 403 |
PUT /documents/{id} (mantendo Finance) |
200 | 403 | 403 | 403 |
PUT /documents/{id} (mudando para Marketing) |
403 | 403 | 403 | 403 |
GET /admin/documents/{id} (07:00–23:00) |
403 | 403 | 403 | 200 |
GET /admin/documents/{id} (fora do horário) |
403 | 403 | 403 | 403 |
DELETE /documents/{id} |
204 | 403 | 403 | 403 |
Repare em duas linhas dessa tabela:
-
O admin não consegue ler o documento pela rota comum. Ter a role "admin" não dá acesso a tudo: na operação
Read, o que importa é autoria e departamento. Ele só tem acesso pela rota administrativa, que tem regra própria. - Nem a autora consegue mover o documento de departamento. O estado proposto é barrado pela segunda verificação do PUT.
E sem o header Authorization, qualquer rota de documentos responde 401, barrada pela fallback policy antes de chegar ao handler.
Um exemplo de teste:
Alice lendo o documento no qual ela criou

Carol tentando ler um documento que ela não escreveu e de outro departamento

Carol tentando ler um documento administrativo

Um admin lendo um documento admnistrativo

DICA BÔNUS: testando as regras sem subir a API
Como o handler depende só de abstrações, dá para testar todas as regras sem banco, sem HTTP e sem esperar dar 23h. O FakeTimeProvider, do pacote Microsoft.Extensions.TimeProvider.Testing, permite fixar a hora que você quiser.
Em um projeto de testes xUnit que referencia a API, instale o pacote:
dotnet add package Microsoft.Extensions.TimeProvider.Testing
E monte o IAuthorizationService com o handler real e o relógio falso:
using System.Security.Claims;
using AbacAuthorization.Auth.Handlers;
using AbacAuthorization.Entities;
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Time.Testing;
public class DocumentAuthorizationHandlerTests
{
private static IAuthorizationService CreateService(TimeProvider timeProvider)
{
var services = new ServiceCollection();
services.AddLogging();
services.AddAuthorization();
services.AddSingleton(timeProvider);
services.Configure<BusinessHoursOptions>(o => o.TimeZoneId = "America/Sao_Paulo");
services.AddSingleton<IAuthorizationHandler, DocumentAuthorizationHandler>();
return services.BuildServiceProvider().GetRequiredService<IAuthorizationService>();
}
[Fact]
public async Task Admin_nao_le_documento_privado_fora_do_horario()
{
// 02:30 UTC = 23:30 em São Paulo
var clock = new FakeTimeProvider(new DateTimeOffset(2026, 10, 18, 2, 30, 0, TimeSpan.Zero));
var service = CreateService(clock);
var admin = new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.NameIdentifier, "admin-id"), new Claim(ClaimTypes.Role, "admin")], "test"));
var document = new Document { Author = "alice-id", Department = "Finance" };
var result = await service.AuthorizeAsync(admin, document, DocumentOperations.ReadPrivate);
Assert.False(result.Succeeded);
}
}
Com esse mesmo CreateService, você consegue cobrir cada linha da tabela de regras com um teste rápido.
Antes de levar para produção
-
Muitas regras. Um
switchem C# atende muito bem enquanto as regras forem poucas e mudarem junto com o código. Se as políticas crescerem muito ou precisarem ser alteradas sem deploy, vale olhar para motores de políticas externos, como o Open Policy Agent.
Conclusão
ABAC não exige biblioteca externa no ASP.NET Core. Com um AuthorizationHandler baseado em recurso, o OperationAuthorizationRequirement e chamadas imperativas ao IAuthorizationService, as regras de negócio ficam centralizadas em um único lugar, legíveis e fáceis de testar. E roles continuam úteis: como você viu, elas podem ser só mais um atributo dentro da decisão, ao lado de autoria, departamento e horário. Comece pelo que é mais simples e use atributos quando a regra de negócio pedir.
Dicionário
- RBAC (Role-Based Access Control) — Controle de acesso baseado em papéis (roles). O acesso depende de o usuário ter determinada role.
- ABAC (Attribute-Based Access Control) — Controle de acesso baseado em atributos do usuário, do recurso, da operação e do ambiente.
-
Claim — Uma informação sobre o usuário no formato chave/valor, como
Department: Finance. Fica dentro do token e é lida pela API a cada requisição. - Requirement — No ASP.NET Core, representa uma exigência de autorização a ser avaliada (por exemplo, "pode ler o documento").
-
Handler de autorização — Classe que avalia um requirement e decide se ele foi atendido, chamando
context.Succeed. - Policy — Conjunto de um ou mais requirements com um nome, aplicado a endpoints.
- Fallback policy — Policy aplicada automaticamente a endpoints que não declaram nenhuma regra de autorização.
-
Autorização imperativa — Quando o próprio código chama o serviço de autorização (
IAuthorizationService.AuthorizeAsync), em vez de usar atributos como[Authorize].
Referências
- Resource-based authorization in ASP.NET Core
- Policy-based authorization in ASP.NET Core
- NIST SP 800-162: Guide to Attribute Based Access Control (ABAC) Definition and Considerations
Link do projeto no GitHub: abac-authorization-asp-net-core-example
Top comments (0)