Especificação · Plataforma Gryd.IO

GrydFiles

Este documento não é do Nexio. Especifica um módulo novo do Gryd.IO — por isso a cor muda. O Nexio é o primeiro consumidor, não o dono. O Gryd.Infrastructure já sabe falar com S3, Azure Blob e Google Cloud Storage, e já emite URL pré-assinada de upload e de download; o que não existe em lugar nenhum do framework é registro do arquivo: quem subiu, que hash tem, se passou pelo antivírus, quem ainda aponta para ele e quando ele pode morrer. Hoje o GrydReports guarda uma URI em string e o GrydNotifications guarda o arquivo inteiro como byte[] numa coluna. O GrydFiles fecha essa lacuna uma vez.

depende de Gryd.Application.Abstractions.Storage · GrydJobs · GrydAudit consumidores previstos Nexio.Attachment · GrydReports · GrydNotifications 3 entidades · 1 serviço · 5 jobs · 1 provedor de scan v1.2 · 08/09/2026 · 16 decisões de detalhamento

Ponto de partida

Oito decisões

Decisão 1 · Camada

O módulo conhece o arquivo e nunca o domínio. StoredFile é binário e metadado; anexo, tipo de anexo, visibilidade e propagação entre documentos ficam no produto que consome. O GrydFiles não sabe o que é uma requisição, e é essa ignorância que o torna reusável. A mesma linha separa, do lado do Nexio, o StoredFile do Attachment.

Decisão 2 · Trânsito

O binário nunca passa pela API. Subida e descida por URL pré-assinada, direto no bucket. A API concede a permissão e recebe a confirmação. Passar 100 MB pelo processo é pagar memória, egress duas vezes e o timeout do request — e nenhuma das três compra nada.

Decisão 3 · Quarentena

Nada é referenciável antes de ser escaneado. O estado vive no registro, não no bucket: Pending → Scanning → Available, com Infected e Failed como saídas. Arquivo infectado não é apagado — vai para um prefixo de quarentena com acesso restrito, porque apagar destrói a evidência do incidente.

Decisão 4 · Identidade

A identidade do conteúdo é o sha256, calculado no servidor. Nunca aceito do cliente. O nome original é metadado de exibição e não entra na chave do objeto — nome de arquivo carrega acentuação, colisão, path traversal e, com frequência, dado pessoal.

Decisão 5 · Referência

Quem aponta para o arquivo se registra. FileReference guarda ownerScope + ownerKey em texto — o mesmo polimorfismo que o AuditLog do framework já pratica. Sem esse registro não existe resposta para "este blob ainda serve para alguma coisa?", e sem essa resposta não existe expurgo seguro.

Decisão 6 · Retenção declarada

Ninguém sobe arquivo sem dizer por quanto tempo ele fica. A retenção vem no upload, do consumidor. O módulo não sabe que documento fiscal se guarda por cinco anos — o Nexio sabe. O que o módulo garante é que a pergunta foi feita e a resposta está gravada.

Decisão 7 · Expurgo com lápide

O objeto morre; o registro fica. Expurgar apaga o blob e preserva hash, tamanho, tipo, datas e quem pediu. Atende ao apagamento da LGPD sem quebrar o ISoftDeletable do framework e sem perder a prova de que o arquivo existiu — que é exatamente o que uma auditoria pergunta depois.

Decisão 8 · Antivírus

ClamAV, em serviço próprio, com o provedor plugável. É o único caminho que funciona igual nos três provedores de storage, não cobra por GB nem por objeto, e mantém o arquivo dentro da rede. Quem quiser trocar por GuardDuty ou por serviço pago troca a implementação de IFileScanner — a decisão está isolada atrás de uma interface.

Por que isso não nasce dentro do Nexio. Guardar arquivo com hash, antivírus e retenção não tem nada de específico de compras. Nascendo no Nexio, o próximo produto do Gryd reescreve tudo — e o GrydReports continua com URI em string e o GrydNotifications com byte[] no banco. O custo de subir para a plataforma é real e tem nome: o Nexio passa a depender de um release do framework, e a Requisição não começa antes da v1 deste módulo existir.

Escopo

A fronteira

A pergunta que decide cada campo é sempre a mesma: isso continuaria verdade num produto que não é o Nexio? Tamanho, tipo, hash, vírus e retenção continuam. Anexo de requisição, visibilidade para o fornecedor e propagação do pedido para o recebimento não continuam.

O que fica de cada lado
AssuntoGrydFilesConsumidor (ex.: Nexio)
Bytes, bucket, chaveSim — é a razão de existirNunca vê objectKey nem nome de bucket
Hash, tamanho, tipoCalcula e validaDeclara o esperado e recebe o verificado
Antivírus e quarentenaSim, com o resultado no registroSó lê status
Quanto tempo guardarExecuta e cobra a declaraçãoDecide — é regra de negócio e de lei
Quem pode subir e baixarPermissão genérica do móduloPermissão de domínio, antes de chamar
O que o arquivo éNão sabe e não quer saberAttachment, ItemDocument, relatório, planilha de importação
Visibilidade para terceiroFora — o módulo só emite URL curta para quem já foi autorizadovisibility = Internal | Supplier vive no anexo
Vínculo com o donoFileReference em texto opacoSabe traduzir ownerKey de volta em documento

Reaproveitamento

O que já existe no Core

Metade deste módulo é composição, não construção. Gryd.Application.Abstractions.Storage e Gryd.Infrastructure.Storage já entregam o acesso ao objeto; o GrydFiles é a camada de registro por cima.

PeçaOndeO que o GrydFiles faz com ela
IObjectStorageServiceGryd.ApplicationUploadAsync, OpenReadAsync, DeleteAsync, ExistsAsync e GeneratePresignedUrlAsync — usado como está, sem envelope novo
ObjectStorageSignedUrlOperationGryd.ApplicationUpload e Download. É o que torna a decisão 2 possível sem escrever nada de provedor
ObjectStoragePresignedUrlRequestGryd.ApplicationPrecisa ser estendido por este épico. Hoje tem só ObjectKey, ExpiresIn, Operation, BucketName, ContentType e Provider — não há sobreposição de cabeçalho de resposta, sem a qual o download não carrega o nome original. Ver § Download
ProvedoresGryd.InfrastructureAmazonS3 · AzureBlobStorage · GoogleCloudStorage, resolvidos por IObjectStorageProviderResolver. O ServiceUrl + ForcePathStyle do S3 cobre MinIO e R2
ObjectStorageOptionsseção GrydStorageBucket, região, credencial e DefaultPrefix. O GrydFiles acrescenta a seção GrydFiles com os perfis, e não duplica nada de provedor
ObjectStorageObjectDescriptorGryd.ApplicationDevolve SizeBytes e ContentType reais — é com ele que a confirmação do upload confere o que o cliente declarou
Padrão do GrydReportsModules/ReportsConfigurableReportFileStore (local × nuvem) e ReportFilePathGenerator (tenant/aaaa/MM/dd/id.ext) são o molde da chave e do modo local de desenvolvimento
GrydJobsModules/JobsCinco jobs sobre o Hangfire já configurado (IRecurringJob + RecurringJobRegistrationService): scan, varredura de Pending vencido, expurgo por retenção, varredura de consistência com expurgo de quarentena e reconciliação de referenceCount. Concorrência por [DisableConcurrentExecution] mais FOR UPDATE SKIP LOCKED na seleção de candidatos
IAuditLogServiceModules/AuditRecordAsync para o que não é mudança de linha: emissão de URL de download, expurgo e quarentena

O que não existe em lugar nenhum do framework hoje — e é exatamente a lista de entregas deste documento: entidade de arquivo, sha256 e dedupe, antivírus (a busca por clamav, antivirus e virusscan no repositório inteiro não devolve nada), validação por assinatura de conteúdo, retenção com expurgo e contagem de referência — mais a sobreposição de cabeçalho de resposta na URL pré-assinada, que os três provedores sabem fazer mas o contrato compartilhado não expõe.

Modelo

Mapa de entidades

StoredFile aggregate root · GrydFiles O registro do arquivo: perfil, nome original, tipo, tamanho, sha256, provedor, bucket, chave, estado, resultado do scan, retenção e lápide de expurgo.
FileReference N por arquivo · GrydFiles Quem aponta para o arquivo, em texto opaco: ownerScope + ownerKey. Nasce com o vínculo e é liberada quando ele cai. É o que autoriza o expurgo.
FileScanAttempt N por arquivo · append-only Cada passagem pelo antivírus: motor, versão da base de assinaturas, início, fim, resultado e mensagem. Nunca sobrescrito — é a resposta de "com que base este arquivo foi liberado".
IObjectStorageService Gryd.Application · já existe A porta para S3, Azure Blob e GCS, incluindo a URL pré-assinada. O GrydFiles é cliente dela e não conhece nenhum SDK de nuvem.
Attachment Nexio · fora deste módulo O vínculo de negócio: dono polimórfico, tipo de anexo, visibilidade, propagação e revogação com motivo. Cria uma FileReference ao nascer.
clamd serviço externo · 1..N réplicas O daemon do ClamAV, com a base de assinaturas em memória. Falado por socket, comando INSTREAM. Não é biblioteca embarcada.

