Skip to content
Closed
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
15 changes: 12 additions & 3 deletions src/api/platform.py
Original file line number Diff line number Diff line change
Expand Up @@ -784,7 +784,7 @@ def _audit_endpoint(self, method, actor) -> APIResponse:
if method != "GET":
return self._method_not_allowed("GET")
events = self.audit.list_visible(actor, limit=500)
return APIResponse(200, {"audit_events": [self._audit(item) for item in events]})
return APIResponse(200, {"audit_events": self._audit_items(events)})

_AUDIT_PAGE_SIZES = (25, 50, 100, 150, 250, 500)

Expand All @@ -805,7 +805,7 @@ def _audit_page_endpoint(self, method, actor, page, size=None) -> APIResponse:
return APIResponse(
200,
{
"audit_events": [self._audit(item) for item in events],
"audit_events": self._audit_items(events),
"pagination": {
"page": current,
"page_size": page_size,
Expand Down Expand Up @@ -1988,11 +1988,20 @@ def _delivery_result(item):
"safe_error": sanitize_text(item.safe_error)[:500],
}

def _audit_items(self, events):
usernames = {user.id: user.username for user in self.users.list()}
return [self._audit(item, usernames) for item in events]

@staticmethod
def _audit(item):
def _audit(item, usernames=None):
actor_username = None
if item.actor_user_id:
actor_username = (usernames or {}).get(item.actor_user_id)

return {
"id": item.id,
"actor_user_id": item.actor_user_id,
"actor_username": actor_username,
"action": item.action,
"resource_type": item.resource_type,
"resource_id": item.resource_id,
Expand Down
115 changes: 107 additions & 8 deletions src/webui/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,6 @@ async function loadWorkspace() {
preferences: ["Regional settings", request("/preferences"), (value) => { state.preferences = value.preferences; }],
notices: ["Notices", request("/notices"), (value) => { state.notices = value.notices; }],
metrics: ["Overview metrics", request(`/metrics/${state.historyRange}`), (value) => { state.metrics = value.metrics; }],
health: ["Health checks", request("/health-checks"), (value) => { state.healthChecks = value.checks; }],
version: ["Version status", request("/version"), (value) => { state.versionStatus = value.version; }],
};
if (isAdmin()) {
Expand Down Expand Up @@ -1582,24 +1581,124 @@ function renderDeliveries() {
}
}

function auditActionLabel(value) {
const parts = String(value || "")
.split(".")
.filter(Boolean)
.map(capitalize);

return parts.length ? parts.join(" · ") : "Unknown action";
}

function auditActorLabel(item) {
if (item.actor_username) return item.actor_username;

if (item.actor_user_id) {
return `User ${String(item.actor_user_id).slice(0, 8)}`;
}

if (item.action === "session.login" && item.outcome !== "success") {
return "Unauthenticated";
}

return "System";
}

function auditDetailValue(value) {
if (value === null || value === undefined || value === "") return "—";
if (typeof value === "boolean") return value ? "Yes" : "No";
if (Array.isArray(value)) return value.join(", ");
return String(value);
}

function auditDetailsText(details) {
if (!details || typeof details !== "object") return "No additional details";

const entries = Object.entries(details);
if (!entries.length) return "No additional details";

return entries
.map(([key, value]) => `${friendlyName(key)}: ${auditDetailValue(value)}`)
.join(" · ");
}

function renderAudit() {
const query = byId("audit-search").value.trim().toLowerCase();
const items = state.audit.filter((item) => JSON.stringify([item.action, item.resource_type, item.outcome, item.details]).toLowerCase().includes(query)).slice(0, state.auditPageSize);

const items = state.audit
.filter((item) => JSON.stringify([
item.action,
item.actor_username,
item.actor_user_id,
item.resource_type,
item.resource_id,
item.outcome,
item.details,
]).toLowerCase().includes(query))
.slice(0, state.auditPageSize);

const body = byId("audit-table");
body.replaceChildren();
byId("audit-empty").hidden = items.length > 0;

if (!items.length) {
byId("audit-empty").replaceChildren(element("strong", { text: query ? "No matching audit events" : "No audit events" }), element("span", { text: query ? "Try a different search." : "Security-relevant activity appears here." }));
byId("audit-empty").replaceChildren(
element("strong", {
text: query ? "No matching audit events" : "No audit events",
}),
element("span", {
text: query
? "Try a different search."
: "Security-relevant activity appears here.",
}),
);
return;
}

for (const item of items) {
const details = item.details && Object.keys(item.details).length ? JSON.stringify(item.details) : "—";
const actor = auditActorLabel(item);
const resource = friendlyName(item.resource_type);
const resourceId = String(item.resource_id || "");

body.append(element("tr", {}, [
element("td", { text: formatTime(item.created_at) }),
element("td", {}, element("strong", { text: item.action })),
element("td", { text: item.resource_type }),
element("td", {}, badge(item.outcome, item.outcome === "success" ? "success" : "danger")),
element("td", {}, element("small", { text: details })),

element("td", {}, [
element("strong", { text: auditActionLabel(item.action) }),
element("small", { text: item.action || "unknown" }),
]),

element("td", {}, [
element("strong", { text: actor }),
item.actor_user_id
? element("small", {
text: `ID ${String(item.actor_user_id).slice(0, 8)}`,
title: String(item.actor_user_id),
})
: element("small", { text: "No authenticated user" }),
]),

element("td", {}, [
element("strong", { text: resource }),
resourceId
? element("code", { text: resourceId, title: resourceId })
: element("small", { text: "Platform-level action" }),
]),

element(
"td",
{},
badge(
item.outcome,
item.outcome === "success" ? "success" : "danger",
),
),

element(
"td",
{},
element("small", { text: auditDetailsText(item.details) }),
),
]));
}
}
Expand Down
2 changes: 1 addition & 1 deletion src/webui/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -258,7 +258,7 @@ <h1 id="page-title">Dashboard</h1>
<section id="view-audit" class="view" data-page="audit" hidden>
<div class="section-toolbar"><div><h2>Audit log</h2><p>Review security-relevant actions, health checks, outcomes, and operational details.</p></div><label class="search-field"><span class="sr-only">Search audit events</span><input id="audit-search" type="search" placeholder="Search action or outcome"></label></div>
<article class="panel health-panel"><div class="panel-heading"><div><p class="eyebrow">System checks</p><h3>Nowlert health</h3><p>Read-only tests of configuration, storage, credentials, routes, backups, and recent delivery failures.</p></div><button class="button secondary" type="button" data-action="run-health-checks">Run checks</button></div><div id="health-check-list" class="health-check-list"></div></article>
<div class="table-panel"><div class="table-scroll"><table><thead><tr><th>Time</th><th>Action</th><th>Resource</th><th>Outcome</th><th>Details</th></tr></thead><tbody id="audit-table"></tbody></table></div><div id="audit-empty" class="empty-state" hidden></div></div>
<div class="table-panel"><div class="table-scroll"><table><thead><tr><th>Time</th><th>Action</th><th>User</th><th>Resource</th><th>Outcome</th><th>Details</th></tr></thead><tbody id="audit-table"></tbody></table></div><div id="audit-empty" class="empty-state" hidden></div></div>
<div class="audit-footer"><label><span>Entries</span><select id="audit-page-size"><option>25</option><option>50</option><option>100</option><option>150</option><option>250</option><option>500</option></select></label></div>
</section>

Expand Down
14 changes: 13 additions & 1 deletion tests/test_platform_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -883,12 +883,24 @@ def test_notices_avatar_and_application_lifecycle_are_exposed_safely(

def test_audit_visibility_and_database_exclude_submitted_credentials(platform_api):
headers = login(platform_api)
create_destination(platform_api, headers)
destination = create_destination(platform_api, headers)
audit = call(platform_api, "GET", "/api/v2/audit-events", headers=headers)
database_bytes = platform_api["database"].path.read_bytes()

assert audit.status == 200
assert audit.payload["audit_events"]

created = next(
item
for item in audit.payload["audit_events"]
if item["action"] == "destination.create"
)
assert created["actor_user_id"] == platform_api["admin"].id
assert created["actor_username"] == "administrator"
assert created["resource_type"] == "destination"
assert created["resource_id"] == destination["id"]
assert created["details"]["output_type"] == "webhook"

assert b"https://example.invalid/events" not in database_bytes
assert "https://example.invalid/events" not in json.dumps(audit.payload)

Expand Down
36 changes: 36 additions & 0 deletions tests/test_webui.py
Original file line number Diff line number Diff line change
Expand Up @@ -549,3 +549,39 @@ def test_nce22_nce24_delivery_and_audit_direct_pagination_controls():
# Layout remains usable on narrow displays.
assert ".qa-pagination .qa-page-number" in styles
assert "@media (max-width: 720px)" in styles



def test_nce28_nce29_audit_context_and_explicit_health_checks():
markup = (ROOT / "src" / "webui" / "index.html").read_text(encoding="utf-8")
script = (ROOT / "src" / "webui" / "app.js").read_text(encoding="utf-8")

# NCE-28: Audit Log exposes actor, precise action, affected resource,
# resource identifier, outcome, and safe details.
assert (
"<th>Time</th><th>Action</th><th>User</th><th>Resource</th>"
"<th>Outcome</th><th>Details</th>"
) in markup
assert "function auditActionLabel(value)" in script
assert "function auditActorLabel(item)" in script
assert "function auditDetailsText(details)" in script
assert "item.actor_username" in script
assert "item.actor_user_id" in script
assert "item.resource_id" in script
assert 'text: item.action || "unknown"' in script
assert 'text: auditDetailsText(item.details)' in script

# Search includes the newly-visible actor and affected entity context.
assert "item.actor_username," in script
assert "item.actor_user_id," in script
assert "item.resource_id," in script

# NCE-29: generic workspace loading must not execute a health check.
assert (
'health: ["Health checks", request("/health-checks")'
not in script
)

# Health checks remain available only as an explicit user action.
assert 'action === "run-health-checks"' in script
assert 'const response = await request("/health-checks");' in script