
В предыдущих публикациях я уже рассказывал, зачем вообще появился Browser Policy Manager, почему я начал с Firefox Enterprise Policies и почему не стал делать «просто генератор policies.json». Эта статья — более техническая. Здесь я хочу разобрать, как Browser Policy Manager устроен внутри на версии 0.8.8.
Версия 0.8.8 для проекта важна тем, что в ней фактически сложилась основная архитектура продукта:
есть библиотека профилей;
есть пошаговый редактор для типовых сценариев;
есть All settings как полноценная рабочая поверхность для всех настроек;
есть отдельный JSON-редактор;
есть импорт и экспорт настоящего Firefox Enterprise
policies.json;есть сравнение сохранённых профилей;
есть поддержка Firefox ESR 140.12 и Firefox Release 152;
есть шесть локалей интерфейса;
есть слой проверок, миграций, контрактных тестов и браузерных проверок.
Следующий большой блок — версия 0.9.0: документация на основе DITA-OT, контекстная справка, более цельное описание сценариев администратора и специалиста по информационной безопасности. После этого уже можно будет доводить продукт до 1.0.
Но прежде чем писать документацию, полезно «распаковать» сам продукт: какие сущности в нём есть, где проходит граница между внутренней моделью и policies.json, почему интерфейс разделён на несколько маршрутов и как проверяется, что всё это не разваливается при очередном обновлении схем Firefox.
Что такое Browser Policy Manager технически
Browser Policy Manager — это веб-приложение на FastAPI для создания, проверки, редактирования и выгрузки профилей корпоративных политик Firefox.
Текущий стек:
Python 3.14+;
FastAPI;
Pydantic v2;
SQLAlchemy 2.x;
Alembic;
SQLite по умолчанию;
опциональная поддержка PostgreSQL;
Jinja2 для серверных шаблонов;
локально поставляемые статические ресурсы;
Monaco Editor для JSON-поверхности;
jsonschema для проверки политик;
pytest, Ruff, mypy, Selenium/Chromium-проверки и отдельные live-проверки Firefox.

