Перейти к содержимому

Авторизация

Как приложение решает, что можно уже аутентифицированному пользователю: роли и claims, политики с requirements и handlers, проверка доступа к конкретному объекту, модели ABAC и ReBAC и устройство централизованной системы управления доступом. На собеседовании здесь проверяют и знание ASP.NET Core, и умение спроектировать права для десятков приложений.

Перед этой темой: Основы веб-безопасности, JWT и защита API.

Junior+

Что такое ролевая модель доступа (RBAC) и как она выглядит в ASP.NET Core?

Заголовок раздела «Что такое ролевая модель доступа (RBAC) и как она выглядит в ASP.NET Core?»
Ответ

Что это

RBAC — модель доступа в два шага: права собирают в роли, а пользователям назначают роли. Роль «Бухгалтер» даёт просмотр и проведение платежей, и новому бухгалтеру выдают её, а не двадцать прав.

Зачем

Доступом управляют группами, а не каждым пользователем, и по составу роли видно, кто что может.

Как пользоваться

  • [Authorize] — только вошедшие пользователи.
  • [Authorize(Roles = "Admin,Manager")] — роль Admin или Manager.
  • Два атрибута [Authorize(Roles = ...)] с разными ролями — нужны обе.
  • [AllowAnonymous] — доступ без входа: страница логина.
  • В Minimal API — RequireAuthorization() и AllowAnonymous() у эндпоинта или группы MapGroup.

Частая ошибка

Забыть [Authorize] на новом контроллере: эндпоинт без атрибута открыт всем. Защищает fallback policy, которая требует входа везде, где не сказано иное.

Как устроено

  1. Аутентификация заполняет HttpContext.User; роли в нём — claims специального типа.
  2. Middleware авторизации собирает метаданные эндпоинта — [Authorize], RequireAuthorization — в одну политику.
  3. Roles превращается в требование: User.IsInRole истинно хотя бы для одной роли из списка.
  4. Если требование не выполнено, аноним получает challenge (предложение войти), а вошедший — forbid (отказ).

Какая политика применится

Метаданные эндпоинта Что проверяется
нет fallback policy; по умолчанию её нет, и доступ открыт
[Authorize], RequireAuthorization() default policy: пользователь вошёл
Roles = "..." или Policy = "..." роли или именованная политика
[AllowAnonymous], AllowAnonymous() ничего: отказа не будет

Ловушки

  • [AllowAnonymous] сильнее. На контроллере он отменяет все [Authorize] его actions.
  • Регистр. Роли сравниваются с учётом регистра: Admin и admin — разные роли.
  • Строки в коде. Опечатку в имени роли компилятор не поймает, а переименование роли превращается в поиск по всему проекту.

Правило

Fallback policy с RequireAuthenticatedUser, явный AllowAnonymous для публичного, а в атрибутах — имена политик вместо списков ролей.

Под капотом

  • Модель NIST RBAC (стандарт ANSI INCITS 359) — это базовый уровень (пользователи, роли, права, сессии), иерархии ролей и ограничения: статическое и динамическое разделение обязанностей. ASP.NET Core даёт только базовый уровень, остальное строят снаружи.
  • Roles превращается в RolesAuthorizationRequirement, а IsInRole ищет в каждой identity claim с типом её RoleClaimType. Если провайдер кладёт роли в claim с другим именем, IsInRole вернёт false при правильных ролях.

Компромиссы

  • Роль или право в коде. [Authorize(Roles = "HR,Admin")] смешивает «кто» и «что можно». Политика CanApproveVacation прячет состав ролей в одном месте, и новая роль не требует правки контроллеров.
  • Роли в токене или cookie. Проверка ничего не стоит, но снятая роль действует до перевыпуска токена или конца сессии.
  • Безопасность по умолчанию. Fallback policy делает забытый атрибут отказом. Цена — каждое публичное место надо пометить явно: вход, health checks, публичные страницы и их статику.

Как проверить в проде

  • Интеграционный тест через WebApplicationFactory перебирает EndpointDataSource и падает, если у эндпоинта нет ни метаданных авторизации, ни AllowAnonymous.
  • При отказе категория логов Microsoft.AspNetCore.Authorization пишет «Authorization failed» и список невыполненных требований.

