
Бакет в S3 — не просто папка для файлов, а ресурс, на котором хранилище принимает решение о доступе к объектам. Пока доступ контролирует только nginx или собственный прокси, у любого обладателя действующего ключа остаётся прямой путь к S3 endpoint. Достаточно указать другой --endpoint-url в aws-cli. Прокси не увидит запрос, а хранилище вернёт 200, если на самом бакете нет правил, способных его остановить.
Меня зовут Стас Погоржельский, я технологический евангелист VK Cloud. В статье разберём, как вернуть авторизацию туда, где лежат данные: выдать ключи минимальной области — на бакет или префикс — и дополнить их Bucket Access Policy. Посмотрим, что из шести функций прокси действительно переезжает в хранилище, что переносится лишь частично, а что остаётся снаружи: бизнес-логика допуска, лимиты на конкретного принципала, аудит с бизнес-контекстом и гарантированный запрет нешифрованного трафика.
Дальше разложим решение endpoint на политику бакета, область действия ключа и ACL; отделим явный Deny от запрета по умолчанию, проверим, как режим OwnershipControls может выдать доступ в обход политики. На примере AWS-политики покажем, почему знакомый JSON нельзя переносить без ревью: у VK Object Storage ограничен набор Condition, а aws:SecureTransport и s3:prefix не работают. Затем соберём политику четырьмя итерациями — Resource, Principal, aws:SourceIp и явный Deny — и подтвердим результат матрицей из 16 запросов с ожидаемыми 200, 403 и открытыми вопросами, которые надо снять на своём стенде.
В конце настроим наблюдение по Cloud Audit, разберём, какие доказательства переноса пригодятся для ИБ и аудита, и составим чек-лист для переезда между AWS S3, Ceph, MinIO, Yandex Object Storage и VK Object Storage. Главный критерий успеха здесь не сохранённый JSON и не работающий разрешённый запрос, а отказ там, где доступ должен быть закрыт.
Что переедет с прокси в политику бакета S3, а что останется
Возьмите ключи из переменных окружения приложения, укажите в aws s3api head-bucket endpoint https://hb.ru-msk.vkcloud-storage.ru — и получите 200. Запрос пойдёт прямо в хранилище, минуя прокси и его таблицу прав. Если правила заданы только в nginx, а на бакете нет политики, endpoint не знает, что этому ключу следовало отказать.
При переносе правил в хранилище важно не переписать JSON из AWS-документации без проверки. Согласно политикам доступа, в VK Object Storage для сетевых условий есть только aws:SourceIp. Условий aws:SecureTransport и s3:prefix в наборе нет: политика с ними может выглядеть привычно, но нужного ограничения не даст. Из типового примера AWS с тремя statement здесь полноценно работает только один.
Проверять перенос надо не разрешённым запросом, а тем, который обязан получить отказ. На endpoint доступ определяют три механизма, но на этой платформе доступны два, а один можно отключать. Разрешения складываются: если доступ выдала политика или ACL, запрос пройдёт. В статье разберём восемь мест, где при таком переносе теряется ограничение: шесть проверим матрицей из 16 запросов, ещё два выявим ревью политики. Три проверки останутся открытыми до прогона на вашем стенде — результат зависит от реализации, топологии и настройки в личном кабинете. И даже после переноса прокси обычно не исчезает: четыре его функции политикой бакета не заменить.
Функции прокси перед хранилищем
Такую схему часто оставляют после переезда из собственного дата-центра в объектное хранилище. Перед S3 ставят прокси, и приложения работают только через него. Прокси проверяет токен, сверяется со своей таблицей прав и решает, кому доступны бакет и префикс. Заодно он ведёт общий журнал и ограничивает частоту запросов.
Правила при этом остаются в конфиге nginx или в коде внутреннего сервиса. В самом хранилище используется аккаунт с полными правами, а его ключи прописаны в прокси. Потом та же ключевая пара появляется в CI, .env разработчиков и разовых скриптах. Любая такая копия позволяет обратиться к S3 endpoint напрямую.
У прокси в этой схеме шесть задач. Правила доступа почти целиком можно перенести в политику бакета. Лимиты и аудит переносятся только отчасти. Необходимость скрывать общий ключ исчезает, если выдать каждому потребителю отдельный ключ бакета или префикса. Бизнес-правила и терминация TLS остаются за прокси.
Аутентификация и правила доступа. Это основная часть переноса. Доступ к бакету и объектам начинает проверять само хранилище, а не только внешний сервис.
Ограничение частоты запросов. У Object Storage уже есть лимиты: 1000 запросов в секунду на обычные операции и 250 — на листинг. Лимиты указаны базовые и могут быть увеличены через менеджера. Они считаются внутри сервиса, поэтому отдельный сайдкар нужен не всегда. Но это общий лимит на проект. Настроить в нём отдельную квоту для конкретного принципала или префикса нельзя — если такая гранулярность нужна, её по-прежнему обеспечивает прокси.
Сокрытие ключей от клиентов. Политика бакета этого не делает. Но при отказе от общего аккаунтного ключа задача обычно исчезает сама: сервису выдают ключ бакета, а потребителю конкретной директории — префиксный ключ. Секретный ключ показывается только при создании и не восстанавливается; для ротации к аккаунту можно привязать две ключевые пары.
Бизнес-логика допуска. Правило вида «разрешить доступ клиенту с активной подпиской» зависит от состояния вашей базы. В 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 и https://hb.kz-ast.vkcloud-storage.ru. Пока правила заданы только на прокси, любой, у кого есть валидная пара ключей, обращается на endpoint напрямую и прокси в этом пути не участвует.
Обычно это происходит без намерения обойти защиту. Инженеру нужно выгрузить дамп локальным скриптом, а ключи уже лежат в переменных окружения. Проще запустить aws-cli с другим 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. Там работают три механизма, а их разрешения складываются: доступ дают, если разрешил хотя бы один из них.
Права субъекта (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. Этот порядок можно считать надёжной опорой при переносе политики — даже там, где остальной набор ключей и условий у платформ расходится.
Два смысла явного запрета
В документации VK Cloud описано: если для действия нет подходящего правила, по умолчанию применяется явный запрет (Deny). А чуть выше в том же разделе сказано, что каждому правилу политики доступа в параметре Effect назначается явное разрешение (Allow) или запрет (Deny).
Слово «явный» здесь встречается в двух разных местах, но описывает разное поведение. Запрет, который вы сами прописали в Effect, соответствует explicit deny из модели AWS: его не отменит никакое разрешение. А дефолтное состояние — когда ни одно правило не подошло — на деле работает как implicit deny: его снимет любой подходящий Allow.
Если запрет стоит в политике явно, добавление Allow в другом месте его не перебьёт. Если же запрет — это просто отсутствие подходящего правила, достаточно одного Allow, чтобы доступ появился. Документация называет оба случая «явным запретом», но по последствиям это два разных механизма.
Место ACL
У VK Cloud порядок проверки между политикой и ACL задаёт параметр бакета OwnershipControls. Он же определяет, участвует ли ACL в проверке доступа вообще.
В режиме BucketOwnerEnforced используется только политика доступа, ACL не используется (отключен). В режиме BucketOwnerPreferred доступ сначала проверяется по политике, а если она разрешения не дала, срабатывает ACL. Порядок здесь важен: политика идёт первой, а ACL может только добавить доступ, но не отнять его.
Вендор сам предупреждает, что использование BucketOwnerPreferred может вызывать конфликты между политикой доступа и ACL, и рекомендует по возможности отключать ACL на бакете. Совет разумный, поэтому дальше в статье всюду используется режим BucketOwnerEnforced.
Здесь важно, что дефолты у платформ разные. В AWS Object Ownership по умолчанию стоит Bucket owner enforced, ACL для новых бакетов выключены, а обратное включение восстанавливает ранее сохранённые ACL. В VK Cloud новый бакет получает canned ACL private. Разница выглядит незначительной, но именно из-за неё перенос политики может сработать иначе, чем ожидалось. Поэтому режим ACL на бакете-приёмнике стоит проверять отдельно, не полагаясь на то, что политика ведёт себя одинаково на обеих платформах.

Кто отклонил запрос
По ошибке 403 невозможно понять, что чинить: политику, ACL или права субъекта. В таком случае обычно сначала правят политику, потом ACL, потом заново выдают ключи — и непонятно, на каком именно шаге ситуация налаживается.
AWS дважды улучшал сообщения об отказе: 21 августа 2024 года вышли Enhanced access denied error messages for same-account requests, а 16 июня 2025 года такие же сообщения появились для запросов внутри одной организации.
Помогает уже разделение 403 и 404. По документации, HeadBucket возвращает 200, если бакет существует и доступ к нему разрешён, иначе — 404 либо 403. Выбор между этими двумя кодами протокол не фиксирует, каждый вендор решает сам. Например, в 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 Cloud полноценно работает только одно.
Что реально доступно в наборе проверок
Условия срабатывания правила задаются в блоке Condition. У VK Cloud там семь операторов: 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 Cloud насчитывает 47 позиций. В таблице префикс записан как S3:, но регистр в политиках не имеет значения, поэтому дальше в тексте использую строчный s3:. В этом списке нет s3:PutBucketPublicAccessBlock и s3:GetBucketPublicAccessBlock (доступен только s3:GetBucketPolicyStatus), s3:GetBucketLogging и s3:PutBucketLogging, s3:CreateBucket, s3:ListAllMyBuckets. Block Public Access настраивается вне модели политик, так что ни включить его политикой, ни проверить его состояние через политику не получится.
Из наивного примера два statement из трёх ломаются, причем по разным причинам.
Первый statement держится на ключе и операторе, которых у провайдера нет. Ключ aws:SecureTransport и оператор Bool в наборе VK Cloud отсутствуют, поэтому запрет нешифрованного трафика этой политикой не обеспечивается.
На 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 Cloud эту задачу решают иначе — через префиксные ключи доступа и wildcard в Resource. Оба варианта разбираются в итерациях 1 и 2, и там же видны их ограничения.
Ключей s3:x-amz-acl и s3:x-amz-grant-* среди условий тоже нет, хотя сами заголовки поддерживаются в CreateBucket — правда, использовать оба способа одновременно нельзя. Потребовать через политику, чтобы объект загружался только с 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 |
200, ACL не участвует |
нет, по той же причине |
Пока ACL включены, у вас есть путь выдать доступ в обход политики. В самой политике он не виден, это шестая строка. Седьмая строка отличается от второй только режимом владения и показывает, что при BucketOwnerEnforced ACL из вычисления выпадает.
Тихая ошибка валидации
Но что делает PutBucketPolicy, если в политике встречается неизвестный ключ условия или неизвестный оператор — отдаёт ошибку валидации или принимает JSON, молча игнорируя нерабочий statement?
Страница «Политики доступа» перечисляет допустимые значения, инструкция описывает put-bucket-policy, а раздел API покрывает ACL, Bucket, CORS, Lifecycle, Multipart, Object, Object Lock, Prefix access keys, Tagging и Webhooks — но ни один из них прямо не отвечает на вопрос про валидацию. Ответ даёт get-bucket-policy, вызванный сразу после put.
Элементы NotPrincipal, NotAction и NotResource в перечень не входят: сама структура содержит только Version, Id, Statement, Sid, Action, Effect, Principal, Condition и Resource.
Заведомо неизвестный ключ условия кладётся так:
# Кладём политику с заведомо неизвестным ключом условия # и смотрим, что ответит API. aws --endpoint-url [https://hb.ru-msk.vkcloud-storage.ru](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](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 Cloud wildcard работает и по префиксу, и по суффиксу ключа объекта. Допустимы формы arn:aws:s3:::<ИМЯБАКЕТА>/<ЧАСТЬКЛЮЧАОБЪЕКТА>/*, arn:aws:s3:::<ИМЯБАКЕТА>/<ЧАСТЬКЛЮЧАОБЪЕКТА>* и arn:aws:s3:::<ИМЯБАКЕТА>/*<ЧАСТЬКЛЮЧАОБЪЕКТА>. Суффиксная форма шире типичных AWS-примеров: под правило *.csv попадает любой объект с таким окончанием ключа в любой директории бакета.
Отдельная ловушка — wildcard в имени бакета. Запись arn:aws:s3:::project* покрывает и бакет project-media, и любой ключ внутри него: звёздочка сопоставляет остаток строки вместе со слэшем. Так одна звёздочка стирает разделение бакета и объектов, с которого начинается эта итерация. Правило, задуманное для листинга, одновременно открывает доступ к объектам. Это мой личный опыт.
Я сталкивался с этим сам. Звёздочка в имени бакета выглядит компактнее двух ARN в массиве, но обходится существенно дороже. Насколько широко такая запись трактуется в S3-совместимых системах, показывает MinIO: «arn:aws:s3:::data* would match the buckets data, data_private, and data_internal». Речь там идёт о политиках пользователей и групп, которых нет в VK Object Storage, но синтаксис остаётся общим.
Добавьте к этому плоское пространство имён: имя бакета должно быть уникальным для всего сервиса VK Object Storage, иметь длину от 4 до 63 символов и не совпадать с именем другого бакета в сервисе. Соседний project-backup из чужого проекта защищён собственной политикой, но перебор имён по вашему шаблону всё равно укладывается в список арендаторов.

Другой дизайн возможен, например, в Ceph все арендаторы используют единое пространство имён. RGW предоставляет каждому арендатору собственное пространство имён бакетов. В VK Cloud экранирования нет, поэтому wildcard в имени бакета лучше не использовать.
В матрицу тестов из этого попадает строка 14: при политике с project* аналитик запрашивает соседний бакет project-backup и должен получить 403 или 404. Рядом проверяется wildcard внутри бакета: аналитик запрашивает объект из inbox/. Ответ 200 будет означать, что звёздочка открыла доступ к объектам, на которые права не выдавались.
Итерация 2. Principal
Элемент Principal отвечает на вопрос, кому адресовано правило. На VK Cloud у него пять форм:
Форма |
Синтаксис |
Кого адресует |
|---|---|---|
анонимно |
"*" |
всех, включая неаутентифицированных |
проект |
{"AWS": "<ID_ПРОЕКТА>"} |
всех пользователей и сервисные учётные записи проекта |
аккаунт S3 |
arn:aws:iam::<ID_ПРОЕКТА>:user/<АККАУНТ_S3> |
конкретный аккаунт |
сервисный пользователь |
arn:aws:iam::<ID_ПРОЕКТА>:user/serviceusers-<СЕРВИСНЫЙ_ПОЛЬЗОВАТЕЛЬ> |
конкретную сервисную учётку |
префиксный ключ |
arn:aws:iam::<ID_ПРОЕКТА>:user/<ПРЕФИКСНЫЙ_КЛЮЧ> |
владельца префиксного ключа |

Запись {"AWS": "<ID_ПРОЕКТА>"} выглядит как выдача права проекту, но фактически адресует группу людей. Согласно Мы уже писали, что к одному Проекту могут иметь доступ до 25 пользователей и все пользователи Проекта имеют доступ ко всем бакетам Проекта. К ним добавляются сервисные учётные записи: документация прямо указывает, что политика распространяется на пользователей и сервисные учетные записи проекта VK Cloud.
Лимит в 25 относится к пользователям проекта. Таблица лимитов говорит о 50 аккаунтах Object Storage на проект, а это другая сущность, хотя порядок величин близок. Principal с ID проекта адресует всех пользователей проекта и их сервисные учётные записи. Для конкретного субъекта предназначены три формы из пяти: аккаунт S3, сервисный пользователь и префиксный ключ. Анонимный * по определению не указывает на конкретного получателя.
Это самостоятельный источник утечки прав. В матрице тестов ему соответствует строка 10: при политике с {"AWS": "<ID_ПРОЕКТА>"} запрос от второго аккаунта того же проекта должен пройти. Ожидаемый код 200 и показывает проблему. Если такая форма использовалась в значении «выдать право одному сервису», доступ получат все пользователи и сервисные учётные записи проекта.
{"AWS": "<ID_ПРОЕКТА>"} часто используют для выдачи доступа одному сервису: ID проекта виден в личном кабинете, тогда как ARN аккаунта S3 приходится искать отдельно.
Кросс-проектный доступ. Проект, аккаунт S3 и сервисный пользователь могут указывать как на собственный проект, так и на другой проект VK Cloud. Поэтому кросс-проектный доступ для этих типов Principal задаётся одной строкой.
У префиксного ключа такой возможности нет. Согласно документации, поддерживаются только префиксные ключи, которые привязаны к одному из бакетов в проекте. Значит, кросс-проектная адресация префиксного ключа не поддерживается. В матрицу тестов отсюда добавляются два негативных сценария: чужой проект и чужой префиксный ключ.
Префиксные ключи. Для мультиарендности VK Object Storage предлагает префиксные ключи. Они привязываются к конкретному бакету, а не к аккаунту. Права такого ключа можно ограничить объектами всего бакета или одной директории.
У механизма есть свои ограничения: имя ключа должно содержать от 4 до 74 символов, быть уникальным для всей платформы VK Cloud во всех регионах, а создавать и удалять ключи можно через личный кабинет или API. Если указать несуществующий префикс, будет создана пустая директория с именем префикса. Требование глобальной уникальности легко упустить: вместе с именами ключей в общее пространство имён попадают и имена арендаторов.
Переменные в Resource. Соблазн использовать AWS-паттерн с переменной в пути понятен, но его переносимость почти нулевая. В Ceph RGW роль 12-значного account ID там играет tenant, а политики для пользователей, групп и ролей не поддерживаются. Поэтому конструкция bucket/${aws:username}/* в Ceph не работает.
В документации VK Object Storage переменные и интерполяция в Resource не описаны. Ресурс задаётся строкой или массивом строк формата 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-серверах.habr+1. Hitrod — внутренний компонент уровня Fronts в VK Object Storage, который принимает 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-совместимое объектное хранилище в облаке
Масштабируйте хранилище под любой объём данных и подключайте через стандартный S3 API без изменений в коде
После четырёх итераций политика готова, но её всё ещё нужно проверить на обходы и лишние разрешения. Сохранённая политика показывает, какие права предполагалось выдать, но проверить её можно только запросом и кодом ответа. Скриншот из веб-консоли подтверждает, что разрешённое действие выполняется, однако ничего не говорит о запретах. Лишние разрешения обнаруживаются именно в негативных сценариях.
Строка матрицы задаёт принципала, действие и ожидаемый код ответа. Ожидаемые коды берутся из документации вендора. 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 — как чужой ключ.
Значения в столбцах взяты из документации вендора и спецификаций операций. Три знака вопроса отмечают случаи без публичного контракта: валидацию PutBucketPolicy, адрес за прокси и поведение отключённого переключателя политики. Валидация различается у провайдеров, наблюдаемый адрес зависит от топологии, а состояние переключателя меняется вручную в личном кабинете, поэтому в скрипт оно не вошло.
Остальные перечни операторов, ключей и действий сверены по ссылкам в тексте.
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 Cloud описана поддержка ACL для бакетов и объектов.
Доля запросов, отклонённых на стороне хранилища
Матрицу запускают вручную и в конкретный момент времени. Чтобы постоянно контролировать перенос, достаточно одной метрики: доли отклонённых запросов среди всех запросов к хранилищу с разбивкой по принципалу и бакету.
До переноса отказы выдаёт прокси, а хранилище возвращает 200 на все запросы, потому что политики ещё нет. После переноса часть отказов должна приходить от самого хранилища. Если доля отказов не изменилась, возможны два варианта: политика не применилась или весь трафик по-прежнему проходит через прокси, а прямой доступ к хранилищу не закрыт.
В модели политик нет отдельного построчного журнала запросов к бакету: s3:GetBucketLogging и s3:PutBucketLogging не входят в список Action, поэтому аналога AWS Server Access Logging здесь не будет. Считать метрику нужно по платформенному аудиту.
Что собирать. В журнал попадают отказы при запросах к объектам.
Чем фильтровать. В языке поисковых запросов доступны поля
status.code, subject.user_id,resource.idиresource.type. Пример из документации:resource.type: "s3" OR severity >= WARNING.Как выгружать. Журнал можно скачать в TSV, а в SIEM — передавать в форматах CEF или RAW по Syslog RFC 5424 через TCP over TLS. (Нюанс, TSV-выгрузка ограничена 1000 записями на файл. Для метрики доли отказов на живом проекте этого мало, Я рекомендую SIEM-поток. TLS не единственный канал: доступны также TCP, UDP и TCP over TLS
В числитель входят события с resource.type: "s3" и кодом отказа, в знаменатель — все события того же типа. Разбивку нужно делать по subject.user_id и resource.id.
У метрики есть ограничение: по этим полям нельзя определить причину отказа. В числитель попадут и 403 из-за политики, и ошибки неверной подписи или просроченного ключа, и отказы при обращении к несуществующему бакету. Cloud Audit не передаёт причину отдельным полем, а status.code содержит только код ответа.
Поэтому долю отказов стоит считать только для subject.user_id тех принципалов, которых переносили. В срезе всего проекта ошибки подписей и просроченных ключей заглушат отказы по политике. Метрика показывает лишь направление: она позволяет увидеть, что отказы переместились с прокси на хранилище. На этом её применимость заканчивается.
Если метрика нужна для исторического тренда, срок хранения журнала нужно уточнить в поддержке до того, как на нём будет построена отчётность.
Архив самих логов вендор рекомендует хранить в бакете с блокировкой объектов в строгом режиме COMPLIANCE. Инструкция по выгрузке в ArcSight предназначена для соответствия требованиям политик безопасности, включая PCI DSS, GDPR и 152-ФЗ.
152-ФЗ и меры ФСТЭК
Статья 19, часть 2, пункт 8 152-ФЗ требует установить правила доступа к персональным данным в ИСПДн и обеспечить регистрацию и учёт всех действий с ними. Для объектного хранилища эти требования складываются в связку из политики бакета и аудит-лога.
Дальше требования конкретизируются в постановлении Правительства № 1119 от 01.11.2012, которое устанавливает четыре уровня защищённости ИСПДн, и в приказе ФСТЭК России № 21 от 18.02.2013 в редакции от 14.05.2020. Для всех четырёх уровней обязательны УПД.2 — реализация методов, типов и правил разграничения доступа — и УПД.5 — назначение минимально необходимых прав и привилегий. Приказ также устанавливает меры группы РСБ, связанные с составом событий, сроками хранения, сбором и защитой информации о них.
Политика бакета и аудит-лог помогают реализовать УПД.2, УПД.5 и меры группы РСБ применительно к объектному хранилищу. ФСТЭК не требует использовать именно bucket policy: требования сформулированы через меры защиты, а не названия технологий.
Границы применимости таковы:
Набор мер определяется уровнем защищённости конкретной ИСПДн. Уровень зависит от типа обрабатываемых данных, категории субъектов и вида актуальных угроз. УПД.2, УПД.5 и меры группы РСБ обязательны для всех четырёх уровней, а мониторинг и реагирование по РСБ.5 требуются только для первого и второго уровней.
Политика бакета закрывает лишь часть меры разграничения доступа и не заменяет аттестацию или оценку соответствия ИСПДн, сертификацию средств защиты и разработку модели угроз. JSON в бакете не заменяет ни один из этих процессов.
Для аудитора нужны три выгрузки:
Действующая политика с датой получения — результат
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 на приёмнике. Иначе доступ может появиться там, где его нет в самой политике.
Второе расхождение связано с изменением модели арендаторов между релизами. Deprecation tenant-level IAM в Ceph разобран в итерации 2, а после Tentacle вышли v20.2.1 от 6 апреля 2026 года и v20.2.2 от 16 июня 2026 года. Поэтому в чек-листе переноса должен быть отдельный пункт: на какой релиз выполняется миграция.
Чек-лист перед переносом политики на другое S3-совместимое хранилище:
Сверить поддерживаемые операторы и ключи
Condition. Всё, чего нет в списке, убрать из политики и закрыть другим уровнемПроверить реакцию API на неизвестный ключ: отказ или принятие политики без ошибки
Проверить режим ACL на приёмнике и отключить ACL, если это возможно
Проверить, включает ли ресурс бакета его объекты, и при необходимости явно перечислить оба ARN
Убедиться, что wildcard в
Resourceне покрывает лишние ресурсы, включая объекты внутри бакетаСверить формы
Principalи схему ARN на приёмнике, а главное — выяснить, кого каждая форма адресует на практике. В VK Cloud{"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 доступа по тегам нет.
Что остаётся за прокси
Resource без объектов или без бакета. Права на бакет и права на объекты это разные вещи (итерация 1).
Wildcard, цепляющий лишнее. Запись project* покрывает и бакет, и все его объекты одной звёздочкой (итерация 1). Чужой бакет проверяется строкой 14 под аккаунтным ключом, объекты внутри своего строкой 6.
Principal на проект вместо конкретного субъекта. Запись {"AWS": "<ID_ПРОЕКТА>"} читается как «право проекту», а выдаёт право всем пользователям проекта и их сервисным учёткам (итерация 2).
Condition по адресу за прокси. Итерация 3.
Неподдерживаемый ключ, который выглядит защитой. JSON выглядит рабочим, а условие на запрос не влияет (секция про набор проверок).
Забытый парный Deny. Ограничение стоит на Deny, политику расширили, Deny не обновили (итерация 4).
ACL как второй шанс. В режиме BucketOwnerPreferred доступ выдаётся в обход политики (секция про три механизма).
Политика написана и выключена. JSON в консоли виден, а тумблер выключен (итерация 4).
После переноса прокси не всегда можно отключить. За ним остаются четыре функции, которых нет в политике бакета.
Кастомная логика доступа. Проверка подписки остаётся в приложении. Политика бакета не знает, активна ли она у клиента, и не может принять решение на основании этого условия.
Rate limiting по принципалу. Платформенный лимит действует на уровне проекта, а не отдельного субъекта.
Аудит с бизнес-контекстом. Cloud Audit фиксирует технические поля. Идентификатор заказа, арендатора или внутреннего пользователя добавляет прокси.
Гарантированный запрет HTTP. Клиент можно настроить на HTTPS, но принудительно отклонять незашифрованные запросы способен только компонент, который завершает TLS. Если такой запрет нужен как технический контроль, а не как договорённость с разработчиками, прокси придётся оставить.
Политики, команды и скрипт прогона выше можно взять за основу для другого S3-совместимого хранилища, но перед запуском нужно сверить поддерживаемые ключи условий, формы Principal, схему ARN и наличие интерполяции. Эти различия и должен выявить первый прогон.
В Object Storage VK Cloud правила доступа можно задавать на уровне бакетов, например, в личном кабинете. Ниже приведен скрин в личном кабинете, где это можно настроить