Архитектурно BPM — не одностраничное приложение с одной большой скрытой панелью, а набор связанных рабочих поверхностей:
Поверхность | Маршрут | Назначение |
|---|---|---|
Библиотека профилей |
| Управление сохранёнными профилями |
Сравнение |
| Сравнение двух сохранённых профилей |
Пошаговый редактор |
| Типовые сценарии настройки |
Все настройки |
| Полный каталог и проверка всех настроек |
JSON-редактор |
| Прямая работа с итоговым документом |
Такое разделение появилось не сразу. На ранних этапах было соблазнительно держать всё в одном большом интерфейсе. Но при росте функциональности это быстро превращается в мешанину: библиотека начинает отвечать за редактирование, редактор — за сравнение, сравнение — за навигацию, а пользователь теряет понимание, где он сейчас находится.
В версии 0.8.8 я окончательно развёл рабочие поверхности по маршрутам. Это дало простую, но важную вещь: один и тот же профиль можно открыть в разных вкладках браузера в разных режимах. Например, слева держать All settings, справа JSON-редактор, а в отдельной вкладке — сравнение с эталонным профилем.
Точка сборки приложения
Приложение собирается через фабрику create_app(). В ней подключаются middleware, статика, API-маршруты, HTML-маршруты и локализационные каталоги.
Упрощённо это выглядит так:
def create_app() -> FastAPI: app = FastAPI( title=settings.APP_NAME, version=settings.APP_VERSION, ) app.add_middleware(SecurityHeadersMiddleware) if settings.ENABLE_CORS: app.add_middleware( CORSMiddleware, allow_origins=settings.CORS_ALLOW_ORIGINS, allow_credentials=True, allow_methods=["*"], allow_headers=["*"], ) app.mount( "/static", StaticFiles(directory=str(settings.STATIC_DIR)), name="static", ) app.include_router(web_profiles.router) app.include_router(health.router) app.include_router(profiles.router) app.include_router(export.router) app.include_router(validation.router) @app.get("/i18n/{locale}.json", include_in_schema=False) async def locale_catalog(locale: str) -> Response: if locale not in settings.SUPPORTED_LOCALES: raise HTTPException(status_code=404, detail="Locale not supported") locale_path = _resolve_path(settings.I18N_DIR) / f"{locale}.json" if not locale_path.is_file(): raise HTTPException(status_code=404, detail="Locale file not found") return Response( content=locale_path.read_text(encoding="utf-8"), media_type="application/json", ) return app
Здесь есть несколько принципиальных моментов.
Первый — HTML-интерфейс и API живут в одном приложении. BPM сейчас не требует отдельного фронтенд-приложения, отдельного сборщика для основной логики и отдельного развёртывания клиентской части. Для продукта такого класса это важно: администратор должен иметь возможность поднять инструмент локально или в небольшой внутренней среде без лишней инфраструктуры.
Второй — локализация обслуживается как часть приложения. Каталоги доступны через /i18n/{locale}.json, а не вшиты хаотично в шаблоны или JavaScript.
Третий — безопасность интерфейса не вынесена «на потом». Есть middleware, который добавляет базовые HTTP-заголовки безопасности и политику Content Security Policy. Для приложения, которое управляет настройками безопасности браузера, было бы странно игнорировать собственную поверхность атаки.
Профиль как главная сущность
Главная внутренняя сущность BPM — не файл и не отдельная политика, а профиль.
Профиль — это сохранённая конфигурация Firefox Enterprise Policies с метаданными, версией схемы, набором политик, данными соответствия требованиям и жизненным циклом.
Упрощённая ORM-модель:
class Profile(Base): __tablename__ = "profiles" id: Mapped[int] = mapped_column( Integer, primary_key=True, autoincrement=True, ) name: Mapped[str] = mapped_column( String(255), nullable=False, index=True, unique=True, ) description: Mapped[str | None] = mapped_column(Text, nullable=True) schema_version: Mapped[str] = mapped_column( String(50), nullable=False, index=True, default=DEFAULT_SCHEMA_CHANNEL, ) flags: Mapped[dict[str, Any]] = mapped_column( JSON, nullable=False, default=dict, ) compliance: Mapped[dict[str, Any] | None] = mapped_column( JSON, nullable=True, ) revision: Mapped[int] = mapped_column( Integer, nullable=False, default=1, server_default="1", ) created_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), nullable=False, index=True, ) updated_at: Mapped[datetime] = mapped_column( DateTime(timezone=True), server_default=func.now(), onupdate=func.now(), nullable=False, index=True, ) deleted_at: Mapped[datetime | None] = mapped_column( DateTime(timezone=True), nullable=True, index=True, ) @property def is_deleted(self) -> bool: return self.deleted_at is not None
Здесь важно поле flags. На границе продукта пользователь импортирует и экспортирует документ вида:
{ "policies": { "DisableTelemetry": true, "Preferences": { "browser.tabs.warnOnClose": { "Value": true, "Status": "locked" } } } }
Но внутри профиля хранится не весь внешний документ, а нормализованное содержимое policies.
То есть внешний контракт Firefox:
{ "policies": { "...": "..." } }
А внутренняя модель BPM:
{ "...": "..." }
Почему так?
Потому что внутри продукта нужно работать не с файлом, а с управляемой сущностью. Библиотеке, редактору, сравнению, проверке, All settings и экспорту не нужен лишний внешний контейнер. Им нужна каноническая карта настроек профиля. А вот на входе и выходе продукт обязан говорить на языке Firefox — поэтому policies.json остаётся публичным форматом импорта и экспорта.
Pydantic-схемы: граница API
Pydantic-модели отделяют внутреннюю ORM-сущность от данных, которые принимает и возвращает API.
Упрощённо:
class ProfileBase(BaseModel): name: str = Field(..., max_length=255) description: str | None = None schema_version: str = Field(default=DEFAULT_SCHEMA_CHANNEL, max_length=50) flags: dict[str, Any] = Field(default_factory=dict) compliance: dict[str, Any] | None = None class ProfileCreate(ProfileBase): pass class ProfileUpdate(BaseModel): description: str | None = None schema_version: str | None = Field(default=None, max_length=50) flags: dict[str, Any] | None = None compliance: dict[str, Any] | None = None expected_revision: int | None = Field(default=None, ge=1) class ProfileRead(ProfileBase): id: int revision: int created_at: datetime updated_at: datetime deleted_at: datetime | None = None is_deleted: bool validation_state: str = "not_validated" model_config = ConfigDict(from_attributes=True)
Особенно важен expected_revision. Он нужен для защиты от неприятного сценария: один и тот же профиль открыт в нескольких вкладках, пользователь меняет его в одном месте, а затем сохраняет устаревшее состояние из другой вкладки.
Это не полноценная совместная работа нескольких пользователей, но уже нормальная оптимистическая проверка версии для локального или небольшого командного сценария.
Сервисный слой: CRUD, жизненный цикл и состояние проверки
В BPM есть отдельный сервисный слой для работы с профилями. Он отвечает за выборку, фильтрацию, подсчёты, создание, обновление, архивирование, восстановление и безвозвратное удаление.
Упрощённый фрагмент:
@dataclass(frozen=True, slots=True) class ProfileQuery: q: str | None = None schema_version: str | None = None validation_state: str | None = None lifecycle: str = "active" include_deleted: bool = False limit: int = 50 offset: int = 0 sort: str = "updated_at" order: str = "desc" class ProfileService: @staticmethod def _validation_state(profile: Profile) -> str: if not profile.flags: return "not_validated" try: validate_profile_payload_with_schema( { "channel": profile.schema_version, "policies": profile.flags, } ) except (PolicyValidationError, ValueError): return "invalid" return "valid"
Состояние проверки (valid, invalid, not_validated) не хранится как отдельное поле в базе. Оно вычисляется на основе текущих flags и выбранной схемы Firefox.
Это осознанное решение. Если схема Firefox обновилась, старое сохранённое значение valid могло бы стать ложным. Поэтому состояние проверки лучше получать из фактических данных профиля и актуального валидатора.

