Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
a726ca3
feat(audit): add global config and context enrichment
jakedoublev May 1, 2026
81f19d5
refactor(audit): drop legacy claim shorthand
jakedoublev May 1, 2026
903912a
fix(audit): fail fast on invalid claim mappings
jakedoublev May 1, 2026
d9595b6
fix(audit): allow top-level claim destinations
jakedoublev May 1, 2026
e2cb8aa
chore(audit): simplify dotnotation import alias
jakedoublev May 1, 2026
ffcc5f2
test(audit): cover more claim destination paths
jakedoublev May 1, 2026
35103dc
fix(auth): rehydrate IPC auth context
jakedoublev May 1, 2026
18d472f
docs(auth): align IPC auth comments
jakedoublev May 1, 2026
d42fb00
test(audit): simplify logger test helper
jakedoublev May 1, 2026
b3687d8
refactor(audit): export validation errors
jakedoublev May 1, 2026
138f9f4
chore(config): remove empty kas stanzas
jakedoublev May 1, 2026
560ce14
fix(audit): tighten JWT claim transport
jakedoublev May 1, 2026
ca170c2
fix(audit): address review feedback
jakedoublev May 1, 2026
ba0a309
test(server): use standard test logger helper
jakedoublev May 1, 2026
0811a3c
refactor(audit): derive payload shape from tags
jakedoublev May 1, 2026
6e2e191
fix(audit): return CodeUnauthenticated for IPC auth failures and reje…
jakedoublev May 4, 2026
21dff6b
fix(audit): reject overlapping JWT claim mapping paths at config time
jakedoublev May 4, 2026
f143cc2
refactor(audit): remove ok+err return from parseAuditFieldOptions
jakedoublev May 4, 2026
e924b91
fix(audit): validate full dot-path syntax before extensible fallback
jakedoublev May 4, 2026
520fbca
fix(audit): reject duplicate destination paths in claim mappings
jakedoublev May 4, 2026
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
10 changes: 9 additions & 1 deletion opentdf-core-mode.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ logger:
level: debug
type: text
output: stderr
audit:
# Optional JWT claim mappings for audit enrichment.
# Paths use dot notation over the emitted audit log shape.
# jwt_claim_mappings:
# - claim: sub
# path: eventMetaData.requester.sub
# - claim: realm_access.roles
# path: eventMetaData.requester.roles
# DB and Server configurations are defaulted for local development
# db:
# host: localhost
Expand Down Expand Up @@ -57,4 +65,4 @@ server:
maxage: 3600
grpc:
reflectionEnabled: true # Default is false
port: 8383
port: 8383
8 changes: 8 additions & 0 deletions opentdf-dev.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ logger:
level: debug
type: text
output: stderr
audit:
# Optional JWT claim mappings for audit enrichment.
# Paths use dot notation over the emitted audit log shape.
# jwt_claim_mappings:
# - claim: sub
# path: eventMetaData.requester.sub
# - claim: realm_access.roles
# path: eventMetaData.requester.roles
# DB and Server configurations are defaulted for local development
# db:
# host: localhost
Expand Down
8 changes: 8 additions & 0 deletions opentdf-ers-mode.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ logger:
level: debug
type: text
output: stderr
audit:
# Optional JWT claim mappings for audit enrichment.
# Paths use dot notation over the emitted audit log shape.
# jwt_claim_mappings:
# - claim: sub
# path: eventMetaData.requester.sub
# - claim: realm_access.roles
# path: eventMetaData.requester.roles
services:
entityresolution:
log_level: info
Expand Down
10 changes: 9 additions & 1 deletion opentdf-ers-test.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,14 @@ mode: standalone
logger:
level: info
type: json
audit:
# Optional JWT claim mappings for audit enrichment.
# Paths use dot notation over the emitted audit log shape.
# jwt_claim_mappings:
# - claim: sub
# path: eventMetaData.requester.sub
# - claim: realm_access.roles
# path: eventMetaData.requester.roles

