Skip to content

Commit 24980b5

Browse files
authored
Merge pull request #116 from LerianStudio/fix/grpc-product-arg-clarity
refactor(grpc): name product arg consistently in policy interceptors
2 parents 56b1409 + bce4655 commit 24980b5

3 files changed

Lines changed: 110 additions & 12 deletions

File tree

auth/middleware/middleware.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -317,6 +317,16 @@ func (auth *AuthClient) checkAuthorization(ctx context.Context, product, resourc
317317
}
318318

319319
userID, _ := claims["sub"].(string)
320+
if userID == "" {
321+
logErrorf(ctx, auth.Logger, "Missing sub claim in token")
322+
323+
err := errors.New("missing sub claim in token")
324+
325+
tracing.HandleSpanError(span, "Missing sub claim in token", err)
326+
327+
return false, http.StatusUnauthorized, err
328+
}
329+
320330
sub = fmt.Sprintf("%s/%s", owner, userID)
321331
}
322332

auth/middleware/middlewareGRPC.go

Lines changed: 19 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,13 @@ type Policy struct {
2626
Action string
2727
}
2828

29-
// PolicyConfig binds gRPC methods to Policies and optional subject resolution.
29+
// PolicyConfig binds gRPC methods to Policies and optional product resolution.
3030
// - MethodPolicies keyed by info.FullMethod ("/pkg.Service/Method").
3131
// - DefaultPolicy used when a method mapping is absent.
32-
// - SubResolver derives the subject base (e.g., editor scope). Return "" when not applicable.
32+
// - SubResolver derives the product identifier (e.g., "midaz") that is forwarded
33+
// to checkAuthorization as its product argument. For M2M tokens it becomes the
34+
// subject "admin/<product>-editor-role"; for normal-user tokens it is forwarded
35+
// for product isolation. Return "" when not applicable.
3336
type PolicyConfig struct {
3437
MethodPolicies map[string]Policy
3538
DefaultPolicy *Policy
@@ -39,11 +42,11 @@ type PolicyConfig struct {
3942
// NewGRPCAuthUnaryPolicy authorizes unary RPCs via per-method Policy.
4043
// Behavior:
4144
// - Resolves the Policy by info.FullMethod; falls back to DefaultPolicy when provided.
42-
// - Optionally derives the subject using cfg.SubResolver (e.g., editor roles). Empty subject is valid.
45+
// - Optionally derives the product using cfg.SubResolver (e.g., "midaz"). Empty product is valid.
4346
// - Rejects missing tokens with codes.Unauthenticated; misconfiguration returns codes.Internal.
4447
// Telemetry:
4548
// - Sets app.request.request_id.
46-
// - Sets app.request.payload with {sub, resource, action} per standard.
49+
// - Sets app.request.payload with {product, resource, action} per standard.
4750
func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServerInterceptor {
4851
return func(ctx context.Context, req any, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (any, error) {
4952
if auth == nil || !auth.Enabled || auth.Address == "" {
@@ -69,29 +72,31 @@ func NewGRPCAuthUnaryPolicy(auth *AuthClient, cfg PolicyConfig) grpc.UnaryServer
6972
return nil, status.Error(codes.Internal, "internal configuration error")
7073
}
7174

72-
var sub string
75+
// product is the resolved product identifier passed as checkAuthorization's
76+
// product argument (M2M subject base and normal-user isolation key).
77+
var product string
7378

7479
if cfg.SubResolver != nil {
7580
var err error
7681

77-
sub, err = cfg.SubResolver(ctx, info.FullMethod, req)
82+
product, err = cfg.SubResolver(ctx, info.FullMethod, req)
7883
if err != nil {
79-
tracing.HandleSpanError(span, "failed to resolve subject", err)
84+
tracing.HandleSpanError(span, "failed to resolve product", err)
8085

8186
return nil, status.Error(codes.Internal, "internal configuration error")
8287
}
8388
}
8489

8590
payload := map[string]string{
86-
"sub": sub,
91+
"product": product,
8792
"resource": pol.Resource,
8893
"action": pol.Action,
8994
}
9095
if err := tracing.SetSpanAttributesFromValue(span, "app.request.payload", payload, nil); err != nil {
9196
tracing.HandleSpanError(span, "failed to set span payload", err)
9297
}
9398

94-
authorized, httpStatus, err := auth.checkAuthorization(ctx, sub, pol.Resource, pol.Action, token)
99+
authorized, httpStatus, err := auth.checkAuthorization(ctx, product, pol.Resource, pol.Action, token)
95100
if err != nil {
96101
return nil, grpcErrorFromHTTP(httpStatus)
97102
}
@@ -248,18 +253,20 @@ func NewGRPCAuthStreamPolicy(auth *AuthClient, cfg PolicyConfig) grpc.StreamServ
248253
return status.Error(codes.Internal, "internal configuration error")
249254
}
250255

251-
var sub string
256+
// product is the resolved product identifier passed as checkAuthorization's
257+
// product argument (M2M subject base and normal-user isolation key).
258+
var product string
252259

253260
if cfg.SubResolver != nil {
254261
var err error
255262

256-
sub, err = cfg.SubResolver(ctx, info.FullMethod, nil)
263+
product, err = cfg.SubResolver(ctx, info.FullMethod, nil)
257264
if err != nil {
258265
return status.Error(codes.Internal, "internal configuration error")
259266
}
260267
}
261268

262-
authorized, httpStatus, err := auth.checkAuthorization(ctx, sub, pol.Resource, pol.Action, token)
269+
authorized, httpStatus, err := auth.checkAuthorization(ctx, product, pol.Resource, pol.Action, token)
263270
if err != nil {
264271
return grpcErrorFromHTTP(httpStatus)
265272
}

auth/middleware/middleware_test.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -195,6 +195,87 @@ func TestCheckAuthorization_MissingOwnerClaim(t *testing.T) {
195195
assert.Contains(t, err.Error(), "missing owner claim")
196196
}
197197

198+
func TestCheckAuthorization_MissingSubClaim(t *testing.T) {
199+
t.Parallel()
200+
201+
server := mockAuthServer(t, true, http.StatusOK)
202+
defer server.Close()
203+
204+
auth := &AuthClient{
205+
Address: server.URL,
206+
Enabled: true,
207+
Logger: &testLogger{},
208+
}
209+
210+
// normal-user without "sub" claim must fail closed instead of emitting "<owner>/".
211+
token := createTestJWT(jwt.MapClaims{
212+
"type": "normal-user",
213+
"owner": "acme-org",
214+
// "sub" is intentionally missing
215+
})
216+
217+
authorized, statusCode, err := auth.checkAuthorization(
218+
context.Background(), "midaz", "resource", "action", token,
219+
)
220+
221+
require.Error(t, err)
222+
assert.False(t, authorized)
223+
assert.Equal(t, http.StatusUnauthorized, statusCode)
224+
assert.Contains(t, err.Error(), "missing sub claim")
225+
}
226+
227+
func TestCheckAuthorization_NormalUser_EmptyProduct_NotForwarded(t *testing.T) {
228+
t.Parallel()
229+
230+
// With an empty product the previous behavior must be preserved: the subject
231+
// is still the JWT identity and no "product" field is forwarded (gate-by-presence).
232+
var capturedBody map[string]string
233+
234+
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
235+
err := json.NewDecoder(r.Body).Decode(&capturedBody)
236+
if err != nil {
237+
t.Errorf("mock server: failed to decode request body: %v", err)
238+
}
239+
240+
w.Header().Set("Content-Type", "application/json")
241+
w.WriteHeader(http.StatusOK)
242+
243+
resp := AuthResponse{Authorized: true}
244+
245+
encErr := json.NewEncoder(w).Encode(resp)
246+
if encErr != nil {
247+
t.Errorf("mock server: failed to encode response: %v", encErr)
248+
}
249+
}))
250+
defer server.Close()
251+
252+
auth := &AuthClient{
253+
Address: server.URL,
254+
Enabled: true,
255+
Logger: &testLogger{},
256+
}
257+
258+
token := createTestJWT(jwt.MapClaims{
259+
"type": "normal-user",
260+
"owner": "acme-org",
261+
"sub": "user123",
262+
})
263+
264+
authorized, statusCode, err := auth.checkAuthorization(
265+
context.Background(), "", "resource", "action", token,
266+
)
267+
268+
require.NoError(t, err)
269+
assert.True(t, authorized)
270+
assert.Equal(t, http.StatusOK, statusCode)
271+
272+
// Subject is still the JWT identity, unchanged by the empty product.
273+
assert.Equal(t, "acme-org/user123", capturedBody["sub"])
274+
// No product forwarded when product is empty.
275+
_, hasProduct := capturedBody["product"]
276+
assert.False(t, hasProduct)
277+
}
278+
198279
func TestCheckAuthorization_MockServerReturnsAuthorizedTrue(t *testing.T) {
199280
t.Parallel()
200281

0 commit comments

Comments
 (0)