diff --git a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/AuthE2ETest.java b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/AuthE2ETest.java index 442ed82..1823e6c 100644 --- a/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/AuthE2ETest.java +++ b/ordinis-app/src/test/java/cloud/nstart/terravault/ordinis/app/e2e/AuthE2ETest.java @@ -113,16 +113,13 @@ class AuthE2ETest { .content(om.writeValueAsBytes(recBody))) .andExpect(status().is2xxSuccessful()); - // ⚠️ ИЗВЕСТНОЕ ПОВЕДЕНИЕ: writer-side endpoint /api/v1/dictionaries/{n}/records/{k} - // НЕ фильтрует по scope юзера. Public-юзер сейчас МОЖЕТ прочитать INTERNAL. - // Read-api (/api/v1/{n}/records/...) — фильтрует. Writer считается admin-only. - // - // Тест документирует текущее поведение. После security review (см. issue - // #scope-writer-filter) заменить на 403/404 expectation. + // Writer endpoint фильтрует по scope: PUBLIC-юзер не видит INTERNAL словарь. + // Возвращаем 404 (а не 403) — намеренно скрываем существование INTERNAL + // данных, как делает read-api. Защита от enumeration. String publicToken = JwtTestSupport.signJwt(List.of("ordinis:client:public"), "user-public"); mvc.perform(get("/api/v1/dictionaries/{n}/records/{k}", dictName, key) .header(HttpHeaders.AUTHORIZATION, "Bearer " + publicToken)) - .andExpect(status().isOk()); // TODO: должно быть isForbidden() после fix scope filter + .andExpect(status().isNotFound()); } @Test diff --git a/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java index c9f1e90..2c26e73 100644 --- a/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java +++ b/ordinis-auth/src/main/java/cloud/nstart/terravault/ordinis/auth/ScopeContext.java @@ -49,6 +49,19 @@ public class ScopeContext { return EnumSet.of(DataScope.PUBLIC); } + /** + * True если пользовательский scope видит данные {@code dataScope}. + * Использует {@link DataScope#visibleTo(DataScope)}: PUBLIC видит PUBLIC, + * INTERNAL видит PUBLIC+INTERNAL, RESTRICTED видит всё. + */ + public boolean canAccess(DataScope dataScope) { + if (dataScope == null) return true; + for (DataScope userScope : currentScopes()) { + if (dataScope.visibleTo(userScope)) return true; + } + return false; + } + public Set resolveForRead(String queryAsScope) { Authentication auth = SecurityContextHolder.getContext().getAuthentication(); if (auth instanceof JwtAuthenticationToken jwtAuth) { diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java index cc9b4a9..29cb327 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryDefinitionController.java @@ -1,11 +1,13 @@ package cloud.nstart.terravault.ordinis.restapi.web; +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.DataScope; import cloud.nstart.terravault.ordinis.restapi.dto.CreateDictionaryRequest; import cloud.nstart.terravault.ordinis.restapi.dto.DictionaryResponse; +import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -22,24 +24,41 @@ import java.util.List; public class DictionaryDefinitionController { private final DictionaryDefinitionService service; + private final ScopeContext scopeContext; - public DictionaryDefinitionController(DictionaryDefinitionService service) { + public DictionaryDefinitionController( + DictionaryDefinitionService service, ScopeContext scopeContext) { this.service = service; + this.scopeContext = scopeContext; } @GetMapping public List list() { - return service.findAll().stream().map(DictionaryResponse::from).toList(); + return service.findAll().stream() + .filter(d -> scopeContext.canAccess(d.getScope())) + .map(DictionaryResponse::from) + .toList(); } @GetMapping("/{name}") public DictionaryResponse get(@PathVariable String name) { - return DictionaryResponse.from(service.findByName(name)); + var d = service.findByName(name); + if (!scopeContext.canAccess(d.getScope())) { + throw OrdinisException.notFound( + "dictionary_not_found", "Dictionary not found: " + name); + } + return DictionaryResponse.from(d); } @PostMapping @ResponseStatus(HttpStatus.CREATED) public DictionaryResponse create(@Valid @RequestBody CreateDictionaryRequest req) { + DataScope newScope = req.scope(); + if (newScope != null && !scopeContext.canAccess(newScope)) { + throw OrdinisException.forbidden( + "scope_insufficient", + "Недостаточно прав scope для создания словаря с scope=" + newScope + "."); + } return DictionaryResponse.from(service.create(req)); } @@ -47,6 +66,18 @@ public class DictionaryDefinitionController { public DictionaryResponse update( @PathVariable String name, @Valid @RequestBody CreateDictionaryRequest req) { + var existing = service.findByName(name); + if (!scopeContext.canAccess(existing.getScope())) { + throw OrdinisException.notFound( + "dictionary_not_found", "Dictionary not found: " + name); + } + DataScope newScope = req.scope(); + if (newScope != null && newScope != existing.getScope() + && !scopeContext.canAccess(newScope)) { + throw OrdinisException.forbidden( + "scope_insufficient", + "Недостаточно прав scope для смены словаря на scope=" + newScope + "."); + } return DictionaryResponse.from(service.updateSchema(name, req)); } } diff --git a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java index 2a9af69..504b75e 100644 --- a/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java +++ b/ordinis-rest-api/src/main/java/cloud/nstart/terravault/ordinis/restapi/web/DictionaryRecordController.java @@ -1,12 +1,14 @@ package cloud.nstart.terravault.ordinis.restapi.web; +import cloud.nstart.terravault.ordinis.auth.ScopeContext; +import cloud.nstart.terravault.ordinis.domain.DataScope; import cloud.nstart.terravault.ordinis.restapi.dto.CreateRecordRequest; import cloud.nstart.terravault.ordinis.restapi.dto.RecordResponse; import cloud.nstart.terravault.ordinis.restapi.error.OrdinisException; +import cloud.nstart.terravault.ordinis.restapi.service.DictionaryDefinitionService; import cloud.nstart.terravault.ordinis.restapi.service.DictionaryRecordService; import jakarta.validation.Valid; import org.springframework.http.HttpStatus; -import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; @@ -26,9 +28,16 @@ import java.util.List; public class DictionaryRecordController { private final DictionaryRecordService service; + private final DictionaryDefinitionService definitionService; + private final ScopeContext scopeContext; - public DictionaryRecordController(DictionaryRecordService service) { + public DictionaryRecordController( + DictionaryRecordService service, + DictionaryDefinitionService definitionService, + ScopeContext scopeContext) { this.service = service; + this.definitionService = definitionService; + this.scopeContext = scopeContext; } @GetMapping("/{businessKey}") @@ -36,6 +45,7 @@ public class DictionaryRecordController { @PathVariable String name, @PathVariable String businessKey, @RequestParam(required = false) OffsetDateTime at) { + requireReadAccess(name); OffsetDateTime when = at == null ? OffsetDateTime.now() : at; return service.findActive(name, businessKey, when) .map(RecordResponse::from) @@ -48,6 +58,7 @@ public class DictionaryRecordController { public List getHistory( @PathVariable String name, @PathVariable String businessKey) { + requireReadAccess(name); return service.findHistory(name, businessKey).stream().map(RecordResponse::from).toList(); } @@ -56,6 +67,7 @@ public class DictionaryRecordController { public RecordResponse create( @PathVariable String name, @Valid @RequestBody CreateRecordRequest req) { + requireWriteAccess(name); return RecordResponse.from(service.create(name, req)); } @@ -64,6 +76,7 @@ public class DictionaryRecordController { @PathVariable String name, @PathVariable String businessKey, @Valid @RequestBody CreateRecordRequest req) { + requireWriteAccess(name); return RecordResponse.from(service.update(name, businessKey, req)); } @@ -74,6 +87,33 @@ public class DictionaryRecordController { @PathVariable String businessKey, @RequestParam(required = false) OffsetDateTime at, @RequestParam(required = false) String reason) { + requireWriteAccess(name); service.close(name, businessKey, at, reason); } + + /** + * 404 если scope словаря недоступен текущему пользователю — намеренно скрываем + * существование записи (security through obscurity), как делает read-api. + */ + private void requireReadAccess(String dictionaryName) { + DataScope dictScope = definitionService.findByName(dictionaryName).getScope(); + if (!scopeContext.canAccess(dictScope)) { + throw OrdinisException.notFound( + "dictionary_not_found", "Dictionary not found: " + dictionaryName); + } + } + + /** + * 403 для записи: пользователь должен иметь scope ≥ scope словаря. PUBLIC-юзер + * не имеет права создавать/менять/закрывать INTERNAL/RESTRICTED записи даже + * если знает имя словаря. + */ + private void requireWriteAccess(String dictionaryName) { + DataScope dictScope = definitionService.findByName(dictionaryName).getScope(); + if (!scopeContext.canAccess(dictScope)) { + throw OrdinisException.forbidden( + "scope_insufficient", + "Недостаточно прав scope для записи в словарь '" + dictionaryName + "'."); + } + } }