Início › Desafios › Desafio 10

Desafio 10 Nível: Intermediário

Leia e atualize contatos sem atravessar as permissões

Rodando como administrador, a classe passa em todos os testes. Reescreva os dois métodos Apex para que um atendente com perfil restrito não leia nem altere, por essa tela, um contato ou campo que a página de registro padrão esconderia dele.

CRUDFLSSharingUSER_MODEstripInaccessibleTestes de permissão

O cenário

A Vetra Atendimento, empresa fictícia, tem um LWC que lista os contatos de uma conta e deixa o atendente corrigir telefone e e-mail. O componente chama dois métodos Apex: um para listar e outro para salvar a lista de alterações. A versão original consulta e grava tudo o que recebe, e o único teste rodava como administrador.

Com um usuário restrito, isso abre dois buracos. A listagem devolve contatos de contas que ele não enxerga. O método de salvar grava qualquer campo que vier no payload, inclusive os que o perfil dele não pode editar. Esse payload sai do navegador e pode trazer qualquer Id ou campo que alguém acrescente na requisição. Trate a lista como entrada não confiável.

O que você deve construir

Implemente a classe ContatoSeguroService com a assinatura abaixo. O LWC já consome esses métodos, então o nome da classe, os nomes dos métodos, os parâmetros e os tipos de retorno não podem mudar.

public with sharing class ContatoSeguroService {
    public static List<Contact> listarPorConta(Id accountId)
    public static Integer atualizarContatos(List<Contact> entradas)
}

O contrato de cada método:

  • listarPorConta(accountId) retorna os contatos com AccountId igual ao recebido que o usuário corrente pode ver, com exatamente estes campos: Id, FirstName, LastName, Email, Phone e AccountId. Não acrescente outros campos à consulta.
  • Para listarPorConta, "vazio" significa uma List<Contact> sem elementos, nunca null. Retorne vazio quando accountId for nulo, quando o usuário não puder ler o objeto Contact, quando a conta não estiver compartilhada com ele e quando a consulta for barrada por falta de permissão de leitura em algum dos seis campos.
  • atualizarContatos(entradas) recebe contatos com Id e os campos alterados, grava somente os registros que o usuário enxerga e somente os campos que ele pode editar, e retorna quantos registros foram gravados com sucesso.
  • Para atualizarContatos, "vazio" significa retornar 0. Isso vale para lista nula, lista vazia, lista sem nenhum Id preenchido, lista em que nenhum Id é visível ao usuário e usuário sem permissão de edição em Contact. Elementos nulos dentro da lista são ignorados.

A verificação é feita pela classe de teste WL_Desafio10Verifier, publicada no fim desta página. Ela roda como um usuário restrito, e cada asserção indica qual das cinco regras abaixo está conferindo.

Regras de aceite

  • Retorne em listarPorConta somente contatos visíveis ao usuário e somente os seis campos do contrato. Uma conta não compartilhada devolve lista vazia. Um campo fora do contrato e sem permissão de leitura, como Title, não pode voltar preenchido.
  • Remova do payload, antes do DML, todo campo que o usuário não pode editar. Os campos permitidos do mesmo contato continuam sendo gravados.
  • Descarte da atualização todo Id de contato que o usuário não enxerga. Esse contato precisa permanecer intacto depois da chamada.
  • Grave com processamento parcial e retorne apenas a quantidade de sucessos. Um contato que falha numa validação não pode reverter os demais nem entrar na contagem.
  • Borda: retorne 0 para lista nula ou vazia e lista vazia para accountId nulo. Garanta o mesmo resultado para usuário sem acesso ao objeto Contact, sem lançar exceção em nenhum desses casos.

O que este desafio avalia

  • Se você separa acesso a registro de acesso a objeto e campo.
  • Se você entende quando usar operações em user mode e quando sanitizar registros.
  • Se o payload é tratado como entrada não confiável.
  • Se a estratégia de erro preserva sucessos válidos sem esconder o resultado.
Travei. Me dá uma direção sem entregar a resposta

Responda primeiro a estas perguntas. Uma classe with sharing já confere FLS numa consulta? Se o payload traz um campo proibido junto com um permitido, você quer rejeitar o contato inteiro ou gravar só a parte permitida? E como uma falha de validação num contato fica isolada dos outros?

Termos de busca: "Apex user mode vs system mode", "Security.stripInaccessible", "Database DML allOrNone SaveResult" e "Apex permission set tests System.runAs".

Uma pista de desenho: saber quais registros o usuário enxerga e saber quais campos ele edita são perguntas diferentes. Responda cada uma numa etapa própria.

Como testar

Teste sempre com um usuário que tenha menos acesso que você. Como administrador, o código original passa em todas as regras, então um teste rodado só com o seu usuário não prova nada sobre permissão.

