收紧报销草稿读取边界 [09-04-reimbursement-state-gap-finance-view][R2]
这个提交包含在:
@@ -686,3 +686,56 @@ await prisma.$transaction(async (tx) => {
|
||||
await writeRequiredAudit(tx, request, "CONTRACT_REGISTRY_TERMINATED", id, { reason });
|
||||
});
|
||||
```
|
||||
## Scenario: Reimbursement Draft Business-Fact Visibility
|
||||
|
||||
### 1. Scope / Trigger
|
||||
|
||||
- Trigger: changing reimbursement list/detail/material/search/export/statistics read scope or adding a new record-level read surface.
|
||||
- `expense.view_all` covers submitted business facts; it does not turn another employee's never-submitted draft into a shared ledger record.
|
||||
|
||||
### 2. Signatures
|
||||
|
||||
- Shared builder: `buildReadableReimbursementWhere({ employee, scope, filters })` in `server/src/services/reimbursementVisibility.js`.
|
||||
- Object check: `canReadReimbursementBusinessFact(prisma, reimbursement, employeeId)`.
|
||||
- Workflow fact: `workflow_instances.entity_type='reimbursement' AND entity_id=<reimbursement-id>`; Prisma relation filters may use `workflow_instances: { some: {} }` because the relation is reimbursement-specific.
|
||||
|
||||
### 3. Contracts
|
||||
|
||||
- Owner or submitter keeps existing access to their draft. A non-draft keeps the existing role/team scope. Another employee's draft is readable only if at least one reimbursement workflow instance exists.
|
||||
- Returned and withdrawn drafts remain business facts regardless of the latest workflow status or outcome. Never replace existence with a pending/latest/outcome whitelist.
|
||||
- Apply the shared condition before pagination/counting to list, global search, reimbursement exports and dashboard/statistics. Raw SQL uses an indexed `EXISTS` on `entity_type + entity_id`.
|
||||
- Detail, attachment file, attachment preview URL, material bundle and read-only `precheck` fail closed through the same fact check. A current or historical workflow reviewer remains independently readable through task assignment and is not granted edit permission.
|
||||
|
||||
### 4. Validation & Error Matrix
|
||||
|
||||
- Other employee + `draft` + no workflow -> excluded from collections and `403 PERM_001` on known-ID reads without revealing why.
|
||||
- Other employee + `draft` + workflow exists -> existing global/team/reviewer scope applies.
|
||||
- Owner/submitter draft -> existing own permission applies without requiring workflow history.
|
||||
- Missing status/object or failed fact lookup -> deny; never assume a missing value is non-draft.
|
||||
|
||||
### 5. Good/Base/Bad Cases
|
||||
|
||||
- Good: finance sees a returned draft but not a colleague's unfinished first draft; the same result holds in list, search, export, statistics and materials.
|
||||
- Base: a reviewer without `expense.view_all` opens the reimbursement attached to a current or historical assigned task.
|
||||
- Bad: hide drafts only in React, filter every `draft`, or let `precheck` expose policy/risk facts for an otherwise unreadable draft.
|
||||
|
||||
### 6. Tests Required
|
||||
|
||||
- Table-driven global/team/own scope and owner/submitter/non-draft/returned/withdrawn/never-submitted classification.
|
||||
- Route tests cover all five object-level read entries, including `precheck`, plus a reviewer non-regression.
|
||||
- Structural and route tests keep list, search, both reimbursement exports, dashboard/statistics and raw SQL on the shared fact contract.
|
||||
|
||||
### 7. Wrong vs Correct
|
||||
|
||||
#### Wrong
|
||||
|
||||
```js
|
||||
if (hasPermission(employee, "expense.view_all")) return reimbursement;
|
||||
```
|
||||
|
||||
#### Correct
|
||||
|
||||
```js
|
||||
const where = buildReadableReimbursementWhere({ employee, scope: "all", filters });
|
||||
await canReadReimbursementBusinessFact(prisma, reimbursement, employee.id);
|
||||
```
|
||||
|
||||
在新工单中引用
屏蔽一个用户