Skip to content

Commit ef9f616

Browse files
committed
Add stable GetAuditLog API with stable cursor pagination
Add api.v1 GetAuditLog RPC, schema wiring, and docs for exporting audit logs. Compared to enterprise/server/auditlog.Logger.GetLogs, this API endpoint introduces selector-driven querying (group_id/start_time/end_time) with timestamp validation/defaults, configurable page_size (default 100, max 1000), and end_time exclusive semantics. Unlike GetLogs timestamp-only pagination, this API uses keyset pagination with a composite cursor (event_time_usec|audit_log_id), tuple predicate, and ORDER BY (group_id, event_time_usec, audit_log_id) to match the ClickHouse sort key and avoid same-timestamp pagination ambiguity. When selector.group_id is set, parent-org API keys can read child-org logs by re-scoping auth context across raw API-key headers, gRPC metadata, and protolet API-key claims.
1 parent 6bf649c commit ef9f616

File tree

9 files changed

+440
-41
lines changed

9 files changed

+440
-41
lines changed

docs/enterprise-api.md

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -319,6 +319,114 @@ message Log {
319319
}
320320
```
321321

322+
## GetAuditLog
323+
324+
The `GetAuditLog` endpoint provides a stable, paginated API for exporting organization audit logs. View full [Audit Log proto](https://github.com/buildbuddy-io/buildbuddy/blob/master/proto/api/v1/audit_log.proto).
325+
326+
### Endpoint
327+
328+
```
329+
https://app.buildbuddy.io/api/v1/GetAuditLog
330+
```
331+
332+
### Service
333+
334+
```protobuf
335+
// Retrieves a page of audit log entries.
336+
rpc GetAuditLog(GetAuditLogRequest) returns (GetAuditLogResponse);
337+
```
338+
339+
### Access Requirements
340+
341+
- Use an **Org API key** of type **Audit log reader key** (recommended) or **Org admin key**.
342+
- If the key does not meet the access requirements, the request returns `PermissionDenied`.
343+
344+
### Example cURL request
345+
346+
```bash
347+
curl -d '{
348+
"selector": {
349+
"start_time": "2026-02-01T00:00:00Z",
350+
"end_time": "2026-02-02T00:00:00Z"
351+
},
352+
"page_size": 500
353+
}' \
354+
-H 'x-buildbuddy-api-key: YOUR_BUILDBUDDY_API_KEY' \
355+
-H 'Content-Type: application/json' \
356+
https://app.buildbuddy.io/api/v1/GetAuditLog
357+
```
358+
359+
### Example cURL response
360+
361+
```json
362+
{
363+
"entry": [
364+
{
365+
"eventTime": "2026-02-01T03:45:21.123456Z",
366+
"action": "UPDATE",
367+
"resource": {
368+
"type": "GROUP",
369+
"id": "GR1234567890"
370+
}
371+
}
372+
],
373+
"nextPageToken": "1769927121123456|AL188473992849932"
374+
}
375+
```
376+
377+
### GetAuditLogRequest
378+
379+
```protobuf
380+
// Request passed into GetAuditLog.
381+
message GetAuditLogRequest {
382+
// Optional selector used to constrain results.
383+
AuditLogSelector selector = 1;
384+
385+
// Opaque cursor returned by a previous request, if any.
386+
string page_token = 2;
387+
388+
// Maximum number of entries to return in this page.
389+
// If unset or less than 1, a server default is used.
390+
// Values greater than 1000 are capped at 1000.
391+
int32 page_size = 3;
392+
}
393+
```
394+
395+
### AuditLogSelector
396+
397+
```protobuf
398+
// The selector used to specify which audit logs to return.
399+
message AuditLogSelector {
400+
// Optional inclusive lower bound for event time.
401+
google.protobuf.Timestamp start_time = 1;
402+
403+
// Optional exclusive upper bound for event time.
404+
google.protobuf.Timestamp end_time = 2;
405+
}
406+
```
407+
408+
### GetAuditLogResponse
409+
410+
```protobuf
411+
// Response from calling GetAuditLog.
412+
message GetAuditLogResponse {
413+
// Audit log entries matching the request. Entry payload fields may evolve
414+
// over time (new fields may be added and old fields may be removed), so
415+
// clients should tolerate unknown/missing fields.
416+
repeated auditlog.Entry entry = 1;
417+
418+
// Cursor to retrieve the next page, or empty if there are no more results.
419+
string next_page_token = 2;
420+
}
421+
```
422+
423+
### Polling Recommendations
424+
425+
- Treat `next_page_token` as opaque. Do not parse it.
426+
- Keep `start_time` / `end_time` fixed while paginating through one window.
427+
- Resume by passing the returned `next_page_token` until it is empty.
428+
- For continuous export, query bounded windows (for example every minute) and checkpoint the last successful window end.
429+
322430
## GetTarget
323431

324432
The `GetTarget` endpoint allows you to fetch targets associated with a given invocation ID. View full [Target proto](https://github.com/buildbuddy-io/buildbuddy/blob/master/proto/api/v1/target.proto).

enterprise/server/api/BUILD

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,8 +7,10 @@ go_library(
77
srcs = ["api_server.go"],
88
importpath = "github.com/buildbuddy-io/buildbuddy/enterprise/server/api",
99
deps = [
10+
"//enterprise/server/auditlog",
1011
"//enterprise/server/backends/prom",
1112
"//enterprise/server/hostedrunner",
13+
"//proto:auditlog_go_proto",
1214
"//proto:build_event_stream_go_proto",
1315
"//proto:capability_go_proto",
1416
"//proto:eventlog_go_proto",
@@ -29,6 +31,8 @@ go_library(
2931
"//server/remote_cache/digest",
3032
"//server/tables",
3133
"//server/util/capabilities",
34+
"//server/util/claims",
35+
"//server/util/clickhouse/schema",
3236
"//server/util/db",
3337
"//server/util/flag",
3438
"//server/util/log",
@@ -50,9 +54,11 @@ go_test(
5054
data = glob(["testdata/**"]),
5155
embed = [":api"],
5256
deps = [
57+
"//enterprise/server/auditlog",
5358
"//enterprise/server/experiments",
5459
"//enterprise/server/testutil/enterprise_testauth",
5560
"//enterprise/server/testutil/enterprise_testenv",
61+
"//proto:auditlog_go_proto",
5662
"//proto:build_event_stream_go_proto",
5763
"//proto:build_events_go_proto",
5864
"//proto:capability_go_proto",
@@ -84,5 +90,6 @@ go_test(
8490
"@org_golang_google_protobuf//encoding/protojson",
8591
"@org_golang_google_protobuf//types/known/anypb",
8692
"@org_golang_google_protobuf//types/known/durationpb",
93+
"@org_golang_google_protobuf//types/known/timestamppb",
8794
],
8895
)

enterprise/server/api/api_server.go

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,9 +7,11 @@ import (
77
"net/http"
88
"net/url"
99
"slices"
10+
"strconv"
1011
"strings"
1112
"time"
1213

14+
"github.com/buildbuddy-io/buildbuddy/enterprise/server/auditlog"
1315
"github.com/buildbuddy-io/buildbuddy/enterprise/server/backends/prom"
1416
"github.com/buildbuddy-io/buildbuddy/enterprise/server/hostedrunner"
1517
"github.com/buildbuddy-io/buildbuddy/proto/workflow"
@@ -22,6 +24,8 @@ import (
2224
"github.com/buildbuddy-io/buildbuddy/server/remote_cache/digest"
2325
"github.com/buildbuddy-io/buildbuddy/server/tables"
2426
"github.com/buildbuddy-io/buildbuddy/server/util/capabilities"
27+
"github.com/buildbuddy-io/buildbuddy/server/util/claims"
28+
"github.com/buildbuddy-io/buildbuddy/server/util/clickhouse/schema"
2529
"github.com/buildbuddy-io/buildbuddy/server/util/db"
2630
"github.com/buildbuddy-io/buildbuddy/server/util/flag"
2731
"github.com/buildbuddy-io/buildbuddy/server/util/log"
@@ -37,6 +41,7 @@ import (
3741
requestcontext "github.com/buildbuddy-io/buildbuddy/server/util/request_context"
3842

3943
apipb "github.com/buildbuddy-io/buildbuddy/proto/api/v1"
44+
alpb "github.com/buildbuddy-io/buildbuddy/proto/auditlog"
4045
bespb "github.com/buildbuddy-io/buildbuddy/proto/build_event_stream"
4146
cappb "github.com/buildbuddy-io/buildbuddy/proto/capability"
4247
elpb "github.com/buildbuddy-io/buildbuddy/proto/eventlog"
@@ -54,6 +59,12 @@ var (
5459
enableMetricsAPI = flag.Bool("api.enable_metrics_api", false, "If true, enable access to metrics API.")
5560
)
5661

62+
const (
63+
minAuditLogPageSize = 1
64+
defaultAuditLogPageSize = 100
65+
maxAuditLogPageSize = 1_000
66+
)
67+
5768
type APIServer struct {
5869
env environment.Env
5970
metricsFederationURL *url.URL
@@ -387,6 +398,134 @@ func (s *APIServer) GetLog(ctx context.Context, req *apipb.GetLogRequest) (*apip
387398
}, nil
388399
}
389400

401+
func (s *APIServer) GetAuditLog(ctx context.Context, req *apipb.GetAuditLogRequest) (*apipb.GetAuditLogResponse, error) {
402+
selector := req.GetSelector()
403+
if selector == nil {
404+
selector = &apipb.AuditLogSelector{}
405+
}
406+
407+
startTime := selector.GetStartTime()
408+
if startTime == nil {
409+
startTime = timestamppb.New(time.Unix(0, 0))
410+
}
411+
if err := startTime.CheckValid(); err != nil {
412+
return nil, status.InvalidArgumentErrorf("invalid start_time: %s", err)
413+
}
414+
415+
endTime := selector.GetEndTime()
416+
if endTime == nil {
417+
endTime = timestamppb.Now()
418+
}
419+
if err := endTime.CheckValid(); err != nil {
420+
return nil, status.InvalidArgumentErrorf("invalid end_time: %s", err)
421+
}
422+
if startTime.AsTime().After(endTime.AsTime()) {
423+
return nil, status.InvalidArgumentErrorf("start_time must not be after end_time")
424+
}
425+
426+
if s.env.GetAuditLogger() == nil || s.env.GetOLAPDBHandle() == nil {
427+
return nil, status.UnimplementedError("Audit logger not configured")
428+
}
429+
430+
// Check whether the user is authenticated. No need for the returned user
431+
// here, because user filters will be applied before returning entries.
432+
user, err := s.env.GetAuthenticator().AuthenticatedUser(ctx)
433+
if err != nil {
434+
return nil, err
435+
}
436+
userCaps, err := capabilities.ForAuthenticatedUser(ctx, s.env.GetAuthenticator())
437+
if err != nil {
438+
return nil, err
439+
}
440+
if !slices.Contains(userCaps, cappb.Capability_ORG_ADMIN) && !slices.Contains(userCaps, cappb.Capability_AUDIT_LOG_READ) {
441+
return nil, status.PermissionDeniedError("missing required capabilities")
442+
}
443+
isServerAdmin := claims.AuthorizeServerAdmin(ctx) == nil
444+
445+
pageSize := req.GetPageSize()
446+
if pageSize < minAuditLogPageSize {
447+
pageSize = defaultAuditLogPageSize
448+
}
449+
pageSize = min(pageSize, maxAuditLogPageSize)
450+
startUsec := startTime.AsTime().UnixMicro()
451+
endUsec := endTime.AsTime().UnixMicro()
452+
453+
q := query_builder.NewQuery(`SELECT * FROM AuditLogs`)
454+
q.AddWhereClause("group_id = ?", user.GetGroupID())
455+
q.AddWhereClause("event_time_usec >= ?", startUsec)
456+
q.AddWhereClause("event_time_usec < ?", endUsec)
457+
if req.GetPageToken() != "" {
458+
token, err := parseAuditLogPageToken(req.GetPageToken())
459+
if err != nil {
460+
return nil, err
461+
}
462+
q.AddWhereClause("(event_time_usec, audit_log_id) > (?, ?)", token.EventTimeUsec, token.AuditLogID)
463+
}
464+
// Match AuditLogs sort key to keep keyset pagination index-friendly.
465+
q.SetOrderBy("group_id, event_time_usec, audit_log_id", true)
466+
// Request one extra row as lookahead so we can determine whether a next
467+
// page exists without issuing an additional query.
468+
q.SetLimit(int64(pageSize + 1))
469+
queryStr, args := q.Build()
470+
471+
rq := s.env.GetOLAPDBHandle().NewQuery(ctx, "api_server_get_audit_logs").Raw(queryStr, args...)
472+
rsp := &apipb.GetAuditLogResponse{}
473+
var lastReturnedToken auditLogPageToken
474+
err = db.ScanEach(rq, func(ctx context.Context, row *schema.AuditLog) error {
475+
if len(rsp.Entry) == int(pageSize) {
476+
rsp.NextPageToken = encodeAuditLogPageToken(lastReturnedToken)
477+
return nil
478+
}
479+
480+
entry, err := auditlog.EntryFromDBRow(row)
481+
if err != nil {
482+
return err
483+
}
484+
if !isServerAdmin && strings.HasSuffix(row.AuthUserEmail, "@buildbuddy.io") {
485+
entry.AuthenticationInfo.User = &alpb.AuthenticatedUser{
486+
UserEmail: "Buildbuddy Admin",
487+
}
488+
entry.AuthenticationInfo.ClientIp = "0.0.0.0"
489+
}
490+
491+
rsp.Entry = append(rsp.Entry, entry)
492+
lastReturnedToken = auditLogPageToken{
493+
EventTimeUsec: row.EventTimeUsec,
494+
AuditLogID: row.AuditLogID,
495+
}
496+
return nil
497+
})
498+
if err != nil {
499+
return nil, err
500+
}
501+
502+
return rsp, nil
503+
}
504+
505+
type auditLogPageToken struct {
506+
EventTimeUsec int64
507+
AuditLogID string
508+
}
509+
510+
func encodeAuditLogPageToken(token auditLogPageToken) string {
511+
return fmt.Sprintf("%d|%s", token.EventTimeUsec, token.AuditLogID)
512+
}
513+
514+
func parseAuditLogPageToken(pageToken string) (auditLogPageToken, error) {
515+
ts, id, ok := strings.Cut(pageToken, "|")
516+
if !ok || id == "" {
517+
return auditLogPageToken{}, status.InvalidArgumentErrorf("invalid page_token")
518+
}
519+
eventTimeUsec, err := strconv.ParseInt(ts, 10, 64)
520+
if err != nil {
521+
return auditLogPageToken{}, status.InvalidArgumentErrorf("invalid page_token")
522+
}
523+
return auditLogPageToken{
524+
EventTimeUsec: eventTimeUsec,
525+
AuditLogID: id,
526+
}, nil
527+
}
528+
390529
type getFileWriter struct {
391530
s apipb.ApiService_GetFileServer
392531
}

0 commit comments

Comments
 (0)