Skip to main content

ACL Authorization

1. What this document is about

This document describes how to design, implement and operate an enterprise-grade ACL authorization system where access decisions must be made at the individual resource instance level, not just at the role or coarse permission level.

It applies to system where:

  • RBAC and simple policy-based authorization are insufficient
  • Access must be enforced per object, record or domain entity
  • Authorization decisions must be deterministic, auditable and explainable
  • The system spans multiple services and teams over time.

It does not apply to:

  • Small systems with low authorization cardinality
  • Applications where role-based access is stable and sufficient
  • Environments where coarse feature-level access is acceptable

2. Why this matters in real systems

ACL systems emerge when real systems collide with reality:

  • Multiple users collaborate over the same resource instances
  • Access must be delegated temporarily or conditionally
  • Ownership and responsibility change over time
  • Data spans tenants, domains, and hierarchical boundaries
  • Compliance requires provable, reviewable least privilege

These conditions are common in mature systems and cannot be addressed reliably with coarse authorization models.

Pressure points that force ACLs into existence

RBAC explosion

Roles multiply to encode context, scope and exceptions whey were never designed to represent.

Over-permissioning by default

Broad access is granted to unblock delivery and quietly becomes permanent.

Authorization drift

No one can explain why access exists, who granted it or whether it is still valid.

Latency regressions on hot paths

Authorization logic grows organically and leaks into request paths without design discipline.

Operational paralysis

Permission changes require redeployments, token refreshes or cross-team coordination.

Failure modes when ACLs are ignored or poorly designed

When these pressures are not addressed explicitly, system fail in predictable ways:

  • Security and compliance reviews block releases late in the cycle
  • Revocations are unsafe, delayed or avoided entirely
  • Caching introduces subtle correctness and consistency bugs
  • Each service reimplements authorization logic differently

At this stage, authorization becomes both a delivery risk and a security liability.

ACLs are not an optimization or an architectural preference. They are a structural response to scale, collaboration and organizational complexity.


3. Core concept (mental model)

An ACL system is a deterministic authorization decision engine that answers a single, explicit question:

Is principal P allowed to perform action A on resource R within context C at time T?

Every authorization decision is evaluated against materialized authorization facts, not inferred rules or implicit behavior.

Mental model: authorization as fact resolution

At runtime, the system resolves a set of concrete facts and determines whether at least one valid grant exists that satisfies all invariants.

The decision space is defined by four first-class dimensions:

Principal

A concrete security identity: user, group or service principal. Principals may be expanded (e.g., user → groups), but expansion is explicit and bounded.

Resource

A specific resource instance, not a category or type. Authorization always targets an object with identity and ownership.

Action

A verb from a controlled, versioned permission vocabulary (read, update, delete, approve, etc.).

Context

Constraints that qualify the grant:

  • tenant boundary
  • temporal validity
  • delegation semantics
  • optional scope dimensions

An authorization decision is therefore the result of fact matching, not rule execution.

Examples of resolved authorization facts

  • User 123 is granted read on Document 4721 within Tenant A
  • Group X is granted approve on Invoice 9 until 2026-03-01
  • Service A is granted list on Orders scoped to Tenant T

In no fact matches the full decision tuple (P, A, R, C, T)), access is denied.

Explicitness as a design invariant

The ACL model enforces strict explicitness:

  • No access exists unless it is granted
  • No inheritance exits unless it is modeled
  • No permission exists outside its defined scope and validity window

I every authorization outome can be explained as a resolved, auditable fact, the ACL model is behaving correcly.


4. How it works (step-by-step)

Step 1 — Identity and coarse context (Keycloak)

Keycloak is responsible for establishing who the caller is and providing stable, low-cardinality context.

It provides:

  • Authentication (OIDC/Auth2)
  • Stable subject identity (sub)
  • Coarse grouping (groups, realm roles)
  • Tenant or organizational context via claims

Key invariant

Keycloak asserts who the caller is, not what they can access.

ACLs must not live in JWTs. Tokens must remain stable and small.


Step 2 — Intent declaration in .NET services

Each API endpoint declares authorization intent, not access logic.

Examples:

  • "Caller wants to read a Document"
  • "Caller wants to update an Account"

This is expressed via attributes, matadata or middleware markers.

Endpoints never encode:

  • permission joins
  • role logic
  • grant resolution rules

This separation ensures intent remains readable, reviewable and consistent across services.


Step 3 — Resource context resolution

Before any authorization decision is made, the service resolves the complete resource context.

This includes:

  • The concrete resource identifier(s)
  • The resource type
  • The tenant boundary
  • Optional hierarchical relationships or scope dimensions

Resource context resolution is a security boundary and must happen inside the service.

The authorization engine does not infer or reconstruct context. It receives an explcit, validated resource description.


Step 4 — ACL evaluation against the authorization store

SQL Server acts as the authoritative source of authorization facts.

The ACL evaluator executes query-shaped authorization, optimized for real access patterns:

  • Single-resource checks for command endpoints
  • Set-based batch checks for list and search endpoints
  • Hierarchical traversal only when explicitly modeled

The evaluation yields a structured decision:

  • Allow or deny
  • Reason code(s)
  • Grant source (direct, group, delegation)

No implicit access exists outside what is materialized in the store.


Step 5 — Decision enforcement, caching and observability

The authorization decision is:

  • Enforced synchronously in the request path
  • Logged with structured metadata
  • Attached to the request trace for correlation
  • Optionally cached with explicitly defined staleness semantics

At this stage, authorization is:

  • deterministic
  • explainable
  • auditable
  • observable under load

Authorization stops being an opaque check and becomes a controlled, inspectable subsystem.


5. Minimal but realistic example

Permission vocabulary

resource_type: document
action: read | update | delete | share
scope: optional (tenant, department, classification)

Core grant table

CREATE TABLE acl_grant (
grant_id BIGINT IDENTITY PRIMARY KEY,
tenant_id UNIQUEIDENTIFIER NOT NULL,
principal_type TINYINT NOT NULL, -- user, group, service
principal_id UNIQUEIDENTIFIER NOT NULL,
resource_type VARCHAR(64) NOT NULL,
resource_id UNIQUEIDENTIFIER NOT NULL,
action VARCHAR(32) NOT NULL,
valid_from DATETIME2 NOT NULL,
valid_to DATETIME2 NULL,
created_at DATETIME2 NOT NULL,
created_by UNIQUEIDENTIFIER NOT NULL
);

Hot-path index

CREATE NONCLUSTERED INDEX ix_acl_check
ON acl_grant (
tenant_id,
principal_id,
resource_type,
resource_id,
action
)
INCLUDE (valid_from, valid_to);

Auhtorization query (single check)

SELECT TOP 1 1
FROM acl_grant
WHERE tenant_id = @tenantId
AND principal_id IN (@principalIds)
AND resource_type = @resourceType
AND resource_id = @resourceId
AND action = @action
AND valid_from <= SYSUTCDATETIME()
AND (valid_to IS NULL OR valid_to >= SYSUTCDATETIME());

