9351ec1d2c
Every RPC failure surfaced as a GatewayError with no way to tell a rejected key from a missing scope, though docs/ClientLibrariesDesign.md requires the two be treated distinctly. GatewayError now matches the new sentinels ErrUnauthenticated and ErrPermissionDenied via errors.Is (by gRPC status code only, so it works through the secret-redaction wrapper) and exposes Code(). Tests: errors_auth_test.go — sentinel matching per code, Code(), and a bufconn AcknowledgeAlarm round trip proving the match survives the wire and the password stays redacted (both behavioural tests fail without GatewayError.Is).
350 lines
12 KiB
Go
350 lines
12 KiB
Go
package mxgateway
|
|
|
|
import (
|
|
"errors"
|
|
"fmt"
|
|
"strings"
|
|
|
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
|
"google.golang.org/grpc/codes"
|
|
"google.golang.org/grpc/status"
|
|
"google.golang.org/protobuf/proto"
|
|
)
|
|
|
|
// redactedSecretMarker is the placeholder substituted for credential material in
|
|
// surfaced error text. It matches the marker used by RedactAPIKey so the client
|
|
// presents one consistent redaction shape everywhere secrets could otherwise
|
|
// leak.
|
|
const redactedSecretMarker = "<redacted>"
|
|
|
|
// secretRedactingError wraps a typed error so any occurrence of a known
|
|
// credential in the underlying message is replaced with redactedSecretMarker in
|
|
// the surfaced text. Unwrap still exposes the wrapped error, so errors.As /
|
|
// errors.Is continue to reach the underlying MxAccessError, CommandError, or
|
|
// GatewayError. This is the seam that keeps AuthenticateUser credentials and
|
|
// WriteSecured/WriteSecured2 payload strings out of any error a caller might log,
|
|
// even if a gateway diagnostic message were to echo them back.
|
|
type secretRedactingError struct {
|
|
err error
|
|
secrets []string
|
|
}
|
|
|
|
// Error returns the wrapped error's message with every non-empty secret redacted.
|
|
func (e *secretRedactingError) Error() string {
|
|
if e == nil || e.err == nil {
|
|
return ""
|
|
}
|
|
message := e.err.Error()
|
|
for _, secret := range e.secrets {
|
|
if secret != "" {
|
|
message = strings.ReplaceAll(message, secret, redactedSecretMarker)
|
|
}
|
|
}
|
|
return message
|
|
}
|
|
|
|
// Unwrap returns the wrapped error so typed-error inspection still works through
|
|
// the redaction wrapper.
|
|
func (e *secretRedactingError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.err
|
|
}
|
|
|
|
// scrubReplyStrings returns a clone of reply with every non-empty secret replaced
|
|
// by redactedSecretMarker in the free-text fields a gateway diagnostic could echo a
|
|
// credential into: ProtocolStatus.Message, DiagnosticMessage, and each
|
|
// Statuses[].DiagnosticText. It clones with proto.Clone so the caller's original
|
|
// reply is never mutated. A nil reply, or an empty/whitespace-only secret set, is a
|
|
// no-op (nil in, nil out; a clone otherwise).
|
|
func scrubReplyStrings(reply *pb.MxCommandReply, secrets []string) *pb.MxCommandReply {
|
|
if reply == nil {
|
|
return nil
|
|
}
|
|
clone, ok := proto.Clone(reply).(*pb.MxCommandReply)
|
|
if !ok {
|
|
return reply
|
|
}
|
|
for _, secret := range secrets {
|
|
if secret == "" {
|
|
continue
|
|
}
|
|
if clone.GetProtocolStatus() != nil {
|
|
clone.ProtocolStatus.Message = strings.ReplaceAll(clone.GetProtocolStatus().GetMessage(), secret, redactedSecretMarker)
|
|
}
|
|
clone.DiagnosticMessage = strings.ReplaceAll(clone.GetDiagnosticMessage(), secret, redactedSecretMarker)
|
|
for _, status := range clone.GetStatuses() {
|
|
status.DiagnosticText = strings.ReplaceAll(status.GetDiagnosticText(), secret, redactedSecretMarker)
|
|
}
|
|
}
|
|
return clone
|
|
}
|
|
|
|
// scrubProtocolStatusMessage returns a clone of status with every non-empty secret
|
|
// redacted from its Message, leaving the original untouched.
|
|
func scrubProtocolStatusMessage(status *ProtocolStatus, secrets []string) *ProtocolStatus {
|
|
if status == nil {
|
|
return nil
|
|
}
|
|
clone, ok := proto.Clone(status).(*ProtocolStatus)
|
|
if !ok {
|
|
return status
|
|
}
|
|
for _, secret := range secrets {
|
|
if secret != "" {
|
|
clone.Message = strings.ReplaceAll(clone.GetMessage(), secret, redactedSecretMarker)
|
|
}
|
|
}
|
|
return clone
|
|
}
|
|
|
|
// redactSecrets scrubs a non-empty secret set from the error it surfaces. When the
|
|
// wrapped error is a typed *MxAccessError or *CommandError it is rebuilt carrying
|
|
// scrubbed clones of its reply and protocol status, so a caller logging the typed
|
|
// error's structured fields cannot reintroduce the credential the rendered message
|
|
// hides. The rebuilt (or original, for other error types) value is then wrapped in
|
|
// secretRedactingError as a belt-and-suspenders scrub of any remaining rendered
|
|
// text. errors.As / errors.Is still reach the typed error through the wrapper. It
|
|
// returns nil unchanged and skips all work when no non-empty secret is supplied, so
|
|
// non-secret-bearing calls keep their original error verbatim.
|
|
func redactSecrets(err error, secrets ...string) error {
|
|
if err == nil {
|
|
return nil
|
|
}
|
|
hasSecret := false
|
|
for _, secret := range secrets {
|
|
if secret != "" {
|
|
hasSecret = true
|
|
break
|
|
}
|
|
}
|
|
if !hasSecret {
|
|
return err
|
|
}
|
|
|
|
rebuilt := rebuildScrubbedError(err, secrets)
|
|
return &secretRedactingError{err: rebuilt, secrets: secrets}
|
|
}
|
|
|
|
// rebuildScrubbedError rebuilds the typed error carrying scrubbed clones of any
|
|
// command reply / protocol status it holds, so credential text never survives in the
|
|
// error's structured fields. Non-reply-bearing error types are returned unchanged.
|
|
func rebuildScrubbedError(err error, secrets []string) error {
|
|
switch typed := err.(type) {
|
|
case *MxAccessError:
|
|
return &MxAccessError{
|
|
Command: scrubCommandError(typed.Command, secrets),
|
|
Reply: scrubReplyStrings(typed.Reply, secrets),
|
|
}
|
|
case *CommandError:
|
|
return scrubCommandError(typed, secrets)
|
|
default:
|
|
return err
|
|
}
|
|
}
|
|
|
|
// scrubCommandError rebuilds a CommandError with a scrubbed Status and Reply.
|
|
func scrubCommandError(cmd *CommandError, secrets []string) *CommandError {
|
|
if cmd == nil {
|
|
return nil
|
|
}
|
|
return &CommandError{
|
|
Op: cmd.Op,
|
|
Status: scrubProtocolStatusMessage(cmd.Status, secrets),
|
|
Reply: scrubReplyStrings(cmd.Reply, secrets),
|
|
}
|
|
}
|
|
|
|
// ErrSlowConsumer is the terminal error sent on the Events/EventsAfter
|
|
// (cancel-when-full) path when the buffered results channel overflows because
|
|
// the consumer fell behind. It is delivered as the final EventResult.Err before
|
|
// the channel closes, so overflow is always observable rather than silently
|
|
// dropping events. Match it with errors.Is.
|
|
var ErrSlowConsumer = errors.New("mxgateway: event consumer fell behind; stream terminated")
|
|
|
|
// MalformedReplyError reports an OK command reply that carried neither the
|
|
// typed payload the operation expected nor a usable int32 return_value, so the
|
|
// client cannot produce a result. It gives every affected helper one uniform,
|
|
// inspectable failure instead of silently returning a zero value.
|
|
type MalformedReplyError struct {
|
|
// Op names the operation whose reply was malformed.
|
|
Op string
|
|
// Detail explains what the reply was missing.
|
|
Detail string
|
|
}
|
|
|
|
// Error returns the formatted malformed-reply message.
|
|
func (e *MalformedReplyError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
return fmt.Sprintf("mxgateway: %s returned a malformed reply: %s", e.Op, e.Detail)
|
|
}
|
|
|
|
// ErrUnauthenticated matches, via errors.Is, a GatewayError whose gRPC status is
|
|
// Unauthenticated: the gateway did not accept the API key (missing, malformed,
|
|
// unknown, or revoked). Retrying with the same key will not help.
|
|
var ErrUnauthenticated = errors.New("mxgateway: unauthenticated")
|
|
|
|
// ErrPermissionDenied matches, via errors.Is, a GatewayError whose gRPC status
|
|
// is PermissionDenied: the API key is valid but lacks the scope the RPC needs, or
|
|
// the session belongs to another key. It is distinct from ErrUnauthenticated so a
|
|
// caller can tell "fix the key" from "grant the key a scope".
|
|
var ErrPermissionDenied = errors.New("mxgateway: permission denied")
|
|
|
|
// GatewayError wraps transport-level gRPC failures. errors.Is(err,
|
|
// ErrUnauthenticated) and errors.Is(err, ErrPermissionDenied) classify the two
|
|
// authorization failures; Code returns the raw gRPC status code.
|
|
type GatewayError struct {
|
|
// Op names the operation that failed (for example "dial" or "invoke").
|
|
Op string
|
|
// Err is the underlying gRPC or transport error.
|
|
Err error
|
|
}
|
|
|
|
// Error returns the formatted gateway error message.
|
|
func (e *GatewayError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
if e.Op == "" {
|
|
return fmt.Sprintf("mxgateway: %v", e.Err)
|
|
}
|
|
return fmt.Sprintf("mxgateway: %s failed: %v", e.Op, e.Err)
|
|
}
|
|
|
|
// Unwrap returns the wrapped transport error.
|
|
func (e *GatewayError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.Err
|
|
}
|
|
|
|
// Code returns the gRPC status code of the wrapped error, codes.Unknown for a
|
|
// non-gRPC error, and codes.OK for a nil receiver or nil wrapped error.
|
|
func (e *GatewayError) Code() codes.Code {
|
|
if e == nil || e.Err == nil {
|
|
return codes.OK
|
|
}
|
|
return status.Code(e.Err)
|
|
}
|
|
|
|
// Is reports whether target is the sentinel matching this error's gRPC status
|
|
// code (ErrUnauthenticated or ErrPermissionDenied). The check reads only the
|
|
// status code, never the message, so credential redaction is unaffected.
|
|
func (e *GatewayError) Is(target error) bool {
|
|
if e == nil {
|
|
return false
|
|
}
|
|
switch target {
|
|
case ErrUnauthenticated:
|
|
return e.Code() == codes.Unauthenticated
|
|
case ErrPermissionDenied:
|
|
return e.Code() == codes.PermissionDenied
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
// CommandError reports a non-OK gateway protocol status and keeps the raw
|
|
// command reply when one exists.
|
|
type CommandError struct {
|
|
// Op names the gateway operation that produced the non-OK status.
|
|
Op string
|
|
// Status carries the gateway-reported protocol status.
|
|
Status *ProtocolStatus
|
|
// Reply is the raw command reply, when one was returned alongside the status.
|
|
Reply *MxCommandReply
|
|
}
|
|
|
|
// Error returns the formatted command error message.
|
|
func (e *CommandError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
status := e.Status
|
|
if status == nil {
|
|
return fmt.Sprintf("mxgateway: %s failed with missing protocol status", e.Op)
|
|
}
|
|
if status.GetMessage() == "" {
|
|
return fmt.Sprintf("mxgateway: %s failed with protocol status %s", e.Op, status.GetCode())
|
|
}
|
|
return fmt.Sprintf("mxgateway: %s failed with protocol status %s: %s", e.Op, status.GetCode(), status.GetMessage())
|
|
}
|
|
|
|
// MxAccessError reports HRESULT or MXSTATUS_PROXY failures returned by MXAccess.
|
|
type MxAccessError struct {
|
|
// Command is the wrapped CommandError when the protocol status carried one.
|
|
Command *CommandError
|
|
// Reply is the raw MXAccess command reply that surfaced the failure.
|
|
Reply *MxCommandReply
|
|
}
|
|
|
|
// Error returns the formatted MXAccess error message.
|
|
func (e *MxAccessError) Error() string {
|
|
if e == nil {
|
|
return ""
|
|
}
|
|
if e.Command != nil && e.Command.Status != nil && e.Command.Status.GetMessage() != "" {
|
|
return e.Command.Error()
|
|
}
|
|
if e.Reply != nil && e.Reply.GetDiagnosticMessage() != "" {
|
|
return fmt.Sprintf("mxgateway: MXAccess command %s failed: %s", e.Reply.GetKind(), e.Reply.GetDiagnosticMessage())
|
|
}
|
|
if e.Reply != nil && e.Reply.Hresult != nil {
|
|
return fmt.Sprintf("mxgateway: MXAccess command %s failed with HRESULT 0x%08X", e.Reply.GetKind(), uint32(e.Reply.GetHresult()))
|
|
}
|
|
return "mxgateway: MXAccess command failed"
|
|
}
|
|
|
|
// Unwrap returns the wrapped CommandError, when one is present.
|
|
func (e *MxAccessError) Unwrap() error {
|
|
if e == nil {
|
|
return nil
|
|
}
|
|
return e.Command
|
|
}
|
|
|
|
// EnsureProtocolSuccess returns a typed CommandError when status is non-OK.
|
|
func EnsureProtocolSuccess(op string, status *ProtocolStatus, reply *MxCommandReply) error {
|
|
if status == nil || status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_OK {
|
|
return nil
|
|
}
|
|
|
|
commandError := &CommandError{
|
|
Op: op,
|
|
Status: status,
|
|
Reply: reply,
|
|
}
|
|
if status.GetCode() == pb.ProtocolStatusCode_PROTOCOL_STATUS_CODE_MXACCESS_FAILURE {
|
|
return &MxAccessError{
|
|
Command: commandError,
|
|
Reply: reply,
|
|
}
|
|
}
|
|
return commandError
|
|
}
|
|
|
|
// EnsureMxAccessSuccess returns a typed MxAccessError for failing HRESULTs or
|
|
// MXSTATUS_PROXY entries.
|
|
//
|
|
// Following COM semantics, only a negative HRESULT is a failure — positive
|
|
// success codes such as S_FALSE (1) pass. Status entries are judged by
|
|
// StatusSucceeded, which branches on the authoritative category.
|
|
func EnsureMxAccessSuccess(op string, reply *MxCommandReply) error {
|
|
if reply == nil {
|
|
return nil
|
|
}
|
|
if reply.Hresult != nil && reply.GetHresult() < 0 {
|
|
return &MxAccessError{Reply: reply}
|
|
}
|
|
for _, status := range reply.GetStatuses() {
|
|
if !StatusSucceeded(status) {
|
|
return &MxAccessError{Reply: reply}
|
|
}
|
|
}
|
|
return nil
|
|
}
|