Проверка политик доступа
Вход из предыдущего урока сообщает серверу, что запрос сделал editor-01. Но этого недостаточно, чтобы разрешить изменение любого курса. Представим два объекта: JavaScript принадлежит editor-01, Markdown — editor-02. Оба существуют, однако вошедший редактор должен менять только свой. Соединим общую политику действия с проверкой конкретного ресурса.
Полная ветка lesson-22/after продолжает HTTPS-песочницу 21; её start совпадает с предыдущим after. Курсы здесь представлены неизменяемыми объектами в памяти, не записью в базе раннего CRUD. Это отдельная модель для проверки доступа. Пароль остаётся внешним, изменяющие операции уже защищены CSRF. Компиляция, вход и запросы при подготовке не выполнялись, поэтому дальнейшая последовательность описывает ожидаемое поведение.
Политика определяет допустимый вид действия
При регистрации authorization зададим требование аутентифицированного пользователя и server-issued permission:
builder.Services.AddAuthorization(options =>
options.AddPolicy("CourseEditor", policy =>
policy.RequireAuthenticatedUser()
.RequireClaim("permission", "course:edit")));
Эта политика допускает рассмотрение редакторской команды. Она не знает, какой курс найден, и не сравнивает его владельца. Если применить только RequireAuthorization("CourseEditor"), вошедший человек с permission сможет пройти к любому ID. Поэтому общая политика — лишь первая граница.
Permission появляется при успешной проверке пароля и выпуске cookie в уроке 21. Клиент не может легитимно выдать себе это утверждение через JSON, поле формы или заголовок X-Role. Так важно проследить источник прав: значение в коде после удостоверения имеет другой смысл, чем текст, присланный пользователем.
Для разных ролей реальное приложение может формировать разные наборы claims. Однако claims в билете способны устаревать. Проверка самой cookie не обязана каждый раз обращаться к аккаунту, если это отдельно не настроено. Учебная песочница не добавляет административное управление ролями и не обещает мгновенный отзыв полномочий.
Ресурсная проверка получает найденный курс
Модель содержит ID, владельца, название и версию. В отдельном handler сравниваются удостоверенный ID и владелец. Полное правило выглядит так:
public sealed class OwnCourseRequirement : IAuthorizationRequirement { }
public sealed class OwnCourseHandler
: AuthorizationHandler<OwnCourseRequirement, OwnedCourse>
{
protected override Task HandleRequirementAsync(
AuthorizationHandlerContext context,
OwnCourseRequirement requirement, OwnedCourse resource)
{
if (context.User.FindFirstValue(ClaimTypes.NameIdentifier)
== resource.OwnerUserId)
context.Succeed(requirement);
return Task.CompletedTask;
}
}
Handler ничего не меняет и не читает ID из входной команды. Он получает уже загруженный серверный ресурс. Общая политика обеспечивает наличие аутентифицированного principal, а ресурсное требование проверяет отношение этого principal к конкретному объекту. Пустое имя пользователя не превращается в совпадение владельцев, потому что учебные владельцы заданы непустыми стабильными ID.
Сервис IAuthorizationService регистрируется вместе с authorization, а handler — как singleton: он не хранит изменяемое состояние запроса и не захватывает scoped repository. Если позднее правило станет обращаться к базе, время жизни и зависимости нужно пересмотреть. Нельзя переносить соединение или текущий HttpContext в singleton ради сокращения кода.
Обработчик соблюдает порядок решений
Редакторская команда сначала проходит общую политику и CSRF, затем находит ресурс по ID. Если ID отсутствует, выбирается 404. Для найденного объекта выполняется ресурсная проверка:
var decision = await authorization.AuthorizeAsync(
context.User, course, new OwnCourseRequirement());
if (!decision.Succeeded) return Results.Forbid();
Только после успешного решения проверяется предметное новое название и выполняется изменение. Иначе сервер мог бы принять или частично обработать чужую команду до отказа. Для нашего договора существующий чужой курс возвращает 403. Некоторые продукты предпочитают одинаковый 404, чтобы не раскрывать существование объектов; это допустимое проектное решение, но оно должно быть единым и осознанным.
Ожидаемые случаи просты. Анонимная команда получает 401 на общей границе. Вошедший editor-01 с правильным token может переименовать JavaScript. Тот же пользователь получает 403 для Markdown. Несуществующий ID получает 404. Результат не зависит от того, добавил ли клиент ownerUserId или придуманный permission к телу: эти поля не входят в принимаемый тип команды.
В памяти запись заменяется через ConcurrentDictionary.TryUpdate: новая неизменяемая версия сопоставляется с ранее прочитанной. Если параллельная команда уже сменила объект, handler возвращает 409. Это не та же реализация Npgsql из урока 19, но сохраняет её смысл: проверка относится к прочитанному состоянию и не должна молча заменять новое.
Проверка права тоже может устареть
В нашем примере владелец не изменяется: команды разрешают только название, а ID и owner остаются серверными. Поэтому между проверкой и заменой не возникает разрешённого переназначения владельца. В более сложной базе право может зависеть от изменяемого членства или владельца. Тогда нужно согласовать проверку и запись транзакцией либо включить нужное условие в саму SQL-команду.
Простая последовательность «проверили право, долго подождали, записали» не доказывает корректность при конкурентном изменении права. Условия доступа являются частью предметного контракта, а не украшением HTTP-handler. Особенно это важно для многоарендного приложения: один правильный permission не заменяет ограничения по tenant и ресурсу в каждом запросе.
При чтении внутренней заметки действует такая же логика. Публичный список может показывать название, но не должен случайно сериализовать служебные поля в ответ отказа. Error detail не выводит владельца, password hash или содержимое команды. Даже корректный 403 не оправдывает раскрытие защищённых данных в теле или журнале.
Скрытая кнопка интерфейса полезна пользователю, но не является проверкой политики. Любой клиент может самостоятельно отправить запрос к знакомому URL. Авторизация должна оставаться на сервере, где известны principal и ресурс. CORS также не выполняет это сравнение: он регулирует браузерный доступ между origin, а не предметное право редактора.
Для последующего просмотра попробуйте одно и то же новое название на JavaScript и Markdown, сохраняя одинаковую аутентификацию и token. В успешном случае изменится только принадлежащий курс. После отказа прочитайте Markdown ещё раз: его название и версия должны остаться прежними. Мы получили наблюдаемое различие между входом и правом на объект, которое понадобится и при загрузке вложений.
Политики описаны в официальном руководстве ASP.NET Core, передача объекта в authorization — в руководстве ресурсной проверки.
Можно проверить ещё одну предметную границу: изменение собственником только разрешённых полей. Тип rename содержит название и expectedVersion; он не принимает новый ID или нового владельца. Поэтому даже успешная политика не разрешает массовую замену объекта произвольным JSON. Внутренний OwnerUserId копируется из прочитанного ресурса, а версия увеличивается сервером. Если позже появится команда передачи собственности, её нельзя добавить как необязательное поле этой формы. Понадобятся другое разрешение, правила целевого аккаунта и аудит. Такая раздельность уменьшает число скрытых решений в одном handler и позволяет показать читателю, какое именно действие разрешено. Право на rename не означает право на удаление, экспорт всех данных или управление учётными записями.