Skip to content

Commit 692c0d7

Browse files
author
Christian
committed
chore: add gRPC authorization support and upgrade go-authx to v1.2.0
1 parent 124ca4c commit 692c0d7

7 files changed

Lines changed: 183 additions & 5 deletions

File tree

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -128,6 +128,9 @@ Environment-based configuration:
128128
- `AUTH_INTROSPECTION_PRIVATE_KEY_FILE` - Alternative file path for `AUTH_INTROSPECTION_PRIVATE_KEY` (mutually exclusive)
129129
- `AUTH_INTROSPECTION_PRIVATE_KEY_JWT_KID` - Optional JWT header `kid` override for `private_key_jwt`
130130
- `AUTH_INTROSPECTION_PRIVATE_KEY_JWT_ALG` - Optional signing alg override (`RS256` or `ES256`)
131+
- `AUTHZ_REQUIRED_ROLES` / `AUTHZ_REQUIRED_SCOPES` - Optional required roles/scopes (comma-separated); enables authorization checks when set
132+
- `AUTHZ_ROLE_MATCH_MODE` / `AUTHZ_SCOPE_MATCH_MODE` - Matching mode for required roles/scopes (`any` or `all`; default: `any`)
133+
- `AUTHZ_ROLE_CLAIM_PATHS` / `AUTHZ_SCOPE_CLAIM_PATHS` - Optional claim paths (comma-separated, dot-notation supported) used for role/scope extraction
131134
- `TLS_ENABLED` - Enable TLS for the gRPC server (default: false)
132135
- `TLS_CERT_FILE` / `TLS_KEY_FILE` - Server certificate and key (required when TLS is enabled)
133136
- `TLS_CA_FILE` - Optional CA bundle for client cert verification (mTLS)

cmd/server/main.go

Lines changed: 44 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -247,17 +247,59 @@ func buildUnaryInterceptors(cfg *config.Config) ([]grpc.UnaryServerInterceptor,
247247
Str("introspection_auth_method", cfg.AuthIntrospectionAuthMethod).
248248
Msg("gRPC authentication enabled")
249249

250-
authInterceptor := grpcserver.UnaryServerInterceptor(
251-
validator,
250+
policy := buildAuthorizationPolicy(cfg)
251+
interceptorOptions := []grpcserver.InterceptorOption{
252+
grpcserver.WithAuthorizationPolicy(policy),
252253
grpcserver.WithExemptMethods(
253254
"/grpc.health.v1.Health/Check",
254255
"/grpc.health.v1.Health/Watch",
255256
),
257+
}
258+
259+
if authorizationEnabled(cfg) {
260+
log.Info().
261+
Strs("required_roles", cfg.AuthzRequiredRoles).
262+
Strs("required_scopes", cfg.AuthzRequiredScopes).
263+
Str("role_match_mode", cfg.AuthzRoleMatchMode).
264+
Str("scope_match_mode", cfg.AuthzScopeMatchMode).
265+
Strs("role_claim_paths", cfg.AuthzRoleClaimPaths).
266+
Strs("scope_claim_paths", cfg.AuthzScopeClaimPaths).
267+
Msg("gRPC authorization enabled")
268+
}
269+
270+
authInterceptor := grpcserver.UnaryServerInterceptor(
271+
validator,
272+
interceptorOptions...,
256273
)
257274

258275
return append(interceptors, authInterceptor), nil
259276
}
260277

278+
func buildAuthorizationPolicy(cfg *config.Config) grpcserver.AuthorizationPolicy {
279+
roleMatchMode := grpcserver.RoleMatchModeAny
280+
if cfg.AuthzRoleMatchMode == "all" {
281+
roleMatchMode = grpcserver.RoleMatchModeAll
282+
}
283+
284+
scopeMatchMode := grpcserver.ScopeMatchModeAny
285+
if cfg.AuthzScopeMatchMode == "all" {
286+
scopeMatchMode = grpcserver.ScopeMatchModeAll
287+
}
288+
289+
return grpcserver.AuthorizationPolicy{
290+
RequiredRoles: cfg.AuthzRequiredRoles,
291+
RequiredScopes: cfg.AuthzRequiredScopes,
292+
RoleMatchMode: roleMatchMode,
293+
ScopeMatchMode: scopeMatchMode,
294+
RoleClaimPaths: cfg.AuthzRoleClaimPaths,
295+
ScopeClaimPaths: cfg.AuthzScopeClaimPaths,
296+
}
297+
}
298+
299+
func authorizationEnabled(cfg *config.Config) bool {
300+
return len(cfg.AuthzRequiredRoles) > 0 || len(cfg.AuthzRequiredScopes) > 0
301+
}
302+
261303
// buildGRPCServerOptions assembles gRPC server options from configuration. When TLS
262304
// is enabled, it constructs TLS credentials from the configured certificate, key,
263305
// optional CA file, client authentication mode, and minimum TLS version.

cmd/server/main_test.go

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -156,6 +156,37 @@ func TestBuildUnaryInterceptorsWithOpaqueAuthPrivateKeyJWTZitadelJSON(t *testing
156156
}
157157
}
158158

