Connect an application to its trail¶
Give an application a catalogue, one constructor per action, the emit library, a CI check and the Audit page.
Before you start¶
-
Run an installation in the application's namespace, on Kubernetes or AWS Lambda.
-
A changed catalogue under an unchanged
versionstops the application at start (rollout order). -
Do not hash identifiers, embed a writer or read the bucket from the application. Use the query service.
Steps¶
The catalogue¶
Keep one YAML document and a JSON Schema per data-carrying action in the application's repository (catalogue reference).
source: shop # every action is under this namespace
version: "1.0.0" # a new version for any change to what a record carries
locales: [en]
actor_kinds: # who can act, and how profiles treat them
customer: { category: external } # an identifier the application minted
clerk: { category: internal } # staff, kept in clear for accountability
service: { category: machine }
target_types: # what an action can be about
order: { description: "An order." }
actions:
shop.order.placed:
summary: A customer placed an order.
operation: create # create, access, modify, remove, authentication, transfer, restore
categories: [data_change] # what frameworks call it
category: security # which destinations keep a copy (profiles: is deprecated)
target_types: [order]
delivery: async # block or async
data_schema: https://schemas.example.com/shop/order-placed.json
message: { en: "{actor} placed order {targets_0_id}" }
Name each action source.thing.verb, in the past tense. Choose block or async per action. The application registers the catalogue at start-up and stops if the receiver refuses it. Do not retry a refusal.
One constructor per action¶
Spell each action name once, in a constructor. check-emitters cannot see a name built at run time.
// Package shopaudit is the only place an action name is spelled.
package shopaudit
import (
auditv1 "github.com/truvity/sluis/audit/sdk/gen/audit/v1"
"github.com/truvity/sluis/audit/sdk/record"
)
func OrderPlaced(tenant, customerID, orderID string) *record.Record {
return &record.Record{
Action: "shop.order.placed",
Operation: auditv1.Operation_OPERATION_CREATE,
TenantId: tenant,
Actor: &record.Actor{Kind: "customer", Id: customerID},
Targets: []*record.Target{{Type: "order", Id: orderID}},
Outcome: &record.Outcome{Result: auditv1.Outcome_RESULT_SUCCESS},
}
}
The emit library¶
// At start-up: the catalogue reaches the receiver, or the application stops.
if err := emit.Register(ctx, emit.Registration{
URL: receiverURL, Source: shop.Source, Version: shop.Version,
Document: document, Schemas: schemas, HTTP: client,
}); err != nil {
return err
}
emitter, err := emit.New(emit.Options{
Source: shop.Source,
Catalogue: shop,
Sink: sink.NewClient(client, receiverURL),
Version: build.Version,
Instance: os.Getenv("HOSTNAME"),
Hooks: hooks, // without OnDropped the emitter logs each drop itself
})
// Wherever the action happens:
err = emitter.Record(ctx, shopaudit.OrderPlaced(tenant, customerID, orderID))
The emitter sends the pod's projected token, audience audit by default. Tenants, middleware and metrics are in emit records.
The CI check¶
audit validate catalogue/shop.yaml
audit check-emitters . --catalogue catalogue/shop.yaml
check-emitters fails on an action the code emits and the catalogue lacks, and the reverse. Add --deployment to validate to check the composed profiles.
The Audit page¶
The console calls the query service with the person's session token. The grants name the gateway's issuer and audience.
import { createConnectTransport } from "@connectrpc/connect-web";
import { createQueryClient } from "@truvity/audit";
import { AuditProvider, AuditView } from "@truvity/audit-react";
import shop from "./audit-sentences.json"; // `audit messages catalogue/shop.yaml`
const audit = createQueryClient(createConnectTransport({
baseUrl: "https://audit-query.example.com",
jsonOptions: { useProtoFieldName: true },
interceptors: [(next) => async (req) => {
req.header.set("Authorization", `Bearer ${await session.token()}`);
return next(req);
}],
}));
<AuditProvider client={audit} sentences={[shop]}>
<AuditView permalink={(p, id) => `/audit/${p}/${id}`} />
</AuditProvider>
Generate the sentences with audit messages catalogue/shop.yaml > audit-sentences.json. A console in the cluster reaches the Service <fullname>-query. If it runs outside, set query.route and list the gateway's namespace in networkPolicy.queryIngressFrom (chart README). The view is described in the Audit page.
A console with a session of its own¶
If the session is a cookie, proxy /audit/ through the console backend.
browser ──(the console's cookie)──▶ console backend ──(a short token per person)──▶ query service
/audit/audit.v1.QueryService/* Authorization: Bearer …
The backend refuses anyone not signed in and adds a bearer token for that person, from a granted issuer, with the installation's audience. Set baseUrl to /audit. Prefer the direct call.
Verify¶
-
Run
audit conformance --query <url> --profile security --token-file tokenwith a token that may read. -
Stop the receiver. A
blockaction fails; anasyncaction succeeds and appears once the receiver returns.