Files
temporal/common/authorization/authorizer.go
Vladyslav Simonenko cdc4633a09 Propagate principal into workflow history (#9582)
## What changed?
This PR implements the server-side plumbing for Principal Attribution -
attaching a server-computed, immutable caller identity (Principal) to
workflow history events.
* Extended the Authorizer Result struct to include a Principal struct
* Added the authorization interceptor logic to propagate Principal via
gRPC metadata headers when enabled via dynamic config
(frontend.enablePrincipalPropagation)
* The default authorizer sets principal based on sub JWT claim
* On the history service side, Principal is read from context headers
and stamped onto every history event
* Principal is set per-operation at two entry points:
UpdateWorkflowWithNew (existing workflows) and NewWorkflowWithSignal
(new workflows)
* Added Principal headers to the Nexus disallowed operation headers
blocklist to prevent leaking internal identity to external endpoints

## Why?
The existing identity field on history events is client-supplied and
spoofable. Principal Attribution provides a trusted, server-computed
identity derived from authentication context (e.g. JWT claims, mTLS
certificates), enabling
 reliable audit trails for workflow operations.

## How did you test it?
- [X] built
- [X] run locally and tested manually
- [X] covered by existing tests
- [X] added new unit test(s)

I ran a simple test with local JWT server (PR here:
https://github.com/temporalio/temporal/pull/9647). There a test client
sends a signal to a running workflow using a separate JWT identity, then
waits for the workflow to complete. Here is the workflow history:

| eventId | eventType | principal |
  |---------|-----------|-----------|
  | 1 | WORKFLOW_EXECUTION_STARTED | jwt/workflow-starter |
  | 2 | WORKFLOW_TASK_SCHEDULED | jwt/workflow-starter |
  | 3 | WORKFLOW_TASK_STARTED | jwt/workflow-starter |
  | 4 | WORKFLOW_TASK_COMPLETED | jwt/workflow-starter |
  | 5 | WORKFLOW_EXECUTION_SIGNALED | jwt/signal-sender |
  | 6 | WORKFLOW_TASK_SCHEDULED | jwt/signal-sender |
  | 7 | WORKFLOW_TASK_STARTED | jwt/workflow-starter |
  | 8 | WORKFLOW_TASK_COMPLETED | jwt/workflow-starter |
  | 9 | ACTIVITY_TASK_SCHEDULED | jwt/workflow-starter |
  | 10 | ACTIVITY_TASK_STARTED | jwt/workflow-starter |
  | 11 | ACTIVITY_TASK_COMPLETED | jwt/workflow-starter |
  | 12 | WORKFLOW_TASK_SCHEDULED | jwt/workflow-starter |
  | 13 | WORKFLOW_TASK_STARTED | jwt/workflow-starter |
  | 14 | WORKFLOW_TASK_COMPLETED | jwt/workflow-starter |
  | 15 | WORKFLOW_EXECUTION_COMPLETED | jwt/workflow-starter |

## Potential risks
- Feature-gated: Principal propagation is behind
frontend.enablePrincipalPropagation dynamic config (default off), so no
impact unless explicitly enabled.
- Nexus blocklist addition: Adding principal-type/principal-name to the
Nexus disallowed headers blocklist could reject operations that happen
to use these header names
2026-03-30 11:30:14 -07:00

79 lines
2.1 KiB
Go

//go:generate mockgen -package $GOPACKAGE -source $GOFILE -destination authorizer_mock.go
package authorization
import (
"context"
"fmt"
"strings"
commonpb "go.temporal.io/api/common/v1"
"go.temporal.io/server/common/config"
)
const (
// DecisionDeny means auth decision is deny
DecisionDeny Decision = iota + 1
// DecisionAllow means auth decision is allow
DecisionAllow
)
// @@@SNIPSTART temporal-common-authorization-authorizer-calltarget
// CallTarget is contains information for Authorizer to make a decision.
// It can be extended to include resources like WorkflowType and TaskQueue
type CallTarget struct {
// APIName must be the full API function name.
// Example: "/temporal.api.workflowservice.v1.WorkflowService/StartWorkflowExecution".
APIName string
// If a Namespace is not being targeted this be set to an empty string.
Namespace string
// The nexus endpoint name being targeted (if any).
NexusEndpointName string
// Request contains a deserialized copy of the API request object
Request any
}
// @@@SNIPEND
type (
// Result is result from authority.
Result struct {
Decision Decision
// Reason may contain a message explaining the value of the Decision field.
Reason string
// Principal is the server-computed identity of the caller. Can be nil when not computed.
Principal *commonpb.Principal
}
// Decision is enum type for auth decision
Decision int
)
// @@@SNIPSTART temporal-common-authorization-authorizer-interface
// Authorizer is an interface for implementing authorization logic
type Authorizer interface {
Authorize(ctx context.Context, caller *Claims, target *CallTarget) (Result, error)
}
// @@@SNIPEND
type hasNamespace interface {
GetNamespace() string
}
func GetAuthorizerFromConfig(config *config.Authorization) (Authorizer, error) {
switch strings.ToLower(config.Authorizer) {
case "":
return NewNoopAuthorizer(), nil
case "default":
return NewDefaultAuthorizer(), nil
}
return nil, fmt.Errorf("unknown authorizer: %s", config.Authorizer)
}
func IsNoopAuthorizer(authorizer Authorizer) bool {
_, ok := authorizer.(*noopAuthorizer)
return ok
}