Commit Graph

10 Commits

Author SHA1 Message Date
Александр Зимин 6977ba106d fix(auth): иерархическое расширение scope set в ScopeContext 2026-05-26 12:25:14 +00:00
Zimin A.N. c5adbdf39c fix(auth): роль admin даёт RESTRICTED scope (видит всё)
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.
2026-05-25 18:51:43 +03:00
Александр Зимин b428c78b18 fix(auth): permitAll /api/v1/version (UI polling работает до login) 2026-05-10 18:48:55 +00:00
Zimin A.N. 01cca3a6f4 fix(auth): scope-фильтр на writer-side endpoints (Phase 2c security gap)
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 зелёные.
2026-05-05 22:21:21 +03:00
Zimin A.N. 721607c246 fix(auth): explicit nimbus-jose-jwt в ordinis-auth — production crashloop
Найден 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).
2026-05-05 20:58:44 +03:00
Zimin A.N. 578fe8f476 feat(auth): поддержка colon-separated ролей (Альтум-style)
Альтум выдаёт роли формата "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).
2026-05-04 17:40:43 +03:00
Zimin A.N. 930df5b888 feat(api): Swagger UI + docs/integration для Альтума и других consumer'ов
Springdoc-openapi генерирует OpenAPI 3 spec из аннотаций контроллеров —
интеграторам не нужно поддерживать markdown-ТЗ синхронно с кодом.

- /swagger-ui.html (UI), /v3/api-docs (JSON), /v3/api-docs.yaml.
- OpenApiConfig: title/description/contact/servers (staging+prod+local),
  bearerAuth security scheme (Keycloak JWT).
- SecurityConfig: permitAll на swagger paths.

Документация:
- docs/integration/altum-imaging-order.md — ТЗ интеграции «Заказ съёмки».
  Согласованы: m2m service account через Keycloak, BFF в altum-backend
  (FastAPI), 4 справочника со схемами, cascading select через
  instrument.supported_*_codes, snapshot strategy для аудита.
- docs/tech/api-integration.md — общий гайд для интеграторов: auth flow,
  endpoints, кэш-стратегии, bitemporal модель, current dictionaries,
  cross-refs, best practices.

Скрипты codegen openapi-generator-cli работают off /v3/api-docs.
2026-05-04 15:45:09 +03:00
Zimin A.N. 416c44bd94 test: unit-тесты для ScopeContext, IdempotencyService, DictionaryRecordService + fix exclusion 500 → 409
35 unit-тестов зелёных:

ordinis-auth (11): ScopeContextTest
- Маппинг ролей: ordinis-public/ORDINIS_INTERNAL/x-RESTRICTED/PUBLIC
- Nested claim path realm_access.roles + кастомный путь scopes
- Anonymous + query allowed/disallowed
- JWT всегда побеждает query as_scope (escalation defense)
- Unrecognized roles → PUBLIC fallback

ordinis-rest-api/idempotency (13): IdempotencyServiceTest
- hashRequest: deterministic, sha-256 hex 64 chars, null=empty
- lookup: empty/match/422 на mismatch
- reserve: saveAndFlush, race с тем же hash → 409 InProgress, race с разным hash → 422 Conflict
- saveResponse: skip missing, persist parsed JSON, gracefully игнорит non-JSON

ordinis-rest-api/service (11): DictionaryRecordServiceTest
- resolveBusinessKey: explicit / no-source missing key throws / x-id-source derive /
  match accepted / mismatch throws / missing source field throws
- enforceUniqueFields: no-unique no-op / no-conflict / conflict 409 /
  excludes own record on update / skips null values

Hotfix backend: exclusion constraint 500 → friendly 409
- DictionaryRecordService.create() / update() ловят DataIntegrityViolationException,
  если SQLState 23P01 или message содержит dictionary_records_no_overlap →
  OrdinisException.conflict('record_period_overlap', ...) с понятным сообщением
  про пересекающийся диапазон.
- Saved → saveAndFlush: get DB error eagerly чтобы поймать его в catch.

Test setup:
- spring-boot-starter-test (test scope) в parent pom.
- Mockito mock для DictionaryDefinitionService на JDK 25 ломается, передаём null
  (тестируемые методы не дёргают этот dependency).
2026-05-04 04:07:33 +03:00
Zimin A.N. 7593699990 feat(auth): refactor to Keycloak OIDC issuer-uri (drop static PEM)
@nstart/auth = Keycloak. Уходим от RSA PEM в Vault на OIDC discovery.

OrdinisAuthProperties:
- DROP: publicKey (PEM)
- ADD: issuerUri (https://auth.nstart.space/realms/nstart)
- ADD: jwkSetUri (optional override)
- ADD: audience (для aud валидации, когда узнаем client_id)
- RENAME: scopeClaim → rolesClaim (default: realm_access.roles)

SecurityConfig:
- JwtDecoders.fromIssuerLocation() — auto JWK Set discovery через
  /.well-known/openid-configuration. Ротация ключей не требует ручных операций.
- AudienceValidator (опц.) для проверки aud claim.
- Permissive stub когда issuer-uri пуст (dev mode).

ScopeContext:
- readClaimByPath() поддерживает nested путь "realm_access.roles".
- normalizeRoleToScope(): маппит Keycloak роли ("ordinis-public",
  "ORDINIS_INTERNAL", "x-RESTRICTED") в DataScope.
- JWT всегда побеждает query ?as_scope= (защита от scope escalation).
2026-05-04 02:13:08 +03:00
Александр Зимин 6ee7a70ad1 feat(auth): JWT scope authorization (@nstart/auth контракт)
Новый модуль 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:}.
2026-05-04 00:28:27 +03:00