ASP.NET Core handler (simplified)

public class AclRequirement : IAuthorizationRequirement
{
public string Action { get; }
public string ResourceType { get; }

public AclRequirement(string action, string resourceType)
{
Action = action;
ResourceType = resourceType;
}
}

---

public class AclHandler : AuthorizationHandler<AclRequirement, ResourceContext>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
AclRequirement requirement,
ResourceContext resource)
{
if (await _aclEvaluator.IsAllowed(
context.User,
resource,
requirement))
{
context.Succeed(requirement);
}
}
}

ResourceType, Action and Permission

public readonly record struct ResourceType(string Value)
{
public override string ToString() => Value;
public static implicit operator string(ResourceType t) => t.Value;

// exemplos (mantenha catalogado por domínio)
public static readonly ResourceType Document = new("document");
public static readonly ResourceType Account = new("account");
}

public readonly record struct AclAction(string Value)
{
public override string ToString() => Value;
public static implicit operator string(AclAction a) => a.Value;

public static readonly AclAction Read = new("read");
public static readonly AclAction Update = new("update");
public static readonly AclAction Delete = new("delete");
public static readonly AclAction Share = new("share");
}

public sealed record Permission(ResourceType ResourceType, AclAction Action)
{
public string PolicyName => $"acl:{ResourceType}:{Action}";
}

Resource Context + multi-resource (batch-friendly)

public sealed record ResourceKey(ResourceType Type, Guid Id);

public sealed record ResourceContext(
Guid TenantId,
ResourceKey Resource,
IReadOnlyDictionary<string, string>? Dimensions = null, // opcional: dept/classification/etc
ResourceKey? Parent = null // opcional: hierarquia
);

public sealed record MultiResourceContext(
Guid TenantId,
ResourceType ResourceType,
IReadOnlyList<Guid> ResourceIds,
IReadOnlyDictionary<string, string>? Dimensions = null
);

Reason codes + decision payload

public enum AclDecision
{
Allowed = 1,
Denied = 2
}

public enum AclReasonCode
{
GrantedDirect = 10,
GrantedViaGroup = 11,
GrantedViaDelegation = 12,

DeniedNoGrant = 20,
DeniedExpired = 21,
DeniedWrongTenant = 22,
DeniedInvalidPrincipal = 23
}

public sealed record AclDecisionDetails(
AclDecision Decision,
AclReasonCode Reason,
long? GrantId = null,
Guid? MatchedPrincipalId = null,
string? MatchedPrincipalType = null,
DateTimeOffset EvaluatedAtUtc = default
);

Principal expansion: user + group + service principal

Claims parsing (Keycloak)

public sealed record PrincipalContext(
Guid SubjectId, // user/service principal id
Guid TenantId,
IReadOnlyList<Guid> GroupIds, // internal group ids (mapped)
IReadOnlyList<Guid> AllPrincipalIds // subject + groups (+ others)
);

public interface IPrincipalContextResolver
{
PrincipalContext Resolve(ClaimsPrincipal user);
}

public sealed class KeycloakPrincipalContextResolver : IPrincipalContextResolver
{
private readonly IGroupMappingStore _groupMapping; // Keycloak group string -> internal Guid mapping
private readonly ITenantResolver _tenantResolver;

public KeycloakPrincipalContextResolver(IGroupMappingStore groupMapping, ITenantResolver tenantResolver)
{
_groupMapping = groupMapping;
_tenantResolver = tenantResolver;
}

public PrincipalContext Resolve(ClaimsPrincipal user)
{
// 1) subject id (sub)
var sub = user.FindFirstValue("sub");
if (!Guid.TryParse(sub, out var subjectId))
throw new SecurityException("Invalid 'sub' claim (expected GUID).");

// 2) tenant
var tenantId = _tenantResolver.ResolveTenantId(user);

// 3) groups (Keycloak often puts groups in "groups" claim as string array)
var groupPaths = user.FindAll("groups").Select(c => c.Value).ToArray();
var groupIds = _groupMapping.MapToInternalGroupIds(groupPaths);

// 4) principal set
var all = new List<Guid>(1 + groupIds.Count) { subjectId };
all.AddRange(groupIds);

return new PrincipalContext(subjectId, tenantId, groupIds, all);
}
}
public interface ITenantResolver
{
Guid ResolveTenantId(ClaimsPrincipal user);
}

public interface IGroupMappingStore
{
IReadOnlyList<Guid> MapToInternalGroupIds(IReadOnlyList<string> keycloakGroupPaths);
}

Warning: TenantId cannot come from a route trusted by the client. You resolve this via cliam or internal lookup.


Dynamic policy provider (zero policy explosion)

[Authorize(Policy="acl:document:read")] without having to register each policy individually.

public sealed class AclPolicyProvider : IAuthorizationPolicyProvider
{
private readonly DefaultAuthorizationPolicyProvider _fallback;

public AclPolicyProvider(IOptions<AuthorizationOptions> options)
=> _fallback = new DefaultAuthorizationPolicyProvider(options);

public Task<AuthorizationPolicy> GetDefaultPolicyAsync()
=> _fallback.GetDefaultPolicyAsync();

public Task<AuthorizationPolicy?> GetFallbackPolicyAsync()
=> _fallback.GetFallbackPolicyAsync();

public Task<AuthorizationPolicy?> GetPolicyAsync(string policyName)
{
if (policyName.StartsWith("acl:", StringComparison.OrdinalIgnoreCase))
{
// acl:{resourceType}:{action}
var parts = policyName.Split(':', 3);
if (parts.Length == 3)
{
var rt = new ResourceType(parts[1]);
var act = new AclAction(parts[2]);

var policy = new AuthorizationPolicyBuilder()
.AddRequirements(new AclRequirement(act.Value, rt.Value))
.Build();

return Task.FromResult<AuthorizationPolicy?>(policy);
}
}

return _fallback.GetPolicyAsync(policyName);
}
}


Resource resolution: route → ResourceContext

We need a consistent mechanism to extract resourceId from route/query/body and create ResourceContext

[AttributeUsage(AttributeTargets.Method, AllowMultiple = false)]
public sealed class AclResourceAttribute : Attribute
{
public string ResourceType { get; }
public string RouteParam { get; } // ex: "documentId"
public AclResourceAttribute(string resourceType, string routeParam)
{
ResourceType = resourceType;
RouteParam = routeParam;
}
}
public interface IResourceContextResolver
{
ResourceContext Resolve(HttpContext httpContext, AclRequirement requirement);
}