Итог: [Authorize(Roles)] — базовый RBAC поверх claims; в проде к нему добавляют fallback policy, политики вместо строк ролей и учёт того, что роли в токене устаревают.

Пример
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie(); // единственная схема становится схемой по умолчанию
builder.Services.AddAuthorizationBuilder()
// Всё, что не помечено явно, требует входа
.SetFallbackPolicy(new AuthorizationPolicyBuilder().RequireAuthenticatedUser().Build());
var app = builder.Build();
// Публичный эндпоинт: вход тестового пользователя с ролью Manager
app.MapGet("/login", () => Results.SignIn(new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "alice"), new Claim(ClaimTypes.Role, "Manager")], "Cookies"))))
.AllowAnonymous();
app.MapGet("/profile", (ClaimsPrincipal user) => user.Identity!.Name); // fallback: любой вошедший
// Атрибут на лямбде: Admin ИЛИ Manager — alice проходит
app.MapGet("/reports", [Authorize(Roles = "Admin,Manager")] () => "reports");
// Всем эндпоинтам группы нужна роль Admin — alice получит отказ
var admin = app.MapGroup("/admin").RequireAuthorization(p => p.RequireRole("Admin"));
admin.MapGet("/users", () => "users");
app.Run();
Middle+
Ответ

Что это

  • Claim — пара «тип — значение» о пользователе: name = alice, department = HR, role = Manager.
  • ClaimsIdentity — claims, полученные одним способом входа: из cookie, токена или Windows.
  • ClaimsPrincipal — пользователь целиком, одна или несколько identity. Это HttpContext.User.

Откуда берутся

Их выдаёт тот, кто аутентифицировал пользователя. Форма входа записывает claims в cookie, сервер идентификации — в токен, Windows-аутентификация берёт их из учётной записи домена.

Как пользоваться

  • User.Identity.Name — имя, User.IsInRole("Admin") — роль.
  • User.FindFirst(ClaimTypes.Email)?.Value — значение claim.
  • User.HasClaim("department", "HR") — проверка значения.

Частая ошибка

Класть в claims быстро меняющиеся данные: остаток отпуска, список заявок. Они не обновятся, пока пользователь не войдёт заново.

Как устроено

Тип Что содержит
Claim Type, Value, ValueType и Issuer — кто выдал
ClaimsIdentity claims одной аутентификации, AuthenticationType, NameClaimType, RoleClaimType
ClaimsPrincipal список identity; свойство Claims — claims всех identity сразу
  • Identity считается аутентифицированной, если у неё задан AuthenticationType. new ClaimsIdentity(claims) без него — аноним с claims.
  • Типы claims — длинные URI из ClaimTypes или короткие имена JWT и OIDC: sub, email, role. Как одни превращаются в другие, решает схема аутентификации.

Роли — частный случай

IsInRole("Admin") ищет в каждой identity claim с типом её RoleClaimType и значением Admin. Через него работают [Authorize(Roles)] и RequireRole.

Дополнение claims

После успешной аутентификации ASP.NET Core вызывает IClaimsTransformation.TransformAsync. Там добавляют данные приложения: внутренний id сотрудника, отдел из кадровой системы.

Ловушки

  • TransformAsync может вызываться несколько раз за запрос: claim добавляют, только если его ещё нет.
  • Результат не сохраняется в cookie и вычисляется на каждом запросе, поэтому обращение к базе в нём кешируют.
  • Значения claims сравниваются с учётом регистра.

Правило

В токене или cookie — устойчивые сведения о личности, а данные приложения добавляют в самом приложении.

Под капотом

  • AuthenticationService.AuthenticateAsync вызывает обработчик схемы, а затем IClaimsTransformation. Middleware аутентификации делает это для схемы по умолчанию, а политика с явными AuthenticationSchemes — ещё раз для своих схем.
  • ASP.NET Core Identity собирает claims при входе через IUserClaimsPrincipalFactory<TUser>. Дальше они живут в cookie и обновляются, только когда cookie перевыпускают, например валидатором security stamp — по умолчанию раз в 30 минут.
  • WindowsPrincipal и GenericPrincipal наследуют ClaimsPrincipal, поэтому любой способ входа даёт приложению один и тот же API.