Жизненный цикл профиля сейчас выглядит так:
активный профиль;
архивированный профиль;
восстановление из архива;
безвозвратное удаление.
Архивирование реализовано через deleted_at, то есть это мягкое удаление. В 0.8.8 добавлено отдельное безвозвратное удаление с явным подтверждением. Для интерфейса управления конфигурациями это важно: случайное удаление профиля должно быть обратимым, а необратимое действие должно быть явно отделено.
Почему Все настройки пришлось переделывать
Все настройки — самая важная часть версии 0.8.8.
Изначально полный каталог настроек можно было сделать очень просто: взять схему Firefox, пройтись по properties, нарисовать список политик, добавить поиск и редактор значения. Формально задача решена.
Но на практике это плохо работает для корпоративного профиля.
Когда в одном профиле смешиваются базовые настройки, CIS-рекомендации, ручные правки, импортированные значения, неизвестные параметры, устаревшие элементы и ошибки проверки, пользователю не нужен просто длинный каталог. Ему нужно понять состояние профиля.
Поэтому в 0.8.8 раздел Все настройки разделён на три режима:
Режим | Что показывает | Для чего нужен |
|---|---|---|
Проверка | Ошибки, спорные места, CIS проверка, необработанные/неизвестные/импортированные/устаревшие | Быстрая проверка профиля |
Настроенные | То, что реально настроено в профиле | Анализ применяемой конфигурации |
Каталог | Полный каталог доступных настроек | Поиск и добавление новых политик |