Três entidades, uma tabela de configuração em appsettings e nenhum SDK de nuvem novo. O módulo tem DbContext e migrations próprios, como todos os outros do framework.

Máquina de estados

Ciclo de vida do arquivo

O estado é do registro, não do objeto no bucket. Um blob existir no storage não significa que ele possa ser usado — significa apenas que alguém escreveu bytes lá. A única transição que libera o arquivo para consumo é a saída limpa do antivírus.

Pending aguardando o PUT Scanning hash + antivírus Available pode ser referenciado Purged blob apagado, lápide Failed erro de infra Infected em quarentena confirmação stream: OK sem referência · retenção vencida erro FOUND retry ×3 quarentena vencida
Seis estados e uma única porta de saída limpa. Infected não volta: arquivo com assinatura encontrada nunca é liberado, nem por permissão elevada.
As transições, e quem as dispara
DeParaQuem disparaRegra
PendingPOST /filesValida perfil, tipo e tamanho declarados antes de emitir a URL. Grava uploadExpiresAt. sha256 nasce nulo — só o servidor o calcula, e só na leitura do scan.
PendingScanningPOST /files/{id}/completeConfere que o objeto existe e que o tamanho real bate com o declarado. Divergência é 422, o objeto é apagado e o registro vai a Purged com rejectionCode = FILE_SIZE_MISMATCH_UNPROCESSABLE.
PendingPurgedJob de varredurauploadExpiresAt vencido sem confirmação. O objeto órfão, se existir, é apagado. É o caminho do usuário que desistiu no meio. purgedBy nulo (autor é job).
ScanningAvailableJob de scanResposta stream: OK e tipo verificado dentro da lista do perfil. Grava sha256, motor e versão da base. Só aqui o arquivo passa a existir para quem consome.
ScanningPurgedJob de scanReprovação por tipo. A assinatura de conteúdo não bate com o declarado, ou o tipo verificado está fora do perfil. O objeto é apagado e o registro grava rejectionCode = FILE_CONTENT_TYPE_NOT_ALLOWED_UNPROCESSABLE — o veredito é assíncrono e não tem request para responder 422. Ver § Contrato de erros.
ScanningInfectedJob de scanResposta FOUND. Move o objeto para o prefixo de quarentena, grava sha256 e o nome da assinatura, e registra no AuditLog. Terminal.
ScanningFailedJob de scanErro de infraestrutura — clamd fora do ar, timeout, leitura do bucket. Não é veredito sobre o arquivo, e sha256 não é gravado: a leitura pode ter sido interrompida.
FailedScanningJob de scanAté três tentativas, com espera de 1 min, 5 min e 15 min. Esgotadas, o arquivo fica em Failed e alerta o operador. Nunca libera sem veredito.
AvailablePurgedJob de retenção · POST /files/{id}/purgeSó sem referência ativa e sem legalHold, avaliado sobre o conjunto canônico. Apaga o blob e grava a lápide.
InfectedPurgedJob de varredura de consistênciaQuarentena vencida: QuarantineRetentionDays, padrão 365 dias. A lápide guarda o nome da assinatura para sempre.

Não existe estado "liberado manualmente". Foi uma tentação e foi descartada: a permissão de sobrepor o antivírus é exatamente a que um atacante quer, e o caso legítimo — falso positivo do ClamAV numa planilha com macro — se resolve subindo de novo depois que a base de assinaturas for corrigida, ou trocando o arquivo. Liberar por botão transforma o scan em teatro.

Modelo

Campos · StoredFile

Herda de TenantScopedEntity quando o arquivo é de um tenant, com TenantId anulável pelo contrato IOptionalTenant — nulo significa arquivo de produto, o mesmo padrão que o NotificationTemplate já usa para o modelo global. Id, CreatedAt, CreatedBy, UpdatedAt, UpdatedBy e o bloco de soft delete vêm do BaseEntity e não estão repetidos aqui.

CampoTipoRegra
idGuidgeradoAtribuído pelo agregado, não pelo banco — o BaseEntity já faz isso no construtor. É o que permite emitir a URL de upload antes de qualquer commit.
tenantIdGuid?geradoDo token, nunca aceito do cliente. Nulo é arquivo de produto e exige admin:system.
profilestring(40)obrigO perfil declarado pelo consumidor — gryd.report, gryd.notification-attachment, nexio.attachment, nexio.import. É onde moram tipo permitido, tamanho máximo, retenção padrão e obrigatoriedade de scan. Sempre explícito: não existe perfil padrão (ver § Perfis). Perfil desconhecido é 400.
originalFileNamestring(255)obrigSó exibição e nome sugerido no download. Nunca compõe a chave do objeto. Normalizado: sem separador de caminho, sem caractere de controle, truncado.
contentTypestring(120)obrigDeclarado na criação e reconferido no scan contra a assinatura de conteúdo, inclusive quando ScanRequired: false. Divergência leva a Purged com rejectionCode.
declaredSizeBytesbigintobrigO tamanho que o cliente promete. Serve para recusar antes de gastar rede, e para comparar depois. Zero é recusado.
sizeBytesbigint?geradoO tamanho real, lido do ObjectStorageObjectDescriptor na confirmação. Divergiu do declarado, o arquivo não entra.
sha256char(64)?geradoCalculado no servidor, na mesma leitura do antivírus. Nunca aceito do cliente. Anulável por construção: nulo em Pending e Scanning, gravado na saída para Available e também para Infected — a leitura única já o produziu e a lápide da quarentena o preserva. Não é gravado em Failed, onde a leitura pode ter sido interrompida.
storageProviderenum?geradoAmazonS3 · AzureBlobStorage · GoogleCloudStorage. Guardado no registro porque a configuração padrão muda com o tempo e o objeto antigo continua onde está. Nulo no registro deduplicado — ver objectKey.
bucketNamestring(120)?geradoIdem: o bucket efetivo no momento da gravação, não o configurado hoje. Nulo no registro deduplicado.
objectKeystring(400)?geradoVer § Chave do objeto. Nunca exposto fora do módulo. A localização é a prova de posse do blob: preenchida quando canonicalFileId é nulo, e nula no registro deduplicado, cujo objeto foi apagado. No registro expurgado a localização é preservada — a lápide responde onde o arquivo esteve.
canonicalFileIdGuid?geradoPreenchido quando o sha256 já existia no tenant: este registro reaproveita o objeto daquele. Nulo significa que este registro é o dono do blob. Não há cadeia: o canônico tem sempre canonicalFileId nulo. Ver § Hash, dedupe e referência.
statusenumgeradoPending · Scanning · Available · Infected · Failed · Purged. Só o serviço muda; não existe rota que aceite estado do cliente.
rejectionCodestring(80)?geradoO motivo estruturado da reprovação. Guarda o código do catálogo de erros que reprovou o arquivo — FILE_SIZE_MISMATCH_UNPROCESSABLE ou FILE_CONTENT_TYPE_NOT_ALLOWED_UNPROCESSABLE. Nulo quando não houve reprovação. Existe porque a conferência de tipo é assíncrona: sem ele, quem subiu um .exe renomeado receberia o mesmo FILE_PURGED_CONFLICT de um arquivo apagado por retenção há dois anos.
uploadExpiresAtDateTimegeradoFim da validade da URL de upload. Padrão de 15 minutos, configurável por perfil. Vencido sem confirmação, o registro vira Purged pela varredura.
scannedAtDateTime?geradoQuando o veredito saiu. Junto com scanEngine e scanDatabaseVersion responde "com que base este arquivo foi liberado".
scanEnginestring(60)?geradoEx.: ClamAV 1.4.x. Existe porque o provedor é plugável e a resposta de auditoria precisa dizer qual motor deu o parecer.
scanDatabaseVersionstring(40)?geradoA versão da base de assinaturas no momento do scan, obtida do comando VERSION do clamd.
scanSignaturestring(160)?geradoO nome da assinatura quando houve detecção — a parte antes do FOUND na resposta. Nulo quando limpo.
retentionModeenumobrigUntilReleased (morre quando a última referência cai) · RetainUntil (data explícita) · Permanent (só expurgo manual). Sem padrão implícito: quem sobe escolhe, herdando do perfil se não disser.
retainUntilDateTime?condObrigatório em RetainUntil, recusado nos outros modos. Quando o modo é herdado do perfil, a data é calculada pelo módulo a partir de DefaultRetentionDays e o consumidor não precisa informá-la — só o retainUntil explícito exige modo explícito. Precisa ser futuro, e só pode ser empurrado para frente.
legalHoldboolopcTrava o expurgo, inclusive o manual e o pedido de apagamento por titular. É o consumidor que liga — no Nexio, anexo de documento fiscal. Desligar exige admin:system e vai para a trilha.
referenceCountintgeradoDenormalizado a partir de FileReference ativas, para o job de expurgo não varrer a tabela filha. A contagem é conferência, não verdade: a verdade é a tabela.
lastReferenceReleasedAtDateTime?geradoQuando a última referência ativa caiu — é desta data que a carência do expurgo conta. Denormalizado junto do referenceCount, no mesmo método do agregado e na mesma transação, e recalculado pela mesma rotina de reconciliação. Sem ele o job de retenção teria de agregar FileReference para cada candidato, que é exatamente o que o referenceCount existe para evitar — e a carga cresceria para sempre, porque liberação é append.
purgedAt / purgedBy / purgeReasonDateTime? / Guid? / string(200)?geradoA lápide. purgedAt e purgeReason são obrigatórios e gravados no mesmo método. purgedBy é nulo quando e somente quando o autor foi um job — o framework não tem usuário de sistema (o ICurrentUserService.UserId devolve nulo fora de request autenticado), e o purgeReason identifica a origem: job:retencao, job:pending-expirado, job:quarentena-vencida.

