diff --git a/.gitignore b/.gitignore index 2bdc03d..ec6bf16 100644 --- a/.gitignore +++ b/.gitignore @@ -21,3 +21,4 @@ HELP.md # Sensitive *.pem *.key +.gstack/ diff --git a/docs/operator/index.md b/docs/operator/index.md index 08c72fa..748042c 100644 --- a/docs/operator/index.md +++ b/docs/operator/index.md @@ -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) — типичные ошибки валидации и как их понять ## Базовые понятия diff --git a/docs/operator/rbac.md b/docs/operator/rbac.md new file mode 100644 index 0000000..2455ed9 --- /dev/null +++ b/docs/operator/rbac.md @@ -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. diff --git a/docs/reviewer/index.md b/docs/reviewer/index.md index 1487a15..88b61f2 100644 --- a/docs/reviewer/index.md +++ b/docs/reviewer/index.md @@ -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 | Сценарий | Что делать | diff --git a/docs/toc.yaml b/docs/toc.yaml index ee36daf..728b4e7 100644 --- a/docs/toc.yaml +++ b/docs/toc.yaml @@ -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 diff --git a/ordinis-admin-ui/src/auth/usePermissions.ts b/ordinis-admin-ui/src/auth/usePermissions.ts index 961e7bc..49c7117 100644 --- a/ordinis-admin-ui/src/auth/usePermissions.ts +++ b/ordinis-admin-ui/src/auth/usePermissions.ts @@ -1,56 +1,77 @@ import { useAuth } from 'react-oidc-context' /** - * Permission helpers для schema workflow. Phase 1c — stub: проверяем - * только что пользователь authenticated (любой logged-in юзер может - * approve/publish). Backend всё равно отрежет: + * Permission helpers для schema + record workflow. Phase 1d — **ACTIVE**: + * читает {@code realm_access.roles} из JWT и гейтит actions. + * + *
Naming convention: colon-separated {@code ordinis: Backend enforcement (см. {@code WorkflowRoles}):
* Phase 1d: подключим decode JWT → realm_access.roles, и эти hook'и
- * начнут возвращать false для users без {@code ordinis:schema:reviewer}
- * / {@code ordinis:schema:publisher} ролей. UI hide'ит соответствующие
- * кнопки.
+ * Frontend hide'ит соответствующие кнопки на UI level для UX, backend
+ * гейт остаётся primary defence. Если user имеет роль в Keycloak —
+ * кнопки видны и действия пройдут backend; если не имеет — кнопки скрыты,
+ * backend всё равно 403'нет на прямой API call.
*
- * Naming convention: roles используют colon-separated namespace
- * pattern {@code ordinis: До тех пор все logged-in юзеры могут жать любые workflow buttons —
- * backend гейт остаётся primary defence.
+ * Migration note (Phase 1d enforcement): Keycloak realm `nstart`
+ * должен иметь созданные роли:
+ * Naming convention: colon-separated {@code ordinis: Maker side (create / submit / withdraw) пока не role-gated — любой
+ * authenticated user может создавать drafts. Backend гейтит
+ * {@code self_approve_forbidden} (maker != reviewer) как primary defence
+ * для maker-checker invariant.
+ *
+ * JWT источник: {@code realm_access.roles} claim (Keycloak realm roles).
+ * Совместимо с {@code ScopeContext} JWT parsing pattern.
+ *
+ * Migration note (Phase 1d enforcement): Keycloak realm должен
+ * иметь созданные роли, и users должны их получить. Иначе все decide /
+ * publish операции вернут {@code 403 forbidden_role}.
+ *
+ * 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
- *
*
- *
+ *
+ * и 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
}
diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/ApprovalWorkflowE2ETest.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/ApprovalWorkflowE2ETest.java
index bd725d1..36fce14 100644
--- a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/ApprovalWorkflowE2ETest.java
+++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/ApprovalWorkflowE2ETest.java
@@ -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();
diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRoles.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRoles.java
new file mode 100644
index 0000000..b67ab06
--- /dev/null
+++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/auth/WorkflowRoles.java
@@ -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).
+ *
+ *
+ *
+ *
+ *