crypto:
type: standard
Expand Down Expand Up @@ -256,4 +264,4 @@ development:
# Allow insecure connections for testing
allow_insecure: true
# Disable some validations for easier testing
strict_validation: false
strict_validation: false
8 changes: 8 additions & 0 deletions opentdf-example.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,14 @@ logger:
level: debug
type: text
output: stdout
audit:
# Optional JWT claim mappings for audit enrichment.
# Paths use dot notation over the emitted audit log shape.
# jwt_claim_mappings:
# - claim: sub
# path: eventMetaData.requester.sub
# - claim: realm_access.roles
# path: eventMetaData.requester.roles
# DB and Server configurations are defaulted for local development
db:
host: opentdfdb
Expand Down
8 changes: 8 additions & 0 deletions opentdf-kas-mode.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,14 @@ logger:
level: debug
type: text
output: stderr
audit:
# Optional JWT claim mappings for audit enrichment.
# Paths use dot notation over the emitted audit log shape.
# jwt_claim_mappings:
# - claim: sub
# path: eventMetaData.requester.sub
# - claim: realm_access.roles
# path: eventMetaData.requester.roles
security:
unsafe:
# Increase only when diagnosing clock drift issues; default is 1m
Expand Down
52 changes: 49 additions & 3 deletions service/internal/auth/authn.go
Original file line number Diff line number Diff line change
Expand Up @@ -438,10 +438,52 @@ func IPCMetadataClientInterceptor(log *logger.Logger) connect.UnaryInterceptorFu
})
}

// rehydrateIPCAuthContext reconstructs pkg/auth context from propagated IPC metadata.
// It only transports an already-authenticated token across an internal hop; it does not
// validate the token again. Network-facing requests must continue to rely on the
// ConnectUnaryServerInterceptor auth middleware for validation.
func rehydrateIPCAuthContext(ctx context.Context, l *logger.Logger) (context.Context, error) {
if ctxAuth.GetAccessTokenFromContext(ctx, l) != nil && ctxAuth.GetRawAccessTokenFromContext(ctx, l) != "" {
return ctx, nil
}

md, ok := metadata.FromIncomingContext(ctx)
if !ok {
return ctx, nil
}

rawToken := rawAccessTokenFromIncomingMetadata(md)
if rawToken == "" {
return ctx, nil
}

parsed, err := jwt.Parse([]byte(rawToken), jwt.WithVerify(false), jwt.WithValidate(false))
if err != nil {
if l != nil {
l.ErrorContext(ctx, "failed to rehydrate IPC access token from metadata", slog.Any("error", err))
}
return ctx, fmt.Errorf("rehydrate IPC access token from metadata: %w", err)
}

return ctxAuth.ContextWithAuthNInfo(ctx, ctxAuth.GetJWKFromContext(ctx, l), parsed, rawToken), nil
Comment thread
jakedoublev marked this conversation as resolved.
}