Это изменение кажется интерфейсным, но на самом деле оно архитектурное. Нельзя просто добавить три вкладки, если под ними нет общего инвентаря настроек.
Внутри нужен общий слой, который понимает:
какие настройки пришли из схемы Firefox;
какие уже есть в профиле;
какие пришли из CIS-слоёв;
какие были импортированы;
какие неизвестны текущей схеме;
какие устарели;
какие требуют ручного решения;
какие имеют ошибки проверки;
как показать источник настройки;
как перейти от результата поиска к единственному месту редактирования.
Ключевой принцип: Проверка, Настроенные и Каталог не должны иметь три независимых способа редактирования. Иначе через несколько версий появятся расхождения: в одном режиме настройка сохраняется так, в другом иначе, в третьем забыли обновить источник. Поэтому в 0.8.8 Все настройки строятся вокруг общего инвентаря и одного основного редактора деталей.
Схемы Firefox как версия предметной области
BPM не может быть независим от версий Firefox. Политики появляются, меняются, устаревают, часть настроек доступна только в Release, часть должна корректно скрываться или иначе интерпретироваться в ESR.
В 0.8.8 поддерживаются два активных канала схем:
@dataclass(frozen=True, slots=True) class SchemaChannel: value: str label: str filename: str raw_dir: str mozilla_version: str family: str is_default: bool = False SCHEMA_CHANNELS: tuple[SchemaChannel, ...] = ( SchemaChannel( value="esr-140.12", label="ESR 140.12", filename="firefox-esr-140.12.json", raw_dir="esr14012", mozilla_version="140.12", family="esr", is_default=True, ), SchemaChannel( value="release-152", label="Release 152", filename="firefox-release-152.json", raw_dir="release152", mozilla_version="152.0", family="release", ), )
Выбранный канал влияет сразу на несколько вещей:
проверку профиля;
доступные элементы интерфейса;
импорт
policies.json;нормализацию старых профилей;
покрытие All settings;
поведение отдельных политик, которые есть в Release, но не должны показываться для ESR.

