InícioDesafiosDesafio 07 › Gabarito

Gabarito · Desafio 07 Nível: Avançado

Gabarito: por que o usuário não vê o registro

Esta página destrincha a solução do sétimo desafio. Se você ainda não tentou, vale abrir uma org, criar um registro, brincar com o OWD e as regras de compartilhamento e só depois voltar aqui. Depurar visibilidade com o objeto na frente ensina muito mais do que ler o gabarito de primeira.

Spoiler à frente. A solução completa está logo abaixo. Voltar para o desafio

O problema em uma frase

Saber se um usuário vê um registro é fácil: o Salesforce entrega isso pronto no objeto UserRecordAccess. O difícil, e o que o desafio cobra, é saber por que. O erro que a org devolve quando o acesso falta é apenas Insufficient Privileges, uma mensagem que não diferencia OWD privado, dono trocado, regra ausente ou compartilhamento apagado. O diagnóstico precisa juntar duas fontes: o veredito, que mora em UserRecordAccess, e a explicação, que mora nas linhas de compartilhamento com seu campo RowCause.

A virada de chave é esta: não reimplemente a lógica de sharing. Ela é complexa demais (OWD, hierarquia, regras, equipes, acessos implícitos de pai e filho) para reproduzir sem bug. Pergunte à plataforma o veredito e use as tabelas de share só para nomear a origem.

A solução completa

public with sharing class AcessoDiagnostico {

    // Resultado do diagnostico: o veredito, o nivel e os motivos legiveis.
    public class Diagnostico {
        public Boolean temAcesso;
        public String nivelMaximo;
        public List<String> motivos;

        public Diagnostico() {
            this.temAcesso = false;
            this.nivelMaximo = 'None';
            this.motivos = new List<String>();
        }
    }

    // Traduz o RowCause tecnico para linguagem de diagnostico.
    private static final Map<String, String> CAUSAS = new Map<String, String>{
        'Owner'          => 'E dono do registro',
        'Manual'         => 'Compartilhamento manual',
        'Rule'           => 'Regra de compartilhamento',
        'Team'           => 'Equipe (account ou opportunity team)',
        'Territory'      => 'Territorio',
        'ImplicitChild'  => 'Acesso implicito por registro filho',
        'ImplicitParent' => 'Acesso implicito por registro pai'
    };

    public static Diagnostico diagnosticar(Id userId, Id recordId) {
        Diagnostico d = new Diagnostico();

        // Entrada incompleta: nao consulta, nao lanca, devolve sem acesso.
        if (userId == null || recordId == null) {
            d.motivos.add('Entrada incompleta: informe userId e recordId');
            return d;
        }

        // UserRecordAccess e a fonte de verdade do veredito.
        // Uma consulta responde SE ha leitura e ate que nivel.
        List<UserRecordAccess> acesso = [
            SELECT HasReadAccess, MaxAccessLevel
            FROM UserRecordAccess
            WHERE UserId = :userId AND RecordId = :recordId
        ];

        if (acesso.isEmpty() || acesso[0].HasReadAccess != true) {
            d.motivos.add('Sem acesso: OWD restrito e nenhuma regra, hierarquia ou compartilhamento alcanca este usuario');
            return d;
        }

        d.temAcesso = true;
        d.nivelMaximo = acesso[0].MaxAccessLevel;

        // O objeto do registro sai do proprio Id, sem hardcode.
        String objeto = recordId.getSObjectType().getDescribe().getName();

        // Dono e caso a parte: nem sempre gera linha de share visivel.
        Boolean ehDono = false;
        try {
            List<SObject> reg = Database.query(
                'SELECT OwnerId FROM ' + objeto + ' WHERE Id = :recordId'
            );
            if (!reg.isEmpty() && userId == (Id) reg[0].get('OwnerId')) {
                ehDono = true;
                d.motivos.add(CAUSAS.get('Owner'));
            }
        } catch (Exception e) {
            // Objeto sem OwnerId (raro) nao interrompe o diagnostico.
        }

        // As linhas diretas de compartilhamento explicam o resto do acesso.
        // O nome da tabela varia: AccountShare (padrao) x MeuObjeto__Share (custom).
        try {
            Boolean custom = objeto.endsWith('__c');
            String tabela = custom ? objeto.replace('__c', '__Share') : objeto + 'Share';
            String campoPai = custom ? 'ParentId' : objeto + 'Id';

            String soql =
                'SELECT RowCause FROM ' + tabela +
                ' WHERE ' + campoPai + ' = :recordId AND UserOrGroupId = :userId';

            for (SObject linha : Database.query(soql)) {
                String rc = String.valueOf(linha.get('RowCause'));
                if (rc == 'Owner' && ehDono) { continue; }
                d.motivos.add(CAUSAS.containsKey(rc) ? CAUSAS.get(rc) : ('Compartilhamento: ' + rc));
            }
        } catch (Exception e) {
            // Objeto sem tabela de share (OWD publico, por exemplo): sem linha direta.
        }

        // Tem acesso mas nenhuma linha direta: veio por caminho que nao gera
        // registro para o usuario (hierarquia de papeis, grupo, OWD publico).
        if (d.motivos.isEmpty()) {
            d.motivos.add('Acesso indireto: hierarquia de papeis, grupo publico ou OWD, sem compartilhamento direto para o usuario');
        }

        return d;
    }
}