public sealed class DefaultResourceContextResolver : IResourceContextResolver
{
private readonly ITenantResolver _tenantResolver;

public DefaultResourceContextResolver(ITenantResolver tenantResolver)
=> _tenantResolver = tenantResolver;

public ResourceContext Resolve(HttpContext ctx, AclRequirement req)
{
var endpoint = ctx.GetEndpoint() ?? throw new SecurityException("No endpoint metadata found.");
var meta = endpoint.Metadata.GetMetadata<AclResourceAttribute>()
?? throw new SecurityException("Missing [AclResource] metadata.");

var tenantId = _tenantResolver.ResolveTenantId(ctx.User);

if (!ctx.Request.RouteValues.TryGetValue(meta.RouteParam, out var raw) || raw is null)
throw new SecurityException($"Missing route param '{meta.RouteParam}'.");

if (!Guid.TryParse(raw.ToString(), out var resourceId))
throw new SecurityException($"Invalid route param '{meta.RouteParam}' (expected GUID).");

return new ResourceContext(
TenantId: tenantId,
Resource: new ResourceKey(new ResourceType(meta.ResourceType), resourceId)
);
}
}

ACL Evaluator (single + batch) + query strategy + safety

public interface IAclEvaluator
{
Task<AclDecisionDetails> AuthorizeAsync(
ClaimsPrincipal user,
ResourceContext resource,
AclRequirement requirement,
CancellationToken ct = default);

Task<IReadOnlyDictionary<Guid, AclDecisionDetails>> AuthorizeBatchAsync(
ClaimsPrincipal user,
MultiResourceContext resources,
AclAction action,
CancellationToken ct = default);
}
public sealed class AclEvaluator : IAclEvaluator
{
private readonly IPrincipalContextResolver _principalResolver;
private readonly IAclStore _store;
private readonly IAclClock _clock;

public AclEvaluator(IPrincipalContextResolver principalResolver, IAclStore store, IAclClock clock)
{
_principalResolver = principalResolver;
_store = store;
_clock = clock;
}

public async Task<AclDecisionDetails> AuthorizeAsync(
ClaimsPrincipal user,
ResourceContext resource,
AclRequirement requirement,
CancellationToken ct = default)
{
var principal = _principalResolver.Resolve(user);

// HARD tenant boundary - se o resource context vier com tenant diferente do claim, negue.
if (principal.TenantId != resource.TenantId)
{
return new AclDecisionDetails(
AclDecision.Denied,
AclReasonCode.DeniedWrongTenant,
EvaluatedAtUtc: _clock.UtcNow
);
}

var now = _clock.UtcNow;

var result = await _store.CheckAsync(new AclCheckRequest(
TenantId: resource.TenantId,
PrincipalIds: principal.AllPrincipalIds,
ResourceType: resource.Resource.Type.Value,
ResourceId: resource.Resource.Id,
Action: requirement.Action,
NowUtc: now
), ct);

return result;
}

public async Task<IReadOnlyDictionary<Guid, AclDecisionDetails>> AuthorizeBatchAsync(
ClaimsPrincipal user,
MultiResourceContext resources,
AclAction action,
CancellationToken ct = default)
{
var principal = _principalResolver.Resolve(user);

if (principal.TenantId != resources.TenantId)
{
return resources.ResourceIds.ToDictionary(
id => id,
_ => new AclDecisionDetails(AclDecision.Denied, AclReasonCode.DeniedWrongTenant, EvaluatedAtUtc: _clock.UtcNow)
);
}

var now = _clock.UtcNow;

return await _store.CheckBatchAsync(new AclBatchCheckRequest(
TenantId: resources.TenantId,
PrincipalIds: principal.AllPrincipalIds,
ResourceType: resources.ResourceType.Value,
ResourceIds: resources.ResourceIds,
Action: action.Value,
NowUtc: now
), ct);
}
}
public interface IAclClock { DateTimeOffset UtcNow { get; } }
public sealed class SystemAclClock : IAclClock { public DateTimeOffset UtcNow => DateTimeOffset.UtcNow; }

Store layer (SQL Server) + batch (TVP)

public sealed record AclCheckRequest(
Guid TenantId,
IReadOnlyList<Guid> PrincipalIds,
string ResourceType,
Guid ResourceId,
string Action,
DateTimeOffset NowUtc
);

public sealed record AclBatchCheckRequest(
Guid TenantId,
IReadOnlyList<Guid> PrincipalIds,
string ResourceType,
IReadOnlyList<Guid> ResourceIds,
string Action,
DateTimeOffset NowUtc
);

public interface IAclStore
{
Task<AclDecisionDetails> CheckAsync(AclCheckRequest req, CancellationToken ct);
Task<IReadOnlyDictionary<Guid, AclDecisionDetails>> CheckBatchAsync(AclBatchCheckRequest req, CancellationToken ct);
}

TVP types (SQL)

CREATE TYPE dbo.GuidList AS TABLE (Id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY);

Store Dapper

public sealed class SqlServerAclStore : IAclStore
{
private readonly Func<IDbConnection> _connFactory;

public SqlServerAclStore(Func<IDbConnection> connFactory) => _connFactory = connFactory;

public async Task<AclDecisionDetails> CheckAsync(AclCheckRequest req, CancellationToken ct)
{
using var conn = _connFactory();

// Principals -> TVP
var tvp = new DataTable();
tvp.Columns.Add("Id", typeof(Guid));
foreach (var p in req.PrincipalIds) tvp.Rows.Add(p);

var pParam = new DynamicParameters();
pParam.Add("@tenant_id", req.TenantId);
pParam.Add("@resource_type", req.ResourceType);
pParam.Add("@resource_id", req.ResourceId);
pParam.Add("@action", req.Action);
pParam.Add("@now_utc", req.NowUtc.UtcDateTime);

pParam.Add("@principal_ids", tvp.AsTableValuedParameter("dbo.GuidList"));

// Stored procedure ou query inline (prefira SP para plan stability)
var row = await conn.QueryFirstOrDefaultAsync<AclCheckRow>(
new CommandDefinition(
"dbo.acl_check_single",
pParam,
commandType: CommandType.StoredProcedure,
cancellationToken: ct));

if (row is null)
return new AclDecisionDetails(AclDecision.Denied, AclReasonCode.DeniedNoGrant, EvaluatedAtUtc: req.NowUtc);

return new AclDecisionDetails(
AclDecision.Allowed,
row.ReasonCode,
GrantId: row.GrantId,
MatchedPrincipalId: row.MatchedPrincipalId,
MatchedPrincipalType: row.MatchedPrincipalType,
EvaluatedAtUtc: req.NowUtc
);
}

public async Task<IReadOnlyDictionary<Guid, AclDecisionDetails>> CheckBatchAsync(AclBatchCheckRequest req, CancellationToken ct)
{
using var conn = _connFactory();

var principalTvp = new DataTable();
principalTvp.Columns.Add("Id", typeof(Guid));
foreach (var p in req.PrincipalIds) principalTvp.Rows.Add(p);

var resourceTvp = new DataTable();
resourceTvp.Columns.Add("Id", typeof(Guid));
foreach (var r in req.ResourceIds) resourceTvp.Rows.Add(r);

var pParam = new DynamicParameters();
pParam.Add("@tenant_id", req.TenantId);
pParam.Add("@resource_type", req.ResourceType);
pParam.Add("@action", req.Action);
pParam.Add("@now_utc", req.NowUtc.UtcDateTime);
pParam.Add("@principal_ids", principalTvp.AsTableValuedParameter("dbo.GuidList"));
pParam.Add("@resource_ids", resourceTvp.AsTableValuedParameter("dbo.GuidList"));

var rows = (await conn.QueryAsync<AclBatchRow>(
new CommandDefinition(
"dbo.acl_check_batch",
pParam,
commandType: CommandType.StoredProcedure,
cancellationToken: ct)))
.ToList();

// default deny
var result = req.ResourceIds.ToDictionary(
id => id,
id => new AclDecisionDetails(AclDecision.Denied, AclReasonCode.DeniedNoGrant, EvaluatedAtUtc: req.NowUtc)
);

foreach (var row in rows)
{
result[row.ResourceId] = new AclDecisionDetails(
AclDecision.Allowed,
row.ReasonCode,
GrantId: row.GrantId,
MatchedPrincipalId: row.MatchedPrincipalId,
MatchedPrincipalType: row.MatchedPrincipalType,
EvaluatedAtUtc: req.NowUtc
);
}

return result;
}

private sealed class AclCheckRow
{
public long GrantId { get; init; }
public AclReasonCode ReasonCode { get; init; }
public Guid MatchedPrincipalId { get; init; } // quem “bateu”
public string MatchedPrincipalType { get; init; } = default!;
}

private sealed class AclBatchRow : AclCheckRow
{
public Guid ResourceId { get; init; }
}
}


