Merge branch 'feat/phase-1d-workflow-roles' into 'main'

feat(auth): Phase 1d — backend role enforcement для workflow

See merge request 2-6/2-6-4/terravault/ordinis!162
This commit is contained in:
Александр Зимин
2026-05-13 09:57:44 +00:00
11 changed files with 495 additions and 44 deletions
+1
View File
@@ -21,3 +21,4 @@ HELP.md
# Sensitive
*.pem
*.key
.gstack/
+2
View File
@@ -19,6 +19,8 @@
- [Закрытие записи](close-record.md) — как корректно «удалить» запись,
сохранив историю (bitemporal модель)
- [Журнал аудита](audit-log.md) — как посмотреть, кто и когда что менял
- [Роли доступа (RBAC)](rbac.md) — полная матрица realm-ролей Keycloak:
scope ACL + workflow roles (schema reviewer, publisher, record reviewer)
- [Разбор ошибок](errors.md) — типичные ошибки валидации и как их понять
## Базовые понятия
+144
View File
@@ -0,0 +1,144 @@
# Роли доступа и матрица прав
Ordinis использует **Keycloak realm roles** (claim `realm_access.roles` в JWT) для двух уровней контроля доступа:
1. **Scope ACL** — что пользователь может **читать** (видимость записей и словарей)
2. **Workflow roles** — какие **действия** пользователь может выполнять (approve, publish, reject)
Все роли используют colon-separated namespace `ordinis:<домен>:<действие>`.
## Полная матрица ролей
### Scope ACL (видимость данных)
| Realm role | Видит | Подразумевает |
|---|---|---|
| `ordinis:client:public` | только PUBLIC записи | — |
| `ordinis:client:internal` | PUBLIC + INTERNAL | автоматически включает public |
| `ordinis:client:restricted` | PUBLIC + INTERNAL + RESTRICTED | автоматически включает оба нижних |
Без любой из этих ролей пользователь считается **anonymous** и видит только `PUBLIC` записи (fallback).
> Альтернативные dash-формы (`ordinis-public`, `ordinis-internal`, `ordinis-restricted`) также распознаются для совместимости. Канонические — colon-форма.
### Workflow роли (право на действия)
| Realm role | Право |
|---|---|
| `ordinis:schema:reviewer` | Approve / Запросить правки / Отклонить **schema draft** |
| `ordinis:schema:publisher` | Publish approved schema draft → live |
| `ordinis:record:reviewer` | Approve / Reject **record draft** (Approval Workflow v2) |
Backend проверяет роль перед каждым действием. Без роли — `403 forbidden_role` с сообщением о требуемой роли. Frontend дополнительно скрывает кнопки, для которых нет роли.
> **Maker side** (создание / submit / withdraw draft'а) специальной ролью не gated — любой authenticated user с required scope может создать draft. Backend защищает invariant maker-checker через `403 self_approve_forbidden` (один и тот же user не может approve свой draft).
## Полная матрица действий
| Действие | Endpoint | Scope требуется | Workflow роль требуется |
|---|---|---|---|
| Просмотр PUBLIC dict | `GET /api/v1/dictionaries` | public+ | — |
| Просмотр INTERNAL/RESTRICTED dict | `GET /api/v1/dictionaries` | соответствующий scope | — |
| Создание dict | `POST /api/v1/dictionaries` | соответствующий scope | — |
| Редактирование dict schema (через draft) | `POST /api/v1/dictionaries/{name}/drafts` | соответствующий scope | — (maker) |
| Submit schema draft на ревью | `POST .../drafts/{id}/review` | соответствующий scope | — (maker) |
| **Approve / Request changes / Reject schema draft** | `POST .../drafts/{id}/decision` | соответствующий scope | `ordinis:schema:reviewer` |
| **Publish approved schema draft** | `POST .../drafts/{id}/publish` | соответствующий scope | `ordinis:schema:publisher` |
| Withdraw собственный schema draft | `POST .../drafts/{id}/withdraw` | соответствующий scope | — (maker self) |
| Создание record draft | `POST .../records/.../drafts` | соответствующий scope | — (maker) |
| **Approve record draft** | `POST /api/v1/drafts/{id}/approve` | соответствующий scope | `ordinis:record:reviewer` |
| **Reject record draft** | `POST /api/v1/drafts/{id}/reject` | соответствующий scope | `ordinis:record:reviewer` |
| Просмотр webhook subscriptions | `GET /api/v1/admin/webhooks/subscriptions` | `internal` или `restricted` | — |
| Просмотр audit log | `GET /api/v1/admin/audit` | соответствующий scope | — |
## Типичные профили пользователей
### Только чтение (consumer, integrator)
- `ordinis:client:public`
Видит публичные справочники, не может ничего менять.
### Maker (контент-редактор)
- `ordinis:client:internal` (видит данные своей области)
Создаёт и редактирует справочники, записи, schema drafts, submit'ит их на ревью. **Не может** approve / publish свои собственные действия (maker-checker).
### Reviewer
- `ordinis:client:restricted`
- `ordinis:schema:reviewer`
- `ordinis:record:reviewer`
Может всё что maker, плюс approve / reject чужих drafts. Publish — отдельной ролью.
### Publisher
- `ordinis:client:restricted`
- `ordinis:schema:reviewer` (опционально)
- `ordinis:schema:publisher`
Финальный gatekeeper для production публикации схем. Обычно более узкая группа чем reviewers.
### Admin (полный доступ)
- `ordinis:client:restricted`
- `ordinis:schema:reviewer`
- `ordinis:schema:publisher`
- `ordinis:record:reviewer`
Или **composite role** `ordinis:admin`, которая агрегирует все четыре.
## Настройка в Keycloak
### Создание ролей
1. Открой **Keycloak Admin Console** → realm `nstart`**Realm roles**.
2. Нажми **Create role** и создай по очереди:
- `ordinis:client:public`
- `ordinis:client:internal`
- `ordinis:client:restricted`
- `ordinis:schema:reviewer`
- `ordinis:schema:publisher`
- `ordinis:record:reviewer`
### Composite role `ordinis:admin` (опционально, но удобно)
3. **Create role**`ordinis:admin`.
4. На вкладке роли → **Action****Add associated roles** → выбери все 6 ролей выше → **Assign**.
После этого можно назначать одну роль `ordinis:admin` users'ам, и они автоматически получают все права.
### Назначение пользователю
1. **Users** → найди user'а**Role mapping**.
2. **Assign role** → отметь нужные роли (или `ordinis:admin` для админов).
### Применение
Пользователю нужно **logout / login** в Ordinis UI, чтобы получить новый JWT с обновлённым `realm_access.roles` claim'ом.
## Диагностика
### «Не могу нажать Approve — кнопка скрыта»
UI скрывает кнопку если у user'а нет нужной workflow роли. Проверить:
1. F12 → Application → Session Storage → `oidc.user:...`.
2. В JSON найти `profile.realm_access.roles`.
3. Должна быть `ordinis:schema:reviewer` (для schema) или `ordinis:record:reviewer` (для record).
Если роли нет — назначить в Keycloak и сделать logout/login.
### «403 forbidden_role» от backend
Backend вернул 403 с кодом `forbidden_role`. Сообщение содержит требуемую роль. Назначь её в Keycloak и refresh JWT.
### «403 self_approve_forbidden»
Пользователь пытается approve свой собственный draft. Это **invariant maker-checker** — backend защищает от self-approve. Нужен другой reviewer.
### «404 / пустая страница на /webhooks»
Webhook subscriptions требуют `internal` или `restricted` scope. Назначь `ordinis:client:internal` минимум.
## История
- **Phase 1a/1b/1c** (до 2026-05-12) — backend гейтил только maker-checker, любой authenticated мог review.
- **Phase 1d** (2026-05-13) — backend проверяет workflow роли; frontend скрывает кнопки. Migration require: создать роли в Keycloak и assign actual reviewer-users до или одновременно с deploy.
+7
View File
@@ -26,6 +26,13 @@ DRAFT → REVIEW_PENDING → APPROVED → PUBLISHED
- [Ревью schema drafts](schema-drafts.md) — пошаговая инструкция, как
смотреть diff, какие проверки делать, как принимать решение
## Какая роль нужна
Для approve / reject schema drafts требуется realm role
`ordinis:schema:reviewer`. Для финального publish — `ordinis:schema:publisher`
(обычно более узкая группа). Полная матрица — см.
[Роли доступа](../operator/rbac.md) в руководстве оператора.
## Когда чаще всего стоит REJECT или REQUEST_CHANGES
| Сценарий | Что делать |
+2
View File
@@ -20,6 +20,8 @@ items:
href: operator/close-record.md
- name: Журнал аудита
href: operator/audit-log.md
- name: Роли доступа (RBAC)
href: operator/rbac.md
- name: Разбор ошибок
href: operator/errors.md
+53 -32
View File
@@ -1,56 +1,77 @@
import { useAuth } from 'react-oidc-context'
/**
* Permission helpers для schema workflow. Phase 1cstub: проверяем
* только что пользователь authenticated (любой logged-in юзер может
* approve/publish). Backend всё равно отрежет:
* Permission helpers для schema + record workflow. Phase 1d**ACTIVE**:
* читает {@code realm_access.roles} из JWT и гейтит actions.
*
* <p>Naming convention: colon-separated {@code ordinis:<domain>:<action>}
* pattern (consistent с scope ACL {@code ordinis:client:public}). Backend
* parser в {@code WorkflowRoles.java}.
*
* <p>Backend enforcement (см. {@code WorkflowRoles}):
* <ul>
* <li>{@code 403 self_approve_forbidden} — если maker == reviewer.</li>
* <li>{@code 403 forbidden_role} — когда Keycloak roles прорастут и
* backend начнёт проверять realm-роли (placeholder).</li>
* <li>{@code 403 forbidden_role} — actor не имеет требуемой realm role.</li>
* <li>{@code 403 self_approve_forbidden} — actor == maker (maker-checker).</li>
* </ul>
*
* <p>Phase 1d: подключим decode JWT → realm_access.roles, и эти hook'и
* начнут возвращать false для users без {@code ordinis:schema:reviewer}
* / {@code ordinis:schema:publisher} ролей. UI hide'ит соответствующие
* кнопки.
* <p>Frontend hide'ит соответствующие кнопки на UI level для UX, backend
* гейт остаётся primary defence. Если user имеет роль в Keycloak —
* кнопки видны и действия пройдут backend; если не имеет — кнопки скрыты,
* backend всё равно 403'нет на прямой API call.
*
* <p><b>Naming convention:</b> roles используют colon-separated namespace
* pattern {@code ordinis:<domain>:<action>} (как Altum-style scope roles
* вида {@code ordinis:client:restricted}). Совпадает с realm role parser
* в {@code ScopeContext.java}. Dash-форма ({@code ordinis-schema-reviewer})
* НЕ используется в этом проекте — colon-стиль consistent с уже-existing
* scope ролями ({@code ordinis:client:public|internal|restricted}).
*
* <p>До тех пор все logged-in юзеры могут жать любые workflow buttons —
* backend гейт остаётся primary defence.
* <p><b>Migration note (Phase 1d enforcement):</b> Keycloak realm `nstart`
* должен иметь созданные роли:
* <ul>
* <li>{@code ordinis:schema:reviewer} — schema decide</li>
* <li>{@code ordinis:schema:publisher} — schema publish</li>
* <li>{@code ordinis:record:reviewer} — record approve/reject</li>
* </ul>
* и actual reviewer-users должны их получить. До этого все decide /
* publish операции вернут {@code 403 forbidden_role}.
*/
const ROLE_SCHEMA_REVIEWER = 'ordinis:schema:reviewer'
const ROLE_SCHEMA_PUBLISHER = 'ordinis:schema:publisher'
const ROLE_SCHEMA_MAKER = 'ordinis:schema:maker'
export const ROLE_SCHEMA_REVIEWER = 'ordinis:schema:reviewer'
export const ROLE_SCHEMA_PUBLISHER = 'ordinis:schema:publisher'
export const ROLE_RECORD_REVIEWER = 'ordinis:record:reviewer'
/** Достаёт realm roles из JWT (Keycloak claim path: realm_access.roles). */
function realmRoles(profile: unknown): string[] {
if (!profile || typeof profile !== 'object') return []
const realmAccess = (profile as { realm_access?: { roles?: unknown } }).realm_access
if (!realmAccess || typeof realmAccess !== 'object') return []
const roles = (realmAccess as { roles?: unknown }).roles
if (!Array.isArray(roles)) return []
return roles.filter((r): r is string => typeof r === 'string')
}
function hasRole(profile: unknown, role: string): boolean {
return realmRoles(profile).includes(role)
}
/** Может ли актор approve / request_changes / reject schema draft. */
export function useCanReviewSchema(): boolean {
const auth = useAuth()
// TODO Phase 1d: const roles = (auth.user?.profile as { realm_access?: { roles?: string[] } })?.realm_access?.roles ?? []
// return roles.includes(ROLE_SCHEMA_REVIEWER)
void ROLE_SCHEMA_REVIEWER
return auth.isAuthenticated
return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_SCHEMA_REVIEWER)
}
/** Может ли актор approve / reject record draft. */
export function useCanReviewRecord(): boolean {
const auth = useAuth()
return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_RECORD_REVIEWER)
}
/** Может ли актор publish approved schema draft (bump live version). */
export function useCanPublishSchema(): boolean {
const auth = useAuth()
// TODO Phase 1d: roles.includes(ROLE_SCHEMA_PUBLISHER)
void ROLE_SCHEMA_PUBLISHER
return auth.isAuthenticated
return auth.isAuthenticated && hasRole(auth.user?.profile, ROLE_SCHEMA_PUBLISHER)
}
/** Может ли актор create schema draft (maker side). */
/**
* Может ли актор create schema draft (maker side). Maker не gated
* специальной ролью — любой authenticated с required scope. Backend
* проверяет scope ACL для конкретного словаря.
*/
export function useCanCreateSchemaDraft(): boolean {
const auth = useAuth()
// TODO Phase 1d: roles.includes(ROLE_SCHEMA_MAKER) OR any authenticated
void ROLE_SCHEMA_MAKER
return auth.isAuthenticated
}
@@ -69,11 +69,23 @@ class ApprovalWorkflowE2ETest {
// === Helpers ===
/** Internal-scope JWT для конкретного юзера. */
/** Internal-scope JWT для maker'а (без workflow ролей). */
private static String tokenFor(String subject) {
return JwtTestSupport.signJwt(List.of("ordinis:client:internal"), subject);
}
/**
* JWT для reviewer'а — internal scope + Phase 1d {@code ordinis:record:reviewer}
* role. Без этой роли backend гейт ({@code WorkflowRoles.RECORD_REVIEWER})
* вернёт {@code 403 forbidden_role} до того как тест проверит другие
* invariants (self-approve, missing comment etc).
*/
private static String reviewerTokenFor(String subject) {
return JwtTestSupport.signJwt(
List.of("ordinis:client:internal", "ordinis:record:reviewer"),
subject);
}
private static final String SCHEMA_JSON = """
{
"$schema": "http://json-schema.org/draft-07/schema#",
@@ -155,7 +167,7 @@ class ApprovalWorkflowE2ETest {
@Test
void happyFlow_submitApprove_recordLiveAndOutboxEvent() throws Exception {
String makerToken = tokenFor("alice-maker");
String reviewerToken = tokenFor("bob-reviewer");
String reviewerToken = reviewerTokenFor("bob-reviewer");
String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_h"));
String bk = randBk();
@@ -204,7 +216,7 @@ class ApprovalWorkflowE2ETest {
@Test
void rejectFlow_keepsDraft_noLiveRecord() throws Exception {
String makerToken = tokenFor("carol-maker");
String reviewerToken = tokenFor("dave-reviewer");
String reviewerToken = reviewerTokenFor("dave-reviewer");
String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_rej"));
String bk = randBk();
@@ -235,7 +247,11 @@ class ApprovalWorkflowE2ETest {
@Test
void selfApproveBlocked_409() throws Exception {
String token = tokenFor("eve-solo");
// eve-solo даёт BOTH scope И reviewer role — иначе backend ответит
// 403 forbidden_role до того как мы достигнем self-approve check.
// Тест specifically проверяет 409 self_approve_forbidden (maker == reviewer
// даже при наличии role).
String token = reviewerTokenFor("eve-solo");
String dictName = createDictThenEnableApproval(token, randDictName("appr_self"));
String bk = randBk();
@@ -336,7 +352,7 @@ class ApprovalWorkflowE2ETest {
@Test
void withdrawByMaker_keepsDraftStatus() throws Exception {
String makerToken = tokenFor("ivan-maker");
String reviewerToken = tokenFor("julia-reviewer");
String reviewerToken = reviewerTokenFor("julia-reviewer");
String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_wd"));
String bk = randBk();
@@ -364,7 +380,7 @@ class ApprovalWorkflowE2ETest {
@Test
void rejectWithoutComment_400() throws Exception {
String makerToken = tokenFor("kate-maker");
String reviewerToken = tokenFor("leo-reviewer");
String reviewerToken = reviewerTokenFor("leo-reviewer");
String dictName = createDictThenEnableApproval(makerToken, randDictName("appr_rc"));
String bk = randBk();
@@ -0,0 +1,106 @@
package cloud.nstart.terravault.ordinis.restapi.auth;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import org.springframework.stereotype.Component;
import java.util.Collection;
import java.util.Map;
import java.util.Set;
/**
* Realm-role enforcement для workflow actions (approve/reject/publish
* как record draft, так и schema draft).
*
* <p>Naming convention: colon-separated {@code ordinis:<domain>:<action>}
* pattern (consistent с scope ACL {@code ordinis:client:public}).
* Domain-specific роли для clear separation между record и schema workflows:
* <ul>
* <li>{@link #SCHEMA_REVIEWER} {@value SCHEMA_REVIEWER} — gate на
* schema draft decide (approve / request_changes / reject).</li>
* <li>{@link #SCHEMA_PUBLISHER} {@value SCHEMA_PUBLISHER} — gate на
* publish approved schema draft → live. У records publish
* auto-выполняется при approve, отдельной step'и нет.</li>
* <li>{@link #RECORD_REVIEWER} {@value RECORD_REVIEWER} — gate на
* record draft approve / reject (Approval Workflow v2).</li>
* </ul>
*
* <p>Maker side (create / submit / withdraw) пока не role-gated — любой
* authenticated user может создавать drafts. Backend гейтит
* {@code self_approve_forbidden} (maker != reviewer) как primary defence
* для maker-checker invariant.
*
* <p>JWT источник: {@code realm_access.roles} claim (Keycloak realm roles).
* Совместимо с {@code ScopeContext} JWT parsing pattern.
*
* <p><b>Migration note (Phase 1d enforcement):</b> Keycloak realm должен
* иметь созданные роли, и users должны их получить. Иначе все decide /
* publish операции вернут {@code 403 forbidden_role}.
*
* <p>Composite role idea: {@code ordinis:admin} = combined из всех трёх
* + scope role'ов — single assignment grants full workflow access.
*/
@Component
public class WorkflowRoles {
private static final Logger log = LoggerFactory.getLogger(WorkflowRoles.class);
/** Approve / reject / request_changes schema draft (Phase 1c workflow). */
public static final String SCHEMA_REVIEWER = "ordinis:schema:reviewer";
/** Publish approved schema draft → live (bump dictionary schema version). */
public static final String SCHEMA_PUBLISHER = "ordinis:schema:publisher";
/** Approve / reject record draft (Approval Workflow v2). */
public static final String RECORD_REVIEWER = "ordinis:record:reviewer";
/** Read realm_access.roles из current request JWT. Anonymous → empty. */
public Set<String> currentRoles() {
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
if (!(auth instanceof JwtAuthenticationToken jwt)) {
return Set.of();
}
return extractRoles(jwt.getToken());
}
/** True если у actor'а есть указанная realm role. */
public boolean hasRole(String role) {
return currentRoles().contains(role);
}
/**
* Throw {@code 403 forbidden_role} если у actor'а нет role.
* Service-layer fail-fast guard.
*/
public void requireRole(String role) {
if (!hasRole(role)) {
log.debug("Access denied: missing required realm role '{}'", role);
throw OrdinisException.forbidden(
"forbidden_role",
"Требуется realm role: " + role);
}
}
static Set<String> extractRoles(Jwt token) {
Object realmAccessObj = token.getClaim("realm_access");
if (!(realmAccessObj instanceof Map<?, ?> realmAccess)) {
return Set.of();
}
Object rolesObj = realmAccess.get("roles");
if (!(rolesObj instanceof Collection<?> rolesCol)) {
return Set.of();
}
java.util.LinkedHashSet<String> out = new java.util.LinkedHashSet<>();
for (Object o : rolesCol) {
if (o instanceof String s) {
out.add(s);
}
}
return out;
}
}
@@ -62,6 +62,8 @@ public class DraftService {
private final ScopeContext scopeContext;
/** Phase 4 monitoring. Optional — null в test ctor'ах (no metrics in unit tests). */
private final Optional<MeterRegistry> meterRegistry;
/** Phase 1d: realm role gate. Null в test ctor → skip enforcement. */
private final cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles workflowRoles;
@org.springframework.beans.factory.annotation.Autowired
public DraftService(
@@ -69,12 +71,14 @@ public class DraftService {
DictionaryDefinitionService definitionService,
DictionaryRecordService recordService,
ScopeContext scopeContext,
Optional<MeterRegistry> meterRegistry) {
Optional<MeterRegistry> meterRegistry,
cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles workflowRoles) {
this.draftRepo = draftRepo;
this.definitionService = definitionService;
this.recordService = recordService;
this.scopeContext = scopeContext;
this.meterRegistry = meterRegistry;
this.workflowRoles = workflowRoles;
}
/** Test-friendly ctor — без recordService + без metrics для unit tests submit/list flow. */
@@ -82,7 +86,7 @@ public class DraftService {
RecordDraftRepository draftRepo,
DictionaryDefinitionService definitionService,
ScopeContext scopeContext) {
this(draftRepo, definitionService, null, scopeContext, Optional.empty());
this(draftRepo, definitionService, null, scopeContext, Optional.empty(), null);
}
/** Test-friendly ctor — с recordService но без metrics. Phase 1 + Phase 4 ctor compat. */
@@ -91,7 +95,14 @@ public class DraftService {
DictionaryDefinitionService definitionService,
DictionaryRecordService recordService,
ScopeContext scopeContext) {
this(draftRepo, definitionService, recordService, scopeContext, Optional.empty());
this(draftRepo, definitionService, recordService, scopeContext, Optional.empty(), null);
}
/** Phase 1d guard: enforce realm role если WorkflowRoles bean wired. */
private void requireRoleIfEnforced(String role) {
if (workflowRoles != null) {
workflowRoles.requireRole(role);
}
}
/**
@@ -212,6 +223,8 @@ public class DraftService {
if (recordService == null) {
throw new IllegalStateException("DictionaryRecordService not wired (test ctor used).");
}
// Phase 1d: realm role gate (record-specific 'ordinis:record:reviewer').
requireRoleIfEnforced(cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles.RECORD_REVIEWER);
String reviewerId = currentUserId();
RecordDraft draft = findById(draftId);
if (draft.getStatus() != DraftStatus.PENDING) {
@@ -259,6 +272,8 @@ public class DraftService {
*/
@Transactional
public RecordDraft reject(UUID draftId, String reviewComment) {
// Phase 1d: same role как approve — reviewer'ы могут оба действия.
requireRoleIfEnforced(cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles.RECORD_REVIEWER);
if (reviewComment == null || reviewComment.isBlank()) {
throw OrdinisException.badRequest(
"reject_reason_required",
@@ -7,6 +7,7 @@ import cloud.nstart.terravault.ordinis.domain.schemaworkflow.SchemaDraftStatus;
import cloud.nstart.terravault.ordinis.events.EventEnvelope;
import cloud.nstart.terravault.ordinis.outbox.OutboxRecorder;
import cloud.nstart.terravault.ordinis.restapi.audit.AuditLogger;
import cloud.nstart.terravault.ordinis.restapi.auth.WorkflowRoles;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.CreateSchemaDraftRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.DecisionRequest;
import cloud.nstart.terravault.ordinis.restapi.dto.schemaworkflow.PublishDraftRequest;
@@ -66,6 +67,8 @@ public class SchemaDraftService {
private final ObjectMapper objectMapper;
/** Optional — NULL в test ctor'е (без audit_log writes). */
private final AuditLogger auditLogger;
/** Optional — NULL в test ctor'е (skip role enforcement в unit tests). */
private final WorkflowRoles workflowRoles;
@org.springframework.beans.factory.annotation.Autowired
public SchemaDraftService(
@@ -73,21 +76,30 @@ public class SchemaDraftService {
DictionaryDefinitionService definitionService,
OutboxRecorder outboxRecorder,
ObjectMapper objectMapper,
AuditLogger auditLogger) {
AuditLogger auditLogger,
WorkflowRoles workflowRoles) {
this.repository = repository;
this.definitionService = definitionService;
this.outboxRecorder = outboxRecorder;
this.objectMapper = objectMapper;
this.auditLogger = auditLogger;
this.workflowRoles = workflowRoles;
}
/** Test ctor — без audit_log. */
/** Test ctor — без audit_log + без role enforcement. */
public SchemaDraftService(
SchemaDraftRepository repository,
DictionaryDefinitionService definitionService,
OutboxRecorder outboxRecorder,
ObjectMapper objectMapper) {
this(repository, definitionService, outboxRecorder, objectMapper, null);
this(repository, definitionService, outboxRecorder, objectMapper, null, null);
}
/** Phase 1d guard: enforce realm role если WorkflowRoles bean wired. */
private void requireRoleIfEnforced(String role) {
if (workflowRoles != null) {
workflowRoles.requireRole(role);
}
}
// ─────────────────────────────────────────────────────────────────────
@@ -232,6 +244,9 @@ public class SchemaDraftService {
*/
@Transactional
public DictionarySchemaDraft decide(UUID draftId, DecisionRequest req) {
// Phase 1d: realm role gate. Без 'ordinis:schema:reviewer' actor
// получит 403 forbidden_role до даже status checks — fail-fast.
requireRoleIfEnforced(WorkflowRoles.SCHEMA_REVIEWER);
DictionarySchemaDraft draft = findById(draftId);
String reviewerId = currentUserId();
@@ -293,6 +308,9 @@ public class SchemaDraftService {
*/
@Transactional(isolation = Isolation.SERIALIZABLE)
public DictionarySchemaDraft publish(UUID draftId, PublishDraftRequest req) {
// Phase 1d: publish — отдельная role от reviewer'а. Publishers
// обычно более узкая группа (production gatekeepers) чем reviewers.
requireRoleIfEnforced(WorkflowRoles.SCHEMA_PUBLISHER);
DictionarySchemaDraft draft = findById(draftId);
DictionaryDefinition def = definitionService.findById(draft.getDictionaryId());
@@ -0,0 +1,119 @@
package cloud.nstart.terravault.ordinis.restapi.auth;
import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException;
import org.junit.jupiter.api.AfterEach;
import org.junit.jupiter.api.Test;
import org.springframework.security.core.context.SecurityContextHolder;
import org.springframework.security.oauth2.jwt.Jwt;
import org.springframework.security.oauth2.server.resource.authentication.JwtAuthenticationToken;
import java.time.Instant;
import java.util.List;
import java.util.Map;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
/**
* Unit tests для {@link WorkflowRoles} — realm role extraction из JWT
* claim {@code realm_access.roles} + enforcement через requireRole().
*/
class WorkflowRolesTest {
private final WorkflowRoles roles = new WorkflowRoles();
@AfterEach
void clearSecurityContext() {
SecurityContextHolder.clearContext();
}
@Test
void anonymous_currentRoles_empty() {
SecurityContextHolder.clearContext();
assertThat(roles.currentRoles()).isEmpty();
}
@Test
void anonymous_hasRole_false() {
SecurityContextHolder.clearContext();
assertThat(roles.hasRole(WorkflowRoles.SCHEMA_REVIEWER)).isFalse();
}
@Test
void jwtWithApprover_hasRole_true() {
setJwtWithRealmRoles(List.of(WorkflowRoles.SCHEMA_REVIEWER));
assertThat(roles.hasRole(WorkflowRoles.SCHEMA_REVIEWER)).isTrue();
assertThat(roles.hasRole(WorkflowRoles.SCHEMA_PUBLISHER)).isFalse();
}
@Test
void jwtWithMultipleRoles_currentRoles_contains() {
setJwtWithRealmRoles(List.of(
WorkflowRoles.SCHEMA_REVIEWER,
WorkflowRoles.SCHEMA_PUBLISHER,
"ordinis:client:restricted",
"some-other-role"));
assertThat(roles.currentRoles())
.containsExactlyInAnyOrder(
WorkflowRoles.SCHEMA_REVIEWER,
WorkflowRoles.SCHEMA_PUBLISHER,
"ordinis:client:restricted",
"some-other-role");
}
@Test
void requireRole_present_noThrow() {
setJwtWithRealmRoles(List.of(WorkflowRoles.SCHEMA_REVIEWER));
roles.requireRole(WorkflowRoles.SCHEMA_REVIEWER);
// no exception — passes
}
@Test
void requireRole_missing_throws403() {
setJwtWithRealmRoles(List.of("some-other-role"));
assertThatThrownBy(() -> roles.requireRole(WorkflowRoles.SCHEMA_PUBLISHER))
.isInstanceOf(OrdinisException.class)
.hasMessageContaining(WorkflowRoles.SCHEMA_PUBLISHER);
}
@Test
void requireRole_anonymous_throws() {
SecurityContextHolder.clearContext();
assertThatThrownBy(() -> roles.requireRole(WorkflowRoles.SCHEMA_REVIEWER))
.isInstanceOf(OrdinisException.class);
}
@Test
void jwtWithoutRealmAccess_emptyRoles() {
setJwtWithClaims(Map.of("sub", "user-1")); // no realm_access claim
assertThat(roles.currentRoles()).isEmpty();
}
@Test
void jwtWithMalformedRealmAccess_emptyRoles() {
setJwtWithClaims(Map.of(
"sub", "user-1",
"realm_access", "not-a-map")); // bad type — defensive parse
assertThat(roles.currentRoles()).isEmpty();
}
// ─── helpers ─────────────────────────────────────────────────────────
private void setJwtWithRealmRoles(List<String> realmRoles) {
setJwtWithClaims(Map.of(
"sub", "test-user",
"realm_access", Map.of("roles", realmRoles)));
}
private void setJwtWithClaims(Map<String, Object> claims) {
Jwt jwt = new Jwt(
"test-token",
Instant.now(),
Instant.now().plusSeconds(600),
Map.of("alg", "RS256"),
claims);
JwtAuthenticationToken auth = new JwtAuthenticationToken(jwt);
auth.setAuthenticated(true);
SecurityContextHolder.getContext().setAuthentication(auth);
}
}