Skip to content

Resource Authorization

Resource authorization is a concept in software development that involves controlling access to specific resources or functionalities within an application. It ensures that only authorized users can perform certain actions or access certain data.

For example, assume we have project management software. A user can be an Owner for one project and don't have access at all to another project. The authorization logic is centered around resources and permissions and not about user roles necessarily.

You can create a hierarchical structure for authorization that allows for efficient management and fine-grained control over access to resources. This is particularly useful in complex applications where resources and users need to be organized in a meaningful way.

TIP

💡 Keycloak supports User-Managed Access (UMA).

TIP

💡The other benefit of using Keycloak as Authorization Server - you can change authorization rules at runtime without the need of redeploying your code.

Workspaces API Overview

The Workspaces API includes endpoints for creating, listing, reading, and deleting workspaces, as well as managing users within those workspaces.

INFO

Workspaces: This term is used in the context of collaborative applications. A workspace is a shared environment where a group of users can access and manipulate a set of resources. For example, in a project management application, each project could be considered a workspace. Access to the project (and its associated tasks, files, etc.) can be controlled at the workspace level.

Authorization Use Cases

Here are use cases implemented in this example:

  1. Global access to "Workspaces" functionality. Only users with roles - "Admin" or "Reader" can access API.
  2. Admins can manage workspaces - get, create, delete, add/remove users
  3. Only workspace members get see a workspace details
  4. Workspace members get see each other.
  5. Everyone, including anonymous users, can get a details about "public" workspace, but anonymous users can't see workspace's members.

Endpoints

/workspaces

  • GET: Lists all workspaces. Returns an array of strings.
  • POST: Creates a new workspace. Requires a JSON body specifying workspace details.

/workspaces/{id}

  • GET: Retrieves details of a specific workspace identified by {id}.
  • DELETE: Deletes a workspace identified by {id}.

/my/workspaces

  • GET: Lists all workspaces associated with the authenticated user.

/workspaces/{id}/users

  • GET: Lists all users within a specific workspace identified by {id}.
  • POST: Adds a user to a workspace. Requires a JSON body with user details.
  • DELETE: Removes a user from a workspace identified by {id} and a user email specified in the query.

Data Models

Workspace

  • Type: Object
  • Properties:
    • name: String
    • membersCount: Integer (nullable)

User

  • Type: Object
  • Properties:
    • email: String

Code

Setup DI:

cs
using Keycloak.AuthServices.Authentication;
using Keycloak.AuthServices.Authorization;
using Keycloak.AuthServices.Common;
using Keycloak.AuthServices.Sdk;
using Keycloak.AuthServices.Sdk.Kiota;
using Microsoft.AspNetCore.Authentication.JwtBearer;
using OpenTelemetry;
using OpenTelemetry.Metrics;
using OpenTelemetry.Trace;
using ResourceAuthorization;
using KeycloakAdminClientOptions = Keycloak.AuthServices.Sdk.Kiota.KeycloakAdminClientOptions;

var builder = WebApplication.CreateBuilder(args);
var services = builder.Services;

services.AddProblemDetails();
services.AddApplicationSwagger();

builder.Logging.AddOpenTelemetry(logging =>
{
    logging.IncludeFormattedMessage = true;
    logging.IncludeScopes = true;
});

builder.Services.ConfigureHttpClientDefaults(http => http.AddStandardResilienceHandler());

services
    .AddOpenTelemetry()
    .WithMetrics(metrics =>
        metrics
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddKeycloakAuthServicesInstrumentation()
    )
    .WithTracing(tracing =>
        tracing
            .AddAspNetCoreInstrumentation()
            .AddHttpClientInstrumentation()
            .AddKeycloakAuthServicesInstrumentation()
    )
    .UseOtlpExporter();

services.AddControllers(options => options.AddProtectedResources());

services
    .AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
    .AddKeycloakWebApi(builder.Configuration);

services
    .AddAuthorization()
    .AddAuthorizationBuilder()
    .AddDefaultPolicy("", policy => policy.RequireRealmRoles("Admin", "Reader"));

services
    .AddKeycloakAuthorization()
    .AddAuthorizationServer(builder.Configuration);

