FerrisKeyDocs

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.

FerrisKey SeaWatch dashboard showing event counters, the authentication traffic chart and the event stream FerrisKey SeaWatch dashboard showing event counters, the authentication traffic chart and the event stream

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]
}
FieldDescription
idEvent identifier, a UUIDv7, so identifiers sort by creation time
realm_idRealm where the event occurred
event_typeWhat happened, see event types
statussuccess or failure
actor_id, actor_typeThe account that made the request, and its classification
target_type, target_idThe object the event is about, when there is one
resourceA readable name for the target, on the events that carry one
ip_address, user_agent, trace_idRequest context. FerrisKey leaves all three null on the events it writes
detailsEvent-specific JSON, or null
timestampWhen the event was created, in UTC
event_hash, prev_hashThe 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:

ModeBehaviour
offStore them as they are. The default
maskKeep 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)
pseudonymiseReplace 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.