Dois índices únicos parciais, e o predicado sha256 IS NOT NULL não é opcional.

CREATE UNIQUE INDEX ux_storedfile_tenant_sha256 ON stored_files (tenant_id, sha256)
  WHERE canonical_file_id IS NULL AND sha256 IS NOT NULL AND status <> 'Purged';

CREATE UNIQUE INDEX ux_storedfile_global_sha256 ON stored_files (sha256)
  WHERE tenant_id IS NULL AND canonical_file_id IS NULL AND sha256 IS NOT NULL AND status <> 'Purged';

Um blob por conteúdo por tenant, sem impedir que o mesmo arquivo suba de novo depois de expurgado. O segundo índice existe porque tenantId é anulável e dois nulos não colidem no Postgres — sem ele o arquivo de produto escaparia da unicidade. UNIQUE NULLS NOT DISTINCT (PG 15+) foi avaliado e recusado: ele resolveria o tenantId nulo, mas faria todos os registros Pending do mesmo tenant — que têm sha256 nulo — colidirem entre si, derrubando o segundo upload simultâneo de qualquer arquivo. Os predicados também mantêm os índices pequenos: só entra registro que já tem veredito.

Portabilidade. Índice parcial existe em Postgres, SQLite e SQL Server (onde, por NULL colidir com NULL, o segundo índice vira redundante e inofensivo) e se emula no Oracle por índice baseado em função. MySQL e MariaDB não têm índice parcial — se um produto sobre o Gryd.IO precisar deles, a saída portável é uma coluna persistida dedupeKey, valendo tenantId || sha256 quando o registro é dono de blob não expurgado e NULL caso contrário, com índice único simples: MySQL admite múltiplos NULL em índice único e a semântica cai certa nos cinco bancos. É troca de uma migration, não de arquitetura.

Modelo

FileReference e FileScanAttempt

FileReference — quem ainda precisa deste arquivo

CampoTipoRegra
storedFileIdGuidobrigO arquivo. Referência só pode ser criada sobre arquivo Available — é isso que impede um anexo apontar para algo que ainda está na fila do antivírus.
ownerScopestring(60)obrigQuem aponta, em texto opaco: nexio.attachment, nexio.item-document, gryd.report-execution. O módulo não interpreta — só agrupa e reporta.
ownerKeystring(80)obrigA chave do dono no mundo dele. Normalmente um Guid em texto. Mesmo polimorfismo que o AuditLog do framework já usa em EntityType + EntityId.
releasedAt / releasedByDateTime? / Guid?geradoLiberação é append, não exclusão: a linha continua, com data. O histórico de quem já apontou para o arquivo faz parte da prova.

Único por (storedFileId, ownerScope, ownerKey) entre as não liberadas. Registrar duas vezes é operação idempotente, não erro — o consumidor pode reprocessar sem medo. Criar e liberar referência ajusta, no mesmo método do agregado e na mesma transação, o referenceCount e o lastReferenceReleasedAt do StoredFile; a chamada idempotente não incrementa e a liberação repetida não decrementa duas vezes nem reescreve a data.

FileScanAttempt — o histórico do veredito

CampoTipoRegra
storedFileId · attemptNumberGuid · intobrigChave composta natural. Começa em 1.
engine · databaseVersionstring(60) · string(40)geradoMotor e base no momento da tentativa.
startedAt · finishedAtDateTime · DateTime?geradoTambém servem de métrica: tempo de scan por MB é o número que dimensiona as réplicas do clamd.
outcomeenumgeradoClean · Infected · Error · Timeout · SizeLimitExceeded. O último é a resposta INSTREAM size limit exceeded do clamd e é erro de configuração, não do arquivo.
messagestring(400)?geradoA resposta literal do motor. Guardada como veio, sem interpretação.

Append-only. Nunca sobrescrito, nunca apagado antes do arquivo — mesma tese do SupplierVerification do Nexio: evidência que se reescreve não é evidência.

Fluxo

Upload em duas fases

Três chamadas, e o binário não toca no processo da API em nenhuma delas.

1 · Reservar

POST /files com perfil, nome, tipo, tamanho declarado e retenção. O serviço valida antes de gastar rede: perfil existe, tipo está na lista do perfil, tamanho cabe no teto. Cria o StoredFile em Pending, monta a objectKey e devolve fileId + uploadUrl pré-assinada com validade curta.

2 · Enviar

O cliente faz PUT direto no bucket, com a URL recebida. A URL é assinada para uma operação, um objeto e um Content-Type; não serve para listar, não serve para outro caminho e vence sozinha. Se o cliente sumir no meio, sobra um registro Pending e talvez um objeto órfão — os dois morrem na varredura.

3 · Confirmar

POST /files/{id}/complete. O serviço chama ExistsAsync, lê o descritor, compara SizeBytes com o declarado, passa o registro para Scanning e enfileira o job. A resposta é imediata: o cliente não espera o antivírus, ele recebe o fileId e o estado, e a tela mostra "verificando".

Por que confirmação explícita em vez de evento do bucket. O S3 avisa por event notification, o Azure por Event Grid, o GCS por Pub/Sub — três integrações diferentes, três formatos, três modos de falhar, e nenhuma delas existe quando o provedor é o disco local do desenvolvedor. Uma chamada de confirmação funciona igual nos quatro casos e ainda dá ao serviço o momento exato de comparar o declarado com o real. O custo é depender do cliente chamar; a varredura de Pending vencido cobre quem não chama.

Segurança

Antivírus · ClamAV

Anexo é a única funcionalidade do produto em que um terceiro escreve bytes na nossa infraestrutura. No Nexio isso é literal: o fornecedor sobe documento pelo portal. Não escanear é aceitar que o produto vire meio de distribuição de malware entre os clientes — o arquivo que o comprador baixa foi enviado por alguém de fora.

Por que ClamAV

CritérioComo pesa
Funciona nos três provedoresO módulo é multi-provedor por construção — S3, Azure Blob e GCS. O GuardDuty Malware Protection for S3 só protege bucket S3, e amarraria o framework inteiro à AWS.
CustoSem licença e sem preço por GB nem por objeto. Volume de anexo em compras cresce com o uso do cliente; scan cobrado por objeto cria um custo que sobe junto com a adoção, o que é o pior formato possível para um módulo de plataforma.
O dado não sai da redeProposta comercial, contrato e planilha de custo passariam por um terceiro se o scan fosse serviço externo. É decisão de compliance que nem todo cliente aceita, e que teríamos que negociar cliente a cliente.
Roda em qualquer lugarContêiner, on-premise, nuvem privada. O Gryd.IO não pode assumir nuvem pública em todo cliente.
TrocávelFica atrás de IFileScanner, no mesmo padrão de GrydNotifications.Infrastructure.SendGrid. Quem já tem GuardDuty ou appliance ICAP troca o projeto de provedor sem tocar no módulo.

O que ele é — e o que ele não é

ClamAV é um motor de assinatura. Ele reconhece o que já está catalogado: macro maliciosa em documento do Office, PDF com JavaScript conhecido, executável embutido, conteúdo dentro de arquivo compactado — ele descompacta e olha dentro. Ele não reconhece ameaça nova, não faz análise comportamental e não diz nada sobre o conteúdo ser apropriado. Por isso ele é uma camada: a lista de tipos permitidos, o teto de tamanho, a URL de vida curta e a permissão de domínio continuam valendo, e nenhuma delas é substituída pelo scan.

