From 991ad4aa2b9d3a9dfbc699abad5ae7c5536cf30c Mon Sep 17 00:00:00 2001 From: Jalil Date: Tue, 10 Nov 2026 16:09:15 -0800 Subject: [PATCH] fix(audit): show the newest events in the CLI, dashboard and console `GET /v1/audit-events` returns events oldest first. The CLI's `usage audit`, the dashboard's Audit tab and the console's audit log asked for the first page (100 or 200 events) and reversed it, so on any installation with more events than that, nothing recent ever appeared. The endpoint takes `order=desc` (newest first; the page's nextCursor is the `beforeSeq` of the next, older page) and `beforeSeq`. Both stores implement it, the three views use it, and the TypeScript and Python SDKs take `order` and `beforeSeq`/`before_seq`. The default order is unchanged. Tests: TestAuditEventsNewestFirst (server), new cases in the store conformance suite's AuditChain (run against Postgres in CI only), and one test in each SDK. Co-Authored-By: Claude Fable 5.2 Signed-off-by: Jalil --- CHANGELOG.md | 5 ++++ api/openapi.yaml | 8 ++++- cmd/differently/main.go | 2 +- console/src/pages/audit-log.astro | 2 +- internal/dashboard/static/app.js | 4 +-- internal/server/audit_order_test.go | 42 +++++++++++++++++++++++++++ internal/server/tenancy.go | 11 ++++++- internal/store/memory.go | 7 ++++- internal/store/postgres/postgres.go | 8 +++-- internal/store/store.go | 6 +++- internal/store/storetest/storetest.go | 25 ++++++++++++++++ sdk/python/differently/_resources.py | 19 ++++++++++-- sdk/python/tests/test_client.py | 10 +++++++ sdk/typescript/src/resources.ts | 6 ++-- sdk/typescript/src/types.ts | 7 +++++ sdk/typescript/test/client.test.ts | 11 +++++++ 16 files changed, 157 insertions(+), 16 deletions(-) create mode 100644 internal/server/audit_order_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 136173e..8779609 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -79,6 +79,11 @@ None has run against a live backend. containing "localhost", and not to `127.0.0.1`. - A streamed exec through the Go client (`differently sandbox exec`) is no longer cut off after ten minutes. +- The audit views show the latest events. `differently usage audit`, the + dashboard and the console asked for the first page, which is the oldest + events, so anything after the first 100 or 200 never appeared. + `GET /v1/audit-events` takes `order=desc` (newest first) and `beforeSeq`; + the TypeScript and Python SDKs take `order` and `beforeSeq`/`before_seq`. - CI: the Python SDK job runs on `ubuntu-24.04`, the last runner image with a Python 3.9 build; on `ubuntu-latest` it failed at setup. diff --git a/api/openapi.yaml b/api/openapi.yaml index a611356..659d2cc 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -247,8 +247,14 @@ paths: get: tags: [Usage and audit] summary: List audit events in sequence order (audit:read) + description: >- + Oldest first by default: pass the page's nextCursor as afterSeq for the + next page. With order=desc the newest events come first, and nextCursor + is the beforeSeq of the next, older page. parameters: - - { name: afterSeq, in: query, schema: { type: integer } } + - { name: afterSeq, in: query, schema: { type: integer }, description: Only events after this sequence number } + - { name: beforeSeq, in: query, schema: { type: integer }, description: Only events before this sequence number } + - { name: order, in: query, schema: { type: string, enum: [asc, desc], default: asc }, description: desc returns the newest events first } - { name: resource, in: query, schema: { type: string } } - { name: action, in: query, schema: { type: string } } - { $ref: "#/components/parameters/Limit" } diff --git a/cmd/differently/main.go b/cmd/differently/main.go index b5ec6d5..8844827 100644 --- a/cmd/differently/main.go +++ b/cmd/differently/main.go @@ -672,7 +672,7 @@ func usageCmd() *cobra.Command { cmd.Flags().String("group-by", "pool", "pool, project, org or trust") cmd.AddCommand( &cobra.Command{Use: "quota", Short: "Show quota and current consumption", RunE: func(*cobra.Command, []string) error { return raw("GET", "/v1/quotas", nil) }}, - &cobra.Command{Use: "audit", Short: "List audit events", RunE: func(*cobra.Command, []string) error { return raw("GET", "/v1/audit-events?limit=100", nil) }}, + &cobra.Command{Use: "audit", Short: "List the latest audit events, newest first", RunE: func(*cobra.Command, []string) error { return raw("GET", "/v1/audit-events?limit=100&order=desc", nil) }}, ) return cmd } diff --git a/console/src/pages/audit-log.astro b/console/src/pages/audit-log.astro index 0a38feb..bed2ebf 100644 --- a/console/src/pages/audit-log.astro +++ b/console/src/pages/audit-log.astro @@ -15,7 +15,7 @@ import PageHeader from '../components/PageHeader.astro'; const c = client(); void section('#list', async () => { - const page = await c.auditEvents.list({ limit: 200 }); + const page = await c.auditEvents.list({ limit: 200, order: 'desc' }); const events = [...page.items].sort((a, b) => b.seq - a.seq); return table([ { label: 'Time', cell: (e) => el('span', { class: 'muted' }, date(e.time)) }, diff --git a/internal/dashboard/static/app.js b/internal/dashboard/static/app.js index 51e187d..838e094 100644 --- a/internal/dashboard/static/app.js +++ b/internal/dashboard/static/app.js @@ -70,10 +70,10 @@ const views = { return `

Usage

${esc(u.note)}

${table(["Pool", "Sandboxes", "Run hours", "vCPU hours", "Est. cost"], u.lines.map((l) => [esc(l.key), l.sandboxes, (l.runSeconds / 3600).toFixed(2), (l.cpuSeconds / 3600).toFixed(2), l.estimatedCost.toFixed(4)]))}`; }, async audit() { - const ev = await call("GET", "/v1/audit-events?limit=200"); + const ev = await call("GET", "/v1/audit-events?limit=200&order=desc"); const verify = me.operator ? await call("GET", "/v1/admin/audit/verify") : null; return `

Audit

${verify ? `

Hash chain: ${verify.ok ? "intact" : `broken at ${verify.brokenAt}: ${esc(verify.problem)}`} (${verify.events} events)

` : ""} - ${table(["#", "Time", "Actor", "Action", "Resource", "Details"], ev.items.slice().reverse().map((e) => [e.seq, esc(new Date(e.time).toLocaleString()), esc(e.actor), esc(e.action), `${esc(e.resource)}`, `${esc(JSON.stringify(e.details || {}))}`]))}`; + ${table(["#", "Time", "Actor", "Action", "Resource", "Details"], ev.items.map((e) => [e.seq, esc(new Date(e.time).toLocaleString()), esc(e.actor), esc(e.action), `${esc(e.resource)}`, `${esc(JSON.stringify(e.details || {}))}`]))}`; }, async keys() { const [keys, orgs] = await Promise.all([call("GET", "/v1/api-keys"), me.operator ? call("GET", "/v1/admin/organizations") : null]); diff --git a/internal/server/audit_order_test.go b/internal/server/audit_order_test.go new file mode 100644 index 0000000..de25a7f --- /dev/null +++ b/internal/server/audit_order_test.go @@ -0,0 +1,42 @@ +package server_test + +import ( + "fmt" + "testing" + + "github.com/different-ai/differently/pkg/api" +) + +// The audit views ask for the newest events. Without order=desc a page holds +// the oldest ones, so a busy installation never showed recent activity. +func TestAuditEventsNewestFirst(t *testing.T) { + h := newHarness(t, api.PlacementPolicy{}) + for i := 0; i < 4; i++ { + h.must(201, "POST", "/v1/admin/organizations", operatorToken, map[string]string{"name": fmt.Sprintf("org-%d", i)}, nil) + } + list := func(query string) api.List[api.AuditEvent] { + t.Helper() + var out api.List[api.AuditEvent] + h.must(200, "GET", "/v1/audit-events?"+query, operatorToken, nil, &out) + return out + } + all := list("limit=1000") + total := int64(len(all.Items)) + if total < 4 || all.Items[0].Seq != 1 { + t.Fatalf("default order should start at the oldest event: %d events, first seq %d", total, all.Items[0].Seq) + } + newest := list("order=desc&limit=2") + if len(newest.Items) != 2 || newest.Items[0].Seq != total || newest.Items[1].Seq != total-1 { + t.Fatalf("order=desc should return the two newest events (%d, %d): %+v", total, total-1, newest.Items) + } + if newest.NextCursor != fmt.Sprint(total-1) { + t.Fatalf("nextCursor %q, want the last sequence %d", newest.NextCursor, total-1) + } + older := list("order=desc&limit=2&beforeSeq=" + newest.NextCursor) + if len(older.Items) != 2 || older.Items[0].Seq != total-2 { + t.Fatalf("the next page should continue at %d: %+v", total-2, older.Items) + } + if code := h.call("GET", "/v1/audit-events?order=sideways", operatorToken, nil, nil); code != 400 { + t.Fatalf("an unknown order answered %d, want 400", code) + } +} diff --git a/internal/server/tenancy.go b/internal/server/tenancy.go index 9cb4327..7faa440 100644 --- a/internal/server/tenancy.go +++ b/internal/server/tenancy.go @@ -417,7 +417,16 @@ func (s *Server) listAudit(w http.ResponseWriter, r *http.Request, p auth.Princi if err := p.Require(auth.ScopeAuditRead); err != nil { return err } - f := store.AuditFilter{OrgID: p.OrgID, Resource: r.URL.Query().Get("resource"), Action: r.URL.Query().Get("action"), AfterSeq: int64(queryInt(r, "afterSeq", 0)), Limit: min(max(queryInt(r, "limit", 100), 1), 1000)} + f := store.AuditFilter{OrgID: p.OrgID, Resource: r.URL.Query().Get("resource"), Action: r.URL.Query().Get("action"), AfterSeq: int64(queryInt(r, "afterSeq", 0)), BeforeSeq: int64(queryInt(r, "beforeSeq", 0)), Limit: min(max(queryInt(r, "limit", 100), 1), 1000)} + // order=desc returns the newest events first, so one page is the latest + // activity; its nextCursor is the beforeSeq of the next, older page. + switch r.URL.Query().Get("order") { + case "", "asc": + case "desc": + f.Desc = true + default: + return api.Errorf(api.CodeInvalidRequest, "order must be asc or desc") + } if p.Operator() { f.OrgID = r.URL.Query().Get("orgId") } diff --git a/internal/store/memory.go b/internal/store/memory.go index 4d9b6b0..47a1807 100644 --- a/internal/store/memory.go +++ b/internal/store/memory.go @@ -604,8 +604,13 @@ func (m *Memory) ListAudit(_ context.Context, f AuditFilter) ([]api.AuditEvent, m.mu.Lock() defer m.mu.Unlock() var out []api.AuditEvent - for _, ev := range m.audit { + for i := range m.audit { + ev := m.audit[i] + if f.Desc { + ev = m.audit[len(m.audit)-1-i] + } if ev.Seq <= f.AfterSeq || + (f.BeforeSeq > 0 && ev.Seq >= f.BeforeSeq) || (f.OrgID != "" && ev.OrgID != f.OrgID) || (f.Resource != "" && ev.Resource != f.Resource) || (f.Action != "" && ev.Action != f.Action) { diff --git a/internal/store/postgres/postgres.go b/internal/store/postgres/postgres.go index f338023..2f7b50d 100644 --- a/internal/store/postgres/postgres.go +++ b/internal/store/postgres/postgres.go @@ -737,9 +737,13 @@ func (s *Store) ListAudit(ctx context.Context, f store.AuditFilter) ([]api.Audit if limit <= 0 { limit = 1 << 30 } + order := "ORDER BY seq" + if f.Desc { + order = "ORDER BY seq DESC" + } return listDocs[api.AuditEvent](ctx, s.pool, `SELECT doc FROM audit_events - WHERE seq > $1 AND ($2 = '' OR org_id = $2) AND ($3 = '' OR resource = $3) AND ($4 = '' OR action = $4) - ORDER BY seq LIMIT $5`, f.AfterSeq, f.OrgID, f.Resource, f.Action, limit) + WHERE seq > $1 AND ($6 <= 0 OR seq < $6) AND ($2 = '' OR org_id = $2) AND ($3 = '' OR resource = $3) AND ($4 = '' OR action = $4) + `+order+` LIMIT $5`, f.AfterSeq, f.OrgID, f.Resource, f.Action, limit, f.BeforeSeq) } // TryLeader holds a session advisory lock on a dedicated connection. If the diff --git a/internal/store/store.go b/internal/store/store.go index 6a33a7a..1bfb6c8 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -89,7 +89,11 @@ type AuditFilter struct { Resource string Action string AfterSeq int64 - Limit int + // BeforeSeq, when positive, keeps only events with a lower sequence. + BeforeSeq int64 + // Desc returns the newest events first, so a limit keeps the latest. + Desc bool + Limit int } // Admission is the input to AdmitSandbox. diff --git a/internal/store/storetest/storetest.go b/internal/store/storetest/storetest.go index a5e964d..150fb3c 100644 --- a/internal/store/storetest/storetest.go +++ b/internal/store/storetest/storetest.go @@ -3,6 +3,7 @@ package storetest import ( "context" + "slices" "sync" "sync/atomic" "testing" @@ -324,6 +325,30 @@ func auditChain(t *testing.T, s store.Store) { if res := audit.Verify(events); !res.OK || res.Events != 5 { t.Fatalf("chain %+v", res) } + // Newest first: a limit keeps the latest events, and BeforeSeq pages back. + seqs := func(f store.AuditFilter) (out []int64) { + t.Helper() + list, err := s.ListAudit(ctx, f) + if err != nil { + t.Fatal(err) + } + for _, ev := range list { + out = append(out, ev.Seq) + } + return out + } + if got := seqs(store.AuditFilter{Desc: true, Limit: 2}); !slices.Equal(got, []int64{5, 4}) { + t.Fatalf("newest two events: %v, want [5 4]", got) + } + if got := seqs(store.AuditFilter{Desc: true, Limit: 2, BeforeSeq: 4}); !slices.Equal(got, []int64{3, 2}) { + t.Fatalf("the page before 4: %v, want [3 2]", got) + } + if got := seqs(store.AuditFilter{AfterSeq: 1, BeforeSeq: 4}); !slices.Equal(got, []int64{2, 3}) { + t.Fatalf("between 1 and 4, oldest first: %v, want [2 3]", got) + } + if got := seqs(store.AuditFilter{Limit: 2}); !slices.Equal(got, []int64{1, 2}) { + t.Fatalf("default order: %v, want [1 2]", got) + } } func concurrentStart(t *testing.T, s store.Store) { diff --git a/sdk/python/differently/_resources.py b/sdk/python/differently/_resources.py index 58a9b42..8b90278 100644 --- a/sdk/python/differently/_resources.py +++ b/sdk/python/differently/_resources.py @@ -146,17 +146,30 @@ def list( resource: Optional[str] = None, action: Optional[str] = None, after_seq: Optional[int] = None, + before_seq: Optional[int] = None, + order: Optional[str] = None, limit: Optional[int] = None, org_id: Optional[str] = None, ) -> Page[AuditEvent]: - """Returns one page in ascending sequence order.""" - query = {"resource": resource, "action": action, "afterSeq": after_seq, "limit": limit, "orgId": org_id} + """Returns one page, oldest first by default. + + ``order="desc"`` returns the newest events first; the page's + ``next_cursor`` is then the ``before_seq`` of the next, older page. + """ + query = { + "resource": resource, "action": action, "afterSeq": after_seq, "beforeSeq": before_seq, + "order": order, "limit": limit, "orgId": org_id, + } page = self._http.json("GET", "/v1/audit-events", query=query) return Page(items=page.get("items") or [], next_cursor=page.get("nextCursor") or None) def iterate(self, **filters: Any) -> Iterator[AuditEvent]: - """Yields every matching event, following pagination. Takes ``list``'s filters.""" + """Yields every matching event from oldest to newest, following pagination. + + Takes ``list``'s filters; ``order`` is always ascending here. + """ after_seq = filters.pop("after_seq", None) + filters.pop("order", None) while True: page = self.list(after_seq=after_seq, **filters) yield from page.items diff --git a/sdk/python/tests/test_client.py b/sdk/python/tests/test_client.py index e967391..1604360 100644 --- a/sdk/python/tests/test_client.py +++ b/sdk/python/tests/test_client.py @@ -176,6 +176,16 @@ def event(seq): second = self.mock.calls("GET /v1/audit-events")[1] self.assertEqual((second.param("afterSeq"), second.param("action")), ("2", "sandbox.create")) + def test_audit_events_list_newest_first(self) -> None: + def event(seq): + return {"seq": seq, "time": "t", "actor": "k", "action": "sandbox.create", "resource": "sbx", "outcome": "ok", "prevHash": "", "hash": ""} + + self.mock.on("GET /v1/audit-events", reply(200, {"items": [event(9), event(8)], "nextCursor": "8"})) + page = self.client.audit_events.list(order="desc", limit=2, before_seq=10) + self.assertEqual(([ev["seq"] for ev in page.items], page.next_cursor), ([9, 8], "8")) + call = self.mock.calls("GET /v1/audit-events")[0] + self.assertEqual((call.param("order"), call.param("beforeSeq")), ("desc", "10")) + def test_admin_namespace(self) -> None: org = {"id": "org_1", "name": "Acme", "suspended": True, "quota": {}, "createdAt": "t"} self.mock.on("POST /v1/admin/organizations", reply(201, {"organization": org, "project": {"id": "prj_1"}})) diff --git a/sdk/typescript/src/resources.ts b/sdk/typescript/src/resources.ts index 968c6b7..02e9dbc 100644 --- a/sdk/typescript/src/resources.ts +++ b/sdk/typescript/src/resources.ts @@ -151,18 +151,18 @@ export class AuditEvents { this.#http = http; } - /** Returns one page in ascending sequence order. */ + /** Returns one page, oldest first unless `order: "desc"` asks for the newest. */ async list(params: ListAuditEventsParams = {}): Promise> { const page = await this.#http.json>({ method: "GET", path: "/v1/audit-events", query: { ...params } }); const items = page.items ?? []; return page.nextCursor ? { items, nextCursor: page.nextCursor } : { items }; } - /** Yields every matching event, following pagination. */ + /** Yields every matching event from oldest to newest, following pagination. */ async *iterate(params: ListAuditEventsParams = {}): AsyncGenerator { let afterSeq = params.afterSeq; for (;;) { - const page = await this.list({ ...params, afterSeq }); + const page = await this.list({ ...params, order: "asc", afterSeq }); yield* page.items; if (!page.nextCursor) return; afterSeq = Number(page.nextCursor); diff --git a/sdk/typescript/src/types.ts b/sdk/typescript/src/types.ts index b0d8fef..38b1028 100644 --- a/sdk/typescript/src/types.ts +++ b/sdk/typescript/src/types.ts @@ -498,6 +498,13 @@ export interface ListAuditEventsParams { action?: string; /** Return events with a sequence number greater than this. */ afterSeq?: number; + /** Return events with a sequence number lower than this. */ + beforeSeq?: number; + /** + * "desc" returns the newest events first; the page's nextCursor is then the + * beforeSeq of the next, older page. Default "asc". + */ + order?: "asc" | "desc"; /** Page size, 1-1000 (server default 100). */ limit?: number; /** Operator keys only. */ diff --git a/sdk/typescript/test/client.test.ts b/sdk/typescript/test/client.test.ts index bb7f5fc..4b541c1 100644 --- a/sdk/typescript/test/client.test.ts +++ b/sdk/typescript/test/client.test.ts @@ -181,6 +181,17 @@ test("audit events iterate with afterSeq", async () => { assert.equal(second?.query.get("action"), "sandbox.create"); }); +test("audit events list the newest first with order desc", async () => { + const event = (seq: number) => ({ seq, time: "t", actor: "k", action: "sandbox.create", resource: "sbx", outcome: "ok", prevHash: "", hash: "" }); + mock.on("GET /v1/audit-events", reply(200, { items: [event(9), event(8)], nextCursor: "8" })); + const page = await client.auditEvents.list({ order: "desc", limit: 2, beforeSeq: 10 }); + assert.deepEqual(page.items.map((e) => e.seq), [9, 8]); + assert.equal(page.nextCursor, "8"); + const [call] = mock.calls("GET /v1/audit-events"); + assert.equal(call?.query.get("order"), "desc"); + assert.equal(call?.query.get("beforeSeq"), "10"); +}); + test("admin namespace", async () => { const org = { id: "org_1", name: "Acme", suspended: true, quota: {}, createdAt: "t" }; mock.on("POST /v1/admin/organizations", reply(201, { organization: { ...org, suspended: false }, project: { id: "prj_1" } }));