From f6447374356fdc3e62cd3569c708aaf56eda79bb Mon Sep 17 00:00:00 2001 From: youfak Date: Sun, 2 Aug 2026 16:07:27 +0800 Subject: [PATCH] feat: enforce credential extraction policies --- README.md | 3 +- api/openapi/proxy-pool.yaml | 6 +- docs/api/distribution.md | 5 + docs/configuration/reference.md | 30 ++++++ docs/development/implementation-plan.md | 3 +- docs/requirements/completion-audit.md | 4 +- internal/config/config.go | 42 +++++---- internal/config/config_test.go | 35 +++++++ internal/config/redact.go | 2 + internal/config/validate.go | 57 ++++++++++++ internal/controller/distribution/handler.go | 25 ++++- .../controller/distribution/handler_test.go | 82 +++++++++++++++++ internal/domain/clientpolicy/policy.go | 82 +++++++++++++++++ internal/domain/clientpolicy/policy_test.go | 92 +++++++++++++++++++ internal/platform/httpsecurity/auth.go | 31 ++++--- internal/platform/httpsecurity/config.go | 32 ++++--- internal/platform/httpsecurity/config_test.go | 8 +- internal/platform/httpsecurity/protection.go | 5 +- .../platform/httpsecurity/security_test.go | 38 ++++++++ internal/platform/httpsecurity/types.go | 40 ++++---- 20 files changed, 549 insertions(+), 73 deletions(-) create mode 100644 internal/domain/clientpolicy/policy.go create mode 100644 internal/domain/clientpolicy/policy_test.go diff --git a/README.md b/README.md index 41c6fc9..390272f 100644 --- a/README.md +++ b/README.md @@ -79,7 +79,8 @@ Proxy Pool 用 Controller 协调这些变化,并让 Gateway 数据面只消费 定时刷新仍作为跨进程收敛与失效保护。 - **安全边界**:Gateway、Distribution 与 Admin 使用各自的认证语义,并支持 CIDR、可信代理、严格请求解析和敏感信息最小化;Admin 的读写权限与 - Distribution 提取权限可按命中凭据分别收敛。 + Distribution 提取权限可按命中凭据分别收敛。Distribution 凭据还可限制单次 + 提取数量、可访问 Upstream 与地区;未配置时保持既有全范围行为。 ## 架构概览 diff --git a/api/openapi/proxy-pool.yaml b/api/openapi/proxy-pool.yaml index c42b391..a807e0f 100644 --- a/api/openapi/proxy-pool.yaml +++ b/api/openapi/proxy-pool.yaml @@ -9,7 +9,8 @@ info: records are not persisted in PostgreSQL. There is no release, renew, or lease API. An authenticated credential may be limited to the fixed `distribution:extract` permission; legacy credentials without configured - permissions retain full access. + permissions retain full access. Credential client policy may further bound + a request's count, upstreams, and regions before Redis extraction. servers: - url: http://127.0.0.1:8081 description: Distribution API @@ -32,7 +33,8 @@ paths: `partial` 允许实际返回数量小于请求数量;`allOrNothing` 数量不足时不 提取任何代理并返回 409。未传 `fulfillment` 时使用服务端配置,默认 为 `partial`。`Idempotency-Key` 在 Redis 的有界 TTL 窗口内避免客户端因 - 响应丢失重试而再次消耗库存。 + 响应丢失重试而再次消耗库存。命中凭据的 Client policy 可以额外限制 + count、allowedUpstreams 和 regions;越过该边界返回 403,未调用 Redis。 security: - ApiKeyAuth: [] - BasicAuth: [] diff --git a/docs/api/distribution.md b/docs/api/distribution.md index bc114b3..fae4429 100644 --- a/docs/api/distribution.md +++ b/docs/api/distribution.md @@ -202,6 +202,11 @@ TTL 到期或 Redis 数据丢失后不再保证旧 Key 去重,系统不回退 返回 `403`,不会读取请求体、消耗幂等键或调用提取服务。未声明权限的旧凭据保持全 权限兼容。 +Distribution 认证凭据还可声明 client 提取约束:maxExtractCount、 +allowedUpstreams 与 allowedRegions。该约束随实际命中的凭据返回,早于幂等键和 +提取服务执行。未传地区或 Upstream 过滤时,服务端自动写入凭据允许集合;传入 +集合外的值、或数量超过凭据上限时返回 403,并且不会消耗库存。 + ## 10. 短期运行记录与数据最小化 Redis 幂等结果只在配置的 TTL 窗口内保留重放响应所需的数据: diff --git a/docs/configuration/reference.md b/docs/configuration/reference.md index 1e6810a..905d68f 100644 --- a/docs/configuration/reference.md +++ b/docs/configuration/reference.md @@ -150,6 +150,36 @@ admin: permissions: [admin:read, admin:write] ``` +### 3.3 Distribution Client 提取约束 + +Distribution 的已认证凭据可在 auth.client 中设置固定的提取约束;mode: any +则必须在实际命中的 auth.methods 项下配置。当前该字段只允许出现在 +Distribution 监听器,Gateway 与 Admin 配置会被启动校验拒绝,防止出现只声明 +不执行的访问控制。 + +- maxExtractCount:单次提取的额外上限;0 表示不追加上限。 +- allowedUpstreams:允许访问的 Upstream 名称集合。省略请求过滤条件时,服务端 + 自动使用这个集合;请求携带集合外名称会返回 403。 +- allowedRegions:允许访问的地区集合,行为与 allowedUpstreams 相同。 + +空 client 配置保留旧版兼容语义,不会限制已认证凭据。Upstream 名称必须引用 +当前配置中已有的 Upstream。 + +~~~yaml +distribution: + auth: + mode: any + methods: + - mode: apiKey + header: X-API-Key + valueFile: /run/secrets/tenant-a-key + permissions: [distribution:extract] + client: + maxExtractCount: 20 + allowedUpstreams: [provider-a, provider-b] + allowedRegions: [shanghai, beijing] +~~~ + Gateway、Distribution、Admin 与 Provider API 是独立认证边界。改变其中一套 不得连带改变其他入口。 diff --git a/docs/development/implementation-plan.md b/docs/development/implementation-plan.md index d901d55..bb385f1 100644 --- a/docs/development/implementation-plan.md +++ b/docs/development/implementation-plan.md @@ -191,7 +191,8 @@ reject/wait/direct,并会在快照刷新后看到停用状态。 - [x] Keep Provider output in Redis TTL activity state and node memory only; keep the Gateway request path on immutable local snapshots with no Redis/PostgreSQL calls. - [x] Expose Distribution extraction/status and Admin status/audit/enable/disable/switch/reload - HTTP handlers and contracts with credential-level endpoint permissions. + HTTP handlers and contracts with credential-level endpoint permissions and + Distribution credential-level extraction boundaries. - [x] Add Compose-backed Redis 8.2 integration and shared Adapter contract tests. - [x] Add PostgreSQL management Adapter and Compose-backed integration tests. diff --git a/docs/requirements/completion-audit.md b/docs/requirements/completion-audit.md index ff84a58..0ee7f5b 100644 --- a/docs/requirements/completion-audit.md +++ b/docs/requirements/completion-audit.md @@ -47,8 +47,8 @@ - `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin Handler 已实现;Admin 审计查询以有界 `afterId` 游标读取 PostgreSQL 权威记录, 不读取 Proxy 或 Redis 活动池。共享认证、CIDR、可信代理、Client ID、凭据级 - 固定权限与本地准入保护链已接入,Controller Runtime 已将二者装配到独立监听器 - 并支持联动优雅停机。 + 固定权限、Distribution 凭据级提取数量/Upstream/地区限制与本地准入保护链已接入, + Controller Runtime 已将二者装配到独立监听器并支持联动优雅停机。 - `Redis Activity Adapter`:真实 Redis 8.2 已覆盖 Provider Upsert、健康更新、 原子独占提取、短期幂等、Worker ownership、库存和有界过期清理,Memory/Redis 运行同一公用契约。 diff --git a/internal/config/config.go b/internal/config/config.go index ae92d8b..e46d2f7 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -3,6 +3,8 @@ package config import ( "fmt" "time" + + "proxy-pool/internal/domain/clientpolicy" ) // MaximumCheckURLs bounds per-upstream EGRESS references. The bound keeps @@ -78,28 +80,30 @@ type Access struct { } type Auth struct { - Mode string `yaml:"mode"` - Permissions []string `yaml:"permissions"` - Username string `yaml:"username"` - Password string `yaml:"password"` - PasswordFile string `yaml:"passwordFile"` - Token string `yaml:"token"` - TokenFile string `yaml:"tokenFile"` - Header string `yaml:"header"` - CIDRs []string `yaml:"cidrs"` - Methods []AuthMethod `yaml:"methods"` + Mode string `yaml:"mode"` + Permissions []string `yaml:"permissions"` + ClientPolicy clientpolicy.Policy `yaml:"client"` + Username string `yaml:"username"` + Password string `yaml:"password"` + PasswordFile string `yaml:"passwordFile"` + Token string `yaml:"token"` + TokenFile string `yaml:"tokenFile"` + Header string `yaml:"header"` + CIDRs []string `yaml:"cidrs"` + Methods []AuthMethod `yaml:"methods"` } type AuthMethod struct { - Mode string `yaml:"mode"` - Permissions []string `yaml:"permissions"` - Username string `yaml:"username"` - Password string `yaml:"password"` - PasswordFile string `yaml:"passwordFile"` - Header string `yaml:"header"` - Value string `yaml:"value"` - ValueFile string `yaml:"valueFile"` - CIDRs []string `yaml:"cidrs"` + Mode string `yaml:"mode"` + Permissions []string `yaml:"permissions"` + ClientPolicy clientpolicy.Policy `yaml:"client"` + Username string `yaml:"username"` + Password string `yaml:"password"` + PasswordFile string `yaml:"passwordFile"` + Header string `yaml:"header"` + Value string `yaml:"value"` + ValueFile string `yaml:"valueFile"` + CIDRs []string `yaml:"cidrs"` } type Limits struct { diff --git a/internal/config/config_test.go b/internal/config/config_test.go index e6ec5c5..acb5427 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -12,6 +12,7 @@ import ( "go.yaml.in/yaml/v4" "proxy-pool/internal/domain/authorization" + "proxy-pool/internal/domain/clientpolicy" ) const validConfig = ` @@ -365,6 +366,40 @@ func TestValidateListenerPermissionConfiguration(t *testing.T) { } } +func TestValidateDistributionCredentialClientPolicy(t *testing.T) { + t.Parallel() + + cfg, err := Load(strings.NewReader(validConfig)) + if err != nil { + t.Fatalf("Load() error = %v", err) + } + cfg.Distribution.Auth = Auth{ + Mode: "bearer", Token: "distribution-token", + ClientPolicy: clientpolicy.Policy{ + MaxExtractCount: 5, + AllowedUpstreams: []string{"provider-a"}, + AllowedRegions: []string{"shanghai"}, + }, + } + if err := Validate(cfg); err != nil { + t.Fatalf("Validate(distribution client policy) error = %v", err) + } + + cfg.Distribution.Auth.ClientPolicy.AllowedUpstreams = []string{"missing"} + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "missing") { + t.Fatalf("Validate(unknown allowed upstream) error = %v", err) + } + + cfg.Distribution.Auth.ClientPolicy = clientpolicy.Policy{} + cfg.Gateway.Auth = Auth{ + Mode: "bearer", Token: "gateway-token", + ClientPolicy: clientpolicy.Policy{MaxExtractCount: 1}, + } + if err := Validate(cfg); err == nil || !strings.Contains(err.Error(), "only supported on distribution") { + t.Fatalf("Validate(gateway client policy) error = %v", err) + } +} + func TestValidateMetricsListener(t *testing.T) { t.Parallel() cfg := mustLoadValidConfig(t) diff --git a/internal/config/redact.go b/internal/config/redact.go index d4caa7d..6257f38 100644 --- a/internal/config/redact.go +++ b/internal/config/redact.go @@ -81,10 +81,12 @@ func cloneListener(source Listener) Listener { cloned.Access.TrustedProxies = cloneStrings(source.Access.TrustedProxies) cloned.Auth.CIDRs = cloneStrings(source.Auth.CIDRs) cloned.Auth.Permissions = cloneStrings(source.Auth.Permissions) + cloned.Auth.ClientPolicy = source.Auth.ClientPolicy.Clone() cloned.Auth.Methods = append([]AuthMethod(nil), source.Auth.Methods...) for index := range cloned.Auth.Methods { cloned.Auth.Methods[index].CIDRs = cloneStrings(source.Auth.Methods[index].CIDRs) cloned.Auth.Methods[index].Permissions = cloneStrings(source.Auth.Methods[index].Permissions) + cloned.Auth.Methods[index].ClientPolicy = source.Auth.Methods[index].ClientPolicy.Clone() } cloned.Retry.RetryMethods = cloneStrings(source.Retry.RetryMethods) cloned.DestinationPolicy.DenyPrivateNetworks = cloneBool(source.DestinationPolicy.DenyPrivateNetworks) diff --git a/internal/config/validate.go b/internal/config/validate.go index a0fa9c0..9ff4d63 100644 --- a/internal/config/validate.go +++ b/internal/config/validate.go @@ -10,6 +10,7 @@ import ( "strings" "proxy-pool/internal/domain/authorization" + "proxy-pool/internal/domain/clientpolicy" ) var ( @@ -37,6 +38,12 @@ func Validate(cfg *Config) error { return err } } + if err := validateNoClientPolicy("gateway", cfg.Gateway.Auth); err != nil { + return err + } + if err := validateNoClientPolicy("admin", cfg.Admin.Auth); err != nil { + return err + } if cfg.Metrics.Enabled { if cfg.Metrics.Listen == "" { return fmt.Errorf("validate metrics listen: address is required") @@ -96,6 +103,9 @@ func Validate(cfg *Config) error { } } if cfg.Distribution.Enabled { + if err := validateDistributionClientPolicies(cfg.Distribution.Auth, cfg.Upstreams); err != nil { + return err + } clientIdentificationMode := cfg.Distribution.ClientIdentification.Mode if clientIdentificationMode == "" { clientIdentificationMode = "sourceIP" @@ -377,11 +387,17 @@ func validateListenerAuth(listener string, auth Auth) error { if err := authorization.Validate(auth.Permissions); err != nil { return fmt.Errorf("validate %s auth.permissions: %w", listener, err) } + if err := auth.ClientPolicy.Validate(); err != nil { + return fmt.Errorf("validate %s auth.client: %w", listener, err) + } switch auth.Mode { case "none": if len(auth.Permissions) != 0 { return fmt.Errorf("validate %s auth.permissions: requires an authentication mode", listener) } + if !auth.ClientPolicy.IsZero() { + return fmt.Errorf("validate %s auth.client: requires an authentication mode", listener) + } return nil case "usernamePassword": if auth.Username == "" || (auth.Password == "" && auth.PasswordFile == "") { @@ -406,6 +422,9 @@ func validateListenerAuth(listener string, auth Auth) error { if len(auth.Permissions) != 0 { return fmt.Errorf("validate %s auth.permissions: configure permissions on auth.methods", listener) } + if !auth.ClientPolicy.IsZero() { + return fmt.Errorf("validate %s auth.client: configure client policy on auth.methods", listener) + } if len(auth.Methods) == 0 { return fmt.Errorf("validate %s auth.mode any: methods are required", listener) } @@ -424,6 +443,9 @@ func validateAuthMethod(listener string, index int, method AuthMethod) error { if err := authorization.Validate(method.Permissions); err != nil { return fmt.Errorf("validate %s auth.methods[%d].permissions: %w", listener, index, err) } + if err := method.ClientPolicy.Validate(); err != nil { + return fmt.Errorf("validate %s auth.methods[%d].client: %w", listener, index, err) + } switch method.Mode { case "usernamePassword": if method.Username == "" || (method.Password == "" && method.PasswordFile == "") { @@ -450,6 +472,41 @@ func validateAuthMethod(listener string, index int, method AuthMethod) error { return nil } +func validateNoClientPolicy(listener string, auth Auth) error { + if !auth.ClientPolicy.IsZero() { + return fmt.Errorf("validate %s auth.client: client extraction policy is only supported on distribution", listener) + } + for index, method := range auth.Methods { + if !method.ClientPolicy.IsZero() { + return fmt.Errorf("validate %s auth.methods[%d].client: client extraction policy is only supported on distribution", listener, index) + } + } + return nil +} + +func validateDistributionClientPolicies(auth Auth, upstreams map[string]Upstream) error { + if err := validateClientPolicyUpstreams("distribution auth.client", auth.ClientPolicy, upstreams); err != nil { + return err + } + for index, method := range auth.Methods { + if err := validateClientPolicyUpstreams( + fmt.Sprintf("distribution auth.methods[%d].client", index), method.ClientPolicy, upstreams, + ); err != nil { + return err + } + } + return nil +} + +func validateClientPolicyUpstreams(scope string, policy clientpolicy.Policy, upstreams map[string]Upstream) error { + for _, upstream := range policy.AllowedUpstreams { + if _, exists := upstreams[upstream]; !exists { + return fmt.Errorf("validate %s.allowedUpstreams: upstream %q does not exist", scope, upstream) + } + } + return nil +} + func validateCIDRs(name string, cidrs []string) error { for _, cidr := range cidrs { if _, _, err := net.ParseCIDR(cidr); err != nil { diff --git a/internal/controller/distribution/handler.go b/internal/controller/distribution/handler.go index f26d732..76ebd0c 100644 --- a/internal/controller/distribution/handler.go +++ b/internal/controller/distribution/handler.go @@ -9,6 +9,7 @@ import ( controllerExtraction "proxy-pool/internal/controller/extraction" "proxy-pool/internal/domain/authorization" + "proxy-pool/internal/domain/clientpolicy" domainExtraction "proxy-pool/internal/domain/extraction" "proxy-pool/internal/platform/httpapi" "proxy-pool/internal/platform/httpsecurity" @@ -206,7 +207,15 @@ func (h *Handler) handleExtract(writer http.ResponseWriter, request *http.Reques return } - filters := payload.filtersOrZero() + if !identity.ClientPolicy.AllowsExtractCount(payload.Count) { + h.writeProblem(writer, httpapi.NewProblem(http.StatusForbidden, "FORBIDDEN", "Forbidden", "", requestID)) + return + } + filters, allowed := restrictExtractFilters(identity.ClientPolicy, payload.filtersOrZero()) + if !allowed { + h.writeProblem(writer, httpapi.NewProblem(http.StatusForbidden, "FORBIDDEN", "Forbidden", "", requestID)) + return + } serviceResponse, err := h.extractor.Extract(request.Context(), controllerExtraction.Request{ RequestID: requestID, ClientID: identity.ClientID, @@ -252,6 +261,20 @@ func (h *Handler) handleExtract(writer http.ResponseWriter, request *http.Reques h.writeJSON(writer, requestID, http.StatusOK, response) } +func restrictExtractFilters(policy clientpolicy.Policy, filters extractFiltersDTO) (extractFiltersDTO, bool) { + upstreams, allowed := policy.RestrictUpstreams(filters.AllowedUpstreams) + if !allowed { + return extractFiltersDTO{}, false + } + regions, allowed := policy.RestrictRegions(filters.Regions) + if !allowed { + return extractFiltersDTO{}, false + } + filters.AllowedUpstreams = upstreams + filters.Regions = regions + return filters, true +} + func validateIdempotencyKey(values []string) (string, error) { if len(values) == 0 || (len(values) == 1 && values[0] == "") { return "", nil diff --git a/internal/controller/distribution/handler_test.go b/internal/controller/distribution/handler_test.go index a6bae54..66ed09d 100644 --- a/internal/controller/distribution/handler_test.go +++ b/internal/controller/distribution/handler_test.go @@ -7,12 +7,14 @@ import ( "fmt" "net/http" "net/http/httptest" + "slices" "strings" "testing" "time" controllerExtraction "proxy-pool/internal/controller/extraction" "proxy-pool/internal/domain/authorization" + "proxy-pool/internal/domain/clientpolicy" domainExtraction "proxy-pool/internal/domain/extraction" "proxy-pool/internal/platform/httpapi" "proxy-pool/internal/platform/httpsecurity" @@ -180,6 +182,86 @@ func TestHandlerRejectsIdentityWithoutExtractPermission(t *testing.T) { } } +func TestHandlerEnforcesCredentialExtractionPolicy(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + body string + wantStatus int + wantUpstreams []string + wantRegions []string + wantExtractorCall int + }{ + { + name: "omitted filters are bound to credential policy", + body: "{\"count\":2}", + wantStatus: http.StatusOK, + wantUpstreams: []string{"provider-a"}, + wantRegions: []string{"shanghai"}, + wantExtractorCall: 1, + }, + { + name: "count exceeds credential maximum", + body: "{\"count\":3}", + wantStatus: http.StatusForbidden, + wantExtractorCall: 0, + }, + { + name: "upstream exceeds credential boundary", + body: "{\"count\":1,\"filters\":{\"allowedUpstreams\":[\"provider-b\"]}}", + wantStatus: http.StatusForbidden, + wantExtractorCall: 0, + }, + { + name: "region exceeds credential boundary", + body: "{\"count\":1,\"filters\":{\"regions\":[\"beijing\"]}}", + wantStatus: http.StatusForbidden, + wantExtractorCall: 0, + }, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + extractor := &fakeExtractor{} + handler := mustNewHandler(t, Config{BodyLimitBytes: 1024}, Dependencies{ + Extractor: extractor, + Identity: fakeIdentityResolver{identity: Identity{ + ClientID: "tenant-restricted", + Permissions: []string{authorization.DistributionExtract}, + ClientPolicy: clientpolicy.Policy{ + MaxExtractCount: 2, + AllowedUpstreams: []string{"provider-a"}, + AllowedRegions: []string{"shanghai"}, + }, + }}, + Readiness: fakeReadinessChecker{}, + }) + request := httptest.NewRequest(http.MethodPost, pathExtract, strings.NewReader(test.body)) + request.Header.Set("Content-Type", httpapi.JSONContentType) + response := httptest.NewRecorder() + + handler.ServeHTTP(response, request) + + if response.Code != test.wantStatus || extractor.calls != test.wantExtractorCall { + t.Fatalf("response = status %d calls=%d, want status %d calls=%d", + response.Code, extractor.calls, test.wantStatus, test.wantExtractorCall) + } + if response.Code == http.StatusForbidden && !strings.Contains(response.Body.String(), "\"code\":\"FORBIDDEN\"") { + t.Fatalf("forbidden response = %s", response.Body.String()) + } + if got := extractor.request.Filters.Upstreams; !slices.Equal(got, test.wantUpstreams) { + t.Fatalf("upstreams = %v, want %v", got, test.wantUpstreams) + } + if got := extractor.request.Filters.Regions; !slices.Equal(got, test.wantRegions) { + t.Fatalf("regions = %v, want %v", got, test.wantRegions) + } + }) + } +} + func TestHandlerRejectsEmptyResolvedIdentity(t *testing.T) { t.Parallel() extractor := &fakeExtractor{} diff --git a/internal/domain/clientpolicy/policy.go b/internal/domain/clientpolicy/policy.go new file mode 100644 index 0000000..9f3f6bd --- /dev/null +++ b/internal/domain/clientpolicy/policy.go @@ -0,0 +1,82 @@ +// Package clientpolicy defines fixed credential-level limits that can be +// enforced before an extraction reaches the activity-pool store. +package clientpolicy + +import ( + "errors" + "strings" +) + +var ErrInvalidPolicy = errors.New("invalid client policy") + +// Policy limits an authenticated client's extraction request. Zero values +// preserve the existing unrestricted listener behavior. +type Policy struct { + MaxExtractCount int `yaml:"maxExtractCount"` + AllowedUpstreams []string `yaml:"allowedUpstreams"` + AllowedRegions []string `yaml:"allowedRegions"` +} + +func (policy Policy) IsZero() bool { + return policy.MaxExtractCount == 0 && len(policy.AllowedUpstreams) == 0 && len(policy.AllowedRegions) == 0 +} + +func (policy Policy) Validate() error { + if policy.MaxExtractCount < 0 || + !validUniqueValues(policy.AllowedUpstreams) || + !validUniqueValues(policy.AllowedRegions) { + return ErrInvalidPolicy + } + return nil +} + +func (policy Policy) Clone() Policy { + policy.AllowedUpstreams = append([]string(nil), policy.AllowedUpstreams...) + policy.AllowedRegions = append([]string(nil), policy.AllowedRegions...) + return policy +} + +func (policy Policy) AllowsExtractCount(count int) bool { + return policy.MaxExtractCount == 0 || count <= policy.MaxExtractCount +} + +func (policy Policy) RestrictUpstreams(requested []string) ([]string, bool) { + return restrict(requested, policy.AllowedUpstreams) +} + +func (policy Policy) RestrictRegions(requested []string) ([]string, bool) { + return restrict(requested, policy.AllowedRegions) +} + +func restrict(requested, allowed []string) ([]string, bool) { + if len(allowed) == 0 { + return append([]string(nil), requested...), true + } + if len(requested) == 0 { + return append([]string(nil), allowed...), true + } + allowedSet := make(map[string]struct{}, len(allowed)) + for _, value := range allowed { + allowedSet[value] = struct{}{} + } + for _, value := range requested { + if _, ok := allowedSet[value]; !ok { + return nil, false + } + } + return append([]string(nil), requested...), true +} + +func validUniqueValues(values []string) bool { + seen := make(map[string]struct{}, len(values)) + for _, value := range values { + if value == "" || strings.TrimSpace(value) != value { + return false + } + if _, exists := seen[value]; exists { + return false + } + seen[value] = struct{}{} + } + return true +} diff --git a/internal/domain/clientpolicy/policy_test.go b/internal/domain/clientpolicy/policy_test.go new file mode 100644 index 0000000..a3494fd --- /dev/null +++ b/internal/domain/clientpolicy/policy_test.go @@ -0,0 +1,92 @@ +package clientpolicy + +import ( + "errors" + "slices" + "testing" +) + +func TestValidate(t *testing.T) { + t.Parallel() + + tests := []struct { + name string + policy Policy + wantErr bool + }{ + {name: "empty policy", policy: Policy{}}, + {name: "bounded policy", policy: Policy{ + MaxExtractCount: 10, + AllowedUpstreams: []string{"provider-a", "provider-b"}, + AllowedRegions: []string{"shanghai", "beijing"}, + }}, + {name: "negative max count", policy: Policy{MaxExtractCount: -1}, wantErr: true}, + {name: "empty upstream", policy: Policy{AllowedUpstreams: []string{""}}, wantErr: true}, + {name: "whitespace upstream", policy: Policy{AllowedUpstreams: []string{" provider-a"}}, wantErr: true}, + {name: "duplicate upstream", policy: Policy{AllowedUpstreams: []string{"provider-a", "provider-a"}}, wantErr: true}, + {name: "empty region", policy: Policy{AllowedRegions: []string{""}}, wantErr: true}, + {name: "duplicate region", policy: Policy{AllowedRegions: []string{"shanghai", "shanghai"}}, wantErr: true}, + } + + for _, test := range tests { + test := test + t.Run(test.name, func(t *testing.T) { + t.Parallel() + err := test.policy.Validate() + if test.wantErr && !errors.Is(err, ErrInvalidPolicy) { + t.Fatalf("Validate() error = %v, want ErrInvalidPolicy", err) + } + if !test.wantErr && err != nil { + t.Fatalf("Validate() error = %v", err) + } + }) + } +} + +func TestPolicyRestrictsExtractInputs(t *testing.T) { + t.Parallel() + + policy := Policy{ + MaxExtractCount: 5, + AllowedUpstreams: []string{"provider-a", "provider-b"}, + AllowedRegions: []string{"shanghai"}, + } + if !policy.AllowsExtractCount(5) || policy.AllowsExtractCount(6) { + t.Fatal("max extract count restriction was not applied") + } + + upstreams, ok := policy.RestrictUpstreams(nil) + if !ok || !slices.Equal(upstreams, []string{"provider-a", "provider-b"}) { + t.Fatalf("RestrictUpstreams(nil) = (%v, %v)", upstreams, ok) + } + upstreams, ok = policy.RestrictUpstreams([]string{"provider-b"}) + if !ok || !slices.Equal(upstreams, []string{"provider-b"}) { + t.Fatalf("RestrictUpstreams(subset) = (%v, %v)", upstreams, ok) + } + if _, ok = policy.RestrictUpstreams([]string{"provider-c"}); ok { + t.Fatal("RestrictUpstreams() allowed an unauthorized upstream") + } + + regions, ok := policy.RestrictRegions(nil) + if !ok || !slices.Equal(regions, []string{"shanghai"}) { + t.Fatalf("RestrictRegions(nil) = (%v, %v)", regions, ok) + } + if _, ok = policy.RestrictRegions([]string{"beijing"}); ok { + t.Fatal("RestrictRegions() allowed an unauthorized region") + } +} + +func TestPolicyWithoutRestrictionsPreservesRequestAndClones(t *testing.T) { + t.Parallel() + + policy := Policy{} + requested := []string{"provider-a"} + effective, ok := policy.RestrictUpstreams(requested) + if !ok || !slices.Equal(effective, requested) { + t.Fatalf("RestrictUpstreams() = (%v, %v)", effective, ok) + } + effective[0] = "changed" + if requested[0] != "provider-a" { + t.Fatal("RestrictUpstreams() aliases caller input") + } +} diff --git a/internal/platform/httpsecurity/auth.go b/internal/platform/httpsecurity/auth.go index 7ec96aa..6881616 100644 --- a/internal/platform/httpsecurity/auth.go +++ b/internal/platform/httpsecurity/auth.go @@ -11,6 +11,7 @@ import ( "strings" "proxy-pool/internal/domain/authorization" + "proxy-pool/internal/domain/clientpolicy" ) var ( @@ -24,8 +25,9 @@ type authenticator interface { } type authenticationResult struct { - Principal string - Permissions []string + Principal string + Permissions []string + ClientPolicy clientpolicy.Policy } type noAuthenticator struct{} @@ -36,8 +38,9 @@ func (noAuthenticator) authenticate(*http.Request, string) (authenticationResult func (noAuthenticator) challenges() []string { return nil } type scopedAuthenticator struct { - delegate authenticator - permissions []string + delegate authenticator + permissions []string + clientPolicy clientpolicy.Policy } func (auth scopedAuthenticator) authenticate(request *http.Request, source string) (authenticationResult, error) { @@ -46,6 +49,7 @@ func (auth scopedAuthenticator) authenticate(request *http.Request, source strin return authenticationResult{}, err } result.Permissions = append([]string(nil), auth.permissions...) + result.ClientPolicy = auth.clientPolicy.Clone() return result, nil } @@ -138,13 +142,16 @@ func buildAuthenticator(authentication Authentication, semantics Semantics) (aut if err := authorization.Validate(authentication.Permissions); err != nil { return nil, ErrInvalidConfig } + if err := authentication.ClientPolicy.Validate(); err != nil { + return nil, ErrInvalidConfig + } header := "Authorization" if semantics == ProxySemantics { header = "Proxy-Authorization" } switch authentication.Mode { case "", ModeNone: - if len(authentication.Permissions) != 0 { + if len(authentication.Permissions) != 0 || !authentication.ClientPolicy.IsZero() { return nil, ErrInvalidConfig } return noAuthenticator{}, nil @@ -152,25 +159,25 @@ func buildAuthenticator(authentication Authentication, semantics Semantics) (aut if authentication.Username == "" || authentication.Password == "" { return nil, ErrInvalidConfig } - return scopedAuthenticator{delegate: basicAuthenticator{header: header, username: authentication.Username, password: authentication.Password}, permissions: authentication.Permissions}, nil + return scopedAuthenticator{delegate: basicAuthenticator{header: header, username: authentication.Username, password: authentication.Password}, permissions: authentication.Permissions, clientPolicy: authentication.ClientPolicy.Clone()}, nil case ModeAPIKey: if !validHeaderName(authentication.Header) || authentication.Token == "" { return nil, ErrInvalidConfig } - return scopedAuthenticator{delegate: tokenAuthenticator{mode: ModeAPIKey, header: authentication.Header, token: authentication.Token}, permissions: authentication.Permissions}, nil + return scopedAuthenticator{delegate: tokenAuthenticator{mode: ModeAPIKey, header: authentication.Header, token: authentication.Token}, permissions: authentication.Permissions, clientPolicy: authentication.ClientPolicy.Clone()}, nil case ModeBearer: if authentication.Token == "" { return nil, ErrInvalidConfig } - return scopedAuthenticator{delegate: tokenAuthenticator{mode: ModeBearer, header: header, token: authentication.Token}, permissions: authentication.Permissions}, nil + return scopedAuthenticator{delegate: tokenAuthenticator{mode: ModeBearer, header: header, token: authentication.Token}, permissions: authentication.Permissions, clientPolicy: authentication.ClientPolicy.Clone()}, nil case ModeIPWhitelist: allowed, err := newCIDRMatcher(authentication.CIDRs) if err != nil || len(authentication.CIDRs) == 0 { return nil, ErrInvalidConfig } - return scopedAuthenticator{delegate: ipAuthenticator{allowed: allowed}, permissions: authentication.Permissions}, nil + return scopedAuthenticator{delegate: ipAuthenticator{allowed: allowed}, permissions: authentication.Permissions, clientPolicy: authentication.ClientPolicy.Clone()}, nil case ModeAny: - if len(authentication.Permissions) != 0 { + if len(authentication.Permissions) != 0 || !authentication.ClientPolicy.IsZero() { return nil, ErrInvalidConfig } if len(authentication.Methods) == 0 { @@ -195,8 +202,8 @@ func buildMethod(method Method, semantics Semantics) (authenticator, error) { return nil, ErrInvalidConfig } authentication := Authentication{ - Mode: method.Mode, Permissions: method.Permissions, Username: method.Username, - Password: method.Password, Header: method.Header, Token: method.Value, CIDRs: method.CIDRs, + Mode: method.Mode, Permissions: method.Permissions, ClientPolicy: method.ClientPolicy, + Username: method.Username, Password: method.Password, Header: method.Header, Token: method.Value, CIDRs: method.CIDRs, } if method.Mode == ModeAny || method.Mode == ModeNone || method.Mode == "" { return nil, ErrInvalidConfig diff --git a/internal/platform/httpsecurity/config.go b/internal/platform/httpsecurity/config.go index 6171753..e9888fc 100644 --- a/internal/platform/httpsecurity/config.go +++ b/internal/platform/httpsecurity/config.go @@ -28,27 +28,29 @@ func NewFromListener(listener config.Listener, clientIdentification string, sema methods := make([]Method, 0, len(listener.Auth.Methods)) for _, method := range listener.Auth.Methods { methods = append(methods, Method{ - Mode: method.Mode, - Permissions: append([]string(nil), method.Permissions...), - Username: method.Username, - Password: method.Password, - Header: method.Header, - Value: method.Value, - CIDRs: append([]string(nil), method.CIDRs...), + Mode: method.Mode, + Permissions: append([]string(nil), method.Permissions...), + ClientPolicy: method.ClientPolicy.Clone(), + Username: method.Username, + Password: method.Password, + Header: method.Header, + Value: method.Value, + CIDRs: append([]string(nil), method.CIDRs...), }) } return New(Config{ TrustedProxies: append([]string(nil), listener.Access.TrustedProxies...), AllowCIDRs: append([]string(nil), listener.Access.AllowCIDRs...), Authentication: Authentication{ - Mode: listener.Auth.Mode, - Permissions: append([]string(nil), listener.Auth.Permissions...), - Username: listener.Auth.Username, - Password: listener.Auth.Password, - Header: listener.Auth.Header, - Token: listener.Auth.Token, - CIDRs: append([]string(nil), listener.Auth.CIDRs...), - Methods: methods, + Mode: listener.Auth.Mode, + Permissions: append([]string(nil), listener.Auth.Permissions...), + ClientPolicy: listener.Auth.ClientPolicy.Clone(), + Username: listener.Auth.Username, + Password: listener.Auth.Password, + Header: listener.Auth.Header, + Token: listener.Auth.Token, + CIDRs: append([]string(nil), listener.Auth.CIDRs...), + Methods: methods, }, ClientIdentification: clientIdentification, Semantics: semantics, diff --git a/internal/platform/httpsecurity/config_test.go b/internal/platform/httpsecurity/config_test.go index 83a2653..4bd2fc8 100644 --- a/internal/platform/httpsecurity/config_test.go +++ b/internal/platform/httpsecurity/config_test.go @@ -8,6 +8,7 @@ import ( "proxy-pool/internal/config" "proxy-pool/internal/domain/authorization" + "proxy-pool/internal/domain/clientpolicy" ) func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) { @@ -18,7 +19,8 @@ func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) { TrustedProxies: []string{"10.0.0.0/8"}, }, Auth: config.Auth{Mode: ModeAny, Methods: []config.AuthMethod{ - {Mode: ModeBearer, Value: "bearer-secret", Permissions: []string{authorization.AdminRead}}, + {Mode: ModeBearer, Value: "bearer-secret", Permissions: []string{authorization.AdminRead}, + ClientPolicy: clientpolicy.Policy{MaxExtractCount: 2, AllowedUpstreams: []string{"provider-a"}}}, {Mode: ModeAPIKey, Header: "X-API-Key", Value: "api-secret", Permissions: []string{authorization.AdminWrite}}, }}, } @@ -39,6 +41,10 @@ func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) { if !identity.Allows(authorization.AdminRead) || identity.Allows(authorization.AdminWrite) { t.Fatalf("identity permissions = %v", identity.Permissions) } + if identity.ClientPolicy.MaxExtractCount != 2 || len(identity.ClientPolicy.AllowedUpstreams) != 1 || + identity.ClientPolicy.AllowedUpstreams[0] != "provider-a" { + t.Fatalf("identity client policy = %+v", identity.ClientPolicy) + } if _, err := protection.Resolve(httptest.NewRequest(http.MethodGet, "/", nil)); err == nil { t.Fatal("Resolve(request without remote address) error = nil") } diff --git a/internal/platform/httpsecurity/protection.go b/internal/platform/httpsecurity/protection.go index d85dff2..e07a21e 100644 --- a/internal/platform/httpsecurity/protection.go +++ b/internal/platform/httpsecurity/protection.go @@ -80,7 +80,10 @@ func (protection *Protection) evaluate(ctx context.Context, request *http.Reques } return Identity{}, protection.unauthorized(err) } - identity := Identity{SourceIP: source, Permissions: append([]string(nil), authentication.Permissions...)} + identity := Identity{ + SourceIP: source, Permissions: append([]string(nil), authentication.Permissions...), + ClientPolicy: authentication.ClientPolicy.Clone(), + } switch protection.clientMode { case ClientSourceIP: identity.ClientID = "source:" + source diff --git a/internal/platform/httpsecurity/security_test.go b/internal/platform/httpsecurity/security_test.go index 2bf9120..b56eae8 100644 --- a/internal/platform/httpsecurity/security_test.go +++ b/internal/platform/httpsecurity/security_test.go @@ -11,6 +11,7 @@ import ( "testing" "proxy-pool/internal/domain/authorization" + "proxy-pool/internal/domain/clientpolicy" ) func TestProtectionAuthenticatesAPIRequestsAndBuildsStableIdentity(t *testing.T) { @@ -139,6 +140,43 @@ func TestProtectionCarriesPermissionsFromMatchedCredential(t *testing.T) { } } +func TestProtectionCarriesClientPolicyFromMatchedCredential(t *testing.T) { + t.Parallel() + + protection := mustProtection(t, Config{ + Authentication: Authentication{Mode: ModeAny, Methods: []Method{ + {Mode: ModeBearer, Value: "reader-token", ClientPolicy: clientpolicy.Policy{ + MaxExtractCount: 1, AllowedUpstreams: []string{"provider-reader"}, + }}, + {Mode: ModeBearer, Value: "writer-token", ClientPolicy: clientpolicy.Policy{ + MaxExtractCount: 5, AllowedUpstreams: []string{"provider-a"}, AllowedRegions: []string{"shanghai"}, + }}, + }}, + ClientIdentification: ClientAuthenticated, + }, nil) + request := newRequest() + request.Header.Set("Authorization", "Bearer writer-token") + + identity, err := protection.Resolve(request) + + if err != nil { + t.Fatalf("Resolve() error = %v", err) + } + want := clientpolicy.Policy{ + MaxExtractCount: 5, AllowedUpstreams: []string{"provider-a"}, AllowedRegions: []string{"shanghai"}, + } + if identity.ClientPolicy.MaxExtractCount != want.MaxExtractCount || + !slices.Equal(identity.ClientPolicy.AllowedUpstreams, want.AllowedUpstreams) || + !slices.Equal(identity.ClientPolicy.AllowedRegions, want.AllowedRegions) { + t.Fatalf("ClientPolicy = %+v, want %+v", identity.ClientPolicy, want) + } + identity.ClientPolicy.AllowedUpstreams[0] = "mutated" + next, err := protection.Resolve(request) + if err != nil || next.ClientPolicy.AllowedUpstreams[0] != "provider-a" { + t.Fatalf("subsequent Resolve() = (%+v, %v), policy aliases authenticator", next.ClientPolicy, err) + } +} + func TestProtectionAnyPreservesSourceRejection(t *testing.T) { t.Parallel() tests := []struct { diff --git a/internal/platform/httpsecurity/types.go b/internal/platform/httpsecurity/types.go index bbd71c1..c2d563c 100644 --- a/internal/platform/httpsecurity/types.go +++ b/internal/platform/httpsecurity/types.go @@ -6,6 +6,7 @@ import ( "net/http" "proxy-pool/internal/domain/authorization" + "proxy-pool/internal/domain/clientpolicy" ) const ( @@ -31,24 +32,26 @@ var ErrInvalidConfig = errors.New("invalid HTTP security configuration") type Semantics uint8 type Authentication struct { - Mode string - Permissions []string - Username string - Password string - Header string - Token string - CIDRs []string - Methods []Method + Mode string + Permissions []string + ClientPolicy clientpolicy.Policy + Username string + Password string + Header string + Token string + CIDRs []string + Methods []Method } type Method struct { - Mode string - Permissions []string - Username string - Password string - Header string - Value string - CIDRs []string + Mode string + Permissions []string + ClientPolicy clientpolicy.Policy + Username string + Password string + Header string + Value string + CIDRs []string } type Config struct { @@ -60,9 +63,10 @@ type Config struct { } type Identity struct { - ClientID string - SourceIP string - Permissions []string + ClientID string + SourceIP string + Permissions []string + ClientPolicy clientpolicy.Policy } // Allows reports whether this identity has the endpoint's fixed permission.