var adminSection = "KeycloakAdmin";

var adminClient = "admin";
var protectionClient = "protection";

AddAccessTokenManagement(builder, services);

services
    .AddKiotaKeycloakAdminHttpClient(builder.Configuration, keycloakClientSectionName: adminSection)
    .AddClientCredentialsTokenHandler(adminClient);

services
    .AddKeycloakProtectionHttpClient(
        builder.Configuration,
        keycloakClientSectionName: KeycloakProtectionClientOptions.Section
    )
    .AddClientCredentialsTokenHandler(protectionClient);

services.AddScoped<WorkspaceService>();

var app = builder.Build();

app.UseStatusCodePages();
app.UseExceptionHandler();

app.UseOpenApi();
app.UseSwaggerUi(ui => ui.UseApplicationSwaggerSettings(builder.Configuration));

app.UseAuthentication();
app.UseAuthorization();

app.MapControllers().RequireAuthorization();

app.Run();

void AddAccessTokenManagement(WebApplicationBuilder builder, IServiceCollection services)
{
    services.AddDistributedMemoryCache();
    services
        .AddClientCredentialsTokenManagement()
        .AddClient(
            adminClient,
            client =>
            {
                var options = builder.Configuration.GetKeycloakOptions<KeycloakAdminClientOptions>(
                    adminSection
                )!;

                client.ClientId = options.Resource;
                client.ClientSecret = options.Credentials.Secret;
                client.TokenEndpoint = options.KeycloakTokenEndpoint;
            }
        )
        .AddClient(
            protectionClient,
            client =>
            {
                var options =
                    builder.Configuration.GetKeycloakOptions<KeycloakProtectionClientOptions>()!;

                client.ClientId = options.Resource;
                client.ClientSecret = options.Credentials.Secret;
                client.TokenEndpoint = options.KeycloakTokenEndpoint;
            }
        );
}

Protected Resource are configured based on ProtectedResourceAttribute, attributes are applied based on hierarchy.

cs
namespace ResourceAuthorization.Controllers;

using System.Collections.Generic;
using System.Threading.Tasks;
using Keycloak.AuthServices.Authorization;
using Microsoft.AspNetCore.Authorization;
using Microsoft.AspNetCore.Mvc;
using NSwag.Annotations;
using ResourceAuthorization.Models;

#region WorkspaceAPI
[ApiController]
[Route("workspaces")]
[OpenApiTag("Workspaces", Description = "Manage workspaces.")]
[ProtectedResource("workspaces")]
public class WorkspacesController(WorkspaceService workspaceService) : ControllerBase
{
    [HttpGet(Name = nameof(GetWorkspacesAsync))]
    [OpenApiOperation("[workspace:list]", "")]
    [ProtectedResource("workspaces", "workspace:list")]
    public async Task<ActionResult<IEnumerable<string>>> GetWorkspacesAsync()
    {
        var workspaces = await workspaceService.ListWorkspacesAsync();

        return this.Ok(workspaces.Select(w => w.Name));
    }

    [HttpGet("public")]
    [OpenApiIgnore]
    [AllowAnonymous]
    public async Task<IActionResult> GetPublicWorkspaceAsync() =>
        await this.GetWorkspaceAsync("public");

    [HttpGet("{id}", Name = nameof(GetWorkspaceAsync))]
    [OpenApiOperation("[workspace:read]", "")]
    [ProtectedResource("workspaces__{id}", "workspace:read")]
    public async Task<IActionResult> GetWorkspaceAsync(string id)
    {
        var workspace = await workspaceService.GetWorkspaceAsync(id);

        return this.Ok(workspace);
    }

    [HttpPost("", Name = nameof(CreateWorkspaceAsync))]
    [OpenApiOperation("[workspace:create]", "")]
    [ProtectedResource("workspaces", "workspace:create")]
    public async Task<IActionResult> CreateWorkspaceAsync(Workspace workspace)
    {
        await workspaceService.CreateWorkspaceAsync(workspace);

        return this.Created();
    }

