Skip to content

fix(api): deny out-of-scope single-record access to outbreak people - #24

Draft
lbrunofidelis wants to merge 2 commits into
devfrom
fix/#39-restricted-person-access
Draft

lbrunofidelis wants to merge 2 commits into
devfrom
fix/#39-restricted-person-access

Conversation

@lbrunofidelis

@lbrunofidelis lbrunofidelis commented Sep 24, 2026 •

Copy link
Copy Markdown
Collaborator

PR atrelado

  • Frontend: tira o link e a ação de abrir a pessoa mascarada nas listas de relacionamento e nos diálogos de contatos e exposições, e adiciona os filtros de endereço na lista de relacionamento. Sem esta API o registro continuaria acessível pela URL; sem o front o usuário continuaria vendo um link que agora devolve 403.

Por que

Na aba de relacionamento (Exposições e Contatos) e no diálogo aberto por "Número de contatos" da lista de casos, o usuário com restrição geográfica vê a pessoa de fora da área com o nome escondido, mas o ID continuava sendo um link. Clicando, ele abria o caso completo, com todos os dados, e conseguia até editar. Esconder o link no front não basta, porque o registro continuava acessível pelo id.

Causa

Os endpoints de registro único das relações do surto (GET, PUT e DELETE em /outbreaks/{id}/cases/{fk}, contacts, contactsOfContacts e events) não aplicavam a restrição geográfica que as listas aplicam. E a API não dizia ao front quando uma pessoa tinha sido mascarada: ela só anulava os campos de nome.

O que muda

  1. common/controllers/outbreak.js: beforeRemote em prototype.__findById__, __updateById__ e __destroyById__ de cases, contacts, contactsOfContacts e events. O where é montado com Person.addGeographicalRestrictions, o mesmo usado pelas listas, então a regra de escopo (residência ou notificação) é uma só. Registro fora do escopo recebe ACCESS_DENIED com status 403, no mesmo formato de server/middleware/authenticationError.js. Quando não há restrição aplicável (admin, ou surto sem restrição geográfica), nada muda.
  2. common/models/relationship.js: Relationship.maskPersonName passa a marcar masked: true junto dos campos de nome anulados, para o front distinguir uma pessoa mascarada de uma pessoa sem nome. server/scripts/checkRelationshipNameMasking.js passa a conferir essa marcação.

Como validar

  1. Com um usuário restrito a um município: GET /api/outbreaks/{outbreakId}/cases/{id} de um caso fora da área devolve 403; de um caso da área, 200. PUT e DELETE fora da área devolvem 403.
  2. Com o admin, as mesmas chamadas seguem 200.
  3. GET /api/outbreaks/{outbreakId}/contacts/{id}/relationships/exposures com include de people, como usuário restrito: a pessoa de fora da área vem com os nomes nulos e masked: true; as da área vêm sem a marcação.

Validado

  • Sonda de registro único, admin e usuário restrito (teste2): 0 divergências. Fora da área 403, dentro da área 200, PUT e DELETE fora da área 403 para o restrito, tudo 200 para o admin.
  • server/scripts/checkGeoVisibilityQueryShapes.js: 8/8 PASS. server/scripts/checkRelationshipNameMasking.js: 10/10.
  • Listas de relacionamento: masked: true só nas pessoas fora do escopo.
  • Conferência manual extra, cadastrando casos como o usuário restrito: com endereço de notificação na área, o cadastro, a leitura e a edição dão 200; com residência só fora da área e sem notificação na área, o cadastro dá 200 e a leitura e a edição seguintes dão 403 (ver Fora do escopo).
  • eslint sem achado novo nos arquivos alterados (relationship.js já usa CRLF em todo o arquivo e a linha nova segue o mesmo padrão).

Fora do escopo

  • Precisa de decisão de produto: um usuário restrito consegue cadastrar um caso com residência só fora da área e sem endereço de notificação na área. A API aceita, mas depois disso ele não consegue mais abrir nem editar o registro. Caminhos possíveis: exigir notificação na área quando o cadastro é de fora, ou liberar o acesso a quem criou.
  • Outbreak.prototype.exportExistingEmptyCaseInvestigation chama this.__findById__cases direto no código, sem passar pelo remoting, então não passa pela verificação nova. É uma exportação pouco usada e fica para outra entrega.
  • GET /outbreaks/{id}/relationships no nível do surto continua sem restrição geográfica, comportamento original do Go.Data.
  • A entrega das cadeias de transmissão (feat(cot): keep out-of-scope people in a transmission chain, masked #25) também grava masked em Relationship.maskPersonName. A mudança é a mesma nas duas branches; a que entrar por último precisa de um rebase simples.

Why (English)

In the relationship tabs (Exposures and Contacts) and in the dialog opened from the cases list "Number of contacts", a user restricted to a geographic area sees an out-of-scope person with the name withheld, but the ID was still a link that opened the full record and even allowed editing it. Hiding the link alone is not enough because the record stayed reachable by id.

Root cause

The outbreak single-record relation endpoints (GET, PUT and DELETE for cases, contacts, contacts of contacts and events) did not apply the geographic restriction the lists apply, and the API gave the front end no signal that a person had been masked.

What changes

  1. common/controllers/outbreak.js: beforeRemote hooks on __findById__, __updateById__ and __destroyById__ for the four person relations, building the where clause with Person.addGeographicalRestrictions (the same helper the lists use) and answering ACCESS_DENIED (403) when the record is out of scope. No restriction applies to admins or to outbreaks without geographic restrictions.
  2. common/models/relationship.js: Relationship.maskPersonName sets masked: true next to the nulled name fields; the masking check script asserts it.

How to validate

As a restricted user, read, update and delete a case outside the area (403) and inside the area (200); as the admin everything stays 200; relationship lists flag only out-of-scope people with masked: true.

Validated

Single-record probe with 0 mismatches for both users, checkGeoVisibilityQueryShapes.js 8/8, checkRelationshipNameMasking.js 10/10, eslint clean on the change, plus a manual create-then-read check described below.

Out of scope

  • Product decision needed: a restricted user can create a case whose residence is outside the area with no notification address inside it; the API accepts it and then refuses to read or update it for that user.
  • exportExistingEmptyCaseInvestigation calls __findById__cases directly and bypasses the new hook.
  • The outbreak-level GET /outbreaks/{id}/relationships remains unrestricted, as in upstream Go.Data.
  • The transmission chain pull request (feat(cot): keep out-of-scope people in a transmission chain, masked #25) sets the same masked flag in Relationship.maskPersonName; whichever lands last needs a trivial rebase.

Reading, updating or deleting a case, contact, contact of contact or
event by id through the outbreak relation endpoints now checks the
same geographic restriction query used to build the person lists, and
refuses the request with ACCESS_DENIED (403) when the record falls
outside it. Hiding the link in the UI was not enough since the record
was still reachable by id.
Relationship.maskPersonName now sets masked: true next to the nulled
name fields, so the front end can tell a withheld person apart from
one that simply has no name, instead of inferring it from empty
strings.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant