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" } }));