@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/src/filelayer.ts CHANGED
@@ -58,8 +58,9 @@ import {
58
58
  toCapabilities,
59
59
  LIST_DEFAULT_LIMIT,
60
60
  LIST_MAX_LIMIT,
61
- type AuditRow,
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
- /** Requires `read_audit` in the org, which is admin+. Asked of the engine. */
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
- decision?: 'allow' | 'deny';
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.listAudit(orgId, filter);
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 = ['org_id IS NOT DISTINCT FROM $1'];
723
- if (filter.decision) {
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);