Skip to content

Commit eb799bc

Browse files
Improvement: Add explicit scope allowlist
1 parent 48a9367 commit eb799bc

3 files changed

Lines changed: 86 additions & 28 deletions

File tree

portal/backend/internal/system/auth/manager.go

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -130,8 +130,11 @@ func (m *Manager) RequireAPI(next http.Handler) http.Handler {
130130
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
131131
required, ok := ScopeForAPIRequest(r.Method, r.URL.Path)
132132
if !ok {
133-
// Preserve the proxy's canonical 404/405 behavior for unknown routes.
134-
next.ServeHTTP(w, r)
133+
if isKnownAPIPath(r.URL.Path) {
134+
writeError(w, http.StatusMethodNotAllowed, "METHOD_NOT_ALLOWED", "method not allowed")
135+
return
136+
}
137+
writeError(w, http.StatusNotFound, "NOT_FOUND", "route not found")
135138
return
136139
}
137140
m.Require(next, required).ServeHTTP(w, r)

portal/backend/internal/system/auth/scopes.go

Lines changed: 67 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,10 @@
55

66
package auth
77

8-
import "strings"
8+
import (
9+
"net/http"
10+
"strings"
11+
)
912

1013
// ScopePrefix and the remaining constants define the canonical portal authorization scopes.
1114
const (
@@ -31,34 +34,73 @@ var AllPortalScopes = []string{
3134
ScopePurposesRead, ScopePurposesWrite,
3235
}
3336

34-
// ScopeForAPIRequest returns the canonical scope for an allowlisted /api request.
37+
type apiScopePolicy struct {
38+
method, pattern, scope string
39+
}
40+
41+
// apiScopePolicies explicitly allowlists operations exposed through the
42+
// catch-all API proxy. New upstream operations require a deliberate policy.
43+
var apiScopePolicies = []apiScopePolicy{
44+
{http.MethodGet, "/api/consents", ScopeConsentsReadAny},
45+
{http.MethodPost, "/api/consents", ScopeConsentsWriteAny},
46+
{http.MethodGet, "/api/consents/attributes", ScopeConsentsReadAny},
47+
{http.MethodPost, "/api/consents/validate", ScopeConsentsReadAny},
48+
{http.MethodGet, "/api/consents/{consentId}", ScopeConsentsReadAny},
49+
{http.MethodPut, "/api/consents/{consentId}", ScopeConsentsWriteAny},
50+
{http.MethodGet, "/api/consents/{consentId}/history", ScopeConsentsReadAny},
51+
{http.MethodPost, "/api/consents/{consentId}/revoke", ScopeConsentsWriteAny},
52+
{http.MethodGet, "/api/consents/{consentId}/authorizations", ScopeConsentsReadAny},
53+
{http.MethodPost, "/api/consents/{consentId}/authorizations", ScopeConsentsWriteAny},
54+
{http.MethodGet, "/api/consents/{consentId}/authorizations/{authorizationId}", ScopeConsentsReadAny},
55+
{http.MethodPut, "/api/consents/{consentId}/authorizations/{authorizationId}", ScopeConsentsWriteAny},
56+
{http.MethodGet, "/api/consent-elements", ScopeElementsRead},
57+
{http.MethodPost, "/api/consent-elements", ScopeElementsWrite},
58+
{http.MethodGet, "/api/consent-elements/{elementId}", ScopeElementsRead},
59+
{http.MethodGet, "/api/consent-elements/{elementId}/versions", ScopeElementsRead},
60+
{http.MethodPost, "/api/consent-elements/{elementId}/versions", ScopeElementsWrite},
61+
{http.MethodGet, "/api/consent-elements/{elementId}/versions/{version}", ScopeElementsRead},
62+
{http.MethodDelete, "/api/consent-elements/{elementId}/versions/{version}", ScopeElementsWrite},
63+
{http.MethodGet, "/api/consent-purposes", ScopePurposesRead},
64+
{http.MethodPost, "/api/consent-purposes", ScopePurposesWrite},
65+
{http.MethodGet, "/api/consent-purposes/{purposeId}", ScopePurposesRead},
66+
{http.MethodGet, "/api/consent-purposes/{purposeId}/versions", ScopePurposesRead},
67+
{http.MethodPost, "/api/consent-purposes/{purposeId}/versions", ScopePurposesWrite},
68+
{http.MethodGet, "/api/consent-purposes/{purposeId}/versions/{version}", ScopePurposesRead},
69+
{http.MethodDelete, "/api/consent-purposes/{purposeId}/versions/{version}", ScopePurposesWrite},
70+
}
71+
72+
// ScopeForAPIRequest returns the canonical scope for an explicitly allowlisted
73+
// API operation.
3574
func ScopeForAPIRequest(method, path string) (string, bool) {
3675
method = strings.ToUpper(method)
37-
parts := strings.Split(strings.Trim(strings.TrimPrefix(path, "/api/"), "/"), "/")
38-
if len(parts) == 0 || parts[0] == "" {
39-
return "", false
40-
}
41-
write := method == "POST" || method == "PUT" || method == "DELETE"
42-
switch parts[0] {
43-
case "consents":
44-
if len(parts) == 2 && parts[1] == "validate" && method == "POST" {
45-
return ScopeConsentsReadAny, true
46-
}
47-
if write {
48-
return ScopeConsentsWriteAny, true
76+
for _, policy := range apiScopePolicies {
77+
if method == policy.method && matchAPIPath(policy.pattern, path) {
78+
return policy.scope, true
4979
}
50-
return ScopeConsentsReadAny, method == "GET"
51-
case "consent-elements":
52-
if write {
53-
return ScopeElementsWrite, true
80+
}
81+
return "", false
82+
}
83+
84+
func isKnownAPIPath(path string) bool {
85+
for _, policy := range apiScopePolicies {
86+
if matchAPIPath(policy.pattern, path) {
87+
return true
5488
}
55-
return ScopeElementsRead, method == "GET"
56-
case "consent-purposes":
57-
if write {
58-
return ScopePurposesWrite, true
89+
}
90+
return false
91+
}
92+
93+
func matchAPIPath(pattern, path string) bool {
94+
patternParts := strings.Split(strings.Trim(pattern, "/"), "/")
95+
pathParts := strings.Split(strings.Trim(path, "/"), "/")
96+
if len(patternParts) != len(pathParts) {
97+
return false
98+
}
99+
for i, patternPart := range patternParts {
100+
placeholder := strings.HasPrefix(patternPart, "{") && strings.HasSuffix(patternPart, "}")
101+
if (!placeholder && patternPart != pathParts[i]) || pathParts[i] == "" {
102+
return false
59103
}
60-
return ScopePurposesRead, method == "GET"
61-
default:
62-
return "", false
63104
}
105+
return true
64106
}

portal/backend/internal/system/auth/scopes_test.go

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -102,14 +102,27 @@ func TestEveryAPIRouteHasCanonicalScopePolicy(t *testing.T) {
102102
}
103103
}
104104
for _, test := range []struct{ method, path string }{
105-
{"PATCH", "/api/consents/c1"}, {"GET", "/api/unknown"}, {"GET", "/not-api/consents"},
105+
{"PATCH", "/api/consents/c1"},
106+
{"GET", "/api/unknown"},
107+
{"GET", "/not-api/consents"},
108+
{"POST", "/api/consents/c1/export"},
109+
{"DELETE", "/api/consent-elements/e1"},
106110
} {
107111
if scope, ok := ScopeForAPIRequest(test.method, test.path); ok {
108112
t.Errorf("unexpected policy for %s %s: %q", test.method, test.path, scope)
109113
}
110114
}
111115
}
112116

117+
func TestKnownAPIPathIgnoresMethod(t *testing.T) {
118+
if !isKnownAPIPath("/api/consents/c1/revoke") {
119+
t.Fatal("documented API path should be known")
120+
}
121+
if isKnownAPIPath("/api/consents/c1/export") {
122+
t.Fatal("undocumented API path should not be known")
123+
}
124+
}
125+
113126
func TestOpenAPIScopesMatchCanonicalRoutePolicies(t *testing.T) {
114127
path := filepath.Join("..", "..", "..", "openapi", "portal-backend.yaml")
115128
content, err := os.ReadFile(path)

0 commit comments

Comments
 (0)