A preparação mínima é esta: dentro de System.runAs, use um usuário de perfil sem acesso a Contact que receba, por um permission set criado no próprio teste, leitura e edição no objeto Contact, edição em Phone e apenas leitura em Email. Crie duas contas, compartilhe só uma delas com esse usuário por AccountShare e deixe a outra fora. Com isso montado, cubra estes cenários no seu teste:

  • Listar a conta compartilhada devolve os contatos dela, sem nenhum campo fora do contrato.
  • Listar a conta não compartilhada devolve lista vazia.
  • Enviar Phone e Email novos para o mesmo contato grava só o Phone.
  • Enviar o Id de um contato da conta não compartilhada deixa esse contato como estava.
  • Um contato que quebra uma validação, como LastName vazio, não desfaz os outros, e a contagem considera só os gravados.
  • Lista nula e lista vazia devolvem 0; accountId nulo devolve lista vazia.

Você não precisa escrever essa preparação do zero. O WL_Desafio10Verifier da seção seguinte é autocontido: cria o usuário, o permission set e o compartilhamento dentro do próprio teste, cobre os seis cenários acima e não depende de nenhuma classe auxiliar. Use-o como sua classe de teste e acrescente casos se quiser, por exemplo um usuário sem nenhum acesso a Contact.

Validação opcional na sua org

Deixe um teste verificável, sem entregar seu código ao site

Depois de criar a ContatoSeguroService, adicione a classe de teste abaixo com o nome exato WL_Desafio10Verifier e execute esse teste na Developer Org ou no Trailhead Playground. O conector procura somente o nome da classe e o resultado da execução mais recente pela Tooling API. O corpo da sua solução não é consultado nem armazenado.

Este teste é autocontido e não depende de nenhuma classe auxiliar. Ele cria um usuário temporário, um permission set com acesso mínimo e o compartilhamento de uma única conta, e tudo é desfeito ao fim da execução. Antes de rodar, confira duas preparações da org: o padrão de compartilhamento de Conta precisa ser Privado, com Contato controlado pelo pai, e a org precisa ter um perfil Salesforce sem acesso a Contact, como o perfil de acesso mínimo. Se uma delas faltar, o teste falha com uma mensagem que começa por Preparacao, e o problema está na org, não na sua solução.

@IsTest
private class WL_Desafio10Verifier {
    @IsTest
    static void validarDesafio() {
        Organization org = [SELECT DefaultAccountAccess, DefaultContactAccess FROM Organization LIMIT 1];
        Boolean contatoPrivado = org.DefaultContactAccess == 'ControlledByParent'
            && org.DefaultAccountAccess == 'None';
        System.assert(contatoPrivado,
            'Preparacao: deixe o OWD de Conta como Privado e Contato como Controlado pelo pai');

        User restrito = criarUsuarioRestrito();

        Account contaVisivel = new Account(Name = 'Conta compartilhada');
        Account contaOculta = new Account(Name = 'Conta nao compartilhada');
        insert new List<Account>{ contaVisivel, contaOculta };

        Contact visivel = new Contact(LastName = 'Visivel', AccountId = contaVisivel.Id,
            Email = 'original@exemplo.com', Title = 'Diretora');
        Contact invalido = new Contact(LastName = 'Vai falhar', AccountId = contaVisivel.Id);
        Contact oculto = new Contact(LastName = 'Oculto', AccountId = contaOculta.Id,
            Phone = '1100000000');
        insert new List<Contact>{ visivel, invalido, oculto };

        // compartilha somente a primeira conta; com Contato controlado pelo pai,
        // o contato herda o nivel da conta e ContactAccessLevel nao e gravavel
        insert new AccountShare(AccountId = contaVisivel.Id, UserOrGroupId = restrito.Id,
            AccountAccessLevel = 'Edit',
            OpportunityAccessLevel = 'None', CaseAccessLevel = 'None');

        List<Contact> listados;
        List<Contact> deOutraConta;
        List<Contact> contaNula;
        Integer atualizados;
        Integer paraNulo;
        Integer paraVazio;

        Test.startTest();
        System.runAs(restrito) {
            listados = ContatoSeguroService.listarPorConta(contaVisivel.Id);
            deOutraConta = ContatoSeguroService.listarPorConta(contaOculta.Id);
            atualizados = ContatoSeguroService.atualizarContatos(new List<Contact>{
                new Contact(Id = visivel.Id, Phone = '5511999990000', Email = 'nao-pode@exemplo.com'),
                new Contact(Id = invalido.Id, LastName = null),
                new Contact(Id = oculto.Id, Phone = '0')
            });
            paraNulo = ContatoSeguroService.atualizarContatos(null);
            paraVazio = ContatoSeguroService.atualizarContatos(new List<Contact>());
            contaNula = ContatoSeguroService.listarPorConta(null);
        }
        Test.stopTest();

        Contact visivelDepois = [SELECT Phone, Email FROM Contact WHERE Id = :visivel.Id];
        Contact ocultoDepois = [SELECT Phone FROM Contact WHERE Id = :oculto.Id];
        Boolean campoVazou = false;
        for (Contact contato : listados) {
            if (contato.isSet('Title')) {
                campoVazou = true;
            }
        }

        System.assertEquals(2, listados.size(), 'Regra 1: le somente os contatos compartilhados');
        System.assert(!campoVazou, 'Regra 1: campo sem permissao de leitura nao pode voltar');
        System.assertEquals(0, deOutraConta.size(), 'Regra 1: conta nao compartilhada devolve vazio');
        System.assertEquals('5511999990000', visivelDepois.Phone, 'Regra 2: campo permitido e gravado');
        System.assertEquals('original@exemplo.com', visivelDepois.Email,
            'Regra 2: campo sem permissao de edicao nao chega ao DML');
        System.assertEquals('1100000000', ocultoDepois.Phone, 'Regra 3: contato invisivel continua intacto');
        System.assertEquals(1, atualizados, 'Regra 4: conta somente a atualizacao bem-sucedida');
        System.assertEquals(0, paraNulo, 'Regra 5: lista nula devolve zero');
        System.assertEquals(0, paraVazio, 'Regra 5: lista vazia devolve zero');
        System.assertEquals(0, contaNula.size(), 'Regra 5: conta nula devolve lista vazia');
    }

