SeaWatch: audit and security events
SeaWatch records the security-relevant actions in a realm. A sign-in, a failed sign-in, a password reset, a role assignment, a client deletion: each becomes one structured event saying who did it, what it touched, when, and whether it worked. The event types page lists all 29.
When an account looks compromised, the trail answers the first questions: when it signed in, which sessions it opened, which roles it was given and by whom. It also shows who read a client secret and who deleted a client. The network address of a sign-in attempt is not part of these events. It is recorded on the Compass flow of the same attempt.
The Sea Watch page of the console opens on four counters over the last seven days (events, failures, success rate, distinct actors) and an authentication traffic curve drawn from Compass. When something failed, a banner reports the failed events and another one reports each account behind them. The event stream below is the listing described in Querying events.
Event structure
Every event has the same shape. This one is the first event of a realm, written when the administrator signed in:
{
"id": "01a18e8b-8e28-7591-bfbe-a32e4b4ef27e",
"realm_id": "01a18e87-67a8-7801-8d2e-ab97acf9fc35",
"actor_id": "01a18e87-67a8-76d4-8ab8-07d5830d8a99",
"actor_type": "user",
"event_type": "login_success",
"status": "success",
"target_type": "session",
"target_id": "ad9654e4-8c48-43fa-890c-c63e5f693c68",
"resource": null,
"timestamp": "2026-10-30T20:41:13.554970Z",
"trace_id": null,
"ip_address": null,
"user_agent": null,
"details": null,
"event_hash": [84, 221, 63, 112, 62, 114, 217, 99, 133, 197, 0, 41, 226, 93, 27, 138, 176, 31, 204, 103, 112, 198, 19, 47, 221, 194, 118, 210, 28, 196, 200, 153],
"prev_hash": [0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]
}
| Field | Description |
|---|---|
id | Event identifier, a UUIDv7, so identifiers sort by creation time |
realm_id | Realm where the event occurred |
event_type | What happened, see event types |
status | success or failure |
actor_id, actor_type | The account that made the request, and its classification |
target_type, target_id | The object the event is about, when there is one |
resource | A readable name for the target, on the events that carry one |
ip_address, user_agent, trace_id | Request context. FerrisKey leaves all three null on the events it writes |
details | Event-specific JSON, or null |
timestamp | When the event was created, in UTC |
event_hash, prev_hash | The hash chain, as arrays of 32 integers. Null on rows written before chaining existed |
Actor and target
actor_id is the account that made the request: the administrator for console actions, the user for sign-ins and self-service actions. An event with no caller has none, such as a failed sign-in for a username that matches no account, or a webhook delivery that ran out of attempts.
actor_type is user on login_success and on login_failure for a known account, and null on every other event. The schema also defines service_account, admin and system, but nothing writes them, so do not build a rule that waits for one of them.
target_type is one of user, client, session, webhook or identity_provider_link, and target_id is the UUID of that object. resource adds a readable name where one exists: the role name on role_assigned, the provider alias on identity_provider_link_removed, the client id on token_exchanged. client_created and the role events other than role_assigned and role_unassigned have no target at all.
What you can do with it
For an incident, filter by actor_id and a time window to replay what one account did in order, then follow target_id into the objects it touched.
For a compliance question such as every role assignment last quarter, combine event_types with from_timestamp and to_timestamp. Querying events has a table of questions with the query for each.
For alerting, poll the listing. SeaWatch does not push events anywhere. Webhooks have their own list of triggers, which covers account, client and role changes but not sign-ins, sessions or secret reads.
Tamper evidence
Every event carries two hashes. event_hash is a SHA-256 over the event’s fields and the previous hash, and prev_hash is the event_hash of the event before it in the same realm. The first hashed event of a realm has a prev_hash of 32 zero bytes, as in the example above.
The hash covers every field of the event except the two hashes, details included, with the timestamp at microsecond precision. Each event is inserted in the same statement that advances the realm’s chain head, under a row lock, so two requests that log at the same moment line up one behind the other and the chain never forks.
Edit a stored event and its hash no longer matches its content. Delete one and the next event’s prev_hash no longer matches its predecessor. FerrisKey contains a routine that walks a realm’s chain and reports the position of the first altered event or broken link, but no endpoint, command or console screen runs it. To check the links yourself, list the realm’s events oldest first:
curl -G "http://localhost:3333/realms/my-app/seawatch/v1/security-events" \
--data-urlencode "order_by=created_at" \
--data-urlencode "order=asc" \
--data-urlencode "limit=100" \
-H "Authorization: Bearer $ADMIN_TOKEN"
Each event’s prev_hash must equal the event_hash of the one before it, across pages. That catches deleted and reordered events. It does not catch an edited event whose neighbours were left alone, because recomputing a hash needs FerrisKey’s canonical encoding of the event, and the API does not do that for you.
The chain detects tampering by anyone who can write to the database, including someone who edits rows directly. It does not prevent it, and it is not a notary: an attacker able to recompute the whole chain from the point of the edit forward can produce a consistent log. Copying the latest event_hash to a place the database administrator cannot write to, such as your SIEM or a WORM bucket, closes that gap and is worth doing where the log is evidence.
Privacy
Events can carry an IP address and a user agent, which are personal data in some jurisdictions. The realm setting seawatch_pii_mode decides what is stored in those two fields:
| Mode | Behaviour |
|---|---|
off | Store them as they are. The default |
mask | Keep the first two groups of an IPv4 address (192.168.1.42 becomes 192.168.0.0) or the first three of an IPv6 address, and keep only the product name of the user agent (Mozilla/5.0 ... becomes Mozilla) |
pseudonymise | Replace each value with the first 8 bytes, in hex, of an HMAC-SHA-256 keyed by seawatch_pseudo_key |
Pseudonymisation keeps events correlatable, since the same source produces the same value, without keeping the raw address. Set seawatch_pseudo_key when you use that mode: without one the HMAC key is empty.
The modes rewrite ip_address and user_agent only, and details is stored as it is. The events FerrisKey writes leave both fields empty, so the modes have nothing to rewrite on them. A server reads the setting at most every 30 seconds, so a change reaches new events within that time.