У нас в публичном облаке VK Cloud у каждого бакета Object Storage есть Bucket Access Policy. Это набор правил в формате JSON, который лежит на самом бакете и говорит, кому какие операции с какими объектами разрешены. Хранилище проверяет эти правила само на каждый запрос. Включается политика в личном кабинете и через S3 API, и я регулярно вижу проекты, где её не настраивали ни разу.

В статье разбираю состав бакет-политики, её отличия от AWS-руководства и что задавать областью действия ключа, а что политикой. Дальше три механизма проверки на endpoint и порядок между ними, перенос политики из AWS-руководства как есть с разбором, почему он не работает, и четыре итерации доводки (Resource, Principal, aws:SourceIp, явный Deny). Затем матрица из 16 запросов с ожидаемыми кодами и скрипт прогона, метрика доли отказов по Cloud Audit, регуляторика, чек-лист переноса между AWS S3, Ceph, MinIO и VK Object Storage.

Пригодится тем, кто держит прокси перед хранилищем и хочет перенести правила доступа в само хранилище, и специалистам по ИБ, которым нужно доказать разграничение доступа выгрузкой, а не скриншотом консоли. Для другого S3-совместимого хранилища применима основная часть: механика проверки и матрица тестов от платформы не зависят.

Как это обычно устроено

Разберу на схеме, которую встречаю чаще всего. Она остаётся после переезда из своего дата-центра в облако. Приложения ходят в объектное хранилище не сами, а через nginx или собственный прокси. Прокси проверяет токен, сверяется со своей таблицей прав и решает, кому доступен бакет и папка внутри него. Заодно он ведёт общий журнал и ограничивает частоту запросов.

Пара слов о терминах, дальше они встречаются постоянно. Бакет это контейнер для объектов, у него собственное имя и собственные настройки доступа. Префикс это путь внутри бакета, по смыслу папка: inbox/ для сырых загрузок, reports/ для отчётов. Ключ доступа S3 это пара из идентификатора и секрета, которой подписывается каждый запрос. Endpoint это адрес, по которому хранилище эти запросы принимает.

Правила доступа при этом остаются в конфиге nginx или в коде внутреннего сервиса. В самом хранилище заведён один аккаунт S3 с полными правами на все бакеты проекта, и его пара ключей прописана в прокси.

Типовая схема: правила доступа живут в конфиге прокси, а решение о доступе принимает S3 endpoint. Копия аккаунтного ключа обходит прокси и приходит на endpoint напрямую.

Пока все запросы идут через прокси, схема работает. Ломается она в момент, когда пара ключей из конфига прокси оказывается ещё где-нибудь. Обычно она туда и попадает: тот же аккаунт нужен CI для выкладки артефактов, дежурному для разбора инцидента, разработчику для локальных тестов. А S3 endpoint у публичного облака доступен из интернета по фиксированному адресу, это свойство протокола. Дальше достаточно указать в aws-cli другой --endpoint-url. Запрос уйдёт в хранилище напрямую, мимо прокси со всеми его правилами.

Отсюда три типовые истории.

Аналитик получает больше, чем просил. Ему нужны отчёты из reports/, а выдали ту же аккаунтную пару. Вместе с отчётами он получил inbox/ с сырыми загрузками пользователей. Прокси бы его туда не пустил, а хранилище пустит, потому что правил на бакете нет.

Доступ закрыли только в прокси. Подрядчик закончил работу, учётку в прокси ему отключили. Копия ключей осталась у него в скриптах и продолжает работать: ключ живёт в хранилище, а не в конфиге прокси.

CI стирает чужой префикс. Раннер выкладывает артефакты в бакет под той же аккаунтной парой. Ошибка в переменной пути, и aws s3 rm --recursive отрабатывает по inbox/ вместо своего каталога. Прав на это у раннера быть не должно, но аккаунтный ключ их даёт.

Ни один из этих трёх запросов прокси не увидит. В журнале хранилища все три придут от одного и того же аккаунта S3, и по нему не разобрать, кто именно ходил.

На endpoint доступ определяют три механизма: права субъекта, политика бакета и ACL. У нас работают два последних, и ACL можно отключить параметром бакета. Разрешения складываются, поэтому запрос пройдёт, если доступ выдал хотя бы один из них. Явный Deny при этом перебивает любое разрешение. Полностью выключить прокси после переноса удаётся редко, четыре его функции из шести остаются за ним.

Функции прокси перед хранилищем

У прокси в этой схеме шесть задач. Правила доступа почти целиком можно перенести в политику бакета. Лимиты и аудит переносятся только отчасти. Необходимость скрывать общий ключ исчезает, если выдать каждому потребителю отдельный ключ бакета или префикса. Бизнес-правила и терминация TLS остаются за прокси.

Аутентификация и правила доступа. Это основная часть переноса. Доступ к бакету и объектам начинает проверять само хранилище, а не только внешний сервис.

Ограничение частоты запросов. У Object Storage уже есть лимиты: 1000 запросов в секунду на обычные операции и 250 на листинг. Лимиты указаны базовые и могут быть увеличены через менеджера. Они считаются внутри сервиса, поэтому отдельный сайдкар нужен не всегда. Но это общий лимит на проект. Настроить в нём отдельную квоту для конкретного принципала или префикса нельзя. Если такая гранулярность нужна, её по-прежнему обеспечивает прокси.

Общий журнал запросов. Прокси пишет все обращения в один лог, где рядом с техническими полями лежат поля приложения. Построчного журнала запросов к бакету в модели политик нет, события приходится брать из платформенного Cloud Audit, а он фиксирует только технические поля. Как считать по нему метрику переноса, разбираю ниже, в разделе «Доля запросов, отклонённых на стороне хранилища».

Сокрытие ключей от клиентов. Политика бакета этого не делает. Но при отказе от общего аккаунтного ключа задача обычно исчезает сама: сервису выдают ключ бакета, а потребителю конкретной директории префиксный ключ. Секретный ключ показывается только при создании и не восстанавливается. Для ротации к аккаунту можно привязать две ключевые пары.

Бизнес-логика допуска. Правило вида «разрешить доступ клиенту с активной подпиской» зависит от состояния вашей базы. В Bucket Access Policy его не выразить, поэтому здесь прокси или отдельный сервис авторизации остаётся.

Терминация TLS. Политика бакета не умеет отклонять нешифрованные соединения. Если нужно гарантированно не пропускать plain HTTP до хранилища, это по-прежнему задача прокси, ingress или другого слоя, который завершает TLS.

Прямой путь на endpoint, минуя прокси

Политика бакета и ACL вычисляются на самом S3-endpoint. Endpoint в VK Cloud публичный и фиксированный: https://hb.vkcloud-storage.ru, https://hb.ru-msk.vkcloud-storage.ru. Пока правила заданы только на прокси, любой, у кого есть валидная пара ключей, обращается на endpoint напрямую и прокси в этом пути не участвует.

Проверить прямой путь можно одной командой, даже если прокси продолжает работать:

# Прямое обращение на endpoint хранилища, минуя прокси.
# Профиль настроен на ключи, выданные приложению, которое
# «должно» ходить только через прокси.
aws --endpoint-url https://hb.ru-msk.vkcloud-storage.ru \
  --profile app-writer \
  s3api head-bucket --bucket project-media
# Ожидаемо, пока на бакете нет политики: HTTP 200.
# То есть прокси пустил в хранилище.

Для проверки достаточно aws-cli, тех же ключей и другого значения --endpoint-url. Запрос уйдёт сразу в Object Storage, а прокси со своими правилами в обработке не участвует.

Телеметрия 2025 года

Публичные бакеты встречаются заметно реже, чем несколько лет назад. По телеметрии Datadog за сентябрь 2025 года, более 80% бакетов S3 защищены Block Public Access, платформенной настройкой, которая отключает публичный доступ на уровне аккаунта или отдельного бакета. Фактически публичными остаются около 1% бакетов, а в 2024 и 2023 годах их было по 1,5%.

