Skip to content

Commit ea62599

Browse files
committed
feat(openapi): select conditional security alternatives
1 parent 0fdffb9 commit ea62599

10 files changed

Lines changed: 329 additions & 7 deletions

File tree

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
# Conditional Operation Security
2+
3+
## Status
4+
5+
Accepted for the v2 development line.
6+
7+
## Problem
8+
9+
OpenAPI security can express alternatives and conjunctions, but it cannot make
10+
one alternative conditional on the concrete value of a path parameter. Some
11+
transparent APIs need exactly that behavior. Publishing all sets as ordinary
12+
alternatives loses the condition, so a client can request too little authority
13+
and discover the missing requirement only after the server rejects the request.
14+
15+
## Decision
16+
17+
Restish supports the additive operation extension
18+
`x-restish-security-alternatives`. It is an ordered list of rules:
19+
20+
```yaml
21+
x-restish-security-alternatives:
22+
- when:
23+
pathParameter: path
24+
prefix: .github/workflows/
25+
alternatives: [1]
26+
```
27+
28+
`alternatives` contains zero-based indexes into that operation's standard
29+
OpenAPI `security` array. After the concrete request path is known, Restish
30+
extracts and percent-decodes the named path parameter. The first matching rule
31+
replaces the candidate list with the indexed alternatives. When no rule matches,
32+
standard OpenAPI selection is unchanged.
33+
34+
The extension only narrows standard alternatives; it cannot create a scheme,
35+
scope, or conjunction. Invalid parameter names, empty predicates, repeated
36+
indexes, and out-of-range indexes fail specification loading. Authority remains
37+
visible to ordinary OpenAPI tooling and the extension cannot silently widen it.
38+
39+
## Compatibility
40+
41+
The extension is optional. Existing specifications and clients continue to see
42+
the complete standard OpenAPI security alternatives. Inspection, help, and
43+
configuration retain the complete static view because no concrete path value
44+
exists at those stages. Generated commands and generic requests both apply the
45+
predicate immediately before operation authentication planning.

docs/design/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,7 @@ were a recurring source of remediation work.
144144
- [007-api-command-generation.md](./007-api-command-generation.md) - Config-backed API registration, OpenAPI-to-command mapping, naming, parameter handling, and compatibility aliases.
145145
- [033-openapi-operation-security.md](./033-openapi-operation-security.md) - Operation-specific OpenAPI security policy, credential bindings, setup UX, and compatibility rules.
146146
- [044-dpop-credential-sources.md](./044-dpop-credential-sources.md) - Native RFC 9449 credential custody with provider-neutral token-source plugins.
147+
- [045-conditional-operation-security.md](./045-conditional-operation-security.md) - Request-path predicates that narrow standard OpenAPI security alternatives at execution time.
147148
- [034-openapi-implementation-contract.md](./034-openapi-implementation-contract.md) - Implementation-grade OpenAPI 3.x behavior matrix for loading, command generation, parameters, servers, schemas, auth, media types, caching, and tests.
148149
- [008-shorthand-input.md](./008-shorthand-input.md) - Building request bodies from CLI arguments and stdin using shorthand syntax.
149150
- [029-request-execution-pipeline.md](./029-request-execution-pipeline.md) - End-to-end request planning, execution order, cancellation, transport layering, normalization, filtering, and rendering.

