feat(audit): append-only audit log for spaces
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
OpenSSF Scorecard / Scorecard analysis (push) Waiting to run
CI / Build & test (Node 20) (push) Canceled after 0s
CI / Build & test (Node 24) (push) Canceled after 0s
CI / Build & test (push) Canceled after 0s
CodeQL / Analyze (javascript-typescript) (push) Canceled after 0s
Security / Secret scan (gitleaks) (push) Canceled after 0s
Security / Dependency scan (OSV-Scanner) (push) Canceled after 0s
Security / IaC/config scan (Trivy) (push) Canceled after 0s
Security / License compliance scan (Trivy) (push) Canceled after 0s
Records who changed what, and is the mechanism statistics will read — one event table rather than two logs that drift apart. The table is deliberately generic (action + target + JSON metadata) so a new action needs no migration. Writes never throw: a kick must not fail because its log entry could not be written, since the kick already happened. Leaving is recorded as a different action from being removed. The same route serves both, and a log that conflates them misleads exactly when it matters. Actor is nullable with ON DELETE SET NULL: the event outlives the account, and a log that vanished with its actor would be worthless. Reads are gated on MANAGE_SPACE rather than a new permission bit, which would default to nobody until every role was re-edited. Paging uses the snowflake id, stable even for two events in the same millisecond, and an action this build does not know still renders a row.
This commit is contained in:
@@ -587,3 +587,26 @@ export const gifFavorites = sqliteTable('gif_favorites', {
|
||||
pk: primaryKey({ columns: [table.userId, table.gifId] }),
|
||||
userIdx: index('idx_gif_favorites_user_id').on(table.userId),
|
||||
}));
|
||||
|
||||
/**
|
||||
* Append-only record of who changed what in a space.
|
||||
*
|
||||
* Deliberately generic (action + target + JSON metadata) rather than a column
|
||||
* per event type: new actions must not require a migration. Statistics read
|
||||
* this same table — two features, one mechanism, instead of two logs that
|
||||
* drift apart.
|
||||
*/
|
||||
export const auditEvents = sqliteTable('audit_events', {
|
||||
id: text('id').primaryKey(),
|
||||
spaceId: text('space_id').notNull().references(() => spaces.id, { onDelete: 'cascade' }),
|
||||
actorId: text('actor_id').references(() => users.id, { onDelete: 'set null' }),
|
||||
action: text('action').notNull(),
|
||||
targetType: text('target_type'),
|
||||
targetId: text('target_id'),
|
||||
// JSON blob; shape depends on `action`. Never trusted for permissions.
|
||||
metadata: text('metadata'),
|
||||
createdAt: integer('created_at').notNull(),
|
||||
}, (table) => ({
|
||||
spaceIdx: index('idx_audit_events_space_created').on(table.spaceId, table.createdAt),
|
||||
actorIdx: index('idx_audit_events_actor').on(table.actorId),
|
||||
}));
|
||||
|
||||
Reference in New Issue
Block a user