159+
func TestBuildAuthorizationPolicy(t *testing.T) {
160+
cfg := &config.Config{
161+
AuthzRequiredRoles: []string{"NIST_ROLE"},
162+
AuthzRequiredScopes: []string{"openid", "profile"},
163+
AuthzRoleMatchMode: "all",
164+
AuthzScopeMatchMode: "any",
165+
AuthzRoleClaimPaths: []string{"roles", "realm_access.roles"},
166+
AuthzScopeClaimPaths: []string{"scope", "scp"},
167+
}
168+
169+
policy := buildAuthorizationPolicy(cfg)
170+
if len(policy.RequiredRoles) != 1 || policy.RequiredRoles[0] != "NIST_ROLE" {
171+
t.Fatalf("unexpected policy required roles: %#v", policy.RequiredRoles)
172+
}
173+
if len(policy.RequiredScopes) != 2 || policy.RequiredScopes[0] != "openid" || policy.RequiredScopes[1] != "profile" {
174+
t.Fatalf("unexpected policy required scopes: %#v", policy.RequiredScopes)
175+
}
176+
if policy.RoleMatchMode != "all" {
177+
t.Fatalf("unexpected role match mode: %s", policy.RoleMatchMode)
178+
}
179+
if policy.ScopeMatchMode != "any" {
180+
t.Fatalf("unexpected scope match mode: %s", policy.ScopeMatchMode)
181+
}
182+
if len(policy.RoleClaimPaths) != 2 || policy.RoleClaimPaths[1] != "realm_access.roles" {
183+
t.Fatalf("unexpected policy role claim paths: %#v", policy.RoleClaimPaths)
184+
}
185+
if len(policy.ScopeClaimPaths) != 2 || policy.ScopeClaimPaths[1] != "scp" {
186+
t.Fatalf("unexpected policy scope claim paths: %#v", policy.ScopeClaimPaths)
187+
}
188+
}
189+
159190
func TestStartMetricsServer(t *testing.T) {
160191
ln := mustListen(t)
161192
// No defer ln.Close() here, server will close it

go.mod

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ module github.com/AmmannChristian/nist-sp800-22-rev1a
33
go 1.25.7
44

55
require (
6-
github.com/AmmannChristian/go-authx v1.1.0
6+
github.com/AmmannChristian/go-authx v1.2.0
77
github.com/golangci/golangci-lint v1.64.8
88
github.com/google/uuid v1.6.0
99
github.com/prometheus/client_golang v1.23.2

go.sum

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,8 +12,8 @@ github.com/4meepo/tagalign v1.4.2 h1:0hcLHPGMjDyM1gHG58cS73aQF8J4TdVR96TZViorO9E
1212
github.com/4meepo/tagalign v1.4.2/go.mod h1:+p4aMyFM+ra7nb41CnFG6aSDXqRxU/w1VQqScKqDARI=
1313
github.com/Abirdcfly/dupword v0.1.3 h1:9Pa1NuAsZvpFPi9Pqkd93I7LIYRURj+A//dFd5tgBeE=
1414
github.com/Abirdcfly/dupword v0.1.3/go.mod h1:8VbB2t7e10KRNdwTVoxdBaxla6avbhGzb8sCTygUMhw=
15-
github.com/AmmannChristian/go-authx v1.1.0 h1:ExytDcxQj+435vbYm/HtNJ7sz5csYLLHb/NyXD+j9is=
16-
github.com/AmmannChristian/go-authx v1.1.0/go.mod h1:eRp0jNgv25ARPG/dcakOPaU/a5UmqXphngCb0OnjtJg=
15+
github.com/AmmannChristian/go-authx v1.2.0 h1:ETNvuugwVfztHRFGA+/slV7Jwz7Dr//cEe74FhXPCbY=
16+
github.com/AmmannChristian/go-authx v1.2.0/go.mod h1:eRp0jNgv25ARPG/dcakOPaU/a5UmqXphngCb0OnjtJg=
1717
github.com/Antonboom/errname v1.0.0 h1:oJOOWR07vS1kRusl6YRSlat7HFnb3mSfMl6sDMRoTBA=
1818
github.com/Antonboom/errname v1.0.0/go.mod h1:gMOBFzK/vrTiXN9Oh+HFs+e6Ndl0eTFbtsRTSRdXyGI=
1919
github.com/Antonboom/nilnil v1.0.1 h1:C3Tkm0KUxgfO4Duk3PM+ztPncTFlOf0b2qadmS0s4xs=

internal/config/config.go

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,6 +45,12 @@ type Config struct {
4545
AuthIntrospectionPrivateKeyFile string
4646
AuthIntrospectionPrivateKeyJWTKeyID string
4747
AuthIntrospectionPrivateKeyJWTAlgorithm string
48+
AuthzRequiredRoles []string
49+
AuthzRequiredScopes []string
50+
AuthzRoleMatchMode string
51+
AuthzScopeMatchMode string
52+
AuthzRoleClaimPaths []string
53+
AuthzScopeClaimPaths []string
4854
}
4955

5056
// Load reads configuration from environment variables with sensible defaults
@@ -74,6 +80,12 @@ func Load() (*Config, error) {
7480
AuthIntrospectionPrivateKeyFile: getEnvString("AUTH_INTROSPECTION_PRIVATE_KEY_FILE", ""),
7581
AuthIntrospectionPrivateKeyJWTKeyID: getEnvString("AUTH_INTROSPECTION_PRIVATE_KEY_JWT_KID", ""),
7682
AuthIntrospectionPrivateKeyJWTAlgorithm: getEnvString("AUTH_INTROSPECTION_PRIVATE_KEY_JWT_ALG", ""),
83+
AuthzRequiredRoles: parseCSV(getEnvString("AUTHZ_REQUIRED_ROLES", "")),
84+
AuthzRequiredScopes: parseCSV(getEnvString("AUTHZ_REQUIRED_SCOPES", "")),
85+
AuthzRoleMatchMode: getEnvString("AUTHZ_ROLE_MATCH_MODE", "any"),
86+
AuthzScopeMatchMode: getEnvString("AUTHZ_SCOPE_MATCH_MODE", "any"),
87+
AuthzRoleClaimPaths: parseCSV(getEnvString("AUTHZ_ROLE_CLAIM_PATHS", "")),
88+
AuthzScopeClaimPaths: parseCSV(getEnvString("AUTHZ_SCOPE_CLAIM_PATHS", "")),
7789
}
7890

7991
if err := cfg.Validate(); err != nil {
@@ -106,6 +118,23 @@ func (c *Config) Validate() error {
106118
return fmt.Errorf("invalid LOG_LEVEL: %s (must be debug/info/warn/error)", c.LogLevel)
107119
}
108120

121+
roleMatchMode, err := parseAuthzMatchMode(c.AuthzRoleMatchMode, "AUTHZ_ROLE_MATCH_MODE")
122+
if err != nil {
123+
return err
124+
}
125+
c.AuthzRoleMatchMode = roleMatchMode
126+
127+
scopeMatchMode, err := parseAuthzMatchMode(c.AuthzScopeMatchMode, "AUTHZ_SCOPE_MATCH_MODE")
128+
if err != nil {
129+
return err
130+
}
131+
c.AuthzScopeMatchMode = scopeMatchMode
132+
133+
c.AuthzRequiredRoles = normalizeCSVValues(c.AuthzRequiredRoles)
134+
c.AuthzRequiredScopes = normalizeCSVValues(c.AuthzRequiredScopes)
135+
c.AuthzRoleClaimPaths = normalizeCSVValues(c.AuthzRoleClaimPaths)
136+
c.AuthzScopeClaimPaths = normalizeCSVValues(c.AuthzScopeClaimPaths)
137+
109138
if c.AuthEnabled {
110139
if c.AuthIssuer == "" {
111140
return fmt.Errorf("invalid AUTH_ISSUER: required when AUTH_ENABLED=true")
@@ -259,6 +288,42 @@ func parseAuthIntrospectionPrivateKeyJWTAlgorithm(algorithm string) (string, err
259288
}
260289
}
261290

291+
func parseAuthzMatchMode(mode string, envName string) (string, error) {
292+
switch strings.ToLower(strings.TrimSpace(mode)) {
293+
case "", "any":
294+
return "any", nil
295+
case "all":
296+
return "all", nil
297+
default:
298+
return "", fmt.Errorf("invalid %s: %s (use any or all)", envName, mode)
299+
}
300+
}
301+
302+
func parseCSV(value string) []string {
303+
return normalizeCSVValues(strings.Split(value, ","))
304+
}
305+
306+
func normalizeCSVValues(values []string) []string {
307+
if len(values) == 0 {
308+
return nil
309+
}
310+
311+
normalizedValues := make([]string, 0, len(values))
312+
for _, value := range values {
313+
normalizedValue := strings.TrimSpace(value)
314+
if normalizedValue == "" {
315+
continue
316+
}
317+
normalizedValues = append(normalizedValues, normalizedValue)
318+
}
319+
320+
if len(normalizedValues) == 0 {
321+
return nil
322+
}
323+
324+
return normalizedValues
325+
}
326+
262327
// getEnvString reads a string from environment variable or returns default
263328
func getEnvString(key, defaultValue string) string {
264329
if value := os.Getenv(key); value != "" {

internal/config/config_test.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,12 @@ func TestLoadWithEnvOverrides(t *testing.T) {
1414
t.Setenv("AUTH_AUDIENCE", "nist-api")
1515
t.Setenv("AUTH_JWKS_URL", "https://issuer.example.com/jwks.json")
1616
t.Setenv("AUTH_TOKEN_TYPE", "jwt")
17+
t.Setenv("AUTHZ_REQUIRED_ROLES", "NIST_ROLE, entropy-admin ")
18+
t.Setenv("AUTHZ_REQUIRED_SCOPES", "openid, profile")
19+
t.Setenv("AUTHZ_ROLE_MATCH_MODE", "all")
20+
t.Setenv("AUTHZ_SCOPE_MATCH_MODE", "any")
21+
t.Setenv("AUTHZ_ROLE_CLAIM_PATHS", "roles,urn:zitadel:iam:org:project:roles")
22+
t.Setenv("AUTHZ_SCOPE_CLAIM_PATHS", "scope,scp")
1723
t.Setenv("TLS_ENABLED", "true")
1824
t.Setenv("TLS_CERT_FILE", "/tmp/cert.pem")
1925
t.Setenv("TLS_KEY_FILE", "/tmp/key.pem")
@@ -47,6 +53,24 @@ func TestLoadWithEnvOverrides(t *testing.T) {
4753
if cfg.AuthTokenType != "jwt" {
4854
t.Fatalf("unexpected auth token type: %s", cfg.AuthTokenType)
4955
}
56+
if len(cfg.AuthzRequiredRoles) != 2 || cfg.AuthzRequiredRoles[0] != "NIST_ROLE" || cfg.AuthzRequiredRoles[1] != "entropy-admin" {
57+
t.Fatalf("unexpected authz required roles: %#v", cfg.AuthzRequiredRoles)
58+
}
59+
if len(cfg.AuthzRequiredScopes) != 2 || cfg.AuthzRequiredScopes[0] != "openid" || cfg.AuthzRequiredScopes[1] != "profile" {
60+
t.Fatalf("unexpected authz required scopes: %#v", cfg.AuthzRequiredScopes)
61+
}
62+
if cfg.AuthzRoleMatchMode != "all" {
63+
t.Fatalf("unexpected authz role match mode: %s", cfg.AuthzRoleMatchMode)
64+
}
65+
if cfg.AuthzScopeMatchMode != "any" {
66+
t.Fatalf("unexpected authz scope match mode: %s", cfg.AuthzScopeMatchMode)
67+
}
68+
if len(cfg.AuthzRoleClaimPaths) != 2 || cfg.AuthzRoleClaimPaths[0] != "roles" || cfg.AuthzRoleClaimPaths[1] != "urn:zitadel:iam:org:project:roles" {
69+
t.Fatalf("unexpected authz role claim paths: %#v", cfg.AuthzRoleClaimPaths)
70+
}
71+
if len(cfg.AuthzScopeClaimPaths) != 2 || cfg.AuthzScopeClaimPaths[0] != "scope" || cfg.AuthzScopeClaimPaths[1] != "scp" {
72+
t.Fatalf("unexpected authz scope claim paths: %#v", cfg.AuthzScopeClaimPaths)
73+
}
5074
if !cfg.TLSEnabled {
5175
t.Fatalf("expected TLSEnabled to be true")
5276
}
@@ -196,6 +220,8 @@ func TestValidateFailures(t *testing.T) {
196220
{"auth opaque private key jwt missing private key", Config{GRPCPort: 9000, MetricsPort: 9001, LogLevel: "info", AuthEnabled: true, AuthIssuer: "https://issuer.example.com", AuthAudience: "api", AuthTokenType: "opaque", AuthIntrospectionURL: "https://issuer.example.com/oauth2/introspect", AuthIntrospectionAuthMethod: "private_key_jwt"}},
197221
{"auth opaque private key jwt both inline and file set", Config{GRPCPort: 9000, MetricsPort: 9001, LogLevel: "info", AuthEnabled: true, AuthIssuer: "https://issuer.example.com", AuthAudience: "api", AuthTokenType: "opaque", AuthIntrospectionURL: "https://issuer.example.com/oauth2/introspect", AuthIntrospectionAuthMethod: "private_key_jwt", AuthIntrospectionPrivateKey: "PEM", AuthIntrospectionPrivateKeyFile: "/tmp/key.json"}},
198222
{"auth opaque private key jwt invalid algorithm", Config{GRPCPort: 9000, MetricsPort: 9001, LogLevel: "info", AuthEnabled: true, AuthIssuer: "https://issuer.example.com", AuthAudience: "api", AuthTokenType: "opaque", AuthIntrospectionURL: "https://issuer.example.com/oauth2/introspect", AuthIntrospectionAuthMethod: "private_key_jwt", AuthIntrospectionPrivateKey: "PEM", AuthIntrospectionPrivateKeyJWTAlgorithm: "PS256"}},
223+
{"authz invalid role match mode", Config{GRPCPort: 9000, MetricsPort: 9001, LogLevel: "info", AuthzRoleMatchMode: "one"}},
224+
{"authz invalid scope match mode", Config{GRPCPort: 9000, MetricsPort: 9001, LogLevel: "info", AuthzScopeMatchMode: "one"}},
199225
{"tls enabled missing cert", Config{GRPCPort: 9000, MetricsPort: 9001, LogLevel: "info", TLSEnabled: true, TLSKeyFile: "/tmp/key.pem"}},
200226
{"tls enabled missing key", Config{GRPCPort: 9000, MetricsPort: 9001, LogLevel: "info", TLSEnabled: true, TLSCertFile: "/tmp/cert.pem"}},
201227
{"tls enabled invalid client auth", Config{GRPCPort: 9000, MetricsPort: 9001, LogLevel: "info", TLSEnabled: true, TLSCertFile: "/tmp/cert.pem", TLSKeyFile: "/tmp/key.pem", TLSClientAuth: "invalid"}},
@@ -269,6 +295,8 @@ func TestLoadDefaults(t *testing.T) {
269295
"AUTH_INTROSPECTION_URL", "AUTH_INTROSPECTION_AUTH_METHOD", "AUTH_INTROSPECTION_CLIENT_ID", "AUTH_INTROSPECTION_CLIENT_SECRET",
270296
"AUTH_INTROSPECTION_PRIVATE_KEY", "AUTH_INTROSPECTION_PRIVATE_KEY_FILE",
271297
"AUTH_INTROSPECTION_PRIVATE_KEY_JWT_KID", "AUTH_INTROSPECTION_PRIVATE_KEY_JWT_ALG",
298+
"AUTHZ_REQUIRED_ROLES", "AUTHZ_REQUIRED_SCOPES", "AUTHZ_ROLE_MATCH_MODE", "AUTHZ_SCOPE_MATCH_MODE",
299+
"AUTHZ_ROLE_CLAIM_PATHS", "AUTHZ_SCOPE_CLAIM_PATHS",
272300
"TLS_ENABLED", "TLS_CERT_FILE", "TLS_KEY_FILE", "TLS_CA_FILE", "TLS_CLIENT_AUTH", "TLS_MIN_VERSION",
273301
} {
274302
t.Setenv(key, "")
@@ -295,6 +323,15 @@ func TestLoadDefaults(t *testing.T) {
295323
if cfg.AuthIssuer != "" || cfg.AuthAudience != "" || cfg.AuthJWKSURL != "" || cfg.AuthIntrospectionURL != "" || cfg.AuthIntrospectionClientID != "" || cfg.AuthIntrospectionClientSecret != "" || cfg.AuthIntrospectionPrivateKey != "" || cfg.AuthIntrospectionPrivateKeyFile != "" || cfg.AuthIntrospectionPrivateKeyJWTKeyID != "" || cfg.AuthIntrospectionPrivateKeyJWTAlgorithm != "" {
296324
t.Errorf("expected auth config defaults to be empty, got %+v", cfg)
297325
}
326+
if len(cfg.AuthzRequiredRoles) != 0 || len(cfg.AuthzRequiredScopes) != 0 || len(cfg.AuthzRoleClaimPaths) != 0 || len(cfg.AuthzScopeClaimPaths) != 0 {
327+
t.Errorf("expected authz list defaults to be empty, got %+v", cfg)
328+
}
329+
if cfg.AuthzRoleMatchMode != "any" {
330+
t.Errorf("expected AuthzRoleMatchMode to default to 'any', got %s", cfg.AuthzRoleMatchMode)
331+
}
332+
if cfg.AuthzScopeMatchMode != "any" {
333+
t.Errorf("expected AuthzScopeMatchMode to default to 'any', got %s", cfg.AuthzScopeMatchMode)
334+
}
298335
if cfg.AuthTokenType != "jwt" {
299336
t.Errorf("expected AuthTokenType to default to 'jwt', got %s", cfg.AuthTokenType)
300337
}

0 commit comments

Comments
 (0)