Это особенно важно для организаций, которые сидят на ESR. Если инструмент показывает администратору настройки, которых нет в его целевой версии Firefox, он не помогает, а создаёт риск ошибки.
Загрузка и нормализация схем
Схемы Firefox в BPM поставляются вместе с проектом. Загрузчик схем ищет файл в нескольких местах и нормализует структуру там, где это нужно для валидного JSON Schema.
Упрощённый фрагмент:
@lru_cache(maxsize=16) def load_schema(profile: str, *, allow_stub_fallback: bool = False) -> dict[str, Any]: if profile not in _PROFILE_FILES: raise UnsupportedProfileError( f"Unsupported profile '{profile}'. Supported: {', '.join(_PROFILE_FILES)}" ) _ensure_dirs() static_path = _STATIC_DIR / _PROFILE_FILES[profile] mozilla_path = _mozilla_raw_schema_path(profile) bundled_policy_path = _bundled_policy_schema_path(profile) cache_path = _CACHE_DIR / _PROFILE_FILES[profile] if static_path.exists(): return _read_json_file(static_path) if mozilla_path.exists(): return _read_json_file(mozilla_path) if bundled_policy_path.exists(): return _read_json_file(bundled_policy_path) if cache_path.exists(): return _read_json_file(cache_path) if not allow_stub_fallback: raise SchemaNotFoundError( f"Schema file not found for profile '{profile}'" ) stub = _minimal_schema(f"Firefox {profile} Policies (stub)") _write_json_file(cache_path, stub) return stub
Здесь важно, что fallback на минимальную заглушку не является обычным поведением приложения. Для тестов это может быть удобно, но в реальной работе инструмент не должен молча валидировать политики по неполной схеме. Если схема потерялась при упаковке или обновлении, это ошибка поставки, а не повод делать вид, что всё нормально.
Проверка политик
Проверка построена поверх jsonschema, но сырой ValidationError неудобен для интерфейса и API. Пользователю нужно показать не стек исключения, а понятные проблемы: какая политика, какой путь, что именно не так.
Поэтому внутри есть собственный формат ошибки:
@dataclass class PolicyValidationIssue: policy: str | None path: list[str | int] message: str class PolicyValidationError(ValueError): def __init__(self, issues: list[PolicyValidationIssue]) -> None: super().__init__("Policy validation failed") self.issues = issues
При сборке валидатора BPM дополнительно ужесточает корень документа:
def _normalize_policy_document_schema(schema: JsonSchema) -> JsonSchema: if schema.get("type") != "object": return schema properties = schema.get("properties") if not isinstance(properties, dict) or not properties: return schema if schema.get("additionalProperties") is False: return schema normalized = dict(schema) normalized["additionalProperties"] = False return normalized
Это нужно, чтобы неизвестные top-level политики не проходили как допустимые только потому, что в исходном снимке схемы было слишком мягкое additionalProperties.
Сам высокоуровневый вызов проверки выглядит так:
def validate_profile_payload_with_schema(payload: dict[str, Any]) -> None: channel = payload.get("channel") or DEFAULT_RELEASE_SCHEMA_CHANNEL policies = payload.get("policies") or {} if not isinstance(policies, dict): raise PolicyValidationError( [ PolicyValidationIssue( policy=None, path=["policies"], message="Expected object with policy mappings", ) ] ) validate_profile_policies_or_raise_for_channel(policies, channel)
В результате один и тот же механизм может использоваться:
при создании профиля;
при обновлении профиля;
при импорте
policies.json;в отдельном API проверки;
при вычислении состояния профиля в библиотеке;
в All settings для подсветки проблемных мест.
Импорт policies.json: внешний контракт против внутренней модели
Импорт Firefox Enterprise policies.json — это хороший пример границы между внешним и внутренним форматом.
Пользователь приносит документ:
{ "policies": { "DisableTelemetry": true, "DisablePrivateBrowsing": false } }
BPM проверяет, что это именно внешний документ Firefox, а не произвольный внутренний JSON. Если у документа нет корневого объекта policies, это ошибка импорта.
Упрощённый код:
def parse_firefox_policies_document(document: Any) -> dict[str, Any]: if not isinstance(document, dict): raise FirefoxPoliciesImportError( [ FirefoxPoliciesImportIssue( path=[], message="Expected Firefox policies.json root object", ) ] ) issues: list[FirefoxPoliciesImportIssue] = [] if "policies" not in document: issues.append( FirefoxPoliciesImportIssue( path=["policies"], message="Missing required Firefox policies object", ) ) unsupported_keys = sorted(key for key in document if key != "policies") for key in unsupported_keys: issues.append( FirefoxPoliciesImportIssue( path=[key], message=f"Unsupported top-level key '{key}'", ) ) policies = document.get("policies") if "policies" in document and not isinstance(policies, dict): issues.append( FirefoxPoliciesImportIssue( path=["policies"], message="Expected policies to be an object", ) ) if issues: raise FirefoxPoliciesImportError(issues) return dict(policies)
После структурной проверки документ валидируется по выбранному каналу Firefox:
def validate_firefox_policies_document( document: Any, channel: str, ) -> dict[str, Any]: flags = parse_firefox_policies_document(document) schema = load_policy_schema_for_channel(channel) try: validate_profile_policies_or_raise(flags, schema) except PolicyValidationError as exc: raise FirefoxPoliciesDocumentValidationError( [ PolicyValidationIssue( policy=issue.policy, path=["policies", *issue.path], message=issue.message, ) for issue in exc.issues ] ) from exc return flags
Обратите внимание на path=["policies", *issue.path]. Внутренняя проверка работает с картой политик, а пользователь импортировал внешний документ. Поэтому путь ошибки нужно вернуть в терминах внешнего policies.json. Иначе пользователь увидит сообщение, которое технически правильно внутри приложения, но плохо соотносится с файлом, который он загрузил.

