refactor(audit): derive the audit event vocabulary from one table - #6983
refactor(audit): derive the audit event vocabulary from one table#6983pfreixes wants to merge 3 commits into
Conversation
`AuditResource` and `AuditAction` were two independently hand-listed unions, so nothing tied a resource to the actions it actually records. They are now derived from a single table of recorded events, which also yields `AuditEventKey` (`resource.action`) for consumers that need to enumerate the vocabulary — the dashboard's filter list restates it today. Three places name an event and each is now checked against that table: an endpoint's `AuditPolicy` constrains its action by its resource, and two assertions tie the emit-side metadata union to the vocabulary in both directions. Follows the `apiKeyScopes` pattern in @nangohq/utils. Drops `audit_log`, `exported` and `reauthorized`, which no event emits. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
`Audit.auditable` took its action parameter independently of its resource, so it accepted a pair no event can emit and only failed later, where the spec is checked against the endpoint's declaration — reporting an assignability mismatch rather than the invalid pair. It now fails on the argument itself. Also derives `AuditAction` from `AuditActionOf` rather than repeating the indexed access, so the table is read in one place. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| | 'billing' | ||
| | 'audit_log'; | ||
| // Not exported: this package emits no JavaScript, so a const here is unusable at runtime — code needing | ||
| // the list keeps a twin checked against `AuditEventKey`, as `apiKeyScopes` does for scopes. |
There was a problem hiding this comment.
can you not achieve the same derivation without a const that's gonna be discarded at compile time
interface AuditEventTable {
connection: 'created' | 'updated' | 'metadata_updated' | 'refreshed' | 'deleted';
sync: 'enabled' | 'disabled' | ...
}
export type AuditResource = keyof AuditEventTable;
export type AuditActionOf<R extends AuditResource> = AuditEventTable[R];
export type AuditAction = AuditActionOf<AuditResource>;
export type AuditEventKey = { [R in AuditResource]: `${R}.${AuditEventTable[R]}` }[AuditResource];
There was a problem hiding this comment.
good point, easier.
There was a problem hiding this comment.
Done — interface AuditEventTable in 4c39dc5. Better than I had it: the const was discarded at compile time and needed two lines of comment explaining why it couldn't be exported, both of which are gone now. All five compile-time guards re-verified against the new derivation (endpoint declaration, policy builder, and both assertion directions).
| type InVocabularyButNotEmitted = Exclude<AuditEventKey, EmittedKey<AuditResourceAction>>; | ||
|
|
||
| true satisfies [EmittedButNotInVocabulary] extends [never] ? true : never; | ||
| true satisfies [InVocabularyButNotEmitted] extends [never] ? true : never; |
There was a problem hiding this comment.
nit, you can probably do it in one line
true satisfies [EmittedButNotInVocabulary, InVocabularyButNotEmitted] extends [never, never] ? true : never;
There was a problem hiding this comment.
Applied. Verified it still catches a failure in either tuple position independently, since a single assertion covering two invariants is easy to get silently half-working — an event emitted but absent from the table, and a table entry nothing emits, each fail it on their own.
…tions The table was a const in a package that emits no JavaScript, so it was discarded at compile time and needed a comment explaining why it couldn't be exported. As an interface it derives everything the same way with no runtime artifact and nothing to explain. Both vocabulary assertions fit in one, checking each direction as a tuple position. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
AuditResourceandAuditActionwere hand-listed separately, so nothing tied a resource to the actions it records:teamanddeletedwere each valid, which madeteam.deleteddeclarable even though no such event exists. Both now derive from a single table of recorded events, along withAuditEventKey(resource.action) for consumers that need to enumerate the vocabulary.audit_log,exportedandreauthorized, none of which any event emits. The table can't express a resource with no actions or an action belonging to no resource, so they go by construction.Type-only. No runtime behaviour changes.
Why the table isn't exported
@nangohq/typesisemitDeclarationOnly— it ships declarations and no JavaScript, so an exported const there is visible to the type checker and absent at runtime. The table stays module-local and only its type projections cross the package boundary.Code that needs the list at runtime keeps a twin checked against
AuditEventKey. That is whatapiKeyScopesin@nangohq/utilsalready does for API key scopes: the canonical list lives in@nangohq/types, the runtime twin lives in a package that emits JavaScript, and two compile-time assertions keep them in step. This PR adds no twin, because nothing on master consumes the vocabulary at runtime yet.Constraining the policy's action by its resource follows the correlated-parameter pattern already used by
APIEndpointsPickerinpackages/types/lib/api.endpoints.ts,Extract<Event, { subject: TSubject }>inpackages/pubsub/lib/publisher.ts, and the task payload lookup inpackages/tasks/lib/types.ts.The builder was the gap
Narrowing the policy type alone was not enough.
Audit.auditabledeclared its action parameter independently of its resource, so it accepted an impossible pair and the mistake only surfaced downstream, where the middleware spec is checked against the endpoint's declaration — which reports an assignability mismatch between two policy types rather than the invalid pair. Constraining the builder makes it fail on the argument itself.What this unblocks
#6979 hand-maintains the dashboard's audit filter list against the emit union, which lives in a package the webapp cannot import. Measured against master, that list is missing 14 of the 51 emitted pairs — every lifecycle and authentication event from the coverage PRs that merged after the branch was cut,
app_auth.loginincluded.AuditEventKeylets the list be verified at compile time, so an omission fails the build instead of silently making those events unfilterable.Adding an audited event
Still four files: the vocabulary table, the emit union's metadata member, the middleware spec, and the endpoint's policy declaration. This PR doesn't reduce that count — it makes the four checked against each other rather than kept in step by hand.
Test plan
ts-buildclean, zero errors.packages/webappis intsconfig.build.json, so the dashboard is coveredteam.deleted, and the builder called withteam.deletedAuditEventKey