Skip to content

Commit d733fda

Browse files
committed
Optional claims-based authorization for private deployments
This pull request adds optional claims-based authorization functionality to Fulcio, enabling organizations with private deployments to implement fine-grained access control policies based on OIDC token claims. This feature addresses the need for basic authorization in private deployments to avoid generating certificates blindly for any valid OIDC token issued by a configured OIDC Issuer. The public Sigstore instance does not need this functionality as it serves the broader community. Authorization operates as an additional security layer after successful OIDC authentication and before certificate issuance. When enabled, configurable regex-based rules evaluate authenticated token claims to determine if a certificate request should be approved. Resolves: #1989 Signed-off-by: Arbër Salihi <[email protected]>
1 parent dfb6b83 commit d733fda

File tree

12 files changed

+1430
-15
lines changed

12 files changed

+1430
-15
lines changed

cmd/app/grpc.go

Lines changed: 36 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ import (
3535
grpc_recovery "github.com/grpc-ecosystem/go-grpc-middleware/recovery"
3636
grpc_prometheus "github.com/grpc-ecosystem/go-grpc-prometheus"
3737
"github.com/prometheus/client_golang/prometheus"
38+
"github.com/sigstore/fulcio/pkg/authorization"
3839
"github.com/sigstore/fulcio/pkg/ca"
3940
"github.com/sigstore/fulcio/pkg/config"
4041
gw "github.com/sigstore/fulcio/pkg/generated/protobuf"
@@ -148,6 +149,38 @@ func (c *cachedTLSCert) UpdateCertificate() error {
148149
return nil
149150
}
150151

152+
// setupAuthorization creates and configures the authorization component
153+
func setupAuthorization(cfg *config.FulcioConfig) authorization.Authorizer {
154+
authorizer := authorization.NewDefaultAuthorizer()
155+
156+
// If no config provided, return default authorizer (no rules)
157+
if cfg == nil || cfg.OIDCIssuers == nil {
158+
return authorizer
159+
}
160+
161+
// Compile rules for all configured issuers (any authorization configuration error is fatal)
162+
for issuerURL, issuerConfig := range cfg.OIDCIssuers {
163+
if len(issuerConfig.AuthorizationRules) > 0 {
164+
if err := authorizer.CompileRules(issuerURL, issuerConfig.AuthorizationRules); err != nil {
165+
log.Logger.Fatalf("Failed to compile authorization rules for issuer %s: %v", issuerURL, err)
166+
}
167+
log.Logger.Infof("Compiled %d authorization rules for issuer: %s", len(issuerConfig.AuthorizationRules), issuerURL)
168+
}
169+
}
170+
171+
// Compile rules for meta issuers (any authorization configuration error is fatal)
172+
for metaURL, issuerConfig := range cfg.MetaIssuers {
173+
if len(issuerConfig.AuthorizationRules) > 0 {
174+
if err := authorizer.CompileRules(metaURL, issuerConfig.AuthorizationRules); err != nil {
175+
log.Logger.Fatalf("Failed to compile authorization rules for meta issuer %s: %v", metaURL, err)
176+
}
177+
log.Logger.Infof("Compiled %d authorization rules for meta issuer: %s", len(issuerConfig.AuthorizationRules), metaURL)
178+
}
179+
}
180+
181+
return authorizer
182+
}
183+
151184
func (c *cachedTLSCert) GRPCCreds() grpc.ServerOption {
152185
return grpc.Creds(credentials.NewTLS(&tls.Config{
153186
GetCertificate: func(_ *tls.ClientHelloInfo) (*tls.Certificate, error) {
@@ -188,7 +221,9 @@ func createGRPCServer(cfg *config.FulcioConfig, ctClient *ctclient.LogClient, ba
188221

189222
myServer := grpc.NewServer(serverOpts...)
190223

191-
grpcCAServer := server.NewGRPCCAServer(ctClient, baseca, algorithmRegistry, ip)
224+
authorizer := setupAuthorization(cfg)
225+
226+
grpcCAServer := server.NewGRPCCAServer(ctClient, baseca, algorithmRegistry, ip, authorizer)
192227

193228
health.RegisterHealthServer(myServer, grpcCAServer)
194229
// Register your gRPC service implementations.

cmd/app/serve.go

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -47,6 +47,7 @@ import (
4747
"github.com/grpc-ecosystem/grpc-gateway/v2/runtime"
4848
"github.com/prometheus/client_golang/prometheus"
4949
"github.com/prometheus/client_golang/prometheus/promhttp"
50+
"github.com/sigstore/fulcio/pkg/authorization"
5051
certauth "github.com/sigstore/fulcio/pkg/ca"
5152
"github.com/sigstore/fulcio/pkg/ca/ephemeralca"
5253
"github.com/sigstore/fulcio/pkg/ca/fileca"
@@ -353,13 +354,15 @@ func runServeCmd(cmd *cobra.Command, args []string) { //nolint: revive
353354
}
354355
ip := server.NewIssuerPool(cfg)
355356

357+
authorizer := setupAuthorization(cfg)
358+
356359
portsMatch := viper.GetString("port") == viper.GetString("grpc-port")
357360
hostsMatch := viper.GetString("host") == viper.GetString("grpc-host")
358361
if portsMatch && hostsMatch {
359362
port := viper.GetInt("port")
360363
metricsPort := viper.GetInt("metrics-port")
361364
// StartDuplexServer will always return an error, log fatally if it's non-nil
362-
if err := StartDuplexServer(ctx, cfg, ctClient, baseca, algorithmRegistry, viper.GetString("host"), port, metricsPort, ip); err != http.ErrServerClosed {
365+
if err := StartDuplexServer(ctx, cfg, ctClient, baseca, algorithmRegistry, viper.GetString("host"), port, metricsPort, ip, authorizer); err != http.ErrServerClosed {
363366
log.Logger.Fatal(err)
364367
}
365368
return
@@ -460,7 +463,7 @@ func duplexHealthz(_ context.Context, mux *runtime.ServeMux, endpoint string, op
460463
return nil
461464
}
462465

463-
func StartDuplexServer(ctx context.Context, cfg *config.FulcioConfig, ctClient *ctclient.LogClient, baseca certauth.CertificateAuthority, algorithmRegistry *signature.AlgorithmRegistryConfig, host string, port, metricsPort int, ip identity.IssuerPool) error {
466+
func StartDuplexServer(ctx context.Context, cfg *config.FulcioConfig, ctClient *ctclient.LogClient, baseca certauth.CertificateAuthority, algorithmRegistry *signature.AlgorithmRegistryConfig, host string, port, metricsPort int, ip identity.IssuerPool, authorizer authorization.Authorizer) error {
464467
logger, opts := log.SetupGRPCLogging()
465468

466469
d := duplex.New(
@@ -482,7 +485,7 @@ func StartDuplexServer(ctx context.Context, cfg *config.FulcioConfig, ctClient *
482485
)
483486

484487
// GRPC server
485-
grpcCAServer := server.NewGRPCCAServer(ctClient, baseca, algorithmRegistry, ip)
488+
grpcCAServer := server.NewGRPCCAServer(ctClient, baseca, algorithmRegistry, ip, authorizer)
486489
protobuf.RegisterCAServer(d.Server, grpcCAServer)
487490
if err := d.RegisterHandler(ctx, protobuf.RegisterCAHandlerFromEndpoint); err != nil {
488491
return fmt.Errorf("registering grpc ca handler: %w", err)

cmd/app/serve_test.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -56,7 +56,7 @@ func TestDuplex(t *testing.T) {
5656
}
5757

5858
go func() {
59-
if err := StartDuplexServer(ctx, config.DefaultConfig, nil, ca, algorithmRegistry, "localhost", port, metricsPort, nil); err != nil {
59+
if err := StartDuplexServer(ctx, config.DefaultConfig, nil, ca, algorithmRegistry, "localhost", port, metricsPort, nil, nil); err != nil {
6060
log.Fatalf("error starting duplex server: %v", err)
6161
}
6262
}()

docs/authorization.md

Lines changed: 245 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,245 @@
1+
# Authorization
2+
3+
## Summary
4+
5+
Fulcio supports optional claims-based authorization that can be configured to restrict certificate issuance based on OIDC token claims. Authorization runs after successful OIDC authentication and before certificate creation, allowing fine-grained access control based on token metadata.
6+
7+
## Overview
8+
9+
The authorization system evaluates configurable rules against OIDC token claims to determine if a certificate request should be approved. This enables scenarios such as:
10+
11+
- Restricting certificate issuance to specific repositories or organizations
12+
- Allowing only certain CI/CD pipelines to obtain certificates
13+
- Implementing custom access control policies based on token claims
14+
- Supporting multi-tenant environments with isolated access
15+
16+
Authorization is **optional** - Fulcio continues to work when no authorization rules are configured.
17+
18+
## How authorization works
19+
20+
```
21+
OIDC Token → Authentication → Authorization → Certificate Issuance
22+
```
23+
24+
1. **Authentication**: OIDC token is validated (configured OIDC Issuers + valid signature)
25+
2. **Authorization**: Token claims are evaluated against configured rules
26+
3. **Certificate Issuance**: Certificate is created if authorization passes or if no authorization rules are defined for the OIDC Issuer
27+
28+
If authorization fails, the request is rejected with HTTP 403 Forbidden.
29+
30+
## Configuration
31+
32+
Authorization rules are configured per OIDC issuer in the Fulcio configuration file:
33+
34+
```yaml
35+
oidc-issuers:
36+
https://token.actions.githubusercontent.com:
37+
issuer-url: https://token.actions.githubusercontent.com
38+
client-id: sigstore
39+
type: github-workflow
40+
authorization-rules:
41+
- name: "Allow specific organization repositories"
42+
logic: "AND"
43+
conditions:
44+
- field: "repository_owner"
45+
pattern: "^myorg$"
46+
- field: "repository"
47+
pattern: "^myorg/(prod-app|staging-app)$"
48+
- name: "Allow admin user for any repository"
49+
logic: "AND"
50+
conditions:
51+
- field: "actor"
52+
pattern: "^admin@myorg\\.com$"
53+
```
54+
55+
### Rule structure
56+
57+
- **name**: Descriptive name for the rule (used in logging)
58+
- **logic**: Either "AND" or "OR" to combine conditions
59+
- **conditions**: Array of field/pattern pairs to evaluate
60+
61+
### Condition structure
62+
63+
- **field**: OIDC token claim to evaluate (e.g., "repository", "sub", "email")
64+
- **pattern**: Regular expression pattern to match against the claim value
65+
66+
### Evaluation logic
67+
68+
- **AND logic**: ALL conditions must match for the rule to pass
69+
- **OR logic**: ANY condition can match for the rule to pass
70+
- **Rule evaluation**: If ANY rule passes, authorization succeeds
71+
- **No rules**: If no rules are configured, authorization is skipped
72+
73+
## Common use cases
74+
75+
### 1. Repository-based access control
76+
77+
Restrict certificate issuance to specific GitHub repositories:
78+
79+
```yaml
80+
authorization-rules:
81+
- name: "Production repositories only"
82+
logic: "AND"
83+
conditions:
84+
- field: "repository_owner"
85+
pattern: "^myorg$"
86+
- field: "repository"
87+
pattern: "^myorg/(api|web|mobile)$"
88+
```
89+
90+
### 2. Organization-wide access
91+
92+
Allow any repository within an organization:
93+
94+
```yaml
95+
authorization-rules:
96+
- name: "Organization members"
97+
logic: "AND"
98+
conditions:
99+
- field: "repository_owner"
100+
pattern: "^myorg$"
101+
```
102+
103+
### 3. User-based access control
104+
105+
Allow specific users regardless of repository:
106+
107+
```yaml
108+
authorization-rules:
109+
- name: "Authorized maintainers"
110+
logic: "OR"
111+
conditions:
112+
- field: "actor"
113+
pattern: "^(alice|bob|charlie)$"
114+
```
115+
116+
### 4. Environment-based access
117+
118+
Restrict based on deployment environment:
119+
120+
```yaml
121+
authorization-rules:
122+
- name: "Production deployments"
123+
logic: "AND"
124+
conditions:
125+
- field: "job_workflow_ref"
126+
pattern: "^myorg/[a-zA-Z0-9._-]{1,100}/.github/workflows/production.yaml@refs/heads/main"
127+
- field: "repository_owner"
128+
pattern: "^myorg$"
129+
```
130+
131+
### 5. Multiple rule example
132+
133+
Combine different access patterns:
134+
135+
```yaml
136+
authorization-rules:
137+
- name: "Production repositories"
138+
logic: "AND"
139+
conditions:
140+
- field: "repository_owner"
141+
pattern: "^myorg$"
142+
- field: "repository"
143+
pattern: "^myorg/(api|web)$"
144+
- name: "Admin override"
145+
logic: "AND"
146+
conditions:
147+
- field: "actor"
148+
pattern: "^myorg-admin-bot$"
149+
```
150+
151+
## Security considerations
152+
153+
### Defense in depth
154+
155+
Authorization provides an additional security layer after OIDC authentication:
156+
157+
- **Authentication**: Verifies the token is valid and from a trusted issuer
158+
- **Authorization**: Verifies the authenticated identity should have access
159+
- **Transparency**: All decisions are logged to the certificate transparency log
160+
161+
### Regular expression safety
162+
163+
- Patterns use Go's `regexp` package, which is safe from ReDoS attacks
164+
- Patterns are compiled once at startup for performance
165+
- It is recommended to use anchors (`^` and `$`) to prevent partial matches
166+
- Patterns should be tested thoroughly before deployment
167+
168+
### Token claim validation
169+
170+
- Claims are extracted from authenticated OIDC tokens only
171+
- Authorization cannot be bypassed by manipulating unauthenticated tokens
172+
- All claim values are treated as strings for pattern matching
173+
174+
### Configuration validation and server startup
175+
176+
Fulcio prioritizes security over availability when it comes to authorization configuration:
177+
178+
- **Configuration validation**: All regex patterns and rule structures are validated at startup
179+
- **Fail-secure by design**: Any malformed authorization rules will prevent server startup
180+
181+
This ensures that authorization policies are always correctly applied and prevents accidental security misconfigurations.
182+
183+
### Logging and monitoring
184+
185+
Authorization decisions are logged with structured logging:
186+
187+
```
188+
DEBUG authorization/authorizer.go:130 Authorization passed: rule matched
189+
DEBUG authorization/authorizer.go:145 Authorization denied: no rules matched
190+
```
191+
192+
Monitor these logs to detect:
193+
- Unexpected authorization failures
194+
- Potential security policy violations
195+
- Need for rule adjustments
196+
197+
## Troubleshooting
198+
199+
### Server fails to start with authorization configuration errors
200+
201+
Fulcio uses a fail-secure approach. Any malformed authorization configuration will prevent server startup:
202+
203+
1. **Invalid regex patterns**: Check server startup logs for regex compilation errors
204+
2. **Empty rule names**: Ensure all rules have descriptive names
205+
3. **Invalid logic operators**: Use only "AND" or "OR" (case-insensitive)
206+
4. **Missing conditions**: Each rule must have at least one condition
207+
208+
### Authorization always fails
209+
210+
1. Verify OIDC token contains expected claims
211+
2. Test regex patterns against actual claim values
212+
3. Ensure at least one rule matches your use case
213+
4. Check that field names match actual token claims
214+
4. Start with broad patterns and narrow down
215+
5. Test regex patterns separately before adding to configuration
216+
217+
### Authorization always passes
218+
219+
1. Verify rules are configured in the correct issuer section
220+
2. Check that field names match actual token claims
221+
3. Ensure regex patterns have proper anchors when relevant (`^` and `$`)
222+
5. Test regex patterns separately before adding to configuration
223+
224+
## Integration with Helm charts
225+
226+
When deploying Fulcio with Helm (see [sigstore/helm-charts](https://github.com/sigstore/helm-charts/tree/main/charts/fulcio)), authorization rules can be configured via values:
227+
228+
```yaml
229+
fulcio:
230+
config:
231+
contents:
232+
OIDCIssuers:
233+
"https://token.actions.githubusercontent.com":
234+
IssuerURL: "https://token.actions.githubusercontent.com"
235+
ClientID: "sigstore"
236+
Type: "github-workflow"
237+
AuthorizationRules:
238+
- Name: "Allow specific repositories"
239+
Logic: "AND"
240+
Conditions:
241+
- Field: "repository_owner"
242+
Pattern: "^myorg$"
243+
- Field: "repository"
244+
Pattern: "^myorg/allowed-repo$"
245+
```

0 commit comments

Comments
 (0)