Início › Desafios › Desafio 10 › Gabarito

Gabarito · Desafio 10Nível: Intermediário

Gabarito: leia e atualize sem atravessar permissões

Tente implementar antes de continuar. O valor deste exercício está em separar três perguntas que parecem iguais: quais registros o usuário enxerga, quais campos ele pode ler e quais campos ele pode atualizar. with sharing responde só à primeira, e o contrato ainda exige que o método devolva vazio ou zero em vez de estourar exceção.

Spoiler à frente. A implementação completa e as decisões aparecem abaixo. Voltar para o desafio

O problema

Apex roda por padrão em system mode. Uma consulta comum devolve campos que o perfil não lê, e um update comum grava campos que o perfil não edita. Consultar e atualizar o payload direto expõe exatamente o que a interface padrão esconderia. O desafio pede segurança explícita e um resultado previsível quando a permissão não basta.

Há também uma questão de manutenção. Cada classe roda com o comportamento da versão da API em que foi salva, e o padrão implícito muda conforme o contexto de execução. Para saber se uma consulta sem modo declarado respeita FLS, quem revisa teria de abrir o -meta.xml e conferir a versão. Por isso a solução não depende do padrão implícito: toda consulta e todo DML declaram o modo em que rodam.

A decisão de desenho

A leitura cabe numa consulta só, porque WITH USER_MODE aplica objeto, campo e compartilhamento de uma vez e o contrato aceita lista vazia quando algo é barrado. A escrita pede três etapas separadas, cada uma com uma garantia própria. Primeiro, uma consulta em user mode decide quais IDs do payload o usuário enxerga. Depois, Security.stripInaccessible tira dos registros restantes os campos que ele não pode editar. Por fim, um DML parcial em user mode grava o que sobrou e devolve um resultado por linha, que vira a contagem.

Juntar essas etapas parece economizar código, mas cada uma responde a uma pergunta diferente. Um DML em user mode com o payload bruto rejeitaria o contato inteiro por causa de um único campo proibido, e o contrato pede que o telefone seja gravado mesmo quando o e-mail vem junto.

A solução completa

public with sharing class ContatoSeguroService {
    private static final Integer LIMITE = 200;

    public static List<Contact> listarPorConta(Id accountId) {
        if (accountId == null || !Schema.SObjectType.Contact.isAccessible()) {
            return new List<Contact>();
        }

        try {
            List<Contact> contatos = [
                SELECT Id, FirstName, LastName, Email, Phone, AccountId
                FROM Contact
                WHERE AccountId = :accountId
                WITH USER_MODE
                ORDER BY Id
                LIMIT :LIMITE
            ];
            return contatos;
        } catch (QueryException e) {
            return new List<Contact>();
        }
    }

    public static Integer atualizarContatos(List<Contact> entradas) {
        if (entradas == null || entradas.isEmpty()
            || !Schema.SObjectType.Contact.isUpdateable()) {
            return 0;
        }

        Set<Id> idsSolicitados = new Set<Id>();
        for (Contact entrada : entradas) {
            if (entrada != null && entrada.Id != null) {
                idsSolicitados.add(entrada.Id);
            }
        }
        if (idsSolicitados.isEmpty()) {
            return 0;
        }

        Set<Id> idsVisiveis;
        try {
            idsVisiveis = new Map<Id, Contact>([
                SELECT Id
                FROM Contact
                WHERE Id IN :idsSolicitados
                WITH USER_MODE
            ]).keySet();
        } catch (QueryException e) {
            return 0;
        }

        List<Contact> candidatos = new List<Contact>();
        for (Contact entrada : entradas) {
            if (entrada != null && idsVisiveis.contains(entrada.Id)) {
                candidatos.add(entrada);
            }
        }
        if (candidatos.isEmpty()) {
            return 0;
        }

        SObjectAccessDecision decisao = Security.stripInaccessible(
            AccessType.UPDATABLE,
            candidatos,
            true
        );
        List<SObject> seguros = decisao.getRecords();

        Database.SaveResult[] resultados = Database.update(
            seguros,
            false,
            AccessLevel.USER_MODE
        );

        Integer sucessos = 0;
        for (Database.SaveResult resultado : resultados) {
            if (resultado.isSuccess()) {
                sucessos++;
            }
        }
        return sucessos;
    }
}

Destrinchando parte por parte