Цена

  • Каждый claim увеличивает cookie или токен, а они едут в каждом запросе. Большую cookie ASP.NET Core делит на части (.AspNetCore.CookiesC1, C2), но заголовки всё равно упираются в лимиты прокси и сервера.
  • Трансформация с запросом к базе добавляет задержку каждому запросу, поэтому результат кешируют по id пользователя с коротким TTL.

Компромиссы

Источник данных для решения Плюс Минус
claims в cookie или токене без сетевых вызовов устаревают до перевыпуска
трансформация с кешем свежие данные приложения кеш или база на каждый запрос
запрос в момент проверки точное решение по объекту задержка в момент проверки

Как проверить в проде

  • Эндпоинт для администраторов, который отдаёт User.Claims, показывает типы, издателей и то, что добавила трансформация.
  • Размер заголовков Cookie и Authorization в логах прокси — ранний сигнал, что claims разрослись.

Итог: claims — снимок утверждений издателя; держите в них устойчивое, а изменчивое добавляйте трансформацией с кешем или проверяйте в момент решения.

Пример
using System.Security.Claims;
using Microsoft.AspNetCore.Authentication;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("HrOnly", p => p.RequireClaim("department", "HR"));
builder.Services.AddSingleton<EmployeeDirectory>();
builder.Services.AddTransient<IClaimsTransformation, DepartmentClaimsTransformation>();
var app = builder.Build();
// В cookie попадают только имя и роль
app.MapGet("/login", () => Results.SignIn(new ClaimsPrincipal(new ClaimsIdentity(
[new Claim(ClaimTypes.Name, "alice"), new Claim(ClaimTypes.Role, "Manager")], "Cookies"))));
// name, role и department, который добавила трансформация
app.MapGet("/me", (ClaimsPrincipal user) => user.Claims.Select(c => new { c.Type, c.Value }))
.RequireAuthorization();
app.MapGet("/hr/vacancies", () => "vacancies").RequireAuthorization("HrOnly"); // alice из HR — проходит
app.Run();
public class EmployeeDirectory
{
public string GetDepartment(string userName) => userName == "alice" ? "HR" : "IT";
}
// Вызывается после каждой успешной аутентификации, иногда несколько раз за запрос
public class DepartmentClaimsTransformation(EmployeeDirectory directory) : IClaimsTransformation
{
public Task<ClaimsPrincipal> TransformAsync(ClaimsPrincipal principal)
{
if (principal.Identity?.Name is not { } name || principal.HasClaim(c => c.Type == "department"))
return Task.FromResult(principal); // аноним или claim уже есть: ничего не делаем
var clone = principal.Clone(); // исходный principal не меняем
clone.AddIdentity(new ClaimsIdentity([new Claim("department", directory.GetDepartment(name))]));
return Task.FromResult(clone);
}
}
Middle+
Ответ

Что это

Политика — правило доступа с именем. Внутри — требования: «вошёл», «есть роль», «есть claim» или своё условие.

Как пользоваться

  1. Зарегистрируйте политику: AddAuthorizationBuilder().AddPolicy("HrOnly", p => p.RequireClaim("department", "HR")).
  2. Примените её: [Authorize(Policy = "HrOnly")] на контроллере или .RequireAuthorization("HrOnly") у эндпоинта.
  3. Для сложного правила напишите свой requirement и handler — класс, который принимает решение.

Зачем

Правило описано один раз. Если доступ нужно дать ещё и аудиторам, правят регистрацию политики, а не все атрибуты.

Частая ошибка

Не зарегистрировать handler в DI. Requirement тогда никто не обработает, и политика всегда отказывает.

Как устроено

  1. AddAuthorizationBuilder() (.NET 7+) регистрирует политики; каждая — список IAuthorizationRequirement.
  2. При проверке IAuthorizationService создаёт AuthorizationHandlerContext: пользователь, requirements и ресурс.
  3. Вызываются все зарегистрированные IAuthorizationHandler. Handler вызывает context.Succeed(requirement), context.Fail() или ничего.
  4. Политика выполнена, если успешны все requirements и никто не вызвал Fail.