internal/cli/generated.go

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -491,7 +491,7 @@ func (c *CLI) buildOperationCommand(apiName, examplePrefix string, op spec.Opera
491491
}
492492
acceptOverride := c.generatedOperationAcceptHeader(op.ResponseMediaTypes, op.ResponseMediaType)
493493
rawBinaryBody := op.Help.Request != nil && op.Help.Request.RawBinary
494-
return c.runGeneratedOp(cmd, apiName, op.Path, op.OperationServer, op.Method, op.RequestMediaType, acceptOverride, op.RequestMultipartContentTypes, op.Help, op.BodyRequired, rawBinaryBody, op.NoAuth, op.OptionalAuth, op.CredentialAlternatives, required, optional, args)
494+
return c.runGeneratedOp(cmd, apiName, op.Path, op.OperationServer, op.Method, op.RequestMediaType, acceptOverride, op.RequestMultipartContentTypes, op.Help, op.BodyRequired, rawBinaryBody, op.NoAuth, op.OptionalAuth, op.CredentialAlternatives, op.ConditionalSecurity, required, optional, args)
495495
},
496496
}
497497
if candidates := authOverrideCandidates(op.OptionalAuth, op.CredentialAlternatives); len(candidates) > 0 {
@@ -1382,6 +1382,7 @@ func (c *CLI) runGeneratedOp(
13821382
noAuth bool,
13831383
optionalAuth bool,
13841384
credentialAlternatives []spec.CredentialAlternative,
1385+
conditionalSecurity []spec.ConditionalSecurityRule,
13851386
required, optional []*paramInfo,
13861387
args []string,
13871388
) error {
@@ -1511,6 +1512,8 @@ func (c *CLI) runGeneratedOp(
15111512
OptionalAuth: optionalAuth,
15121513
NoAuth: noAuth,
15131514
CredentialAlternatives: credentialAlternatives,
1515+
OperationPath: opPath,
1516+
ConditionalSecurity: conditionalSecurity,
15141517
Override: gf.Auth,
15151518
},
15161519
})