В выборку вошли AWS, Azure и Google Cloud, но не российские облака. Поэтому эти проценты нельзя переносить на российский рынок, но общий вывод по ним сделать можно. Публичный доступ всё чаще закрывают настройками по умолчанию, а не оставляют на усмотрение создателя бакета.

В AWS эту тенденцию закрепили 27 апреля 2023 года. Для новых бакетов стали автоматически включать Block Public Access и отключать ACL. Раньше безопасное начальное состояние зависело от того, как бакет настроил конкретный инженер. Теперь публичный доступ приходится включать отдельно.

Закрытый от анонимных запросов бакет это ещё не разграничение доступа между внутренними потребителями. По данным той же телеметрии, 32% организаций используют политики бакетов, в основном с условием aws:SourceAccount, около 15% используют политики VPC endpoint. Организационные политики SCP и RCP применяют меньше 1%. Хотя бы один из этих механизмов использует примерно 40% организаций.

Здесь решаются разные задачи. Block Public Access закрывает публичный доступ одним переключателем. Политика бакета определяет, какой ключ, аккаунт или сервис получает доступ к конкретным данным. И именно это важно, когда ключи уже разошлись по CI, локальным окружениям и скриптам.

По данным Verizon DBIR 2025, медианное время устранения секрета, обнаруженного в GitHub-репозитории, составляет 94 дня. Это не срок, в течение которого бакет остаётся открытым, а срок жизни скомпрометированного ключа. Но пока ключ действует, доступ к данным ограничен только теми правилами, которые настроены в самом хранилище.

Что возвращает endpoint на каждом из двух путей. Пока правил на бакете нет, оба заканчиваются кодом 200.

Три механизма проверки и порядок между ними

На схеме прямой путь всё равно доходит до проверки на endpoint. Там работают три механизма, и их разрешения складываются. Доступ дают, если разрешил хотя бы один из них.

  • Права субъекта, или identity-based policy, это понятие из AWS. Такая политика привязана к пользователю, роли или сервисному аккаунту и отвечает на вопрос, что этому субъекту разрешено вообще.

  • Политика бакета (resource-based policy) привязана к ресурсу и отвечает на вопрос, что разрешено в этом конкретном бакете. Её мы и настраиваем.

  • ACL это списки прав на бакет и на объект. Запрета в ACL не существует, они умеют только разрешать. Список ограничен 100 элементами, а по умолчанию при создании бакета применяется предустановленный ACL private.

Первый пункт этого списка относится к модели AWS. У VK Object Storage он реализован иначе. Документация описывает пять способов управления доступом: ключи доступа, политика доступа, подписанный URL, ACL и CORS. Политики, которую можно привязать напрямую к аккаунту Object Storage, среди них нет, аккаунты и их ключи управляются в личном кабинете.

Роль identity-based policy здесь берут на себя две другие вещи. Первая и самая важная на практике это область действия ключа. Ключ, привязанный к аккаунту, открывает доступ ко всем его бакетам. Ключ бакета сужает его до одного бакета, а префиксный ключ до одной директории внутри бакета. Иначе говоря, права субъекта здесь определяются не отдельной политикой, а тем, к чему привязан сам ключ.

Отсюда и порядок работы, обратный привычному в AWS: сначала выдаём ключ с минимально нужной областью, а уже потом пишем политику на то, что осталось. Если аналитику нужен доступ только к одному префиксу, префиксный ключ решает задачу сам, без единой строки JSON.

Три области действия ключа доступа. Чем уже область, тем меньше остаётся работы политике бакета.
Три области действия ключа доступа. Чем уже область, тем меньше остаётся работы политике бакета.

Вторую роль играет ролевая модель платформы. Роли и разрешения назначаются пользователям проекта и сервисным учётным записям, но описаны они для операций в личном кабинете и в API платформы, а особые наборы прав в справочнике перечислены для Cloud Logging, Cloud Monitoring, Cloud Audit и Security Gate. Object Storage в этот перечень не входит. На подписанный S3-запрос ролевая модель влияет иначе, чем identity-based policy в AWS. Она решает, кто зайдёт в личный кабинет и создаст бакет или ключ, а что вернёт endpoint на конкретный GetObject, не определяет. Закрыть ею прямой путь к данным не получится.

Порядок вычисления в AWS

AWS задаёт эталонную модель, через которую удобно смотреть на любую S3-совместимую систему. По умолчанию все запросы получают неявный отказ. Исключение сделано только для root-пользователя аккаунта AWS. А если в процессе оценки находится хотя бы один применимый явный запрет, итоговое решение это отказ, независимо от остальных правил. Конвейер проверки состоит из семи стадий: оценка Deny, RCP организации, SCP организации, resource-based policies, identity-based policies, permissions boundaries, session policies.

Identity-based и resource-based политики при этом не конкурируют, а складываются. Итоговые права это их объединение. Если действие разрешено хотя бы одной из двух политик, AWS его пропускает. Но явный запрет в любой из них перекрывает любое разрешение. Фраза «я запретил в IAM, значит политикой бакета уже не открыть» верна только тогда, когда запрет прописан явно. Явный Deny перебивает всё, а отсутствие разрешения такой силы не имеет.

У S3 к этому добавляется собственная специфика. Авторизация проходит через три контекста по порядку: пользователя, бакета и объекта. Сервис собирает все применимые политики (пользовательскую, политику бакета и ACL) и на их основе формирует набор правил для проверки. Если проверка не проходит, он возвращает 403 Forbidden. На практике это значит, что операции над бакетом и операции над объектом стоит тестировать раздельно, они проверяются в разных контекстах.

Порядок «Deny раньше Allow» действует и в других реализациях. MinIO прямо формулирует это так: любое правило Deny проверяется раньше любого правила Allow. Этот порядок можно считать надёжной опорой при переносе политики, даже там, где остальной набор ключей и условий у платформ расходится.

Два разных запрета

Запрет в модели доступа бывает двух видов, и на переносе политики их надо различать. Первый вид, явный запрет (Deny), вы прописываете сами в параметре Effect. Второй вид это дефолт, который применяется, когда к действию не подошло ни одно правило.

Ведут они себя по-разному. Запрет из Effect соответствует explicit deny из модели AWS, и его не отменит никакое разрешение. Дефолт работает как implicit deny, поэтому достаточно одного подходящего Allow, чтобы доступ появился. Если через полгода политику расширят, разница проявится именно здесь.

Место ACL

В VK Cloud порядок проверки между политикой и ACL задаёт параметр бакета OwnershipControls. Он же определяет, участвует ли ACL в проверке доступа вообще.

В режиме BucketOwnerEnforced доступ проверяет только политика, ACL отключён. В режиме BucketOwnerPreferred доступ сначала проверяется по политике, а если она разрешения не дала, срабатывает ACL. Порядок здесь важен. Политика идёт первой, а ACL может только добавить доступ, но не отнять его.

В VK Object Storage я ставлю режим BucketOwnerEnforced. ACL в нём отключены, доступ к бакету определяет только политика. Это исключает конфликты между двумя механизмами управления доступом, поэтому далее в статье рассматриваем именно этот режим.

Здесь важно, что дефолты у платформ разные. В AWS Object Ownership по умолчанию стоит Bucket owner enforced, ACL для новых бакетов выключены, а обратное включение восстанавливает ранее сохранённые ACL. В VK Cloud новый бакет получает canned ACL private. Разница выглядит незначительной, но именно из-за неё перенос политики может сработать иначе, чем ожидалось. Поэтому режим ACL на бакете-приёмнике стоит проверять отдельно, не полагаясь на то, что политика ведёт себя одинаково на обеих платформах.

Порядок вычисления: явный Deny, затем Allow, затем режим OwnershipControls и ACL. Preferred и Enforced на схеме это BucketOwnerPreferred и BucketOwnerEnforced.
Порядок вычисления: явный Deny, затем Allow, затем режим OwnershipControls и ACL. Preferred и Enforced на схеме это BucketOwnerPreferred и BucketOwnerEnforced.