Как сочетаются

Ситуация Результат
несколько requirements в политике нужны все (И)
несколько handlers одного requirement хватит одного Succeed (ИЛИ)
хотя бы один Fail() отказ, даже если другие вызвали Succeed

Авторизация по ресурсу

Атрибут проверяется до загрузки данных. Поэтому правило «правит только автор» проверяют в коде: загрузить документ, вызвать AuthorizeAsync(User, document, "EditDocument") и при отказе вернуть Forbid(). Handler наследует AuthorizationHandler<TRequirement, Document>; для CRUD есть готовый OperationAuthorizationRequirement.

Ловушки

  • Handlers вызываются в любом порядке и даже для неаутентифицированного пользователя.
  • У эндпоинта context.Resource — это HttpContext, а не ваш объект.
  • Опечатка в имени политики — InvalidOperationException при первом запросе, а не при старте.

Правило

Простое условие — RequireRole, RequireClaim, RequireAssertion. Нужны сервисы или объект — requirement и handler.

Под капотом

  • AuthorizationMiddleware объединяет метаданные эндпоинта в одну политику, а IPolicyEvaluator аутентифицирует пользователя по её схемам и вызывает IAuthorizationService.
  • DefaultAuthorizationHandlerProvider отдаёт все IAuthorizationHandler из DI: каждый handler вызывается при каждой проверке, а AuthorizationHandler<T> просто пропускает чужие requirements.
  • Результат проверки на эндпоинте обрабатывает IAuthorizationMiddlewareResultHandler: по умолчанию challenge или forbid. Своя реализация меняет ответ, например 404 вместо 403.
  • IAuthorizationPolicyProvider строит политики по имени на лету ("MinAge:18"), а с .NET 8 атрибут с IAuthorizationRequirementData сам несёт requirements без регистрации политики.

Цена

Результат handlers не кешируется: запрос к базе или сервису прав внутри handler’а выполняется при каждой проверке. Handler регистрируют с любым временем жизни; scoped получает DbContext запроса.

Компромиссы

  • Атрибут или вызов в коде. Атрибут виден в метаданных, и его наличие проверяется тестом. Вызов AuthorizeAsync легко забыть в новом методе.
  • Объект или список. AuthorizeAsync проверяет один загруженный объект. Для списков права выражают в WHERE, иначе ломаются пагинация и производительность.
  • 404 или 403. 404 скрывает существование ресурса, 403 понятнее клиенту и поддержке.

Как проверить в проде

  • Логи категории Microsoft.AspNetCore.Authorization показывают, какие requirements не выполнены.
  • Handlers тестируют без веб-хоста: создают AuthorizationHandlerContext с нужным principal и ресурсом и вызывают HandleAsync.

Итог: политики — встроенный движок правил ASP.NET Core: требования декларативно, решения в handlers, объект — через IAuthorizationService, списки — фильтром в запросе.

Пример
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("EditDocument", p => p.AddRequirements(new EditDocumentRequirement()));
// Два handler'а одного requirement: хватит успеха любого (ИЛИ)
builder.Services.AddSingleton<IAuthorizationHandler, AuthorHandler>();
builder.Services.AddSingleton<IAuthorizationHandler, AdminHandler>();
var app = builder.Build();
app.MapPut("/documents/{id:int}", async (int id, ClaimsPrincipal user, IAuthorizationService auth) =>
{
var doc = new Document(id, Author: "bob"); // в жизни — загрузка из базы
var result = await auth.AuthorizeAsync(user, doc, "EditDocument");
return result.Succeeded ? Results.Ok(doc) : Results.Forbid(); // не автор и не Admin — Forbid
}).RequireAuthorization();
app.Run();
public record Document(int Id, string Author);
public class EditDocumentRequirement : IAuthorizationRequirement { }
// Handler с ресурсом: получает загруженный документ
public class AuthorHandler : AuthorizationHandler<EditDocumentRequirement, Document>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, EditDocumentRequirement requirement, Document doc)
{
if (context.User.Identity?.Name == doc.Author) context.Succeed(requirement);
return Task.CompletedTask;
}
}
public class AdminHandler : AuthorizationHandler<EditDocumentRequirement>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, EditDocumentRequirement requirement)
{
if (context.User.IsInRole("Admin")) context.Succeed(requirement);
return Task.CompletedTask;
}
}
Senior+