    [HttpDelete("{id}", Name = nameof(DeleteWorkspaceAsync))]
    [OpenApiOperation("[workspace:delete]", "")]
    [ProtectedResource("workspaces__{id}", "workspace:delete")]
    public async Task<IActionResult> DeleteWorkspaceAsync(string id)
    {
        await workspaceService.DeleteWorkspaceAsync(id);

        return this.NoContent();
    }
}
#endregion WorkspaceAPI

The idea is to define Keycloak Protected Resources during the creation of a domain object (i.e.: workspace). Protected Resources are the base for enforcement of the authorization rules.

cs
namespace ResourceAuthorization;

using Keycloak.AuthServices.Sdk.Kiota.Admin;
using Keycloak.AuthServices.Sdk.Kiota.Admin.Models;
using Keycloak.AuthServices.Sdk.Protection;
using Keycloak.AuthServices.Sdk.Protection.Models;
using Keycloak.AuthServices.Sdk.Protection.Requests;
using ResourceAuthorization.Models;

public class WorkspaceService(
    KeycloakAdminApiClient adminApiClient,
    IKeycloakProtectedResourceClient protectedResourceClient,
    IKeycloakPolicyClient policyClient,
    IHttpContextAccessor httpContextAccessor
)
{
    private const string DefaultRealm = "Test";
    private static readonly string[] Scopes =
    [
        "workspace:list",
        "workspace:create",
        "workspace:read",
        "workspace:delete",
        "workspace:list-users",
        "workspace:add-user",
        "workspace:remove-user"
    ];

    private const string WorkspaceType = "urn:workspaces";

    public async Task<IEnumerable<Workspace>> ListWorkspacesAsync()
    {
        var groups = await adminApiClient.Admin.Realms[DefaultRealm].Groups.GetAsync();

        return groups!.Select(x => Map(x, default));
    }

    public async Task<IEnumerable<Workspace>> ListMyWorkspacesAsync()
    {
        var currentUserEmail = httpContextAccessor.HttpContext?.User?.Identity?.Name;

        var currentUser = await adminApiClient
            .Admin.Realms[DefaultRealm]
            .Users.GetAsync(q =>
            {
                q.QueryParameters.Search = currentUserEmail;
                q.QueryParameters.Exact = true;
            });

        if (currentUser is not null && currentUser.Count > 0)
        {
            var userId = currentUser.First().Id;
            var groups = await adminApiClient
                .Admin.Realms[DefaultRealm]
                .Users[userId]
                .Groups.GetAsync();
            return groups!.Select(x => Map(x, default));
        }
        else
        {
            return [];
        }
    }

    public async Task<Workspace> GetWorkspaceAsync(string name)
    {
        var group = await this.GetGroupByExactName(name);
        var members = await this.ListMembersAsync(name);

        return Map(group!, members.Count());
    }

    public async Task DeleteWorkspaceAsync(string name)
    {
        var group = await this.GetGroupByExactName(name);

        if (group is not null)
        {
            await adminApiClient.Admin.Realms[DefaultRealm].Groups[group.Id].DeleteAsync();
        }

        var resource = await this.GetResourceByExactName(WorkspaceResourceId(name));

        if (resource is not null)
        {
            await protectedResourceClient.DeleteResourceAsync(DefaultRealm, resource.Id);
        }
    }

    private static Workspace Map(GroupRepresentation group, int membersCount) =>
        new(group.Name!, membersCount);

    public async Task CreateWorkspaceAsync(Workspace workspace)
    {
        await adminApiClient
            .Admin.Realms[DefaultRealm]
            .Groups.PostAsync(new GroupRepresentation() { Name = workspace.Name, });

        var resource = await protectedResourceClient.CreateResourceAsync(
            DefaultRealm,
            new Resource(WorkspaceResourceId(workspace.Name), Scopes)
            {
                Type = WorkspaceType,
                OwnerManagedAccess = true
            }
        );

        if (resource is not null)
        {
            await policyClient.CreatePolicyAsync(
                DefaultRealm,
                resource.Id,
                new Policy
                {
                    Name = $"Allow read access to group [{workspace.Name}]",
                    Scopes = ["workspace:read", "workspace:list-users"],
                    Groups = [workspace.Name]
                }
            );
        }
    }

    public async Task AddMember(string groupName, User member)
    {
        var group = await this.GetGroupByExactName(groupName);
        var user = await this.GetUserByExactName(member.Email);

        if (group is null || user is null)
        {
            return;
        }

        await adminApiClient.Admin.Realms[DefaultRealm].Users[user.Id].Groups[group.Id].PutAsync();
    }

    public async Task RemoveMember(string groupName, User member)
    {
        var group = await this.GetGroupByExactName(groupName);
        var user = await this.GetUserByExactName(member.Email);

        if (group is null || user is null)
        {
            return;
        }

        await adminApiClient
            .Admin.Realms[DefaultRealm]
            .Users[user.Id]
            .Groups[group.Id]
            .DeleteAsync();
    }

    public async Task<IEnumerable<User>> ListMembersAsync(string groupName)
    {
        var group = await this.GetGroupByExactName(groupName);

        if (group is null)
        {
            return [];
        }

        var members = await adminApiClient
            .Admin.Realms[DefaultRealm]
            .Groups[group.Id]
            .Members.GetAsync();

        return members!.Select(Map);
    }

    private static User Map(UserRepresentation user) => new(user.Email!);

    private static string WorkspaceResourceId(string name) => $"workspaces__{name}";

    private async Task<IEnumerable<GroupRepresentation>> GetGroupsByExactName(string name)
    {
        var groups = await adminApiClient
            .Admin.Realms[DefaultRealm]
            .Groups.GetAsync(q =>
            {
                q.QueryParameters.Search = name;
                q.QueryParameters.Exact = true;
            });

        return groups!;
    }

    private async Task<GroupRepresentation?> GetGroupByExactName(string name)
    {
        var groups = await this.GetGroupsByExactName(name);

        return groups!.FirstOrDefault();
    }

    private async Task<IEnumerable<UserRepresentation>> GetUsersByExactName(string name)
    {
        var users = await adminApiClient
            .Admin.Realms[DefaultRealm]
            .Users.GetAsync(q =>
            {
                q.QueryParameters.Search = name;
                q.QueryParameters.Exact = true;
            });

        return users!;
    }

    private async Task<UserRepresentation?> GetUserByExactName(string name)
    {
        var users = await this.GetUsersByExactName(name);

        return users!.FirstOrDefault();
    }

    private async Task<IEnumerable<ResourceResponse>> GetResourcesByExactName(string name)
    {
        var resources = await protectedResourceClient.GetResourcesAsync(
            DefaultRealm,
            new GetResourcesRequestParameters { ExactName = true, Name = name, }
        );

        return resources!;
    }

    private async Task<ResourceResponse?> GetResourceByExactName(string name)
    {
        var resources = await this.GetResourcesByExactName(name);

        return resources!.FirstOrDefault();
    }
}