Кто отклонил запрос

По ошибке 403 невозможно понять, что чинить: политику, ACL или права субъекта. В таком случае обычно сначала правят политику, потом ACL, потом заново выдают ключи, и на каком шаге доступ восстановился, остаётся непонятным.

AWS дважды улучшал сообщения об отказе: 21 августа 2024 года вышли Enhanced access denied error messages for same-account requests, а 16 июня 2025 года такие же сообщения появились для запросов внутри одной организации.

Помогает уже разделение 403 и 404. По документации, HeadBucket возвращает 200, если бакет существует и доступ к нему разрешён, иначе 404 либо 403. Выбор между этими двумя кодами протокол не фиксирует, каждая S3-совместимая реализация решает сама. Например, в Ceph v20.2.2 от 16 июня 2026 года есть правка, из-за которой read_obj_policy() учитывает s3:prefix при выборе между 403 и 404. Получается, что одинаковый запрос к двум S3-совместимым хранилищам может вернуть разные коды на одну и ту же причину отказа. В матрице тестов такие кейсы приходится помечать сразу двумя допустимыми значениями.

Уровень, на котором произошёл отказ, можно разделить тремя запросами:

BUCKET=project-media
EP=https://hb.ru-msk.vkcloud-storage.ru
# 1. Есть ли доступ на уровне бакета вообще
aws --endpoint-url $EP --profile app-writer \
  s3api head-bucket --bucket $BUCKET
# 200 - бакет виден. 404 - нет бакета или нет права его видеть.
# 403 - бакет есть, права нет.
# 2. Есть ли право на листинг (операция в контексте бакета)
aws --endpoint-url $EP --profile app-writer \
  s3api list-objects-v2 --bucket $BUCKET --max-items 1
# 3. Есть ли право на конкретный объект (контекст объекта)
aws --endpoint-url $EP --profile app-writer \
  s3api head-object --bucket $BUCKET --key inbox/1.jpg

Если первый и второй запросы проходят, а третий возвращает отказ, значит, дело в правах на уровне объекта. В этом случае первым делом стоит проверить элемент Resource, он указывает, на какие бакеты и объекты распространяется правило.

Наивный перенос политики из AWS-руководства

В бакет project-media пишет сервис загрузки под аккаунтом app-writer, а читает аналитик под аккаунтом analyst. Аналитику нужен доступ только к префиксу reports/. Плюс хочется закрыть весь трафик, который идёт не по TLS. Пример учебный, но такой расклад встречается в реальных проектах.

Берём AWS-руководство и переносим политику оттуда без единой правки:

