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:
@@ -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()),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user