Три прямых фикса по /cso security audit на v2.45.2:
## Finding #1 (HIGH): Backend containers run as root
ordinis-app / ordinis-read-api / ordinis-projection-writer Dockerfile'ы
запускали JVM от root (eclipse-temurin:21-jre default'но не задаёт USER).
Compromise через Spring CVE / Jackson deserialization → root-в-контейнере
= escalation surface на ноду через любой pod misconfig.
Добавлено: `RUN groupadd -r -g 10001 app && useradd -r -u 10001 -g app
-s /sbin/nologin app` + `USER app`. UID 10001 — outside system (0-999)
и distribution reserved (1000-9999). `--chown=app:app` на COPY чтобы
JVM могла прочитать jar.
ordinis-migrations (USER liquibase) и docs (nginx default) уже non-root.
## Finding #2 (HIGH): .env / .env.local not in .gitignore
`.gitignore` блокировал *.pem, *.key, .gstack/, .claude/ — но не .env.
Развработчик с realный dotenv для local dev мог случайно `git add .` и
закоммитить creds. Сейчас нет committed .env (verified) но защита нулевая.
Добавлено: `.env`, `.env.local`, `.env.*.local`, `*.env` + whitelist
`!.env.example`, `!.env.template`, `!.env.sample` для шаблонов.
Существующий tracked `ordinis-admin-ui/.env.example` остался tracked
(verified `git ls-files | grep .env.example`).
## Finding #3 (MEDIUM): Spring Actuator permitAll() — defense-in-depth gap
SecurityConfig had `requestMatchers("/actuator/**").permitAll()` →
любой pod в кластере мог `curl ordinis-app:8080/actuator/env` и
получить ORDINIS_KEYCLOAK_ADMIN_CLIENT_SECRET, DB creds и пр.
VERIFIED: nginx ingress на проде НЕ проксирует /actuator/* (SPA fallback
отдаёт index.html), так что не exposed на интернет. Но pod-to-pod в
кластере = открыто.
Defense in two layers:
1. SecurityConfig: split actuator routes —
/actuator/health/**, /actuator/info, /actuator/prometheus → permitAll
/actuator/** → denyAll
Probes/Prometheus scrape работают, остальное блок.
2. application.yml для всех трёх Spring apps: narrowed default exposure
to "health,info,prometheus". Поддерживается env override
MANAGEMENT_ENDPOINTS_WEB_EXPOSURE_INCLUDE для staging diagnostic
(включить env/configprops/loggers только когда нужно дебажить
autoconfig).
## Тесты
- mvn package — все 15 модулей SUCCESS
- ordinis-rest-api + ordinis-auth tests — SUCCESS
## Manual verify post-deploy
После пророллится v2.45.3:
- staging+prod liveness/readiness probes должны остаться зелёными
(/actuator/health/{liveness,readiness} в permitAll list).
- Prometheus scrape /actuator/prometheus → 200.
- ssh pod && curl localhost:8080/actuator/env → должен вернуть
401/403 (или 404 если endpoint вообще выключен через exposure).
## Откат
Если probes сломаются (маловероятно — health endpoints whitelisted) —
revert этого MR. Image rollback к v2.45.2 через helm.
ScopeContext.normalizeRoleToScope ищет суффикс PUBLIC|INTERNAL|RESTRICTED
после `-`/`_`/`:`. Роль `admin` (и `ordinis-admin`, `ordinis:client:admin`)
не match'илась — нет суффикса с known scope name → DataScope.valueOf("ADMIN")
бросал IllegalArgumentException → ignored → юзер с одной только admin
ролью получал PUBLIC scope → 403 на любом /admin/* endpoint'е с requireInternal().
Симптом на проде/staging: админ открывает /audit, видит 403 на /admin/audit
и /admin/users несмотря на JWT с admin ролью.
Fix: processRole() сначала проверяет isAdminRole (tail == "ADMIN" после
prefix-stripping и separator-split) → даёт RESTRICTED (RESTRICTED visibleTo
PUBLIC + INTERNAL + RESTRICTED). Иначе идёт обычный normalizeRoleToScope.
Поддерживаются: admin, ADMIN, ordinis-admin, ordinis_admin, ordinis:client:admin,
ANYTHING-admin, ANYTHING_ADMIN — короче, любая роль с tail'ом ADMIN.
Tests: +4 новых кейса (admin alone, ordinis-dash-admin, colon-separated admin,
admin + explicit scope unionize). Старый unrecognizedRolesGivePublicFallback
оставил только "viewer" — admin теперь recognized.
17/17 ScopeContextTest passed.
ScopeContext.canAccess(DataScope) — единая точка проверки доступа,
использует DataScope.visibleTo (PUBLIC видит PUBLIC, INTERNAL видит
PUBLIC+INTERNAL, RESTRICTED видит всё).
DictionaryRecordController:
- requireReadAccess на GET /{businessKey} и /{businessKey}/history
→ 404 dictionary_not_found если scope словаря недоступен (намеренно
скрываем существование INTERNAL/RESTRICTED данных, защита от
enumeration)
- requireWriteAccess на POST/PUT/DELETE
→ 403 scope_insufficient если scope недостаточен для записи
DictionaryDefinitionController:
- list() фильтрует список по canAccess (PUBLIC-юзер не видит INTERNAL
словари в каталоге)
- get() → 404 если недоступен
- create()/update() → 403 если новый scope выше доступа юзера
(защита от scope escalation через PUT)
AuthE2ETest.publicUser_cannotSeeInternalRecords: TODO snять, expectation
обновлён на isNotFound() (раньше было isOk + комментарий о gap).
До этого fix: PUBLIC-юзер мог GET /api/v1/dictionaries/{n}/records/{k}
INTERNAL-словарь на writer-side. Read-api (отдельный модуль) фильтровал
корректно. Issue найден через AuthE2ETest в Phase 2c.
Все 6 AuthE2ETest и 24 unit-теста rest-api зелёные.
Найден production-bug на staging: ordinis-app в CrashLoopBackOff с
NoClassDefFoundError: com/nimbusds/jose/RemoteKeySourceException.
Причина: в Phase 2c e2e тестах я добавил nimbus-jose-jwt 9.37.3 как
test-scope direct dependency в ordinis-app/pom.xml. Maven scope
resolution: direct test-scope побеждает transitive compile-scope,
nimbus исключился из Spring Boot fat jar. На runtime
JwtDecoders.fromIssuerLocation() (использует Nimbus internally) падал.
Fix:
- ordinis-auth/pom.xml: explicit compile-scope dep на nimbus-jose-jwt
(раньше тянулся транзитивно через oauth2-resource-server starter)
- ordinis-app/pom.xml: убрал test-scope direct decl, JwtTestSupport
использует nimbus с compile classpath.
Verified: BOOT-INF/lib/nimbus-jose-jwt-9.37.3.jar в fat jar после rebuild.
e2e AuthE2ETest всё ещё зелёный (12/12).
Альтум выдаёт роли формата "ordinis:client:public" / "ordinis:client:internal" /
"ordinis:client:restricted" в realm_access.roles. Старая версия ScopeContext
обрабатывала только "-" / "_" разделители + ORDINIS- префикс — colon-роли
приводили к fallback на PUBLIC.
Теперь normalizeRoleToScope:
- strips "ORDINIS:" (как и "ORDINIS-" / "ORDINIS_")
- берёт сегмент после последнего из "-", "_", ":"
Совместимо с прежними форматами. + 2 unit-теста (всего 13 в ScopeContextTest).
Новый модуль ordinis-auth:
- OrdinisAuthProperties — public-key, issuer, requireAuthentication, allowQueryScope
- ScopeContext — резолвит current scopes из JWT claim 'scopes' (приоритет),
потом legacy ?as_scope=, потом anonymous=PUBLIC
- SecurityConfig — Spring Security OAuth2 Resource Server + RSA public-key
decoder. Если public-key пуст — JWT validation отключён (permissive
fallback); rollout без auth-сервера не ломает API.
Read controller теперь делегирует scope resolution в ScopeContext вместо
прямого парсинга ?as_scope=. Backward compat: legacy query параметр
работает пока ordinis.auth.allow-query-scope=true (по умолчанию true в
dev/staging, false в prod после интеграции с @nstart/auth).
Vault: public-key читается из secret/ordinis/cuod/jwt-public-key (key=key).
Spring Cloud Vault flatten'ит в property 'key', resolved в
ordinis.auth.public-key=${key:}.