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
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
8 changes: 7 additions & 1 deletion api/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -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" }
Expand Down
2 changes: 1 addition & 1 deletion cmd/differently/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
Expand Down
2 changes: 1 addition & 1 deletion console/src/pages/audit-log.astro
Original file line number Diff line number Diff line change
Expand Up @@ -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<AuditEvent>([
{ label: 'Time', cell: (e) => el('span', { class: 'muted' }, date(e.time)) },
Expand Down
4 changes: 2 additions & 2 deletions internal/dashboard/static/app.js
Original file line number Diff line number Diff line change
Expand Up @@ -70,10 +70,10 @@ const views = {
return `<h1>Usage</h1><p class="tag">${esc(u.note)}</p>${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 `<h1>Audit</h1>${verify ? `<p>Hash chain: ${verify.ok ? "<span class='ok'>intact</span>" : `<span class='bad'>broken at ${verify.brokenAt}: ${esc(verify.problem)}</span>`} (${verify.events} events)</p>` : ""}
${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), `<code>${esc(e.resource)}</code>`, `<code>${esc(JSON.stringify(e.details || {}))}</code>`]))}`;
${table(["#", "Time", "Actor", "Action", "Resource", "Details"], ev.items.map((e) => [e.seq, esc(new Date(e.time).toLocaleString()), esc(e.actor), esc(e.action), `<code>${esc(e.resource)}</code>`, `<code>${esc(JSON.stringify(e.details || {}))}</code>`]))}`;
},
async keys() {
const [keys, orgs] = await Promise.all([call("GET", "/v1/api-keys"), me.operator ? call("GET", "/v1/admin/organizations") : null]);
Expand Down
42 changes: 42 additions & 0 deletions internal/server/audit_order_test.go
Original file line number Diff line number Diff line change
@@ -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)
}
}
11 changes: 10 additions & 1 deletion internal/server/tenancy.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
7 changes: 6 additions & 1 deletion internal/store/memory.go
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
8 changes: 6 additions & 2 deletions internal/store/postgres/postgres.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 5 additions & 1 deletion internal/store/store.go
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
25 changes: 25 additions & 0 deletions internal/store/storetest/storetest.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package storetest

import (
"context"
"slices"
"sync"
"sync/atomic"
"testing"
Expand Down Expand Up @@ -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) {
Expand Down
19 changes: 16 additions & 3 deletions sdk/python/differently/_resources.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
10 changes: 10 additions & 0 deletions sdk/python/tests/test_client.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"}}))
Expand Down
6 changes: 3 additions & 3 deletions sdk/typescript/src/resources.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<Page<AuditEvent>> {
const page = await this.#http.json<Page<AuditEvent>>({ 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<AuditEvent, void, undefined> {
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);
Expand Down
7 changes: 7 additions & 0 deletions sdk/typescript/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
11 changes: 11 additions & 0 deletions sdk/typescript/test/client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" } }));
Expand Down
Loading