SQL Isolation Anti-IDOR Authorization Spring Data JPA
Módulo 3 de 3

Isolamento Lógico de Dados ao Nível da Query

O JWT prova quem é o utilizador. O Cookie garante que o token viaja em segurança. Mas nenhuma destas camadas responde à pergunta mais importante de autorização: este recurso específico pertence a quem está autenticado? Este módulo explica porque a autorização tem de ser estrutural ao nível da query SQL, e como implementá-la em Spring Data JPA de forma que seja impossível de esquecer.

Autenticação vs. Autorização Autenticação responde a "quem és tu?" — é o domínio do JWT. Autorização responde a "podes aceder a isto?" — é o domínio deste módulo. São problemas distintos que requerem soluções distintas. Confundi-los é a origem direta da vulnerabilidade IDOR.

O Problema Revisitado: Autorização Ingénua

O padrão mais comum de IDOR não resulta de ignorância de segurança — resulta de uma separação incorreta de responsabilidades. O developer valida o JWT corretamente, extrai o user id, e depois faz a query apenas pelo id do recurso, confiando que "quem tem o token válido tem acesso".

// ✗ VULNERÁVEL — autorização ingénua
@GetMapping("/documents/{id}")
public ResponseEntity<Document> getDocument(
        @PathVariable Long id,
        @AuthenticationPrincipal AuthenticatedUser user) {

    // userId está disponível mas não é usado na query
    Document doc = documentRepository.findById(id)
        .orElseThrow(() -> new ResponseStatusException(NOT_FOUND));

    // Verificação tardia — ERRADA: o recurso já foi lido da BD
    if (!doc.getOwnerId().equals(user.getId())) {
        throw new ResponseStatusException(FORBIDDEN);
    }

    return ResponseEntity.ok(doc);
}
Porquê a verificação tardia é problemosa Mesmo que o resultado final seja correto (403 após a leitura), este padrão lê dados da BD que nunca deviam ter saído do storage. É também frágil — uma refatoração remove o if e a vulnerabilidade reaparece silenciosamente. A autorização estrutural na query elimina ambos os problemas.

A Solução: Dois Filtros na Query

A query correta filtra por id e owner_id simultaneamente. Se o recurso não pertence ao utilizador autenticado, a query devolve zero resultados — e o servidor responde 404.

-- ✓ SEGURO — isolamento lógico na query
-- owner_id = 99 vem do JWT, nunca do cliente

SELECT * FROM documents
WHERE  id       = 6
  AND  owner_id = 99;

-- Documento 6 pertence ao user 12 → 0 resultados → 404 Not Found
-- Documento 6 pertence ao user 99 → 1 resultado  → 200 OK
-- O atacante não sabe se o documento 6 existe — enumeração impossível

Implementação com Spring Data JPA

O Spring Data JPA permite expressar este padrão de forma declarativa no repositório. A assinatura do método torna impossível chamar a query sem fornecer o ownerId — a autorização fica estruturalmente acoplada à leitura.

@Repository
public interface DocumentRepository extends JpaRepository<Document, Long> {

    // Leitura única: só devolve se id E ownerId coincidirem
    Optional<Document> findByIdAndOwnerId(Long id, Long ownerId);

    // Listagem: só documentos do utilizador autenticado
    List<Document> findAllByOwnerId(Long ownerId);

    // Update: WHERE id = ? AND owner_id = ? — nunca atualiza o que não é seu
    @Modifying
    @Query("UPDATE Document d SET d.title = :title, d.content = :content " +
           "WHERE d.id = :id AND d.ownerId = :ownerId")
    int updateByIdAndOwnerId(@Param("id") Long id,
                              @Param("ownerId") Long ownerId,
                              @Param("title") String title,
                              @Param("content") String content);

    // Delete: WHERE id = ? AND owner_id = ? — nunca apaga o que não é seu
    @Modifying
    @Query("DELETE FROM Document d WHERE d.id = :id AND d.ownerId = :ownerId")
    int deleteByIdAndOwnerId(@Param("id") Long id,
                              @Param("ownerId") Long ownerId);
}
@RestController
@RequestMapping("/api/documents")
public class DocumentController {

    private final DocumentRepository repo;