Store procedures (Single)

CREATE OR ALTER PROCEDURE dbo.acl_check_single
@tenant_id UNIQUEIDENTIFIER,
@principal_ids dbo.GuidList READONLY,
@resource_type VARCHAR(64),
@resource_id UNIQUEIDENTIFIER,
@action VARCHAR(32),
@now_utc DATETIME2
AS
BEGIN
SET NOCOUNT ON;

SELECT TOP 1
g.grant_id AS GrantId,
CAST(10 AS INT) AS ReasonCode, -- GrantedDirect/Group etc (você decide por join)
g.principal_id AS MatchedPrincipalId,
CAST('principal' AS VARCHAR(16)) AS MatchedPrincipalType
FROM acl_grant g
JOIN @principal_ids p ON p.Id = g.principal_id
WHERE g.tenant_id = @tenant_id
AND g.resource_type = @resource_type
AND g.resource_id = @resource_id
AND g.action = @action
AND g.valid_from <= @now_utc
AND (g.valid_to IS NULL OR g.valid_to >= @now_utc)
ORDER BY g.grant_id DESC;
END

Store procedures (Batch)

CREATE OR ALTER PROCEDURE dbo.acl_check_batch
@tenant_id UNIQUEIDENTIFIER,
@principal_ids dbo.GuidList READONLY,
@resource_ids dbo.GuidList READONLY,
@resource_type VARCHAR(64),
@action VARCHAR(32),
@now_utc DATETIME2
AS
BEGIN
SET NOCOUNT ON;

;WITH matches AS (
SELECT
g.resource_id,
g.grant_id,
g.principal_id
FROM acl_grant g
JOIN @principal_ids p ON p.Id = g.principal_id
JOIN @resource_ids r ON r.Id = g.resource_id
WHERE g.tenant_id = @tenant_id
AND g.resource_type = @resource_type
AND g.action = @action
AND g.valid_from <= @now_utc
AND (g.valid_to IS NULL OR g.valid_to >= @now_utc)
)
SELECT
m.resource_id AS ResourceId,
m.grant_id AS GrantId,
CAST(10 AS INT) AS ReasonCode,
m.principal_id AS MatchedPrincipalId,
CAST('principal' AS VARCHAR(16)) AS MatchedPrincipalType
FROM matches m;
END

Integrating into the AuthorizationHandler with ResourceContextResolver

public sealed class AclHandler : AuthorizationHandler<AclRequirement>
{
private readonly IHttpContextAccessor _http;
private readonly IResourceContextResolver _resourceResolver;
private readonly IAclEvaluator _evaluator;

public AclHandler(
IHttpContextAccessor http,
IResourceContextResolver resourceResolver,
IAclEvaluator evaluator)
{
_http = http;
_resourceResolver = resourceResolver;
_evaluator = evaluator;
}

protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context,
AclRequirement requirement)
{
var http = _http.HttpContext;
if (http is null) return;

var resource = _resourceResolver.Resolve(http, requirement);
var decision = await _evaluator.AuthorizeAsync(context.User, resource, requirement, http.RequestAborted);

if (decision.Decision == AclDecision.Allowed)
context.Succeed(requirement);

// opcional: anexar decisão no HttpContext para log/trace
http.Items["acl.decision"] = decision;
}
}

Cache + invalidation

  • Local memory cache: short TTL (e.g., 30-120s)
  • Event-driven invalidation: when grant/revoke occurs, publishes the event AclChanged(tenant, principalId, resourceType, resourceId?)
  • Bounded staleness: explicit by action risk (e.g., delete with shorter TTL)
public interface IAclCache
{
bool TryGet(string key, out AclDecisionDetails value);
void Set(string key, AclDecisionDetails value, TimeSpan ttl);
void InvalidateByPrefix(string prefix); // simples; ou por tags
}
public sealed class CachedAclEvaluator : IAclEvaluator
{
private readonly IAclEvaluator _inner;
private readonly IAclCache _cache;

public CachedAclEvaluator(IAclEvaluator inner, IAclCache cache)
{
_inner = inner;
_cache = cache;
}

public async Task<AclDecisionDetails> AuthorizeAsync(
ClaimsPrincipal user,
ResourceContext resource,
AclRequirement requirement,
CancellationToken ct = default)
{
var key = $"t:{resource.TenantId}:rt:{resource.Resource.Type}:rid:{resource.Resource.Id}:a:{requirement.Action}:p:{user.FindFirstValue("sub")}";
if (_cache.TryGet(key, out var cached)) return cached;

var decision = await _inner.AuthorizeAsync(user, resource, requirement, ct);

var ttl = requirement.Action switch
{
"delete" => TimeSpan.FromSeconds(15),
"update" => TimeSpan.FromSeconds(30),
_ => TimeSpan.FromSeconds(60),
};

_cache.Set(key, decision, ttl);
return decision;
}

public Task<IReadOnlyDictionary<Guid, AclDecisionDetails>> AuthorizeBatchAsync(
ClaimsPrincipal user,
MultiResourceContext resources,
AclAction action,
CancellationToken ct = default)
{
// geralmente cachear batch dá mais complexidade (fragmenta cache e eleva cardinalidade).
// recomendo cachear apenas single e otimizar batch via SQL/TVP.
return _inner.AuthorizeBatchAsync(user, resources, action, ct);
}
}

Observability: reason codes + tracing + logs

public sealed class AclDecisionLoggingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<AclDecisionLoggingMiddleware> _logger;

public AclDecisionLoggingMiddleware(RequestDelegate next, ILogger<AclDecisionLoggingMiddleware> logger)
{
_next = next;
_logger = logger;
}

