feat(client-go): typed Unauthenticated vs PermissionDenied errors (#198)
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).
This commit is contained in:
@@ -146,7 +146,10 @@ type MxAccessError struct { ... }
|
|||||||
```
|
```
|
||||||
|
|
||||||
Use `errors.Is` / `errors.As` support. Preserve raw protobuf replies on command
|
Use `errors.Is` / `errors.As` support. Preserve raw protobuf replies on command
|
||||||
errors.
|
errors. `GatewayError` matches the sentinels `ErrUnauthenticated` and
|
||||||
|
`ErrPermissionDenied` through `errors.Is` by gRPC status code, so callers treat
|
||||||
|
the two authorization failures distinctly (`docs/ClientLibrariesDesign.md`)
|
||||||
|
without parsing messages.
|
||||||
|
|
||||||
## Test CLI
|
## Test CLI
|
||||||
|
|
||||||
|
|||||||
@@ -92,7 +92,13 @@ returned subscription owns cancellation and exposes `Close` for deterministic
|
|||||||
goroutine cleanup. Raw protobuf messages remain available through the
|
goroutine cleanup. Raw protobuf messages remain available through the
|
||||||
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
|
`mxgateway` package aliases and the `Raw` helper methods. Typed errors support
|
||||||
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
|
`errors.As` for `GatewayError`, `CommandError`, and `MxAccessError`; command
|
||||||
errors preserve the raw reply.
|
errors preserve the raw reply. Authorization failures are distinct:
|
||||||
|
`errors.Is(err, mxgateway.ErrUnauthenticated)` matches a rejected API key
|
||||||
|
(gRPC `Unauthenticated` — fix the key) and `errors.Is(err,
|
||||||
|
mxgateway.ErrPermissionDenied)` matches a valid key missing a scope or another
|
||||||
|
key's session (gRPC `PermissionDenied` — grant the scope). `GatewayError.Code()`
|
||||||
|
returns the raw gRPC status code. The match reads only the status code, so it
|
||||||
|
works through the credential-redaction wrapper.
|
||||||
|
|
||||||
`EnsureMxAccessSuccess` follows COM semantics: only a **negative** HRESULT is a
|
`EnsureMxAccessSuccess` follows COM semantics: only a **negative** HRESULT is a
|
||||||
failure, so positive success codes such as `S_FALSE` (1) pass. `StatusSucceeded`
|
failure, so positive success codes such as `S_FALSE` (1) pass. `StatusSucceeded`
|
||||||
|
|||||||
@@ -6,6 +6,8 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
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"
|
"google.golang.org/protobuf/proto"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -180,7 +182,20 @@ func (e *MalformedReplyError) Error() string {
|
|||||||
return fmt.Sprintf("mxgateway: %s returned a malformed reply: %s", e.Op, e.Detail)
|
return fmt.Sprintf("mxgateway: %s returned a malformed reply: %s", e.Op, e.Detail)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GatewayError wraps transport-level gRPC failures.
|
// 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 {
|
type GatewayError struct {
|
||||||
// Op names the operation that failed (for example "dial" or "invoke").
|
// Op names the operation that failed (for example "dial" or "invoke").
|
||||||
Op string
|
Op string
|
||||||
@@ -207,6 +222,32 @@ func (e *GatewayError) Unwrap() error {
|
|||||||
return e.Err
|
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
|
// CommandError reports a non-OK gateway protocol status and keeps the raw
|
||||||
// command reply when one exists.
|
// command reply when one exists.
|
||||||
type CommandError struct {
|
type CommandError struct {
|
||||||
|
|||||||
@@ -0,0 +1,85 @@
|
|||||||
|
package mxgateway
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
pb "gitea.dohertylan.com/dohertj2/mxaccessgw/clients/go/internal/generated"
|
||||||
|
"google.golang.org/grpc/codes"
|
||||||
|
"google.golang.org/grpc/status"
|
||||||
|
)
|
||||||
|
|
||||||
|
// #198 — the gateway answers a bad key with Unauthenticated and a missing scope
|
||||||
|
// with PermissionDenied; callers must be able to tell them apart with errors.Is.
|
||||||
|
|
||||||
|
func TestGatewayErrorMatchesAuthSentinelsByStatusCode(t *testing.T) {
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
err error
|
||||||
|
unauthenticated bool
|
||||||
|
permissionDenied bool
|
||||||
|
}{
|
||||||
|
{"unauthenticated", status.Error(codes.Unauthenticated, "bad key"), true, false},
|
||||||
|
{"permission denied", status.Error(codes.PermissionDenied, "scope required"), false, true},
|
||||||
|
{"unavailable", status.Error(codes.Unavailable, "down"), false, false},
|
||||||
|
{"non-grpc", errors.New("plain"), false, false},
|
||||||
|
}
|
||||||
|
for _, tc := range cases {
|
||||||
|
t.Run(tc.name, func(t *testing.T) {
|
||||||
|
err := fmt.Errorf("outer: %w", &GatewayError{Op: "invoke", Err: tc.err})
|
||||||
|
if got := errors.Is(err, ErrUnauthenticated); got != tc.unauthenticated {
|
||||||
|
t.Fatalf("errors.Is(ErrUnauthenticated) = %v, want %v", got, tc.unauthenticated)
|
||||||
|
}
|
||||||
|
if got := errors.Is(err, ErrPermissionDenied); got != tc.permissionDenied {
|
||||||
|
t.Fatalf("errors.Is(ErrPermissionDenied) = %v, want %v", got, tc.permissionDenied)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGatewayErrorCode(t *testing.T) {
|
||||||
|
if got := (&GatewayError{Err: status.Error(codes.PermissionDenied, "x")}).Code(); got != codes.PermissionDenied {
|
||||||
|
t.Fatalf("Code() = %v", got)
|
||||||
|
}
|
||||||
|
if got := (*GatewayError)(nil).Code(); got != codes.OK {
|
||||||
|
t.Fatalf("nil Code() = %v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestAuthSentinelsSurviveTheWireAndRedaction(t *testing.T) {
|
||||||
|
for _, tc := range []struct {
|
||||||
|
code codes.Code
|
||||||
|
sentinel error
|
||||||
|
other error
|
||||||
|
}{
|
||||||
|
{codes.Unauthenticated, ErrUnauthenticated, ErrPermissionDenied},
|
||||||
|
{codes.PermissionDenied, ErrPermissionDenied, ErrUnauthenticated},
|
||||||
|
} {
|
||||||
|
t.Run(tc.code.String(), func(t *testing.T) {
|
||||||
|
fake := &fakeGatewayWithAlarms{acknowledgeError: status.Error(tc.code, "denied pw-secret")}
|
||||||
|
client, cleanup := newBufconnClientWithAlarms(t, fake)
|
||||||
|
defer cleanup()
|
||||||
|
|
||||||
|
_, err := client.AcknowledgeAlarm(context.Background(), &pb.AcknowledgeAlarmRequest{
|
||||||
|
AlarmFullReference: "Tank01.Level.HiHi",
|
||||||
|
OperatorUser: "alice",
|
||||||
|
OperatorPassword: "pw-secret",
|
||||||
|
})
|
||||||
|
if err == nil {
|
||||||
|
t.Fatal("AcknowledgeAlarm() returned no error")
|
||||||
|
}
|
||||||
|
if !errors.Is(err, tc.sentinel) {
|
||||||
|
t.Fatalf("errors.Is(%v) = false for %v", tc.sentinel, err)
|
||||||
|
}
|
||||||
|
if errors.Is(err, tc.other) {
|
||||||
|
t.Fatalf("errors.Is(%v) = true for %v", tc.other, err)
|
||||||
|
}
|
||||||
|
if strings.Contains(err.Error(), "pw-secret") {
|
||||||
|
t.Fatalf("error leaked the operator password: %v", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user