1. A leitura prefere falhar fechada

A verificação inicial resolve a borda declarada no desafio antes de gastar consulta. Se a entrada não identifica uma conta ou o usuário não possui acesso de leitura ao objeto Contact, a resposta é uma lista vazia. A tela consegue tratar esse estado sem receber uma exceção interna.

if (accountId == null || !Schema.SObjectType.Contact.isAccessible()) {
    return new List<Contact>();
}

Retornar vazio é uma decisão do contrato deste exercício, pensada para um LWC que mostra "nenhum contato" sem quebrar a tela. Numa API REST usada por outro sistema, eu devolveria um erro de autorização explícito, porque o chamador precisa distinguir "conta sem contatos" de "você não tem acesso". Escolha uma das duas e escreva no método; um vazio que só aparece porque alguma exceção foi engolida no caminho é bug esperando acontecer.

2. USER_MODE cobre a operação de consulta

WITH USER_MODE faz a consulta aplicar permissões de objeto, FLS e regras de compartilhamento do usuário. Isso é mais amplo do que declarar a classe with sharing, que trata visibilidade de registros e deixa os campos selecionados sem checagem.

List<Contact> contatos = [
    SELECT Id, FirstName, LastName, Email, Phone, AccountId
    FROM Contact
    WHERE AccountId = :accountId
    WITH USER_MODE
    ORDER BY Id
    LIMIT :LIMITE
];

Se o usuário não puder ler um dos seis campos da seleção, a consulta em user mode lança QueryException em vez de devolver o campo em branco. O método captura QueryException e devolve vazio porque esse foi o comportamento de borda escolhido. Se o requisito fosse retornar os campos permitidos e remover somente os proibidos, o desenho seria outro: consulta em system mode seguida de stripInaccessible com READABLE. Deixe essa escolha documentada no método, para quem mantiver o código saber que o vazio é intencional.

O contrato não fixa um teto, então o limite de 200 é uma decisão desta solução, não uma regra do desafio. Sem ele, uma conta com 8 mil contatos mandaria os 8 mil para o navegador numa chamada só, e o heap de 6 MB da transação síncrona passaria a ser o limite real. Se a tela precisar de mais, o próximo passo é paginação. A ordenação por Id torna o resultado estável para diagnóstico e para uma futura estratégia de cursor.

3. O payload não decide quais registros serão alterados

Receber um Id válido não prova que o usuário pode acessar o registro. Antes de considerar a atualização, a classe consulta somente os IDs visíveis em user mode. O conjunto resultante funciona como uma fronteira de autorização: entradas cujo Id não aparece são descartadas.

Set<Id> idsVisiveis = new Map<Id, Contact>([
    SELECT Id
    FROM Contact
    WHERE Id IN :idsSolicitados
    WITH USER_MODE
]).keySet();

Essa etapa também evita confiar no LWC. Um usuário pode modificar uma requisição no navegador, reutilizar o endpoint ou enviar campos que a tela não renderiza. Uma checagem feita só no componente muda o que a tela mostra e deixa o método Apex aberto para quem chamar direto. A garantia precisa existir no servidor.

A consulta roda uma vez para todos os IDs. Consultar dentro do laço daria a mesma proteção, mas uma lista de 101 contatos estouraria o limite de 100 consultas por transação.

4. stripInaccessible remove campos antes do DML

Security.stripInaccessible recebe a intenção da operação. Com UPDATABLE, ele remove dos registros os campos que o usuário não pode atualizar. O terceiro argumento ativa a verificação de CRUD no objeto raiz. Só a lista retornada pela decisão pode seguir para persistência.

SObjectAccessDecision decisao = Security.stripInaccessible(
    AccessType.UPDATABLE,
    candidatos,
    true
);
List<SObject> seguros = decisao.getRecords();

Esse detalhe costuma produzir um bug silencioso. Chamar stripInaccessible e depois executar DML em candidatos não protege nada. O método não altera a lista original como efeito lateral confiável. A solução usa explicitamente decisao.getRecords().

A decisão também informa o que foi cortado: decisao.getRemovedFields() devolve um mapa com o nome do objeto e os campos removidos (no teste do verifier, Email). Registrar esses nomes, sem os valores, mostra quantas vezes o LWC tentou gravar um campo que o perfil não edita. O contrato atual não pede isso; basta remover o campo e gravar o resto.

