Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
name: CI

on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'guides/**'
- 'docs/**'
- 'LICENSE'
pull_request:
branches: [main]
paths-ignore:
- '**.md'
- 'guides/**'
- 'docs/**'
- 'LICENSE'

jobs:
ci:
uses: Taure/erlang-ci/.github/workflows/ci.yml@v2
permissions:
contents: write
pull-requests: write
with:
enable-ct: true
enable-hank: true
enable-audit: true
enable-sbom: true
enable-sbom-scan: true
enable-dependency-submission: true
enable-mutate: true
enable-summary: true
secrets: inherit
16 changes: 16 additions & 0 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
name: Release

on:
push:
branches: [main]
paths-ignore:
- '**.md'
- 'guides/**'
- 'docs/**'
- 'LICENSE'

jobs:
release:
uses: Taure/erlang-ci/.github/workflows/release.yml@v2
permissions:
contents: write
12 changes: 12 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
_build/
.rebar3/
*.beam
*.crashdump
rebar.lock
doc/
ebin/
.eunit/
logs/
*.iml
.idea/
.vscode/
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
# Changelog

All notable changes to this project will be documented in this file.

## [unreleased]

### Features

- Initial v0.1 scaffold: append-only audit-event log, logger + Kura adapters, sync + async dispatch with shigoto-optional fallback, write-time redaction, telemetry events, append-only enforcement via `nova_audit_kura:hardening_sql/0`.
71 changes: 70 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
@@ -1,2 +1,71 @@
# nova_audit
Append-only audit event log for the Nova ecosystem

Append-only audit-event log for the Nova ecosystem.

`nova_audit` is **not** a dependency of Nova core and must never become one.

## Quick start

```erlang
%% sys.config
{nova_audit, [{logs, #{
app_events => #{
adapter => nova_audit_kura,
repo => default,
table => audit_events,
redactor => fun my_app:redact_pii/1
},
access_log => #{adapter => nova_audit_log, level => info}
}}]}.

%% application code
Event = #{
actor => #{type => user, id => <<"alice">>},
action => <<"document.delete">>,
target => #{type => <<"document">>, id => <<"doc-42">>},
outcome => success
},
ok = nova_audit:log(app_events, Event),
ok = nova_audit:log_async(access_log, Event).
```

## Adapters

| Adapter | Storage | Status |
| ------------------ | ----------- | ------ |
| `nova_audit_log` | OTP logger | v0.1 |
| `nova_audit_kura` | Postgres | v0.1 |

`nova_audit_storage` (uses `nova_storage`) lands in v0.2.

## Scope

- Append-only by API contract.
- Required: `actor`, `action`. Library auto-assigns `event_id` (UUIDv7),
`schema_version`, and `occurred_at` (microseconds).
- Sync (`log/2`) or async (`log_async/2`, via Shigoto if loaded else
per-log worker with bounded queue).
- Per-log write-time `redactor` hook.
- Query API in v0.1 with 8 filter keys (actor_id, action, occurred_after,
occurred_before, outcome, target_id, target_type, request_id).

Out of scope: retention/archival, tamper-evidence/chain hashing,
application logging (use `logger`), metrics (use `telemetry`/OTel),
tracing (OTel), encryption-at-rest (`nova_vault`'s job).

## Build & test

```sh
rebar3 compile
rebar3 ct
rebar3 dialyzer
rebar3 xref
```

## Documentation

See the [guides](guides/) directory.

## License

Apache-2.0.
11 changes: 11 additions & 0 deletions apps/nova_audit/src/nova_audit.app.src
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
{application, nova_audit, [
{description, "Append-only audit event log for the Nova ecosystem"},
{vsn, "git"},
{registered, [nova_audit_sup, nova_audit_registry]},
{applications, [kernel, stdlib, jhn_stdlib]},
{mod, {nova_audit_app, []}},
{env, [{logs, #{}}]},
{modules, []},
{licenses, ["Apache-2.0"]},
{links, [{"GitHub", "https://github.com/novaframework/nova_audit"}]}
]}.
161 changes: 161 additions & 0 deletions apps/nova_audit/src/nova_audit.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,161 @@
-module(nova_audit).
-moduledoc """
Public API for the nova_audit library.

Append-only domain-event log. "Who did what when" trail. Compounds the
compliance story for DORA, NIS2, GDPR, AI Act audit-trail requirements.

## Quick start

```erlang
%% sys.config
{nova_audit, [{logs, #{
app_events => #{
adapter => nova_audit_kura,
repo => default,
table => audit_events,
redactor => fun my_app:redact_pii/1
},
access_log => #{adapter => nova_audit_log, level => info}
}}]}.

%% application code
Event = #{
actor => #{type => user, id => <<"alice">>},
action => <<"document.delete">>,
target => #{type => <<"document">>, id => <<"doc-42">>},
outcome => success
},
ok = nova_audit:log(app_events, Event).
```

## Append-only

The public API has no update or delete. Storage-level enforcement is the
operator's responsibility; see `nova_audit_kura:hardening_sql/0` for the
Postgres-revoke pattern.

## Redaction

Per-log `redactor => fun((event()) -> event())` is applied before the
adapter sees the event. Write-time only. Query-time access control is the
caller's responsibility; this library exposes raw events from `query/2,3`.
""".

-export([log/2, log_async/2, query/2, query/3]).

-type log_name() :: atom().
-type actor_type() :: user | service | system | anonymous.
-type actor() :: #{
id := binary(),
type := actor_type(),
attributes => #{binary() => binary()}
}.
-type target() :: #{
id := binary(),
type := binary(),
attributes => #{binary() => binary()}
}.
-type event() :: #{
actor := actor(),
action := binary(),
occurred_at => non_neg_integer(),
event_id => binary(),
schema_version => pos_integer(),
target => target(),
outcome => success | failure,
source => binary(),
request_id => binary(),
metadata => #{binary() => term()}
}.
-type filter() :: #{
actor_id => binary(),
action => binary(),
occurred_after => non_neg_integer(),
occurred_before => non_neg_integer(),
outcome => success | failure,
target_id => binary(),
target_type => binary(),
request_id => binary()
}.