    @GetMapping("/{id}")
    public ResponseEntity<Document> get(
            @PathVariable Long id,
            @AuthenticationPrincipal AuthenticatedUser user) {

        return repo.findByIdAndOwnerId(id, user.getId())
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    @PutMapping("/{id}")
    public ResponseEntity<Void> update(
            @PathVariable Long id,
            @RequestBody DocumentUpdateRequest body,
            @AuthenticationPrincipal AuthenticatedUser user) {

        int updated = repo.updateByIdAndOwnerId(
            id, user.getId(), body.title(), body.content());

        return updated > 0
            ? ResponseEntity.noContent().build()
            : ResponseEntity.notFound().build();
    }

    @DeleteMapping("/{id}")
    public ResponseEntity<Void> delete(
            @PathVariable Long id,
            @AuthenticationPrincipal AuthenticatedUser user) {

        int deleted = repo.deleteByIdAndOwnerId(id, user.getId());

        return deleted > 0
            ? ResponseEntity.noContent().build()
            : ResponseEntity.notFound().build();
    }
}
O retorno int nas operações mutáveis Os métodos @Modifying devolvem o número de linhas afetadas. Se o valor for 0, o recurso não existe ou não pertence ao utilizador — ambos os casos mapeiam para 404. Esta convenção evita confirmar ao atacante a existência do recurso.

O Modelo de Dados: Owner como Coluna Obrigatória

O isolamento lógico pressupõe que todas as entidades que requerem autorização por posse têm uma coluna owner_id com constraint NOT NULL.

-- Schema SQL — owner_id NOT NULL é obrigatório
CREATE TABLE documents (
    id         BIGSERIAL    PRIMARY KEY,
    owner_id   BIGINT       NOT NULL REFERENCES users(id) ON DELETE CASCADE,
    title      VARCHAR(255) NOT NULL,
    content    TEXT,
    created_at TIMESTAMPTZ  DEFAULT NOW(),
    updated_at TIMESTAMPTZ  DEFAULT NOW()
);

-- Índice composto: acelera as queries de isolamento lógico
CREATE INDEX idx_documents_owner ON documents(owner_id, id);
@Entity
@Table(name = "documents")
public class Document {

    @Id
    @GeneratedValue(strategy = GenerationType.IDENTITY)
    private Long id;

    @Column(name = "owner_id", nullable = false)
    private Long ownerId;

    @Column(nullable = false)
    private String title;

    private String content;
}

Role-Based vs. Ownership-Based Access Control

RBAC — Role-Based
Baseado em Papel / Função

"Utilizadores com role ADMIN podem aceder a qualquer recurso." Controlado pelo claim role no JWT. Implementado com @PreAuthorize ou SecurityFilterChain.

OBAC — Ownership-Based
Baseado em Posse

"Utilizadores só acedem ao que lhes pertence." Controlado por AND owner_id = ? na query. Não existe claim no JWT para isto — é uma propriedade dos dados.

// Combinação RBAC + OBAC no mesmo endpoint
@GetMapping("/{id}")
public ResponseEntity<Document> get(
        @PathVariable Long id,
        @AuthenticationPrincipal AuthenticatedUser user) {

    // RBAC: admins vêem qualquer documento
    if (user.getRole().equals("ADMIN")) {
        return repo.findById(id)
            .map(ResponseEntity::ok)
            .orElse(ResponseEntity.notFound().build());
    }

    // OBAC: utilizadores regulares só vêem os seus
    return repo.findByIdAndOwnerId(id, user.getId())
        .map(ResponseEntity::ok)
        .orElse(ResponseEntity.notFound().build());
}

Partilha de Recursos: Quando o Owner Não é Suficiente

Em sistemas com partilha (ex: documentos colaborativos, equipas), o modelo owner_id simples não cobre todos os cenários. A extensão natural é uma tabela de permissões explícitas.

-- Tabela de permissões explícitas para partilha
CREATE TABLE document_permissions (
    document_id BIGINT      NOT NULL REFERENCES documents(id) ON DELETE CASCADE,
    user_id     BIGINT      NOT NULL REFERENCES users(id)     ON DELETE CASCADE,
    permission  VARCHAR(20) NOT NULL,  -- 'READ', 'WRITE', 'ADMIN'
    PRIMARY KEY (document_id, user_id)
);

-- Query com permissões explícitas
SELECT d.* FROM documents d
WHERE  d.id = 6
  AND  (
    d.owner_id = 99
    OR EXISTS (
      SELECT 1 FROM document_permissions dp
      WHERE  dp.document_id = d.id
        AND  dp.user_id    = 99
        AND  dp.permission IN ('READ', 'WRITE', 'ADMIN')
    )
  );

Os Três Módulos: Visão Unificada

MóduloTecnologiaGaranteDepende de
1 · JWT HMAC-SHA256 / RSA Identidade verificável, stateless, escalável Chave secreta segura, expiração curta
2 · Cookie HttpOnly + Secure + SameSite Token opaco ao JS, transporte HTTPS, CSRF mitigado Módulo 1 para ter algo a transportar
3 · SQL WHERE id = ? AND owner_id = ? Autorização por posse, anti-enumeração, anti-IDOR Módulo 1 para extrair o owner_id do token
Defense in Depth XSS não rouba o token (Módulo 2). Mesmo com o token, não lê dados alheios (Módulo 3). Mesmo que a BD seja comprometida, as identidades são verificáveis por assinatura criptográfica (Módulo 1). As três camadas são complementares — não redundantes.