Экспорт: обратно в формат Firefox
Экспорт делает обратное преобразование: берёт внутренние flags и собирает канонический документ Firefox.
def render_firefox_policies_document( flags: dict[str, Any] | None, ) -> dict[str, Any]: if isinstance(flags, dict) and isinstance(flags.get("policies"), dict): return {"policies": dict(flags["policies"])} return {"policies": dict(flags or {})}
Небольшая защитная ветка с flags.get("policies") нужна для совместимости с возможными старыми или импортированными состояниями, где данные уже могли быть в обёртке policies.
API-ответ для экспорта:
def _build_firefox_policies_json_response( profile: ProfileRead, *, profile_id: int, download: int = 0, indent: int | None = None, pretty: int = 0, ) -> Response: if indent is None and pretty: indent = 2 body_json = json.dumps( render_firefox_policies_document(profile.flags), indent=indent, ) headers: dict[str, str] = {} if download: headers["Content-Disposition"] = ( f'attachment; filename="profile-{profile_id}-policies.json"' ) return Response( content=body_json, media_type="application/json", headers=headers, )
То есть внутри продукт может иметь сколько угодно служебной логики, но на выходе пользователь получает обычный Firefox Enterprise policies.json, который можно дальше разложить на рабочие станции привычными средствами.

API: не только для интерфейса
API в BPM сейчас нужен не только как внутренняя опора интерфейса. Это ещё и будущая точка интеграции.
Основные маршруты профилей:
GET /api/profiles GET /api/profiles/stats GET /api/profiles/{id} POST /api/profiles PATCH /api/profiles/{id} DELETE /api/profiles/{id} POST /api/profiles/{id}/restore DELETE /api/profiles/{id}/hard DELETE /api/profiles/reset
Импорт, экспорт и проверка:
POST /api/profiles/import/firefox/policies.json GET /api/export/profiles/{id}/firefox/policies.json POST /api/validate/{profile}
В текущем состоянии это уже полезно для автоматизации. Например, можно хранить профили в BPM, а затем внешним процессом забирать policies.json и раскладывать его через Ansible, Salt, собственный агент, MDM или другой контур управления конфигурациями.
В 0.9.0 это надо будет нормально описать в документации: какие сценарии интеграции считаются поддерживаемыми, какие поля являются стабильным контрактом, а какие пока остаются внутренними.
HTML-маршруты: почему интерфейс разделён
HTML-часть живёт в app/web. Основной web-router отдаёт разные шаблоны для разных рабочих поверхностей.
Упрощённо:
@router.get("/profiles", response_class=HTMLResponse) async def profiles_page(request: Request, session: AsyncSession) -> HTMLResponse: return templates.TemplateResponse( request, "profiles_library.html", _build_profiles_page_context( request, title=f"Library — {settings.APP_NAME}", route_mode="library", ), ) @router.get("/profiles/compare", response_class=HTMLResponse) async def profiles_compare_page(request: Request) -> HTMLResponse: return templates.TemplateResponse( request, "profiles_compare.html", _build_profiles_page_context( request, title=f"Compare profile settings — {settings.APP_NAME}", route_mode="compare", ), ) @router.get("/profiles/{profile_id}/settings", response_class=HTMLResponse) async def profiles_settings_page( request: Request, profile_id: int, session: AsyncSession, ) -> HTMLResponse: profile = await ProfileService.get(session, profile_id) if profile is None: raise HTTPException(status_code=404, detail="Profile not found") return templates.TemplateResponse( request, "profiles_settings.html", _build_profiles_page_context( request, title=f"{profile.name} — All settings — {settings.APP_NAME}", route_mode="settings", editing_profile_id=profile_id, editing_profile_schema_version=profile.schema_version, editing_profile_initial=profile.model_dump(mode="json"), ), )
Здесь важно не столько количество маршрутов, сколько контекст. Все поверхности получают общий контекст страницы: локаль, тему, версию статических ресурсов, данные профиля, ссылки перехода между режимами, сведения о текущем маршруте.
Так получается компромисс:
интерфейс разделён на самостоятельные страницы;
каждая страница загружает только нужную ей логику;
разные режимы можно держать в отдельных вкладках;
при этом они не расходятся по модели данных.
Локализация как инженерное ограничение
В BPM шесть активных локалей:
English;
Русский;
Deutsch;
简体中文;
Français;
Español.

