Commit Graph

47 Commits

Author SHA1 Message Date
Александр Зимин fdf321316f fix(actuator): expose loggers endpoint + force release trigger 2026-05-15 09:35:14 +00:00
Александр Зимин 20d6c919cd chore(actuator): expose conditions/beans/configprops для autoconfig debug 2026-05-15 09:14:11 +00:00
Александр Зимин 5e10bbb6bb fix(e2e): disable notifications dispatcher in test profile 2026-05-14 15:21:56 +00:00
Александр Зимин 9050e2427e feat(notifications): TODO 7 — draft decision toast + email + bell badge 2026-05-14 14:40:35 +00:00
Andrei Zimin 83caf9c3c8 feat(user-display): KeycloakAdminUserResolver — Phase 2 on-demand lookup
Implements the Phase 2 hook from the previous commit. With this in
place, UserDisplayService.find chains:

  hot cache → DB (user_display_cache) → Keycloak Admin API

so any realm user resolves on first display, even if they never made
an authenticated request to ordinis. The resolved entry is persisted
via UserDisplayService.upsert with source=KEYCLOAK_ON_DEMAND, so the
next render of the same sub hits the DB tier (~ms) instead of going
back to Keycloak.

Implementation notes:

- Spring RestClient (modern blocking HTTP, replaces RestTemplate).
- client_credentials grant against
  {issuer}/realms/{realm}/protocol/openid-connect/token. Token cached
  in-memory minus a 30s safety margin from `expires_in`.
- 401 on user lookup wipes the cached token and surfaces a
  RuntimeException, so the next resolve refetches.
- 404 on user lookup → Optional.empty (definitive miss, sub not in
  realm). UserDisplayService logs and the caller falls back to short
  UUID. Negative cache TTL not implemented — added complexity not
  justified for the current realm size (~50 users).
- Bean activated only when ordinis.keycloak.admin.enabled=true AND
  the env vars are set. Default disabled — UserDisplayService treats
  KeycloakUserResolver as @Autowired(required = false), so a
  deployment without Keycloak admin creds keeps the previous behavior
  (DB cache only) instead of crashing on startup.

Application config:
- ORDINIS_KEYCLOAK_ADMIN_ENABLED      (false by default)
- ORDINIS_KEYCLOAK_ADMIN_URL          e.g. https://auth.nstart.space
- ORDINIS_KEYCLOAK_ADMIN_REALM        defaults to nstart
- ORDINIS_KEYCLOAK_ADMIN_CLIENT_ID    via vault (see docs-internal/keycloak-admin-setup.md)
- ORDINIS_KEYCLOAK_ADMIN_CLIENT_SECRET via vault

Setup runbook (docs-internal/keycloak-admin-setup.md) covers:
- creating the Keycloak service-account client + assigning view-users
- writing the secret into both vault instances (different per env)
- wiring vault.hashicorp.com agent-inject annotations into the chart
- verification steps + common failure modes

Phase 3 (scheduled bulk sync) is documented at the end of the runbook
but deferred — depends on observed cache miss rate after this lands.
2026-05-14 14:47:49 +03:00
Zimin A.N. ce3a29f08d test(e2e): assign ordinis:record:reviewer role в reviewer JWT tokens
Phase 1d enforcement в DraftService.approve()/.reject() требует
realm role 'ordinis:record:reviewer'. Test fixtures используют
JwtTestSupport.signJwt() — добавляем role в JWT для reviewer'ов.

Changes в ApprovalWorkflowE2ETest:
- Новый helper reviewerTokenFor(subject) — internal scope + record:reviewer
- 4 reviewer call sites (bob/dave/julia/leo) → reviewerTokenFor()
- eve-solo (selfApproveBlocked test) тоже → reviewerTokenFor() чтобы
  достичь self-approve check, иначе forbidden_role срабатывает раньше

Maker callsites (alice/carol/frank/ivan/kate) остаются на tokenFor()
без reviewer role — backend гейтит только approve/reject методы.

Fix для 4 failures в pipeline 6898:
- happyFlow_submitApprove_recordLiveAndOutboxEvent
- rejectFlow_keepsDraft_noLiveRecord
- selfApproveBlocked_409
- rejectWithoutComment_400
2026-05-13 12:55:15 +03:00
Zimin A.N. 000322d6ee test(restapi): drop /swagger-ui from 404 test (env-dependent)
Failed pipeline 6805: /swagger-ui/index.html в test profile вернул 200
(springdoc enabled по умолчанию через ORDINIS_SWAGGER_UI_ENABLED default
true). Это валидно — Swagger render'ит реальную UI page.

Замена: проверяем generic non-API typo path который точно не имеет
controller'а и не SPA-route'д. Покрывает тот же NoResourceFoundException
branch handler'а независимо от springdoc env config.
2026-05-13 00:07:17 +03:00
Zimin A.N. f2adc37e2e fix(restapi): NoResource/NoHandler → 404, не 500
GlobalExceptionHandler catch-all Exception → 500 ловил NoResourceFoundException
и NoHandlerFoundException. Симптомы на prod:

