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:}.
This commit is contained in:
+13
-18
@@ -1,5 +1,6 @@
|
||||
package cloud.nstart.terravault.ordinis.readapi.web;
|
||||
|
||||
import cloud.nstart.terravault.ordinis.auth.ScopeContext;
|
||||
import cloud.nstart.terravault.ordinis.domain.DataScope;
|
||||
import cloud.nstart.terravault.ordinis.readapi.dto.FlattenedRecordResponse;
|
||||
import cloud.nstart.terravault.ordinis.readapi.service.RecordReadService;
|
||||
@@ -16,27 +17,29 @@ import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
import java.time.OffsetDateTime;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
import java.util.stream.Stream;
|
||||
|
||||
/**
|
||||
* Read API для consumers (геопортал, Альтум). Фронт публичный — здесь
|
||||
* locale negotiation + scope filtering.
|
||||
*
|
||||
* <p>Scope authorisation: в v1 берём из query param {@code as_scope} (для
|
||||
* dev/curl). В prod — из JWT (@nstart/auth) — сделаем через filter позже.
|
||||
* <p>Scope authorisation: JWT claim {@code scopes} через {@link ScopeContext}.
|
||||
* Если запрос без JWT и {@code ordinis.auth.allow-query-scope=true} — fallback
|
||||
* на legacy query param {@code ?as_scope=}; иначе только PUBLIC.
|
||||
*/
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/{dictionaryName}/records")
|
||||
public class DictionaryReadController {
|
||||
|
||||
private final RecordReadService service;
|
||||
private final ScopeContext scopeContext;
|
||||
private final Counter scopeDeniedCounter;
|
||||
|
||||
public DictionaryReadController(RecordReadService service, MeterRegistry meterRegistry) {
|
||||
public DictionaryReadController(
|
||||
RecordReadService service, ScopeContext scopeContext, MeterRegistry meterRegistry) {
|
||||
this.service = service;
|
||||
this.scopeContext = scopeContext;
|
||||
this.scopeDeniedCounter = Counter.builder("ordinis_scope_access_denied_total")
|
||||
.description("Попытки чтения недоступного scope (v1 — из query param)")
|
||||
.description("Попытки чтения недоступного scope")
|
||||
.register(meterRegistry);
|
||||
}
|
||||
|
||||
@@ -45,10 +48,10 @@ public class DictionaryReadController {
|
||||
@PathVariable String dictionaryName,
|
||||
@PathVariable String businessKey,
|
||||
@RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage,
|
||||
@RequestParam(value = "as_scope", defaultValue = "PUBLIC") String asScope,
|
||||
@RequestParam(value = "as_scope", required = false) String asScope,
|
||||
@RequestParam(value = "at", required = false) OffsetDateTime at) {
|
||||
|
||||
List<DataScope> scopes = parseScopes(asScope);
|
||||
List<DataScope> scopes = scopeContext.resolveForRead(asScope).stream().toList();
|
||||
var result = service.readActive(dictionaryName, businessKey, acceptLanguage, scopes, at);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
@@ -63,10 +66,10 @@ public class DictionaryReadController {
|
||||
public ResponseEntity<List<FlattenedRecordResponse>> list(
|
||||
@PathVariable String dictionaryName,
|
||||
@RequestHeader(value = HttpHeaders.ACCEPT_LANGUAGE, required = false) String acceptLanguage,
|
||||
@RequestParam(value = "as_scope", defaultValue = "PUBLIC") String asScope,
|
||||
@RequestParam(value = "as_scope", required = false) String asScope,
|
||||
@RequestParam(value = "at", required = false) OffsetDateTime at) {
|
||||
|
||||
List<DataScope> scopes = parseScopes(asScope);
|
||||
List<DataScope> scopes = scopeContext.resolveForRead(asScope).stream().toList();
|
||||
var items = service.listActive(dictionaryName, acceptLanguage, scopes, at);
|
||||
|
||||
HttpHeaders headers = new HttpHeaders();
|
||||
@@ -76,12 +79,4 @@ public class DictionaryReadController {
|
||||
headers.add(HttpHeaders.VARY, HttpHeaders.ACCEPT_LANGUAGE);
|
||||
return new ResponseEntity<>(items, headers, 200);
|
||||
}
|
||||
|
||||
private List<DataScope> parseScopes(String asScope) {
|
||||
return Stream.of(asScope.split(","))
|
||||
.map(String::trim)
|
||||
.filter(s -> !s.isEmpty())
|
||||
.map(s -> DataScope.valueOf(s.toUpperCase()))
|
||||
.collect(Collectors.toList());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -121,3 +121,12 @@ spring:
|
||||
hibernate:
|
||||
ddl-auto: none
|
||||
open-in-view: false
|
||||
|
||||
# Auth — JWT public key из Vault (secret/ordinis/cuod/jwt-public-key, key=key).
|
||||
# Если public-key пуст — резолвер scope падает на anonymous PUBLIC + ?as_scope= legacy.
|
||||
ordinis:
|
||||
auth:
|
||||
public-key: ${key:}
|
||||
issuer: ${jwt-issuer:}
|
||||
require-authentication: ${ORDINIS_AUTH_REQUIRED:false}
|
||||
allow-query-scope: ${ORDINIS_AUTH_ALLOW_QUERY_SCOPE:true}
|
||||
|
||||
Reference in New Issue
Block a user