Для проекта, связанного с настройками безопасности, локализация — это не украшение. Ошибка в термине может привести к неправильному пониманию политики. Поэтому в проекте есть контракты на паритет ключей, плейсхолдеры, допустимые технические английские значения и терминологию.
Пример правила: в локализованном интерфейсе не должно случайно оставаться английского текста, если это не имя политики, бренд, API-термин, JSON-значение или другой намеренно непереведённый технический элемент.
В инженерном смысле локализация здесь выполняет ещё одну функцию: она ломает слишком хрупкий интерфейс. Если кнопка, карточка или фильтр нормально выглядят только по-английски, значит, интерфейс ещё недостаточно устойчив. Русский, немецкий или французский быстро показывают, где не хватает места, где плохая сетка, где неверно работает перенос.
Заголовки безопасности и CSP
У BPM есть middleware для базовых заголовков безопасности:
class SecurityHeadersMiddleware: def __init__(self, app: ASGIApp, *, csp: str | None = None) -> None: self.app = app self.csp = csp or ( "default-src 'self'; " "script-src 'self'; " "style-src 'self'; " "img-src 'self' data:; " "font-src 'self'; " "connect-src 'self'; " "worker-src 'self'; " "child-src 'self'; " "frame-ancestors 'none'; " "base-uri 'self'; " "form-action 'self'" ) async def __call__(self, scope: Scope, receive: Receive, send: Send) -> None: if scope["type"] != "http": await self.app(scope, receive, send) return async def send_with_security_headers(message: Message) -> None: if message["type"] == "http.response.start": headers = message.setdefault("headers", []) self._append_if_missing(headers, b"x-frame-options", b"DENY") self._append_if_missing(headers, b"x-content-type-options", b"nosniff") self._append_if_missing(headers, b"referrer-policy", b"no-referrer") self._append_if_missing( headers, b"permissions-policy", b"geolocation=(), microphone=(), camera=()", ) self._append_if_missing( headers, b"content-security-policy", self.csp.encode("utf-8"), ) await send(message) await self.app(scope, receive, send_with_security_headers)
HSTS намеренно не включён жёстко на уровне приложения, потому что BPM может запускаться локально или во внутреннем контуре без HTTPS. Это как раз тот случай, когда «добавим максимальный заголовок безопасности всегда» может навредить локальным сценариям.
Зато базовые ограничения — запрет фреймов, запрет sniffing, no-referrer, ограничение источников скриптов и соединений — включены.
SQLite по умолчанию, PostgreSQL как путь развития
По умолчанию BPM использует SQLite. Это прагматичный выбор для текущей стадии:
легко поднять локально;
не нужен отдельный сервер базы данных;
удобно для тестов;
достаточно для одиночного администратора, стенда, демонстрации или небольшой внутренней установки.
При этом архитектура не завязана намертво на SQLite. В зависимостях есть опциональный PostgreSQL-набор, а слой SQLAlchemy позволяет двигаться к более серьёзному развёртыванию без переписывания предметной логики.
В базе сейчас хранится не история всех изменений, а актуальные профили с жизненным циклом и ревизией. Для версии 1.0 и следующих этапов можно будет думать о полноценной истории, журнале изменений и более строгом согласовании профилей, но для 0.8.8 важнее было стабилизировать основную модель.
Проверки качества
Для продукта, который управляет настройками безопасности, обычного «открывается у меня в браузере» недостаточно.
В проекте есть несколько уровней проверок:
make typecheck make lint make test-fast make test-contract make test-firefox-schema-contract make test-locale-contract make coverage make test-ui make test-release make test-firefox-live
Они разделены не случайно.
Быстрый контур нужен для ежедневной разработки. Контрактные тесты защищают API, документационные ожидания, локализацию, схемы и устойчивость структуры проекта. UI-проверки через Chromium/Selenium нужны для маршрутов, переключения локалей, больших списков, сравнения, редакторов и основных переходов. Live Firefox-проверки нужны отдельно: они проверяют не интерфейс BPM, а то, что экспортированный policies.json действительно влияет на поведение Firefox.

