Merge branch 'chore/phase3-prep-org-id' into 'main'
chore(auth): Phase 3 cheap prep — nullable org_id + OrgContext skeleton See merge request 2-6/2-6-4/terravault/ordinis!276
This commit is contained in:
@@ -0,0 +1,113 @@
|
|||||||
|
package cloud.nstart.terravault.ordinis.auth;
|
||||||
|
|
||||||
|
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.HashSet;
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.Set;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Phase 3 cheap-prep skeleton — resolver «текущая организация юзера».
|
||||||
|
*
|
||||||
|
* <p><b>Сейчас всегда возвращает empty</b> — Ordinis single-tenant, никакая
|
||||||
|
* логика не использует org-scoping. Bean существует чтобы при старте Phase 3:
|
||||||
|
* <ul>
|
||||||
|
* <li>Callsites могут уже сейчас depend on {@code OrgContext} без падений
|
||||||
|
* (всегда получат "нет org" — поведение совпадает с текущим).</li>
|
||||||
|
* <li>Замена внутри методов на реальный JWT claim parser — изолированная,
|
||||||
|
* без необходимости менять весь call graph.</li>
|
||||||
|
* <li>Phase 3 startup стоит ~30 минут вместо ~2 дней (зависит от того
|
||||||
|
* сколько мест уже подключилось).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Когда Phase 3 стартует, имплементация поменяется на чтение JWT claim
|
||||||
|
* {@code organization} (altum auth-service кладёт туда array of org UUIDs).
|
||||||
|
* Параллельно появится:
|
||||||
|
* <ul>
|
||||||
|
* <li>Liquibase migration 0029+ — backfill {@code org_id} в data-таблицах
|
||||||
|
* (колонки уже добавлены nullable в migration 0028, см. её комментарий).</li>
|
||||||
|
* <li>{@code OrgScopeFilter} — applies {@code WHERE org_id IN (...)} к
|
||||||
|
* repository queries.</li>
|
||||||
|
* <li>{@code X-Org-Id} header / {@code ?org=} query — переключение между
|
||||||
|
* orgs когда юзер в нескольких.</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Не путать со {@link ScopeContext}: scope = data sensitivity tier
|
||||||
|
* (PUBLIC/INTERNAL/RESTRICTED), org = data tenant. Они ортогональны:
|
||||||
|
* Customer-X RESTRICTED data ≠ Customer-Y RESTRICTED data.
|
||||||
|
*/
|
||||||
|
@Component
|
||||||
|
public class OrgContext {
|
||||||
|
|
||||||
|
private static final Logger log = LoggerFactory.getLogger(OrgContext.class);
|
||||||
|
private static final String ORG_CLAIM = "organization";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Все организации текущего юзера. Пустой Set если:
|
||||||
|
* <ul>
|
||||||
|
* <li>JWT отсутствует (anonymous request).</li>
|
||||||
|
* <li>JWT не содержит {@code organization} claim.</li>
|
||||||
|
* <li>Phase 3 ещё не активен (текущее состояние).</li>
|
||||||
|
* </ul>
|
||||||
|
*
|
||||||
|
* <p>Реализация ниже уже умеет читать claim — это сделано чтобы при
|
||||||
|
* включении Phase 3 не пришлось трогать сам метод, только downstream
|
||||||
|
* consumers. Сейчас downstream'ов нет → выход пустой → ничего не делает.
|
||||||
|
*/
|
||||||
|
public Set<String> currentOrgs() {
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (!(auth instanceof JwtAuthenticationToken jwtAuth)) return Set.of();
|
||||||
|
return readOrgClaim(jwtAuth.getToken());
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single «active» org для current-request scope. Когда юзер в одной org —
|
||||||
|
* она. Когда в нескольких — нужен явный select (header / query param),
|
||||||
|
* пока не реализовано → возвращает первую (детерминистично, sorted), либо
|
||||||
|
* empty если нет ни одной.
|
||||||
|
*
|
||||||
|
* <p>В Phase 3 этот метод будет принимать requested org из request и
|
||||||
|
* валидировать что юзер in её состав. Сейчас просто placeholder.
|
||||||
|
*/
|
||||||
|
public Optional<String> activeOrg() {
|
||||||
|
Set<String> orgs = currentOrgs();
|
||||||
|
if (orgs.isEmpty()) return Optional.empty();
|
||||||
|
return orgs.stream().sorted().findFirst();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Парсит {@code organization} claim из JWT. Поддерживает оба формата
|
||||||
|
* которые встречаются у altum auth-service:
|
||||||
|
* <ul>
|
||||||
|
* <li>Массив строк: {@code "organization": ["nstart", "customer-x"]}.</li>
|
||||||
|
* <li>Одна строка (legacy): {@code "organization": "nstart"}.</li>
|
||||||
|
* </ul>
|
||||||
|
* Null/missing claim → empty set.
|
||||||
|
*/
|
||||||
|
private static Set<String> readOrgClaim(Jwt token) {
|
||||||
|
Object raw = token.getClaim(ORG_CLAIM);
|
||||||
|
if (raw == null) return Set.of();
|
||||||
|
Set<String> orgs = new HashSet<>();
|
||||||
|
if (raw instanceof Collection<?> col) {
|
||||||
|
for (Object o : col) {
|
||||||
|
if (o == null) continue;
|
||||||
|
String s = o.toString().trim();
|
||||||
|
if (!s.isEmpty()) orgs.add(s);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
String s = raw.toString().trim();
|
||||||
|
if (!s.isEmpty()) orgs.add(s);
|
||||||
|
}
|
||||||
|
if (log.isDebugEnabled() && !orgs.isEmpty()) {
|
||||||
|
log.debug("OrgContext: JWT organization claim resolved → {}", orgs);
|
||||||
|
}
|
||||||
|
return Set.copyOf(orgs);
|
||||||
|
}
|
||||||
|
}
|
||||||
+63
@@ -0,0 +1,63 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<databaseChangeLog
|
||||||
|
xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
|
||||||
|
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
|
||||||
|
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog
|
||||||
|
http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-4.27.xsd">
|
||||||
|
|
||||||
|
<!--
|
||||||
|
Phase 3 cheap prep — заранее добавить nullable org_id колонки в core
|
||||||
|
data-таблицы. Никакая логика не использует эти колонки сейчас (нет
|
||||||
|
entity полей, нет фильтров, нет индексов). Цель: при старте полноценного
|
||||||
|
Phase 3 (multi-tenant org-scoping) миграция становится трiviальной —
|
||||||
|
UPDATE для backfill + ALTER NOT NULL + индекс, без блокирующего
|
||||||
|
ADD COLUMN на больших таблицах.
|
||||||
|
|
||||||
|
Что НЕ добавлено сюда (отложено до Phase 3):
|
||||||
|
- JPA @Column поля на entities — мусор в текущей модели.
|
||||||
|
- Index'ы на org_id — пока NULL у всех строк, индекс бесполезен и
|
||||||
|
просто занимает место.
|
||||||
|
- NOT NULL constraint — только после backfill.
|
||||||
|
- FK к organizations таблице — её ещё нет (org_id это UUID из
|
||||||
|
altum-auth realm, не локальный FK).
|
||||||
|
- Колонки в schema_drafts, record_drafts, user_display_cache —
|
||||||
|
могут не понадобиться (drafts могут быть привязаны через dictionary
|
||||||
|
JOIN; user_display_cache юзер может быть в нескольких orgs).
|
||||||
|
Решение перенесём на Phase 3 когда дизайн будет конкретнее.
|
||||||
|
|
||||||
|
Контекст: altum auth-service кладёт `organization` claim в JWT (array
|
||||||
|
of org UUIDs). Phase 3 будет filterить queries по этому claim'у.
|
||||||
|
Сейчас realm `altum` имеет одну org ("nstart"), так что cheap prep
|
||||||
|
не активирует никакого мulti-tenancy — это чисто инфра-задел.
|
||||||
|
|
||||||
|
См. checkpoint 20260526-154800-phase2-audited-users-scope-fix.md
|
||||||
|
раздел "Phase 3 cheap preparation".
|
||||||
|
-->
|
||||||
|
|
||||||
|
<changeSet id="0028-1-dictionary-definitions-org-id" author="ordinis">
|
||||||
|
<comment>Phase 3 prep: nullable org_id на dictionary_definitions</comment>
|
||||||
|
<addColumn tableName="dictionary_definitions">
|
||||||
|
<column name="org_id" type="UUID">
|
||||||
|
<constraints nullable="true"/>
|
||||||
|
</column>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0028-2-dictionary-records-org-id" author="ordinis">
|
||||||
|
<comment>Phase 3 prep: nullable org_id на dictionary_records</comment>
|
||||||
|
<addColumn tableName="dictionary_records">
|
||||||
|
<column name="org_id" type="UUID">
|
||||||
|
<constraints nullable="true"/>
|
||||||
|
</column>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
|
||||||
|
<changeSet id="0028-3-audit-log-org-id" author="ordinis">
|
||||||
|
<comment>Phase 3 prep: nullable org_id на audit_log</comment>
|
||||||
|
<addColumn tableName="audit_log">
|
||||||
|
<column name="org_id" type="UUID">
|
||||||
|
<constraints nullable="true"/>
|
||||||
|
</column>
|
||||||
|
</addColumn>
|
||||||
|
</changeSet>
|
||||||
|
</databaseChangeLog>
|
||||||
@@ -37,5 +37,6 @@
|
|||||||
<include file="changes/0025-notification-prefs-event-type.xml" relativeToChangelogFile="true"/>
|
<include file="changes/0025-notification-prefs-event-type.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="changes/0026-notification-quiet-hours.xml" relativeToChangelogFile="true"/>
|
<include file="changes/0026-notification-quiet-hours.xml" relativeToChangelogFile="true"/>
|
||||||
<include file="changes/0027-spacecraft-mission-fk.xml" relativeToChangelogFile="true"/>
|
<include file="changes/0027-spacecraft-mission-fk.xml" relativeToChangelogFile="true"/>
|
||||||
|
<include file="changes/0028-phase3-prep-org-id.xml" relativeToChangelogFile="true"/>
|
||||||
|
|
||||||
</databaseChangeLog>
|
</databaseChangeLog>
|
||||||
|
|||||||
Reference in New Issue
Block a user