Como funciona na prática

Daemon, não linha de comando

O clamscan de linha de comando recarrega a base de assinaturas inteira a cada execução — em servidor isso é inviável. O que se usa é o clamd, um daemon que mantém as assinaturas carregadas em memória e atende por socket. Ele roda como serviço próprio, com 1..N réplicas, nunca embarcado em cada instância da API: a base em memória é cara demais para replicar por processo.

INSTREAM — escanear sem gravar em disco

O comando INSTREAM manda o conteúdo pelo próprio socket, em pedaços: cada um prefixado por 4 bytes big-endian de tamanho, e o fim marcado por um pedaço de tamanho zero (00 00 00 00). É o que permite ao job ler o objeto do bucket em stream e empurrar direto para o motor, sem materializar o arquivo em disco no nó de scan.

Uma leitura, dois consumidores

O stream devolvido por OpenReadAsync alimenta ao mesmo tempo o SHA256 e o socket do clamd. Ler o objeto duas vezes — uma para o hash, outra para o scan — dobraria o tráfego de saída do bucket e o tempo do job sem nenhum ganho.

A resposta é texto, e é curta

stream: OK quando limpo. stream: <NomeDaAssinatura> FOUND quando detecta — o nome vai para scanSignature. ... ERROR em falha, e INSTREAM size limit exceeded quando o conteúdo passa de StreamMaxLength. Os quatro casos são distintos no modelo: só o segundo é veredito sobre o arquivo.

O clamd.conf é gerado, não escrito à mão

Não existe comando do protocolo que permita ao serviço perguntar a configuração ao daemon — o VERSION devolve motor e base, nada mais. Uma validação que compare MaxSizeBytes contra um StreamMaxLength espelhado no appsettings atesta um valor que pode não ser o do daemon. Por isso a seção GrydFiles é a fonte única: o clamd.conf é renderizado a partir dela na subida do serviço, e a checagem de coerência passa a comparar contra o valor que realmente vale. Incoerência derruba a aplicação na inicialização — é configuração de plataforma, e o módulo é fail-closed.

freshclam mantém a base

A base de assinaturas é atualizada pelo freshclam, por padrão diariamente. A base fica em volume compartilhado e a imagem usada é a variante _base, sem assinaturas embutidas — a documentação do próprio projeto pede parcimônia com a banda gratuita do CDN, e reconstruir o contêiner baixando tudo de novo a cada deploy é o caminho mais rápido para ser bloqueado.

Memória é o recurso crítico

A documentação oficial pede mínimo de 3 GiB e preferencialmente 4 GiB por instância, porque a base fica toda em memória e o recarregamento concorrente chega a dobrar o consumo enquanto troca. Faltando memória, o sistema operacional mata o clamd e o contêiner fica de pé sem responder — que é a falha mais traiçoeira possível. O limit do contêiner nasce em 4 GiB e o liveness probe é o comando PING, que responde PONG.

Configuração que precisa de decisão explícita

ParâmetroComo definir
StreamMaxLength64M. Precisa ser maior que o maior maxSizeBytes de todos os perfis — hoje 26.214.400 bytes, que é exatamente 25 MiB, o mesmo valor do padrão do clamd. Igual não basta: o INSTREAM acrescenta 4 bytes de enquadramento por pedaço, e o arquivo no tamanho máximo do perfil voltaria SizeLimitExceeded de forma intermitente — a armadilha mais comum do ClamAV se manifestando já na configuração padrão. 64M dá ~2,5× de folga e admite um perfil futuro maior sem mexer no daemon.
MaxFileSize64M, igualado ao StreamMaxLength. Teto do maior membro individual extraído de um compactado. É a única linha deste arquivo em que errar para menos libera arquivo não examinado: abaixo do necessário o motor não recusa — ele ignora o membro e responde OK.
MaxScanSize400M. Total examinado somando todos os membros de um compactado. Permite um zip de 25 MiB expandir cerca de 16× antes de estourar. Mesma falha silenciosa do anterior se ficar curto.
MaxRecursion · MaxFiles16 e 10000. Proteção contra zip bomb: profundidade de aninhamento e quantidade de arquivos extraídos. Estouro é tratado como Error, e arquivo que estoura esses limites não é liberado.
ConcurrentDatabaseReloadLigado, para o scan não parar durante a atualização — é ele que exige a folga de memória de 4 GiB.
IDSESSIONReaproveita a conexão para vários arquivos na mesma sessão. Desligado no caminho padrão (um arquivo por vez, conexão simples); ligado apenas onde houver processamento em lote.
Timeout do scanner120s, do lado da aplicação, não do clamd.conf. Cabe folgadamente num compactado de 25 MiB no pior caso e libera o worker do Hangfire antes de virar acúmulo. Estouro produz o desfecho Timeout, distinto de Error, e nunca liberação.

Falha, degradação e teste

Fora do ar não libera nada

clamd indisponível deixa os arquivos em Scanning e a fila cresce. O job re-tenta até três vezes, com espera de 1 minuto, 5 minutos e 15 minutos — cerca de vinte minutos no total, que absorvem reinício de contêiner e recarregamento de base sem prender o arquivo a tarde inteira. Esgotadas, o registro fica em Failed. Não existe modo degradado que libere sem veredito. O alerta dispara por dois sinais: fila acima do limite, ou base de assinaturas com mais de 48 horas — a segunda é a que passa despercebida, porque tudo parece funcionar enquanto o motor envelhece.

EICAR no teste de integração

O arquivo de teste EICAR é a cadeia padrão que todo antivírus reconhece por convenção, e existe exatamente para provar que a esteira funciona sem usar malware real. O teste sobe o EICAR e exige que o arquivo termine em Infected, na quarentena, com scanSignature preenchido. Detalhe prático: a cadeia é gerada em tempo de teste, nunca commitada como arquivo no repositório — o antivírus da máquina do desenvolvedor ou do agente de CI apaga o arquivo e quebra a compilação por um motivo que ninguém entende de primeira.

Falso positivo tem caminho, e não é botão

Acontece com planilha com macro e com instalador. O caminho é reportar a assinatura ao ClamAV, aguardar a correção da base e subir de novo — ou converter o arquivo. Não existe liberação manual (ver § Ciclo de vida), porque a permissão de sobrepor o antivírus é a que um atacante mais quer.

Alternativas avaliadas

OpçãoPor que não é a padrão
GuardDuty Malware Protection for S3Só S3 — o módulo perderia Azure e GCS. Cobra por GB e por objeto. Tem cotas próprias: objeto até 100 GB, 100.000 arquivos extraídos, 100 níveis de aninhamento e 25 buckets protegidos por conta e por região, o que é um teto real para uma plataforma multi-cliente. Continua sendo o provedor alternativo natural para quem já vive na AWS.
Serviço de scan por APICusto por chamada e, sobretudo, o arquivo sai da nossa rede. Vira cláusula de contrato com cada cliente.
Appliance ICAPFaz sentido onde já existe um, em cliente grande on-premise. Como padrão de plataforma, custo e operação não se justificam.
Não escanearRecusada. Com portal do fornecedor, o arquivo que o comprador abre foi enviado por alguém de fora da empresa.

Defesa

Validação de tipo e tamanho

Assinatura de conteúdo, não extensão

O tipo é conferido pelos primeiros bytes do arquivo contra a lista permitida do perfil. Extensão e Content-Type declarado são informação do cliente — um .exe renomeado para .pdf passa nos dois. A conferência acontece no job de scan, na mesma leitura do hash, e divergência entre o declarado e o real reprova o arquivo.

A conferência é incondicional, inclusive com ScanRequired: false. A leitura única acontece de todo jeito, porque o sha256 é calculado no servidor; só o socket do clamd deixa de ser alimentado. Desligar o antivírus não desliga a RN-GF-03.

Três classes de tipo, três regras — sem elas, uma leitura literal de "assinatura de conteúdo decide" reprovaria todo CSV legítimo e todo xlsx legítimo:

  • Assinatura conhecida e inequívoca (PDF, PNG, JPEG, WebP): os primeiros bytes decidem. Divergir do declarado, ou cair fora da lista do perfil, reprova.
  • Container ZIP: .docx, .xlsx, .pptx e .zip começam todos com PK\x03\x04 — os quatro primeiros bytes não os distinguem. O detector abre o container e lê o [Content_Types].xml para resolver o tipo OOXML concreto, caindo para application/zip quando não resolver. Sem isso, um perfil que aceita planilha do Office mas não compactado ou reprova toda planilha, ou aceita um zip disfarçado.
  • Sem assinatura (text/csv, text/plain): texto não tem magic bytes. O conteúdo é validado como texto — decodificação válida e ausência de byte NUL — e o tipo declarado prevalece se estiver na lista do perfil. É o que barra o executável renomeado para .csv, porque binário carrega NUL, sem barrar a planilha de carga do produto.

