diff --git a/docs/enterprise-api.md b/docs/enterprise-api.md index 5bbaf64343d..07dfeae8ea6 100644 --- a/docs/enterprise-api.md +++ b/docs/enterprise-api.md @@ -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). diff --git a/enterprise/server/api/BUILD b/enterprise/server/api/BUILD index 2dab7667c24..af5993dc4f1 100644 --- a/enterprise/server/api/BUILD +++ b/enterprise/server/api/BUILD @@ -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", @@ -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", @@ -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 = [ @@ -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", + ], +) diff --git a/enterprise/server/api/api_server.go b/enterprise/server/api/api_server.go index f6805388daa..7f901739afc 100644 --- a/enterprise/server/api/api_server.go +++ b/enterprise/server/api/api_server.go @@ -2,6 +2,8 @@ package api import ( "context" + "encoding/base64" + "encoding/json" "fmt" "maps" "net/http" @@ -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" @@ -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" @@ -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 @@ -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 } diff --git a/enterprise/server/api/api_server_test.go b/enterprise/server/api/api_test.go similarity index 100% rename from enterprise/server/api/api_server_test.go rename to enterprise/server/api/api_test.go diff --git a/enterprise/server/api/auditlog_test.go b/enterprise/server/api/auditlog_test.go new file mode 100644 index 00000000000..77cdd24c52b --- /dev/null +++ b/enterprise/server/api/auditlog_test.go @@ -0,0 +1,83 @@ +package api + +import ( + "context" + "testing" + "time" + + "github.com/buildbuddy-io/buildbuddy/enterprise/server/auditlog" + "github.com/buildbuddy-io/buildbuddy/enterprise/server/testutil/enterprise_testauth" + "github.com/buildbuddy-io/buildbuddy/enterprise/server/testutil/enterprise_testenv" + apipb "github.com/buildbuddy-io/buildbuddy/proto/api/v1" + alpb "github.com/buildbuddy-io/buildbuddy/proto/auditlog" + cappb "github.com/buildbuddy-io/buildbuddy/proto/capability" + grpb "github.com/buildbuddy-io/buildbuddy/proto/group" + "github.com/buildbuddy-io/buildbuddy/server/util/testing/flags" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" +) + +func TestGetAuditLog(t *testing.T) { + flags.Set(t, "app.audit_logs_enabled", true) + flags.Set(t, "testenv.reuse_server", true) + flags.Set(t, "testenv.use_clickhouse", true) + + ctx := context.Background() + env := enterprise_testenv.New(t) + auth := enterprise_testauth.Configure(t, env) + require.NoError(t, auditlog.Register(env)) + + user := enterprise_testauth.CreateRandomUser(t, env, "example.io") + userCtx, err := auth.WithAuthenticatedUser(ctx, user.UserID) + require.NoError(t, err) + authUser, err := env.GetUserDB().GetUser(userCtx) + require.NoError(t, err) + require.NotEmpty(t, authUser.Groups) + groupID := authUser.Groups[0].Group.GroupID + + key, err := env.GetAuthDB().CreateAPIKey( + userCtx, + groupID, + "audit-reader", + []cappb.Capability{cappb.Capability_AUDIT_LOG_READ}, + 0, /*=expiresIn*/ + false, /*=visibleToDevelopers*/ + ) + require.NoError(t, err) + keyCtx := env.GetAuthenticator().AuthContextFromAPIKey(ctx, key.Value) + + env.GetAuditLogger().LogForGroup(userCtx, groupID, alpb.Action_UPDATE, &grpb.UpdateGroupRequest{Name: "before-window"}) + time.Sleep(2 * time.Millisecond) + windowStart := time.Now() + time.Sleep(2 * time.Millisecond) + env.GetAuditLogger().LogForGroup(userCtx, groupID, alpb.Action_UPDATE, &grpb.UpdateGroupRequest{Name: "update-1"}) + env.GetAuditLogger().LogForGroup(userCtx, groupID, alpb.Action_UPDATE, &grpb.UpdateGroupRequest{Name: "update-2"}) + + s := NewAPIServer(env) + page1, err := s.GetAuditLog(keyCtx, &apipb.GetAuditLogRequest{ + Selector: &apipb.AuditLogSelector{ + StartTime: timestamppb.New(windowStart), + }, + PageSize: 1, + }) + require.NoError(t, err) + require.Len(t, page1.GetEntry(), 1) + require.NotEmpty(t, page1.GetNextPageToken()) + require.NotContains(t, page1.GetNextPageToken(), "|") + + time.Sleep(2 * time.Millisecond) + env.GetAuditLogger().LogForGroup(userCtx, groupID, alpb.Action_UPDATE, &grpb.UpdateGroupRequest{Name: "update-3"}) + + page2, err := s.GetAuditLog(keyCtx, &apipb.GetAuditLogRequest{ + PageSize: 1, + PageToken: page1.GetNextPageToken(), + }) + require.NoError(t, err) + require.Len(t, page2.GetEntry(), 1) + require.Empty(t, page2.GetNextPageToken()) + + name1 := page1.GetEntry()[0].GetRequest().GetApiRequest().GetUpdateGroup().GetName() + name2 := page2.GetEntry()[0].GetRequest().GetApiRequest().GetUpdateGroup().GetName() + require.NotEqual(t, name1, name2) + require.ElementsMatch(t, []string{"update-1", "update-2"}, []string{name1, name2}) +} diff --git a/enterprise/server/auditlog/auditlog.go b/enterprise/server/auditlog/auditlog.go index 120225d8368..1e2055967f6 100644 --- a/enterprise/server/auditlog/auditlog.go +++ b/enterprise/server/auditlog/auditlog.go @@ -197,7 +197,9 @@ func (l *Logger) LogForUserList(ctx context.Context, userListID string, userList l.Log(ctx, r, action, request) } -func filterEntry(entry *alpb.Entry, userEmail string) { +// FilterEntry redacts internal BuildBuddy user details from an audit log entry +// for non-server-admin viewers. +func FilterEntry(entry *alpb.Entry, userEmail string) { if strings.HasSuffix(userEmail, "@buildbuddy.io") { entry.AuthenticationInfo.User = &alpb.AuthenticatedUser{ UserEmail: "Buildbuddy Admin", @@ -214,24 +216,71 @@ func filterEntry(entry *alpb.Entry, userEmail string) { // so the ID under the request is redundant. func cleanRequest(e *alpb.Entry_Request) *alpb.Entry_Request { e = e.CloneVT() - if r := e.ApiRequest.GetApiKeys; r != nil { + if e.GetApiRequest() == nil { + return e + } + if r := e.GetApiRequest().GetGetApiKeys(); r != nil { r.GroupId = "" } - if r := e.ApiRequest.UpdateApiKey; r != nil { + if r := e.GetApiRequest().GetUpdateApiKey(); r != nil { r.Id = "" } - if r := e.ApiRequest.DeleteApiKey; r != nil { + if r := e.GetApiRequest().GetDeleteApiKey(); r != nil { r.Id = "" } - if r := e.ApiRequest.UpdateGroup; r != nil { + if r := e.GetApiRequest().GetUpdateGroup(); r != nil { r.Id = "" } - if r := e.ApiRequest.UpdateGroupUsers; r != nil { + if r := e.GetApiRequest().GetUpdateGroupUsers(); r != nil { r.GroupId = "" } return e } +func EntryFromDBRow(row *schema.AuditLog) (*alpb.Entry, error) { + request := &alpb.Entry_Request{} + if row.Request != "" { + if err := proto.Unmarshal([]byte(row.Request), request); err != nil { + return nil, err + } + } + + resourceType := alpb.ResourceType(row.ResourceType) + // If no resource is specified, the resource is implicitly the owning + // organization. + if resourceType == alpb.ResourceType_UNKNOWN_RESOURCE { + resourceType = alpb.ResourceType_GROUP + } + + entry := &alpb.Entry{ + EventTime: timestamppb.New(time.UnixMicro(row.EventTimeUsec)), + AuthenticationInfo: &alpb.AuthenticationInfo{ + ClientIp: row.ClientIP, + }, + Resource: &alpb.ResourceID{ + Type: resourceType, + Id: row.ResourceID, + Name: row.ResourceName, + }, + Action: alpb.Action(row.Action), + Request: cleanRequest(request), + } + if row.AuthUserID != "" { + entry.AuthenticationInfo.User = &alpb.AuthenticatedUser{ + UserId: row.AuthUserID, + UserEmail: row.AuthUserEmail, + } + } + if row.AuthAPIKeyID != "" { + entry.AuthenticationInfo.ApiKey = &alpb.AuthenticatedAPIKey{ + Id: row.AuthAPIKeyID, + Label: row.AuthAPIKeyLabel, + } + } + + return entry, nil +} + func (l *Logger) fillIDDescriptors(ctx context.Context, e *alpb.Entry_Request) error { userIDs := make(map[string]struct{}) @@ -302,51 +351,18 @@ func (l *Logger) GetLogs(ctx context.Context, req *alpb.GetAuditLogsRequest) (*a rq := l.dbh.NewQuery(ctx, "audit_logs_get_logs").Raw(q, args...) resp := &alpb.GetAuditLogsResponse{} err = db.ScanEach(rq, func(ctx context.Context, e *schema.AuditLog) error { - request := &alpb.Entry_Request{} - if err := proto.Unmarshal([]byte(e.Request), request); err != nil { - return err - } - if len(resp.Entries) == pageSize { resp.NextPageToken = strconv.FormatInt(e.EventTimeUsec, 10) return nil } - resourceType := alpb.ResourceType(e.ResourceType) - // If no resource is specified, the resource is implicitely the owning - // organization. - if resourceType == alpb.ResourceType_UNKNOWN_RESOURCE { - resourceType = alpb.ResourceType_GROUP - } - - entry := &alpb.Entry{ - EventTime: timestamppb.New(time.UnixMicro(e.EventTimeUsec)), - AuthenticationInfo: &alpb.AuthenticationInfo{ - ClientIp: e.ClientIP, - }, - Resource: &alpb.ResourceID{ - Type: resourceType, - Id: e.ResourceID, - Name: e.ResourceName, - }, - Action: alpb.Action(e.Action), - Request: cleanRequest(request), - } - if e.AuthUserID != "" { - entry.AuthenticationInfo.User = &alpb.AuthenticatedUser{ - UserId: e.AuthUserID, - UserEmail: e.AuthUserEmail, - } - } - if e.AuthAPIKeyID != "" { - entry.AuthenticationInfo.ApiKey = &alpb.AuthenticatedAPIKey{ - Id: e.AuthAPIKeyID, - Label: e.AuthAPIKeyLabel, - } + entry, err := EntryFromDBRow(e) + if err != nil { + return err } if !isServerAdmin { - filterEntry(entry, e.AuthUserEmail) + FilterEntry(entry, e.AuthUserEmail) } resp.Entries = append(resp.Entries, entry) return nil diff --git a/proto/api/v1/BUILD b/proto/api/v1/BUILD index 469484bd80a..f9c10334305 100644 --- a/proto/api/v1/BUILD +++ b/proto/api/v1/BUILD @@ -38,6 +38,7 @@ proto_library( srcs = [ "action.proto", "api_key.proto", + "audit_log.proto", "file.proto", "invocation.proto", "log.proto", @@ -49,6 +50,7 @@ proto_library( visibility = ["//visibility:public"], deps = [ ":common_proto", + "//proto:auditlog_proto", "@com_google_protobuf//:duration_proto", "@com_google_protobuf//:timestamp_proto", "@googleapis//google/rpc:status_proto", @@ -66,6 +68,7 @@ go_proto_library( visibility = ["//visibility:public"], deps = [ ":common_go_proto", + "//proto:auditlog_go_proto", "@org_golang_google_genproto_googleapis_rpc//status", ], ) diff --git a/proto/api/v1/audit_log.proto b/proto/api/v1/audit_log.proto new file mode 100644 index 00000000000..b232491637a --- /dev/null +++ b/proto/api/v1/audit_log.proto @@ -0,0 +1,40 @@ +syntax = "proto3"; + +package api.v1; + +import "proto/auditlog.proto"; +import "google/protobuf/timestamp.proto"; + +// The selector used to specify which audit logs to return. +message AuditLogSelector { + // Optional inclusive lower bound for event time. + // If unset, defaults to the Unix epoch. + google.protobuf.Timestamp start_time = 1; + + // Optional exclusive upper bound for event time. + // If unset, defaults to the current time. + google.protobuf.Timestamp end_time = 2; +} + +// 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; +} + +// Response from calling GetAuditLog. +message GetAuditLogResponse { + // Audit log entries matching the request. + repeated auditlog.Entry entry = 1; + + // Cursor to retrieve the next page, or empty if there are no more results. + string next_page_token = 2; +} diff --git a/proto/api/v1/service.proto b/proto/api/v1/service.proto index 2c6298715ba..61ca45f1037 100644 --- a/proto/api/v1/service.proto +++ b/proto/api/v1/service.proto @@ -4,6 +4,7 @@ package api.v1; import "proto/api/v1/action.proto"; import "proto/api/v1/api_key.proto"; +import "proto/api/v1/audit_log.proto"; import "proto/api/v1/file.proto"; import "proto/api/v1/invocation.proto"; import "proto/api/v1/log.proto"; @@ -38,6 +39,9 @@ service ApiService { // Retrieves the logs for a specific invocation. rpc GetLog(GetLogRequest) returns (GetLogResponse); + // Retrieves a page of audit log entries. + rpc GetAuditLog(GetAuditLogRequest) returns (GetAuditLogResponse); + // Retrieves a list of targets or a specific target matching the given // request selector. rpc GetTarget(GetTargetRequest) returns (GetTargetResponse); diff --git a/server/capabilities_filter/capabilities_filter.go b/server/capabilities_filter/capabilities_filter.go index 243280be440..f9bafa71f8b 100644 --- a/server/capabilities_filter/capabilities_filter.go +++ b/server/capabilities_filter/capabilities_filter.go @@ -49,6 +49,7 @@ var ( // TODO(bduffany): prefix all of these with the service name, // since API methods and BuildBuddyService methods may be the same. "GetInvocation", + "GetAuditLog", "GetLog", "DeleteFile", "GetTarget",