Início › Desafios › Desafio 10 › Gabarito
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
Emailproibido é 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.