В 0.8.8 после большой переработки раздела Все настройки важно было сохранить зелёными не только обычные тесты, но и браузерные сценарии. Именно такие изменения чаще всего ломают не функцию как таковую, а связку: маршрут → локаль → состояние → отрисовка → действие пользователя → сохранение → экспорт.
Что получилось к 0.8.8
Если коротко, 0.8.8 — это версия, в которой Browser Policy Manager стал похож не на набор экранов, а на цельную рабочую среду.
Главное изменение — Все настройки. Теперь это не просто полный список настроек Firefox, а три разных режима работы с одним инвентарём:
Проверка — сначала проблемы и решения;
Настроенные — сначала то, что реально применяется;
Каталог — полный каталог, когда нужно найти и добавить новую настройку.
Второе важное изменение — продукт стал лучше выдерживать большие профили. Появились ограниченные списки, группировка, более аккуратный поиск, пагинация в каталоге, устойчивость к длинным наборам настроек и проверка тяжёлых сценариев.
Третье — обновление схем до Firefox Release 152 и ESR 140.12. Для такого проекта это не косметика. Каждое обновление схемы затрагивает проверку, интерфейс, All settings, импорт, экспорт, CIS-слои и документацию.
Четвёртое — более зрелый жизненный цикл профиля: архивирование, восстановление, отдельное безвозвратное удаление, именованное клонирование и отдельная поверхность сравнения.
Что осталось до 0.9 и 1.0
Следующий большой этап — документация.
Я хочу делать её не как набор разрозненных Markdown-файлов, а как модульную документационную базу на DITA-OT. Причина простая: у BPM несколько разных аудиторий и сценариев.
Нужны как минимум:
руководство пользователя;
руководство администратора;
справка по настройкам Firefox Policies;
справка по CIS-сценариям;
руководство по API и интеграции;
описание развёртывания;
описание обновления схем Firefox;
описание ограничений и поддерживаемых сценариев.
Если писать это вручную в отдельных документах, они быстро начнут расходиться. Одна и та же политика будет называться по-разному, один и тот же сценарий будет описан в двух местах с разными деталями, а интерфейс уйдёт вперёд быстрее документации.
DITA-OT здесь интересен именно как способ держать один набор смысловых модулей и собирать из них разные выходные документы.
После 0.9.0 уже можно будет говорить о доведении до 1.0: стабилизация публичных контрактов, упаковка, развёртывание, проверка на реальных сценариях, возможно — более формальная модель расширения под другие браузеры или внешние системы управления конфигурациями.
Вместо вывода
Browser Policy Manager начинался как инструмент для управления policies.json, но к версии 0.8.8 стал заметно более сложной системой.
Внутри у него есть несколько ключевых идей:
профиль важнее файла;
policies.json— внешний контракт, а не внутренняя модель;разные рабочие поверхности должны работать с одним источником данных;
схемы Firefox — это версия предметной области, а не просто справочник;
All settings должен помогать разбирать состояние профиля, а не только показывать полный каталог;
локализация и браузерные проверки — часть инженерной устойчивости;
документация должна стать частью продукта, а не приложением к нему.
Сейчас проект открыт, код доступен на GitHub: github.com/Goudron/browser-policy-manager.
Мне особенно интересна обратная связь от администраторов, специалистов по информационной безопасности, инженеров по рабочим местам и тех, кто в реальности сопровождает браузеры в организациях:
какие сценарии управления Firefox вам нужны;
насколько удобна модель профилей;
чего не хватает в импорте, экспорте и сравнении;
как такой инструмент лучше встроить в существующие процессы управления конфигурациями;
нужна ли в будущем поддержка других браузеров и в каком виде.