{
  "Version": "2012-10-17",
  "Statement": [
    {
      "Sid": "DenyInsecureTransport",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": "arn:aws:s3:::project-media/*",
      "Condition": { "Bool": { "aws:SecureTransport": "false" } }
    },
    {
      "Sid": "WriterCanPut",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::mcs1234567890:user/app-writer" },
      "Action": ["s3:PutObject", "s3:AbortMultipartUpload"],
      "Resource": "arn:aws:s3:::project-media/*"
    },
    {
      "Sid": "AnalystCanListReportsOnly",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::mcs1234567890:user/analyst" },
      "Action": "s3:ListBucket",
      "Resource": "arn:aws:s3:::project-media",
      "Condition": { "StringLike": { "s3:prefix": "reports/*" } }
    }
  ]
}

Политика выглядит корректной, но из трёх statement, то есть трёх отдельных правил внутри неё, в VK Object Storage полноценно работает только одно.

Что реально доступно в наборе проверок

Условия срабатывания правила задаются в блоке Condition. В VK Object Storage доступны семь операторов: StringEquals, StringNotEquals, NumericEquals, NumericLessThan, DateEquals, IpAddress и NotIpAddress. Ключей условий пять: s3:object-lock-legal-hold, s3:object-lock-mode, s3:object-lock-remaining-retention-days, s3:object-lock-retain-until-date и aws:SourceIp.

Четыре из пяти ключей относятся к блокировке объектов (object lock). Для сетевой фильтрации доступен один ключ, aws:SourceIp.

Набор ключей условий: четыре из пяти заняты блокировкой объектов, на сетевую фильтрацию остаётся один.
Набор ключей условий: четыре из пяти заняты блокировкой объектов, на сетевую фильтрацию остаётся один.

Список Action в VK Object Storage насчитывает 47 позиций. В таблице префикс записан как S3:, но регистр в политиках значения не имеет, поэтому дальше в тексте я использую строчный s3:.

Шести привычных по AWS действий в списке нет. Управление Block Public Access закрыто, потому что нет s3:PutBucketPublicAccessBlock и s3:GetBucketPublicAccessBlock, а доступен только s3:GetBucketPolicyStatus. Серверных логов доступа тоже не задать, нет s3:GetBucketLogging и s3:PutBucketLogging. Не хватает и s3:CreateBucket с s3:ListAllMyBuckets. Block Public Access настраивается вне модели политик, так что ни включить его политикой, ни проверить его состояние через политику не получится.

Из этого примера два statement из трёх не работают, причём по разным причинам.

Первый statement держится на ключе и операторе, которых в VK Object Storage нет. Ключ aws:SecureTransport и оператор Bool в наборе отсутствуют, поэтому запрет нешифрованного трафика этой политикой не обеспечивается.

В VK Cloud TLS обеспечивается на двух уровнях выше политики. Первый работает на клиентской стороне. Endpoint Object Storage опубликованы по https://. Схему запроса указывает тот, кто его формирует, в конфиге aws-cli, SDK или файлового менеджера.

Второй уровень задаёт тот, кто терминирует TLS перед хранилищем: обратный прокси, ingress или сервисная сеть, где нешифрованное соединение отбивается до endpoint. Отдельной настройки бакета «только TLS» на платформе нет. Гарантированный отказ по TLS обеспечивает тот, кто терминирует его перед хранилищем, поэтому эта функция прокси остаётся на месте.

Третий statement не работает целиком. StringLike с s3:prefix не воспроизводит канонический AWS-паттерн «home folder», когда пользователь видит только свой префикс. Кстати, даже в самом AWS этот паттерн с подвохом. Он работает только в паре с явным Deny на StringNotEquals, потому что явный Deny всегда перебивает Allow, и если пользователь пытается получить ключи за пределами своего префикса, запрос отклоняется. Один Allow без парного Deny легко обходится другой политикой.

В VK Object Storage эту задачу решают через префиксные ключи доступа и wildcard в Resource. Оба варианта разбираются в итерациях 1 и 2, там же видны их ограничения.

Ключей s3:x-amz-acl и s3:x-amz-grant-* среди условий тоже нет, хотя сами заголовки в CreateBucket поддерживаются: либо canned-ACL через x-amz-acl, либо поимённые гранты через x-amz-grant-*, но не то и другое в одном запросе. Потребовать через политику, чтобы объект загружался только с bucket-owner-full-control, невозможно, а вручную выставить этот заголовок можно. Что реально применилось к объекту, политика проверить не способна. Закрывать этот разрыв приходится на стороне клиента.

Матрица истинности

Три механизма из начала статьи дают семь различимых состояний. Каждая строка описывает комбинацию состояний механизмов, а колонка «итог» показывает, что получит запрос. Столбец прав субъекта построен по AWS-модели, поэтому добавлена колонка «проверяемо на VK Object Storage». Она отмечает, какие строки читатель сможет воспроизвести на целевой платформе, а какие остаются иллюстрацией из AWS-руководства. В последней строке столбец ACL показывает второй режим, BucketOwnerEnforced, без которого сравнение режимов осталось бы неполным.

Права субъекта (AWS-рамка)

Политика бакета

ACL (при BucketOwnerPreferred)

Итог

Проверяемо на VK Object Storage

нет правила

нет правила

нет прав

403, неявный запрет

да

Allow

нет правила

нет прав

200, объединение допусков

нет: политики на субъекте в публичной модели платформы нет

нет правила

Allow

нет прав

200, объединение допусков

да

Deny (явный)

Allow

Allow

403, явный запрет финален

нет, по той же причине

Allow

Deny (явный)

Allow

403, явный запрет финален

частично: явный Deny в политике бакета финален и без первого столбца

нет правила

нет правила

Allow

200, ACL расширил доступ на втором шаге

да

нет правила

нет правила

Allow, режим BucketOwnerEnforced

403, ACL не участвует

да

Пока ACL включены, у вас есть путь выдать доступ в обход политики. В самой политике он не виден, это шестая строка. Седьмая строка отличается от шестой только режимом владения и показывает, что при BucketOwnerEnforced ACL из вычисления выпадает.

Тихая ошибка валидации

Что делает PutBucketPolicy, если в политике встречается неизвестный ключ условия или неизвестный оператор? Вариантов два: отдать ошибку валидации или принять JSON, молча проигнорировав нерабочий statement.

Вызовите get-bucket-policy сразу после put и сверьте, что именно сохранилось. Что вернулось, то и работает.

Проверять стоит и состав элементов. Структура политики содержит только Version, Id, Statement, Sid, Action, Effect, Principal, Condition и Resource, а NotPrincipal, NotAction и NotResource в перечень не входят.

Заведомо неизвестный ключ условия кладётся так:

# Кладём политику с заведомо неизвестным ключом условия
# и смотрим, что ответит API.
aws --endpoint-url https://hb.ru-msk.vkcloud-storage.ru \
 --profile owner \
 s3api put-bucket-policy \
 --bucket project-media \
 --policy file://naive-policy.json
# Затем читаем обратно и сверяем, что именно сохранилось.
aws --endpoint-url https://hb.ru-msk.vkcloud-storage.ru \
 --profile owner \
 s3api get-bucket-policy --bucket project-media

Оба исхода оставляют политику без прописанного в ней ограничителя. Если платформа выбрасывает нерабочий statement целиком, защита остаётся только в JSON и больше нигде. Если же она принимает statement, но игнорирует внутри него неизвестное условие, правило начинает работать без своего ограничителя. Третий statement из примера выше превращается в безусловный Allow на s3:ListBucket по всему бакету, а первый в безусловный Deny на s3:* для всех, включая владельца, и бакет закрывается целиком. Поэтому проверку нужно строить на запросе, который должен получить отказ, и делать это на отдельном бакете.

У AWS есть тот же класс проблемы, и она описана в документации. При service-to-service вызовах AWS вырезает сетевой контекст запроса, то есть ключи s3:TlsVersion, aws:SecureTransport, aws:SourceIp и aws:VpcSourceIp. После этого Deny по ним начинает блокировать сервисные принципалы. Лечится это оговоркой "aws:PrincipalIsAWSService": "false". На S3-совместимом хранилище такой оговорки нет, а сетевое условие снимается прогоном.

Доводим политику итерациями

Наивную политику предстоит доработать за четыре прохода. Каждый из них устраняет отдельный класс ошибок и добавляет в матрицу тестов от одной до трёх строк. К этому моменту область действия ключей для каждого принципала уже ограничена бакетом или префиксом, поэтому в политике остаются только ограничения, которые нельзя задать областью ключа.

Итерация 1. Resource

Когда политика есть, но не работает, сначала проверяют Resource. По документации VK Cloud действия для бакетов применяются только к бакетам, а действия для объектов только к объектам. ARN arn:aws:s3:::project-media описывает бакет, а arn:aws:s3:::project-media/* описывает объекты в нём. Если требуются оба типа действий, оба ресурса указывают в массиве.

{
  "Sid": "AnalystCanListAndRead",
  "Effect": "Allow",
  "Principal": { "AWS": "arn:aws:iam::mcs1234567890:user/analyst" },
  "Action": ["s3:ListBucket", "s3:GetObject"],
  "Resource": [
    "arn:aws:s3:::project-media",
    "arn:aws:s3:::project-media/reports/*"
  ]
}

Добавьте к этому плоское пространство имён. Имя бакета уникально для всего сервиса VK Object Storage, длина от 4 до 63 символов, и с именем другого бакета в сервисе оно совпасть не может. Практическое следствие в том, что имена бакетов угадываются по шаблону. Соседний project-backup из чужого проекта защищён собственной политикой, но само его существование перебором имён обнаруживается. Это стоит учитывать при выборе схемы имён.

Два ARN описывают разные ресурсы, а project* одной звёздочкой покрывает и бакет, и все его объекты.
Два ARN описывают разные ресурсы, а project* одной звёздочкой покрывает и бакет, и все его объекты.

Другой дизайн возможен. В Ceph RGW пространство имён бакетов зависит от того, заведён ли арендатор: без арендаторов все пользователи делят единое пространство, а каждому арендатору RGW выдаёт собственное. В VK Object Storage пространство имён общее для всего сервиса, поэтому wildcard в имени бакета лучше не использовать, а его поведение проверить отдельной строкой матрицы.

В матрицу тестов из этого попадает строка 14: при политике с project* аналитик запрашивает соседний бакет project-backup и должен получить 403 или 404. Рядом проверяется wildcard внутри бакета, где аналитик запрашивает объект из inbox/. Ответ 200 будет означать, что звёздочка открыла доступ к объектам, на которые права не выдавались.


S3-совместимое объектное хранилище в облаке

Масштабируйте хранилище под любой объём данных и подключайте через стандартный S3 API без изменений в коде

Получить консультацию

Итерация 2. Principal

Элемент Principal отвечает на вопрос, кому адресовано правило. В VK Object Storage у него пять форм:

Форма

Синтаксис

Кого адресует

анонимно

"*"

всех, включая неаутентифицированных

проект

{"AWS": "<ID_ПРОЕКТА>"}

всех пользователей и сервисные учётные записи проекта

аккаунт S3

arn:aws:iam::<ID_ПРОЕКТА>:user/<АККАУНТ_S3>

конкретный аккаунт

сервисный пользователь

arn:aws:iam::<ID_ПРОЕКТА>:user/serviceusers-<СЕРВИСНЫЙ_ПОЛЬЗОВАТЕЛЬ>

конкретную сервисную учётку

префиксный ключ

arn:aws:iam::<ID_ПРОЕКТА>:user/<ПРЕФИКСНЫЙ_КЛЮЧ>

владельца префиксного ключа

Пять форм Principal по ширине охвата. Вторая форма шире, чем читается.
Пять форм Principal по ширине охвата. Вторая форма шире, чем читается.

Запись {"AWS": "<ID_ПРОЕКТА>"} выглядит как выдача права проекту, но фактически адресует группу людей. В отдельной статье мы разбирали, что к одному проекту могут иметь доступ до 25 пользователей и все пользователи проекта имеют доступ ко всем бакетам проекта. К ним добавляются сервисные учётные записи: документация прямо указывает, что политика распространяется на пользователей и сервисные учетные записи проекта VK Cloud.

Лимит в 25 относится к пользователям проекта. Таблица лимитов говорит о 50 аккаунтах Object Storage на проект, а это другая сущность, хотя порядок величин близок. Principal с ID проекта адресует всех пользователей проекта и их сервисные учётные записи. Для конкретного субъекта предназначены три формы из пяти: аккаунт S3, сервисный пользователь и префиксный ключ. Анонимный * по определению не указывает на конкретного получателя.

Это самостоятельный источник утечки прав. В матрице тестов ему соответствует строка 10, где при политике с {"AWS": "<ID_ПРОЕКТА>"} запрос от второго аккаунта того же проекта должен пройти. Ожидаемый код 200 как раз показывает проблему. Форму с ID проекта часто берут именно для выдачи доступа одному сервису, потому что ID проекта виден в личном кабинете, а ARN аккаунта S3 приходится искать отдельно. В результате доступ получают все пользователи проекта и их сервисные учётные записи.

Кросс-проектный доступ. Проект, аккаунт S3 и сервисный пользователь могут указывать как на собственный проект, так и на другой проект VK Cloud. Поэтому кросс-проектный доступ для этих типов Principal задаётся одной строкой.

У префиксного ключа такой возможности нет. Согласно документации, поддерживаются только префиксные ключи, которые привязаны к одному из бакетов в проекте. Значит, кросс-проектная адресация префиксного ключа не поддерживается. В матрицу тестов отсюда добавляются два негативных сценария: чужой проект и чужой префиксный ключ.

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

У механизма есть свои ограничения: имя ключа должно содержать от 4 до 74 символов, быть уникальным для всей платформы VK Cloud во всех регионах, а создавать и удалять ключи можно через личный кабинет или API. Если указать несуществующий префикс, будет создана пустая директория с именем префикса. Требование глобальной уникальности легко упустить, а следствие у него то же, что и у имён бакетов. Имя, занятое в другом проекте, у вас уже не создастся.

Переменные в Resource. Соблазн использовать AWS-паттерн с переменной в пути понятен, но его переносимость почти нулевая. В Ceph RGW роль 12-значного account ID играет tenant, а политики для пользователей, групп и ролей не поддерживаются. Поэтому конструкция bucket/${aws:username}/* в Ceph не работает.

Переменные и интерполяция в Resource в VK Object Storage не поддерживаются. Ресурс задаётся строкой или массивом строк формата arn:aws:s3:::<РЕСУРС>, и это имена бакетов, пути к объектам и wildcard-паттерны. Подстановок вида ${aws:username} или ${aws:userid} среди поддерживаемых форм нет.

Для изоляции арендаторов здесь нужно явно перечислять бакет или префикс в Resource либо выдавать префиксный ключ, привязанный к нужному бакету. Автоматически подставить идентификатор вызывающего субъекта в путь политика не позволяет.

Итерация 3. Condition по адресу

Единственный сетевой ключ в наборе описан так: aws:SourceIp, IP-адрес клиента, с которого пришёл запрос. Он ограничивает доступ по IPv4- или IPv6-диапазонам CIDR. Для него доступны два оператора: IpAddress и обратный ему NotIpAddress. IpAddress срабатывает, если IP-адрес источника запроса входит в указанный IPv4- или IPv6-диапазон CIDR. На NotIpAddress строится последующий Deny: правило срабатывает, если адрес запроса не входит в разрешённую сеть. Поддерживаются IPv6-диапазоны вроде 2001:db8:1234::/48 и одиночные адреса: /32 для IPv4 и /128 для IPv6.

За прокси, NAT и X-Forwarded-For не всегда понятно, какой IP-адрес попадёт в условие: исходного клиента, промежуточного прокси или вся цепочка адресов. От этого зависит, какие запросы ограничит правило.

В VK Object Storage запрос проходит через несколько промежуточных компонентов. Fronts работают как Proxy в распределённом S3-кластере, а при загрузке HTTP-запрос из Nginx поступает в Hitrod, который размещён на Front-серверах. Hitrod работает на уровне Fronts: принимает S3-совместимые HTTP-запросы от Nginx, запускает их аутентификацию и авторизацию, координирует передачу объектов через Streamers и обновление метаданных, а затем возвращает результат клиенту.

Какой именно адрес попадает в aws:SourceIp и учитывается ли X-Forwarded-For, зависит от вашей топологии. Это снимается отдельным тестом на стенде.

Цепочка хопов между клиентом и точкой вычисления политики.
Цепочка хопов между клиентом и точкой вычисления политики.

Этот тест я советую снять первым:

# Политика: разрешить только из офисной сети
cat > ip-policy.json <<'JSON'
{
  "Version": "2012-10-17",
  "Statement": [{
    "Sid": "OfficeOnly",
    "Effect": "Deny",
    "Principal": "*",
    "Action": "s3:*",
    "Resource": [
      "arn:aws:s3:::project-media",
      "arn:aws:s3:::project-media/*"
    ],
    "Condition": {
      "NotIpAddress": { "aws:SourceIp": "203.0.113.0/24" }
    }
  }]
}

JSON

aws --endpoint-url https://hb.ru-msk.vkcloud-storage.ru --profile owner \
  s3api put-bucket-policy --bucket project-media --policy file://ip-policy.json
# Тест 1: запрос напрямую из офисной сети - ожидаем 200
# Тест 2: запрос из офисной сети через собственный обратный прокси,
# который стоит в другой сети, - и вот тут вопрос,
# чей адрес попал в условие
# Тест 3: запрос из другой сети напрямую - ожидаем 403

Главный тест в этой итерации второй. Если в условие вместо IP-адреса клиента попадает адрес прокси, правило работает не так, как задумано. Оно либо блокирует лишние запросы, либо пропускает лишние. Обычно это обнаруживается уже в проде. Ограничение по IP формально работает, но доступ получают все, кто выходит через один балансировщик. Похожий случай возникает при доступе через сервисную сеть, мы разбирали его в отдельной статье. Наблюдаемый источник запроса там меняется по той же причине.

Ориентиром для ревью может служить подход AWS. При проверке политики сервис исходит из того, что она публичная, и при включённом Block Public Access не принимает диапазоны шире /8 для IPv4 и /32 для IPv6, за исключением RFC 1918. В документации приведён пример 0.0.0.0/1. Порог грубый, но его можно использовать как проверяемое правило для ревью политик.

Та же ошибка возможна и без прокси. При service-to-service вызовах AWS не передаёт сетевой контекст запроса. Условие остаётся в политике, но при таком вызове не проверяется. Заметить это можно только по фактическому поведению.

Итерация 4. Explicit deny, страховка и риск

Три предыдущих прохода сужали разрешения, а этот добавляет запрет, который не отменит следующая правка политики. Основание для такой страховки разобрано выше. Явный Deny имеет приоритет над любым Allow, в том числе над правилами, которые добавят позже.

Финальная политика с Deny в начале, с учётом всех предыдущих выводов:

{
  "Version": "2012-10-17",
  "Id": "project-media-v4",
  "Statement": [
    {
      "Sid": "DenyEveryoneOutsideOffice",
      "Effect": "Deny",
      "Principal": "*",
      "Action": "s3:*",
      "Resource": [
        "arn:aws:s3:::project-media",
        "arn:aws:s3:::project-media/*"
      ],
      "Condition": {
        "NotIpAddress": { "aws:SourceIp": "203.0.113.0/24" }
      }
    },
    {
      "Sid": "WriterCanPutOnly",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::mcs1234567890:user/app-writer" },
      "Action": ["s3:PutObject", "s3:AbortMultipartUpload"],
      "Resource": "arn:aws:s3:::project-media/inbox/*"
    },
    {
      "Sid": "AnalystCanReadReportsOnly",
      "Effect": "Allow",
      "Principal": { "AWS": "arn:aws:iam::mcs1234567890:user/analyst" },
      "Action": ["s3:ListBucket", "s3:GetObject"],
      "Resource": [
        "arn:aws:s3:::project-media",
        "arn:aws:s3:::project-media/reports/*"
      ]
    }
  ]
}

У этой конструкции шесть ограничений. Каждое стоит снять до того, как политика поедет в прод.

Страховку приходится перечислять вручную. Элементов NotPrincipal, NotAction и NotResource в наборе нет, это разобрано выше. Поэтому запретить доступ всем, кроме двух субъектов, одним statement не получится. Остаётся Deny по условию, как в примере выше, или явное перечисление.

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

Пустая политика закрывает бакет. Политика без Statement не сохраняет прежний доступ, а закрывает бакет полностью.

Lifecycle сильнее Deny. Правила жизненного цикла, которые по расписанию удаляют или перемещают объекты, работают вне модели политик. В AWS прямо указано, что конфигурация S3 Lifecycle продолжает работать, даже если bucket policy запрещает все действия для всех принципалов. В VK Cloud в списке Action есть s3:GetLifecycleConfiguration и s3:PutLifecycleConfiguration, а лимит составляет 50 правил.

Приоритет Lifecycle над явным Deny нужно проверить на стенде: запретить политикой все действия, добавить Lifecycle-правило с уже истёкшим сроком и проверить, будут ли удаляться объекты. Пока результат не известен, неизменяемость архива аудит-логов обеспечивает блокировка объектов в строгом режиме с соответствующим контрактом.

Ошибка в CIDR закрывает бакет, включая владельца. Deny для Principal: "*" с условием NotIpAddress действует на всех. Опечатка в диапазоне или пропущенный IP-адрес рабочей машины лишат доступа и того, кто установил политику. Снять её в таком случае придётся через личный кабинет. Первую публикацию стоит делать на тестовом бакете.

Политика может быть сохранена, но выключена. В личном кабинете предусмотрены отдельные кнопки «Включить Bucket Policy» и «Выключить Bucket Policy», а также отдельный переключатель ACL в Ownership control ACL. JSON остаётся в консоли и после отключения Bucket Policy, поэтому возможна ситуация, когда политика видна, но на запросы не влияет. Это проверяется только запросом.

Матрица тестов: политика, принципал, действие, ожидаемый код

После четырёх итераций политика готова, но её всё ещё нужно проверить на обходы и лишние разрешения. Сохранённая политика показывает, какие права предполагалось выдать, но проверить её можно только запросом и кодом ответа. Скриншот из веб-консоли подтверждает, что разрешённое действие выполняется, однако ничего не говорит о запретах. Лишние разрешения обнаруживаются именно в негативных сценариях.

Строка матрицы задаёт принципала, действие и ожидаемый код ответа. Ожидаемые коды берутся из спецификации S3 API и документации VK Cloud. HeadBucket возвращает 200, 404 или 403, отказ по политике или ACL даёт 403 Forbidden, а листинг по умолчанию возвращает до 1000 объектов и указывает это в поле <MaxKeys>1000</MaxKeys> ответа. Это важно для негативных сценариев: пустой результат листинга и отказ в доступе это разные состояния.

Принципал

Действие

Ожидаемый код

Что проверяем

1

app-writer

PutObject в inbox/

200

базовое разрешение работает

2

app-writer

PutObject в reports/

403

Resource ограничивает префиксом

3

app-writer

GetObject из reports/

403

запись не даёт чтения

4

analyst

ListBucket

200

право на бакет

5

analyst

GetObject из reports/

200

право на объект

6

analyst

GetObject из inbox/

403

Resource с /* работает точно

7

analyst

DeleteObject

403

действие вне списка

8

аккаунт другого проекта

GetObject

403

кросс-проектный отказ

9

чужой префиксный ключ

GetObject

403

префиксный ключ не адресуется кросс-проектно

10

второй аккаунт своего проекта, политика с {"AWS": "<ID_ПРОЕКТА>"}

GetObject

200

Principal на проект покрывает всех пользователей проекта и их сервисные учётки, ожидаемое 200 показывает саму проблему

11

app-writer, адрес вне разрешённого

PutObject

403

Condition по адресу держит

12

app-writer через свой обратный прокси

PutObject

?

чей адрес попал в условие

13

анонимно, без ключей

GetObject

403

Principal: "*" нигде не забыт

14

analyst с аккаунтным ключом

HeadBucket на project-backup

403 или 404

wildcard в Resource не цепляет чужой бакет

15

owner

PutBucketPolicy с неизвестным ключом Condition

?

контракт валидации

16

app-writer, политика выключена тумблером

PutObject

?

сохранённый JSON не применяется

Строки 12, 15 и 16 стоят со знаком вопроса и снимаются первыми, ещё до того, как прокси выключается.

В матрице нет отдельных строк для прав субъекта, потому что на стороне хранилища их определяет область действия ключа. Поэтому в прогоне ниже owner использует аккаунтный ключ, а app-writer и analyst ключи бакета. Префиксный ключ для analyst намеренно не используется. Иначе в строках 4 и 6 запрос был бы отклонён областью действия ключа ещё до проверки Resource, и тест ничего бы не показал.

По той же причине строка 14 запускается под отдельным профилем с аккаунтным ключом. Ключ бакета сам закрыл бы доступ к чужому project-backup, независимо от политики. Префиксный ключ появляется только в кейсе 9, и там он выступает как чужой ключ.

Значения в столбцах взяты из документации VK Cloud и спецификации операций S3. Три знака вопроса отмечают случаи без публичного контракта: валидацию PutBucketPolicy, адрес за прокси и поведение отключённого переключателя политики. Валидация различается у S3-совместимых хранилищ, наблюдаемый адрес зависит от топологии, а состояние переключателя меняется вручную в личном кабинете, поэтому в скрипт оно не вошло. Остальные перечни операторов, ключей и действий сверены по ссылкам в тексте.

Put-bucket-policy, get-bucket-policy и delete-bucket-policy требуют обязательного указания endpoint, поэтому в примерах ниже используется флаг --endpoint-url. Профили для каждого принципала готовятся заранее: аккаунты VK Object Storage и их ключи создаются в личном кабинете. Там же указан ID проекта вида mcs1234567890, который подставляется в ARN.

#!/usr/bin/env bash
# Прогон матрицы политик по S3-совместимому хранилищу.
# Профили aws-cli готовятся заранее: по одному на принципала.
# На бакете лежит финальная политика из итерации 4.
# Подготовка и кейсы 1-10, 13, 14, 15 запускаются с хоста внутри
# разрешённого диапазона, кейс 11 - с хоста вне него, кейс 12 - с хоста,
# который ходит через ваш обратный прокси. Кейса 16 в скрипте нет:
# тумблер политики переключается руками в личном кабинете.
set -u
EP="${EP:-https://hb.ru-msk.vkcloud-storage.ru}"
BUCKET="${BUCKET:-project-media}"
PROBE_BUCKET="${PROBE_BUCKET:-project-media-probe}"   # отдельный бакет под кейс 15
# Подготовка: объект в reports/ кладёт владелец бакета. Кейсами матрицы
# его не создать, у app-writer прав на этот префикс нет.
aws --endpoint-url "$EP" --profile owner s3api put-object \
  --bucket "$BUCKET" --key reports/2026-07.csv --body /dev/null

# Один кейс: профиль, ожидаемый код, команда с аргументами.
run_case () {
  local profile="$1" expected="$2"; shift 2
  local out code rc
  out=$(aws --endpoint-url "$EP" --profile "$profile" "$@" 2>&1); rc=$?
  # aws-cli печатает код в тексте ошибки: у HEAD-операций это HTTP-код,
  # у остальных - код ошибки S3. Всё неразобранное с ненулевым выходом
  # считаем ERR, чтобы сбой запуска не выглядел как успешный запрос.
  if [[ "$out" == "(403)"  "$out" == "AccessDenied" ]]; then code=403
  elif [[ "$out" == "(404)"  "$out" == "NoSuchBucket" ]]; then code=404
  elif [[ "$out" == "(409)"  "$out" == "BucketNotEmpty" ]]; then code=409
  elif [[ "$out" == "error"  "$out" == "Error" || $rc -ne 0 ]]; then code="ERR"
  else code=200
  fi
  # В expected можно перечислить допустимые коды через «|».
  if [[ "$expected" == "?" ]]; then
    printf 'OPEN %-14s %-46s -> %s\n' "$profile" "$*" "$code"
  elif [[ "|$expected|" == "|$code|" ]]; then
    printf 'PASS %-14s %-46s -> %s\n' "$profile" "$*" "$code"
  else
    printf 'FAIL %-14s %-46s -> %s (ждали %s)\n' \
      "$profile" "$*" "$code" "$expected"
  fi
}

# 1-3. Сервис записи
run_case app-writer 200 s3api put-object --bucket "$BUCKET" \
  --key inbox/probe.bin --body /dev/null
run_case app-writer 403 s3api put-object --bucket "$BUCKET" \
  --key reports/probe.bin --body /dev/null
run_case app-writer 403 s3api get-object --bucket "$BUCKET" \
  --key reports/2026-07.csv /dev/null

# 4-7. Аналитик. HeadObject авторизуется правом s3:GetObject,
# а HTTP-код у HEAD-операций читается из текста ошибки надёжнее.
run_case analyst 200 s3api list-objects-v2 --bucket "$BUCKET" --max-items 1
run_case analyst 200 s3api head-object --bucket "$BUCKET" \
  --key reports/2026-07.csv
run_case analyst 403 s3api head-object --bucket "$BUCKET" --key inbox/probe.bin
run_case analyst 403 s3api delete-object --bucket "$BUCKET" \
  --key reports/2026-07.csv

# 8-9. Чужие принципалы
run_case foreign-project 403 s3api head-object --bucket "$BUCKET" \
  --key reports/2026-07.csv
run_case foreign-prefix-key 403 s3api head-object --bucket "$BUCKET" \
  --key reports/2026-07.csv

# 10. Principal на проект. Кейс имеет смысл только под политикой,
# в которой стоит форма {"AWS": "<ID_ПРОЕКТА>"}: тогда право получают все
# пользователи проекта и их сервисные учётные записи. project-peer - профиль
# второго аккаунта Object Storage в вашем же проекте, и ожидаемое 200 здесь
# и есть демонстрация проблемы, а не подтверждение корректной настройки.
run_case project-peer 200 s3api head-object --bucket "$BUCKET" \
  --key reports/2026-07.csv

# 11-12. Condition по адресу. Первый кейс запускается с хоста вне
# разрешённого диапазона, второй - с хоста, который ходит через ваш
# обратный прокси. С того же хоста, что кейсы 1-10, оба кейса бессмысленны.
run_case app-writer 403 s3api put-object --bucket "$BUCKET" \
  --key inbox/from-outside.bin --body /dev/null
run_case app-writer-via-proxy '?' s3api put-object --bucket "$BUCKET" \
  --key inbox/via-proxy.bin --body /dev/null

# 13. Анонимный доступ: подпись не ставится вообще, ключи профиля
# не используются, поэтому профиль anonymous достаточно объявить
# в ~/.aws/config без ключей.
run_case anonymous 403 --no-sign-request s3api head-object \
  --bucket "$BUCKET" --key reports/2026-07.csv

# 14. Wildcard в Resource не должен цеплять чужой бакет. Профиль
# analyst-account держит аккаунтный ключ: под ключом бакета чужой
# project-backup закрыт областью ключа, и проверять было бы нечего.
# Внутри бакета тот же wildcard проверяется кейсом 6: под project*
# он вернёт 200 вместо 403.
run_case analyst-account '403|404' s3api head-bucket --bucket project-backup

# 15. Контракт валидации политики. Идёт на отдельном бакете: наивная
# политика может закрыть его целиком, включая владельца.
# PROBE_BUCKET создаётся заранее и после прогона удаляется.
run_case owner '?' s3api put-bucket-policy --bucket "${PROBE_BUCKET:-project-media-probe}" \
  --policy file://naive-policy.json

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

Отдельная строка пригодится тем, кто привык к AWS. При Bucket owner enforced попытка выполнить PUT с неподдерживаемым ACL возвращает 400 AccessControlListNotSupported. Совпадает ли это поведение с вашим хранилищем, покажет тот же скрипт. В VK Object Storage ACL поддерживаются и для бакетов, и для объектов, поэтому код ответа может отличаться.

Доля запросов, отклонённых на стороне хранилища

Матрицу запускают вручную и в конкретный момент времени. Чтобы постоянно контролировать перенос, достаточно одной метрики. Это доля отклонённых запросов среди всех запросов к хранилищу с разбивкой по принципалу и бакету.

До переноса отказы выдаёт прокси, а хранилище возвращает 200 на все запросы, потому что политики ещё нет. После переноса часть отказов должна приходить от самого хранилища. Если доля отказов не изменилась, возможны два варианта: политика не применилась или весь трафик по-прежнему проходит через прокси, а прямой доступ к хранилищу не закрыт.

В модели политик нет отдельного построчного журнала запросов к бакету. Действия s3:GetBucketLogging и s3:PutBucketLogging не входят в список Action, поэтому аналога AWS Server Access Logging здесь не будет. Считать метрику нужно по платформенному аудиту.

  • Что собирать. В журнал Cloud Audit попадают отказы при запросах к объектам: событие, код ответа, идентификатор субъекта и идентификатор ресурса. Этих полей достаточно, чтобы построить долю отказов.

  • Чем фильтровать. В языке поисковых запросов доступны поля status.code, subject.user_id, resource.id и resource.type. Пример из документации: resource.type: "s3" OR severity >= WARNING.

  • Как выгружать. Журнал можно скачать в TSV, а в SIEM передавать в форматах CEF или RAW по Syslog RFC 5424. Доступны каналы TCP, UDP и TCP over TLS. TSV-выгрузка ограничена 1000 записями на файл. Для метрики доли отказов на живом проекте этого мало, поэтому я рекомендую поток в SIEM.

В числитель входят события с resource.type: "s3" и кодом отказа, в знаменатель все события того же типа. Разбивку нужно делать по subject.user_id и resource.id.

У метрики есть ограничение: по этим полям нельзя определить причину отказа. В числитель попадут и 403 из-за политики, и ошибки неверной подписи или просроченного ключа, и отказы при обращении к несуществующему бакету. Cloud Audit не передаёт причину отдельным полем, а status.code содержит только код ответа.

Поэтому долю отказов стоит считать только для subject.user_id тех принципалов, которых переносили. В срезе всего проекта ошибки подписей и просроченных ключей заглушат отказы по политике. Метрика показывает лишь направление: она позволяет увидеть, что отказы переместились с прокси на хранилище. На этом её применимость заканчивается.

Если метрика нужна для исторического тренда, срок хранения журнала нужно уточнить в поддержке до того, как на нём будет построена отчётность.

Архив самих логов мы рекомендуем хранить в бакете с блокировкой объектов в строгом режиме COMPLIANCE. Инструкция по выгрузке в ArcSight предназначена для соответствия требованиям политик безопасности, включая PCI DSS и ПДн.

152-ФЗ и меры ФСТЭК

Если в бакете лежат персональные данные, связка из политики бакета и аудит-лога закрывает часть требований. Статья 19, часть 2, пункт 8 152-ФЗ требует установить правила доступа к ПДн в ИСПДн и обеспечить регистрацию действий с ними. В приказе ФСТЭК России № 21 от 18.02.2013 это конкретизировано мерами УПД.2 (правила разграничения доступа) и УПД.5 (минимально необходимые права), обязательными для всех четырёх уровней защищённости из постановления Правительства № 1119 от 01.11.2012. Меры группы РСБ по составу и хранению событий применяются в объёме, установленном для конкретного уровня.

ФСТЭК не требует именно bucket policy, требования сформулированы через меры защиты. И политика бакета закрывает лишь часть меры разграничения доступа, не заменяя ни аттестацию, ни оценку соответствия ИСПДн, ни сертификацию средств защиты, ни модель угроз.

Для аудитора нужны три выгрузки:

  • Действующая политика с датой получения, то есть вывод get-bucket-policy

  • Журнал Cloud Audit за период проверки с фильтром resource.type: "s3" и полями subject.user_id, status.code

  • Протокол прогона матрицы с фактическим кодом ответа для каждого запрещённого действия

Первые две выгрузки подтверждают, что правила заданы и события фиксируются. Протокол прогона показывает, что правила работают в соответствии с этими настройками.

Перенос на другое S3-совместимое хранилище

Свойство

AWS S3

Ceph RGW

MinIO

Yandex Object Storage

VK Object Storage

Порядок Deny/Allow

explicit deny финален, дефолт implicit deny

Deny раньше Allow

«every Deny before any Allow»

Deny, затем Allow, иначе отказ

дефолт назван «явный запрет (Deny)»

Роль ACL

по умолчанию отключены

поддерживаются

поддерживаются

могут расширить доступ к объекту

canned private, в Preferred проверяются после политики

Переменные в политике

есть

нет, «do not yet support»

нет, только wildcard

${aws:userid} в Resource

не описаны

Ключи Condition

десятки, вкл. s3:TlsVersion и s3:prefix

8 общих, плюс 2 при Keystone

поштучно на Action

Bool с aws:SecureTransport, свои yc:OriginIp и yc:access-key-id

7 операторов, 5 ключей, 4 про object lock

Модель арендатора

account, общее пространство имён

tenant, своё пространство имён

на пользователя или группу

каталог уровнем выше хранилища

проект, аккаунт S3, префиксный ключ, имя бакета уникально в сервисе

Лимит документа

опубликован

не заявлен отдельно

20 KiB

опубликован

публично не заявлен

При переносе политики сначала проверьте ACL. Этот механизм может выдать доступ без единой ошибки в логах. В VK Cloud ACL участвует в проверке доступа в режиме BucketOwnerPreferred, и возможны конфликты между политикой и ACL. Перед переносом политики нужно проверить режим ACL на приёмнике. Иначе доступ может появиться там, где его нет в самой политике.

Второе расхождение связано с изменением модели арендаторов между релизами. В итерации 2 видно только следствие, роль account ID в Ceph RGW играет tenant. После релиза Tentacle, то есть Ceph v20, вышли v20.2.1 от 6 апреля 2026 года и v20.2.2 от 16 июня 2026 года. Поэтому в чек-листе переноса должен быть отдельный пункт про то, на какой релиз выполняется миграция.

Чек-лист перед переносом политики на другое S3-совместимое хранилище:

  • Сверить поддерживаемые операторы и ключи Condition. Всё, чего нет в списке, убрать из политики и закрыть другим уровнем

  • Проверить реакцию API на неизвестный ключ: отказ или принятие политики без ошибки

  • Проверить режим ACL на приёмнике и отключить ACL, если это возможно

  • Проверить, включает ли ресурс бакета его объекты, и при необходимости явно перечислить оба ARN

  • Убедиться, что wildcard в Resource не покрывает лишние ресурсы, включая объекты внутри бакета

  • Сверить формы Principal и схему ARN на приёмнике, а главное выяснить, кого каждая форма адресует на практике. В VK Object Storage {"AWS": "<ID_ПРОЕКТА>"} охватывает всех пользователей проекта и их сервисные учётные записи, в MinIO политика привязывается к пользователю или группе, в Ceph роль account ID выполняет tenant

  • Проверить, какой адрес попадает в сетевое условие в вашей схеме с прокси

  • Зафиксировать релиз и версию платформы-приёмника, потому что модель мультиарендности может меняться между версиями

Отрасль движется к управлению доступом по тегам. AWS развивал этот подход в 2025 году: 31 июля появилась поддержка тегов S3 Access Points для attribute-based access control, 6 ноября теги для S3 Tables. Ещё раньше, 13 ноября 2024 года, появился RCP, новый тип политики над бакетами. В наборе условий VK Object Storage и Yandex Object Storage доступа по тегам нет.

Восемь мест, где теряется ограничение

Шесть из этих восьми закрывает матрица тестов, два остальных находит только ревью политики.

  1. Resource без объектов или без бакета. Права на бакет и права на объекты это разные вещи (итерация 1).

  2. Wildcard, цепляющий лишнее. Запись project* покрывает и бакет, и все его объекты одной звёздочкой (итерация 1). Чужой бакет проверяется строкой 14 под аккаунтным ключом, объекты внутри своего строкой 6.

  3. Principal на проект вместо конкретного субъекта. Запись {"AWS": "<ID_ПРОЕКТА>"} читается как «право проекту», а выдаёт право всем пользователям проекта и их сервисным учёткам (итерация 2).

  4. Condition по адресу за прокси. За прокси, NAT или балансировщиком в aws:SourceIp может попасть адрес промежуточного узла. Тогда правило начнёт пропускать или блокировать не те запросы (итерация 3).

  5. Неподдерживаемый ключ, который выглядит защитой. JSON выглядит рабочим, а условие на запрос не влияет (секция про набор проверок).

  6. Забытый парный Deny. Ограничение стоит на Deny, политику расширили, Deny не обновили (итерация 4).

  7. ACL как второй шанс. В режиме BucketOwnerPreferred доступ выдаётся в обход политики (секция про три механизма).

  8. Политика написана и выключена. JSON в консоли виден, а тумблер выключен (итерация 4).

Что остаётся за прокси

После переноса прокси не всегда можно отключить. За ним остаются четыре функции, которых нет в политике бакета.

Кастомная логика доступа. Проверка подписки остаётся в приложении. Политика бакета не знает, активна ли она у клиента, и не может принять решение на основании этого условия.

Rate limiting по принципалу. Платформенный лимит действует на уровне проекта, а не отдельного субъекта.

Аудит с бизнес-контекстом. Cloud Audit фиксирует технические поля. Идентификатор заказа, арендатора или внутреннего пользователя добавляет прокси.

Гарантированный запрет HTTP. Клиент можно настроить на HTTPS, но принудительно отклонять незашифрованные запросы способен только компонент, который завершает TLS. Если такой запрет нужен как технический контроль, а не как договорённость с разработчиками, прокси придётся оставить.

Политики, команды и скрипт прогона выше можно взять за основу для другого S3-совместимого хранилища, но перед запуском нужно сверить поддерживаемые ключи условий, формы Principal, схему ARN и наличие интерполяции. Эти различия и должен выявить первый прогон.

Bucket Access Policy в VK Object Storage настраивается и через S3 API, и в личном кабинете. Ниже скриншот раздела, где политика включается и где переключается режим Ownership control ACL.

Итоги

Правила доступа действуют там, где их проверяют. Конфиг прокси для хранилища не существует. Пока действует хоть одна копия ключа, запрос на S3 endpoint проверяется только тем, что задано на самом бакете.

Порядок работы обратный привычному в AWS. В VK Object Storage сначала выдаётся ключ с минимальной областью действия (аккаунт, бакет или префикс), и только потом на остаток пишется политика. Если аналитику нужен один префикс, префиксный ключ закрывает задачу сам, и писать под него политику не приходится.

Знакомый JSON из AWS-руководства переносится не целиком. В разобранном примере из трёх statement полноценно работает один: ключа aws:SecureTransport и оператора Bool в наборе VK Object Storage нет, а StringLike с s3:prefix не воспроизводит паттерн «home folder».

ACL открывает второй путь к данным, невидимый в политике. Режим BucketOwnerEnforced убирает его из вычисления, поэтому режим владения на бакете мы проверяем до публикации политики.

Явный Deny и дефолтный отказ ведут себя по-разному. Первый не отменяется никаким Allow, второй снимается любым подходящим Allow. Политика перестаёт ограничивать там, где её расширили через полгода, а парный Deny не обновили.

Четыре функции прокси не переносятся. Бизнес-логика допуска, лимит на конкретного принципала, аудит с бизнес-контекстом и гарантированный отказ нешифрованному соединению политикой бакета не задаются. Выключать прокси стоит по одной функции, и каждую сначала закрывать на новом уровне.

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

Следующий шаг. Возьмите тестовый бакет, включите на нём BucketOwnerEnforced, положите финальную политику из итерации 4 и прогоните скрипт матрицы. Первый прогон закрывает три строки со знаком вопроса: реакцию PutBucketPolicy на неизвестный ключ условия, адрес, который попадает в aws:SourceIp в вашей топологии, а также поведение выключенного тумблера Bucket Policy. После этого матрица становится регрессионным тестом, и её достаточно перезапускать после каждой правки политики.

Комментарии (0)