public async Task Invoke(HttpContext ctx)
{
await _next(ctx);

if (ctx.Items.TryGetValue("acl.decision", out var value) && value is AclDecisionDetails d)
{
_logger.LogInformation("ACL decision {Decision} reason {Reason} grantId {GrantId}",
d.Decision, d.Reason, d.GrantId);
}
}
}

DI wiring

builder.Services.AddHttpContextAccessor();

builder.Services.AddAuthorization();
builder.Services.AddSingleton<IAuthorizationPolicyProvider, AclPolicyProvider>();

builder.Services.AddScoped<IResourceContextResolver, DefaultResourceContextResolver>();
builder.Services.AddScoped<IPrincipalContextResolver, KeycloakPrincipalContextResolver>();

builder.Services.AddSingleton<IAclClock, SystemAclClock>();

builder.Services.AddScoped<IAclStore, SqlServerAclStore>(sp =>
{
var cs = builder.Configuration.GetConnectionString("AuthorizationDb")!;
return new SqlServerAclStore(() => new SqlConnection(cs));
});

builder.Services.AddScoped<IAclEvaluator, AclEvaluator>();
builder.Services.Decorate<IAclEvaluator, CachedAclEvaluator>(); // se usar Scrutor

builder.Services.AddSingleton<IAclCache, MemoryAclCache>(); // implemente via IMemoryCache

builder.Services.AddScoped<IAuthorizationHandler, AclHandler>();

app.UseMiddleware<AclDecisionLoggingMiddleware>();

Endpoint usage

[HttpGet("/tenants/{tenantId}/documents/{documentId:guid}")]
[AclResource("document", "documentId")]
[Authorize(Policy = "acl:document:read")]
public async Task<IActionResult> GetDocument(Guid documentId) { ... }

6. Design trade-offs

ApproachGainGive up
RBAC onlySimplicityNo object-level control
ACL in JWTZero DB thisToken bloat, revocation bugs
Central auth serviceConsistencyLatency, availability coupling
Local enforcement (this model)Determinism, scaleSchema and query complexity
Strong cachingLatencyRevocation lag

Every ACL system chooses where complexity lives. This design pushes complexity into schema and queries, not runtime branching.


7. Common mistakes and misconceptions

"We can store permissions in roles"

Why it happens:

RBAC is familar, easy to explain and supported out of the box by most IAM tools.

Problem:

Roles are forced to encode contextual information they were never designed to represent:

  • resource ownership
  • delegation
  • temporal constraints
  • tenant-specific nuance

Over time, roles explode combinatorially and become impossible to reason about or evolve safely.

Avoidance:

Separate concerns strictly:

  • identity and coarse grouping belong to IAM
  • authorization facts belong to an ACL store
  • roles may grant capabilities, never object-level access

"We'll cache forever and invalidate later"

Why it happens:

Authorization checks sit on latency-sensitive paths, and caching feels like the easiest fix.

Problem:

  • Revocations become unsafe or delayed
  • Security prosture depends on cache correctness
  • No clear semantics about what may be stale

The system silently trades correctness for performance without explicit acceptance.

Avoidance:

Define cache semantics explicitly:

  • short, bounded TTLs
  • event-driven invalidation
  • different staleness budgets per action risk Authorization caching must be designed, not improvised.

"We only need one grant table"

Why it happens:

Early implementations optimize for minimal schema complexity.

Problem:

A single-table model cannot express:

  • delegation
  • inheritance
  • audit trails
  • access reviews

As requirements grow, teams bolt metadata onto rows until queries become unmaintainable.

Avoidance:

Model intent explicitly:

  • separate grant, delegation, inheritance and audit concerns
  • accept schema complexity early to avoid logic complexity later

"Authorization belongs in middleware only"

Why it happens:

Centralization feels safer and more maintainable.

Problem:

Middleware lacks sufficient resource context:

  • no parent-child relationships
  • no domain-specific resolution
  • no batch semantics

Authorization decision become either incorrect or overly permissive.

Avoidance:

Split responsibilities:

  • intent declared at the edge (attribute / metadata)
  • context resolved inside the service
  • decisions made by a dedicated evaluator with full data

"We trust resource identifier coming from the request"

Why it happens:

Route parameters look explicit and validated.

Problem:

Authorization is evaluated against unverified context, enabling:

  • ID tampering
  • cross-tenant access
  • confused-deputy scenarios

Avoidance:

Treat resource resolution as part of authorization:

  • validate tenant ownership explicitly
  • never trust client-supplied identifiers as authorization truth
  • bind resource identity server-side before evaluation

"A deny result does not need to be explainable"

Why it happens:

Teams optimize for allow-path performance and ignore diagnostics.

Problem:

  • No auditability
  • No access reviews
  • Engineers cannot distinguish misconfiguration from abuse

Denials become opaque and operationally toxix

Avoidance:

Authorization decisions must emit structured metadata:

  • reason codes
  • matched principal
  • grant source (direct, group, delegation)

If you cannot explain a decision, you do not control it.


"We should put all permissions into the JWT token"

Why it happens:

Token-based authorization feels fast, stateless and elegant.

Problem:

  • Token bloat and size limits
  • Revocation becomes impossible or delayed
  • Permission churn forces token refresh storms
  • ACL evolution breaks backward compatibility

The token becomes a fragile, high-cardinality permission cache.

Avoidance:

Keep JWTs lean:

  • identity
  • tenant
  • coarse grouping

All fine-grained authorization must be resolved at request time against an authoritative store.


“Authorization logic can evolve without governance”

Why it happens:

Permissions feel like application details rather than contracts.

Problem:

  • breaking changes to permission names
  • silent access loss or escalation
  • cross-service inconsistency

Over time, authorization becomes tribal knowledge.

Avoidance:

Treat permission vocabulary as an API:

  • versioned
  • reviewed
  • backward-compatible

Permission evolution must be intentional and auditable.


"Group expasion is free and harmless"

Why it happens:

IAM systems make groups look cheap and convenient.

Problem:

  • Transitive group expansion explodes cardinality
  • Authorization checks slow down unpredictably
  • Cycles and nesting create correctness risks

What worked for dozens of users fails at thousands.

Avoidance:

Control group expansion explicitly:

  • flatten groups into internal principals
  • cache expansion results with bounded TTL
  • avoid deep or unbounded hierarchies

8. Operational and production considerations

Observability

  • Decision outcome counters (allow/deny)
  • Reason code cardinality
  • Cache hit/miss ratios
  • p95 authorization latency

Auditing

  • Immutable grant history
  • Access reviews by principal or resource
  • "Why does user X have access Y?"

Failure modes

  • DB pressure from list endpoints
  • Parameter sniffing causing scans
  • Cache stampedes on invalidation

9. When NOT use this

Do not build this system if:

  • Roles map cleanly to access
  • Resources are not individually sensitive
  • Authorization curn is low
  • Auditability is not required
  • Team size and system lifespan are small

ACLs are power tools. They can hurt you if unncessary.


