Início › Desafios › Desafio 07 › Gabarito
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.
UserRecordAccessjá 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
RowCausepara 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
AccountSharefixo 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
motivospoder ser nulo. Inicializar no construtor evita oNullPointerExceptionna 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 comGroupMemberpara 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_MODEe 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.