See sample source code: keycloak-authorization-services-dotnet/tree/main/samples/ResourceAuthorization

Configuration of Authorization Server
json
{
  "allowRemoteResourceManagement": true,
  "policyEnforcementMode": "ENFORCING",
  "resources": [
    {
      "name": "workspaces",
      "type": "urn:workspaces",
      "ownerManagedAccess": false,
      "displayName": "",
      "attributes": {},
      "_id": "14b70534-d3f3-43ad-bdb4-99eb19e19b97",
      "uris": [],
      "scopes": [
        {
          "name": "workspace:list"
        },
        {
          "name": "workspace:create"
        }
      ],
      "icon_uri": ""
    },
    {
      "name": "workspaces__public",
      "type": "urn:workspaces",
      "ownerManagedAccess": false,
      "attributes": {},
      "_id": "581c2315-7097-4961-aa98-4ea1c0a808ef",
      "uris": [],
      "scopes": [
        {
          "name": "workspace:list"
        },
        {
          "name": "workspace:add-user"
        },
        {
          "name": "workspace:create"
        },
        {
          "name": "workspace:remove-user"
        },
        {
          "name": "workspace:list-users"
        },
        {
          "name": "workspace:delete"
        },
        {
          "name": "workspace:read"
        }
      ]
    },
    {
      "name": "workspaces__main",
      "type": "urn:workspaces",
      "ownerManagedAccess": true,
      "attributes": {},
      "_id": "3e23a12e-9ecd-4453-867d-760c7b296f0c",
      "uris": [],
      "scopes": [
        {
          "name": "workspace:list"
        },
        {
          "name": "workspace:add-user"
        },
        {
          "name": "workspace:create"
        },
        {
          "name": "workspace:remove-user"
        },
        {
          "name": "workspace:list-users"
        },
        {
          "name": "workspace:delete"
        },
        {
          "name": "workspace:read"
        }
      ]
    }
  ],
  "policies": [
    {
      "id": "303e03b1-f5bf-403f-bacc-578a5d62bc87",
      "name": "Is Admin",
      "description": "",
      "type": "role",
      "logic": "POSITIVE",
      "decisionStrategy": "UNANIMOUS",
      "config": {
        "roles": "[{\"id\":\"Admin\",\"required\":true}]"
      }
    },
    {
      "id": "a14be734-9e9a-4ccb-a6ff-32bcb840b42e",
      "name": "Is Reader",
      "description": "",
      "type": "role",
      "logic": "POSITIVE",
      "decisionStrategy": "UNANIMOUS",
      "config": {
        "roles": "[{\"id\":\"Reader\",\"required\":true}]"
      }
    },
    {
      "id": "9b546455-6ffb-4df7-9806-429b0b67f296",
      "name": "Can Create Workspace",
      "description": "",
      "type": "scope",
      "logic": "POSITIVE",
      "decisionStrategy": "UNANIMOUS",
      "config": {
        "resources": "[\"workspaces\"]",
        "scopes": "[\"workspace:create\"]",
        "applyPolicies": "[\"Is Admin\"]"
      }
    },
    {
      "id": "8535849b-d637-4a60-8b9a-3f045f3f23e9",
      "name": "Can List Workspaces",
      "description": "",
      "type": "scope",
      "logic": "POSITIVE",
      "decisionStrategy": "AFFIRMATIVE",
      "config": {
        "resources": "[\"workspaces\"]",
        "scopes": "[\"workspace:list\"]",
        "applyPolicies": "[\"Is Admin\",\"Is Reader\"]"
      }
    },
    {
      "id": "64782bb2-79bd-4904-8299-5012f66c2c85",
      "name": "Can Manage Workspaces",
      "description": "",
      "type": "scope",
      "logic": "POSITIVE",
      "decisionStrategy": "UNANIMOUS",
      "config": {
        "defaultResourceType": "urn:workspaces",
        "applyPolicies": "[\"Is Admin\"]",
        "scopes": "[\"workspace:delete\",\"workspace:read\",\"workspace:add-user\",\"workspace:remove-user\",\"workspace:list-users\"]"
      }
    }
  ],
  "scopes": [
    {
      "id": "5e5817b9-23ec-4422-aa1c-521aedee90e2",
      "name": "workspace:read",
      "iconUri": ""
    },
    {
      "id": "bb5b4947-607e-4095-bec6-1b5350a3aa31",
      "name": "workspace:delete",
      "iconUri": ""
    },
    {
      "id": "5d8af7a9-ab34-4e35-842f-bd5e1c79ecf0",
      "name": "workspace:list",
      "iconUri": ""
    },
    {
      "id": "b838e1d0-f65b-41ca-a601-a442df709ffd",
      "name": "workspace:list-users",
      "iconUri": ""
    },
    {
      "id": "c4df45cf-8dee-48ce-afc1-ae595387c6a5",
      "name": "workspace:add-user",
      "iconUri": ""
    },
    {
      "id": "0f7ae0fb-2645-4ef0-9f1a-55e59d45ca97",
      "name": "workspace:remove-user",
      "iconUri": ""
    },
    {
      "id": "9e76de21-b0e7-43aa-b959-e72ec7d2d14e",
      "name": "workspace:create",
      "iconUri": ""
    },
    {
      "id": "e9ef9eae-7ff0-44d7-9052-dd884044485c",
      "name": "workspace:list-user"
    }
  ],
  "decisionStrategy": "AFFIRMATIVE"
}