10. Key takeaways

  • ACL systems operate on explicit authorization facts, not inferred roles or rules
    • Every access decision must be traceable to a concrete grant.
  • Identity establishes who the caller is; authorization determines what they may do
    • Blurring this boundary creates token bloat, revocation risk and governance failure.
  • Your authorization schema is part of your security boundary
    • Table design, indexes and query shapes directly impact correctness, latency and auditability.
  • Authorization decisions must be explainable by construction
    • If a decision cannot produce a reason code and a source, it is operationally incomplete.
  • Caching is correctness problem before it is a performance optimization
    • Staleness, invalidation and action risk must be explicitly modeled and accepted.
  • Every permission must have ownership, scope and lifecycle semantics
    • Grants without an owner, purpose or expiry inevitably violate least privilege.
  • If authorization state cannot be reviewed, reconstructed and audited it is not under control
    • Security that cannot be inspected is indistinguishable from security that does not exist.

11. High-Level Overview

Visual representation of the end-to-end ACL authorization flow, highlighting Keycloak identity scope, in-service intent and context resolution, SQL-based permission evaluation, and cache invalidation with explainable decisions.

Scroll to zoom • Drag to pan
Enterprise-Grade ACL Authorization (Keycloak + .NET Enforcement + SQL Server) — End-to-End ViewEnterprise-Grade ACL Authorization (Keycloak + .NET Enforcement + SQL Server) — End-to-End ViewEnterprise System BoundaryIdentity & Coarse Context (Keycloak)Domain Services (ASP.NET Core)Local enforcement (deterministic)Service A (e.g., Documents)Service B (e.g., Accounts)Authorization Platform (SQL Server)Authoritative permission storeAsync Invalidation & Governance (optional but recommended)KeycloakOIDC/OAuth2Realm Roles / Groups(low cardinality)Token Issuer(JWT)API Gateway(optional)Endpoint LayerIntent Declaration[Authorize(\"acl:document:read\")][AclResource(\"document\",\"documentId\")]Resource Context Resolver(route/body -> ResourceContext)(tenant hard boundary)Dynamic Policy Provider(no policy explosion)AuthZ Handler(AclRequirement/Handler)ACL Evaluator(principal expansion + single/batch)Cache LayerLocal TTL + stampede protection(optional Redis)Decision OutputAllow/Deny + ReasonCodesExplainability payloadObservabilityOTel traces + metrics + structured logsreason_code, grant_sourceEndpoint LayerIntent DeclarationResource Context ResolverAuthZ HandlerACL EvaluatorCache LayerDecision OutputObservabilitySQL ServerAuthorization DBSchema Suite (core)- acl_grant- principal mapping- delegation- inheritance edges- audit/event tablesStored Procedures / Query Shapes- single check- batch check (TVP)- access reviews- revocation workflows(plan stability)Indexes & Performance(seeks > scans)- tenant + principal + resource + action- include validity- parameter sniffing guardrailsMessage Bus(Azure Service Bus / Kafka)ACL Change Publisher(grant/revoke/delegation)produces AclChanged eventsCache Invalidation Consumers(per service)update local/redis cache keysPermission Vocabulary Registry(resource_type + action + dimensions)versioned + reviewedAccess Review & Audit APIwho-has-what-why-sinceretention + exportBreak-glass Admin Controlscontrolled + auditedno bypass chaosUser(human)Service Principal(machine)Tokens remain small & stable.Never embed object-level ACL in JWT:- revocation correctness- churn tolerance- token bloat avoidanceEvaluator responsibilities:- principal expansion (user + groups + service principal)- strict tenant boundary (zero leakage)- correctness semantics for caching- explainability payloadBatch authorization must be first-class:- TVPs- set-based query shapes- stable plansAvoid per-item checks (N+1).Revocation correctness strategy:- bounded TTL- event-driven invalidation- optional principal_version keyingExplicit semantics per action risk (reads vs writes).Most senior pitfall:"Authorization can evolve without governance."Treat permission vocabulary as an API contract:- versioned- reviewed- backward compatibleRequest (no token)Request (no token)Redirect / Token RequestResolve groups/rolesProvide coarse contextJWT (sub, tenant, groups)NO ACL facts embeddedRoute to endpointIntent declaredGET /documents?...Policy nameacl:document:readRequirement(Action=read, ResourceType=document)Resolve ResourceContext(resourceId, tenantId, parent?)Resolve MultiResourceContext(resourceIds from query result OR prefetch)Evaluate (P,A,R,C,T)Batch authorize (TVP)set-based queryCache lookup(key includes tenant + principal_version + resource + action)Hit/MissStore decisionbounded TTL + risk-based TTLIf miss: querysingle check (seek-friendly)Decision row(s)(grantId, principal match, source, validity)Batch checkTVP(resource_ids, principal_ids)Map of allowed resourceIdsGrants + delegation + mappingSP-based stable queryIndex seeks / plan stabilityAllow/DenyReasonCode + GrantSourceFilter/Mask results(no N+1 checks)Log/Trace/Metric(with low-cardinality tags)Enforce decision(403 if denied)Requirement(Action=list/read, ResourceType=document)Grant/Revoke(write transaction + audit)Publish AclChanged(tenant, principal, resourceType, resourceId?, version++)Consume eventInvalidate keys / bump versionInvalidate keys / bump versionAccess review querieswho-has-what-why-sinceImmutable audit trailretention strategyShared vocabulary contract(no breaking changes)Versioned permissionsreviewed & backward-compatibleAdmin loginAll break-glass operations auditedDecision TupleP = Principal (user/group/service)A = Action (controlled vocabulary)R = Resource (instance-level)C = Context (tenant + scope + delegation)T = Time (validity window)Non-negotiables- Keycloak: identity & coarse context only- No ACL in JWT (avoid bloat + revocation bugs)- Enforcement local in services (deterministic)- SQL Server authoritative store (query-shaped, indexed)- Batch auth for list endpoints (TVPs, set-based)- Explainability: reason codes + sources- Caching with explicit correctness semantics- Governance: permission vocabulary as a contractplantuml-src dLbjZ-Es4VwkNq5iWDJ6sklcaYAeWKjWUUzbcttBsjsYxHO7Mg9jrhBeYDBk-Oe0_H7zXVqbVMP8IRJNv-tb3hYpfU5mE2_FF4ENkioKU9ENgjWKsgh-kqAcIep6bs9KbKjJw4-or2ONdP_KDicCN8bJSNRzPeerSrCaQgto4eycFr-AYIeULT4L__ttV_0sxPUcZ__4lLPFKVGxyVfpV_3kNjjAPiAMsqpd2z5HZK7TeoljIkSRMSYrcCbajIXCbQTZavb2b8NCBLv1Ko1bbp8rJxH5MLGgUC4h4rFagX3VV1UyIDLSLbdvrkJbjLmhSK6M7Nu_qP-K-ERRA7YxWLLoeIOubnA5IagPBx9mNnnNbTkD4gQ0juCNew8mJ-ueN_qXfh_1ozWKgIhysrTlw6-uQxEH-5SaHBpdcQ_UyXzwSq3Dhw6Yt9IVr_7zURolO_p7qQjNt-_goDH28GiEQ_cU_dxUakD9CqfAKzYZGf94nCcVhIeUyiwoMikyUoAa5VIaVajvgnCbRWkT9teZCuYkPR94Q9pmvFObKzxhnRAVgKU57DxQKY4VwPYos1vTs0Rgf2r1CN4QOg_1cV3N2Lm8J_cC4YSNAL9RbrlnjHWRMM3T63ceFfPjgJhhVngpyqMG5YUrn4D-St4-7jnGhRzoqlxD_eex9RErk3EPic8WtY6A6mi_PUP99B98TIup65FlwDxlwvYQbSh5XRKLX-DFVvawUNxk7cF9hpl7F3ThgNFXOsH5PpIvPVmPcq9r7_9Bam0uL016dLJ1bMkTQrlgn4VmVkpSjsFFwFP2l9EbUf9Rc6Cs17GoSolmxiejQ4ofysG4V3fRdFN4kKagsjBwJHwJaNVrpZO0mOtHiEnIRkdW127uTgwI31L2cpxaVw_HLdKUJcII3LElUrWecJwSTFz1CabsfwofS598rH8F9xtsomNBiZbkcz5pUseTJSBGW-oHOuAubMemC-bMz7yGjQGNXBixq2bX_18nRtBS7H_2u_BZWVtEjxbSwqJScamdMvIPUTGfxvORiN4FrSTDPYnSuNGvqGEwo4r_4-zbdcQiWLtoIwKBZlt0lt1A_9T3Mj3UtZpAh98006ZPr9LFTg1lKCCx5LQJz60cosJfDDAQKVHStnXeeUhmkeoSJYz9HIdN6uM6kYa0fOcBTPDfS5cgVVAmYaFEKubcUswgSbEL3_aeGyqDpbM-XNvKfJNvsAGAnVW6FaIjo9ccKaJURb78AMkljHmo_cO6B7pqgm08Ku0OkYSLswbOgxBGYUMZ55LILeLAHMOMsAxWlJyasBmd5cYtvGUNBRmZjBhJ_FgPAehhAXeb2V8eAABujnLHaFJn5oHzaBln2_aMPbNyGbOr-H8VJPSszl6BiT-9PFn28EiWn3u8-xYwo-vkCra2GTUY4n0vv9eJAY7qY1mcLBLiaCK9NPpFunfOKrdAcRGKuaP7eyBlSnwx8F-IqVDdW3o17zPIJ2eDtj597CBt1V3m0oSOVMbhTIqt6vGeFKmLQ8Q7qhxG-LAHrJd2hT85rGQKLAak1sZHo1eOYZxcJE4jdrj21qm9gnAL8kcfwVsC_hGLawNSE8qE7mGK92lwpa3XlehEzFxMsuv2ib3z21hCo_0H2C7kU3B5QevofawAsYM_-l2vOk0TtORFBBp8K_KHTdqjRbL1iQFZGezLQcN53y8c038smKFsQU2wKsZozN0A4rl79La5j4BQwfJH0Fuc5aSTLTXSp-V4dHSLqB-GEhE-vc09dZ_FCRlD4xncTUx4Nujt1dcHSsnQ89nL9PtDh87YgKhTwT_Tt7ia-ALI5VHTmPEaEAx8OQDFY8jeuAIY4FqavolfLkD1TA10nqjy0TUhPfcsI-uXd5e32ijA3TfCWk-0scb5G8WsunQcWZF87RnH4Xqiy9s30tOiEdJ1eIPdEhknIRM1Y0Aeect02dG4C2fQlrBRsi6jelsjRjkYl8U6MGKut0BF5c0_1T6QEj0VUCoeeuqFgOQpQI5b2M927t6-Kv-hodKELlhCaIwfxrWGCHrHRH5bUiYVbgQ_bBR_j9Gb_jdsKIMKbuKYO7OxeybYtF70IohszSVeBQl-8fFOP9I2oZ5g5oQ3znBtAMDJkQXL-f23ISos6v9Fbj8ujx4MLiAP-U8yyPOgSSXpBvdgQlJe6feYH1z4YTYY60gY8ig2WiASaaXjDvhSlY0GyIkIQIRi8HC3yWTIsf5clp1gP7Y1vzcyWBkPMF23GK4yFMhUat10iY7lIPX8tEYGkAOOXKA1sM3gecEhMSzZIS_lWyIvlk7BWZdIoWgrdgaKzHj5F3aMc8fAOUOx19y_4o0ohxThcMN88CkGV1PTexmAfqUOsJ_X0ZzMqIQGXoN3Q0T3qIN8KpaI4Y-IPLNaS2qwD44Cdh2RnGnEAuLyD2WZUauZATdu_-J4K0JCd-XuZVnrQHs_esZYwmavHOjODYp3tE9OJSDdKkOpy60jJ0iz6wQmERQSqqt4yp7X9AebQLLDVttQUOeCnankUqtolORWJu4DhsjToCbcRTzD0KJiWopRcmi2bBb8wnoXJ-x-vSTkLAiWTKoYeSnJ9RI1szweTzSRzwROsRqbESU88UZGD3DcLMsm5M2nRbJsG6_xuA7iK8_hHbvfQCTxq88hG6YujoSeU7in5uIlG-f1XATXqnVSRFlpGYEEsHR5xfUn2iTbXfYLOI4nwBQr4B5sv2NoOhGYwFPODhdj4vjAVMcu_QDGnkMKwxraANqKhlqFn2wTsB6iFdX3BmlprA5QTfsG0nUGB92Qdd2Ex3NCu72ac3LH3hFUXvm7H1p13KE5jYj_9Zm8rZVJ2LIquyr3tautMCrkdB0PKRk0bjEeWPMNPZ6OqjmokEAX1UTvqZWozFM3AmgGluNjxgbeIzDVQmQ7w7pt-s-5dkD9ha5Dzh7DpOGDxwUasw2dQdQyLfRWwj1ysQceUPnw9AK1ouDC8zp5IaidAUiqhmTkqVca2YCoz3Vm8P8BK12Am0LBcjfm5EOIb6Xzdh6VpuU_7Gh1VSe6mP1o0V_zOaHyzsOg1ZK0sH_Fpivs4F0ubf4XWwE0zWAUNLLPgO-16bnPcBMhGOemvCND7KAbvefk1mu2NCp-aFN5JZqJM5NwAd0bVGXrujQL3pcMjInEfpOeJ_hQFLZSLt93sIcfdB1LS9JF5DvRdO7a3wwaNVaJMdT3SttwZRF4rha_CsK9Rq1zFSZj7jLXtBf2UioKcEl2bls4U1ZL0av3ZNhF2tGpJrDEluOxmYyRZQXXq-3PrtGbwimG7PZKFVl2nBrhASCW76MPCHpLqD3ylI0n8WtkU6uWi2beQkNV7HgEpVoqkwU1HfLXFJm4eqLpknO4kDT4ZZAw5yJnHu2nwtIdfzs8TBgA2cQEOJrrkA4bsdcv0zpD6pTrK35NwqszmMSMnYyltCFAdK7cxY278yI3jiMMSYNe7uHVT8X-McZy3mOGgEgBPf9ypWDW5-4VtOxf16DA2uyqX_bGKMzovCwAHoiublOBSYoOQWVri7zqYP-TTZDg55uMS65ekceuF5D5F9ev3NJpFAIh2RgqU6pdG1wMS4HNoJCQY8Wi99nmgEST5HJF-sOER4k4x_hSI0YcJZ-qFQ6TzZ6jR-1-5Ej-cDtCU6KiEJlNQ48RNSu1HSDexzuf0Kb16X06Kwy6BHQBnfDd9z4K5fTXjGPdmuI7rNI7Nv-IKgOPPDsKm5PJ9dchHM1r6zjsKjmVnDpqcPc5plUbV0IPiZZN-edLm5u-dgtdqyFHlbYlAmTsB4RGeRDmKgvBuQNaQwzKhLuZo-UQBcbrlh9VqePZtt7ZiEF6TUkCWzOP-tONDnqdvgOI1zWGFAYRHFkalSHiFjLCBdxfi9VyU-9H8V-J8-TZzFfrItIdrGRU9aWNhzkV-nmL6p0H6zJtCj488evVq4raS-VG9Y2QFaIQU-MEpYrVERg15QC6tZO_oJLpYaqCNyC4zqpH599J3PZkrFGQrP2dvWaD5-PVcxoVguKfDLzRaltz9aU7GhS_1EvEyyBasHQYroQOdaM7Xs0_49-6a_Ii8cBSt_bbds-bQAbjVe3R_NaDykrrRyq0_8KntnAB3ZVuFZTuC3JDzvOfBMnP0pFL7JvfcIw1wpH4WKtReR13uNw34FGR15De3WMnYB5ZlOmgJ7mP-W-oP4Ytw3qy0ZODpZ1ok2Jx7m00?>Enterprise-Grade ACL Authorization (Keycloak + .NET Enforcement + SQL Server) — End-to-End ViewEnterprise-Grade ACL Authorization (Keycloak + .NET Enforcement + SQL Server) — End-to-End ViewEnterprise System BoundaryIdentity & Coarse Context (Keycloak)Domain Services (ASP.NET Core)Local enforcement (deterministic)Service A (e.g., Documents)Service B (e.g., Accounts)Authorization Platform (SQL Server)Authoritative permission storeAsync Invalidation & Governance (optional but recommended)KeycloakOIDC/OAuth2Realm Roles / Groups(low cardinality)Token Issuer(JWT)API Gateway(optional)Endpoint LayerIntent Declaration[Authorize(\"acl:document:read\")][AclResource(\"document\",\"documentId\")]Resource Context Resolver(route/body -> ResourceContext)(tenant hard boundary)Dynamic Policy Provider(no policy explosion)AuthZ Handler(AclRequirement/Handler)ACL Evaluator(principal expansion + single/batch)Cache LayerLocal TTL + stampede protection(optional Redis)Decision OutputAllow/Deny + ReasonCodesExplainability payloadObservabilityOTel traces + metrics + structured logsreason_code, grant_sourceEndpoint LayerIntent DeclarationResource Context ResolverAuthZ HandlerACL EvaluatorCache LayerDecision OutputObservabilitySQL ServerAuthorization DBSchema Suite (core)- acl_grant- principal mapping- delegation- inheritance edges- audit/event tablesStored Procedures / Query Shapes- single check- batch check (TVP)- access reviews- revocation workflows(plan stability)Indexes & Performance(seeks > scans)- tenant + principal + resource + action- include validity- parameter sniffing guardrailsMessage Bus(Azure Service Bus / Kafka)ACL Change Publisher(grant/revoke/delegation)produces AclChanged eventsCache Invalidation Consumers(per service)update local/redis cache keysPermission Vocabulary Registry(resource_type + action + dimensions)versioned + reviewedAccess Review & Audit APIwho-has-what-why-sinceretention + exportBreak-glass Admin Controlscontrolled + auditedno bypass chaosUser(human)Service Principal(machine)Tokens remain small & stable.Never embed object-level ACL in JWT:- revocation correctness- churn tolerance- token bloat avoidanceEvaluator responsibilities:- principal expansion (user + groups + service principal)- strict tenant boundary (zero leakage)- correctness semantics for caching- explainability payloadBatch authorization must be first-class:- TVPs- set-based query shapes- stable plansAvoid per-item checks (N+1).Revocation correctness strategy:- bounded TTL- event-driven invalidation- optional principal_version keyingExplicit semantics per action risk (reads vs writes).Most senior pitfall:"Authorization can evolve without governance."Treat permission vocabulary as an API contract:- versioned- reviewed- backward compatibleRequest (no token)Request (no token)Redirect / Token RequestResolve groups/rolesProvide coarse contextJWT (sub, tenant, groups)NO ACL facts embeddedRoute to endpointIntent declaredGET /documents?...Policy nameacl:document:readRequirement(Action=read, ResourceType=document)Resolve ResourceContext(resourceId, tenantId, parent?)Resolve MultiResourceContext(resourceIds from query result OR prefetch)Evaluate (P,A,R,C,T)Batch authorize (TVP)set-based queryCache lookup(key includes tenant + principal_version + resource + action)Hit/MissStore decisionbounded TTL + risk-based TTLIf miss: querysingle check (seek-friendly)Decision row(s)(grantId, principal match, source, validity)Batch checkTVP(resource_ids, principal_ids)Map of allowed resourceIdsGrants + delegation + mappingSP-based stable queryIndex seeks / plan stabilityAllow/DenyReasonCode + GrantSourceFilter/Mask results(no N+1 checks)Log/Trace/Metric(with low-cardinality tags)Enforce decision(403 if denied)Requirement(Action=list/read, ResourceType=document)Grant/Revoke(write transaction + audit)Publish AclChanged(tenant, principal, resourceType, resourceId?, version++)Consume eventInvalidate keys / bump versionInvalidate keys / bump versionAccess review querieswho-has-what-why-sinceImmutable audit trailretention strategyShared vocabulary contract(no breaking changes)Versioned permissionsreviewed & backward-compatibleAdmin loginAll break-glass operations auditedDecision TupleP = Principal (user/group/service)A = Action (controlled vocabulary)R = Resource (instance-level)C = Context (tenant + scope + delegation)T = Time (validity window)Non-negotiables- Keycloak: identity & coarse context only- No ACL in JWT (avoid bloat + revocation bugs)- Enforcement local in services (deterministic)- SQL Server authoritative store (query-shaped, indexed)- Batch auth for list endpoints (TVPs, set-based)- Explainability: reason codes + sources- Caching with explicit correctness semantics- Governance: permission vocabulary as a contract