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
1 change: 1 addition & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -818,6 +818,7 @@ func runRoot(c *cobra.Command, args []string) {
MemoryStoreProvider: memoryStoreProvider,
AuthzEngine: authzEngine,
EventsProvider: eventsProvider,
AuditProvider: auditProvider,
})
scimHandler := scimhttp.New(&scimhttp.Dependencies{
Log: &log,
Expand Down
49 changes: 37 additions & 12 deletions internal/audit/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,20 @@ type Provider interface {
// LogEvent asynchronously records an audit log entry.
// It is fire-and-forget: errors are logged but not propagated.
LogEvent(event Event)

// LogEventSync records an audit log entry synchronously and returns the
// storage error.
//
// For operations where the audit record is part of the contract, not a
// side effect: an authorization change that is not evidenced is a change
// nobody can account for. Everything else — logins, token issuance —
// keeps LogEvent, so the audit table stays off those hot paths.
//
// The caller decides what a failure means. For authorization changes the
// convention is to return the error and NOT compensate: by the time this
// is called the change has already been applied, so the error means
// "applied but unevidenced", not "nothing happened".
LogEventSync(ctx context.Context, event Event) error
}

type provider struct {
Expand Down Expand Up @@ -84,23 +98,34 @@ func metadataWithProtocol(meta, protocol string) string {
return string(b)
}

// buildAuditLog converts an Event into its storage row. Shared by LogEvent and
// LogEventSync so the two can never disagree about how a record is shaped —
// in particular, both fold Protocol into Metadata via metadataWithProtocol.
func buildAuditLog(event Event) *schemas.AuditLog {
return &schemas.AuditLog{
ActorID: event.ActorID,
ActorType: event.ActorType,
ActorEmail: event.ActorEmail,
Action: event.Action,
ResourceType: event.ResourceType,
ResourceID: event.ResourceID,
IPAddress: event.IPAddress,
UserAgent: event.UserAgent,
Metadata: metadataWithProtocol(event.Metadata, event.Protocol),
}
}

// LogEvent asynchronously records an audit log entry.
func (p *provider) LogEvent(event Event) {
asyncutil.Go(p.deps.Log, func() {
log := p.deps.Log.With().Str("func", "LogEvent").Logger()
auditLog := &schemas.AuditLog{
ActorID: event.ActorID,
ActorType: event.ActorType,
ActorEmail: event.ActorEmail,
Action: event.Action,
ResourceType: event.ResourceType,
ResourceID: event.ResourceID,
IPAddress: event.IPAddress,
UserAgent: event.UserAgent,
Metadata: metadataWithProtocol(event.Metadata, event.Protocol),
}
if err := p.deps.StorageProvider.AddAuditLog(context.Background(), auditLog); err != nil {
if err := p.deps.StorageProvider.AddAuditLog(context.Background(), buildAuditLog(event)); err != nil {
log.Debug().Err(err).Str("action", event.Action).Msg("Failed to add audit log")
}
})
}

// LogEventSync records an audit log entry synchronously.
func (p *provider) LogEventSync(ctx context.Context, event Event) error {
return p.deps.StorageProvider.AddAuditLog(ctx, buildAuditLog(event))
}
78 changes: 78 additions & 0 deletions internal/audit/provider_sync_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
package audit

import (
"context"
"errors"
"testing"

"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/authorizerdev/authorizer/internal/storage"
"github.com/authorizerdev/authorizer/internal/storage/schemas"
)

// fakeAuditStore records what AddAuditLog received and can be told to fail.
// It embeds storage.Provider (nil) so it satisfies the interface without
// implementing the other ~200 methods; only AddAuditLog is ever called here.
type fakeAuditStore struct {
storage.Provider
got *schemas.AuditLog
err error
}

func (f *fakeAuditStore) AddAuditLog(_ context.Context, l *schemas.AuditLog) error {
if f.err != nil {
return f.err
}
f.got = l
return nil
}

func newTestProvider(st storage.Provider) Provider {
log := zerolog.Nop()
return New(&Dependencies{Log: &log, StorageProvider: st})
}

func TestLogEventSync_PersistsEventAndReturnsNil(t *testing.T) {
st := &fakeAuditStore{}
p := newTestProvider(st)

err := p.LogEventSync(context.Background(), Event{
Action: "admin.fga_tuples_written",
ActorType: "admin",
ActorID: "actor-1",
})

require.NoError(t, err)
require.NotNil(t, st.got)
assert.Equal(t, "admin.fga_tuples_written", st.got.Action)
assert.Equal(t, "actor-1", st.got.ActorID)
}

func TestLogEventSync_ReturnsStorageError(t *testing.T) {
boom := errors.New("audit table unavailable")
p := newTestProvider(&fakeAuditStore{err: boom})

err := p.LogEventSync(context.Background(), Event{Action: "admin.fga_reset"})

require.Error(t, err)
assert.ErrorIs(t, err, boom, "the storage error must reach the caller unchanged")
}

func TestLogEventSync_FoldsProtocolIntoMetadataLikeLogEvent(t *testing.T) {
st := &fakeAuditStore{}
p := newTestProvider(st)

require.NoError(t, p.LogEventSync(context.Background(), Event{
Action: "admin.fga_tuples_written",
Protocol: "grpc",
Metadata: `{"count":2}`,
}))

require.NotNil(t, st.got)
// Both keys must survive: the protocol the caller set, and the metadata it
// already had. A separate builder for the sync path would drop one.
assert.JSONEq(t, `{"protocol":"grpc","count":2}`, st.got.Metadata)
}
16 changes: 13 additions & 3 deletions internal/authctx/principal.go
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
// Package authctx carries authentication principal details on context.Context.
package authctx

import "strings"

import "context"
import (
"context"
"strings"
)

type principalContextKey struct{}

Expand All @@ -27,6 +28,15 @@ type Principal struct {
// enforce per-operation scope for delegated callers. See
// internal/delegatedscope.
Scope []string
// AuthMode records HOW a super-admin caller authenticated — one of
// constants.AuditAuthModeAdminSession or
// constants.AuditAuthModeSharedSecret. Empty for non-admin callers.
//
// Super-admin has no per-admin identity (one shared AdminSecret), so an
// audit record cannot name a person. This records which credential was
// used instead of leaving the question blank. Never the session handle
// itself.
AuthMode string
}

// IsDelegated reports whether this principal is an agent acting for a user.
Expand Down
19 changes: 19 additions & 0 deletions internal/constants/audit_event.go
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,25 @@ const (
AuditActorTypeServiceAccount = "service_account"
)

// Audit auth-mode constants record HOW a super-admin authenticated.
//
// Super-admin is a single shared AdminSecret, or a session cookie derived from
// it — there is no per-admin identity, so an audit record cannot name a person.
// Recording the mode is the honest alternative: it says which credential was
// used, and makes a shared-secret action distinguishable from a dashboard
// session without inventing an identity the system does not have.
//
// The session HANDLE is never recorded. It is a live bearer credential and the
// dashboard renders the audit table.
const (
// AuditAuthModeAdminSession means the caller presented a valid admin
// session cookie (dashboard login).
AuditAuthModeAdminSession = "admin_session"
// AuditAuthModeSharedSecret means the caller presented the
// x-authorizer-admin-secret header.
AuditAuthModeSharedSecret = "shared_secret"
)

// Audit resource type constants identify the type of resource affected by an auditable action.
const (
// AuditResourceTypeUser represents a user entity.
Expand Down
4 changes: 2 additions & 2 deletions internal/grpcsrv/interceptors/auth.go
Original file line number Diff line number Diff line change
Expand Up @@ -178,8 +178,8 @@ func Auth(tp token.Provider, log *zerolog.Logger, resolve TokenResolver) grpc.Un
// is the only version of that guard that holds: merely skipping this
// check would move it one layer down, since service.requireSuperAdmin
// re-derives super-admin from meta.Request on its own.
if tp.IsSuperAdmin(gc) {
ctx = authctx.WithPrincipal(ctx, &authctx.Principal{IsSuperAdmin: true})
if mode := tp.AdminAuthMode(gc); mode != "" {
ctx = authctx.WithPrincipal(ctx, &authctx.Principal{IsSuperAdmin: true, AuthMode: mode})
return handler(ctx, req)
}

Expand Down
9 changes: 9 additions & 0 deletions internal/grpcsrv/interceptors/auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,14 @@ func (s *stubTokenProvider) IsSuperAdmin(_ *gin.Context) bool {
return s.superAdmin
}

func (s *stubTokenProvider) AdminAuthMode(_ *gin.Context) string {
s.superAdminChecks++
if s.superAdmin {
return constants.AuditAuthModeSharedSecret
}
return ""
}

func (s *stubTokenProvider) GetUserIDFromSessionOrAccessToken(_ *gin.Context) (*token.SessionOrAccessTokenData, error) {
s.userChecks++
if s.tokenErr != nil {
Expand Down Expand Up @@ -179,6 +187,7 @@ func TestAuth_AdminMethodRequiresSuperAdmin(t *testing.T) {
require.True(t, ok)
require.NotNil(t, p)
assert.True(t, p.IsSuperAdmin)
assert.Equal(t, constants.AuditAuthModeSharedSecret, p.AuthMode)
assert.Empty(t, p.UserID)
return &authorizerv1.AdminMetaResponse{}, nil
})
Expand Down
2 changes: 1 addition & 1 deletion internal/grpcsrv/interceptors/mcp_auth_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,7 +134,7 @@ func TestMCPTokenResolver(t *testing.T) {
// that resolver is the ONLY way in.
//
// Two paths in the interceptor authenticate without consulting any resolver —
// tp.IsSuperAdmin (an admin cookie or the x-authorizer-admin-secret header) and
// tp.AdminAuthMode (an admin cookie or the x-authorizer-admin-secret header) and
// the Session RPC's cookie-only branch. transport.MetaFromGRPC reconstructs
// cookies from gRPC metadata, so on the MCP server those would be reachable the
// moment the HTTP bridge forwarded request headers. Before this guard the
Expand Down
5 changes: 5 additions & 0 deletions internal/service/admin_access_invite_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import (

"github.com/authorizerdev/authorizer/internal/audit"
"github.com/authorizerdev/authorizer/internal/config"
"github.com/authorizerdev/authorizer/internal/constants"
"github.com/authorizerdev/authorizer/internal/email"
"github.com/authorizerdev/authorizer/internal/graph/model"
"github.com/authorizerdev/authorizer/internal/refs"
Expand Down Expand Up @@ -76,6 +77,8 @@ type inviteToken struct {

func (inviteToken) IsSuperAdmin(_ *gin.Context) bool { return true }

func (inviteToken) AdminAuthMode(_ *gin.Context) string { return constants.AuditAuthModeAdminSession }

func (tp inviteToken) CreateVerificationToken(cfg *token.AuthTokenConfig, _ string, _ string) (string, error) {
if tp.createVerificationToken != nil {
return tp.createVerificationToken(cfg)
Expand All @@ -91,6 +94,8 @@ type inviteAudit struct{ audit.Provider }

func (inviteAudit) LogEvent(_ audit.Event) {}

func (inviteAudit) LogEventSync(_ context.Context, _ audit.Event) error { return nil }

func newInviteProvider(cfg *config.Config, st storage.Provider, tp token.Provider) *provider {
log := zerolog.Nop()
return &provider{
Expand Down
61 changes: 61 additions & 0 deletions internal/service/scim/audit_wiring_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package scim

import (
"context"
"errors"
"testing"

"github.com/rs/zerolog"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"

"github.com/authorizerdev/authorizer/internal/audit"
)

type recordingAudit struct {
audit.Provider
got audit.Event
err error
}

func (recordingAudit) LogEvent(_ audit.Event) {}

func (r *recordingAudit) LogEventSync(_ context.Context, e audit.Event) error {
if r.err != nil {
return r.err
}
r.got = e
return nil
}

// scim's concrete type embeds Dependencies BY VALUE (`type provider struct {
// Dependencies }`), so fields are reached as p.AuditProvider, not p.deps.X.
func newAuditTestProvider(ap audit.Provider) *provider {
log := zerolog.Nop()
return &provider{Dependencies: Dependencies{Log: &log, AuditProvider: ap}}
}

// A deployment that does not wire audit must still serve SCIM, matching the
// documented EventsProvider convention.
func TestLogAuditSync_NilProviderIsNoOp(t *testing.T) {
p := newAuditTestProvider(nil)
assert.NotPanics(t, func() {
require.NoError(t, p.logAuditSync(context.Background(), audit.Event{Action: "x"}))
})
}

func TestLogAuditSync_ForwardsEvent(t *testing.T) {
ra := &recordingAudit{}
p := newAuditTestProvider(ra)

require.NoError(t, p.logAuditSync(context.Background(), audit.Event{Action: "scim.group_members_added"}))

assert.Equal(t, "scim.group_members_added", ra.got.Action)
}

func TestLogAuditSync_PropagatesError(t *testing.T) {
boom := errors.New("audit unavailable")
p := newAuditTestProvider(&recordingAudit{err: boom})

assert.ErrorIs(t, p.logAuditSync(context.Background(), audit.Event{Action: "x"}), boom)
}
22 changes: 22 additions & 0 deletions internal/service/scim/scim.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ import (
"golang.org/x/crypto/bcrypt"

"github.com/authorizerdev/authorizer/internal/asyncutil"
"github.com/authorizerdev/authorizer/internal/audit"
"github.com/authorizerdev/authorizer/internal/authorization/engine"
"github.com/authorizerdev/authorizer/internal/constants"
"github.com/authorizerdev/authorizer/internal/events"
Expand Down Expand Up @@ -97,6 +98,27 @@ type Dependencies struct {
// (user.provisioned/deprovisioned/scim_updated, group.created/updated/deleted).
// Nil when webhooks are not wired — event firing is then a no-op.
EventsProvider events.Provider
// AuditProvider records authorization-relevant SCIM operations. SCIM is
// driven by an external IdP, so its writes are the least supervised
// authorization changes in the system — and until this field existed the
// package could not audit at all.
//
// Nil when audit is not wired — logging is then a no-op, matching the
// EventsProvider convention above.
AuditProvider audit.Provider
}

// logAuditSync records an audit event synchronously, returning the storage
// error. A nil AuditProvider is a no-op returning nil.
//
// Callers decide what a failure means: the SCIM group paths that treat a tuple
// write as non-fatal must treat a failed audit the same way, or an accepted
// partial failure becomes a failed deprovision.
func (p *provider) logAuditSync(ctx context.Context, event audit.Event) error {
if p.AuditProvider == nil {
return nil
}
return p.AuditProvider.LogEventSync(ctx, event)
}

// maxFilterScan bounds the org-member scan a non-indexed SCIM filter performs
Expand Down
Loading
Loading