chore(auth): Phase 3 cheap prep — nullable org_id + OrgContext skeleton

This commit is contained in:
Александр Зимин
2026-05-26 13:01:36 +00:00
parent 9144b2d1d1
commit 2c251311f5
3 changed files with 177 additions and 0 deletions
@@ -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);
}
}