Audit Service
Record immutable audit actions and list them by actor or tenant, with idempotent recording and offset/limit pagination backed by Postgres.
Exampleauditaudit-trailcompliancesecurityloggingpostgresrestapipaginationidempotencyappend-onlymulti-tenant
🌅 Horizon
Audit Service at a Glance
Overview
Compliance, security, and support workflows all need a durable, append-only record of who did what, when, to what, and with what outcome. This example models a single Audit Service that other services call to record a completed action attempt, retrieve one record by id, and list an actor's or a tenant's history.
Architecture
Every endpoint reads or writes the single AuditAction
entity; nothing else in the system participates.
What It Demonstrates
-
@datatype— one wide, thoroughly documented audit record that groups tenant, actor, action, target, request-context, and outcome fields, most of them optional so a caller supplies only what applies to its action. -
@database— a Postgres entity with an explicit primary key, compound indexes that support time-ordered actor/tenant pagination, and a hand-written command that makes recording an action idempotent on its caller-generated id. -
@api— a REST API taggedinternalwhose methods connect straight to generated database queries and commands, with no expression layer in between. -
@configand@deploy— shared, imported Postgres configuration and a packaged Postgres image, deployed with multiple service replicas.
Expected Result
You will record an audit action, retrieve it by id, and list an actor's and a tenant's action history ordered most-recent-first, observing offset/limit pagination and idempotent re-recording.
🧭 Voyage
1. Problem and Constraints
- An accepted audit record is immutable — there is no update or delete endpoint.
- The caller generates the record's id before submitting it, so a request can be retried safely.
- Resubmitting an existing id with identical content is an idempotent retry; resubmitting it with different content must be rejected.
- Actor ids are globally unique, so actor lookups need no tenant scope.
- Most fields are optional — the shape must fit system, anonymous, and pre-authentication actions, not only end-user ones.
- Actor and tenant history is returned most-recent-first, with
idas a tie-breaker, and supports offset/limit pagination.
2. Prerequisites
- The Ocean toolchain, at the version this example targets.
- Docker, to run the generated Postgres database service.
- hurl, to run the included component test suite (optional).
3. Example Structure
Executable model files are grouped by DSL section:
0012-audit-service/
├── audit-dt.ocn
├── audit-cfg.ocn
├── audit-db.ocn
├── audit-service.ocn
├── audit-deploy.ocn
├── api/
│ ├── audit-api.ocn
│ └── *.hurl
└── example-info/
└── example-info.html
api/ holds both the API declaration and a hurl
component test suite covering recording, retrieval, pagination, and
error behavior — documented further in
Verify the Result.
4. Model the Domain
AuditAction groups its fields by concern — tenant,
actor, action, target, request context, and outcome — so a caller
can see at a glance which fields apply to its situation. Nearly
every field beyond identity and outcome is optional.
@datatype
AuditAction
id pattern ACT-ULID
occurredAt : DateTime
tenantId : *String
actorId : *String
actorType : String
actorRole : *String
action : String
actionType : *String
actionSource : String
targetType : *String
targetId : *String
requestId : *String
ipAddress : *String
userAgent : *String
result : String
errorCode : *String
errorMessage : *String
metadata : Map<String,String>
action follows a stable <Resource>.<Operation>
naming convention (for example User.Login), and
metadata is reserved for small, non-sensitive context
that has no dedicated typed field.
5. Persist Actions as an Append-Only Log
Two compound indexes support paginated, time-ordered lookups by actor and by tenant. The insert command is hand-written rather than generated, because it must enforce idempotency on the caller-supplied id instead of always inserting.
@database
Database AuditDb
engine = postgres
configType = DatabaseConfig
tags = audit
Entity AuditAction
key(id)
indexes: index(actorId, occurredAt, id), index(tenantId, occurredAt, id)
query findAuditActionById(id:String) : AuditAction
query listAuditActionsByActor(actorId:String, offset:Int, limit:Int) : List<AuditAction>
query listAuditActionsByTenant(tenantId:String, offset:Int, limit:Int) : List<AuditAction>
# An existing id with identical content is an idempotent retry.
# An existing id with different content must be rejected.
command T recordAuditAction(action:AuditAction) : AuditAction
6. Define the API
Pagination is expressed directly in the path, and lookups by actor or tenant are separate endpoints rather than a single filtered query.
@api
AuditApi style: rest
engine = gin
configType = ApiConfig
tags = internal
version = 1.0.0
basePath = /
generateSwagger = true
post /audit/actions recordAuditAction(action:AuditAction) : AuditAction
get /audit/actions/{id} getAuditActionById(id:String) : AuditAction
get /audit/actors/{actorId}/{offset}/{limit} listAuditByActor(actorId:String, offset:Int, limit:Int) : List<AuditAction>
get /audit/tenants/{tenantId}/{offset}/{limit} listAuditByTenant(tenantId:String, offset:Int, limit:Int) : List<AuditAction>
7. Assemble the Service Without an Expression Layer
Unlike an example that bridges API and database through
@expression, every method here connects directly to its
matching generated database query or command. There is no
intermediate transformation because the API and entity shapes are
already the same.
AuditService
use config AuditServiceConfig as myCfg
impl api AuditApi as api on myCfg.apiConfig.port
use database AuditDb as auditDb
connect myCfg.dbConfig -> auditDb
connect api.recordAuditAction -> AuditAction.recordAuditAction
connect api.getAuditActionById -> AuditAction.findAuditActionById
connect api.listAuditByActor -> AuditAction.listAuditActionsByActor
connect api.listAuditByTenant -> AuditAction.listAuditActionsByTenant
8. Select Technologies and Configure Generation
The API is generated with a REST engine (gin) on a configurable port, the database uses a packaged Postgres image, and the service runs two replicas since recording and reading audit actions carry no in-process state.
@deploy
@import service P.db.postgres.docker@1.0.0 as PostgresqlDB
AuditDeploy
service AuditService
replica 2
export 9092:AuditService.api
dependsOn AuditDatabase
AuditDatabase
service PostgresqlDB
9. Validate, Generate, and Run
- From
0012-audit-service/, validate and generate the example with the Ocean toolchain. - Start the generated Postgres database and
AuditService; wait for a healthy/health/readyresponse on port9092.
10. Verify the Result
Record an action, read it back by id, then list it by actor:
POST /v1/audit/actions
{"actorId":"actor-1","actorType":"user","action":"user.login",
"actionSource":"web-app","result":"success"}
-> {"id":"ACT-...","actorId":"actor-1","action":"user.login",...}
GET /v1/audit/actions/ACT-...
-> {"id":"ACT-...","actorId":"actor-1","action":"user.login",...}
GET /v1/audit/actors/actor-1/0/10
-> [{"id":"ACT-...","actorId":"actor-1",...}]
The api/ hurl suite automates this and more: a
fully-populated round trip and zero-value defaults
(10_record_action_happy_path.hurl,
11_record_action_defaults.hurl), a client-supplied id
being discarded (12_record_action_client_id_ignored.hurl),
actor/tenant pagination (21_list_by_actor.hurl,
22_list_by_tenant.hurl), and invalid-input handling; run
any of them with
hurl --test --variable host=http://localhost:9092 api/<file>.hurl.
11. Troubleshooting
getAuditActionByIdreturns500 Internal Server Errorfor an id that was never recorded: the "record not found" error from the database does not currently match the generic error classifier'sNOT_FOUNDheuristics, so it falls through to a generic 500 instead of 404.recordAuditActionaccepts an almost-empty body: no field-level validation runs beyond JSON binding, so an empty{}is persisted with zero-value fields rather than rejected — only malformed JSON returns400.- A client-supplied
idin the request body is silently replaced: the recording command always generates a fresh id server-side; supply the id you expect to see back only for your own idempotency tracking, not as a way to choose it. - Non-numeric
offset/limitpath segments return400with"invalid offset"or"invalid limit": both are parsed before any database query runs.
12. Experiments and Extensions
- Resubmit the exact same recorded action twice and confirm the identical id and content round-trip as an idempotent retry, then resubmit the same id with different content and observe the rejection.
- Add field-level validation to
recordAuditActionso a missingactionoractorTypereturns400instead of persisting a zero-value record. - Tighten the error classifier so a database "not found" is recognized as
NOT_FOUND, and update the expectation in20_get_action_not_found.hurlfrom500to404. - Add a query that filters by
actionTypeorresultto support "show me all failed logins" style investigations.
Executable model
<\> Implementation
Explore the runnable model by responsibility, then select a file to inspect its complete source.
# GET /info — standard service information endpoint.
GET {{host}}/info
HTTP 200
[Asserts]
header "Content-Type" contains "application/json"
jsonpath "$.id" == "SVC-AUDIT-SERVICE"
jsonpath "$.name" == "AuditService"
jsonpath "$.version" == "0.1.0"
jsonpath "$.address" isString
jsonpath "$.summary" == "SUMMARY-AuditService"
jsonpath "$.startedAt" isString
jsonpath "$.features" count == 0
# GET /health, /health/live, /health/ready — all three currently report a
# static "healthy" state (see audit-service-impl.go: no real dependency
# checks are wired in yet).
GET {{host}}/health
HTTP 200
[Asserts]
jsonpath "$.state" == "healthy"
jsonpath "$.startedAt" isString
jsonpath "$.checkedAt" isString
jsonpath "$.checks" count == 1
jsonpath "$.checks[0].name" == "service"
jsonpath "$.checks[0].state" == "healthy"
GET {{host}}/health/live
HTTP 200
[Asserts]
jsonpath "$.state" == "healthy"
jsonpath "$.checks" count == 1
jsonpath "$.checks[0].name" == "process"
jsonpath "$.checks[0].state" == "healthy"
GET {{host}}/health/ready
HTTP 200
[Asserts]
jsonpath "$.state" == "healthy"
jsonpath "$.checks" count == 1
jsonpath "$.checks[0].name" == "readiness"
jsonpath "$.checks[0].state" == "healthy"
# The router registers a catch-all OPTIONS handler (r.OPTIONS("/*path", ...))
# that always answers 204, independent of whether the underlying route exists.
# This is typically used for CORS preflight requests.
OPTIONS {{host}}/v1/audit/actions
HTTP 204
OPTIONS {{host}}/v1/audit/actions/anything
HTTP 204
OPTIONS {{host}}/this/route/does/not/exist
HTTP 204
# GET /swagger/*any — served by gin-swagger / swaggo, backed by the
# generated docs package imported for its side effect in audit-service-api.go.
GET {{host}}/swagger/index.html
HTTP 200
[Asserts]
header "Content-Type" contains "text/html"
GET {{host}}/swagger/doc.json
HTTP 200
[Asserts]
header "Content-Type" contains "application/json"
jsonpath "$.info.title" contains "AuditService"
# POST /v1/audit/actions with a fully populated payload, then confirm the
# persisted record round-trips exactly via GET /v1/audit/actions/:id.
POST {{host}}/v1/audit/actions
{
"occurredAt": "2026-08-20T12:00:00Z",
"tenantId": "tenant-{{newUuid}}",
"actorId": "actor-{{newUuid}}",
"actorType": "user",
"actorRole": "admin",
"action": "user.login",
"actionType": "auth",
"actionSource": "web-app",
"targetType": "session",
"targetId": "sess-123",
"requestId": "req-abc",
"ipAddress": "203.0.113.7",
"userAgent": "hurl-test-agent/1.0",
"result": "success",
"errorCode": null,
"errorMessage": null,
"metadata": {
"browser": "chrome",
"region": "eu-west-1"
}
}
HTTP 200
[Captures]
action_id: jsonpath "$.id"
tenant_id: jsonpath "$.tenantId"
actor_id: jsonpath "$.actorId"
[Asserts]
jsonpath "$.id" matches "^ACT-"
jsonpath "$.occurredAt" == "2026-08-20T12:00:00Z"
jsonpath "$.actorType" == "user"
jsonpath "$.actorRole" == "admin"
jsonpath "$.action" == "user.login"
jsonpath "$.actionType" == "auth"
jsonpath "$.actionSource" == "web-app"
jsonpath "$.targetType" == "session"
jsonpath "$.targetId" == "sess-123"
jsonpath "$.requestId" == "req-abc"
jsonpath "$.ipAddress" == "203.0.113.7"
jsonpath "$.userAgent" == "hurl-test-agent/1.0"
jsonpath "$.result" == "success"
jsonpath "$.errorCode" == null
jsonpath "$.errorMessage" == null
jsonpath "$.metadata.browser" == "chrome"
jsonpath "$.metadata.region" == "eu-west-1"
GET {{host}}/v1/audit/actions/{{action_id}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{action_id}}"
jsonpath "$.tenantId" == "{{tenant_id}}"
jsonpath "$.actorId" == "{{actor_id}}"
jsonpath "$.occurredAt" == "2026-08-20T12:00:00Z"
jsonpath "$.action" == "user.login"
jsonpath "$.result" == "success"
jsonpath "$.targetId" == "sess-123"
jsonpath "$.metadata.browser" == "chrome"
jsonpath "$.metadata.region" == "eu-west-1"
# The handler performs no server-side field validation (see
# handleRecordAuditAction in audit-service-api.go: only c.BindJSON is
# checked, and the audit_actions table has no NOT NULL constraints besides
# the primary key). An almost-empty JSON body is accepted and persisted
# with zero-value fields.
POST {{host}}/v1/audit/actions
{}
HTTP 200
[Captures]
empty_action_id: jsonpath "$.id"
[Asserts]
jsonpath "$.id" matches "^ACT-"
jsonpath "$.tenantId" == null
jsonpath "$.actorId" == null
jsonpath "$.actorType" == ""
jsonpath "$.actorRole" == null
jsonpath "$.action" == ""
jsonpath "$.actionType" == null
jsonpath "$.actionSource" == ""
jsonpath "$.targetType" == null
jsonpath "$.targetId" == null
jsonpath "$.result" == ""
jsonpath "$.errorCode" == null
jsonpath "$.errorMessage" == null
jsonpath "$.metadata" == null
GET {{host}}/v1/audit/actions/{{empty_action_id}}
HTTP 200
[Asserts]
jsonpath "$.id" == "{{empty_action_id}}"
jsonpath "$.action" == ""
jsonpath "$.result" == ""
# CommandAuditActionRecordAuditAction always overwrites obj.Id with a fresh
# ULID (GenerateAuditActionID) before insert, so any client-supplied "id" is
# silently discarded.
POST {{host}}/v1/audit/actions
{
"id": "CLIENT-SUPPLIED-ID-SHOULD-BE-IGNORED",
"actorType": "service",
"action": "custom.action",
"actionSource": "hurl-test",
"result": "success"
}
HTTP 200
[Asserts]
jsonpath "$.id" != "CLIENT-SUPPLIED-ID-SHOULD-BE-IGNORED"
jsonpath "$.id" matches "^ACT-"
# Malformed JSON body must be rejected by c.BindJSON with a 400, before
# any service/DB call happens.
POST {{host}}/v1/audit/actions
Content-Type: application/json
`{"action": "broken", "trailingComma": true,}`
HTTP 400
[Asserts]
jsonpath "$.error" isString
# Empty body is also invalid JSON (EOF) and must be rejected the same way.
POST {{host}}/v1/audit/actions
Content-Type: application/json
``
HTTP 400
[Asserts]
jsonpath "$.error" isString
# QueryAuditActionFindAuditActionById wraps gorm.ErrRecordNotFound with
# fmt.Errorf("...: %w", err), so errors.Is(err, gorm.ErrRecordNotFound)
# still succeeds through the wrapping. datatype.ClassifyError's
# classifyDatabaseError case for gorm.ErrRecordNotFound therefore matches
# and returns NewNotFoundError() (404 / "item not found").
GET {{host}}/v1/audit/actions/ACT-DOES-NOT-EXIST
HTTP 404
[Asserts]
jsonpath "$.error" == "item not found"
# Seed 3 audit actions under the same freshly generated actorId, then
# verify ListAuditByActor returns exactly those records and correctly
# respects offset/limit pagination (GET /v1/audit/actors/:actorId/:offset/:limit).
POST {{host}}/v1/audit/actions
{
"actorId": "actor-{{newUuid}}",
"actorType": "user",
"action": "record.one",
"actionSource": "hurl-test",
"result": "success"
}
HTTP 200
[Captures]
actor_id: jsonpath "$.actorId"
POST {{host}}/v1/audit/actions
{
"actorId": "{{actor_id}}",
"actorType": "user",
"action": "record.two",
"actionSource": "hurl-test",
"result": "success"
}
HTTP 200
POST {{host}}/v1/audit/actions
{
"actorId": "{{actor_id}}",
"actorType": "user",
"action": "record.three",
"actionSource": "hurl-test",
"result": "success"
}
HTTP 200
# A page large enough to cover all 3 records returns exactly 3, all
# belonging to this actor.
GET {{host}}/v1/audit/actors/{{actor_id}}/0/10
HTTP 200
[Asserts]
jsonpath "$" count == 3
jsonpath "$[*].actorId" contains "{{actor_id}}"
# limit=1 returns exactly 1 record.
GET {{host}}/v1/audit/actors/{{actor_id}}/0/1
HTTP 200
[Asserts]
jsonpath "$" count == 1
jsonpath "$[0].actorId" == "{{actor_id}}"
# Paging past the end of the result set returns an empty array.
GET {{host}}/v1/audit/actors/{{actor_id}}/100/10
HTTP 200
[Asserts]
jsonpath "$" count == 0
# limit=0 returns an empty array (SQL LIMIT 0).
GET {{host}}/v1/audit/actors/{{actor_id}}/0/0
HTTP 200
[Asserts]
jsonpath "$" count == 0
# An actor with no recorded actions returns an empty array, not an error.
GET {{host}}/v1/audit/actors/actor-{{newUuid}}/0/10
HTTP 200
[Asserts]
jsonpath "$" count == 0
# Same coverage as 21_list_by_actor.hurl, but for
# GET /v1/audit/tenants/:tenantId/:offset/:limit.
POST {{host}}/v1/audit/actions
{
"tenantId": "tenant-{{newUuid}}",
"actorType": "user",
"action": "record.one",
"actionSource": "hurl-test",
"result": "success"
}
HTTP 200
[Captures]
tenant_id: jsonpath "$.tenantId"
POST {{host}}/v1/audit/actions
{
"tenantId": "{{tenant_id}}",
"actorType": "user",
"action": "record.two",
"actionSource": "hurl-test",
"result": "success"
}
HTTP 200
POST {{host}}/v1/audit/actions
{
"tenantId": "{{tenant_id}}",
"actorType": "user",
"action": "record.three",
"actionSource": "hurl-test",
"result": "success"
}
HTTP 200
# A page large enough to cover all 3 records returns exactly 3, all
# belonging to this tenant.
GET {{host}}/v1/audit/tenants/{{tenant_id}}/0/10
HTTP 200
[Asserts]
jsonpath "$" count == 3
jsonpath "$[*].tenantId" contains "{{tenant_id}}"
# limit=1 returns exactly 1 record.
GET {{host}}/v1/audit/tenants/{{tenant_id}}/0/1
HTTP 200
[Asserts]
jsonpath "$" count == 1
jsonpath "$[0].tenantId" == "{{tenant_id}}"
# Paging past the end of the result set returns an empty array.
GET {{host}}/v1/audit/tenants/{{tenant_id}}/100/10
HTTP 200
[Asserts]
jsonpath "$" count == 0
# limit=0 returns an empty array (SQL LIMIT 0).
GET {{host}}/v1/audit/tenants/{{tenant_id}}/0/0
HTTP 200
[Asserts]
jsonpath "$" count == 0
# A tenant with no recorded actions returns an empty array, not an error.
GET {{host}}/v1/audit/tenants/tenant-{{newUuid}}/0/10
HTTP 200
[Asserts]
jsonpath "$" count == 0
# Non-numeric :offset/:limit path params must be rejected with 400 before
# any DB query runs (see handleListAuditByActor / handleListAuditByTenant:
# strconv.Atoi errors short-circuit with "invalid offset" / "invalid limit").
GET {{host}}/v1/audit/actors/some-actor/not-a-number/10
HTTP 400
[Asserts]
jsonpath "$.error" == "invalid offset"
GET {{host}}/v1/audit/actors/some-actor/0/not-a-number
HTTP 400
[Asserts]
jsonpath "$.error" == "invalid limit"
GET {{host}}/v1/audit/tenants/some-tenant/not-a-number/10
HTTP 400
[Asserts]
jsonpath "$.error" == "invalid offset"
GET {{host}}/v1/audit/tenants/some-tenant/0/not-a-number
HTTP 400
[Asserts]
jsonpath "$.error" == "invalid limit"
# @ocean-meta-start
# tags:
# - audit-api
# - rest-api
# perspective:
# feature: audit-service
# service: audit-service
# @ocean-meta-end
@api
AuditApi style: rest
engine = gin
configType = ApiConfig
tags = internal
version = 1.0.0
description = Internal API for recording and querying immutable audit actions.
basePath = /
generateSwagger = true
# ----------------------------------------------------------------------------
# Record audit action
# ----------------------------------------------------------------------------
# Records one completed action attempt.
#
# The caller generates the ACT-ULID before submitting the action.
# Audit records are immutable after they have been accepted.
#
# Submitting an existing id with identical content is treated as an idempotent
# retry. Submitting the same id with different content must be rejected.
post /audit/actions recordAuditAction(action:AuditAction) : AuditAction
# ----------------------------------------------------------------------------
# Single audit action
# ----------------------------------------------------------------------------
# Retrieves one audit action by its ACT-ULID.
get /audit/actions/{id} getAuditActionById(id:String) : AuditAction
# ----------------------------------------------------------------------------
# Actor activity
# ----------------------------------------------------------------------------
# Lists audit actions initiated by an actor.
#
# Actor IDs are globally unique, so tenant scope is not required.
# Results are ordered by occurredAt descending, with id as the tie-breaker.
get /audit/actors/{actorId}/{offset}/{limit} listAuditByActor(actorId:String, offset:Int, limit:Int) : List<AuditAction>
# ----------------------------------------------------------------------------
# Tenant activity
# ----------------------------------------------------------------------------
# Lists audit actions affecting a tenant.
#
# Results are ordered by occurredAt descending, with id as the tie-breaker.
get /audit/tenants/{tenantId}/{offset}/{limit} listAuditByTenant(tenantId:String, offset:Int, limit:Int) : List<AuditAction>
# @ocean-meta-start
# tags:
# - configuration
# perspective:
# feature: audit-service
# service: audit-service
# @ocean-meta-end
@config
@import config O.database.postgres.config@1.0.0 as DatabaseConfig
@import config O.log.config@1.0.0 as LogConfig
AuditServiceConfig
apiConfig : ApiConfig
dbConfig : DatabaseConfig
logConfig : LogConfig
ApiConfig
port: Int (default=8080)
# @ocean-meta-start
# tags:
# - audit-trail
# - database
# perspective:
# feature: audit-service
# service: audit-service
# @ocean-meta-end
@database
Database AuditDb
engine = postgres
configType = DatabaseConfig
tags = audit
Entity AuditAction
# --------------------------------------------------------------------
# Primary key
# --------------------------------------------------------------------
# Guarantees that every audit action is uniquely identified.
# It also supports idempotent recording using the caller-generated ID.
key(id)
# Supports listing an actor's actions ordered by occurredAt and id.
# Supports listing a tenant's actions ordered by occurredAt and id.
indexes: index(actorId, occurredAt, id), index(tenantId, occurredAt, id)
# --------------------------------------------------------------------
# Queries
# --------------------------------------------------------------------
# Retrieves one audit action by its ACT-ULID.
query findAuditActionById(id:String) : AuditAction
# Lists actions initiated by a globally unique actor.
# Results are ordered by occurredAt descending, with id as tie-breaker.
query listAuditActionsByActor(actorId:String, offset:Int, limit:Int) : List<AuditAction>
# Lists actions affecting a tenant.
# Results are ordered by occurredAt descending, with id as tie-breaker.
query listAuditActionsByTenant(tenantId:String, offset:Int, limit:Int) : List<AuditAction>
# --------------------------------------------------------------------
# Commands — append-only
# --------------------------------------------------------------------
# Inserts an immutable audit action.
#
# An existing id with identical content is an idempotent retry.
# An existing id with different content must be rejected.
command T recordAuditAction(action:AuditAction) : AuditAction
# @ocean-meta-start
# tags:
# - deployment
# perspective:
# feature: audit-service
# service: audit-service
# @ocean-meta-end
@deploy
Name: Audit-deploy
@import service P.db.postgres.docker@1.0.0 as PostgresqlDB
AuditDeploy
service AuditService
replica 2
export 9092:AuditService.api
dependsOn AuditDatabase
AuditDatabase
service PostgresqlDB
# @ocean-meta-start
# tags:
# - audit-trail
# - datatype
# perspective:
# feature: audit-service
# service: audit-service
# @ocean-meta-end
@datatype
AuditAction
# Unique audit record identifier.
# Generated by the Audit service.
id pattern ACT-ULID
# UTC timestamp indicating when the action occurred.
occurredAt : DateTime
# -------------------------
# Tenant
# -------------------------
# Tenant affected by the action.
# Nil for platform-wide or pre-authentication actions.
tenantId : *String
# -------------------------
# Actor — who initiated it
# -------------------------
# Actor identifier.
# May be nil for system or anonymous actors.
actorId : *String
# Actor classification.
# Conventional values: user, service, system, anonymous.
actorType : String
# Snapshot of the actor's role when the action occurred.
actorRole : *String
# -------------------------
# Action — what was attempted
# -------------------------
# Stable action name using the <Resource>.<Operation> convention.
# Examples: User.Login, Project.Create, Organization.MemberRemove.
action : String
# Optional action classification.
# Examples: authentication, authorization, security, administration.
actionType : *String
# Channel through which the action originated.
# Conventional values: ui, api, broker, scheduler.
actionSource : String
# -------------------------
# Primary target
# -------------------------
# Type of the primary resource affected by the action.
# Examples: User, Organization, Project.
targetType : *String
# Identifier of the primary affected resource.
# targetType and targetId should normally be provided together.
targetId : *String
# -------------------------
# Request context
# -------------------------
# Identifier used to correlate the action with its originating request.
requestId : *String
# Client IP address, when supplied by a trusted gateway or service.
ipAddress : *String
# Client user agent, when available.
userAgent : *String
# -------------------------
# Outcome
# -------------------------
# Outcome of the attempted action.
# Conventional values: success, failure, denied.
result : String
# Stable machine-readable error code.
# Normally nil when result is success.
errorCode : *String
# Short, scrubbed error description.
# Must not contain secrets, stack traces, or sensitive payloads.
errorMessage : *String
# -------------------------
# Additional context
# -------------------------
# Small key-value context that is not represented by a typed field.
# Must be empty when unused and must not contain secrets or full objects.
metadata : Map<String,String>
# @ocean-meta-start
# tags:
# - audit-api
# - service
# perspective:
# feature: audit-service
# service: audit-service
# @ocean-meta-end
@service
AuditService
@perspectives: version:0.1.0, lifestyle:stable
use config AuditServiceConfig as myCfg
impl api AuditApi as api on myCfg.apiConfig.port
use database AuditDb as auditDb
connect myCfg.dbConfig -> auditDb
connect api.recordAuditAction -> AuditAction.recordAuditAction
connect api.getAuditActionById -> AuditAction.findAuditActionById
connect api.listAuditByActor -> AuditAction.listAuditActionsByActor
connect api.listAuditByTenant -> AuditAction.listAuditActionsByTenant