Destrinchando parte por parte

1. O resultado que carrega o motivo, não só o booleano

public class Diagnostico {
    public Boolean temAcesso;
    public String nivelMaximo;
    public List<String> motivos;

    public Diagnostico() {
        this.temAcesso = false;
        this.nivelMaximo = 'None';
        this.motivos = new List<String>();
    }
}

A escolha de devolver uma classe, e não um Boolean, é a decisão de design mais importante do desafio. Um método que responde só "vê" ou "não vê" obriga quem investiga a continuar caçando o porquê na mão. O Diagnostico transporta a resposta e a explicação juntas.

Inicializar tudo no construtor (false, 'None', lista vazia) elimina uma classe inteira de bug: o método pode retornar em vários pontos, e em nenhum deles motivos será null. Quem consome nunca precisa checar nulo antes de iterar.

2. O veredito vem de UserRecordAccess, não de você

List<UserRecordAccess> acesso = [
    SELECT HasReadAccess, MaxAccessLevel
    FROM UserRecordAccess
    WHERE UserId = :userId AND RecordId = :recordId
];

if (acesso.isEmpty() || acesso[0].HasReadAccess != true) {
    d.motivos.add('Sem acesso: OWD restrito e nenhuma regra, hierarquia ou compartilhamento alcanca este usuario');
    return d;
}

UserRecordAccess é o objeto que a própria plataforma consulta para decidir se mostra um registro. Ele já leva em conta OWD, hierarquia de papéis, regras, equipes, permissões "View All" e tudo mais, calculado no motor de sharing. Tentar reproduzir esse cálculo em Apex é o erro clássico: você acerta os casos óbvios e erra os implícitos.

Repare em HasReadAccess != true em vez de !acesso[0].HasReadAccess. Como o campo é um Boolean que teoricamente poderia vir nulo, comparar explicitamente com true evita um NullPointerException e deixa a intenção clara. Se não há linha (registro inexistente) ou não há leitura, o método já retorna aqui, cumprindo a regra de não lançar exceção para registro que não existe.

3. O objeto sai do Id, sem hardcode

String objeto = recordId.getSObjectType().getDescribe().getName();

Todo Id do Salesforce carrega, nos primeiros caracteres, o prefixo do seu tipo de objeto. getSObjectType() lê esse prefixo e devolve o SObjectType, e getDescribe().getName() dá o nome da API. É isso que permite a mesma classe diagnosticar Account, Opportunity ou um objeto custom sem um único if por tipo.

Sem isso, você cairia na tentação de escrever um método por objeto, ou uma cadeia de if que envelhece a cada objeto novo. A regra de aceite exigia funcionar para padrão e custom exatamente para forçar essa generalização.

4. O dono, o caso que quase todo mundo esquece

Boolean ehDono = false;
try {
    List<SObject> reg = Database.query(
        'SELECT OwnerId FROM ' + objeto + ' WHERE Id = :recordId'
    );
    if (!reg.isEmpty() && userId == (Id) reg[0].get('OwnerId')) {
        ehDono = true;
        d.motivos.add(CAUSAS.get('Owner'));
    }
} catch (Exception e) {
    // Objeto sem OwnerId (raro) nao interrompe o diagnostico.
}

O acesso do proprietário é implícito: ele não depende de compartilhamento e nem sempre aparece como linha na tabela de share. Se você confiasse só no RowCause, um registro visível apenas porque o usuário é dono cairia no galho "acesso indireto", uma resposta tecnicamente verdadeira mas menos útil que "é dono". Conferir OwnerId diretamente resolve a regra de aceite específica sobre isso.

O try/catch existe porque nem todo objeto expõe OwnerId (alguns objetos de sistema não têm dono editável). Se a consulta falhar, o diagnóstico segue sem travar: perder a detecção de dono é degradar com elegância, não quebrar.

5. A tabela de share e a tradução do RowCause

Boolean custom = objeto.endsWith('__c');
String tabela = custom ? objeto.replace('__c', '__Share') : objeto + 'Share';
String campoPai = custom ? 'ParentId' : objeto + 'Id';

String soql =
    'SELECT RowCause FROM ' + tabela +
    ' WHERE ' + campoPai + ' = :recordId AND UserOrGroupId = :userId';

for (SObject linha : Database.query(soql)) {
    String rc = String.valueOf(linha.get('RowCause'));
    if (rc == 'Owner' && ehDono) { continue; }
    d.motivos.add(CAUSAS.containsKey(rc) ? CAUSAS.get(rc) : ('Compartilhamento: ' + rc));
}