    private static User criarUsuarioRestrito() {
        // perfil sem acesso a Contact; o acesso vem so do permission set criado aqui
        Set<Id> perfisComAcesso = new Set<Id>();
        for (ObjectPermissions permissao : [
            SELECT Parent.ProfileId FROM ObjectPermissions
            WHERE Parent.IsOwnedByProfile = true
            AND ((SobjectType = 'Contact' AND PermissionsRead = true)
                OR (SobjectType = 'Account' AND PermissionsViewAllRecords = true))
        ]) {
            perfisComAcesso.add(permissao.Parent.ProfileId);
        }
        for (FieldPermissions permissao : [
            SELECT Parent.ProfileId FROM FieldPermissions
            WHERE Parent.IsOwnedByProfile = true
            AND ((Field = 'Contact.Title' AND PermissionsRead = true)
                OR (Field = 'Contact.Email' AND PermissionsEdit = true))
        ]) {
            perfisComAcesso.add(permissao.Parent.ProfileId);
        }
        List<Profile> perfis = [
            SELECT Id FROM Profile
            WHERE UserType = 'Standard' AND UserLicense.Name = 'Salesforce'
            AND PermissionsViewAllData = false AND PermissionsModifyAllData = false
            AND Id NOT IN :perfisComAcesso
            LIMIT 1
        ];
        System.assert(!perfis.isEmpty(),
            'Preparacao: a org precisa de um perfil Salesforce sem acesso a Contact, como o de acesso minimo');

        String sufixo = String.valueOf(Crypto.getRandomInteger()).replace('-', '');
        User restrito = new User(
            LastName = 'Restrito Desafio 10',
            Alias = 'wld10',
            Email = 'restrito.d10@exemplo.com',
            Username = 'restrito.d10.' + sufixo + '@exemplo.com',
            ProfileId = perfis[0].Id,
            EmailEncodingKey = 'UTF-8',
            LanguageLocaleKey = 'pt_BR',
            LocaleSidKey = 'pt_BR',
            TimeZoneSidKey = 'America/Sao_Paulo'
        );
        PermissionSet acesso = new PermissionSet(
            Name = 'WL_Desafio10_Restrito',
            Label = 'WL Desafio 10 Restrito'
        );

        System.runAs(new User(Id = UserInfo.getUserId())) {
            insert restrito;
            insert acesso;
            insert new List<ObjectPermissions>{
                new ObjectPermissions(ParentId = acesso.Id, SobjectType = 'Account',
                    PermissionsRead = true, PermissionsEdit = true),
                new ObjectPermissions(ParentId = acesso.Id, SobjectType = 'Contact',
                    PermissionsRead = true, PermissionsEdit = true)
            };
            insert new List<FieldPermissions>{
                campo(acesso.Id, 'Contact.Phone', true),
                campo(acesso.Id, 'Contact.Email', false),
                campo(acesso.Id, 'Contact.AccountId', false)
            };
            insert new PermissionSetAssignment(PermissionSetId = acesso.Id, AssigneeId = restrito.Id);
        }
        return restrito;
    }

    private static FieldPermissions campo(Id permissionSetId, String nomeCampo, Boolean edita) {
        return new FieldPermissions(ParentId = permissionSetId, SobjectType = 'Contact',
            Field = nomeCampo, PermissionsRead = true, PermissionsEdit = edita);
    }
}
  1. Crie a classe com o nome indicado e rode WL_Desafio10Verifier em Apex Test Execution.
  2. Volte para Meu progresso e conecte a mesma org.
  3. Use o botão Validar desafio 10. A consulta é somente leitura e não executa nada na org.

Ver o tutorial guiado para conectar e validar →

Gabarito comentado

Veja a solução em camadas, o motivo da consulta de IDs, a sanitização antes do DML e por que declarar o modo de acesso em vez de depender do default da versão da API.

Ver o gabarito

O ponto que fica: with sharing decide quais contatos aparecem; não decide se o Email pode ser gravado. Cada garantia precisa estar escrita no código e coberta por um teste em System.runAs com um usuário que tenha menos acesso que você.