func rawAccessTokenFromIncomingMetadata(md metadata.MD) string {
if accessTokens := md.Get(ctxAuth.AccessTokenKey); len(accessTokens) > 0 && accessTokens[0] != "" {
return accessTokens[0]
}

authHeaders := md.Get("authorization")
if len(authHeaders) == 0 || authHeaders[0] == "" {
return ""
}
return strings.TrimPrefix(strings.TrimPrefix(authHeaders[0], "Bearer "), "DPoP ")
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.

// IPCUnaryServerInterceptor is a grpc interceptor that:
// 1. verifies the token in the metadata
// 2. reauthorizes the token if the route is in the list
// 3. translates known IPC Connect request headers back to context metadata for downstream consumers
// 1. translates known IPC Connect request headers back to incoming metadata
// 2. reauthorizes routes that are configured for IPC reauth
// 3. rehydrates auth context from propagated incoming metadata without revalidating it
func (a Authentication) IPCUnaryServerInterceptor() connect.UnaryInterceptorFunc {
interceptor := func(next connect.UnaryFunc) connect.UnaryFunc {
return connect.UnaryFunc(func(
Expand All @@ -467,6 +509,10 @@ func (a Authentication) IPCUnaryServerInterceptor() connect.UnaryInterceptorFunc
if err != nil {
return nil, err
}
nextCtx, err = rehydrateIPCAuthContext(nextCtx, a.logger)
if err != nil {
return nil, connect.NewError(connect.CodeUnauthenticated, errors.New("invalid IPC authentication context"))
}
Comment thread
coderabbitai[bot] marked this conversation as resolved.
return next(nextCtx, req)
})
}
Expand Down
76 changes: 69 additions & 7 deletions service/internal/auth/authn_ipc_metadata_interceptor_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package auth

import (
"context"
"errors"
"testing"

"connectrpc.com/connect"
Expand Down Expand Up @@ -184,6 +185,10 @@ func (m *mockAnyRequest) Any() any {

func TestIPCUnaryServerInterceptor(t *testing.T) {
testLogger := logger.CreateTestLogger()
validJWT, err := jwt.NewBuilder().Subject("ipc-user").Build()
require.NoError(t, err)
validRawToken, err := jwt.Sign(validJWT, jwt.WithInsecureNoSignature())
require.NoError(t, err)

// Create a minimal authentication instance
auth := &Authentication{
Expand All @@ -201,7 +206,7 @@ func TestIPCUnaryServerInterceptor(t *testing.T) {
setupRequest: func() connect.AnyRequest {
req := connect.NewRequest(&kas.PublicKeyRequest{})
req.Header().Set(canonicalIPCHeaderClientID, "test-client-from-header")
req.Header().Set(canonicalIPCHeaderAccessToken, "test-token-from-header")
req.Header().Set(canonicalIPCHeaderAccessToken, string(validRawToken))
return &mockAnyRequest{
Request: req,
isClient: false,
Expand All @@ -225,7 +230,7 @@ func TestIPCUnaryServerInterceptor(t *testing.T) {
setupRequest: func() connect.AnyRequest {
req := connect.NewRequest(&kas.PublicKeyRequest{})
req.Header().Set(canonicalIPCHeaderClientID, "merged-client-id")
req.Header().Set(canonicalIPCHeaderAccessToken, "merged-token")
req.Header().Set(canonicalIPCHeaderAccessToken, string(validRawToken))
return &mockAnyRequest{
Request: req,
isClient: false,
Expand All @@ -251,8 +256,13 @@ func TestIPCUnaryServerInterceptor(t *testing.T) {
for _, key := range tt.expectedIncomingMDKeys {
assert.NotEmpty(t, md.Get(key), "metadata key %s should exist", key)
}
retrievedJWT := ctxAuth.GetAccessTokenFromContext(postInterceptorCtx, testLogger)
require.NotNil(t, retrievedJWT)
assert.Equal(t, "ipc-user", retrievedJWT.Subject())
assert.Equal(t, string(validRawToken), ctxAuth.GetRawAccessTokenFromContext(postInterceptorCtx, testLogger))
} else {
assert.Zero(t, md.Len())
assert.Nil(t, ctxAuth.GetAccessTokenFromContext(postInterceptorCtx, testLogger))
}
return connect.NewResponse(&kas.PublicKeyResponse{}), nil
}
Expand All @@ -267,19 +277,22 @@ func TestIPCUnaryServerInterceptor(t *testing.T) {

func TestIPCUnaryServerInterceptor_Integration(t *testing.T) {
testLogger := logger.CreateTestLogger()
mockJWT, err := jwt.NewBuilder().Subject("integration-user").Build()
require.NoError(t, err)
rawToken, err := jwt.Sign(mockJWT, jwt.WithInsecureNoSignature())
require.NoError(t, err)

auth := &Authentication{
logger: testLogger,
ipcReauthRoutes: []string{},
}

t.Run("clientID and access token from headers available in context metadata", func(t *testing.T) {
t.Run("clientID and access token from headers available in context metadata and auth context", func(t *testing.T) {
clientID := "integration-client-id"
accessToken := "integration-access-token"

req := connect.NewRequest(&kas.PublicKeyRequest{})
req.Header().Set(canonicalIPCHeaderClientID, clientID)
req.Header().Set(canonicalIPCHeaderAccessToken, accessToken)
req.Header().Set(canonicalIPCHeaderAccessToken, string(rawToken))

wrappedReq := &mockAnyRequest{
Request: req,
Expand All @@ -290,7 +303,7 @@ func TestIPCUnaryServerInterceptor_Integration(t *testing.T) {

ctx := t.Context()

var receivedClientID, receivedAccessToken string
var receivedClientID, receivedAccessToken, receivedSubject string
mockNext := func(ctx context.Context, _ connect.AnyRequest) (connect.AnyResponse, error) {
md, ok := metadata.FromIncomingContext(ctx)
require.True(t, ok)
Expand All @@ -302,6 +315,9 @@ func TestIPCUnaryServerInterceptor_Integration(t *testing.T) {
if len(accessTokens) > 0 {
receivedAccessToken = accessTokens[0]
}
retrievedJWT := ctxAuth.GetAccessTokenFromContext(ctx, testLogger)
require.NotNil(t, retrievedJWT)
receivedSubject = retrievedJWT.Subject()
return connect.NewResponse(&kas.PublicKeyResponse{}), nil
}

Expand All @@ -310,6 +326,52 @@ func TestIPCUnaryServerInterceptor_Integration(t *testing.T) {

require.NoError(t, err)
assert.Equal(t, clientID, receivedClientID)
assert.Equal(t, accessToken, receivedAccessToken)
assert.Equal(t, string(rawToken), receivedAccessToken)
assert.Equal(t, "integration-user", receivedSubject)
})

t.Run("invalid access token header fails rehydration", func(t *testing.T) {
req := connect.NewRequest(&kas.PublicKeyRequest{})
req.Header().Set(canonicalIPCHeaderAccessToken, "not-a-jwt")

wrappedReq := &mockAnyRequest{
Request: req,
isClient: false,
}

interceptor := auth.IPCUnaryServerInterceptor()
interceptorFunc := interceptor(func(context.Context, connect.AnyRequest) (connect.AnyResponse, error) {
t.Fatal("next handler should not be called on invalid token")
return nil, errors.New("unreachable")
})

_, err := interceptorFunc(t.Context(), wrappedReq)
require.Error(t, err)

var connectErr *connect.Error
require.ErrorAs(t, err, &connectErr)
assert.Equal(t, connect.CodeUnauthenticated, connectErr.Code())
})
}

func TestRehydrateIPCAuthContextPreservesExistingJWK(t *testing.T) {
testLogger := logger.CreateTestLogger()
mockJWT, err := jwt.NewBuilder().Subject("rehydrated-user").Build()
require.NoError(t, err)
rawToken, err := jwt.Sign(mockJWT, jwt.WithInsecureNoSignature())
require.NoError(t, err)
mockJWK, err := jwk.FromRaw([]byte("existing-jwk"))
require.NoError(t, err)

ctx := metadata.NewIncomingContext(t.Context(), metadata.Pairs(ctxAuth.AccessTokenKey, string(rawToken)))
ctx = ctxAuth.ContextWithAuthNInfo(ctx, mockJWK, nil, "")

rehydratedCtx, err := rehydrateIPCAuthContext(ctx, testLogger)
require.NoError(t, err)
require.Same(t, mockJWK, ctxAuth.GetJWKFromContext(rehydratedCtx, testLogger))

retrievedJWT := ctxAuth.GetAccessTokenFromContext(rehydratedCtx, testLogger)
require.NotNil(t, retrievedJWT)
assert.Equal(t, "rehydrated-user", retrievedJWT.Subject())
assert.Equal(t, string(rawToken), ctxAuth.GetRawAccessTokenFromContext(rehydratedCtx, testLogger))
}
Loading
Loading