- /swagger-ui/index.html при disabled springdoc возвращал 500 (UI всё равно
  не рендерился, но spring логировал 'Unhandled exception' на каждый запрос —
  спамил alerting и затруднял поиск настоящих ошибок).
- GET /api/v1/typo от integrator'ов давал 500 вместо 404, осложняло
  debugging без traceId-correlation.
- Любой stale frontend bundle с переименованным endpoint мог триггерить
  false-positive incident alerts.

Fix: explicit @ExceptionHandler для обоих exception типов → 404 not_found.
Логирование DEBUG (не WARN/ERROR) — это ожидаемый 404, не аномалия.

Test: SmokeE2ETest.unknownPath_returns404WithNotFoundCode покрывает оба пути
(/api/v1/* и /swagger-ui/*) → 404 + body.code='not_found'.
2026-05-13 00:00:36 +03:00
Александр Зимин e2080d8991 fix(docs,security): Swagger UI — staging для exploration, prod env-flagged 2026-05-12 17:43:12 +00:00
Александр Зимин 5479142093 feat(webhook): time-series histogram stats endpoint + frontend chart 2026-05-12 14:30:22 +00:00
Александр Зимин d740092d0c fix(build): pass git info via Docker build args (unblock main pipeline) 2026-05-10 17:10:45 +00:00
Александр Зимин e8f98230f2 feat: version banner UI + GET /api/v1/version backend endpoint 2026-05-10 16:50:50 +00:00
Александр Зимин 51d6709fb3 ci(docker): future-proof backend Dockerfiles (fix root cause from Phase A break) 2026-05-10 16:23:03 +00:00
Александр Зимин 3d5304b308 fix(docker): include ordinis-notifications in 3 Dockerfiles (unblock main pipeline) 2026-05-10 15:57:52 +00:00
Zimin A.N. 9ef9b8147e feat(approval): Approval Workflow v2 Phase 3 — e2e suite + 0020 case fix
Phase 3 e2e coverage (8 tests, ApprovalWorkflowE2ETest):
- happyFlow: maker submit → checker approve → live record + outbox event
- rejectFlow: keeps draft REJECTED, no live record, re-approve blocked
- selfApprove: same JWT subject blocked (self_approve_forbidden / self_reject_forbidden)
- twoPendingDrafts: exclusion constraint → 409 draft_already_pending
- directCRUD on approval_required dict: POST/PUT/DELETE → 409 draft_required (D2=A)
- regression: dict без approval_required — direct CRUD по-прежнему работает
- withdrawByMaker: maker может, reviewer не может (403 not_draft_maker)
- rejectWithoutComment: 400 reject_reason_required

E2e exposed real Phase 0 bug:
Hibernate @Enumerated(EnumType.STRING) пишет UPPERCASE ('PENDING'), а
Liquibase 0019 check constraint + exclusion WHERE использовали lowercase
('pending'). Каждый insert проваливался на check, маппился в
DataIntegrityViolation, и DraftService.submit маскировал его в
draft_already_pending — скрывая реальную причину.

Fixes:
1. Migration 0020: пересоздание record_drafts_status_check + exclusion
   с UPPERCASE значениями (matches enum serialization).
2. DraftService.submit: только SQLSTATE 23P01 (exclusion violation)
   маппится в draft_already_pending. Остальные DataIntegrity (check, FK,
   NOT NULL) пробрасываются как-есть — иначе schema bugs продолжат
   маскироваться.
3. DraftServiceTest StubDraftRepo: оборачивает SQLException 23P01 в
   DataIntegrityViolation cause chain — отражает прод поведение.

Test counts:
- ordinis-rest-api unit: 119/119 PASS
- ordinis-app e2e: 28/28 PASS (был 20, +8 новых ApprovalWorkflowE2ETest)
2026-05-08 13:52:17 +03:00
Zimin A.N. b601c4636f feat(swagger): OAuth2/Keycloak PKCE flow в Swagger UI + docs links
OpenApiConfig:
- Добавлена OAuth2 security scheme (authorization_code flow с Keycloak
  realm `nstart`). Auth/token URLs из ordinis.openapi.oauth.issuer-uri
  configuration property (default: auth.nstart.space/realms/nstart).
- BearerAuth scheme сохранён secondary — для pre-issued tokens из CI/scripts.
- Servers list переупорядочен — prod первый, staging второй.
- Description обновлён с pointer на /docs/ для full integration guide.

application.yml:
- springdoc.swagger-ui.oauth: client-id (env override), use-pkce-with-
  authorization-code-grant=true. Public client `ordinis-swagger`
  настраивается отдельно в Keycloak — see keycloak-setup-swagger-client.md
  в ordinis-infra repo.
- ORDINIS_OPENAPI_OAUTH_ISSUER_URI env var для override realm на разных
  окружениях.

docs/integration/api/read-endpoints.md:
- Swagger UI section обновлён: акцент на Authorize button + Keycloak
  PKCE login (no client_secret needed), tip про bearerAuth fallback.

docs/index.md:
- Добавлен tab "Я хочу пощупать API" с link на Swagger UI.

После deploy:
- https://ordinis.k8s.264.nstart.cloud/swagger-ui.html  — Swagger UI
- https://ordinis.k8s.264.nstart.cloud/v3/api-docs      — OpenAPI spec
Authorize → выбрать oauth2 → Keycloak login (PKCE) → Try it out с JWT.
2026-05-08 00:08:29 +03:00
Zimin A.N. 66a4f13942 test(geo): e2e auto-derive geometry из data.lat/lon — 6 кейсов
Покрытие resolveGeometry() в DictionaryRecordService через REST API
(MockMvc + Postgres testcontainer с PostGIS):

- lat/lon → POINT(lon lat) с SRID 4326 (Москва 55.75, 37.62)
- latitude/longitude alias → тот же flow (СПб 59.93, 30.31)
- lat/lng alias → то же (Токио 35.68, 139.65)
- explicit geometryWkt приоритет над data.lat/lon
- out-of-range lat (200) / lon (300) → silent skip, geometry == null
- no lat/lon → geometry == null

Закрепляет недавнюю работу (commit 9214ee9) после которой Liquibase
0015 backfill заработал на staging. AOI spatial filter теперь работает
на ground_station и similar dictionaries автоматически — этот тест
гарантирует что регрессий не будет.

Прогон: 20/20 e2e (было 14, +6).
2026-05-07 12:02:00 +03:00
Zimin A.N. ac93151fe6 test(webhook): re-enable WebhookE2ETest через no-op scheduler + manual ticks
Fix CI flakiness: тест больше не полагается на @Scheduled тики каждые
200ms. NoSchedulingConfig подменяет TaskScheduler на no-op stub —
@Scheduled методы регистрируются, но никогда не запускаются. Тест
вручную дёргает dispatcher.matchTick() / sendTick() — synchronous,
deterministic, нет race с background threads.

Что изменилось:
- @Disabled убран
- Inject WebhookDispatcher, manual matchTick + sendTick после setup
- Await timeouts: 60s → 5s (manual ticks → нет 200ms scheduler delay)
- Pool=4 / send-interval-ms / match-interval-ms убраны (не нужны)
- Send-timeout-ms 1000 → 2000 (с запасом, но scheduler не работает)
- @AutoConfigureMockMvc сохранён, port=RANDOM_PORT, allowed-hosts тот же

Side-fix: @Autowired на Spring constructor WebhookTestPingService
(добавлен ранее package-private constructor для unit-тестов сделал
выбор конструктора неоднозначным; Spring 6 strict — требует
explicit @Autowired когда есть >1 candidate).

Локально: 2/2 PASSED 1.85s (было 9.9s — 5× быстрее).
CI: 14 e2e всех тестов passed (Smoke, OutboxKafka, Auth, Bitemporal,
Webhook).
2026-05-07 10:57:43 +03:00
Zimin A.N. 09d2d1c325 test: @Disabled WebhookE2ETest — flaky на CI несмотря на 3 итерации fix'ов
Третий timeout подряд: pool=4 + send-timeout=1s + cleanup @BeforeEach
+ truncate outbox не помогли. ConditionTimeout 60s × 2 теста.

Локально 2/2 PASS за 9.9s — значит логика работает. CI runner
медленнее + race conditions между:
- Spring @Scheduled threads (даже pool=4)
- WebhookDispatcher @PostConstruct (фаерится при context init)
- Postgres testcontainer reuse (leftover deliveries из предыдущих CI runs)
- Наш @BeforeEach cleanup (происходит ПОСЛЕ context init)

Логика покрыта 26 unit tests (HmacSigner + SsrfGuard + DispatcherMatch +
DeliveryLifecycle). E2e только sanity на full pipeline — ROI не
оправдывает дальнейших попыток.

Re-enable варианты на будущее:
1. Dedicated CI runner с warm Docker images (отдельная инфра)
2. testcontainer без reuse (slower CI, но reliable state)
3. @Sql(executionPhase = BEFORE_TEST_CLASS) — cleanup ДО Spring context init,
   до того как dispatcher @PostConstruct запустится с leftover state
4. Манульно invoke matchTick/sendTick без @Scheduled — deterministic
2026-05-06 22:54:43 +03:00
Zimin A.N. fbb0aa121a fix(test): WebhookE2ETest — pool=4 + tighter timeouts + truncate outbox
Предыдущий cleanup-fix не помог CI: ConditionTimeout 60s × 2 теста.
Trace показал WebhookDispatcher initialized + ZERO sendTick/matchTick
лог lines после этого. Spring @Scheduled default pool size = 1; если
sendTick блокируется на HTTP timeout (5s) к dead leftover URL —
matchTick тоже не запускается до конца HTTP send.

CI Postgres testcontainer withReuse(true) тащит leftover deliveries
из предыдущих runs. Их слишком много чтобы fail'ить за 60s с pool=1.

Фикс комбо:
- spring.task.scheduling.pool.size=4 — matchTick/sendTick параллельно,
  один blocked на dead URL не стопает другие
- ordinis.webhook.send-timeout-ms=1000 — leftover dead URLs fail'ят
  за 1с вместо 5с (5x faster cleanup)
- truncate outbox_events дополнительно — matchTick не порождает
  delivery rows на старые события

Локально 2/2 passed за 9.9s. Race-warnings про FK violation
(matchTick видит cached subscription но row deleted в @BeforeEach) —
noise, тест проходит.
2026-05-06 21:27:07 +03:00
Zimin A.N. 8fd0da957a fix(test): WebhookE2ETest — cleanup leftover state в @BeforeEach
CI fail: ConditionTimeout 60s в recordCreate_dispatchedToWebhookReceiver.
Logs показали 'Webhook sendTick: 2 due deliveries' через 1с после
boot — dispatcher грузился leftover'ами из предыдущего CI run
(Postgres testcontainer с withReuse=true сохраняет данные между
запусками). Старые subscriptions с dead URLs ели 5s timeout каждая
× N циклов до того как наш новый event дойдёт до receiver.

Локально работало потому что DB была свежая.

Фикс:
- В @BeforeEach: deliveryRepo.deleteAllInBatch() + subRepo.deleteAllInBatch()
- Order важен: deliveries first (FK на subscriptions)

Локально 2/2 passed за 10.8s. Race-warnings StaleObjectStateException
в логах — dispatcher держит entity в Hibernate session и пытается
save после нашего deleteAll'а; assertion'ы это не задевает.
2026-05-06 21:13:42 +03:00
Zimin A.N. d4e2e731df test: re-enable WebhookE2ETest — убрать Kafka из теста
WebhookDispatcher читает outbox_events напрямую и не зависит от
Kafka. OutboxPoller (единственный consumer KafkaTemplate в runtime)
выключаем через ordinis.outbox.enabled=false, и spring-kafka
autoconfig — через spring.autoconfigure.exclude. Kafka container
теперь не нужен для этого теста.

E2ESupport: Kafka container теперь lazy. Тесты вызывающие
registerProperties() триггерят start(); тесты на новом
registerPostgresOnlyProperties() — нет.

Локально: WebhookE2ETest 2/2 PASSED за 9.8s (раньше timeout на CI
20-30s). Boot cold start 7s vs ~15s с Kafka. Уходит главный
источник flakiness — Kafka broker timing на shared CI runner.

OutboxKafkaE2ETest по-прежнему использует registerProperties() →
Kafka container стартует — поведение этого теста не изменено.
2026-05-06 19:33:25 +03:00
Zimin A.N. 00ac5106b8 test: @Disabled WebhookE2ETest — flaky timing на CI
Timeout 20-30s не достаточен для cold start Spring Boot + Postgres
+ Kafka testcontainers + WebhookDispatcher schedule cycles. Pipeline
#5667/#5672 fail с ConditionTimeoutException.

Логика webhook dispatcher покрыта 26 unit tests:
- HmacSigner (8): known vector, prefix, distinct inputs, edge cases
- SsrfGuard (15): private CIDR, scheme, host validation
- WebhookDispatcherMatch (11): filter logic AND-semantics
- WebhookDeliveryLifecycle (7): state machine + backoff + DLQ

@Disabled до внедрения warm-cached Docker CI runner. Manual e2e на
staging через UI создание subscription + curl POST record покрывает
gap.
2026-05-06 17:46:09 +03:00
Zimin A.N. 1308134150 fix(test): WebhookE2ETest receiver500 — remove handler assertion + bump timeout
Pipeline #5667 failed: receiverReturning500_deliveryRetried_notDelivered
condition timeout 20s. Root cause: assertion inside HttpServer handler
(`assertThat(body.length).isGreaterThan(0)`) бросала AssertionError
ВНУТРИ handler потока — exchange.sendResponseHeaders никогда не звался,
client получал connection reset вместо 500. Test ждал
lastStatusCode==500, получал null → timeout.

Fix:
- Убрана assertion из handler (test её не нужно — focus на retry flow)
- Wrapped body-drain в try-catch чтобы handler не падал
- Bumped timeout 20s → 30s (CI cold start + schedule cycles)
2026-05-06 17:36:28 +03:00
Zimin A.N. fcfbb1c420 test(webhook): e2e dispatcher → embedded HttpServer + HMAC verify
Закрывает Phase 5 deferred item. Завершает webhooks v2 покрытие.

WebhookE2ETest (2 tests):

Test 1: success path
- Стартует JDK HttpServer на random localhost port (no extra deps)
- Создаёт WebhookSubscription указывающий на /hook
- POST record в dictionary → outbox event
- Ждёт что receiver получит POST в течение 20s
- Verify body содержит business_key
- Verify HMAC signature header валиден (HmacSigner.sign(secret, body)
  matches X-Ordinis-Signature)
- Verify Ordinis headers (X-Ordinis-Event-Type, X-Ordinis-Scope=PUBLIC)
- Verify DB delivery row.status = delivered + lastStatusCode = 200

Test 2: retry path
- Receiver всегда 500
- POST record → outbox → dispatcher tries deliver
- Verify delivery transitions to status=retrying + lastStatusCode=500
- Не ждём DLQ (1000 attempts × 1m backoff = недели)

SSRF guard через ordinis.webhook.allowed-hosts=localhost,127.0.0.1
test override (production deny private CIDR остаётся включённым).

Test config:
- ordinis.outbox.enabled=true (для outbox poller)
- ordinis.webhook.enabled=true (для dispatcher)
- match-interval-ms=200, send-interval-ms=200 (faster tests)

Webhooks v2 — все 5 phases закрыты.
2026-05-06 15:49:41 +03:00
Zimin A.N. 3a28d741e9 feat(metrics): включить percentiles-histogram для http.server.requests
Spring Boot Micrometer по дефолту эмитит только _count/_sum/_max
для http.server.requests — без _bucket нельзя посчитать
histogram_quantile() в Prometheus, p50/p95/p99 latency дашборды
получают "No data".

Включён percentiles-histogram + кастомные SLO bucket boundaries
от 5ms до 5s — покрывают realistic range read-api JSON endpoints.
Server-side histograms точнее client-side percentiles (по которым
нельзя aggregate across instances).

Применить: build + push image + helm upgrade. После рестарта
ordinis-app/read-api/projection-writer Grafana дашборды покажут
latency графики.
2026-05-06 11:10:33 +03: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. cbaf4e6c9a ci(e2e): отдельный maven-e2e job + surefire profile e2e
ordinis-app/pom.xml:
- surefire excludes **/e2e/*E2ETest.java по умолчанию (быстрые unit без Docker)
- profile `e2e`: combine.self override снимает excludes + includes только e2e

.gitlab-ci.yml: новый job maven-e2e:
- maven image + docker:29.1.2-dind service (тот же что docker-* jobs)
- DOCKER_HOST=tcp://docker:2375, TESTCONTAINERS_HOST_OVERRIDE=docker, RYUK_DISABLED=true
- mvn -pl ordinis-app -am -P e2e test
- artifact: junit reports
- rules: backend-changes (как maven-test)

Локально:
  mvn -pl ordinis-app -am test                  # быстро, без Docker
  mvn -pl ordinis-app -am -P e2e test           # с Docker, ~23 sec для 12 тестов

В CI оба job'а параллельно после path-filter. e2e будет ловить регрессии типа
audit_log INET/scope filter gap которые мы уже нашли через эти тесты.
2026-05-05 20:24:44 +03:00
Zimin A.N. 4747d85d15 test(e2e): Phase 2c — JWT scope mapping + найден scope-filter gap на writer
JwtTestSupport: embedded HTTP server отдаёт JWK Set, генерирует RSA keypair,
подписывает test-JWT с произвольными realm-ролями. Spring Security настраивается
на этот JWKS endpoint через @DynamicPropertySource.

AuthE2ETest (6 тестов):
- anonymousRequest_returns401 — auth.required=true → anonymous = 401
- invalidJwt_returns401 — гарбидж в Authorization header → 401
- publicUser_canListDictionaries — JWT с ordinis:client:public → 200
- internalUser_canSeeInternalRecord — JWT с ordinis:client:internal видит INTERNAL запись
- unrecognizedRole_fallsBackToPublic — admin/viewer роли → PUBLIC scope (default)

 publicUser_cannotSeeInternalRecords — НАЙДЕН security gap:
  Writer endpoint /api/v1/dictionaries/{name}/records/{key} НЕ фильтрует по
  scope JWT юзера → public-юзер может прочитать INTERNAL запись.
  Тест документирует текущее поведение (assertThat 200 OK + TODO comment).
  Read-api (отдельный модуль) фильтрует корректно — issue только в writer.
  Spawned task для security review:
  "Add scope filter to writer GET endpoints".

pom.xml: + nimbus-jose-jwt 9.37.3 (test scope) для JWT signing.

Все 12 e2e тестов зелёные: smoke + 4 bitemporal + outbox-kafka + 6 auth.
Total ~18 sec со Spring boot.
2026-05-05 20:00:08 +03:00
Zimin A.N. 98ee8a24bc test(e2e): Phase 2 — bitemporal, idempotency, outbox→Kafka
Покрытие выросло с 1 до 6 e2e тестов через Testcontainers (Postgres+Kafka).

BitemporalE2ETest (4 тесты):
- createThenUpdate_keepsBothVersionsInHistory: v1+v2 в /history, oldValidTo == newValidFrom
- closeRecord_setsValidTo_andGetReturns404: после DELETE active=null, history живёт
- idempotencyKey_returnsCachedResponse_doesntDuplicate: повторный POST с тем
  же Idempotency-Key → тот же id, count=1 в БД
- breakingSchemaChange_blockedOnUpdate: documents текущее поведение PUT
  /api/v1/dictionaries/{name} с breaking schema (status < 500 = OK)

OutboxKafkaE2ETest (1 тест):
- POST record → outbox event в БД → poller публикует в Kafka → consumer
  получает envelope с правильным dictionaryName/dataScope/payload.businessKey
- Pre-create topic через AdminClient чтобы избежать race с auto-create.
- ordinis.outbox.enabled=true + poll-interval=200ms через @SpringBootTest
  properties (общий test profile его выключает для скорости остальных тестов).

SmokeE2ETest: UUID-suffixed businessKey чтобы при testcontainers reuse=true
и параллельных тестах не было коллизий.

Logging: outbox DEBUG для диагностики publish flow.
pom.xml: + awaitility для polling-based проверок.

Все 6 тестов зелёные: 1 smoke + 4 bitemporal + 1 outbox-kafka. Total ~16 sec
включая Spring context boot. Reuse=true ускоряет повторные прогоны.
2026-05-05 19:44:06 +03:00
Zimin A.N. f535f7544d test(e2e): smoke test через Testcontainers + fix audit_log.ip_address INET bug
Phase 1 e2e — proof framework работает:
- E2ESupport: Postgres 18 (postgis/postgis:18-3.6) + Kafka (cp-kafka 7.6.0)
  через Testcontainers, reuse=true. db-init.sql создаёт postgis+btree_gist
  расширения которые ожидает Liquibase 0001.
- application-test.yml: тушит cloud-config/vault/otel/outbox publishing,
  включает Liquibase autoconfig. Auth disabled для упрощения.
- SmokeE2ETest: full e2e — POST dictionary → POST record → GET record →
  проверка audit_log row. UUID-suffix в dictName для идемпотентности.
- master.xml: relativeToChangelogFile=true для всех include — иначе
  Liquibase из ordinis-app classpath не находит changes/X.xml в JAR.
- pom.xml: testcontainers 1.21.3, surefire env DOCKER_HOST + DOCKER_API_VERSION
  для macOS Docker Desktop.

Найден реальный prod-баг: audit_log.ip_address был INET, Hibernate JPA не
делает автоматический cast String→INET → INSERT падал. AuditLogger.write
обёрнут в try/catch, поэтому тихо съедал ошибку — на staging audit_log
оставался пустым после CRUD.
- 0012-audit-log-ipaddress-varchar.xml: ALTER COLUMN INET → VARCHAR(64)
- AuditLog entity: убрал columnDefinition="inet", length=64.
2026-05-05 19:16:53 +03:00
Zimin A.N. b58f6f400d fix(otel): отключить metrics/logs export — Tempo не принимает их
Tempo на staging (tempo.monitoring:4318) — single-process, принимает
только traces. Spring Boot OTel autoconfig по умолчанию отправляет всё
по тому же endpoint → 404 на каждой попытке (раз в секунду в логах
writer/read-api/projection-writer).

Override через ENV — можно переключить когда поднимем полный stack
(Mimir для metrics, Loki для logs).

Метрики Prometheus продолжают работать через actuator/prometheus
(scrape снаружи), это отдельный path.
2026-05-05 17:33:49 +03:00
Zimin A.N. e182b30c58 feat(outbox): attempt cap → DLQ + admin endpoint + 6 unit-тестов
После N (default 1000) неудачных попыток публикации событие маркируется
dlq_at и исключается из polling. Без cap poller бесконечно ретраил мёртвые
сообщения (orphan topic, ACL deny), забивая логи и lag-метрику.

- 0011-outbox-dlq.xml: dlq_at column + перестроенный idx_outbox_unpublished
  (исключает DLQ) + idx_outbox_dlq для admin-листинга.
- OutboxPoller: ordinis.outbox.attempt-max (default 1000), markDlq() при
  достижении cap, новый counter ordinis_outbox_dlq_total.
- OutboxLagGauge: ordinis_outbox_dlq_size gauge — алерт на > 0.
- OutboxEventRepository: countPending() (без DLQ), findByDlqAtIsNotNull(Pageable).
- OutboxAdminController: GET /admin/outbox/{stats,dlq} для UI.
- 6 unit-тестов через mock-maker-subclass (JDK 25 ломает Mockito inline).
2026-05-04 15:43:56 +03:00
Александр Зимин a969c50072 fix(docker): COPY ordinis-auth в Dockerfile'ах app/read-api/projection-writer
Multi-module Maven build падал на 'Child module ordinis-auth does not exist'
потому что Dockerfile'ы копировали только конкретные используемые модули.
Добавлен COPY ordinis-auth ./ordinis-auth перед COPY ordinis-validation.
2026-05-04 00:43:29 +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
Александр Зимин b5b5bc512b fix(otel): use OTLP HTTP/protobuf to tempo:4318 instead of gRPC:4317
Stack trace showed io.opentelemetry.exporter.internal.http.HttpExporter
sending to :4317 — Tempo on 4317 expects gRPC frames, gets HTTP, sends
RST. Switch endpoint to :4318 (Tempo OTLP HTTP receiver) and protocol to
http/protobuf so the SDK and Tempo speak the same protocol.

The gRPC config never took effect — Spring Boot SDK on micrometer-tracing-
otlp pulls in only the HTTP exporter by default; gRPC needs the explicit
opentelemetry-exporter-otlp jar.
2026-05-03 22:19:32 +03:00
Александр Зимин 1c4e8e5be2 fix(app): switch hibernate ddl-auto to none in k8s profile
Hibernate validate mode threw 'wrong column type [supported_locales]:
found [_text (Types#ARRAY)] but expecting [text[] (Types#OTHER)]' against
the Liquibase-managed schema. This is a well-known hypersistence-utils +
PostgreSQL TEXT[] friction with Hibernate validate.

Schema is owned by Liquibase migrations; validate adds no safety here. Set
to 'none' (matches read-api and projection-writer).
2026-05-03 20:33:21 +03:00
Александр Зимин d49dd6be06 fix(k8s): add datasource/kafka/redis config to k8s profile
K8s profile relied on shared/dev defaults for spring.datasource.url, but those
live inside the dev profile block — invisible when k8s profile is active.
Result: 'APPLICATION FAILED TO START — Failed to configure a DataSource'.

Move full datasource (and kafka/redis for projection-writer) wiring into the
k8s profile section. ENV vars come from Vault Agent injector
(ORDINIS_PG_USER/PASSWORD, ORDINIS_KAFKA_USER/PASSWORD, ORDINIS_REDIS_PASSWORD)
and chart-supplied ENV (ORDINIS_PG_HOST, ORDINIS_KAFKA_BOOTSTRAP, etc).
2026-05-03 20:24:53 +03:00
Александр Зимин 413bd3449d chore: update default config-server fallback to ordinis-staging
application.yml fallback URL was http://config-server.ordinis-dev:8888 — namespace
removed. Default now points to staging; SPRING_CLOUD_CONFIG_URI env still overrides
via Helm chart for any deployment.
2026-05-03 20:11:51 +03:00
Zimin A.N. 8eb0ec086e ci: pull base images через repo.nstart.cloud (DH official → /library/, vendor → /<vendor>/) 2026-05-03 18:40:29 +03:00
Zimin A.N. 9a79b53eb0 ci: align с altum pattern — runner mirror'ит Docker Hub
- Убраны префиксы repo.nstart.cloud/ — runner config (DOCKER_AUTH_CONFIG)
  обрабатывает pull из Docker Hub mirror автоматически
- DinD сервис: alias=docker, --tls=false, DOCKER_HOST=tcp://docker:2375
  (proven pattern из altum/.gitlab/ci/docker.yml)
- IMAGE_TAG = ref-slug + sha (как у altum)
- BUILDKIT_INLINE_CACHE=1 для cache-from optimization
- Dockerfiles: FROM maven/eclipse-temurin/liquibase без префиксов
2026-05-03 18:16:21 +03:00
Zimin A.N. 7c06cf7f71 ci: pull images через repo.nstart.cloud (corp Docker Hub mirror)
Runners в кластере не имеют network access к Docker Hub.
Заменили все base images:
- maven:3.9.9-eclipse-temurin-21 → repo.nstart.cloud/maven:...
- eclipse-temurin:21-jre → repo.nstart.cloud/eclipse-temurin:21-jre
- liquibase/liquibase:4.27.0 → repo.nstart.cloud/liquibase/...
- docker:27 / docker:27-dind → repo.nstart.cloud/docker:...
2026-05-03 18:02:46 +03:00
Zimin A.N. be77c30687 feat(validation,rest-api,outbox): write-side end-to-end working
ordinis-validation:
- ValidationError, ValidationResult, ValidationException
- RecordValidator: networknt JSON Schema (Draft-7) + custom rule для
  x-localized полей. Walk schema, для каждого x-localized:true property
  проверяет: значение это object с locale keys (BCP 47 pattern xx-XX),
  все ключи в supported_locales, default_locale обязательно присутствует,
  значения non-empty strings.

ordinis-outbox:
- KafkaTopicResolver: ordinis.<bundle>.events.<scope>
- OutboxRecorder: запись события в outbox в той же транзакции (MANDATORY
  propagation), с tracer для trace_id/span_id из MDC если OTel доступен
- OutboxPoller: @Scheduled, batch_size + poll_interval из config, метрики
  ordinis_outbox_published_total / publish_errors_total / kafka_publish_duration_seconds
- OutboxLagGauge: ordinis_outbox_pending_events (gauge для алертов)
- ConditionalOnProperty ordinis.outbox.enabled — отключаем в read-api/projection-writer

ordinis-rest-api:
- DTOs: CreateDictionaryRequest, DictionaryResponse, CreateRecordRequest,
  RecordResponse (с WKT serialization geometry)
- DictionaryDefinitionService: CRUD definitions, outbox events DefinitionCreated/Updated
- DictionaryRecordService: bitemporal write-side, SERIALIZABLE isolation,
  bound check FAR_FUTURE valid_to. Update = close current + create new
  (никогда in-place). Outbox event RecordCreated/Updated/Deleted.
- DictionaryDefinitionController: GET list/by-name, POST create, PUT update
- DictionaryRecordController: GET active/history, POST create, PUT update,
  DELETE (close)
- OrdinisException + ApiErrorResponse + GlobalExceptionHandler
  (422 validation, 404 not found, 409 conflict, 500 internal)

ordinis-app:
- @EnableJpaRepositories + @EntityScan для multi-package сканирования
- application.yml profile-based: dev (PG localhost через port-forward,
  ddl-auto=update, OTel disabled), k8s (Cloud Config + Vault Kubernetes auth)
- spring.kafka producer config с SASL SCRAM-SHA-512

ordinis-domain:
- JpaAuditingConfig: добавлен DateTimeProvider возвращающий OffsetDateTime
  (Spring Data Auditing по дефолту его не поддерживает, без него падает
  IllegalArgumentException 'Cannot convert LocalDateTime to OffsetDateTime')
- @ConditionalOnBean(DataSource) убран — race с lifecycle ломал auditing

E2E test (port-forward к cluster PG+Kafka):
- POST dictionary 'ground_station' → 201, JSONB schema сохранена,
  supported_locales = {ru-RU, en-US}, default_locale = ru-RU
- POST record MOSCOW-1 multi-locale name + geometry POINT(37.618 55.751) → 201
- POST record с en-US без ru-RU → 422 с двумя ошибками:
  * networknt 'required property ru-RU not found'
  * custom 'x-localized.missing_default Default locale ru-RU is required'
- outbox_events: 2 row (DefinitionCreated + RecordCreated), topic
  ordinis.cuod.events.public, ключи правильные
- revinfo + dictionary_records_aud: 1 row (Envers tx-time history активен)
2026-05-03 13:04:36 +03:00
Zimin A.N. b778832f49 feat(domain,events): bitemporal entities + i18n-aware definitions + sealed event API
ordinis-domain:
- DataScope enum (PUBLIC/INTERNAL/RESTRICTED) с Kafka topic suffix + visibility logic
- DictionaryDefinition entity: scope, schema_json (JsonNode JSONB), schema_version,
  bundle, supported_locales (text[]), default_locale + Spring Data Auditing
- DictionaryRecord entity: bitemporal (Envers tx-time + valid_from/valid_to),
  data JSONB, geometry (JTS via hibernate-spatial), data_scope. EXCLUSION
  CONSTRAINT enforced в БД (Liquibase 0003).
- AuditLog entity для compliance (отдельно от Loki)
- OutboxEvent entity: aggregate_*, kafka_topic/kafka_key, attempt_count,
  markPublished/markFailed, partial index idx_outbox_unpublished
- IdempotencyKey entity для exactly-once семантики write API
- Repositories: findActiveAt, findActiveByDictionaryAndScopes, findUnpublished
- JpaAuditingConfig: ConditionalOnBean(DataSource) — dev profile без БД
  не активирует auditing, metamodel не строится

ordinis-events-api:
- Mirror DataScope enum (events не depend on domain)
- EventEnvelope<T> record: eventId, occurredAt, schemaVersion (semver),
  bundle, dictionaryName, scope, traceId, spanId, payload
- Sealed DictionaryRecordEvent + records Created/Updated/Deleted
- Sealed DictionaryDefinitionEvent + records Created/Updated
- Jackson polymorphism через @JsonTypeInfo discriminator
- Multi-locale payload: события возят ВСЕ локали (consumer сам сплющивает)

i18n design:
- В JSONB поле кодируется как { 'ru-RU': '...', 'en-US': '...' }
- JSON Schema справочника помечает локализуемые поля extension x-localized:true
- supported_locales + default_locale в dictionary_definitions
- Read API будет negotiate через Accept-Language (RFC 7231) и сплющивать

Validation:
- mvn install all 11 modules: BUILD SUCCESS
- ordinis-app boot dev profile: /actuator/health UP, / отвечает
2026-05-03 12:10:36 +03:00
Zimin A.N. d1c8d5755e fix(scaffolding): make ordinis-app boot in dev profile
- Remove spring-cloud-starter-bootstrap (deprecated в SC 2024.x; используем
  spring.config.import вместо bootstrap context)
- Profile-based config: dev отключает Cloud Config + Vault + OTel + JPA
  auto-config для standalone smoke testing; k8s profile подключает реальную
  инфру (vault + config server)
- Dockerfile: COPY всех модулей parent pom их видит при reactor
- Уберём spring-boot-maven-plugin из read-api/projection-writer пока нет main

Validated:
- mvn install all 11 modules: BUILD SUCCESS
- Spring Boot стартует за 2.7s в dev profile
- /actuator/health UP, /actuator/prometheus отдаёт http_server_requests + jvm
- HelloController отвечает на GET /
2026-05-03 01:03:03 +03:00
Zimin A.N. a28fd0b352 feat: Maven multimodule scaffolding for Ordinis MDM service
- 10 модулей (domain, validation, events-api, outbox, rest-api, app, read-api, projection-writer, cuod-bundle, migrations)
- Parent pom + BOM с Spring Boot 3.4.2, Spring Cloud 2024.0.0, Vault Config 4.2.0
- ordinis-app: Spring Boot main с Actuator/Prometheus/Logstash JSON encoder/OpenTelemetry/Cloud Config/Vault skeleton
- ordinis-migrations: Liquibase changelogs (dictionary_definitions/records, audit_log, outbox_events, idempotency_keys, EXCLUSION CONSTRAINT через btree_gist)
- Multi-stage Dockerfiles для app и migrations
2026-05-03 00:41:53 +03:00