Aqui mora a parte que exige conhecer a plataforma. Objeto padrão tem tabela AccountShare com campo pai AccountId; objeto custom tem MeuObjeto__Share com campo pai ParentId. Montamos o nome certo a partir do sufixo __c e consultamos por Database.query, filtrando pelo registro e pelo usuário.

O RowCause é o campo que nomeia a origem: Owner, Manual, Rule, Team, Territory e os implícitos. O Map de tradução transforma esses códigos em texto que o suporte entende. Note dois detalhes: o continue evita reportar "dono" duas vezes quando já detectamos pelo OwnerId, e o containsKey garante que um RowCause novo, que a plataforma introduza no futuro, apareça como "Compartilhamento: X" em vez de sumir do relatório.

O try/catch ao redor não é preguiça: há objetos sem tabela de share (quando o OWD é público, a plataforma não materializa linhas por usuário). Nesse cenário a consulta lançaria, e o correto é seguir para a dedução de acesso indireto, não abortar.

6. O acesso indireto, deduzido por eliminação

if (d.motivos.isEmpty()) {
    d.motivos.add('Acesso indireto: hierarquia de papeis, grupo publico ou OWD, sem compartilhamento direto para o usuario');
}

Este é o trecho que cumpre a regra mais sutil. O usuário tem acesso (o UserRecordAccess confirmou), não é dono e não há linha de share apontando para ele. Sobra uma explicação: o acesso vem de um caminho que não gera linha por usuário. Acesso por hierarquia de papéis, por pertencer a um grupo público alvo de uma regra, ou por OWD público, nenhum deles cria uma linha com UserOrGroupId = userId. O diagnóstico honesto é dizer que o acesso é indireto, em vez de devolver motivo vazio e deixar a impressão de que não achou nada.

Erros comuns

  • Reimplementar o sharing na mão. Ler o OWD, subir a hierarquia de papéis e cruzar regras em Apex é uma fonte inesgotável de bug. UserRecordAccess já faz isso, correto e atualizado.
  • Devolver só true/false. Passa numa leitura superficial da assinatura, mas ignora o valor real do desafio, que é o motivo. Sem o porquê, a ferramenta não economiza nenhuma investigação.
  • Confiar só no RowCause para detectar dono. O acesso do proprietário é implícito e pode não ter linha de share. Quem depende só da tabela reporta "acesso indireto" para um caso que era simplesmente "é o dono".
  • Hardcode do nome da tabela de share. Escrever AccountShare fixo quebra no primeiro objeto custom. O nome tem que sair do tipo do registro.
  • Não proteger a consulta de share. Objeto com OWD público não tem linhas por usuário, e a consulta lança. Sem try/catch, o diagnóstico morre justamente no caso em que o acesso é mais garantido.
  • Deixar motivos poder ser nulo. Inicializar no construtor evita o NullPointerException na camada que consome e itera a lista.

Como saber se a sua solução passou

O script de teste do desafio cobre os três casos que mais reprovam: o dono vê e o motivo diz "dono", o registro inexistente não lança e volta sem acesso, e a entrada nula não consulta nada. O critério objetivo é este:

AcessoDiagnostico.Diagnostico d = AcessoDiagnostico.diagnosticar(UserInfo.getUserId(), algumaContaSua.Id);
System.assertEquals(true, d.temAcesso, 'Dono deve ver');
System.assert(!d.motivos.isEmpty(), 'Acesso sempre tem motivo');
System.debug('Motivos: ' + d.motivos);

Se temAcesso é true mas motivos vem vazio, a regra do porquê falhou. Se algum Id inexistente derruba a execução com exceção, a regra da robustez falhou. As duas juntas são o que separa um diagnóstico de um Boolean disfarçado.

Um passo além

Para levar o diagnóstico ao nível que se usa em produção, três evoluções:

  • Resolver acesso por grupo. Hoje a consulta de share filtra UserOrGroupId = userId, o que ignora acesso concedido a um grupo público do qual o usuário participa. Cruzar com GroupMember para incluir os grupos do usuário transforma "acesso indireto" em "regra de compartilhamento via grupo X", um nível de detalhe que fecha o chamado sozinho. A teoria completa de como a visibilidade se compõe está em por que um usuário vê (ou não vê) um registro no Salesforce.
  • Respeitar a segurança de quem chama. As consultas dinâmicas rodam em modo de sistema. Num diagnóstico administrativo isso costuma ser intencional, mas vale entender o que muda com WITH USER_MODE e quando aplicá-lo. O raciocínio está em segurança em Apex: CRUD, FLS e sharing.
  • Trazer para a tela. O mesmo diagnóstico exposto num componente ou numa extensão vira uma ferramenta de suporte de verdade. É a ideia por trás do PermLens, a extensão que lê permissões e visibilidade sem abrir dez telas de setup.

O que fica deste desafio: a plataforma já sabe quem vê o quê. O trabalho de quem depura não é recalcular o sharing, é perguntar no objeto certo e traduzir a origem. Quem internaliza isso resolve chamado de visibilidade em minutos, não em tardes.