Lista de permitidos, nunca de proibidos

Cada perfil declara o que aceita. Lista de proibidos é um jogo que se perde sempre: basta um formato novo. O perfil de anexo do Nexio nasce com PDF, imagem, documento e planilha do Office, texto e compactado.

HTML e SVG são código

SVG carrega script. Servido no mesmo domínio da aplicação, vira XSS armazenado — e o vetor entra pelo portal do fornecedor. Três defesas juntas: text/html e image/svg+xml fora da lista de permitidos nos perfis de anexo; todo download sai com Content-Disposition: attachment e Content-Type: application/octet-stream, que juntos impedem qualquer renderização no navegador; e o domínio é o do bucket, não o da aplicação, o que a URL pré-assinada já garante.

Correção da v1.2. A v1.1 prometia X-Content-Type-Options: nosniff na URL emitida. Isso não é possível: o conjunto de cabeçalhos que uma URL pré-assinada pode sobrepor é fechado e igual nos três provedores — content-type, content-language, expires, cache-control, content-disposition e content-encoding. Metadado customizado volta como x-amz-meta-*, não como o cabeçalho. O nosniff só entra por política de bucket ou CDN, e passa a ser recomendação de operação, não garantia da URL. A defesa real não depende dele: attachment mais octet-stream já impedem a renderização.

Tamanho é por perfil, nunca global

Anexo de requisição não precisa do mesmo teto de um contrato digitalizado. O Ariba é o exemplo de para onde isso vai: 100 MB de limite geral e 2 GB só para contrato. O teto do perfil é validado duas vezes — no declarado, antes de emitir a URL, e no real, na confirmação.

Storage

Chave do objeto

{tenantId | _global}/{profile}/{aaaa}/{MM}/{dd}/{fileId}.{ext}
_quarantine/{tenantId | _global}/{fileId}.{ext}

É o padrão que o ReportFilePathGenerator já usa no GrydReports, com o perfil a mais. Cada parte tem motivo:

PartePor quê
tenantId na raizIsolamento visível na chave. Permite política de bucket, ciclo de vida e relatório de custo por cliente sem consultar o banco — e torna evidente, olhando o caminho, se algo vazou de tenant.
PerfilAnexo, relatório e planilha de importação têm retenção e volume muito diferentes. Separados no caminho, aceitam regra de ciclo de vida diferente no próprio storage.
DataEvita diretório com milhões de objetos e dá um caminho barato para diagnóstico e para expurgo em massa.
fileId, não o nomeNome original traz acentuação, espaço, colisão, ../ e, com frequência, dado pessoal — "rescisão_joão_silva.pdf" não deveria estar no caminho de um objeto. O nome viaja no Content-Disposition do download, onde ele serve.
Extensão pelo tipoDerivada do contentType verificado, não do nome enviado. Só ajuda ferramenta de diagnóstico; nada no sistema decide por ela.
Prefixo de quarentenaSeparado, com política de acesso mais restrita e nenhuma URL pré-assinada emitida jamais. Move-se para lá, não se apaga.

Economia e integridade

Hash, dedupe e referência

O mesmo PDF de certidão anexado numa requisição, copiado para o pedido e reenviado por e-mail são três vínculos e um blob. Sem dedupe, seriam três cópias que envelhecem juntas e são expurgadas separadamente — e uma delas fica.

Um registro por envio, um objeto por conteúdo

Cada upload gera seu StoredFile — quem subiu, quando e com que perfil é informação que não se funde. Mas se o sha256 já existir no tenant em estado Available, o registro novo recebe canonicalFileId apontando para o antigo, o objeto recém-enviado é apagado e as leituras passam a servir o objeto canônico.

E a localização do registro deduplicado é zerada: objectKey, bucketName e storageProvider ficam nulos. Com isso canonicalFileId IS NULLobjectKey IS NOT NULL vira um fato verificável — a posse do blob passa a ter uma única representação. Sem essa regra, a varredura de consistência (§ Retenção) reportaria todo registro deduplicado como defeito da RN-GF-10, por ser registro sem purgedAt cujo objeto não existe. Copiar a chave do canônico foi avaliado e recusado: quando o blob canônico morresse, todos os reaproveitadores voltariam a apontar para chave morta e o mesmo falso positivo reapareceria em massa.

O dedupe nunca atravessa tenant

Mesmo que o hash seja idêntico. Compartilhar blob entre clientes economizaria armazenamento e criaria um canal de inferência — dá para descobrir que outro cliente tem o mesmo arquivo pelo tempo de resposta. Não vale o desconto.

Referência é do consumidor, contagem é do módulo

Quem cria vínculo chama AddReferenceAsync; quem desfaz chama ReleaseReferenceAsync. O módulo mantém referenceCount e nada mais sabe. No Nexio, o Attachment cria a referência ao nascer e a libera quando é revogado — o registro do anexo continua vivo, com revokedAt e motivo; o que caiu foi o direito do blob de continuar existindo.

Expurgo olha o canônico

O objeto só morre quando nenhum StoredFile que aponta para ele — o canônico e todos os que o reaproveitam — tem referência ativa ou legalHold. Um legalHold em qualquer um dos registros segura o blob para todos. É conservador de propósito: o erro caro é apagar o que ainda serve.

Ciclo

Retenção, expurgo e LGPD

O framework não apaga nada fisicamente: quem herda BaseEntity é ISoftDeletable por construção, e o filtro global esconde o que foi marcado. Guardar arquivo por prazo indeterminado, porém, é obrigação legal invertida — a LGPD manda eliminar o dado quando a finalidade acaba. A conciliação é a lápide: o registro segue as regras do framework, e o que é apagado de verdade é o objeto no bucket, que não é entidade.

Os três modos de retenção
ModoQuando usarComo morre
UntilReleasedPadrão. Relatório gerado, planilha de importação, rascunho — o arquivo existe enquanto alguém aponta para ele.Última referência liberada e período de carência vencido.
RetainUntilPrazo legal ou contratual: o consumidor calcula a data e a informa. No Nexio, anexo de documento fiscal com os cinco anos contados do fechamento do documento.Depois de retainUntil e sem referência ativa. As duas condições, sempre.
PermanentExceção com nome. Nunca entra no job.Só por POST /files/{id}/purge, com permissão de plataforma e motivo obrigatório.

A quarentena tem prazo próprio: 365 dias

QuarantineRetentionDays, global na seção GrydFiles e nunca por perfil — perfil a perfil permitiria encurtar a guarda de evidência escolhendo o perfil certo, que é o tipo de porta que este módulo fecha. Um ano cobre um ciclo completo de auditoria de segurança e a janela de reanálise forense, sem transformar o bucket em depósito permanente de malware. O que expira é o binário: o scanSignature, o sha256, o tamanho, o tipo, as datas e todo o histórico de FileScanAttempt ficam na lápide para sempre. legalHold ligado trava também este expurgo.

Carência antes de apagar

Referência liberada não expurga na mesma hora: há uma carência configurável em PurgeGracePeriodDays, padrão de 30 dias. É o intervalo em que se descobre que a exclusão foi engano — e depois dele não há mais volta. A contagem parte de lastReferenceReleasedAt, denormalizado no próprio StoredFile, e não de uma agregação sobre FileReference: liberação é append, o histórico nunca encolhe, e varrer a tabela filha por candidato é exatamente o que o referenceCount existe para evitar. O índice (status, referenceCount, lastReferenceReleasedAt) resolve a seleção do job em index scan.

A data só anda para frente

retainUntil pode ser estendido por quem tem a permissão, nunca encurtado por API. Encurtar prazo de guarda é como se apaga prova sem querer; quando é legítimo, o caminho é o expurgo explícito, que fica na trilha com autor e motivo.

Apagamento por titular esbarra em obrigação legal

A LGPD prevê a eliminação a pedido, e prevê também a guarda para cumprimento de obrigação legal. Quem sabe qual das duas prevalece é o consumidor, não a plataforma — por isso o legalHold é ligado por ele. O módulo garante o mecanismo e a trilha; a decisão jurídica não é dele.

O que sobra na lápide

sha256, tamanho, tipo, nome original, perfil, datas, resultado do scan, a localização onde o objeto esteve e quem pediu o expurgo. É o suficiente para responder "existiu um arquivo aqui, este era o conteúdo, foi apagado nesta data por este motivo" — sem guardar o conteúdo. Se o objetivo do apagamento for o próprio nome do arquivo, o consumidor pede o expurgo com redactFileName, e o nome também sai.

