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
110 changes: 110 additions & 0 deletions docs/enterprise-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -319,6 +319,116 @@ message Log {
}
```

## GetAuditLog

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).

### Endpoint

```
https://app.buildbuddy.io/api/v1/GetAuditLog
```

### Service

```protobuf
// Retrieves a page of audit log entries.
rpc GetAuditLog(GetAuditLogRequest) returns (GetAuditLogResponse);
```

### Access Requirements

- Use an **Org API key** of type **Audit log reader key** (recommended) or **Org admin key**.
- If the key does not meet the access requirements, the request returns `PermissionDenied`.

### Example cURL request

```bash
curl -d '{
"selector": {
"start_time": "2026-02-01T00:00:00Z",
"end_time": "2026-02-02T00:00:00Z"
},
"page_size": 500
}' \
-H 'x-buildbuddy-api-key: YOUR_BUILDBUDDY_API_KEY' \
-H 'Content-Type: application/json' \
https://app.buildbuddy.io/api/v1/GetAuditLog
```

### Example cURL response

```json
{
"entry": [
{
"eventTime": "2026-02-01T03:45:21.123456Z",
"action": "UPDATE",
"resource": {
"type": "GROUP",
"id": "GR1234567890"
}
}
],
"nextPageToken": "eyJzdGFydF90aW1lX3VzZWMiOjE3MzgzNjgwMDAwMDAwMDAsImVuZF90aW1lX3VzZWMiOjE3Mzg0NTQ0MDAwMDAwMDAsImV2ZW50X3RpbWVfdXNlYyI6MTczODM3MTkyMTIzNDU2LCJhdWRpdF9sb2dfaWQiOiJBTDE4ODQ3Mzk5Mjg0OTkzMiJ9"
}
```

### GetAuditLogRequest

```protobuf
// Request passed into GetAuditLog.
message GetAuditLogRequest {
// Optional selector used to constrain results.
AuditLogSelector selector = 1;

// Opaque cursor returned by a previous request, if any.
string page_token = 2;

// Maximum number of entries to return in this page.
// If unset or less than 1, a server default is used.
// Values greater than 1000 are capped at 1000.
int32 page_size = 3;
}
```

### AuditLogSelector

```protobuf
// The selector used to specify which audit logs to return.
message AuditLogSelector {
// Optional inclusive lower bound for event time.
google.protobuf.Timestamp start_time = 1;

// Optional exclusive upper bound for event time.
google.protobuf.Timestamp end_time = 2;
}
```

### GetAuditLogResponse

```protobuf
// Response from calling GetAuditLog.
message GetAuditLogResponse {
// Audit log entries matching the request. Entry payload fields may evolve
// over time (new fields may be added and old fields may be removed), so
// clients should tolerate unknown/missing fields.
repeated auditlog.Entry entry = 1;

// Cursor to retrieve the next page, or empty if there are no more results.
string next_page_token = 2;
}
```

### Polling Recommendations

- Treat `next_page_token` as opaque. Do not parse it.
- Keep `start_time` / `end_time` fixed while paginating through one window. If
`end_time` is omitted on the first page, the server pins it into the returned
cursor.
- Resume by passing the returned `next_page_token` until it is empty.
- For continuous export, query bounded windows (for example every minute) and checkpoint the last successful window end.

## GetTarget

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).
Expand Down
36 changes: 35 additions & 1 deletion enterprise/server/api/BUILD
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ go_library(
srcs = ["api_server.go"],
importpath = "github.com/buildbuddy-io/buildbuddy/enterprise/server/api",
deps = [
"//enterprise/server/auditlog",
"//enterprise/server/backends/prom",
"//enterprise/server/hostedrunner",
"//proto:build_event_stream_go_proto",
Expand All @@ -29,6 +30,8 @@ go_library(
"//server/remote_cache/digest",
"//server/tables",
"//server/util/capabilities",
"//server/util/claims",
"//server/util/clickhouse/schema",
"//server/util/db",
"//server/util/flag",
"//server/util/log",
Expand All @@ -43,10 +46,14 @@ go_library(
],
)

# Tell gazelle to make each file a separate test target,
# so the ClickHouse-backed audit-log test can keep separate runner properties.
# gazelle:go_test file

go_test(
name = "api_test",
size = "small",
srcs = ["api_server_test.go"],
srcs = ["api_test.go"],
data = glob(["testdata/**"]),
embed = [":api"],
deps = [
Expand Down Expand Up @@ -86,3 +93,30 @@ go_test(
"@org_golang_google_protobuf//types/known/durationpb",
],
)

go_test(
name = "auditlog_test",
size = "small",
srcs = ["auditlog_test.go"],
data = glob(["testdata/**"]),
embed = [":api"],
exec_properties = {
"test.workload-isolation-type": "firecracker",
"test.init-dockerd": "true",
"test.recycle-runner": "true",
"test.runner-recycling-key": "clickhouse25.3",
},
tags = ["docker"],
deps = [
"//enterprise/server/auditlog",
"//enterprise/server/testutil/enterprise_testauth",
"//enterprise/server/testutil/enterprise_testenv",
"//proto:auditlog_go_proto",
"//proto:capability_go_proto",
"//proto:group_go_proto",
"//proto/api/v1:api_v1_go_proto",
"//server/util/testing/flags",
"@com_github_stretchr_testify//require",
"@org_golang_google_protobuf//types/known/timestamppb",
],
)
158 changes: 158 additions & 0 deletions enterprise/server/api/api_server.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ package api

import (
"context"
"encoding/base64"
"encoding/json"
"fmt"
"maps"
"net/http"
Expand All @@ -10,6 +12,7 @@ import (
"strings"
"time"

"github.com/buildbuddy-io/buildbuddy/enterprise/server/auditlog"
"github.com/buildbuddy-io/buildbuddy/enterprise/server/backends/prom"
"github.com/buildbuddy-io/buildbuddy/enterprise/server/hostedrunner"
"github.com/buildbuddy-io/buildbuddy/proto/workflow"
Expand All @@ -22,6 +25,8 @@ import (
"github.com/buildbuddy-io/buildbuddy/server/remote_cache/digest"
"github.com/buildbuddy-io/buildbuddy/server/tables"
"github.com/buildbuddy-io/buildbuddy/server/util/capabilities"
"github.com/buildbuddy-io/buildbuddy/server/util/claims"
"github.com/buildbuddy-io/buildbuddy/server/util/clickhouse/schema"
"github.com/buildbuddy-io/buildbuddy/server/util/db"
"github.com/buildbuddy-io/buildbuddy/server/util/flag"
"github.com/buildbuddy-io/buildbuddy/server/util/log"
Expand Down Expand Up @@ -54,6 +59,12 @@ var (
enableMetricsAPI = flag.Bool("api.enable_metrics_api", false, "If true, enable access to metrics API.")
)

const (
minAuditLogPageSize = 1
defaultAuditLogPageSize = 100
maxAuditLogPageSize = 1_000
)

type APIServer struct {
env environment.Env
metricsFederationURL *url.URL
Expand Down Expand Up @@ -387,6 +398,153 @@ func (s *APIServer) GetLog(ctx context.Context, req *apipb.GetLogRequest) (*apip
}, nil
}

func (s *APIServer) GetAuditLog(ctx context.Context, req *apipb.GetAuditLogRequest) (*apipb.GetAuditLogResponse, error) {
selector := req.GetSelector()
if selector == nil {
selector = &apipb.AuditLogSelector{}
}
pageToken, err := parseAuditLogPageToken(req.GetPageToken())
if err != nil {
return nil, err
}

startTime := selector.GetStartTime()
if startTime == nil {
startTime = timestamppb.New(time.Unix(0, 0))
}
if err := startTime.CheckValid(); err != nil {
return nil, status.InvalidArgumentErrorf("invalid start_time: %s", err)
}

endTime := selector.GetEndTime()
if endTime == nil {
endTime = timestamppb.Now()
}
if err := endTime.CheckValid(); err != nil {
return nil, status.InvalidArgumentErrorf("invalid end_time: %s", err)
}
if startTime.AsTime().After(endTime.AsTime()) {
return nil, status.InvalidArgumentErrorf("start_time must not be after end_time")
}
startUsec := startTime.AsTime().UnixMicro()
endUsec := endTime.AsTime().UnixMicro()
if pageToken != nil {
if selector.GetEndTime() != nil && endUsec != pageToken.EndTimeUsec {
return nil, status.InvalidArgumentError("page_token does not match selector.end_time")
}
endUsec = pageToken.EndTimeUsec
}
if startUsec > endUsec {
return nil, status.InvalidArgumentErrorf("start_time must not be after end_time")
}

if s.env.GetAuditLogger() == nil || s.env.GetOLAPDBHandle() == nil {
return nil, status.UnimplementedError("Audit logger not configured")
}

// Check whether the user is authenticated. No need for the returned user
// here, because user filters will be applied before returning entries.
user, err := s.env.GetAuthenticator().AuthenticatedUser(ctx)
if err != nil {
return nil, err
}
userCaps, err := capabilities.ForAuthenticatedUser(ctx, s.env.GetAuthenticator())
if err != nil {
return nil, err
}
if !slices.Contains(userCaps, cappb.Capability_ORG_ADMIN) && !slices.Contains(userCaps, cappb.Capability_AUDIT_LOG_READ) {
return nil, status.PermissionDeniedError("missing required capabilities")
}
isServerAdmin := claims.AuthorizeServerAdmin(ctx) == nil

pageSize := req.GetPageSize()
if pageSize < minAuditLogPageSize {
pageSize = defaultAuditLogPageSize
}
pageSize = min(pageSize, maxAuditLogPageSize)

q := query_builder.NewQuery(`SELECT * FROM AuditLogs`)
q.AddWhereClause("group_id = ?", user.GetGroupID())
q.AddWhereClause("event_time_usec >= ?", startUsec)
q.AddWhereClause("event_time_usec < ?", endUsec)
if pageToken != nil {
q.AddWhereClause("(event_time_usec, audit_log_id) > (?, ?)", pageToken.EventTimeUsec, pageToken.AuditLogID)
}
// Match AuditLogs sort key to keep keyset pagination index-friendly.
q.SetOrderBy("group_id, event_time_usec, audit_log_id", true)
// Request one extra row as lookahead so we can determine whether a next
// page exists without issuing an additional query.
q.SetLimit(int64(pageSize + 1))
queryStr, args := q.Build()

rq := s.env.GetOLAPDBHandle().NewQuery(ctx, "api_server_get_audit_logs").Raw(queryStr, args...)
rsp := &apipb.GetAuditLogResponse{}
var lastReturnedToken auditLogPageToken
err = db.ScanEach(rq, func(ctx context.Context, row *schema.AuditLog) error {
if len(rsp.Entry) == int(pageSize) {
nextPageToken, err := encodeAuditLogPageToken(lastReturnedToken)
if err != nil {
return err
}
rsp.NextPageToken = nextPageToken
return nil
}

entry, err := auditlog.EntryFromDBRow(row)
if err != nil {
return err
}
if !isServerAdmin {
auditlog.FilterEntry(entry, row.AuthUserEmail)
}

rsp.Entry = append(rsp.Entry, entry)
lastReturnedToken = auditLogPageToken{
EndTimeUsec: endUsec,
EventTimeUsec: row.EventTimeUsec,
AuditLogID: row.AuditLogID,
}
return nil
})
if err != nil {
return nil, err
}

return rsp, nil
}

type auditLogPageToken struct {
EndTimeUsec int64 `json:"end_time_usec"`
EventTimeUsec int64 `json:"event_time_usec"`
AuditLogID string `json:"audit_log_id"`
}

func encodeAuditLogPageToken(token auditLogPageToken) (string, error) {
data, err := json.Marshal(token)
if err != nil {
return "", status.InternalErrorf("failed to encode page_token: %s", err)
}
return base64.RawURLEncoding.EncodeToString(data), nil
}

func parseAuditLogPageToken(pageToken string) (*auditLogPageToken, error) {
if pageToken == "" {
return nil, nil
}
data, err := base64.RawURLEncoding.DecodeString(pageToken)
if err != nil {
return nil, status.InvalidArgumentErrorf("invalid page_token")
}
token := &auditLogPageToken{}
if err := json.Unmarshal(data, token); err != nil {
return nil, status.InvalidArgumentErrorf("invalid page_token")
}
if token.AuditLogID == "" {
return nil, status.InvalidArgumentErrorf("invalid page_token")
}
return token, nil
}

type getFileWriter struct {
s apipb.ApiService_GetFileServer
}
Expand Down
Loading
Loading