5. O DML informa modo e permite sucesso parcial

O parâmetro false escolhe allOrNone desativado. Um contato que falha por validação não desfaz os outros registros válidos. Cada SaveResult informa se aquela linha foi gravada, e o retorno soma apenas sucessos reais.

Database.SaveResult[] resultados = Database.update(
    seguros,
    false,
    AccessLevel.USER_MODE
);

AccessLevel.USER_MODE torna a intenção de segurança explícita no DML. Mesmo depois da sanitização, a operação ainda aplica o contexto do usuário. A sanitização remove os campos proibidos sem derrubar o registro. O modo de acesso é a rede de segurança: se uma mudança futura esquecer a sanitização, o DML falha em vez de gravar o campo proibido.

Em um serviço real, os erros dos SaveResult deveriam ser convertidos em um resultado estruturado por registro. Este exercício retorna apenas a contagem porque essa é a assinatura definida. Não grave mensagens completas contendo payload ou dados pessoais em log.

Erros comuns

  • Confiar apenas em with sharing. A classe limita registros, mas a consulta e o DML continuam sem FLS: o Email proibido é gravado do mesmo jeito.
  • Sanitizar e usar a lista original. A decisão segura é calculada, porém o DML recebe novamente os dados não filtrados.
  • Usar o Id recebido como autorização. Um payload adulterado alcança registros que a tela nunca mostrou.
  • Executar update direto. Uma única falha reverte toda a lista e impede contar sucessos de forma honesta.
  • Capturar Exception genérica. O método esconde defeitos de programação junto com falhas esperadas de permissão.
  • Testar somente como administrador. O teste passa justamente porque o usuário mais privilegiado não reproduz a ameaça.

Como saber se a sua solução passou

Rode o WL_Desafio10Verifier, publicado na página do desafio, em Apex Test Execution. A classe é autocontida: cria um usuário de perfil sem acesso a Contact, dá a ele por permission set edição em Phone e só leitura em Email, e compartilha apenas uma de duas contas. Cada asserção cita a regra que confere. A Regra 1 espera dois contatos na conta compartilhada, nenhum com Title preenchido, e zero na outra. A Regra 2 espera o telefone novo e o e-mail original. A Regra 3 espera o contato da conta oculta intacto. A Regra 4 espera contagem 1, porque o contato com LastName vazio falha na validação. A Regra 5 espera zero e lista vazia para as entradas nulas e vazias.

Se a falha começar por Preparacao, o problema está na org e não na solução: o padrão de compartilhamento de Conta precisa ser Privado, com Contato controlado pelo pai, e precisa existir um perfil Salesforce sem acesso a Contact. Qualquer outra mensagem aponta pelo número a regra que quebrou. Um caso fica de fora do verifier, o usuário sem nenhum acesso ao objeto Contact. Para conferir, acrescente um teste com um usuário desse perfil sem permission set: a leitura deve devolver lista vazia e a atualização, zero, sem exceção.

Também confira os limites. A implementação usa duas consultas e um DML para uma lista inteira, independentemente de receber um ou duzentos contatos. Não existe SOQL nem DML dentro dos laços.

Um passo além

O primeiro avanço é trocar a contagem por um DTO que informe sucesso e motivo por registro, sem expor dados proibidos. Com ele, o LWC consegue marcar em vermelho a linha que falhou ("Sobrenome obrigatório") e manter as outras salvas. O segundo é registrar os campos de getRemovedFields() e as falhas por regra de validação.

Revise também o conteúdo sobre CRUD, FLS e sharing no Apex e o desafio anterior de SOQL dinâmico seguro. Injection, visibilidade de registro e permissão de campo são ameaças diferentes, embora apareçam na mesma operação.

Se a atualização disparar automações complexas, aplique o mesmo raciocínio de volume do Desafio 01, sobre bulkificação. Um trigger de Contact que faz SOQL por registro derruba a atualização de 200 contatos, com ou sem user mode. E não resolva esse estouro trocando para without sharing: isso devolve ao atendente os contatos que a primeira etapa escondeu.

O aprendizado que fica: acesso seguro é uma cadeia. A consulta de IDs delimita registros, o stripInaccessible delimita campos e o DML em user mode barra o que escapar. Se qualquer uma dessas etapas confiar no que o LWC mandou, um Id ou campo editado no navegador passa direto.