Когда RBAC не хватает: ABAC, ReBAC и внешние движки политик?

Заголовок раздела «Когда RBAC не хватает: ABAC, ReBAC и внешние движки политик?»
Ответ

Что это

  • Взрыв ролей — под каждое сочетание должности, отдела и региона заводят роль: HR_Kyiv_Manager, HR_Lviv_Manager и так сотни.
  • ABAC (attribute-based) — доступ решает правило над атрибутами: пользователя (отдел, должность), объекта (отдел, гриф), действия и обстановки (время, сеть).
  • ReBAC (relationship-based) — доступ решают связи: Аня — редактор папки, папка содержит документ, значит, Аня может его править.

Пример

Правило Модель
админка — только администраторам RBAC
менеджер видит заявки своего отдела ABAC
документ видят те, с кем им поделились ReBAC

Частая ошибка

Решать каждое новое правило новой ролью. Через год ролей больше, чем пользователей, и никто не помнит, что даёт каждая.

Как устроено

  • ABAC. По NIST SP 800-162 атрибуты субъекта, объекта, операции и окружения сравниваются с политикой. В ASP.NET Core это handler, который сравнивает claim department пользователя с отделом заявки.
  • ReBAC. Права выводятся из графа. Zanzibar хранит связи кортежами объект#отношение@пользователь, например doc:readme#owner@10, а конфигурация задаёт вывод: «читатель папки — читатель и её документов».
  • OPA (Open Policy Agent) — универсальный движок: приложение отправляет JSON с данными запроса, а правила на языке Rego возвращают решение.

Когда что выбирать

Модель Подходит, когда
RBAC права определяются должностью, ролей десятки
ABAC правила «своё»: отдел, регион, гриф, время
ReBAC шаринг, вложенные папки и группы, делегирование

Где проверять

  • Шлюз — грубо: вошёл ли пользователь и есть ли у него доступ к сервису.
  • Сервис — точно: этот пользователь и этот объект.
  • База — списки: условие в WHERE или row-level security.

Ловушки

  • Проверить один объект легко, а показать «всё, что мне доступно» — нет: фильтр после выборки ломает пагинацию.
  • Атрибуты пользователя берут из claims или каталога, а не из параметров запроса.

Правило

Начинайте с ролей и политик, ABAC реализуйте в handlers, внешний движок подключайте, когда правила общие для многих сервисов.

Под капотом

  • NIST SP 800-162 делит ABAC на точки: PEP применяет решение, PDP принимает его, PIP поставляет атрибуты, PAP хранит политики. В ASP.NET Core middleware и вызовы AuthorizeAsync — PEP, handlers — встроенный PDP.
  • Zanzibar (USENIX ATC 2019) обслуживает Drive, YouTube, Cloud: триллионы ACL и миллионы проверок в секунду. Против «new enemy problem» — отозванный пользователь видит контент, добавленный после отзыва, — клиент хранит с контентом токен версии (zookie), и проверка идёт по данным не старше него. В SpiceDB это ZedToken.

Цена

  • Внешний PDP — сетевой вызов на каждую проверку: его ставят рядом с сервисом и кешируют решения.
  • «Что мне доступно» — отдельный дорогой запрос: ListObjects в OpenFGA, LookupResources в SpiceDB.
  • Отношения дублируются в базе приложения и в графе: нужны синхронизация и сверка.

Компромиссы

Где проверять Плюс Минус
шлюз один раз для всех сервисов не знает данных объекта
сервис полный контекст объекта правила дублируются по сервисам
база (RLS) защищает от забытой проверки в коде логика в SQL, нужен контекст пользователя

RLS требует передать пользователя в соединение: set_config в PostgreSQL, sp_set_session_context в SQL Server. С пулом соединений значение задают на транзакцию, иначе оно достанется следующему запросу.

Как проверить в проде

  • Каждое решение логируют: кто, какое действие, над каким объектом, результат, версия политики.
  • Политики тестируют в CI: opa test для Rego, fga model test для модели OpenFGA.