-export_type([log_name/0, actor/0, target/0, event/0, filter/0]).

-spec log(log_name(), event()) -> ok | {error, term()}.
log(LogName, Event0) ->
Event = nova_audit_event:build(Event0),
with_log(LogName, fun(Adapter, State, _Worker, Spec) ->
Redacted = apply_redactor(Spec, Event),
nova_audit_telemetry:span(log, LogName, Adapter, fun() ->
Adapter:write(Redacted, State)
end)
end).

-spec log_async(log_name(), event()) -> ok | {error, term()}.
log_async(LogName, Event0) ->
Event = nova_audit_event:build(Event0),
with_log(LogName, fun(Adapter, _State, Worker, Spec) ->
Redacted = apply_redactor(Spec, Event),
dispatch_async(LogName, Worker, Adapter, Redacted)
end).

-spec query(log_name(), filter()) ->
{ok, [event()], nova_audit_adapter:cursor()} | {error, term()}.
query(LogName, Filter) ->
query(LogName, Filter, #{}).

-spec query(log_name(), filter(), nova_audit_adapter:query_opts()) ->
{ok, [event()], nova_audit_adapter:cursor()} | {error, term()}.
query(LogName, Filter, Opts) ->
with_log(LogName, fun(Adapter, State, _Worker, _Spec) ->
nova_audit_telemetry:span(query, LogName, Adapter, fun() ->
Adapter:query(Filter, Opts, State)
end)
end).

%% Internal

with_log(LogName, Fun) ->
case nova_audit_registry:lookup(LogName) of
{ok, Adapter, State, Worker} ->
Spec = log_spec(LogName),
Fun(Adapter, State, Worker, Spec);
{error, _} = E ->
E
end.

log_spec(LogName) ->
case application:get_env(nova_audit, logs, #{}) of
#{LogName := Spec} -> Spec;
_ -> #{}
end.

apply_redactor(#{redactor := F}, Event) when is_function(F, 1) ->
F(Event);
apply_redactor(_, Event) ->
Event.

dispatch_async(LogName, Worker, Adapter, Event) ->
case shigoto_available() of
true -> shigoto_enqueue(LogName, Adapter, Event);
false -> nova_audit_worker:write(Worker, Event)
end.

shigoto_available() ->
case code:is_loaded(shigoto) of
{file, _} -> true;
false -> false
end.

shigoto_enqueue(LogName, Adapter, Event) ->
try
M = shigoto,
F = enqueue,
apply(M, F, [#{
queue => nova_audit,
module => nova_audit_shigoto_job,
args => #{log_name => LogName, adapter => Adapter, event => Event}
}])
catch
_:_ -> ok
end.
27 changes: 27 additions & 0 deletions apps/nova_audit/src/nova_audit_adapter.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
-module(nova_audit_adapter).
-moduledoc """
Behaviour for nova_audit adapters.

Adapters own their own process. The `State` returned at registration time is
opaque to `nova_audit` and passed back to every callback.

## Append-only contract

Adapters MUST NOT expose update or delete operations through this behaviour.
The audit log is append-only by API contract. Storage-level enforcement
(database privileges, immutable buckets) is the operator's responsibility;
see `nova_audit_kura:hardening_sql/0` for the Kura-backed pattern.
""".

-type log_name() :: atom().
-type event() :: nova_audit:event().
-type filter() :: nova_audit:filter().
-type query_opts() :: #{cursor => binary() | done, limit => pos_integer()}.
-type cursor() :: binary() | done.

-export_type([log_name/0, query_opts/0, cursor/0]).

-callback start_link(Name :: log_name(), Opts :: map()) -> {ok, pid()} | {error, term()}.
-callback write(Event :: event(), State :: term()) -> ok | {error, term()}.
-callback query(Filter :: filter(), Opts :: query_opts(), State :: term()) ->
{ok, [event()], cursor()} | {error, term()}.
12 changes: 12 additions & 0 deletions apps/nova_audit/src/nova_audit_app.erl
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
-module(nova_audit_app).
-moduledoc false.

-behaviour(application).

-export([start/2, stop/1]).

start(_StartType, _StartArgs) ->
nova_audit_sup:start_link().

stop(_State) ->
ok.
Loading
Loading