DEV Community

Guilherme Govaski
Guilherme Govaski

Posted on

Autorização ABAC no ASP.NET Core: indo além das roles

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 (claim Department) 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; } = [];
}
Enter fullscreen mode Exit fullscreen mode

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));
Enter fullscreen mode Exit fullscreen mode

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)));
Enter fullscreen mode Exit fullscreen mode

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) };
}
Enter fullscreen mode Exit fullscreen mode

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;
    }
}
Enter fullscreen mode Exit fullscreen mode

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 chamar Fail para negar acesso: se nenhum handler marcar o requirement como atendido, a autorização falha. O context.Fail serve para garantir a falha mesmo que outro handler tenha aprovado.
  • O _ => false nega 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 Create não verifica o autor? Porque o Author do 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);
}
Enter fullscreen mode Exit fullscreen mode

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"
}
Enter fullscreen mode Exit fullscreen mode

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;
}
Enter fullscreen mode Exit fullscreen mode

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>();
Enter fullscreen mode Exit fullscreen mode

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);
});
Enter fullscreen mode Exit fullscreen mode

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:

  1. O estado atual: esse usuário pode editar esse documento?
  2. 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);
});
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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 é:

  1. Faça login com a Alice (POST /auth/login), copie o token da resposta e cole na variável @Token do arquivo.
  2. Crie um documento no departamento Finance (POST /documents). A resposta traz o id como uma string JSON ("69f0ce92-..."); cole na variável @DocumentId sem as aspas.
  3. Repita as requisições fazendo login com cada usuário e trocando o @Token. Deixe o DELETE com 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
alice-read-document-same-author.png

Carol tentando ler um documento que ela não escreveu e de outro departamento
carol-read-document-other-deparment.png

Carol tentando ler um documento administrativo
carol-read-admin-documents.png

Um admin lendo um documento admnistrativo
admin-read-admin-documents.png

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);
    }
}
Enter fullscreen mode Exit fullscreen mode

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 switch em 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

Link do projeto no GitHub: abac-authorization-asp-net-core-example

Top comments (0)