Итог: RBAC не исчезает, а дополняется: атрибуты — в handlers, отношения — в графе вроде OpenFGA, списки — в запросе или RLS, а внешний PDP оправдан, когда правила общие для многих сервисов.

Пример
using System.Security.Claims;
using Microsoft.AspNetCore.Authorization;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();
builder.Services.AddSingleton(TimeProvider.System);
builder.Services.AddSingleton<IAuthorizationHandler, ApproveRequestHandler>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("ApproveRequest", p => p.AddRequirements(new ApproveRequirement()));
var app = builder.Build();
app.MapPost("/requests/{id:int}/approve", async (int id, ClaimsPrincipal user, IAuthorizationService auth) =>
{
var request = new AccessRequest(id, Department: "HR", RequestedBy: "bob"); // из базы
var result = await auth.AuthorizeAsync(user, request, "ApproveRequest");
return result.Succeeded ? Results.Ok() : Results.Forbid();
}).RequireAuthorization();
app.Run();
public record AccessRequest(int Id, string Department, string RequestedBy);
public class ApproveRequirement : IAuthorizationRequirement { }
// ABAC: решение из атрибутов субъекта, объекта и окружения
public class ApproveRequestHandler(TimeProvider time)
: AuthorizationHandler<ApproveRequirement, AccessRequest>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context, ApproveRequirement requirement, AccessRequest request)
{
var user = context.User;
var hour = time.GetLocalNow().Hour;
if (user.IsInRole("Manager") // субъект: роль
&& user.HasClaim("department", request.Department) // субъект и объект: отдел
&& user.Identity?.Name != request.RequestedBy // объект: не своя заявка
&& hour is >= 8 and < 20) // окружение: рабочее время
context.Succeed(requirement);
return Task.CompletedTask;
}
}
RLS в PostgreSQL
-- Приложение подключается не владельцем таблицы: владелец обходит RLS
ALTER TABLE access_requests ENABLE ROW LEVEL SECURITY;
-- Без значения app.department_id условие даёт NULL, и строк не видно
CREATE POLICY same_department ON access_requests
USING (department_id = current_setting('app.department_id', true)::int);
BEGIN;
-- true: значение действует до конца транзакции и не достанется следующему запросу из пула
SELECT set_config('app.department_id', '42', true);
SELECT * FROM access_requests; -- только заявки отдела 42
COMMIT;
Senior+

Как спроектировать централизованную систему управления доступом для многих внутренних приложений?

Заголовок раздела «Как спроектировать централизованную систему управления доступом для многих внутренних приложений?»
Ответ

Что это

Система управления доступом — одно место, где видны все приложения компании и кто какие права в них имеет.

Как устроено для сотрудника

  1. Выбирает в каталоге приложение и роль, пишет обоснование.
  2. Заявку согласуют руководитель и владелец приложения.
  3. Система выдаёт права через группу AD или API приложения.
  4. Всё пишется в журнал: кто просил, кто согласовал, когда выдали.

Принципы

  • Наименьшие привилегии — только то, что нужно для работы.
  • Срок — временные права заканчиваются сами.
  • Пересмотр — руководитель регулярно подтверждает, что права ещё нужны.

Частая ошибка

Выдавать, но не забирать. Сотрудник сменил отдел, старые права остались, и через несколько переводов у него доступ почти ко всему — это накопление прав.

Как устроено

  • Каталог — приложения, их права, роли как наборы прав, владельцы.
  • Источник сотрудников — кадровая система или AD: приём, перевод, увольнение.
  • Заявки — маршрут согласования зависит от риска права: руководитель, владелец приложения, служба безопасности.
  • Выдача — группы AD, протокол SCIM или API приложения; повторный запуск не должен дублировать права.
  • Доставка в приложение — claims при входе или запрос к сервису прав.
  • Аудит и пересмотр — журнал всех изменений и регулярные кампании подтверждения.

Когда что выбирать

Доставка прав Плюс Минус
группы AD или claims в токене без вызовов, стандартно изменения видны после нового входа
запрос к API прав с кешем свежие и точные права задержка, зависимость от сервиса

