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

@@ -1,4 +1,4 @@
// Copyright (c) 2025 Probo Inc <hello@getprobo.com>.
// Copyright (c) 2025-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
@@ -16,11 +16,14 @@ package iam
import (
"context"
"encoding/json"
"errors"
"fmt"
"maps"
"strings"
"time"
"go.gearno.de/kit/log"
"go.gearno.de/kit/pg"
"go.probo.inc/probo/pkg/coredata"
"go.probo.inc/probo/pkg/gid"
@@ -35,11 +38,13 @@ type AuthorizationAttributer interface {
// AuthorizeParams contains the parameters for an authorization request.
type AuthorizeParams struct {
Principal gid.GID
Resource gid.GID
Session *gid.GID
Action string
ResourceAttributes map[string]string
Principal gid.GID
Resource gid.GID
Session *gid.GID
Action string
ResourceAttributes map[string]string
DryRun bool
SkipAssumptionCheck bool
}
// Authorizer evaluates authorization requests against registered policies.
@@ -47,14 +52,16 @@ type Authorizer struct {
pg *pg.Client
evaluator *policy.Evaluator
policySet *PolicySet
logger *log.Logger
}
// NewAuthorizer creates a new Authorizer instance.
func NewAuthorizer(pgClient *pg.Client) *Authorizer {
func NewAuthorizer(pgClient *pg.Client, logger *log.Logger) *Authorizer {
return &Authorizer{
pg: pgClient,
evaluator: policy.NewEvaluator(),
policySet: NewPolicySet(),
logger: logger,
}
}
@@ -87,7 +94,7 @@ func (a *Authorizer) authorize(ctx context.Context, conn pg.Conn, params Authori
}
// Check whether the viewer is currently assuming the org of the accessed resource
if membership != nil && params.Session != nil {
if membership != nil && params.Session != nil && !params.SkipAssumptionCheck {
if _, err := a.getActiveChildSessionForMembership(
ctx,
conn,
@@ -137,6 +144,7 @@ func (a *Authorizer) authorize(ctx context.Context, conn pg.Conn, params Authori
}
if a.evaluator.Evaluate(req, policies).IsAllowed() {
a.recordAuditLog(ctx, conn, params, resourceAttrs)
return nil
}
@@ -258,3 +266,85 @@ func (a *Authorizer) buildPoliciesForRole(role string) []*policy.Policy {
return policies
}
// resourceTypeFromAction extracts the resource type name from an action
// string. For example, "core:vendor:create" returns "Vendor" and
// "core:webhook-subscription:delete" returns "WebhookSubscription".
func resourceTypeFromAction(action string) string {
parts := strings.Split(action, ":")
if len(parts) < 3 {
return "Unknown"
}
segments := strings.Split(parts[1], "-")
for i, s := range segments {
if len(s) > 0 {
segments[i] = strings.ToUpper(s[:1]) + s[1:]
}
}
return strings.Join(segments, "")
}
func (a *Authorizer) recordAuditLog(
ctx context.Context,
conn pg.Conn,
params AuthorizeParams,
resourceAttrs map[string]string,
) {
if params.DryRun {
return
}
orgIDStr := resourceAttrs["organization_id"]
if orgIDStr == "" {
return
}
orgID, err := gid.ParseGID(orgIDStr)
if err != nil {
a.logger.ErrorCtx(ctx, "cannot parse organization id for audit log",
log.Error(err),
)
return
}
var actorType coredata.AuditLogActorType
if params.Session != nil {
actorType = coredata.AuditLogActorTypeUser
} else {
actorType = coredata.AuditLogActorTypeAPIKey
}
resourceType := resourceTypeFromAction(params.Action)
metadata, err := json.Marshal(map[string]any{})
if err != nil {
a.logger.ErrorCtx(ctx, "cannot marshal audit log metadata",
log.Error(err),
)
return
}
entry := &coredata.AuditLogEntry{
ID: gid.New(orgID.TenantID(), coredata.AuditLogEntryEntityType),
OrganizationID: orgID,
ActorID: params.Principal,
ActorType: actorType,
Action: params.Action,
ResourceType: resourceType,
ResourceID: params.Resource,
Metadata: metadata,
CreatedAt: time.Now(),
}
scope := coredata.NewScope(orgID.TenantID())
if err := entry.Insert(ctx, conn, scope); err != nil {
a.logger.ErrorCtx(ctx, "cannot insert audit log entry",
log.Error(err),
log.String("action", params.Action),
log.String("resource_id", params.Resource.String()),
)
}
}

View File

@@ -92,4 +92,8 @@ const (
// Connector actions
ActionConnectorGet = "iam:connector:get"
// Audit log entry actions
ActionAuditLogEntryGet = "iam:audit-log-entry:get"
ActionAuditLogEntryList = "iam:audit-log-entry:list"
)

View File

@@ -203,6 +203,14 @@ var IAMOwnerPolicy = policy.NewPolicy(
policy.Allow(ActionSCIMBridgeUpdate).
WithSID("scim-bridge-update-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
// Full access to audit log entries (scoped to own organization)
policy.Allow(
ActionAuditLogEntryGet,
ActionAuditLogEntryList,
).
WithSID("audit-log-entry-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
).
WithDescription("Full IAM access for organization owners")
@@ -301,6 +309,14 @@ var IAMAdminPolicy = policy.NewPolicy(
ActionSCIMConfigurationDelete,
).
WithSID("deny-scim-management"),
// Can view audit log entries (scoped to own organization)
policy.Allow(
ActionAuditLogEntryGet,
ActionAuditLogEntryList,
).
WithSID("audit-log-entry-admin-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
).
WithDescription("IAM admin access - can manage members but cannot delete organization or manage SAML/SCIM")
@@ -335,5 +351,13 @@ var IAMViewerPolicy = policy.NewPolicy(
policy.Allow(ActionIdentityGet).
WithSID("view-member-identity").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
// Can view audit log entries (scoped to own organization)
policy.Allow(
ActionAuditLogEntryGet,
ActionAuditLogEntryList,
).
WithSID("audit-log-entry-viewer-access").
When(policy.Equals("principal.organization_id", "resource.organization_id")),
).
WithDescription("Read-only IAM access for organization viewers")

View File

@@ -2110,3 +2110,79 @@ func (s OrganizationService) DeleteSCIMBridge(ctx context.Context, organizationI
return nil
}
func (s *OrganizationService) GetAuditLogEntry(
ctx context.Context,
id gid.GID,
) (*coredata.AuditLogEntry, error) {
var (
scope = coredata.NewScopeFromObjectID(id)
entry = &coredata.AuditLogEntry{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
return entry.LoadByID(ctx, conn, scope, id)
},
)
if err != nil {
return nil, fmt.Errorf("cannot load audit log entry: %w", err)
}
return entry, nil
}
func (s *OrganizationService) ListAuditLogEntries(
ctx context.Context,
organizationID gid.GID,
cursor *page.Cursor[coredata.AuditLogEntryOrderField],
filter *coredata.AuditLogEntryFilter,
) (*page.Page[*coredata.AuditLogEntry, coredata.AuditLogEntryOrderField], error) {
var (
scope = coredata.NewScopeFromObjectID(organizationID)
entries = coredata.AuditLogEntries{}
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) error {
if err := entries.LoadAllByOrganizationID(ctx, conn, scope, organizationID, cursor, filter); err != nil {
return fmt.Errorf("cannot load audit log entries: %w", err)
}
return nil
},
)
if err != nil {
return nil, err
}
return page.NewPage(entries, cursor), nil
}
func (s *OrganizationService) CountAuditLogEntries(
ctx context.Context,
organizationID gid.GID,
filter *coredata.AuditLogEntryFilter,
) (int, error) {
var (
scope = coredata.NewScopeFromObjectID(organizationID)
count int
)
err := s.pg.WithConn(
ctx,
func(conn pg.Conn) (err error) {
entries := coredata.AuditLogEntries{}
count, err = entries.CountByOrganizationID(ctx, conn, scope, organizationID, filter)
if err != nil {
return fmt.Errorf("cannot count audit log entries: %w", err)
}
return nil
},
)
return count, err
}

View File

@@ -123,7 +123,7 @@ func NewService(
svc.AuthService = NewAuthService(svc)
svc.APIKeyService = NewAPIKeyService(svc)
svc.Authorizer = NewAuthorizer(pgClient)
svc.Authorizer = NewAuthorizer(pgClient, cfg.Logger.Named("authorizer"))
svc.Authorizer.RegisterPolicySet(IAMPolicySet())
samlService, err := saml.NewService(svc.pg, svc.baseURL, svc.certificate, svc.privateKey, cfg.Logger)