internal/cli/operation_auth.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,8 @@ import (
44
"context"
55
"fmt"
66
"net/http"
7+
"net/url"
8+
"regexp"
79
"sort"
810
"strings"
911

@@ -21,6 +23,8 @@ type operationAuthPolicy struct {
2123
OptionalAuth bool
2224
NoAuth bool
2325
CredentialAlternatives []spec.CredentialAlternative
26+
OperationPath string
27+
ConditionalSecurity []spec.ConditionalSecurityRule
2428
Override string
2529
Transport request.Options
2630
}
@@ -34,6 +38,13 @@ type selectedOperationAuth struct {
3438
}
3539

3640
func (c *CLI) planOperationAuth(apiName, profileName string, prof *config.ProfileConfig, policy *operationAuthPolicy) ([]selectedOperationAuth, bool, error) {
41+
if policy != nil {
42+
resolved, err := conditionalOperationAuthPolicy(*policy)
43+
if err != nil {
44+
return nil, false, err
45+
}
46+
policy = &resolved
47+
}
3748
if policy != nil && strings.TrimSpace(policy.Override) != "" {
3849
return c.planOperationAuthOverride(apiName, profileName, prof, policy)
3950
}
@@ -146,6 +157,61 @@ func (c *CLI) planOperationAuth(apiName, profileName string, prof *config.Profil
146157
return nil, false, fmt.Errorf("profile %q of API %q is missing credential bindings for this operation: %s%s%s; %s", profileName, apiName, strings.Join(uniqueStrings(missing), ", "), securityIssueSuffix, operationAuthConfiguredOverrideHint(prof, policy.CredentialAlternatives), operationAuthSetupHint(apiName, profileName))
147158
}
148159

160+
func conditionalOperationAuthPolicy(policy operationAuthPolicy) (operationAuthPolicy, error) {
161+
if len(policy.ConditionalSecurity) == 0 || policy.URL == "" {
162+
return policy, nil
163+
}
164+
u, err := url.Parse(policy.URL)
165+
if err != nil {
166+
return operationAuthPolicy{}, fmt.Errorf("conditional operation security: parse request URL: %w", err)
167+
}
168+
for _, rule := range policy.ConditionalSecurity {
169+
value, ok := operationPathParameter(policy.OperationPath, u.EscapedPath(), rule.When.PathParameter)
170+
if !ok || !strings.HasPrefix(value, rule.When.Prefix) {
171+
continue
172+
}
173+
alternatives := make([]spec.CredentialAlternative, 0, len(rule.Alternatives))
174+
for _, index := range rule.Alternatives {
175+
if index < 0 || index >= len(policy.CredentialAlternatives) {
176+
return operationAuthPolicy{}, fmt.Errorf("conditional operation security selects unavailable alternative %d", index)
177+
}
178+
alternatives = append(alternatives, policy.CredentialAlternatives[index])
179+
}
180+
policy.CredentialAlternatives = alternatives
181+
return policy, nil
182+
}
183+
return policy, nil
184+
}
185+
186+
func operationPathParameter(template, escapedRequestPath, name string) (string, bool) {
187+
var source strings.Builder
188+
source.WriteString(`^.*`)
189+
index := 0
190+
found := false
191+
for _, match := range regexp.MustCompile(`\{([^}]+)\}`).FindAllStringSubmatchIndex(template, -1) {
192+
source.WriteString(regexp.QuoteMeta(template[index:match[0]]))
193+
parameterName := template[match[2]:match[3]]
194+
if parameterName == name {
195+
source.WriteString(`(.+)`)
196+
found = true
197+
} else {
198+
source.WriteString(`[^/]+`)
199+
}
200+
index = match[1]
201+
}
202+
if !found {
203+
return "", false
204+
}
205+
source.WriteString(regexp.QuoteMeta(template[index:]))
206+
source.WriteString(`$`)
207+
match := regexp.MustCompile(source.String()).FindStringSubmatch(escapedRequestPath)
208+
if len(match) != 2 {
209+
return "", false
210+
}
211+
value, err := url.PathUnescape(match[1])
212+
return value, err == nil
213+
}
214+
149215
func (c *CLI) planOperationAuthOverride(apiName, profileName string, prof *config.ProfileConfig, policy *operationAuthPolicy) ([]selectedOperationAuth, bool, error) {
150216
override := strings.TrimSpace(policy.Override)
151217
if strings.EqualFold(override, "anonymous") {

internal/cli/operation_auth_test.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package cli
33
import (
44
"context"
55
"net/http"
6+
"reflect"
67
"strings"
78
"sync/atomic"
89
"testing"
@@ -79,6 +80,44 @@ func TestPlanOperationAuthDerivesSatisfiesFromAuthProfileScopes(t *testing.T) {
7980
}
8081
}
8182

83+
func TestPlanOperationAuthSelectsConditionalAlternativeFromConcretePath(t *testing.T) {
84+
c := &CLI{}
85+
prof := &config.ProfileConfig{Credentials: map[string]*config.CredentialConfig{
86+
"provider": {
87+
Auth: &config.AuthConfig{Type: "api-key", Params: map[string]string{"in": "header", "name": "Authorization", "value": "token"}},
88+
Satisfies: []string{"contents:write", "workflows:write"},
89+
},
90+
}}
91+
policy := operationAuthPolicy{
92+
URL: "https://adapter.example.com/github/repos/acme/widgets/contents/.github/workflows/release.yml",
93+
OperationPath: "/repos/{owner}/{repo}/contents/{path}",
94+
CredentialAlternatives: []spec.CredentialAlternative{
95+
{{ID: "provider", Needs: []string{"contents:write"}}},
96+
{{ID: "provider", Needs: []string{"contents:write", "workflows:write"}}},
97+
},
98+
ConditionalSecurity: []spec.ConditionalSecurityRule{{
99+
When: spec.ConditionalSecurityPredicate{PathParameter: "path", Prefix: ".github/workflows/"},
100+
Alternatives: []int{1},
101+
}},
102+
}
103+
selected, handled, err := c.planOperationAuth("github", "default", prof, &policy)
104+
if err != nil {
105+
t.Fatalf("planOperationAuth: %v", err)
106+
}
107+
if !handled || len(selected) != 1 || !reflect.DeepEqual(selected[0].requirement.Needs, []string{"contents:write", "workflows:write"}) {
108+
t.Fatalf("selected = %#v handled=%v, want workflow conjunction", selected, handled)
109+
}
110+
111+
policy.URL = "https://adapter.example.com/github/repos/acme/widgets/contents/README.md"
112+
selected, handled, err = c.planOperationAuth("github", "default", prof, &policy)
113+
if err != nil {
114+
t.Fatalf("planOperationAuth ordinary path: %v", err)
115+
}
116+
if !handled || len(selected) != 1 || !reflect.DeepEqual(selected[0].requirement.Needs, []string{"contents:write"}) {
117+
t.Fatalf("selected = %#v handled=%v, want ordinary contents authority", selected, handled)
118+
}
119+
}
120+
82121
func TestPlanOperationAuthHandlesAnonymousOnlySecurity(t *testing.T) {
83122
c := &CLI{}
84123
prof := &config.ProfileConfig{

internal/cli/operation_route_auth.go

Lines changed: 26 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -44,7 +44,7 @@ func (c *CLI) operationAuthForGenericRequest(ctx context.Context, method, rawURL
4444
if !ok {
4545
continue
4646
}
47-
score, ok := routeTemplateMatchScore(routePath, requestPath)
47+
score, ok := routeTemplateMatchScore(routePath, requestPath, conditionalSecurityPathParameters(op.ConditionalSecurity))
4848
if !ok || score < bestScore {
4949
continue
5050
}
@@ -69,6 +69,8 @@ func (c *CLI) operationAuthForGenericRequest(ctx context.Context, method, rawURL
6969
policy: &operationAuthPolicy{
7070
OptionalAuth: best.OptionalAuth,
7171
CredentialAlternatives: best.CredentialAlternatives,
72+
OperationPath: best.Path,
73+
ConditionalSecurity: best.ConditionalSecurity,
7274
},
7375
}, true
7476
}
@@ -112,20 +114,33 @@ func operationRoutePath(apiCfg *config.APIConfig, profileName, opPath string) (s
112114
return urlpath.Clean(path), true
113115
}
114116

115-
func routeTemplateMatchScore(templatePath, requestPath string) (int, bool) {
117+
func routeTemplateMatchScore(templatePath, requestPath string, greedyParameters map[string]bool) (int, bool) {
116118
templatePath = urlpath.Clean(templatePath)
117119
requestPath = urlpath.Clean(requestPath)
118120
if templatePath == requestPath && !strings.Contains(templatePath, "{") {
119121
return len(templatePath) * 4, true
120122
}
121123
templateSegments := splitCleanPath(templatePath)
122124
requestSegments := splitCleanPath(requestPath)
123-
if len(templateSegments) != len(requestSegments) {
125+
greedyName := ""
126+
if len(templateSegments) > 0 {
127+
last := templateSegments[len(templateSegments)-1]
128+
if strings.HasPrefix(last, "{") && strings.HasSuffix(last, "}") {
129+
name := strings.TrimSuffix(strings.TrimPrefix(last, "{"), "}")
130+
if greedyParameters[name] {
131+
greedyName = name
132+
}
133+
}
134+
}
135+
if len(templateSegments) != len(requestSegments) && (greedyName == "" || len(requestSegments) < len(templateSegments)) {
124136
return 0, false
125137
}
126138
score := 0
127139
for i, templateSegment := range templateSegments {
128140
requestSegment := requestSegments[i]
141+
if i == len(templateSegments)-1 && greedyName != "" {
142+
requestSegment = strings.Join(requestSegments[i:], "/")
143+
}
129144
if strings.Contains(templateSegment, "{") && strings.Contains(templateSegment, "}") {
130145
if requestSegment == "" {
131146
return 0, false
@@ -141,6 +156,14 @@ func routeTemplateMatchScore(templatePath, requestPath string) (int, bool) {
141156
return score, true
142157
}
143158

159+
func conditionalSecurityPathParameters(rules []spec.ConditionalSecurityRule) map[string]bool {
160+
parameters := map[string]bool{}
161+
for _, rule := range rules {
162+
parameters[rule.When.PathParameter] = true
163+
}
164+
return parameters
165+
}
166+
144167
func splitCleanPath(p string) []string {
145168
p = strings.Trim(urlpath.Clean(p), "/")
146169
if p == "" {
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package cli
2+
3+
import (
4+
"testing"
5+
6+
"github.com/rest-sh/restish/v2/internal/spec"
7+
)
8+
9+
func TestConditionalSecurityPathParameterMatchesSlashContainingGenericRequest(t *testing.T) {
10+
rules := []spec.ConditionalSecurityRule{{
11+
When: spec.ConditionalSecurityPredicate{PathParameter: "path", Prefix: ".github/workflows/"},
12+
Alternatives: []int{1},
13+
}}
14+
template := "/github/repos/{owner}/{repo}/contents/{path}"
15+
requestPath := "/github/repos/acme/widgets/contents/.github/workflows/release.yml"
16+
17+
if _, ok := routeTemplateMatchScore(template, requestPath, conditionalSecurityPathParameters(rules)); !ok {
18+
t.Fatal("conditional path parameter did not consume the remaining generic request path")
19+
}
20+
if _, ok := routeTemplateMatchScore(template, requestPath, nil); ok {
21+
t.Fatal("ordinary OpenAPI path parameters must still consume exactly one segment")
22+
}
23+
}

0 commit comments

Comments
 (0)