Quem pediu, quando o autor é um job. Três dos quatro caminhos de expurgo são automáticos e não têm usuário: o framework não tem conceito de usuário de sistema, e o ICurrentUserService.UserId devolve nulo fora de request autenticado. Então purgedAt e purgeReason são obrigatórios e gravados juntos, e purgedBy é nulo quando e somente quando o expurgo foi automático — com a origem nomeada no motivo (job:retencao, job:pending-expirado, job:quarentena-vencida) e a execução registrada no AuditLog. Um GUID de sistema reservado foi avaliado e recusado: seria um identificador sem linha correspondente em Users, sem chave estrangeira possível, que alguma tela tentaria resolver em nome e receberia vazio.

Fluxo

Download

Uma chamada devolve uma URL pré-assinada de leitura, com validade em minutos. O serviço nunca faz proxy do conteúdo.

RegraDetalhe
AvailablePending, Scanning, Failed, Infected e Purged devolvem 409 com o código próprio de cada um — a tela precisa distinguir "ainda verificando" de "infectado". A resposta de Purged traz purgedAt, o motivo e o rejectionCode, quando houve reprovação.
Validade curtaPadrão de 5 minutos, configurável por perfil em DownloadUrlTtlMinutes. A URL é a credencial: quanto mais longa, mais tempo ela vale se for colada num chat.
Autorização é de quem chamaO módulo confere a permissão genérica read:file. Quem decide se este usuário pode ver este documento é o consumidor, antes de pedir a URL. O GrydFiles não conhece o Nexio e não tem como avaliar visibilidade de anexo.
Nome no cabeçalhoContent-Disposition: attachment; filename*=UTF-8''… com o nome original do registro pedido, e Content-Type: application/octet-stream — nunca abertura no navegador. Exige estender o Core (ver a linha abaixo).
Extensão necessária no CoreO ObjectStoragePresignedUrlRequest não tem hoje sobreposição de cabeçalho de resposta. Este épico acrescenta ResponseContentDisposition e ResponseContentType ao contrato e os implementa nos três provedores: S3 por ResponseHeaderOverrides, Azure por BlobSasBuilder.ContentDisposition (rscd/rsct) e GCS por response-content-disposition na URL v4. Sem isso o usuário baixa a3f9….pdf em vez do nome que enviou.
Registro deduplicadoA URL aponta para o objeto canônico; o nome no Content-Disposition e todo o metadado exibido continuam sendo os do registro pedido. A regra de "só Available" é avaliada sobre o registro pedido.
Nunca sobre quarentenaNenhuma URL pré-assinada é emitida para objeto sob o prefixo _quarantine/, por nenhuma rota e com nenhuma permissão — purge:file e admin:system inclusive.
Cada emissão vai para a trilhaIAuditLogService.RecordAsync com arquivo, usuário, IP e finalidade. Emissão de URL não é mudança de linha e não seria capturada pelo interceptor de SaveChanges — precisa ser explícita.

Operação

Perfis e configuração

Perfil é configuração, não tabela. Nasce assim porque muda com deploy, não com uso: é o operador que decide o teto de tamanho, não o usuário. Personalização por tenant é gancho declarado — quando aparecer o primeiro caso real, vira tabela com a configuração como padrão.

Não existe perfil padrão. O UseDefaultProfile da v1.1 foi removido: a RN-GF-11 diz que não existe upload sem perfil e o profile é campo obrigatório, então o padrão nunca teria quando agir — é API sem chamador, que a premissa de "nada de código sem uso" classifica como defeito e não como preparação. Mantê-lo só criaria a categoria de bug "subiu no perfil errado por omissão", com os limites errados aplicados em silêncio.

O framework declara apenas perfis gryd.*. Os perfis nexio.* são configuração do produto e vivem no appsettings dele — pela decisão 1, o módulo não conhece o domínio, e o template da plataforma não deveria carregar a lista de tipos de anexo de um produto de compras. Aparecem abaixo como exemplo de consumidor.

builder.Services
    .AddGrydStorage()                      // já existe em Gryd.Infrastructure
    .AddGrydFiles(f => {
        f.UseScanner<ClamAvFileScanner>(); // GrydFiles.Infrastructure.ClamAv
    });