Ловушки

  • «Как у коллеги». Копирование прав коллеги переносит и лишнее.
  • Перевод опаснее увольнения. При увольнении блокируют учётную запись, а при переводе старые права тихо копятся.
  • Права в обход системы. Выданное вручную в приложении система не видит — нужна сверка.

Правило

У каждого права есть владелец, обоснование и срок, и всё, что система выдала, она умеет отозвать.

Схема компонентов

  1. Каталог: приложения, права, роли-пакеты, владельцы, уровень риска.
  2. Синхронизация сотрудников из кадровой системы или AD: события приёма, перевода, увольнения.
  3. Движок заявок: маршрут по риску и проверка разделения обязанностей до согласования.
  4. Хранилище назначений — желаемое состояние: кто, что, основание, срок.
  5. Провижининг: коннекторы к AD, SCIM и API через очередь с повторами.
  6. Сверка: сравнение желаемого и фактического, поиск прав, выданных в обход.
  7. API решений и события «права изменились» для кешей приложений.
  8. Неизменяемый журнал аудита.
  9. Кампании пересмотра и автоматический отзыв по сроку.

Компромиссы

  • Отзыв. С claims в токене он доходит только через время жизни токена или сессии. Сокращают жизнь токена или проверяют версию прав при обновлении сессии.
  • Онлайн-проверка. Кеш в приложении с TTL в минуты плюс событие для сброса. Если сервис прав недоступен, изменения запрещают (fail closed), а для чтения выбор между устаревшим кешем и отказом согласуют с безопасностью.
  • Разделение обязанностей. Конфликтные пары прав («создаёт платёж» и «утверждает платёж») проверяют при заявке; без владельцев эти правила быстро устаревают.

Как проверить в проде

  • Время от согласования до фактической выдачи и число прав без владельца.
  • Расхождения сверки: права в приложениях, которых нет в системе.
  • Доля прав, подтверждённых на пересмотре без изменений: стопроцентное «подтвердить всё» — признак формальности.
  • Журнал отвечает на вопрос «кто имел доступ к X на дату Y»; если нет — аудит неполный.

Итог: централизованный доступ — это желаемое состояние и циклы, которые сводят к нему реальность; главное архитектурное решение — как и с какой задержкой права доходят до приложений.

Приложение спрашивает права у центрального сервиса
using Microsoft.AspNetCore.Authorization;
using Microsoft.Extensions.Caching.Memory;
var builder = WebApplication.CreateBuilder(args);
builder.Services.AddAuthentication().AddCookie();
builder.Services.AddMemoryCache();
builder.Services.AddSingleton<PermissionClient>();
builder.Services.AddSingleton<IAuthorizationHandler, PermissionHandler>();
builder.Services.AddAuthorizationBuilder()
.AddPolicy("requests.approve", p => p.AddRequirements(new PermissionRequirement("requests.approve")));
var app = builder.Build();
app.MapPost("/requests/{id:int}/approve", (int id) => $"Request {id} approved")
.RequireAuthorization("requests.approve");
app.Run();
public record PermissionRequirement(string Permission) : IAuthorizationRequirement;
public class PermissionClient
{
// В жизни — HttpClient к сервису прав: GET /users/{user}/permissions?app=hr-portal
public Task<string[]> GetAsync(string user) => Task.FromResult<string[]>(["requests.approve"]);
}
public class PermissionHandler(PermissionClient client, IMemoryCache cache)
: AuthorizationHandler<PermissionRequirement>
{
protected override async Task HandleRequirementAsync(
AuthorizationHandlerContext context, PermissionRequirement requirement)
{
if (context.User.Identity?.Name is not { } user) return;
// Кеш на минуту: компромисс между нагрузкой на сервис прав и скоростью отзыва
var permissions = await cache.GetOrCreateAsync($"perm:{user}", entry =>
{
entry.AbsoluteExpirationRelativeToNow = TimeSpan.FromMinutes(1);
return client.GetAsync(user);
});
if (permissions?.Contains(requirement.Permission) == true) context.Succeed(requirement);
}
}
// Подписчик на событие «права изменились»: отзыв доходит быстрее TTL
public class PermissionsChangedHandler(IMemoryCache cache)
{
public void Handle(string user) => cache.Remove($"perm:{user}");
}