@filelayer/core 0.4.0 → 0.4.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +223 -41
- package/README.md +16 -9
- package/dist/db.d.ts.map +1 -1
- package/dist/db.js +27 -1
- package/dist/db.js.map +1 -1
- package/dist/filelayer.d.ts +22 -9
- package/dist/filelayer.d.ts.map +1 -1
- package/dist/filelayer.js +21 -2
- package/dist/filelayer.js.map +1 -1
- package/dist/simple.d.ts +1 -1
- package/dist/store.d.ts +98 -7
- package/dist/store.d.ts.map +1 -1
- package/dist/store.js +115 -18
- package/dist/store.js.map +1 -1
- package/package.json +1 -1
- package/src/db.ts +28 -1
- package/src/filelayer.ts +25 -11
- package/src/store.ts +243 -28
- package/test/audit-resolution.test.ts +379 -0
package/src/filelayer.ts
CHANGED
|
@@ -58,8 +58,9 @@ import {
|
|
|
58
58
|
toCapabilities,
|
|
59
59
|
LIST_DEFAULT_LIMIT,
|
|
60
60
|
LIST_MAX_LIMIT,
|
|
61
|
-
type
|
|
61
|
+
type AuditFilter,
|
|
62
62
|
type AuditChainResult,
|
|
63
|
+
type ResolvedAuditRow,
|
|
63
64
|
} from './store.ts';
|
|
64
65
|
import {
|
|
65
66
|
canList,
|
|
@@ -1646,24 +1647,37 @@ export class Filelayer {
|
|
|
1646
1647
|
// Audit
|
|
1647
1648
|
// ---------------------------------------------------------------------------
|
|
1648
1649
|
|
|
1649
|
-
/**
|
|
1650
|
+
/**
|
|
1651
|
+
* "Who touched this?", answered in the words the caller used.
|
|
1652
|
+
*
|
|
1653
|
+
* Requires `read_audit` in the org, which is admin+. Asked of the engine.
|
|
1654
|
+
*
|
|
1655
|
+
* Each row is a `ResolvedAuditRow`: the stored `actorId`, `fileId` and
|
|
1656
|
+
* `orgId` are present and unchanged, and alongside them are `.actor`,
|
|
1657
|
+
* `.file` and `.org`, carrying the external ids the caller supplied, plus a
|
|
1658
|
+
* `.summary` line that prints as an answer:
|
|
1659
|
+
*
|
|
1660
|
+
* ```text
|
|
1661
|
+
* 2026-09-06T10:12:41.002Z marco file.read deny:grant_revoked contract.pdf @acme
|
|
1662
|
+
* ```
|
|
1663
|
+
*
|
|
1664
|
+
* Resolution is a join on the same statement, so this remains one query.
|
|
1665
|
+
* `.actor.label` is `'anonymous'` for a link redemption, `.org.label` is
|
|
1666
|
+
* `'system'` on the system chain, and an id that resolves to nothing in this
|
|
1667
|
+
* project (a probe) keeps the uuid as its label with `resolution:
|
|
1668
|
+
* 'unresolved'` -- there is no null to interpret and nothing is invented.
|
|
1669
|
+
*/
|
|
1650
1670
|
async auditLog(
|
|
1651
1671
|
principal: Principal,
|
|
1652
1672
|
orgId: string,
|
|
1653
|
-
filter: {
|
|
1654
|
-
|
|
1655
|
-
fileId?: string;
|
|
1656
|
-
actorId?: string;
|
|
1657
|
-
action?: string;
|
|
1658
|
-
limit?: number;
|
|
1659
|
-
} = {},
|
|
1660
|
-
): Promise<AuditRow[]> {
|
|
1673
|
+
filter: AuditFilter = {},
|
|
1674
|
+
): Promise<ResolvedAuditRow[]> {
|
|
1661
1675
|
const decision = await authorizeOrg(this.store, principal, orgId, 'read_audit', {
|
|
1662
1676
|
action: 'audit.read',
|
|
1663
1677
|
emitAllow: false, // reading the log should not spam the log
|
|
1664
1678
|
});
|
|
1665
1679
|
this.#raise(decision);
|
|
1666
|
-
return this.store.
|
|
1680
|
+
return this.store.listAuditResolved(orgId, filter);
|
|
1667
1681
|
}
|
|
1668
1682
|
|
|
1669
1683
|
/**
|
package/src/store.ts
CHANGED
|
@@ -114,6 +114,120 @@ export interface AuditRow {
|
|
|
114
114
|
hash: string;
|
|
115
115
|
}
|
|
116
116
|
|
|
117
|
+
// -----------------------------------------------------------------------------
|
|
118
|
+
// THE AUDIT LOG, IN THE CALLER'S OWN VOCABULARY
|
|
119
|
+
// -----------------------------------------------------------------------------
|
|
120
|
+
//
|
|
121
|
+
// THE PROBLEM. `AuditRow` answers "who touched this?" in `actor_id`,
|
|
122
|
+
// `file_id` and `org_id` -- internal uuids. They are the right thing to store
|
|
123
|
+
// (they are stable, they survive a rename, and they are what the engine
|
|
124
|
+
// decides over) and the wrong thing to *hand back*, because the caller never
|
|
125
|
+
// supplied one. A developer who wrote `as: 'marco'` gets `97cf1649-dd8...`
|
|
126
|
+
// back and has no supported way to turn it into `marco` again.
|
|
127
|
+
//
|
|
128
|
+
// THE SHAPE OF THE FIX. Resolution is additive and presentational: every
|
|
129
|
+
// internal id below is still on the row, unchanged, in the field it has always
|
|
130
|
+
// been in. What is added is a `.actor`, `.file` and `.org` view of the same
|
|
131
|
+
// three ids carrying the identifier the caller supplied, plus a `.summary`
|
|
132
|
+
// line that is printable without any further work. Nothing about the data
|
|
133
|
+
// model, the chain digest or the authorization decision changes -- these
|
|
134
|
+
// values are read through a LEFT JOIN in the SAME query that reads the events.
|
|
135
|
+
//
|
|
136
|
+
// WHAT IS NOT DONE HERE. The joins are scoped to the store's project, exactly
|
|
137
|
+
// like every other read on this class. An audit event may legitimately name an
|
|
138
|
+
// identifier from outside the project -- a probe at a uuid that belongs to
|
|
139
|
+
// another application, or one that belongs to nothing at all -- and such an id
|
|
140
|
+
// must resolve to NOTHING rather than to a foreign customer's vocabulary. That
|
|
141
|
+
// is P8, and it is why these joins carry the project predicate even though the
|
|
142
|
+
// caller has already been authorized for the org.
|
|
143
|
+
|
|
144
|
+
/** How a `label` was arrived at. Never inferred by the caller. */
|
|
145
|
+
export type AuditRefResolution =
|
|
146
|
+
/** The id resolved to an identifier the caller supplied. */
|
|
147
|
+
| 'resolved'
|
|
148
|
+
/** There is no actor: an anonymous or share-link request. Not "unknown". */
|
|
149
|
+
| 'anonymous'
|
|
150
|
+
/** There is no tenant: the system chain (`org_id IS NULL`). */
|
|
151
|
+
| 'system'
|
|
152
|
+
/** The event is not about a file. */
|
|
153
|
+
| 'none'
|
|
154
|
+
/** An id is recorded but names no row in this project. A probe, typically. */
|
|
155
|
+
| 'unresolved';
|
|
156
|
+
|
|
157
|
+
interface AuditRefBase {
|
|
158
|
+
/** The internal id, exactly as the log stored it. `null` when none was recorded. */
|
|
159
|
+
id: string | null;
|
|
160
|
+
/**
|
|
161
|
+
* What to print. **Never null**, so a template literal can never render
|
|
162
|
+
* `null` or `undefined` into an operator's console.
|
|
163
|
+
*/
|
|
164
|
+
label: string;
|
|
165
|
+
resolution: AuditRefResolution;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** The principal named by an event. `resolution` is `anonymous` when there is none. */
|
|
169
|
+
export interface AuditActorRef extends AuditRefBase {
|
|
170
|
+
/** The id the caller supplied (`as: 'marco'`). `null` when nothing resolved. */
|
|
171
|
+
externalId: string | null;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** The tenant an event belongs to. `resolution` is `system` for the system chain. */
|
|
175
|
+
export interface AuditOrgRef extends AuditRefBase {
|
|
176
|
+
/** The tenant id the caller supplied (`org: 'acme'`). `null` for the system chain. */
|
|
177
|
+
externalId: string | null;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* The file an event is about.
|
|
182
|
+
*
|
|
183
|
+
* A file has no `external_id` -- the caller never names one; they are handed a
|
|
184
|
+
* uuid at upload. Its readable handle is therefore the name it was stored
|
|
185
|
+
* under, and the field says so rather than calling a filename an external id.
|
|
186
|
+
*/
|
|
187
|
+
export interface AuditFileRef extends AuditRefBase {
|
|
188
|
+
name: string | null;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* An audit event with its three identifiers resolved.
|
|
193
|
+
*
|
|
194
|
+
* Extends `AuditRow`: every internal id is still present and unchanged, so
|
|
195
|
+
* anything already reading `actorId`, `fileId` or `orgId` keeps working.
|
|
196
|
+
*/
|
|
197
|
+
export interface ResolvedAuditRow extends AuditRow {
|
|
198
|
+
actor: AuditActorRef;
|
|
199
|
+
file: AuditFileRef;
|
|
200
|
+
org: AuditOrgRef;
|
|
201
|
+
/**
|
|
202
|
+
* The whole event as one printable line, e.g.
|
|
203
|
+
*
|
|
204
|
+
* ```text
|
|
205
|
+
* 2026-09-06T10:12:41.002Z marco file.read deny:grant_revoked contract.pdf @acme
|
|
206
|
+
* ```
|
|
207
|
+
*/
|
|
208
|
+
summary: string;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
/** The label for an event with no actor. A link redemption is not "unknown". */
|
|
212
|
+
export const AUDIT_ANONYMOUS_LABEL = 'anonymous';
|
|
213
|
+
/** The label for the system chain, which has no tenant. */
|
|
214
|
+
export const AUDIT_SYSTEM_LABEL = 'system';
|
|
215
|
+
/** The label for an event that is not about a file. */
|
|
216
|
+
export const AUDIT_NO_FILE_LABEL = 'none';
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* One line an operator can read. Deliberately fixed-order and greppable
|
|
220
|
+
* rather than prose: `when who what outcome file @org`.
|
|
221
|
+
*/
|
|
222
|
+
export function auditSummary(row: Omit<ResolvedAuditRow, 'summary'>): string {
|
|
223
|
+
const outcome =
|
|
224
|
+
row.decision === 'allow' ? 'allow' : `deny:${row.reason ?? 'unspecified'}`;
|
|
225
|
+
const parts = [row.occurredAt.toISOString(), row.actor.label, row.action, outcome];
|
|
226
|
+
if (row.file.id !== null) parts.push(row.file.label);
|
|
227
|
+
parts.push(`@${row.org.label}`);
|
|
228
|
+
return parts.join(' ');
|
|
229
|
+
}
|
|
230
|
+
|
|
117
231
|
/**
|
|
118
232
|
* PGlite returns text[] as a JS array already; node-postgres does too. This
|
|
119
233
|
* guard exists because a raw text round-trip ('{read,write}') would otherwise
|
|
@@ -708,35 +822,10 @@ export class PostgresStore implements AuthzDeps {
|
|
|
708
822
|
);
|
|
709
823
|
}
|
|
710
824
|
|
|
711
|
-
async listAudit(
|
|
712
|
-
orgId: string | null,
|
|
713
|
-
filter: {
|
|
714
|
-
decision?: 'allow' | 'deny';
|
|
715
|
-
fileId?: string;
|
|
716
|
-
actorId?: string;
|
|
717
|
-
action?: string;
|
|
718
|
-
limit?: number;
|
|
719
|
-
} = {},
|
|
720
|
-
): Promise<AuditRow[]> {
|
|
825
|
+
async listAudit(orgId: string | null, filter: AuditFilter = {}): Promise<AuditRow[]> {
|
|
721
826
|
const params: unknown[] = [orgId];
|
|
722
|
-
const clauses =
|
|
723
|
-
|
|
724
|
-
params.push(filter.decision);
|
|
725
|
-
clauses.push(`decision = $${params.length}`);
|
|
726
|
-
}
|
|
727
|
-
if (filter.fileId) {
|
|
728
|
-
params.push(filter.fileId);
|
|
729
|
-
clauses.push(`file_id = $${params.length}`);
|
|
730
|
-
}
|
|
731
|
-
if (filter.actorId) {
|
|
732
|
-
params.push(filter.actorId);
|
|
733
|
-
clauses.push(`actor_id = $${params.length}`);
|
|
734
|
-
}
|
|
735
|
-
if (filter.action) {
|
|
736
|
-
params.push(filter.action);
|
|
737
|
-
clauses.push(`action = $${params.length}`);
|
|
738
|
-
}
|
|
739
|
-
params.push(Math.min(filter.limit ?? 500, 5000));
|
|
827
|
+
const clauses = auditFilterClauses('', 1, filter, params);
|
|
828
|
+
params.push(auditLimit(filter));
|
|
740
829
|
const { rows } = await this.db.query<Record<string, never>>(
|
|
741
830
|
`${AUDIT_COLUMNS}
|
|
742
831
|
WHERE ${clauses.join(' AND ')}
|
|
@@ -747,6 +836,50 @@ export class PostgresStore implements AuthzDeps {
|
|
|
747
836
|
return rows.map(mapAuditRow);
|
|
748
837
|
}
|
|
749
838
|
|
|
839
|
+
/**
|
|
840
|
+
* `listAudit`, with `actor_id`, `file_id` and `org_id` resolved back to the
|
|
841
|
+
* identifiers the caller supplied. See `ResolvedAuditRow`.
|
|
842
|
+
*
|
|
843
|
+
* ONE QUERY. The resolution is three LEFT JOINs on the same statement, not a
|
|
844
|
+
* lookup per row: an audit read is on the operator's critical path during an
|
|
845
|
+
* incident and turning it into N+1 round trips would be a worse defect than
|
|
846
|
+
* the one this fixes. Each join is scoped to this store's project, so an id
|
|
847
|
+
* belonging to another application resolves to nothing (P8) rather than
|
|
848
|
+
* leaking a foreign customer's vocabulary into this tenant's trail.
|
|
849
|
+
*
|
|
850
|
+
* Soft-deleted rows still resolve, deliberately. Deleting a user must not
|
|
851
|
+
* rewrite what they did -- the same reason `audit_event.actor_id` carries no
|
|
852
|
+
* foreign key.
|
|
853
|
+
*/
|
|
854
|
+
async listAuditResolved(
|
|
855
|
+
orgId: string | null,
|
|
856
|
+
filter: AuditFilter = {},
|
|
857
|
+
): Promise<ResolvedAuditRow[]> {
|
|
858
|
+
const params: unknown[] = [this.projectId, orgId];
|
|
859
|
+
const clauses = auditFilterClauses('e.', 2, filter, params);
|
|
860
|
+
params.push(auditLimit(filter));
|
|
861
|
+
const { rows } = await this.db.query<Record<string, unknown>>(
|
|
862
|
+
`SELECT e.id, e.org_id, e.occurred_at, e.action, e.decision, e.reason,
|
|
863
|
+
e.actor_id, e.file_id, e.grant_id, host(e.ip) AS ip, e.user_agent,
|
|
864
|
+
e.context, e.prev_hash, e.hash,
|
|
865
|
+
a.external_id AS actor_external_id,
|
|
866
|
+
o.external_id AS org_external_id,
|
|
867
|
+
f.name AS file_name
|
|
868
|
+
FROM audit_event e
|
|
869
|
+
LEFT JOIN actor a ON a.id = e.actor_id
|
|
870
|
+
AND ($1::uuid IS NULL OR a.project_id = $1::uuid)
|
|
871
|
+
LEFT JOIN org o ON o.id = e.org_id
|
|
872
|
+
AND ($1::uuid IS NULL OR o.project_id = $1::uuid)
|
|
873
|
+
LEFT JOIN file f ON f.id = e.file_id
|
|
874
|
+
AND ($1::uuid IS NULL OR f.project_id = $1::uuid)
|
|
875
|
+
WHERE ${clauses.join(' AND ')}
|
|
876
|
+
ORDER BY e.id ASC
|
|
877
|
+
LIMIT $${params.length}`,
|
|
878
|
+
params,
|
|
879
|
+
);
|
|
880
|
+
return rows.map(mapResolvedAuditRow);
|
|
881
|
+
}
|
|
882
|
+
|
|
750
883
|
/**
|
|
751
884
|
* Full chain replay. Returns the first inconsistency found, if any.
|
|
752
885
|
* Pass `null` to verify the system chain.
|
|
@@ -949,6 +1082,50 @@ const AUDIT_COLUMNS = `SELECT id, org_id, occurred_at, action, decision, reason,
|
|
|
949
1082
|
file_id, grant_id, host(ip) AS ip, user_agent, context, prev_hash, hash
|
|
950
1083
|
FROM audit_event`;
|
|
951
1084
|
|
|
1085
|
+
/** Narrowing only. There is no way to widen the org an audit read reaches. */
|
|
1086
|
+
export interface AuditFilter {
|
|
1087
|
+
decision?: 'allow' | 'deny';
|
|
1088
|
+
fileId?: string;
|
|
1089
|
+
actorId?: string;
|
|
1090
|
+
action?: string;
|
|
1091
|
+
limit?: number;
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
/**
|
|
1095
|
+
* The WHERE clauses for an audit read, written once so the plain and the
|
|
1096
|
+
* resolved reader cannot drift apart in what they filter on.
|
|
1097
|
+
*
|
|
1098
|
+
* `prefix` is the table alias (`''` or `'e.'`); `orgParam` is the 1-based index
|
|
1099
|
+
* at which the caller already placed the org id. Everything else is appended.
|
|
1100
|
+
*/
|
|
1101
|
+
function auditFilterClauses(
|
|
1102
|
+
prefix: string,
|
|
1103
|
+
orgParam: number,
|
|
1104
|
+
filter: AuditFilter,
|
|
1105
|
+
params: unknown[],
|
|
1106
|
+
): string[] {
|
|
1107
|
+
const clauses = [`${prefix}org_id IS NOT DISTINCT FROM $${orgParam}`];
|
|
1108
|
+
if (filter.decision) {
|
|
1109
|
+
params.push(filter.decision);
|
|
1110
|
+
clauses.push(`${prefix}decision = $${params.length}`);
|
|
1111
|
+
}
|
|
1112
|
+
if (filter.fileId) {
|
|
1113
|
+
params.push(filter.fileId);
|
|
1114
|
+
clauses.push(`${prefix}file_id = $${params.length}`);
|
|
1115
|
+
}
|
|
1116
|
+
if (filter.actorId) {
|
|
1117
|
+
params.push(filter.actorId);
|
|
1118
|
+
clauses.push(`${prefix}actor_id = $${params.length}`);
|
|
1119
|
+
}
|
|
1120
|
+
if (filter.action) {
|
|
1121
|
+
params.push(filter.action);
|
|
1122
|
+
clauses.push(`${prefix}action = $${params.length}`);
|
|
1123
|
+
}
|
|
1124
|
+
return clauses;
|
|
1125
|
+
}
|
|
1126
|
+
|
|
1127
|
+
const auditLimit = (filter: AuditFilter): number => Math.min(filter.limit ?? 500, 5000);
|
|
1128
|
+
|
|
952
1129
|
export interface AuditHashInput {
|
|
953
1130
|
prevHash: string | null;
|
|
954
1131
|
orgId: string | null;
|
|
@@ -1039,6 +1216,44 @@ function mapAuditRow(r: Record<string, unknown>): AuditRow {
|
|
|
1039
1216
|
};
|
|
1040
1217
|
}
|
|
1041
1218
|
|
|
1219
|
+
/**
|
|
1220
|
+
* `mapAuditRow` plus the three resolved views and the printable line.
|
|
1221
|
+
*
|
|
1222
|
+
* Every branch below produces a non-null `label`. That is the whole point: the
|
|
1223
|
+
* caller must never have to write `?? 'unknown'` to print a row, and must never
|
|
1224
|
+
* have to guess whether a null actor means "anonymous" or "we lost it".
|
|
1225
|
+
*/
|
|
1226
|
+
function mapResolvedAuditRow(r: Record<string, unknown>): ResolvedAuditRow {
|
|
1227
|
+
const base = mapAuditRow(r);
|
|
1228
|
+
const actorExternal = (r['actor_external_id'] as string | null) ?? null;
|
|
1229
|
+
const orgExternal = (r['org_external_id'] as string | null) ?? null;
|
|
1230
|
+
const fileName = (r['file_name'] as string | null) ?? null;
|
|
1231
|
+
|
|
1232
|
+
const actor: AuditActorRef =
|
|
1233
|
+
base.actorId === null
|
|
1234
|
+
? { id: null, externalId: null, label: AUDIT_ANONYMOUS_LABEL, resolution: 'anonymous' }
|
|
1235
|
+
: actorExternal !== null
|
|
1236
|
+
? { id: base.actorId, externalId: actorExternal, label: actorExternal, resolution: 'resolved' }
|
|
1237
|
+
: { id: base.actorId, externalId: null, label: base.actorId, resolution: 'unresolved' };
|
|
1238
|
+
|
|
1239
|
+
const org: AuditOrgRef =
|
|
1240
|
+
base.orgId === null
|
|
1241
|
+
? { id: null, externalId: null, label: AUDIT_SYSTEM_LABEL, resolution: 'system' }
|
|
1242
|
+
: orgExternal !== null
|
|
1243
|
+
? { id: base.orgId, externalId: orgExternal, label: orgExternal, resolution: 'resolved' }
|
|
1244
|
+
: { id: base.orgId, externalId: null, label: base.orgId, resolution: 'unresolved' };
|
|
1245
|
+
|
|
1246
|
+
const file: AuditFileRef =
|
|
1247
|
+
base.fileId === null
|
|
1248
|
+
? { id: null, name: null, label: AUDIT_NO_FILE_LABEL, resolution: 'none' }
|
|
1249
|
+
: fileName !== null
|
|
1250
|
+
? { id: base.fileId, name: fileName, label: fileName, resolution: 'resolved' }
|
|
1251
|
+
: { id: base.fileId, name: null, label: base.fileId, resolution: 'unresolved' };
|
|
1252
|
+
|
|
1253
|
+
const resolved = { ...base, actor, file, org };
|
|
1254
|
+
return { ...resolved, summary: auditSummary(resolved) };
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1042
1257
|
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
1043
1258
|
export function isUuid(v: unknown): v is string {
|
|
1044
1259
|
return typeof v === 'string' && UUID_RE.test(v);
|