"GrydFiles": {
  "PurgeGracePeriodDays": 30,
  "QuarantineRetentionDays": 365,
  "Scanner": {
    "TimeoutSeconds": 120,
    "RetryDelays": [ "00:01:00", "00:05:00", "00:15:00" ],
    "StreamMaxLengthBytes": 67108864,
    "MaxFileSizeBytes": 67108864,
    "MaxScanSizeBytes": 419430400,
    "MaxRecursion": 16,
    "MaxFiles": 10000,
    "ConcurrentDatabaseReload": true,
    "UseIdSession": false
  },
  "Profiles": {
    "gryd.report": {
      "MaxSizeBytes": 52428800,
      "AllowedContentTypes": ["application/pdf", "text/csv",
        "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
      "ScanRequired": false,
      "DefaultRetentionMode": "UntilReleased"
    },
    "gryd.notification-attachment": {
      "MaxSizeBytes": 10485760,
      "AllowedContentTypes": ["application/pdf", "image/png", "image/jpeg", "text/csv"],
      "ScanRequired": true,
      "DefaultRetentionMode": "RetainUntil",
      "DefaultRetentionDays": 30
    }
  }
}

A seção Scanner é a fonte única do clamd.conf, que é renderizado a partir dela na subida do serviço — ver § Antivírus. StreamMaxLengthBytes menor que o maior MaxSizeBytes de qualquer perfil derruba a aplicação na inicialização.

Exemplo de consumidor · os perfis do Nexio

Vivem no appsettings do produto, não no template do framework.

"GrydFiles": {
  "Profiles": {
    "nexio.attachment": {
      "MaxSizeBytes": 26214400,
      "AllowedContentTypes": ["application/pdf", "image/png", "image/jpeg", "…"],
      "ScanRequired": true,
      "UploadUrlTtlMinutes": 15,
      "DownloadUrlTtlMinutes": 5,
      "DefaultRetentionMode": "UntilReleased"
    },
    "nexio.import": {
      "MaxSizeBytes": 20971520,
      "AllowedContentTypes": ["text/csv", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"],
      "ScanRequired": true,
      "DefaultRetentionMode": "RetainUntil",
      "DefaultRetentionDays": 90
    },
    "nexio.item-photo": {
      "MaxSizeBytes": 5242880,
      "AllowedContentTypes": ["image/png", "image/jpeg", "image/webp"],
      "ScanRequired": true,
      "DefaultRetentionMode": "UntilReleased"
    },
    "nexio.item-document": {
      "MaxSizeBytes": 26214400,
      "AllowedContentTypes": ["application/pdf", "image/png", "image/jpeg"],
      "ScanRequired": true,
      "DefaultRetentionMode": "RetainUntil",
      "DefaultRetentionDays": 1825
    },
    "nexio.supplier-document": {
      "MaxSizeBytes": 26214400,
      "AllowedContentTypes": ["application/pdf", "image/png", "image/jpeg"],
      "ScanRequired": true,
      "DefaultRetentionMode": "RetainUntil",
      "DefaultRetentionDays": 1825
    }
  }
}

gryd.notification-attachment e gryd.report são os dois perfis de plataforma; o primeiro foi acrescentado em 03/09/2026 pela spec de Notificação. O GrydNotifications guarda hoje o anexo de e-mail como byte[] numa coluna do banco — a mesma troca por storedFileId que Item e Supplier fizeram em 02/09. Trinta dias de retenção, porque anexo de e-mail entregue não é prova de nada depois que o e-mail chegou.

Os cinco perfis do Nexio. nexio.attachment é o anexo solto; nexio.import, a planilha de carga, com vida curta. Os três acrescentados em 02/09/2026 existem porque arquivo com metadado próprio não é anexo: nexio.item-photo só aceita imagem e morre com a foto; nexio.item-document e nexio.supplier-document guardam FISPQ, laudo e certidão por cinco anos — o prazo de decadência do CTN, o mesmo número que o tipo fiscal do anexo usa. Nenhum deles dispensa o scan: nexio.supplier-document é justamente o que o fornecedor sobe pelo portal.

ScanRequired: false existe para o arquivo que o próprio sistema gera — o PDF do GrydReports, que saiu do nosso processo e não passou por ninguém de fora. Para qualquer arquivo recebido, ligar essa opção é decisão de quem assina o risco, e ela vai para a trilha na inicialização. Ela dispensa o antivírus e nada mais: o sha256 continua sendo calculado no servidor e a conferência de tipo por assinatura de conteúdo continua valendo (§ Validação).

API

Endpoints

A superfície HTTP existe para a SPA. Módulo que roda no mesmo processo — o Nexio, o GrydReports — consome a interface IFileService diretamente, sem passar por HTTP; os dois caminhos executam o mesmo serviço de aplicação. Dez rotas: as nove da v1.1 mais o status do scanner, que a v1.2 acrescenta para dar chamador real ao FILE_SCAN_UNAVAILABLE.

RotaPermissãoDevolveErros
POST /filesupload:filefileId, uploadUrl pré-assinada, uploadExpiresAt e os cabeçalhos que o PUT precisa repetir400 · 403 · 422
POST /files/{id}/completeupload:fileStoredFileDto em Scanning. Idempotente: chamar duas vezes devolve o mesmo estado404 · 409 · 422
GET /files/{id}read:fileMetadado, estado, resultado do scan e rejectionCode. Nunca objectKey nem bucketName404
GET /files/{id}/download-urlread:fileURL assinada de leitura com validade curta. Registra a emissão na trilha404 · 409
POST /files/{id}/referencesupload:fileA referência criada. Idempotente por (escopo, chave)404 · 409
DELETE /files/{id}/references/{referenceId}upload:file204. Libera, não apaga — a linha fica com releasedAt404
GET /files/{id}/scan-attemptsread:fileO histórico completo de tentativas, ordenado por attemptNumber decrescente. Sem paginação: são no máximo quatro linhas por arquivo (a tentativa inicial e três re-tentativas)404
PUT /files/{id}/retentionretain:fileEstende retainUntil ou liga legalHold. Só para frente. Desligar legalHold exige admin:system404 · 422
POST /files/{id}/purgepurge:fileExpurgo manual com motivo obrigatório. Recusa com referência ativa ou legalHold404 · 409 · 422
GET /files/scanner-statusread:fileNovo na v1.2. Motor, versão e idade da base de assinaturas e profundidade da fila de scan, a partir do PING e do VERSION. É o chamador do FILE_SCAN_UNAVAILABLE, e é o que permite à SPA dizer "verificação indisponível, seu envio ficará na fila" em vez de deixar o usuário no escuro503

Não existe DELETE /files/{id}. Apagar arquivo é expurgo, tem motivo obrigatório e permissão própria — dar a ele o verbo mais fácil da API seria convidar ao acidente.

Contrato

Contrato de erros

Mesma convenção do resto da plataforma: o status HTTP vem do sufixo do código, nunca da mensagem. O ErrorCodeSuffixes do Gryd.Application hoje declara nove sufixos — _NOT_FOUND → 404 · _ALREADY_EXISTS, _ALREADY_ASSIGNED e _CONFLICT → 409 · _FORBIDDEN, _ACCESS_DENIED e _BLOCKED → 403 · _UNAUTHORIZED → 401 · _UNPROCESSABLE → 422 · sem sufixo → 400. Este documento acrescenta o décimo, _UNAVAILABLE → 503, e a mudança no mapeador do Core exige teste de regressão provando que os nove anteriores continuam mapeando igual.

O caminho assíncrono não tem request para responder. A conferência de tipo acontece no job de scan, depois de o cliente já ter recebido 200 da confirmação. Nesse caminho o código não vira status HTTP: ele é gravado em rejectionCode no registro e devolvido no corpo do FILE_PURGED_CONFLICT e no GET /files/{id}. Sem isso, quem subiu um .exe renomeado receberia a mesma resposta de um arquivo apagado por retenção há dois anos.

CódigoHTTPQuando
FILE_NOT_FOUND404Não existe neste tenant. Arquivo de outro tenant responde igual, de propósito
FILE_PROFILE_UNKNOWN400Perfil não está na configuração. Erro de programação do consumidor, não do usuário
FILE_CONTENT_TYPE_NOT_ALLOWED_UNPROCESSABLE422Tipo fora da lista do perfil — no declarado, ou no verificado durante o scan
FILE_TOO_LARGE_UNPROCESSABLE422Acima do teto do perfil. A mensagem carrega o teto, para a tela poder dizer qual é
FILE_SIZE_MISMATCH_UNPROCESSABLE422O tamanho real não bate com o declarado. O objeto é apagado e o registro vai para Purged
FILE_UPLOAD_EXPIRED_CONFLICT409Confirmação depois de uploadExpiresAt. O caminho é reservar de novo
FILE_NOT_UPLOADED_CONFLICT409Confirmação sem objeto no bucket — o PUT não aconteceu ou falhou
FILE_NOT_AVAILABLE_CONFLICT409Uso de arquivo que ainda está em Pending, Scanning ou Failed. É o estado normal de "espere um pouco"
FILE_INFECTED_CONFLICT409Código separado do anterior de propósito: a tela precisa dizer o que houve, e o suporte precisa distinguir fila de incidente
FILE_PURGED_CONFLICT409O registro existe, o conteúdo não. A resposta traz purgedAt, o motivo e o rejectionCode, quando o arquivo morreu por reprovação e não por retenção
FILE_STILL_REFERENCED_CONFLICT409Expurgo com referência ativa. A resposta lista os ownerScope que ainda apontam — sem isso ninguém descobre quem está segurando
FILE_LEGAL_HOLD_CONFLICT409Expurgo com legalHold ligado. Precisa ser desligado primeiro, com trilha
FILE_RETENTION_SHORTENED_UNPROCESSABLE422Tentativa de puxar retainUntil para trás
FILE_SCAN_UNAVAILABLE503Sufixo novo, proposto por este documento: _UNAVAILABLE → 503. Dependência externa fora do ar não é erro do cliente e não deve virar 400 nem 409 — e, sem um sufixo próprio, viraria. Chamador único: GET /files/scanner-status. Nenhuma outra rota fala com o clamd de forma síncrona; arquivo parado em Scanning continua respondendo FILE_NOT_AVAILABLE_CONFLICT

Segurança

Permissões

PermissãoO que libera
upload:fileReservar, confirmar e gerir referência. É a permissão que todo serviço consumidor tem
read:fileLer metadado, pedir URL de download, listar tentativas de scan e consultar o status do scanner. Não é autorização de negócio — quem decide se este usuário pode ver este documento é o consumidor
retain:fileEstender retenção e ligar legal hold. Separada porque prende custo de armazenamento por anos
purge:fileExpurgo manual. A mais restrita das quatro: é a única que destrói conteúdo
admin:systemJá existe no framework — é a permissão de plataforma usada por ICrossTenantAuthorizedBy e pelos controllers administrativos. Aqui libera as três operações que não são de tenant: arquivo de produto (tenantId nulo), desligar legal hold e expurgar arquivo em modo Permanent

Quatro permissões próprias, mais a de plataforma que já existia, e nenhuma que libere arquivo sem scan — admin:system inclusive. A ausência é a decisão.

Correção da v1.2. A v1.1 escrevia upload:file, read:file, retain:file e purge:file. O PermissionCodeParser do GrydAuth quebra o código na primeira : e lê o que vem antes como ação e o que vem depois como recurso — a convenção do catálogo é {ação}:{recurso} com o recurso no plural (read:users, update:tenants, create:permissions, admin:system). Na forma antiga a tela de permissões exibiria "File Upload" e agruparia por "upload" como se fosse um recurso. Os códigos foram invertidos para a convenção da casa; upload, retain e purge entram no ActionOrderMap do PermissionCatalogMetadata para manter a ordenação da tela.

Invariantes

Regras de negócio

RN-GF-01Nenhum arquivo é referenciável antes do veredito

FileReference só pode ser criada sobre Available. É a invariante central: tudo o mais no módulo existe para sustentá-la.

RN-GF-02O hash é sempre calculado pelo servidor

Na mesma leitura do antivírus, a partir do objeto gravado. Hash informado pelo cliente é ignorado, mesmo quando enviado.

RN-GF-03Tipo declarado que não bate com o conteúdo reprova o arquivo

A conferência é por assinatura de conteúdo, sempre — inclusive com ScanRequired: false, porque a leitura única já acontece para o hash. O objeto é apagado e o registro termina em Purged. No caminho síncrono (tipo declarado, antes da URL) a resposta é 422; no assíncrono (tipo verificado, no job) não há request para responder, e o motivo fica em rejectionCode.

RN-GF-04Infected é terminal

Não existe transição de volta, nem por permissão elevada. O objeto vai para a quarentena e a assinatura fica gravada para sempre.

RN-GF-05Falha de infraestrutura nunca libera arquivo

clamd indisponível produz Failed e nova tentativa. Não há modo degradado, timeout que libere, nem flag de emergência.

RN-GF-06O binário não passa pela API

Subida e descida sempre por URL pré-assinada. Não existe rota que aceite ou devolva o conteúdo do arquivo.

RN-GF-07objectKey e bucketName nunca saem do módulo

Não aparecem em DTO, resposta de API nem log de aplicação. Quem consome recebe fileId e, quando precisa, uma URL que vence. Dentro do módulo, a localização é prova de posse: preenchida no dono do blob, nula no registro deduplicado, preservada na lápide.

RN-GF-08Dedupe nunca atravessa tenant

Mesmo com sha256 idêntico. O índice único é sempre (tenantId, sha256).

RN-GF-09Retenção só se estende

retainUntil nunca é puxado para trás por API, e Permanent jamais entra no job de expurgo.

RN-GF-10Expurgo apaga o objeto e preserva a lápide

Nunca o contrário. purgedAt e purgeReason são gravados juntos, no mesmo método; purgedBy é nulo quando e somente quando o autor foi um job. Registro dono de blob sem purgedAt com objeto ausente é defeito, e a varredura de consistência reporta — registro deduplicado não entra na conta, porque não tem objectKey.

RN-GF-11Todo arquivo tem perfil, e todo perfil tem teto

Não existe upload sem perfil, nem perfil sem tamanho máximo, lista de tipos e modo de retenção padrão. O perfil é sempre explícito: não há perfil padrão para o qual cair por omissão, e perfil incompleto derruba a aplicação na inicialização.

RN-GF-12Emissão de URL de download vai para a trilha

Registrada por IAuditLogService.RecordAsync, porque leitura não passa por SaveChanges e o interceptor não a veria. Quem baixou o quê é a pergunta que uma auditoria de vazamento faz primeiro.

v1.2 · 08/09/2026

Dezesseis decisões de detalhamento

A v1.1 foi validada contra o backlog do Epic 1376 em 08/09/2026, item a item. A cobertura de escopo estava completa — cada seção tinha história dona e nenhuma história inventava escopo. O que faltava eram decisões que o documento não tomava e que seriam tomadas por quem pegasse o cartão: seis contradições e dez valores em branco. Estão fechadas abaixo, e já refletidas nas seções acima.

O que mudou e por quê
#DecisãoOnde
D1Permissões invertidas para a convenção da casa: upload:file, read:file, retain:file, purge:file. A v1.1 usava upload:file, que o PermissionCodeParser leria como ação file. Permissão de plataforma é o admin:system que já existe§ Permissões
D2sha256 anulável e dois índices parciais com sha256 IS NOT NULL. UNIQUE NULLS NOT DISTINCT recusado: faria os Pending do mesmo tenant colidirem entre si§ Campos
D3Localização nula no registro deduplicado, preservada no expurgado. Sem isso a varredura de consistência reportaria todo deduplicado como defeito§ Dedupe
D4Campo rejectionCode, porque a reprovação por tipo é assíncrona e não tem request para devolver 422. Estado Rejected separado foi avaliado e recusado pelo custo de reescrita§ Erros
D5Core estendido com ResponseContentDisposition e ResponseContentType na URL pré-assinada, nos três provedores. O nosniff sai da URL — é impossível nos três — e vira recomendação de bucket/CDN; a defesa passa a ser attachment mais application/octet-stream§ Download
D6Rota GET /files/scanner-status como chamador real do FILE_SCAN_UNAVAILABLE, alimentada pelo PING. Sem ela o código e o sufixo novo no Core nasceriam sem uso§ Endpoints
D7Sniffing incondicional, com regra por classe de tipo: assinatura conhecida decide; container ZIP é aberto para resolver o tipo OOXML; text/* valida como texto. Sem isso, fail-closed puro reprovaria todo CSV e todo xlsx legítimo§ Validação
D8Campo lastReferenceReleasedAt para a carência. Agregar FileReference por candidato é o que o referenceCount existe para evitar, e a carga cresceria para sempre§ Retenção
D9QuarantineRetentionDays: 365, global e nunca por perfil. Um ano cobre auditoria e reanálise forense sem virar depósito de malware; a assinatura fica na lápide para sempre§ Retenção
D10Migração do GrydNotifications não bloqueia por anexo infectado: ele é quarentenado, não cria referência e entra no relatório. O plano de verificação passa a exigir migrados + infectados = origem, com a lista assinada antes do DROP§ Consumidores
D11Download de relatório muda de stream para URL, como quebra assumida, sem shim nem período de convivência. Um proxy de compatibilidade manteria justamente a violação da decisão 2 que o épico existe para corrigir
D12UseDefaultProfile removido e perfis nexio.* movidos para o appsettings do produto. Perfil padrão é API sem chamador; perfil de produto no template do framework contraria a decisão 1§ Perfis
D13Valores do clamd.conf: StreamMaxLength e MaxFileSize 64M, MaxScanSize 400M, MaxRecursion 16, MaxFiles 10000. O padrão do clamd é 25M — exatamente o maior perfil, sem folga para o enquadramento do INSTREAM§ Antivírus
D14Retry de 1, 5 e 15 minutos; timeout de 120 s. As três tentativas cobrem ~21 minutos, que absorvem reinício de contêiner e recarregamento de base§ Antivírus
D15O clamd.conf é gerado a partir do appsettings. Não há comando do protocolo que leia a configuração do daemon, então validar contra um valor espelhado atestaria algo que pode não ser verdade§ Antivírus
D16purgedBy nulo em expurgo automático, com a origem no purgeReason. O framework não tem usuário de sistema, e inventar um GUID reservado criaria identificador sem linha em Users§ Campos

Consequência de sequenciamento. A validação também mostrou que a Feature de antivírus depende da Feature de infraestrutura: as histórias do IFileScanner e do ClamAvFileScanner exigem um clamd em execução, que só é entregue pelo contêiner da F8. A F8 precisa ser executada antes da F4, e o teste de EICAR contra motor real pertence à história do provedor, não à da abstração — que entrega interface e fake.

Referência

Referência de mercado

ProdutoO que fazO que aproveitamos
Oracle E-Business Suite / FusionSepara FND_DOCUMENTS (o documento) de FND_ATTACHED_DOCUMENTS (o vínculo, com ENTITY_NAME + PK1..PK5_VALUE), e exige registro prévio da entidade que pode receber anexo. O conteúdo em si vive no repositório de documentosAs duas camadas e o registro do consumidor. As cinco colunas de chave existem porque as chaves do EBS são compostas; aqui é um Guid
SAPAnexo sai do módulo de negócio e vai para repositório externo por ArchiveLink/DMS. O documento guarda a referência, não o arquivoA ideia de que arquivo é infraestrutura, não domínio — e de que o módulo de negócio nunca deveria conhecer storage
SAP AribaTeto de 100 MB de anexo, elevado a 2 GB especificamente para contratoA confirmação de que limite é por contexto, não global. É a origem do perfil
Amazon S3 · presigned URLURL assinada com escopo de operação, objeto e prazo, para tirar o binário do caminho da aplicaçãoO desenho inteiro do fluxo de duas fases — que o Gryd.Infrastructure já implementa nos três provedores
Amazon GuardDuty for S3Scan gerenciado no bucket, com cotas de 100 GB por objeto, 100.000 arquivos extraídos e 25 buckets por conta e regiãoProvedor alternativo de IFileScanner, não o padrão. As cotas por conta são o limite prático para plataforma multi-cliente
ClamAVMotor de assinatura com daemon residente, protocolo INSTREAM e base atualizada por freshclamO provedor padrão. Ver § Antivírus

Limites

Fora de escopo

AssuntoPor quê
Versionamento de documento"A versão 3 do contrato" é conceito de negócio, com aprovação e vigência. Aqui, versão nova é arquivo novo com referência nova. Quem encadeia é o consumidor
Preview, miniatura e conversãoRenderizar PDF e gerar miniatura pede biblioteca pesada e é superfície de ataque conhecida. Volta como módulo próprio se aparecer demanda real
OCR e extração de conteúdoOutro problema, com outro custo e outra precisão. O gancho é o sha256: dá para indexar depois sem tocar no módulo
Assinatura eletrônica e ICP-BrasilGancho declarado. Assinar documento é fluxo com signatário, ordem e carimbo de tempo — módulo próprio, que consumiria este
Criptografia por chave do clienteGancho declarado. Hoje vale a criptografia em repouso do provedor. Chave por tenant em KMS muda o expurgo — destruir a chave passa a ser uma forma de apagar — e merece decisão própria
Upload resumível e multipartGancho declarado. Necessário acima de alguns GB; com o teto atual dos perfis, complexidade sem retorno
Perfil configurável por tenantNasce em appsettings. Vira tabela quando existir o primeiro cliente que precise de teto diferente — e não antes
CDN e link público permanenteConflita com URL de vida curta, que é o controle de acesso do módulo. Ativo público é outro problema
Antivírus como serviço multi-tenant isoladoUm clamd compartilhado atende todos os tenants: ele vê bytes, não contexto, e não guarda nada. Isolar por cliente multiplicaria por N os 4 GiB de memória sem ganho de segurança