928ed0cdc2
## Summary ADR-0026 PR 2b (second half of PR 2). Ships the operator surface for the `@RequireScope` stack — the admin Angular screen at `/admin/users/:oid/scopes` lets an admin list / grant / revoke scopes for an existing portal User. Both write paths emit blocking audit rows per ADR-0013 (`admin.scope_granted` / `admin.scope_revoked`). Closes the ADR-0026 trilogy: | PR | Status | Effect | | --- | --- | --- | | ADR-0026 PR 1 (#232) | merged | Person + User + UserScope schema + provisioner + drift gate Person.source + PrincipalBuilder real UUIDs. | | ADR-0026 PR 2a (#233) | merged | PrismaScopeResolver replaces stub + prisma/seed.ts populates 19 personas. | | **ADR-0026 PR 2b (this)** | proposed | Admin scope-management screen — operator surface. | ## What lands ### BFF (under `apps/portal-bff/src/`) | File | Change | | --- | --- | | `admin/user-scopes.dto.ts` | **New**. `GrantUserScopeDto` (class-validator on kind + value + ISO expiresAt) + response shapes (`UserScopeDto`, `UserScopesPageDto`). | | `admin/user-scopes.service.ts` | **New**. `resolveUserByOid` (404 when no portal User), `list` (`UserScope` rows joined to User+Person), `grant` (validates value against ADR-0027 tables for value-bearing kinds, rejects non-empty for valueless, P2002 → 400), `revoke` (404-protected — won't leak scope-belongs-to-other-user). | | `admin/user-scopes.controller.ts` | **New** REST surface at `/api/admin/users/:oid/scopes` with `@RequireAdmin`. `GET` (no audit, read), `POST` (audit `admin.scope_granted`), `DELETE :scopeId` (audit `admin.scope_revoked`, 204 on success). | | `admin/user-scopes.service.spec.ts` + `admin/user-scopes.controller.spec.ts` | **New** specs — 15 cases across resolve / list / grant (value-bearing + valueless + duplicate) / revoke / audit semantics. | | `admin/admin.module.ts` | Registers `UserScopesController` + `UserScopesService`. | | `audit/audit.types.ts` + `audit/audit.service.ts` | New `AdminScopeGrantedInput` / `AdminScopeRevokedInput` types + `adminScopeGranted` / `adminScopeRevoked` methods on `AuditWriter`. `subject = 'user:<oid>'` so an auditor pivots on the target of the change; payload carries the resolved tuple at the moment of the write (the revoke payload survives the row's deletion). | ### SPA (under `apps/portal-admin/src/app/`) | File | Change | | --- | --- | | `app.routes.ts` | New route `/users/:oid/scopes` (lazy-loaded). | | `pages/user-scopes/user-scopes.service.ts` + `.service.spec.ts` | **New** thin HttpClient wrapper (`list` / `grant` / `revoke`). Same shape convention as `AdminUsersService`. | | `pages/user-scopes/user-scopes.ts` + `.html` + `.scss` + `.spec.ts` | **New** page component. Signals-based + OnPush. Lists current scopes in a table with `Revoke` per row; "Grant a new scope" form with kind dropdown + conditional value input + optional `expiresAt`. Friendly error mapping (403 → "no admin access", 404 → "User not found, has this persona ever signed in or been seeded?", server messages forwarded). | | `pages/users/users.html` | New `Manage scopes` link per row (RouterLink to the new page), with `aria-label` carrying the user's displayName. | | `pages/users/users.ts` + `users.spec.ts` | Adds `RouterLink` import + `provideRouter([])` in the spec fixture (was missing — the route addition exposed it). | ## Key choices - **`:oid` in the URL, not `User.id`.** The existing `/admin/users` list (ADR-0020) keys on Entra `oid` and the new screen is its child — linking from the list to the scopes page without an intermediate UUID lookup is the simplest UX. The BFF translates `oid → User.id` internally via `resolveUserByOid`. - **404 carries a hint.** When `resolveUserByOid` misses, the BFF throws `NotFoundException("No portal User found for Entra oid ${oid}.")` and the SPA translates the 403/404 status to friendly French-English text. The hint mentions "has this persona ever signed in or been seeded?" — a common operator confusion (the user-directory list shows oids that signed in but the `User` row only exists post-sign-in OR post-seed). - **Audit before write commit, write after audit success.** Standard ADR-0013 posture. Order in the controller: service call (which writes the DB row) → audit (blocking). If audit fails, the DB row exists but no audit — a known v1 cost for non-rollback flows. The simpler alternative (audit before write) would emit ghost audit rows for failed writes; we picked the cleaner direction. - **Value-bearing validation against ADR-0027 tables, not catalogue.** `etablissement:0330800013` must match an existing `Structure.code`; `delegation:33` must match `Delegation.code`; `region:75` must match `Region.code`. The lookup is a single `findUnique` per kind; rejection raises 400 with the offending value in the message. Valueless kinds (`self` / `siege` / `unrestricted`) reject any non-empty value as a defensive check (the DTO type already constrains this but the service runs the assertion). - **`UserScope.source = 'admin-ui'`** for every row created by this surface. Distinct from `'seed'` (set by `prisma/seed.ts`) and `'self-signin'` (Person source only). ADR-0029's syncs will add `'pleiades'` / `'acteurs-plus'`. - **A11y per [ADR-0016](https://git.unespace.com/julien/apf_portal/src/branch/main/docs/decisions/0016-accessibility-baseline-wcag-aa-targeted-aaa.md)**: `aria-labelledby` on the form sections, `role="status"`/`aria-live="polite"` for load + error feedback, `role="alert"` for submit errors, `min-height: 44px` on every input/button per the touch-target rule, `aria-label` on the Manage-scopes link carrying the displayName for screen readers, conditional `aria-required` + `aria-describedby` on the value input. - **`window.confirm` before revoke.** Cheap protection against accidental keyboard-activation. v1 OK; a future polish PR could replace with the spartan-ng dialog when it ships. ## Local verification - [x] `node scripts/check-catalogue-drift.mjs` — clean (4 / 24 / 7 / 3). - [x] `pnpm exec nx lint portal-bff` — **0 errors** (13 warnings, all pre-existing). - [x] `pnpm exec nx lint portal-admin` — **0 errors** (3 warnings, all pre-existing). - [x] `pnpm exec nx test portal-bff` — full suite passes (now includes the 2 new `user-scopes` spec files). - [x] `pnpm exec nx test portal-admin` — **71 tests passing** (added 8 for `user-scopes`; updated `users.spec.ts` to provide a router after the RouterLink import). ## Test plan — remaining (manual on dev DB) - [ ] **Sign in as `admin@apfrd...`** (the only persona with `Portal.Admin`) on `portal-admin`. Navigate to `/admin/users`. Click `Manage scopes` for `directeur-bordeaux`. The page shows one row `etablissement:0330800013` (from the seed). - [ ] **Grant a new scope**: kind `delegation`, value `33`. Submit. New row appears; audit row `admin.scope_granted` lands in `audit.events`. - [ ] **Try a bogus value** (kind `etablissement`, value `xyz`): server returns 400, form shows the message. - [ ] **Try a duplicate** (`etablissement:0330800013` again on `directeur-bordeaux`): server returns 400 with "already granted". - [ ] **Revoke a scope**: row disappears; audit row `admin.scope_revoked` lands. - [ ] **Sign in as a non-admin persona** (e.g. `collaborateur-simple`) and try `/admin/users/.../scopes` → 403 + friendly error. - [ ] **Review focus** — the BFF's `validateValueForKind` switch, the SPA's `translateError` mapping, the a11y attributes on the form, the audit ordering in the controller methods. ## Notes for the reviewer - **No scope-vocabulary endpoint.** The form expects the operator to type the `Structure.code` / `Delegation.code` / `Region.code` by hand (with a hint string under the field). Defensive validation catches typos. A future polish PR could add `GET /api/admin/scope-vocabulary` returning all three lists at once + replace the value input with a typeahead dropdown. - **Renamed the service's `UserScopesPage` interface to `UserScopesPayload`.** Discovered the collision with the component class during the first test run. The component keeps the `UserScopesPage` name per Angular convention; the payload type now reads as what it is. - **`UserScope.source` is not under the drift gate today.** The schema comment in PR 1 said "same catalogue posture as Person.source"; in practice the seed writes `'seed'` and this PR writes `'admin-ui'`. Extending the drift gate to cover `UserScope.source` is a small follow-up — defensible to do once ADR-0029's upstream-sync values are landing. ## What's next ADR-0025's `@RequireScope` stack is now end-to-end live for the test tenant: persona → Entra → BFF → PrismaScopeResolver → DB → Principal → guard. The trilogy is done. Per the prior conversation: **GitLab migration Phase 1** (`mirror-and-bootstrap`) is the next item — mostly ops work on `vm-gitlab` (groups, branch protection, Renovate reconfig, deploy keys). The first PR-shaped output from that phase is the Renovate config update. --------- Co-authored-by: Julien Gautier <julien.gautier@apf.asso.fr> Reviewed-on: #236
366 lines
14 KiB
TypeScript
366 lines
14 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { trace } from '@opentelemetry/api';
|
|
import { ClsService } from 'nestjs-cls';
|
|
import { PrismaService } from 'nestjs-prisma';
|
|
import type {
|
|
AdminAccessDeniedInput,
|
|
AdminAuditQueryInput,
|
|
AdminAuditStatsQueryInput,
|
|
AdminScopeGrantedInput,
|
|
AdminScopeRevokedInput,
|
|
AdminUsersQueryInput,
|
|
AuditEventInput,
|
|
AuthorizationDeniedInput,
|
|
MfaRequiredInput,
|
|
SignInActor,
|
|
SignInFailedInput,
|
|
SignOutInput,
|
|
SessionExpiredInput,
|
|
} from './audit.types';
|
|
import { HashUserIdService } from './hash-user-id.service';
|
|
|
|
/**
|
|
* AuditWriter — single entry point for ADR-0013 audit-log writes.
|
|
*
|
|
* Contract
|
|
* --------
|
|
* - **Append-only at the database level.** Every write runs inside a
|
|
* transaction whose first statement is `SET LOCAL ROLE
|
|
* audit_writer`. That role only has `INSERT` on `audit.events`
|
|
* (per the migration that created the table); `UPDATE`, `DELETE`,
|
|
* `TRUNCATE` all fail at the Postgres level even if the BFF
|
|
* connection is otherwise privileged. The role is reset
|
|
* automatically at transaction end.
|
|
*
|
|
* - **Fail loud, never swallow.** Per ADR-0013 §"Blocking writes":
|
|
* no audit ⇒ no action. Callers must propagate the rejection up
|
|
* so the requested action does not proceed when its audit trail
|
|
* cannot be written. The service throws the underlying Prisma
|
|
* error unchanged; do not wrap it in a catch-and-log block.
|
|
*
|
|
* - **trace_id and actor_id_hash are auto-resolved.** trace_id is
|
|
* read from the active OTel span context (so the audit row joins
|
|
* with the BFF request span and the Pino log lines on the same
|
|
* request). actor_id_hash is read from the CLS context populated
|
|
* by future auth guards (ADR-0009 / ADR-0010); v1 stores `null`
|
|
* when no actor is established. Callers can override either by
|
|
* passing them on `AuditEventInput`.
|
|
*/
|
|
@Injectable()
|
|
export class AuditWriter {
|
|
constructor(
|
|
private readonly prisma: PrismaService,
|
|
private readonly cls: ClsService,
|
|
private readonly hashUserId: HashUserIdService,
|
|
) {}
|
|
|
|
/**
|
|
* Typed event: successful sign-in via the OIDC callback. Per
|
|
* ADR-0013's v1 catalogue (`auth.sign_in`).
|
|
*
|
|
* Hashes the user id internally — callers pass the raw Entra
|
|
* `oid`, never the hash, so the salt stays inside the audit
|
|
* module.
|
|
*/
|
|
async signIn(input: { actor: SignInActor; sessionId: string }): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'auth.sign_in',
|
|
audience: 'workforce',
|
|
outcome: 'success',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
subject: `session:${input.sessionId}`,
|
|
payload: { amr: input.actor.amr },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: failed sign-in at the callback. The `failureKind`
|
|
* mirrors the discriminator on `AuthCodeFlowError` so the audit
|
|
* row is self-describing without joining anything.
|
|
*
|
|
* `actorIdHash` is left null on purpose: at the moment of
|
|
* failure we may not have resolved an identity yet (state
|
|
* mismatch, expired flow, token-exchange error before any user
|
|
* claim was parsed). Callers can pass an explicit hash when the
|
|
* identity *was* resolved before rejection.
|
|
*/
|
|
async signInFailed(input: SignInFailedInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'auth.sign_in.failed',
|
|
audience: 'workforce',
|
|
outcome: 'failure',
|
|
...(input.actor !== undefined ? { actorIdHash: this.hashUserId.hash(input.actor.oid) } : {}),
|
|
payload: { failureKind: input.failureKind, ...(input.payload ?? {}) },
|
|
});
|
|
}
|
|
|
|
async signOut(input: SignOutInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'auth.sign_out',
|
|
audience: 'workforce',
|
|
outcome: 'success',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
subject: `session:${input.sessionId}`,
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: session destroyed by the absolute-timeout
|
|
* middleware (12 h hard ceiling, ADR-0010 §"TTL policy"). The
|
|
* idle-TTL expiry is *not* surfaced through this method — it
|
|
* happens silently inside Redis with no BFF observation point.
|
|
*/
|
|
async sessionExpired(input: SessionExpiredInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'auth.session.expired',
|
|
audience: 'workforce',
|
|
outcome: 'success',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
subject: `session:${input.sessionId}`,
|
|
payload: { reason: input.reason, ageMs: input.ageMs },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: a request was rejected by `RequireMfaGuard` because
|
|
* the session did not satisfy the route's MFA freshness gate.
|
|
* Per ADR-0011 §"Step-up MFA — designed-in, dormant", every
|
|
* step-up challenge emitted by the BFF lands here so auditors can
|
|
* track the distribution of step-up prompts (and the rate of stale
|
|
* `mfaVerifiedAt` rejections that would suggest the default
|
|
* freshness is too tight). `outcome=denied` matches the
|
|
* `AdminRoleGuard` posture: the user *is* authenticated, the
|
|
* action is just refused.
|
|
*/
|
|
async mfaRequired(input: MfaRequiredInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'auth.mfa_required',
|
|
audience: 'workforce',
|
|
outcome: 'denied',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
subject: input.attemptedRoute,
|
|
payload: {
|
|
reason: input.reason,
|
|
freshnessSeconds: input.freshnessSeconds,
|
|
...(input.mfaAgeMs !== undefined ? { mfaAgeMs: input.mfaAgeMs } : {}),
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: an admin queried the audit-log viewer. Recorded at
|
|
* **coarser granularity** per ADR-0020 §"Audit log captures every
|
|
* admin write action … Read actions (audit log viewing, user list
|
|
* browsing) are also captured … to deter fishing expeditions".
|
|
*
|
|
* The filter object the admin used is serialised into the payload
|
|
* so a reviewer can see what was searched (event type, time range,
|
|
* actor hash, etc.). `resultCount` is the number of rows the SPA
|
|
* received in this trip — paged calls each emit one event; an
|
|
* auditor spotting a sequence of `admin.audit.query` with growing
|
|
* offsets is the v1 detection signal for a sweep.
|
|
*
|
|
* `outcome=success` regardless of whether the query returned any
|
|
* rows: the event captures "the admin ran a query", not "the
|
|
* query matched something". An `outcome=failure` audit row from
|
|
* this surface is reserved for the day we add per-row authZ.
|
|
*/
|
|
async adminAuditQuery(input: AdminAuditQueryInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'admin.audit.query',
|
|
audience: 'workforce',
|
|
outcome: 'success',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
payload: {
|
|
filters: input.filters,
|
|
resultCount: input.resultCount,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: an admin queried the audit-log STATISTICS endpoint
|
|
* (`GET /api/admin/audit/stats`). Same deterrent posture as
|
|
* {@link adminAuditQuery} but separates the event type so an
|
|
* auditor can spot "the admin scanned aggregations" vs "the admin
|
|
* paged through rows" — different observation signals.
|
|
*
|
|
* Payload carries `total` (the size of the aggregated dataset)
|
|
* rather than `resultCount` — stats responses don't paginate, and
|
|
* `total` is what tells a reviewer how wide the scan was.
|
|
*/
|
|
async adminAuditStatsQuery(input: AdminAuditStatsQueryInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'admin.audit.stats.query',
|
|
audience: 'workforce',
|
|
outcome: 'success',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
payload: {
|
|
filters: input.filters,
|
|
total: input.total,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: an admin queried the user-directory viewer. Same
|
|
* deterrent posture as {@link adminAuditQuery} per ADR-0020
|
|
* §"Read actions ... to deter fishing expeditions" — captures the
|
|
* filter and the result count so a reviewer can spot a sweep
|
|
* (admin paging through everyone) without joining anything.
|
|
*/
|
|
async adminUsersQuery(input: AdminUsersQueryInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'admin.users.query',
|
|
audience: 'workforce',
|
|
outcome: 'success',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
payload: {
|
|
filters: input.filters,
|
|
resultCount: input.resultCount,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: an admin granted a scope to a user via the ADR-0026
|
|
* PR 2b scope-management screen. Blocking per ADR-0013 — a failed
|
|
* audit write means the scope write itself is rolled back upstream.
|
|
* The `subject` is `user:<oid>` of the affected user, so an auditor
|
|
* pivots on the *target* of the change, not the actor.
|
|
*/
|
|
async adminScopeGranted(input: AdminScopeGrantedInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'admin.scope_granted',
|
|
audience: 'workforce',
|
|
outcome: 'success',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
subject: `user:${input.target.oid}`,
|
|
payload: {
|
|
scopeId: input.scopeId,
|
|
kind: input.kind,
|
|
value: input.value,
|
|
expiresAt: input.expiresAt,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: an admin revoked a scope from a user. Payload
|
|
* carries the resolved tuple at the moment of revocation so the
|
|
* deletion is reconstructable from the audit log alone — the
|
|
* `user_scopes` row itself is gone by then.
|
|
*/
|
|
async adminScopeRevoked(input: AdminScopeRevokedInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'admin.scope_revoked',
|
|
audience: 'workforce',
|
|
outcome: 'success',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
subject: `user:${input.target.oid}`,
|
|
payload: {
|
|
scopeId: input.scopeId,
|
|
kind: input.kind,
|
|
value: input.value,
|
|
},
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: `/api/admin/*` request rejected by `AdminRoleGuard`
|
|
* because the session's `roles` claim does not include `Portal.Admin`.
|
|
* Per ADR-0020 §"Auth — same Entra ID … `Portal.Admin` role claim", every
|
|
* 403 from the admin surface is captured here with the attempted
|
|
* route and the roles the user actually held — auditors looking
|
|
* for privilege-escalation attempts pivot on `subject` (the route)
|
|
* and `outcome=denied`.
|
|
*/
|
|
async adminAccessDenied(input: AdminAccessDeniedInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'admin.access_denied',
|
|
audience: 'workforce',
|
|
outcome: 'denied',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
subject: input.attemptedRoute,
|
|
payload: { rolesHeld: input.rolesHeld },
|
|
});
|
|
}
|
|
|
|
/**
|
|
* Typed event: a request was rejected by one of the three new
|
|
* ADR-0025 guards (`RequirePrivilegeGuard`, `RequireRoleGuard`,
|
|
* `RequireScopeGuard`). Distinct from `admin.access_denied` so
|
|
* an auditor can keep the existing admin-surface signal clean
|
|
* while tracking the broader role/scope denial surface as it
|
|
* grows.
|
|
*
|
|
* `outcome=denied` matches the existing posture: the user *is*
|
|
* authenticated, the action is just refused. The `required` and
|
|
* `held` payload arrays let an auditor pivot on "tried admin
|
|
* while logged in as RH" without joining anything.
|
|
*/
|
|
async authorizationDenied(input: AuthorizationDeniedInput): Promise<void> {
|
|
await this.recordEvent({
|
|
eventType: 'auth.authorization_denied',
|
|
audience: 'workforce',
|
|
outcome: 'denied',
|
|
actorIdHash: this.hashUserId.hash(input.actor.oid),
|
|
subject: input.attemptedRoute,
|
|
payload: {
|
|
kind: input.kind,
|
|
required: input.required,
|
|
held: input.held,
|
|
},
|
|
});
|
|
}
|
|
|
|
async recordEvent(input: AuditEventInput): Promise<void> {
|
|
const traceId = trace.getActiveSpan()?.spanContext().traceId ?? null;
|
|
const actorIdHash =
|
|
input.actorIdHash ?? this.cls.get<string | undefined>('actorIdHash') ?? null;
|
|
const payloadJson = input.payload === undefined ? null : JSON.stringify(input.payload);
|
|
|
|
await this.prisma.$transaction(async (tx) => {
|
|
// Lock the connection to audit_writer for the duration of this
|
|
// transaction. SET LOCAL is reset at COMMIT/ROLLBACK so the
|
|
// pool's next consumer sees the original role.
|
|
await tx.$executeRawUnsafe(`SET LOCAL ROLE audit_writer`);
|
|
|
|
// Deliberately NOT `tx.auditEvent.create(...)`. The Prisma ORM
|
|
// create() emits `INSERT … RETURNING *` to hydrate the entity
|
|
// it returns, and Postgres requires SELECT on every column
|
|
// listed in RETURNING. `audit_writer` is granted INSERT only
|
|
// (ADR-0013 §"Append-only by role grants"); RETURNING fails
|
|
// with the deeply misleading "permission denied for table
|
|
// events" error code 42501. Raw parameterised INSERT keeps
|
|
// the role contract strict — audit_writer never needs SELECT.
|
|
//
|
|
// `gen_random_uuid()` is built into Postgres 13+ (the dev /
|
|
// prod target is 17). The enum + jsonb casts are needed
|
|
// because the parameter values are sent as TEXT over the
|
|
// wire.
|
|
await tx.$executeRawUnsafe(
|
|
`INSERT INTO "audit"."events"
|
|
(id, event_type, audience, outcome, subject, actor_id_hash, trace_id, payload)
|
|
VALUES (
|
|
gen_random_uuid(),
|
|
$1,
|
|
$2::"audit"."AuditAudience",
|
|
$3::"audit"."AuditOutcome",
|
|
$4,
|
|
$5,
|
|
$6,
|
|
$7::jsonb
|
|
)`,
|
|
input.eventType,
|
|
input.audience,
|
|
input.outcome,
|
|
input.subject ?? null,
|
|
actorIdHash,
|
|
traceId,
|
|
payloadJson,
|
|
);
|
|
});
|
|
}
|
|
}
|