Add audit log feature for recording all actions

Adds audit logging that records all authorized actions performed by
users and API keys. The audit log is automatically populated whenever
the authorizer approves an action, and is queryable via GraphQL, MCP,
and CLI interfaces. Permission checks are excluded via a dry-run flag
to avoid phantom entries on page loads.

Signed-off-by: Bryan Frimin <bryan@getprobo.com>
This commit is contained in:
Bryan Frimin
2026-03-20 15:42:11 +01:00
parent 000347b1f9
commit 7b320916af
34 changed files with 2546 additions and 13 deletions

View File

@@ -65,7 +65,7 @@ func NewMux(logger *log.Logger, svc *iam.Service, cookieConfig securecookie.Conf
}
func (r *Resolver) Permission(ctx context.Context, obj types.Node, action string) (bool, error) {
return r.authorize(ctx, obj.GetID(), action) == nil, nil
return r.authorize(ctx, obj.GetID(), action, authz.WithDryRun()) == nil, nil
}
func (r *Resolver) SSOLoginURL(samlConfigID gid.GID) string {

View File

@@ -243,6 +243,15 @@ type Organization implements Node {
scimConfiguration: SCIMConfiguration @goField(forceResolver: true)
auditLogEntries(
first: Int
after: CursorKey
last: Int
before: CursorKey
orderBy: AuditLogEntryOrder
filter: AuditLogEntryFilter
): AuditLogEntryConnection! @goField(forceResolver: true)
viewer: Profile @goField(forceResolver: true)
permission(action: String!): Boolean!
@@ -602,6 +611,79 @@ type SCIMEventEdge {
cursor: CursorKey!
}
enum AuditLogActorType
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AuditLogActorType"
) {
USER
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AuditLogActorTypeUser"
)
API_KEY
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AuditLogActorTypeAPIKey"
)
SYSTEM
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AuditLogActorTypeSystem"
)
}
enum AuditLogEntryOrderField
@goModel(
model: "go.probo.inc/probo/pkg/coredata.AuditLogEntryOrderField"
) {
CREATED_AT
@goEnum(
value: "go.probo.inc/probo/pkg/coredata.AuditLogEntryOrderFieldCreatedAt"
)
}
input AuditLogEntryOrder
@goModel(
model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.AuditLogEntryOrderBy"
) {
field: AuditLogEntryOrderField!
direction: OrderDirection!
}
input AuditLogEntryFilter {
action: String
actorId: ID
resourceType: String
resourceId: ID
}
type AuditLogEntry implements Node {
id: ID!
organization: Organization @goField(forceResolver: true)
actorId: ID!
actorType: AuditLogActorType!
action: String!
resourceType: String!
resourceId: ID!
metadata: String
createdAt: Datetime!
permission(action: String!): Boolean!
@goField(forceResolver: true)
@session(required: PRESENT)
}
type AuditLogEntryConnection
@goModel(
model: "go.probo.inc/probo/pkg/server/api/connect/v1/types.AuditLogEntryConnection"
) {
edges: [AuditLogEntryEdge!]!
pageInfo: PageInfo!
totalCount: Int! @goField(forceResolver: true)
}
type AuditLogEntryEdge {
cursor: CursorKey!
node: AuditLogEntry!
}
type PageInfo {
hasNextPage: Boolean!
hasPreviousPage: Boolean!

View File

@@ -0,0 +1,85 @@
// Copyright (c) 2026 Probo Inc <hello@getprobo.com>.
//
// Permission to use, copy, modify, and/or distribute this software for any
// purpose with or without fee is hereby granted, provided that the above
// copyright notice and this permission notice appear in all copies.
//
// THE SOFTWARE IS PROVIDED "AS IS" AND THE AUTHOR DISCLAIMS ALL WARRANTIES WITH
// REGARD TO THIS SOFTWARE INCLUDING ALL IMPLIED WARRANTIES OF MERCHANTABILITY
// AND FITNESS. IN NO EVENT SHALL THE AUTHOR BE LIABLE FOR ANY SPECIAL, DIRECT,
// INDIRECT, OR CONSEQUENTIAL DAMAGES OR ANY DAMAGES WHATSOEVER RESULTING FROM
// LOSS OF USE, DATA OR PROFITS, WHETHER IN AN ACTION OF CONTRACT, NEGLIGENCE OR
// OTHER TORTIOUS ACTION, ARISING OUT OF OR IN CONNECTION WITH THE USE OR
// PERFORMANCE OF THIS SOFTWARE.
package types
import (
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
"go.probo.inc/probo/pkg/page"
)
type (
AuditLogEntryOrderBy OrderBy[coredata.AuditLogEntryOrderField]
AuditLogEntryConnection struct {
TotalCount int
Edges []*AuditLogEntryEdge
PageInfo PageInfo
Resolver any
ParentID gid.GID
Filter *coredata.AuditLogEntryFilter
}
)
func NewAuditLogEntryConnection(
p *page.Page[*coredata.AuditLogEntry, coredata.AuditLogEntryOrderField],
resolver any,
parentID gid.GID,
filter *coredata.AuditLogEntryFilter,
) *AuditLogEntryConnection {
edges := make([]*AuditLogEntryEdge, len(p.Data))
for i := range edges {
edges[i] = NewAuditLogEntryEdge(p.Data[i], p.Cursor.OrderBy.Field)
}
return &AuditLogEntryConnection{
Edges: edges,
PageInfo: *NewPageInfo(p),
Resolver: resolver,
ParentID: parentID,
Filter: filter,
}
}
func NewAuditLogEntryEdge(e *coredata.AuditLogEntry, orderBy coredata.AuditLogEntryOrderField) *AuditLogEntryEdge {
return &AuditLogEntryEdge{
Cursor: e.CursorKey(orderBy),
Node: NewAuditLogEntry(e),
}
}
func NewAuditLogEntry(e *coredata.AuditLogEntry) *AuditLogEntry {
var metadata *string
if len(e.Metadata) > 0 {
metadata = new(string(e.Metadata))
}
return &AuditLogEntry{
ID: e.ID,
Organization: &Organization{
ID: e.OrganizationID,
},
ActorID: e.ActorID,
ActorType: e.ActorType,
Action: e.Action,
ResourceType: e.ResourceType,
ResourceID: e.ResourceID,
Metadata: metadata,
CreatedAt: e.CreatedAt,
}
}

View File

@@ -27,6 +27,32 @@ import (
"go.probo.inc/probo/pkg/server/gqlutils/types/cursor"
)
// Organization is the resolver for the organization field.
func (r *auditLogEntryResolver) Organization(ctx context.Context, obj *types.AuditLogEntry) (*types.Organization, error) {
return obj.Organization, nil
}
// Permission is the resolver for the permission field.
func (r *auditLogEntryResolver) Permission(ctx context.Context, obj *types.AuditLogEntry, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
}
// TotalCount is the resolver for the totalCount field.
func (r *auditLogEntryConnectionResolver) TotalCount(ctx context.Context, obj *types.AuditLogEntryConnection) (int, error) {
filter := coredata.NewAuditLogEntryFilter()
if obj.Filter != nil {
filter = obj.Filter
}
count, err := r.iam.OrganizationService.CountAuditLogEntries(ctx, obj.ParentID, filter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot count audit log entries", log.Error(err))
return 0, gqlutils.Internal(ctx)
}
return count, nil
}
// Permission is the resolver for the permission field.
func (r *connectorResolver) Permission(ctx context.Context, obj *types.Connector, action string) (bool, error) {
return r.Resolver.Permission(ctx, obj, action)
@@ -1302,6 +1328,50 @@ func (r *organizationResolver) ScimConfiguration(ctx context.Context, obj *types
return types.NewSCIMConfiguration(config), nil
}
// AuditLogEntries is the resolver for the auditLogEntries field.
func (r *organizationResolver) AuditLogEntries(ctx context.Context, obj *types.Organization, first *int, after *page.CursorKey, last *int, before *page.CursorKey, orderBy *types.AuditLogEntryOrderBy, filter *types.AuditLogEntryFilter) (*types.AuditLogEntryConnection, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionAuditLogEntryList); err != nil {
return nil, err
}
pageOrderBy := page.OrderBy[coredata.AuditLogEntryOrderField]{
Field: coredata.AuditLogEntryOrderFieldCreatedAt,
Direction: page.OrderDirectionDesc,
}
if orderBy != nil {
pageOrderBy = page.OrderBy[coredata.AuditLogEntryOrderField]{
Field: orderBy.Field,
Direction: orderBy.Direction,
}
}
c := cursor.NewCursor(first, after, last, before, pageOrderBy)
coredataFilter := coredata.NewAuditLogEntryFilter()
if filter != nil {
if filter.Action != nil {
coredataFilter.WithAction(*filter.Action)
}
if filter.ActorID != nil {
coredataFilter.WithActorID(*filter.ActorID)
}
if filter.ResourceType != nil {
coredataFilter.WithResourceType(*filter.ResourceType)
}
if filter.ResourceID != nil {
coredataFilter.WithResourceID(*filter.ResourceID)
}
}
p, err := r.iam.OrganizationService.ListAuditLogEntries(ctx, obj.ID, c, coredataFilter)
if err != nil {
r.logger.ErrorCtx(ctx, "cannot list audit log entries", log.Error(err))
return nil, gqlutils.Internal(ctx)
}
return types.NewAuditLogEntryConnection(p, r, obj.ID, coredataFilter), nil
}
// Viewer is the resolver for the viewer field.
func (r *organizationResolver) Viewer(ctx context.Context, obj *types.Organization) (*types.Profile, error) {
if err := r.authorize(ctx, obj.ID, iam.ActionMembershipProfileGet); err != nil {
@@ -1909,6 +1979,14 @@ func (r *sessionConnectionResolver) TotalCount(ctx context.Context, obj *types.S
return nil, gqlutils.Internal(ctx)
}
// AuditLogEntry returns schema.AuditLogEntryResolver implementation.
func (r *Resolver) AuditLogEntry() schema.AuditLogEntryResolver { return &auditLogEntryResolver{r} }
// AuditLogEntryConnection returns schema.AuditLogEntryConnectionResolver implementation.
func (r *Resolver) AuditLogEntryConnection() schema.AuditLogEntryConnectionResolver {
return &auditLogEntryConnectionResolver{r}
}
// Connector returns schema.ConnectorResolver implementation.
func (r *Resolver) Connector() schema.ConnectorResolver { return &connectorResolver{r} }
@@ -1980,6 +2058,8 @@ func (r *Resolver) SessionConnection() schema.SessionConnectionResolver {
return &sessionConnectionResolver{r}
}
type auditLogEntryResolver struct{ *Resolver }
type auditLogEntryConnectionResolver struct{ *Resolver }
type connectorResolver struct{ *Resolver }
type identityResolver struct{ *Resolver }
type invitationResolver struct{ *Resolver }