вход переехал на доверенный заголовок Authelia вместо собственного OIDC
- пришедшего называет заголовок Remote-User от прокси, и верят ему только с адреса из перечня trusted_proxies; своего входа у сервиса не осталось — ни корня /auth, ни кук, ни срока сессии, ни секрета клиента в конфиге и в базе - учётная запись заводится первым обращением: EnsureUser в пакете хранилища, шаг схемы 202608220001 с колонкой provider_login и снятыми правилами users - cmd/oidcstub заменён на cmd/devtools с подкомандой proxy; заодно закрыт унаследованный DL3066 — пользователь образа назван числом
This commit is contained in:
+477
-321
@@ -2,11 +2,16 @@
|
||||
|
||||
## Purpose
|
||||
|
||||
Кто пришёл в сервис и пускают ли его дальше: вход через внешнего провайдера
|
||||
OIDC, чем предъявляется сессия, что её прекращает и какие адреса остаются
|
||||
открытыми.
|
||||
Кто пришёл в сервис и пускают ли его дальше: узнавание по заголовку доверенного
|
||||
источника, заведение учётной записи первым обращением и то, какие адреса
|
||||
остаются открытыми неузнанному.
|
||||
|
||||
Здесь же разграничение записей по владельцу: с 2026-08-14 сессия отвечает не
|
||||
Своего входа у сервиса нет. Собственный протокол OIDC — с адресом, уводящим к
|
||||
провайдеру, возвратом, кукой сессии и её сроком — жил здесь с 2026-08-12 по
|
||||
2026-08-22 и убран задачей `trusted-header-login`: пришедшего называет обратный
|
||||
прокси, сходивший к провайдеру, и делает это на каждом запросе.
|
||||
|
||||
Здесь же разграничение записей по владельцу: с 2026-08-14 узнавание отвечает не
|
||||
только на вопрос «узнан ли пришедший», но и на «чьё он смотрит». Запись из веба
|
||||
принадлежит тому, кто её принёс, и чужая неотличима от несуществующей.
|
||||
|
||||
@@ -15,360 +20,183 @@ OIDC, чем предъявляется сессия, что её прекращ
|
||||
вход Telegram — связи чата с учётной записью сервис не вёл, — и 2026-08-14 вход
|
||||
убран вместе с этим исключением.
|
||||
## Requirements
|
||||
### Requirement: Вход через внешнего провайдера
|
||||
|
||||
Сервис SHALL заводить сессию только по итогу входа у внешнего провайдера OIDC.
|
||||
Своей регистрации, своей формы пароля и своего восстановления доступа сервис
|
||||
MUST не заводить: учётные записи держит провайдер, и это граница домена из
|
||||
паспорта.
|
||||
|
||||
Вход начинается собственным адресом сервиса: он уводит человека к провайдеру.
|
||||
Провайдер возвращает человека на адрес возврата, и сервис MUST обменять
|
||||
принесённый код на учётную запись **средствами хранилища**, а не разбором ответа
|
||||
провайдера своими руками — так решено 2026-08-11. Учётная запись, которой ещё
|
||||
нет, заводится сама; связь её с внешним провайдером ведёт хранилище.
|
||||
|
||||
Возврат от провайдера MUST быть проверен на подмену: сервис сверяет пришедшее
|
||||
состояние с тем, что сам выдал, и отвергает возврат, чьё состояние он не
|
||||
выдавал. Без этой сверки вход принимает чужой код.
|
||||
|
||||
Состояние и проверочный код PKCE сервис SHALL хранить у браузера — тем же
|
||||
носителем, что и сессию, и с теми же признаками защиты. Носитель MUST жить не
|
||||
дольше одного входа, MUST убираться на возврате — и на успешном, и на отказном,
|
||||
— а состояние MUST быть одноразовым: возврат, чьё состояние уже употреблено,
|
||||
отвергается наравне с невыданным. Проверочный код PKCE обязателен: обмен кода
|
||||
средствами хранилища его требует.
|
||||
|
||||
Носитель без защиты соединения отменял бы то, ради чего заведён: перехваченный
|
||||
проверочный код обесценивает PKCE, а подставленное состояние — сверку подмены.
|
||||
|
||||
Уборка носителя MUST происходить до записи ответа. Отложенная не работает вовсе:
|
||||
заголовки фиксируются в момент, когда ответ начинают писать, и позднейшая правка
|
||||
до браузера не доезжает.
|
||||
|
||||
Обмен кода MUST быть ограничен во времени: у обращения к провайдеру есть
|
||||
таймаут, и по его истечении вход кончается отказом. Молчащий провайдер иначе
|
||||
держит обработчик возврата открытым неограниченно долго, а «провайдер медленный»
|
||||
становится неотличим от «провайдер отказал».
|
||||
|
||||
Ни код, принесённый от провайдера, ни секрет клиента MUST не попадать в журнал.
|
||||
|
||||
Адреса нормативны: вход — `GET /auth/login`, возврат — `GET /auth/callback`,
|
||||
выход — `POST /auth/logout`. Они лежат вне `/api/`, потому что это пространство
|
||||
поделено с собственными адресами хранилища. Выход берёт `POST` намеренно: по
|
||||
`GET` его срабатывание уносится переходом по чужой ссылке.
|
||||
|
||||
#### Scenario: Человек входит впервые
|
||||
|
||||
- **GIVEN** провайдер настроен и учётной записи в сервисе ещё нет
|
||||
- **WHEN** человек проходит вход и возвращается с кодом провайдера
|
||||
- **THEN** учётная запись заводится, а сессия открывается
|
||||
- **AND** дальнейший запрос к API от этой сессии проходит
|
||||
|
||||
#### Scenario: Признаки носителя состояния
|
||||
|
||||
- **WHEN** сервис уводит человека к провайдеру
|
||||
- **THEN** носитель состояния и проверочного кода несёт те же признаки защиты,
|
||||
что и кука сессии
|
||||
|
||||
#### Scenario: Возврат нельзя переиграть
|
||||
|
||||
- **GIVEN** человек уже вернулся от провайдера и сессия открылась
|
||||
- **WHEN** тот же возврат с тем же состоянием приходит второй раз
|
||||
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||
|
||||
#### Scenario: Возврат с чужим состоянием
|
||||
|
||||
- **WHEN** на адрес возврата приходит код с состоянием, которого сервис не
|
||||
выдавал
|
||||
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||
- **AND** учётная запись не заводится
|
||||
|
||||
#### Scenario: Провайдер отказал
|
||||
|
||||
- **WHEN** провайдер возвращает человека с ошибкой вместо кода
|
||||
- **THEN** сессия не открывается, а ответ несёт отказ
|
||||
|
||||
### Requirement: Иных способов открыть сессию нет
|
||||
|
||||
Сервис SHALL оставить вход у провайдера единственным способом завести учётную
|
||||
запись и получить сессию. Собственное создание записи в коллекции пользователей,
|
||||
вход по паролю, вход по одноразовому коду и восстановление доступа MUST быть
|
||||
Сервис SHALL оставить собственные адреса входа хранилища неработающими: ни один
|
||||
из них MUST не давать доступа и MUST не менять учётной записи. Собственное
|
||||
создание записи в коллекции пользователей, вход по паролю, вход по одноразовому
|
||||
коду, обмен кода у внешнего провайдера и восстановление доступа MUST быть
|
||||
выключены настройкой коллекции.
|
||||
|
||||
Требование отдельно от «Вход через внешнего провайдера» намеренно: то нормирует
|
||||
наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
|
||||
**Закрывается не только вход, но и правка.** Перечисление, чтение, создание,
|
||||
правка и удаление записи коллекции пользователей MUST быть закрыты правилами
|
||||
доступа — то есть оставлены пустыми, что у хранилища означает «только владелец
|
||||
панели». Умолчание библиотеки открывает всё это владельцу самой записи, и до сих
|
||||
пор оно ничему не мешало ровно потому, что до поверхности хранилища браузер с
|
||||
кукой не дотягивался. С узнаванием по заголовку эта защита перестаёт быть
|
||||
защитой, а ключ учётной записи лежит в коллекции обычной колонкой: правка своей
|
||||
записи и есть захват чужого имени. Наш код читает и заводит запись мимо правил,
|
||||
панель работает суперпользователем, своих экранов профиля сервис не заводит —
|
||||
закрытие не стоит ничего.
|
||||
|
||||
Требование отдельно от «Пришедшего называет доверенный источник» намеренно: то
|
||||
нормирует наш код, а это — **поверхность, которую приносит хранилище**. Умолчание
|
||||
хранилища заводит коллекцию пользователей с открытым созданием записи и
|
||||
включённым входом по паролю, и без этого требования закрытие приёма обходится
|
||||
двумя запросами: завести себе запись, войти по паролю, предъявить полученное.
|
||||
включённым входом по паролю, и без этого требования узнавание по заголовку
|
||||
обходится двумя запросами: завести себе запись, войти по паролю, предъявить
|
||||
полученное.
|
||||
|
||||
Отдельная цена у открытого создания записи — захват учётной записи. Обмен кода
|
||||
ищет запись сперва по неизменяемому признаку провайдера, а не найдя — по адресу
|
||||
почты; запись, заведённая посторонним на чужой адрес, достаётся первому же
|
||||
настоящему входу с этим адресом.
|
||||
Отдельная цена у открытого создания записи — захват учётной записи: запись,
|
||||
заведённая посторонним под чужим именем, досталась бы первому же настоящему
|
||||
обращению с этим именем.
|
||||
|
||||
Закрытие MUST не отменять заведения записи самим входом: запись при первом входе
|
||||
заводит внутренний запрос обмена, и правило, отвергающее его наравне с
|
||||
посторонним, оставляет сервис без единого способа войти.
|
||||
Закрытие MUST не отменять заведения записи самим сервисом: учётную запись при
|
||||
первом обращении заводит наш код, а не запрос снаружи, и правило коллекции ему
|
||||
не судья.
|
||||
|
||||
#### Scenario: Завести учётную запись самому нельзя
|
||||
|
||||
- **WHEN** анонимный запрос создаёт запись в коллекции пользователей
|
||||
- **WHEN** запрос снаружи создаёт запись в коллекции пользователей
|
||||
- **THEN** ответ несёт отказ, а записи не появляется
|
||||
|
||||
#### Scenario: Вход у провайдера запись заводит
|
||||
#### Scenario: Обращение с заголовком запись заводит
|
||||
|
||||
- **GIVEN** учётной записи в сервисе ещё нет
|
||||
- **WHEN** человек проходит вход у провайдера
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** запрос с заголовком приходит с доверенного адреса
|
||||
- **THEN** учётная запись появляется
|
||||
|
||||
#### Scenario: Вход паролем недоступен
|
||||
|
||||
- **WHEN** запрос идёт на вход по паролю к коллекции пользователей
|
||||
- **THEN** ответ несёт отказ, а сессия не открывается
|
||||
- **THEN** ответ несёт отказ, а доступа не открывается
|
||||
|
||||
#### Scenario: Обмен кода у провайдера недоступен
|
||||
|
||||
- **WHEN** запрос идёт на обмен кода внешнего провайдера к коллекции
|
||||
пользователей
|
||||
- **THEN** ответ несёт отказ, а доступа не открывается
|
||||
|
||||
#### Scenario: Восстановление доступа недоступно
|
||||
|
||||
- **WHEN** запрос просит восстановление пароля или одноразовый код
|
||||
- **THEN** ответ несёт отказ
|
||||
|
||||
### Requirement: Сессия предъявляется кукой
|
||||
#### Scenario: Правка учётной записи снаружи закрыта
|
||||
|
||||
Сервис SHALL принимать сессию, предъявленную кукой, — браузер отдаёт её сам, и
|
||||
своей страницы со скриптом для этого не требуется. Кука сессии MUST быть
|
||||
недоступна скриптам страницы (`HttpOnly`), MUST не уходить по незашифрованному
|
||||
соединению (`Secure`) и MUST не отправляться при переходе с чужого сайта
|
||||
(`SameSite=Lax` или строже).
|
||||
- **GIVEN** человек узнан и его учётная запись заведена
|
||||
- **WHEN** он правит свою запись в коллекции пользователей запросом к хранилищу
|
||||
- **THEN** ответ несёт отказ, а запись остаётся прежней
|
||||
|
||||
Имя куки нормативно — `transcriber_session`: смена имени молча выкидывает всех
|
||||
вошедших, а тест, ставящий и читающий одно и то же имя, этого не замечает.
|
||||
#### Scenario: Перечисление учётных записей закрыто
|
||||
|
||||
Хранилище читает предъявленную сессию заголовком `Authorization`, и этот способ
|
||||
остаётся рабочим: его требуют собственные адреса аутентификации хранилища.
|
||||
Сервис MUST перекладывать значение куки в этот заголовок **только когда
|
||||
заголовка нет**: предъявленный заголовок побеждает, иначе браузер с сессионной
|
||||
кукой получал бы не то, что предъявил на собственных адресах хранилища.
|
||||
|
||||
Область действия слоя MUST быть ограничена **адресами приложения** — теми, что
|
||||
живут под его собственным корнем. Собственная поверхность хранилища под него не
|
||||
подпадает: часть её защищена сегодня ровно тем, что браузер заголовка сам не
|
||||
шлёт, и расширение слоя на всё сняло бы эту защиту молча.
|
||||
|
||||
Область названа корнем, а не перечнем адресов: перечень рос бы с каждым новым
|
||||
адресом приложения, и забытый в нём адрес остался бы без слоя молча — сессия,
|
||||
предъявленная кукой, перестала бы на нём работать, а на соседнем работала бы.
|
||||
|
||||
#### Scenario: Кука открывает доступ
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** он шлёт запрос к адресу приложения с этой кукой и без заголовка
|
||||
- **THEN** запрос проходит
|
||||
|
||||
#### Scenario: Кука защищена от чтения скриптом
|
||||
|
||||
- **WHEN** сервис ставит куку сессии
|
||||
- **THEN** она несёт признаки `HttpOnly`, `Secure` и `SameSite`
|
||||
|
||||
#### Scenario: Предъявленный заголовок побеждает куку
|
||||
|
||||
- **WHEN** запрос несёт и куку сессии, и заголовок `Authorization`
|
||||
- **THEN** проверку проходит значение заголовка, а не куки
|
||||
|
||||
#### Scenario: Слой не расширяется на поверхность хранилища
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** он шлёт запрос к собственному адресу хранилища с одной лишь кукой
|
||||
- **THEN** значение куки в заголовок не перекладывается
|
||||
- **GIVEN** человек узнан
|
||||
- **WHEN** он перечисляет коллекцию пользователей запросом к хранилищу
|
||||
- **THEN** ответ несёт отказ
|
||||
|
||||
### Requirement: Значение, дающее доступ, не печатается
|
||||
|
||||
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение сессии,
|
||||
ни код, принесённый от провайдера, ни секрет клиента, ни адрес почты
|
||||
пользователя. Записанное значение сессии MUST читаться как ключ к чужому
|
||||
доступу: оно годно до выхода или до истечения срока, и строка журнала уезжает в
|
||||
собранные логи, откуда её не убрать.
|
||||
Сервис SHALL не писать в журнал, в ответ и в метку метрики ни значение заголовка,
|
||||
которым назван пришедший, ни короткий токен файла, ни адрес почты пользователя.
|
||||
|
||||
Требование того же рода, что и запрет писать имя файла в хранилище: там строка
|
||||
журнала собирала бы ссылку на чужую запись, здесь — предъявление чужой сессии.
|
||||
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису.
|
||||
Записанное значение MUST читаться как ключ к чужому доступу: заголовок целиком
|
||||
задаёт тот, кто шлёт запрос, и строка журнала уезжает в собранные логи, откуда её
|
||||
не убрать. Требование того же рода, что и запрет писать имя файла в хранилище:
|
||||
там строка журнала собирала бы ссылку на чужую запись, здесь — имя, которым
|
||||
довольно назваться, чтобы стать этим человеком.
|
||||
|
||||
Причина отказа, пришедшая от провайдера строкой запроса, MUST приводиться к
|
||||
перечню известных: значение целиком задаёт тот, кто шлёт запрос, и без
|
||||
приведения аноним пишет в журнал что угодно и сколько угодно.
|
||||
Адрес почты приходит от провайдера и принадлежит человеку, а не сервису. Имя из
|
||||
заголовка — тоже: это логин человека у провайдера.
|
||||
|
||||
#### Scenario: Значения сессии нет в журнале
|
||||
Идентификатор учётной записи в журнал писать можно и нужно: он выдан сервисом,
|
||||
доступа сам по себе не даёт и без него путь запроса не прослеживается.
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** он шлёт запрос к API с этой кукой
|
||||
- **THEN** значение сессии не встречается ни в одной журнальной записи
|
||||
#### Scenario: Значения заголовка нет в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком проходит через сервис
|
||||
- **THEN** значение заголовка не встречается ни в одной журнальной записи
|
||||
|
||||
#### Scenario: Адреса почты нет в журнале
|
||||
|
||||
- **WHEN** человек проходит вход и учётная запись заводится
|
||||
- **THEN** адрес его почты не встречается ни в одной журнальной записи
|
||||
|
||||
### Requirement: Сессия переживает перезапуск сервиса
|
||||
|
||||
Сервис SHALL держать сессию годной после своего перезапуска: подпись сессии MUST
|
||||
опираться на секрет, лежащий в хранилище, а не на значение, заведённое в памяти
|
||||
при старте. Иначе всякая выкладка выкидывает всех вошедших молча.
|
||||
|
||||
#### Scenario: Прежняя кука годна после перезапуска
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** сервис поднимается заново на том же хранилище
|
||||
- **THEN** запрос с прежней кукой проходит
|
||||
|
||||
### Requirement: Срок жизни сессии назначен, а не достался умолчанию
|
||||
|
||||
Сервис SHALL назначать срок жизни сессии сам — **семь суток**, и тем же числом
|
||||
задавать срок жизни куки. Умолчание хранилища MUST не применяться: оно даёт пять
|
||||
суток, и это число никем не выбрано.
|
||||
|
||||
Назначаться срок MUST при каждом подъёме, а не шагом схемы: применённый шаг не
|
||||
переписывается, и число, положенное туда, разошлось бы со сроком жизни куки при
|
||||
первой же правке — браузер получил бы новый срок, а хранилище продолжило выдавать
|
||||
прежний.
|
||||
|
||||
Срок здесь — единственное, что доносит до сервиса **отзыв доступа у
|
||||
провайдера**. Сессия выдана однажды, и к провайдеру сервис больше не ходит:
|
||||
человек, которому провайдер закрыл доступ, работает до истечения своей сессии.
|
||||
Паспорт опирается на отзыв у провайдера как на способ остановить того, кто
|
||||
тратит слишком много, — значит срок сессии и есть цена этой остановки.
|
||||
|
||||
**Отсюда запрет на продление.** Хранилище выдаёт сессию продлеваемой:
|
||||
предъявитель меняет своё значение на новое, с новым сроком, и делает это сколько
|
||||
угодно раз, никуда не входя. Сервис SHALL закрыть продление — иначе срок жизни
|
||||
сессии не значит ничего, а канал отзыва перестаёт существовать вовсе.
|
||||
|
||||
Владелец MUST иметь способ закрыть чужие сессии немедленно, не дожидаясь срока.
|
||||
|
||||
#### Scenario: Сессия не продлевает саму себя
|
||||
|
||||
- **GIVEN** человек вошёл и получил сессию
|
||||
- **WHEN** этой же сессией он просит продлить её
|
||||
- **THEN** ответ несёт отказ, а нового значения в нём нет
|
||||
|
||||
#### Scenario: Сессия истекает назначенным сроком
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** назначенный срок прошёл
|
||||
- **THEN** запрос с этой кукой получает отказ
|
||||
|
||||
#### Scenario: Владелец закрывает чужую сессию
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** владелец обесценивает сессии этой учётной записи
|
||||
- **THEN** запрос с прежней кукой получает отказ
|
||||
|
||||
### Requirement: Выход прекращает доступ
|
||||
|
||||
Сервис SHALL закрывать доступ по выходу немедленно: выход MUST обесценивать
|
||||
выданные этой учётной записи сессии на стороне сервиса, а не только убирать куку
|
||||
у браузера. Куку сервис при этом MUST убрать тоже.
|
||||
|
||||
Одной уборки куки мало: сессия предъявляется значением, и унесённое значение
|
||||
продолжало бы открывать доступ до самого своего истечения.
|
||||
|
||||
Порядок обязателен: сперва обесценивание, потом уборка куки. При обратном
|
||||
порядке выход, разошедшийся с одновременным входом, оставляет годную сессию, а
|
||||
человек уверен, что вышел.
|
||||
|
||||
#### Scenario: После выхода прежняя кука не работает
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** он выходит, а затем шлёт запрос к API с прежней кукой
|
||||
- **THEN** запрос получает отказ
|
||||
|
||||
#### Scenario: Выход убирает куку
|
||||
|
||||
- **WHEN** человек выходит
|
||||
- **THEN** ответ убирает куку сессии у браузера
|
||||
- **WHEN** приходит первое обращение и учётная запись заводится
|
||||
- **THEN** адрес почты не встречается ни в одной журнальной записи
|
||||
|
||||
### Requirement: Кого пускать, решает провайдер
|
||||
|
||||
Сервис SHALL пускать всякого, кого пропустил провайдер, и своей проверки допуска
|
||||
MUST не делать. Кто допущен, определяет правило провайдера на этого клиента —
|
||||
настройка выкладки, лежащая вне репозитория.
|
||||
Сервис SHALL пускать всякого, кого назвал доверенный источник, и своей проверки
|
||||
допуска MUST не делать. Кто допущен, определяет правило провайдера на домен
|
||||
сервиса — настройка выкладки, лежащая вне репозитория.
|
||||
|
||||
Требование записано именно как решение с ценой, а не как умолчание: провайдер
|
||||
общий для контура, и клиент, настроенный слишком широко, открывает сервис
|
||||
общий для контура, и правило, настроенное слишком широко, открывает сервис
|
||||
всякому, у кого есть учётная запись у провайдера. Проверить это по коду нельзя,
|
||||
поэтому граница названа здесь и повторена в модели угроз.
|
||||
|
||||
#### Scenario: Пропущенный провайдером получает доступ
|
||||
Цена сдвинулась в нашу пользу: провайдер судит **каждый** запрос, а не только
|
||||
первый. Прежде сервис спрашивал провайдера однажды и потом верил выданному
|
||||
значению до его истечения — отзыв доступа доходил до сервиса с задержкой в срок
|
||||
жизни этого значения. Теперь отзыв действует со следующего запроса.
|
||||
|
||||
- **WHEN** человек проходит вход у провайдера и возвращается с кодом
|
||||
- **THEN** учётная запись заводится, а доступ открывается
|
||||
- **AND** сервис не спрашивает у ответа провайдера ничего сверх того, что нужно
|
||||
для заведения записи
|
||||
Обратная сторона у этого одна, и она названа прямо: **весь барьер держится на
|
||||
том, что прокси ставит заголовок сам, а не пропускает пришедший**. Прокси,
|
||||
пропускающий чужой заголовок, открывает сервис всякому под любым именем.
|
||||
Требование к контуру записано в модели угроз; репозиторием оно не проверяется.
|
||||
|
||||
#### Scenario: Названный провайдером получает доступ
|
||||
|
||||
- **WHEN** запрос приходит с доверенного адреса с заголовком, поставленным
|
||||
прокси
|
||||
- **THEN** доступ открывается, а учётная запись заводится, если её не было
|
||||
- **AND** сервис не спрашивает у заголовков ничего сверх имени, имени для показа
|
||||
и адреса почты
|
||||
|
||||
#### Scenario: Отзыв у провайдера действует со следующего запроса
|
||||
|
||||
- **GIVEN** человек работал в сервисе, и провайдер закрыл ему доступ
|
||||
- **WHEN** приходит следующий его запрос
|
||||
- **THEN** прокси заголовка не ставит, и запрос получает отказ
|
||||
|
||||
### Requirement: Проба здоровья и метрики остаются открытыми
|
||||
|
||||
Сервис SHALL отдавать `GET /health` и `GET /metrics` без сессии. Ни у пробы
|
||||
здоровья, ни у сборщика метрик сессии нет, и требование входа остановило бы
|
||||
наблюдение за сервисом.
|
||||
Сервис SHALL отдавать `GET /health` и `GET /metrics` неузнанному. Ни у пробы
|
||||
здоровья, ни у сборщика метрик учётной записи нет, и требование узнавания
|
||||
остановило бы наблюдение за сервисом.
|
||||
|
||||
Наружу эти адреса закрывает правило обратного прокси — это работа выкладки, и
|
||||
сервис на неё не полагается: содержимого записей и текстов расшифровок оба
|
||||
адреса не несут.
|
||||
|
||||
#### Scenario: Проба здоровья доступна анонимно
|
||||
Заголовок, пришедший с недоверенного адреса, MUST не менять их ответа: узнавание
|
||||
отказа не выдаёт, и посторонняя строка в запросе не вправе гасить наблюдение.
|
||||
|
||||
- **WHEN** запрос приходит на `GET /health` без сессии
|
||||
#### Scenario: Проба здоровья доступна неузнанному
|
||||
|
||||
- **WHEN** запрос приходит на `GET /health` без заголовка
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
#### Scenario: Метрики доступны анонимно
|
||||
#### Scenario: Метрики доступны неузнанному
|
||||
|
||||
- **WHEN** запрос приходит на `GET /metrics` без сессии
|
||||
- **WHEN** запрос приходит на `GET /metrics` без заголовка
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
### Requirement: Секрет провайдера живёт в конфиге
|
||||
#### Scenario: Чужой заголовок наблюдения не гасит
|
||||
|
||||
Сервис SHALL брать адреса провайдера, идентификатор клиента и секрет клиента из
|
||||
конфига. Секрет MUST не попадать ни в журнал, ни в ответ, ни в git; настройки
|
||||
провайдера в хранилище MUST приводиться к значениям конфига при каждом запуске,
|
||||
а не заводиться однажды шагом схемы.
|
||||
|
||||
Причина второго требования в необратимости шага схемы: применённый шаг не
|
||||
переписывается, и смена секрета в конфиге иначе не доехала бы до хранилища
|
||||
вовсе — вход сломался бы после ротации.
|
||||
|
||||
Незаполненная или негодная настройка входа MUST ронять старт с перечнем ключей и
|
||||
без их значений. Форма адресов проверяется там же: непустая, но негодная строка
|
||||
иначе отвергается хранилищем позже — из хука подъёма, до регистрации пробы
|
||||
здоровья, — и сервис падает целиком, не оставив владельцу даже кода состояния.
|
||||
|
||||
#### Scenario: Секрета нет в журнале
|
||||
|
||||
- **WHEN** сервис поднимается с настроенным провайдером
|
||||
- **THEN** значение секрета не встречается ни в одной журнальной записи
|
||||
|
||||
#### Scenario: Смена секрета доезжает до хранилища
|
||||
|
||||
- **GIVEN** сервис уже поднимался с прежним секретом
|
||||
- **WHEN** секрет в конфиге заменён и сервис поднят заново
|
||||
- **THEN** настройки провайдера в хранилище несут новое значение
|
||||
|
||||
#### Scenario: Негодная настройка роняет старт
|
||||
|
||||
- **WHEN** сервис поднимается с пустым или негодным ключом секции входа
|
||||
- **THEN** старт кончается отказом, а отказ называет имена ключей
|
||||
- **AND** значений этих ключей в отказе нет
|
||||
- **WHEN** запрос на `GET /health` приходит с недоверенного адреса с заголовком
|
||||
`Remote-User`
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
### Requirement: У записи есть владелец, и чужую ей не отдают
|
||||
|
||||
Сервис SHALL заводить у каждой принятой записи владельца — учётную запись, от
|
||||
имени которой запись принята, — и MUST отдавать данные такой записи только её
|
||||
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни конвейером, ни
|
||||
рукой в панели: колонка владельца пустого значения не принимает, и норму эту
|
||||
держит capability `storage`.
|
||||
владельцу. Запись без владельца MUST не заводиться ничем — ни приёмом, ни
|
||||
конвейером, ни рукой в панели: колонка владельца пустого значения не принимает, и
|
||||
норму эту держит capability `storage`.
|
||||
|
||||
Владелец назначается один раз, при приёме, и MUST не меняться: совместного
|
||||
доступа, ролей и передачи записи другому сервис не знает.
|
||||
|
||||
Владелец MUST браться из предъявленной сессии и ниоткуда больше. Владелец,
|
||||
пришедший полем запроса, дал бы всякому вошедшему право завести запись на чужое
|
||||
Владелец MUST браться из узнанного предъявителя и ниоткуда больше. Владелец,
|
||||
пришедший полем запроса, дал бы всякому узнанному право завести запись на чужое
|
||||
имя.
|
||||
|
||||
Обращение к чужой записи MUST быть неотличимо от обращения к несуществующей.
|
||||
@@ -387,20 +215,20 @@ capability `archive`: там живут адреса чтения записи,
|
||||
|
||||
#### Scenario: Своя запись доступна
|
||||
|
||||
- **GIVEN** человек вошёл и принял запись
|
||||
- **WHEN** он спрашивает карточку этой записи своей сессией
|
||||
- **GIVEN** человек узнан и принял запись
|
||||
- **WHEN** он спрашивает карточку этой записи
|
||||
- **THEN** ответ несёт данные записи
|
||||
|
||||
#### Scenario: Чужая запись неотличима от несуществующей
|
||||
|
||||
- **GIVEN** запись принята одним вошедшим
|
||||
- **WHEN** её карточку спрашивает другой вошедший
|
||||
- **GIVEN** запись принята одним узнанным
|
||||
- **WHEN** её карточку спрашивает другой узнанный
|
||||
- **THEN** ответ тот же, что и на неизвестный идентификатор, — и кодом, и телом
|
||||
|
||||
#### Scenario: Владельца не задают запросом
|
||||
|
||||
- **WHEN** запрос на приём записи несёт своё значение владельца
|
||||
- **THEN** владельцем принятой записи становится предъявитель сессии
|
||||
- **THEN** владельцем принятой записи становится узнанный предъявитель
|
||||
|
||||
#### Scenario: Ничью запись завести нечем
|
||||
|
||||
@@ -415,65 +243,393 @@ capability `archive`: там живут адреса чтения записи,
|
||||
|
||||
### Requirement: Приложение узнаёт вошедшего
|
||||
|
||||
Сервис SHALL отдавать приложению сведения о том, кто вошёл, — `GET /app/me` — и
|
||||
MUST отвечать отказом `401`, когда сессии нет. Своей страницы со скриптом,
|
||||
которой сервер отрисовал бы имя вошедшего, у сервиса нет: приложение собирает
|
||||
разметку само и вошедшего узнаёт ответом.
|
||||
Сервис SHALL отдавать приложению сведения о том, кто пришёл, — `GET /app/me` — и
|
||||
MUST отвечать отказом `401`, когда пришедший не узнан. Своей страницы со
|
||||
скриптом, которой сервер отрисовал бы имя пришедшего, у сервиса нет: приложение
|
||||
собирает разметку само и пришедшего узнаёт ответом.
|
||||
|
||||
Кука сессии недоступна скриптам страницы, и прочитать из неё имя приложение не
|
||||
может вовсе — этот адрес единственный способ его узнать.
|
||||
Заголовок ставит прокси, и прочитать его из браузера приложение не может вовсе —
|
||||
этот адрес единственный способ узнать, кто пришёл.
|
||||
|
||||
Ответ MUST нести идентификатор учётной записи и имя, пригодное к показу, полями
|
||||
`id` и `name`. Адрес почты MUST в ответ не попадать: он приходит от провайдера и
|
||||
принадлежит человеку, а не сервису, и правило о непечатаемых значениях запрещает
|
||||
ему выходить наружу наравне с журналом.
|
||||
|
||||
#### Scenario: Вошедший узнан
|
||||
#### Scenario: Пришедший узнан
|
||||
|
||||
- **GIVEN** человек вошёл и получил куку сессии
|
||||
- **WHEN** приложение спрашивает, кто вошёл
|
||||
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
|
||||
- **WHEN** приложение спрашивает, кто пришёл
|
||||
- **THEN** ответ несёт идентификатор его учётной записи
|
||||
|
||||
#### Scenario: Сессии нет
|
||||
#### Scenario: Пришедший не узнан
|
||||
|
||||
- **WHEN** приложение спрашивает, кто вошёл, без сессии
|
||||
- **WHEN** приложение спрашивает, кто пришёл, без заголовка
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** тело ответа не несёт учётной записи
|
||||
|
||||
#### Scenario: Адреса почты в ответе нет
|
||||
|
||||
- **GIVEN** человек вошёл, и у его учётной записи есть адрес почты
|
||||
- **WHEN** приложение спрашивает, кто вошёл
|
||||
- **GIVEN** пришедший узнан, и у его учётной записи есть адрес почты
|
||||
- **WHEN** приложение спрашивает, кто пришёл
|
||||
- **THEN** адреса почты в ответе нет
|
||||
|
||||
### Requirement: Приложение отдаётся без сессии
|
||||
|
||||
Сервис SHALL отдавать разметку приложения и её ресурсы без сессии. Перечень
|
||||
адресов, открытых анонимно, пополняется ими: прежде в нём стояли только проба
|
||||
здоровья и метрики.
|
||||
Сервис SHALL отдавать разметку приложения и её ресурсы неузнанному. Перечень
|
||||
адресов, открытых без узнавания, пополняется ими: прежде в нём стояли только
|
||||
проба здоровья и метрики.
|
||||
|
||||
Причина в самом входе: человек, ещё не вошедший, дошёл бы до входа только через
|
||||
приложение, а закрытая сессией разметка отдала бы ему отказ вместо экрана. Цена
|
||||
открытости названа здесь же и невелика — ни разметка, ни ресурсы содержимого
|
||||
записей не несут: они одинаковы для всех и собираются до всякого запроса.
|
||||
Причина внешняя: заголовок ставит прокси, и человек, которого прокси не назвал,
|
||||
до приложения не доходит вовсе. Разметка при этом обязана отдаваться и ему —
|
||||
иначе неудача узнавания выглядела бы поломкой сервиса, а не отказом входа. Цена
|
||||
открытости названа здесь же и невелика: ни разметка, ни ресурсы содержимого
|
||||
записей не несут — они одинаковы для всех и собираются до всякого запроса.
|
||||
|
||||
Открытость MUST не касаться данных: всякий адрес под корнем приложения
|
||||
по-прежнему требует сессии, и приложение, открытое анонимно, не получает ни
|
||||
одной записи.
|
||||
по-прежнему требует узнанного предъявителя, и приложение, открытое неузнанным,
|
||||
не получает ни одной записи.
|
||||
|
||||
#### Scenario: Разметка доступна анонимно
|
||||
#### Scenario: Разметка доступна неузнанному
|
||||
|
||||
- **WHEN** запрос приходит на корень сервиса без сессии
|
||||
- **WHEN** запрос приходит на корень сервиса без заголовка
|
||||
- **THEN** ответ имеет код `200`
|
||||
- **AND** тело ответа — разметка приложения
|
||||
|
||||
#### Scenario: Ресурс приложения доступен анонимно
|
||||
#### Scenario: Ресурс приложения доступен неузнанному
|
||||
|
||||
- **WHEN** запрос приходит на ресурс приложения без сессии
|
||||
- **WHEN** запрос приходит на ресурс приложения без заголовка
|
||||
- **THEN** ответ имеет код `200`
|
||||
|
||||
#### Scenario: Данные анонимно не отдаются
|
||||
#### Scenario: Данные неузнанному не отдаются
|
||||
|
||||
- **GIVEN** приложение открыто без сессии
|
||||
- **GIVEN** приложение открыто без заголовка
|
||||
- **WHEN** оно спрашивает список записей
|
||||
- **THEN** ответ имеет код `401`
|
||||
|
||||
### Requirement: Пришедшего называет доверенный источник
|
||||
|
||||
Сервис SHALL узнавать пришедшего по заголовку `Remote-User`, который ставит
|
||||
обратный прокси, сходивший к провайдеру, и MUST не вести собственного входа: ни
|
||||
адреса, уводящего к провайдеру, ни адреса возврата, ни куки, ни выхода у сервиса
|
||||
не остаётся.
|
||||
|
||||
Заголовку сервис MUST верить только тогда, когда запрос пришёл с адреса из
|
||||
объявленного перечня доверенных, и адрес этот MUST браться у самого соединения,
|
||||
а не из пересылаемого заголовка: значением пересылаемого распоряжается тот, кто
|
||||
шлёт запрос, и барьер, подделываемый той же строкой, которой он обходится, не
|
||||
барьер вовсе.
|
||||
|
||||
Заголовок, пришедший с недоверенного адреса, MUST не узнавать никого. Отказа при
|
||||
этом MUST не наступать в самом узнавании: проба здоровья, метрики и разметка
|
||||
приложения открыты неузнанному, и отказ на них закрыл бы наблюдение за сервисом
|
||||
всякому, кто пришлёт заголовок. Отказ приходит там, где приходил и раньше, —
|
||||
требованием учётной записи на адресах приложения.
|
||||
|
||||
**Узнавание идёт после ограничителя частоты, а не до него.** Оно читает
|
||||
хранилище, а на новом имени ещё и пишет в него; выполненное раньше ограничителя,
|
||||
оно работало бы на запросах, которые тот уже отверг, и поток отвергнутых
|
||||
обращений заводил бы учётные записи, которые потом не убираются ничем.
|
||||
|
||||
**Узнавание действует на объявленной области, а не на всей поверхности сервиса.**
|
||||
Область — корень приложения и адрес, которым хранилище выдаёт короткий токен
|
||||
файла; она MUST выводиться из объявленного адресного пространства сервиса, а не
|
||||
перечисляться вторым списком. Собственная поверхность хранилища под узнавание
|
||||
MUST не подпадать: правка учётной записи, её чтение и перечисление коллекции
|
||||
пользователей остаются недостижимыми для узнанного, потому что ключ учётной
|
||||
записи лежит там обычной колонкой, а правило правки у коллекции — библиотечное и
|
||||
разрешает править свою запись. Расширение области на всё дало бы узнанному
|
||||
переписать себе ключ на чужое имя и забрать чужой архив.
|
||||
|
||||
Сужение области закрывает и вторую вещь: узнавание MUST не срабатывать на пробе
|
||||
здоровья, на метриках и на ресурсах приложения. Иначе запрос за каждой картинкой
|
||||
стоил бы обращения к базе, а первый такой запрос с новым именем — записи в неё.
|
||||
|
||||
**Значение заголовка принимается, а не берётся как есть.** Пустое значение и
|
||||
значение из одних пробельных знаков MUST не узнавать никого и MUST не заводить
|
||||
учётной записи: прокси штатно шлёт пустой заголовок там, где никого не назвал, и
|
||||
без этой нормы все неназванные собрались бы в одну учётную запись с общим
|
||||
архивом. Запрос, несущий **более одного** значения `Remote-User`, MUST не
|
||||
узнавать никого: прокси, настроенный добавлять заголовок вместо замены, оставляет
|
||||
рядом со своим значением присланное анонимом, и выбор «первое попавшееся» отдал
|
||||
бы вход анониму. Значение сверх объявленного предела длины и значение с
|
||||
управляющими знаками MUST не узнавать никого. Сравнение при поиске MUST быть
|
||||
точным, знак в знак: приведение регистра склеило бы двух разных людей по правилу,
|
||||
которого у провайдера нет. Обрамляющие пробелы при этом MUST срезаться до
|
||||
сравнения: они не часть имени, и заголовок с ведущим пробелом называет того же
|
||||
человека. Предел длины MUST считаться в **знаках** — той же единицей, что
|
||||
считает колонка.
|
||||
|
||||
Отказ хранилища при узнавании MUST кончаться отказом сервиса, а не молчаливым
|
||||
проходом неузнанным: иначе человек увидит отказ входа там, где легла база.
|
||||
|
||||
Исход узнавания MUST оставлять строку журнала — и когда заголовок пришёл с
|
||||
недоверенного адреса, и когда заголовок пришёл **более чем одним значением**, и
|
||||
когда учётная запись заведена. Уровень первых двух MUST быть виден при боевой
|
||||
настройке журнала: обе строки означают поломку контура, а поломка, записанная
|
||||
уровнем, который в бою выключен, не записана вовсе. Без неё владелец, у
|
||||
которого никто не может войти, не отличит своей поломки (перечень доверенных
|
||||
адресов) от поломки контура (прокси заголовка не ставит), а это разные поломки в
|
||||
разных местах. Строка несёт адрес пира и идентификатор учётной записи и MUST не
|
||||
нести значения заголовка.
|
||||
|
||||
Имя, пригодное к показу, сервис SHALL брать из заголовка `Remote-Name`, адрес
|
||||
почты — из `Remote-Email`. Имена всех трёх заголовков нормативны: смена имени
|
||||
молча перестаёт узнавать всех, а проверка, которая сама ставит и сама читает своё
|
||||
имя, этого не замечает. Контур уже пишет эти имена соседним сервисам.
|
||||
|
||||
Узнавание MUST идти на каждом запросе, и значения, переживающего запрос, сервис
|
||||
MUST не выдавать — ни куки, ни токена сессии. Исключение одно и названо здесь же:
|
||||
**короткий токен файла**, который хранилище выдаёт узнанному, чтобы тот прошёл по
|
||||
ссылке на файл записи; его нормирует capability `storage`, а срок его жизни
|
||||
назначается числом и живёт там, где проект держит числовые настройки. На этот
|
||||
срок — и только на него — отзыв доступа до файловой ссылки не доходит.
|
||||
|
||||
В остальном смысл именно таков: отзыв доступа судит провайдер на каждом
|
||||
обращении, а не однажды выданный срок.
|
||||
|
||||
Собственный токен хранилища, предъявленный запросом, MUST побеждать заголовок:
|
||||
владелец панели предъявляет свой, и подмена его учётной записью пользователя
|
||||
отобрала бы у него панель посреди работы.
|
||||
|
||||
Значение заголовка MUST не попадать ни в журнал, ни в ответ, ни в метку метрики.
|
||||
Оно приходит строкой запроса и целиком задаётся тем, кто её шлёт, а с
|
||||
недоверенного адреса — анонимом; сверх того имя принадлежит человеку наравне с
|
||||
адресом его почты.
|
||||
|
||||
#### Scenario: Заголовок с доверенного адреса узнаёт человека
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения приходит с заголовком `Remote-User`
|
||||
- **THEN** запрос идёт от имени учётной записи с этим значением
|
||||
|
||||
#### Scenario: Заголовок с недоверенного адреса не узнаёт никого
|
||||
|
||||
- **GIVEN** адреса источника в перечне доверенных нет
|
||||
- **WHEN** запрос к адресу приложения приходит с тем же заголовком
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи с этим значением не появляется
|
||||
|
||||
#### Scenario: Предъявленный токен побеждает заголовок
|
||||
|
||||
- **GIVEN** запрос несёт и заголовок `Remote-User`, и годный собственный токен
|
||||
хранилища
|
||||
- **WHEN** сервис решает, кто пришёл
|
||||
- **THEN** пришедшим считается предъявитель токена
|
||||
|
||||
#### Scenario: Протухший токен узнаванию не мешает
|
||||
|
||||
- **GIVEN** запрос несёт заголовок `Remote-User` и негодный либо протухший токен
|
||||
хранилища
|
||||
- **WHEN** сервис решает, кто пришёл
|
||||
- **THEN** пришедшим считается названный заголовком
|
||||
|
||||
#### Scenario: Пустой заголовок не узнаёт никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения приходит с пустым `Remote-User`
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Два значения одного заголовка не узнают никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения несёт два значения `Remote-User`
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Значение сверх предела длины не узнаёт никого
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос несёт `Remote-User` длиннее объявленного предела
|
||||
- **THEN** ответ имеет код `401`
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Поверхность хранилища узнаванию не подпадает
|
||||
|
||||
- **GIVEN** запрос идёт с доверенного адреса с заголовком `Remote-User`
|
||||
- **WHEN** он правит запись коллекции пользователей собственным адресом
|
||||
хранилища
|
||||
- **THEN** правка не проходит
|
||||
|
||||
#### Scenario: Проба здоровья учётной записи не заводит
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** запрос с заголовком приходит на `GET /health` с доверенного адреса
|
||||
- **THEN** учётной записи не появляется
|
||||
|
||||
#### Scenario: Недоверенный источник виден в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком приходит с недоверенного адреса
|
||||
- **THEN** журнал несёт строку об этом исходе с адресом пира
|
||||
- **AND** значения заголовка в ней нет
|
||||
|
||||
#### Scenario: Сервис не ставит браузеру куки
|
||||
|
||||
- **GIVEN** адрес источника стоит в перечне доверенных
|
||||
- **WHEN** запрос к адресу приложения проходит с заголовком
|
||||
- **THEN** ответ не ставит браузеру ни куки сессии, ни иного значения доступа
|
||||
|
||||
#### Scenario: Значения заголовка нет в журнале
|
||||
|
||||
- **WHEN** запрос с заголовком `Remote-User` проходит через сервис
|
||||
- **THEN** значение заголовка не встречается ни в одной журнальной записи
|
||||
|
||||
### Requirement: Учётная запись заводится первым обращением
|
||||
|
||||
Сервис SHALL заводить учётную запись при первом обращении с новым значением
|
||||
`Remote-User` и MUST находить её по тому же значению при каждом следующем.
|
||||
Значение MUST быть ключом учётной записи — уникальным и хранимым своей колонкой
|
||||
коллекции пользователей.
|
||||
|
||||
Имя и адрес почты MUST браться из заголовков того же запроса, и только при
|
||||
заведении. Оба MUST **приниматься**, а не браться как есть: имя обрезается по
|
||||
пределу колонки и чистится от управляющих знаков, негодный адрес почты
|
||||
отбрасывается. Негодное значение необязательного поля MUST не отменять
|
||||
заведения записи — иначе человек с длинным именем у провайдера не завёлся бы
|
||||
никогда, получая отказ сервиса на каждом запросе. Найденную запись повторное обращение MUST не переписывать: иначе
|
||||
всякий запрос был бы записью в базу, а правка имени у провайдера меняла бы
|
||||
карточку человека молча, посреди его работы.
|
||||
|
||||
Адрес почты MUST быть необязательным: провайдер не обязан его приносить, а ключом
|
||||
он не служит. Ключом его брать нельзя вовсе — адрес меняется, и первое обращение
|
||||
с чужим адресом досталось бы чужой записи.
|
||||
|
||||
**Ключ учётной записи MUST не правиться ничем, кроме заведения самим сервисом.**
|
||||
Ни запросом снаружи, ни рукой в панели: переписанный ключ отдаёт архив
|
||||
следующему, кто придёт с этим именем, а вернуть его будет нечем — владелец записи
|
||||
назначается один раз и не меняется. Правило доступа коллекции пользователей MUST
|
||||
закрывать правку записи снаружи наглухо, и MUST это держать схема, а не область
|
||||
действия узнавания: защита, стоящая на том, что до поверхности хранилища никто не
|
||||
дотянется, однажды уже оказалась случайной.
|
||||
|
||||
Одновременные первые обращения одним значением MUST кончаться одной учётной
|
||||
записью: уникальность держит схема, а не порядок обращений.
|
||||
|
||||
**Два отказа уникальности различаются, и исход у них разный.** Отказ по ключевой
|
||||
колонке — это гонка двух первых обращений одним именем, и он MUST кончаться
|
||||
повторным поиском и продолжением работы. Отказ по любой другой колонке — адрес
|
||||
почты, пришедший от провайдера, уже занят другой учётной записью — MUST кончаться
|
||||
заведением записи **без почты**: она необязательна. Без этого разреза второй
|
||||
человек с общим почтовым ящиком не завёлся бы никогда, потому что повторный поиск
|
||||
по имени снова ничего не находит.
|
||||
|
||||
Цена ключа называется целиком, обеими сторонами. Переименование пользователя у
|
||||
провайдера заводит **новую** учётную запись, и записи прежней остаются у прежней;
|
||||
слить их или убрать нечем — владелец записи не меняется, а учётная запись с
|
||||
записями не удаляется по норме `storage`. **Логин же переиспользуем**: человек,
|
||||
которому провайдер выдал логин ушедшего, при первом обращении попадает в
|
||||
существующую запись и получает весь её архив. Не допускать переиспользования —
|
||||
работа провайдера; сервису неизменяемого признака заголовок не приносит, и эта
|
||||
цена принимается, а не обходится.
|
||||
|
||||
#### Scenario: Первое обращение заводит запись
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** приходит запрос с заголовком `Remote-User`
|
||||
- **THEN** учётная запись появляется
|
||||
- **AND** запрос идёт от её имени
|
||||
|
||||
#### Scenario: Повторное обращение попадает в ту же запись
|
||||
|
||||
- **GIVEN** учётная запись заведена первым обращением
|
||||
- **WHEN** приходит второй запрос с тем же значением заголовка
|
||||
- **THEN** новой учётной записи не появляется
|
||||
- **AND** запрос идёт от имени прежней
|
||||
|
||||
#### Scenario: Разным значениям — разные записи
|
||||
|
||||
- **WHEN** приходят запросы с двумя разными значениями заголовка
|
||||
- **THEN** заводятся две учётные записи
|
||||
- **AND** записи одного не видны другому
|
||||
|
||||
#### Scenario: Имя не переписывается вторым обращением
|
||||
|
||||
- **GIVEN** учётная запись заведена с одним значением `Remote-Name`
|
||||
- **WHEN** приходит запрос с тем же `Remote-User` и другим `Remote-Name`
|
||||
- **THEN** имя учётной записи остаётся прежним
|
||||
|
||||
#### Scenario: Два одновременных первых обращения дают одну запись
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** два запроса с одним значением заголовка приходят одновременно
|
||||
- **THEN** в коллекции пользователей появляется ровно одна запись
|
||||
- **AND** оба запроса идут от её имени
|
||||
|
||||
#### Scenario: Занятая почта не мешает завести запись
|
||||
|
||||
- **GIVEN** учётная запись с этим адресом почты уже заведена
|
||||
- **WHEN** приходит первое обращение с другим `Remote-User` и тем же
|
||||
`Remote-Email`
|
||||
- **THEN** заводится своя учётная запись
|
||||
- **AND** адреса почты у неё нет
|
||||
|
||||
#### Scenario: Ключ учётной записи не правится и рукой в панели
|
||||
|
||||
- **GIVEN** учётная запись заведена
|
||||
- **WHEN** её ключ меняют сохранением записи мимо адресов приложения
|
||||
- **THEN** сохранение отвергается, а ключ остаётся прежним
|
||||
|
||||
#### Scenario: Негодное имя не отменяет заведения
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** приходит обращение с именем длиннее предела колонки
|
||||
- **THEN** учётная запись заводится, а имя обрезано по пределу
|
||||
|
||||
#### Scenario: Негодная почта отбрасывается, а не отменяет заведение
|
||||
|
||||
- **GIVEN** учётной записи с этим значением ещё нет
|
||||
- **WHEN** приходит обращение с адресом почты, не похожим на адрес
|
||||
- **THEN** учётная запись заводится без почты
|
||||
|
||||
#### Scenario: Отвергнутый ограничителем запрос учётной записи не заводит
|
||||
|
||||
- **GIVEN** бюджет ограничителя частоты выбран
|
||||
- **WHEN** приходит обращение с новым значением заголовка
|
||||
- **THEN** ответ несёт отказ ограничителя
|
||||
- **AND** учётной записи не появляется
|
||||
|
||||
#### Scenario: Заведение учётной записи видно в журнале
|
||||
|
||||
- **WHEN** приходит первое обращение с новым значением заголовка
|
||||
- **THEN** журнал несёт строку о заведении с идентификатором записи
|
||||
- **AND** значения заголовка в ней нет
|
||||
|
||||
#### Scenario: Ключ учётной записи снаружи не правится
|
||||
|
||||
- **GIVEN** человек узнан и его учётная запись заведена
|
||||
- **WHEN** он правит ключ своей учётной записи запросом к хранилищу
|
||||
- **THEN** правка не проходит, а ключ остаётся прежним
|
||||
|
||||
### Requirement: Доверенный источник объявлен настройкой
|
||||
|
||||
Сервис SHALL брать перечень доверенных адресов из конфига и MUST ронять старт,
|
||||
когда перечень пуст либо его строки не читаются как адрес или подсеть. Пустой
|
||||
перечень значит «не верить никому»: сервис поднялся бы никого не узнающим, а
|
||||
узнать об этом было бы неоткуда.
|
||||
|
||||
Отказ старта MUST называть имя ключа. Ни адресов провайдера, ни идентификатора
|
||||
клиента, ни секрета клиента в конфиге MUST не быть: менять код больше не на что,
|
||||
и секрет уходит из конфига вместе с протоколом.
|
||||
|
||||
Перечень MUST называться строкой журнала при подъёме. Сервис, никого не узнающий
|
||||
из-за неверного перечня, иначе неотличим от сервиса, до которого заголовок не
|
||||
доходит вовсе, — а это разные поломки в разных местах.
|
||||
|
||||
#### Scenario: Пустой перечень роняет старт
|
||||
|
||||
- **WHEN** сервис поднимается с пустым перечнем доверенных адресов
|
||||
- **THEN** старт кончается отказом
|
||||
- **AND** отказ называет имя ключа
|
||||
|
||||
#### Scenario: Негодная строка перечня роняет старт
|
||||
|
||||
- **WHEN** сервис поднимается с перечнем, где строка не читается как адрес или
|
||||
подсеть
|
||||
- **THEN** старт кончается отказом
|
||||
|
||||
#### Scenario: Перечень виден в журнале подъёма
|
||||
|
||||
- **WHEN** сервис поднимается с заполненным перечнем
|
||||
- **THEN** журнал подъёма называет доверенные адреса
|
||||
|
||||
|
||||
Reference in New Issue
Block a user