---
title: "Service audit, data cards"
description: "The data context of the service review: 2 cards, each with a FAIL and a PASS example and the organisations that back the rule."
canonical: "https://goodcodereview.com/service-data"
updated: "2026-11-06"
---

# service, data

The stable service cards for the `data` context. The whole level is at https://goodcodereview.com/service.md.

## svc-export-scope

severity: block  
concerns: security  
applies_when: An endpoint or job produces a file, archive or stream of many records.  
context: data  
tool gate: A test that exports as a second tenant and asserts the other tenant's rows are absent.  
Backed by: OWASP (unjudged), Atlassian (unjudged), Microsoft (unjudged), Google (unjudged). Backing: unjudged.

**Rule.** Scope an export to what the requesting principal may read.

**Why.** An export is a read that returns many rows at once. The per-object check on the detail page never runs, so a `SELECT *` with no tenant or access predicate hands the caller the table. Apply the same filter the list endpoint uses, including any role or ownership predicate and not only the tenant, and apply it inside the export query rather than on rows already fetched. OWASP's examples put the tenant scope in the query itself. Test it with two tenants. A restore asks the same question in reverse: a shared backup holds several tenants and old memberships, so restore one tenant into a separate resource and re-apply current access and retention before anyone can read it. A recovered environment also needs the production controls, not the ones it had when the backup was taken.

**FAIL**

```text
GET /export  ->  SELECT * FROM orders; streamed to any authenticated caller
```

**PASS**

```python
def export(user):
    rows = session.query(Order).filter(Order.tenant_id == user.tenant_id)
    return csv_stream(rows)
```

**Counterpoint.** "Some exports deliberately include everything." Some products ship an admin XML export that includes pages the exporter cannot view. That path is an administrative backup, so gate it on an admin permission and audit it. The user-facing exports in the same products contain only what the exporter can view, which is the rule here. Check which export path the change touches before calling a missing filter a bug.

## svc-soft-delete-default

severity: should-fix  
concerns: security, correctness  
applies_when: A table or model marks a row as deleted instead of removing it.  
context: data  
tool gate: A test per read path: soft-delete a row, then assert the endpoint returns 404 or leaves it out of the list.  
Backed by: Microsoft (unjudged), Laravel (unjudged), Sequelize (unjudged), OWASP (unjudged). Backing: unjudged.

**Rule.** Filter soft-deleted rows out of every read path by default, and make a read that includes them an explicit call.

**Why.** A soft delete sets a column. It does not remove the row, so any query that forgets the filter still returns the record. One raw query or one new endpoint is enough to expose it. Put the filter in the model's default scope, so new queries inherit it, and keep the escape hatch visible. A deletion policy has to reach caches, exports, replicas and object versions too, or the record lives on in a copy.

**FAIL**

```csharp
modelBuilder.Entity<Document>();                 // no HasQueryFilter
var doc = await context.Documents.FindAsync(42); // returns the soft-deleted row
```

**PASS**

```csharp
modelBuilder.Entity<Document>().HasQueryFilter(d => d.DeletedAt == null);

// The one admin report that needs deleted rows, behind an admin check:
var all = await context.Documents.IgnoreQueryFilters().ToListAsync();
```

**Counterpoint.** "The admin console has to see deleted rows." Keep that one call explicit, name it for what it does, and check the caller is an admin. The default stays closed. Raw SQL and reporting replicas are the usual bypass, so route them through the same helper or audit them. A service that hard-deletes does not need the filter, but backups and object versions can still hold the old copy.
