feat: enforce credential extraction policies

This commit is contained in:
youfak 2026-08-02 16:07:27 +08:00
parent aaed288211
commit f644737435
20 changed files with 549 additions and 73 deletions

View File

@ -79,7 +79,8 @@ Proxy Pool 用 Controller 协调这些变化,并让 Gateway 数据面只消费
定时刷新仍作为跨进程收敛与失效保护。 定时刷新仍作为跨进程收敛与失效保护。
- **安全边界**Gateway、Distribution 与 Admin 使用各自的认证语义,并支持 - **安全边界**Gateway、Distribution 与 Admin 使用各自的认证语义,并支持
CIDR、可信代理、严格请求解析和敏感信息最小化Admin 的读写权限与 CIDR、可信代理、严格请求解析和敏感信息最小化Admin 的读写权限与
Distribution 提取权限可按命中凭据分别收敛。 Distribution 提取权限可按命中凭据分别收敛。Distribution 凭据还可限制单次
提取数量、可访问 Upstream 与地区;未配置时保持既有全范围行为。
## 架构概览 ## 架构概览

View File

@ -9,7 +9,8 @@ info:
records are not persisted in PostgreSQL. There is no release, renew, or records are not persisted in PostgreSQL. There is no release, renew, or
lease API. An authenticated credential may be limited to the fixed lease API. An authenticated credential may be limited to the fixed
`distribution:extract` permission; legacy credentials without configured `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: servers:
- url: http://127.0.0.1:8081 - url: http://127.0.0.1:8081
description: Distribution API description: Distribution API
@ -32,7 +33,8 @@ paths:
`partial` 允许实际返回数量小于请求数量;`allOrNothing` 数量不足时不 `partial` 允许实际返回数量小于请求数量;`allOrNothing` 数量不足时不
提取任何代理并返回 409。未传 `fulfillment` 时使用服务端配置,默认 提取任何代理并返回 409。未传 `fulfillment` 时使用服务端配置,默认
为 `partial`。`Idempotency-Key` 在 Redis 的有界 TTL 窗口内避免客户端因 为 `partial`。`Idempotency-Key` 在 Redis 的有界 TTL 窗口内避免客户端因
响应丢失重试而再次消耗库存。 响应丢失重试而再次消耗库存。命中凭据的 Client policy 可以额外限制
count、allowedUpstreams 和 regions越过该边界返回 403未调用 Redis。
security: security:
- ApiKeyAuth: [] - ApiKeyAuth: []
- BasicAuth: [] - BasicAuth: []

View File

@ -202,6 +202,11 @@ TTL 到期或 Redis 数据丢失后不再保证旧 Key 去重,系统不回退
返回 `403`,不会读取请求体、消耗幂等键或调用提取服务。未声明权限的旧凭据保持全 返回 `403`,不会读取请求体、消耗幂等键或调用提取服务。未声明权限的旧凭据保持全
权限兼容。 权限兼容。
Distribution 认证凭据还可声明 client 提取约束maxExtractCount、
allowedUpstreams 与 allowedRegions。该约束随实际命中的凭据返回早于幂等键和
提取服务执行。未传地区或 Upstream 过滤时,服务端自动写入凭据允许集合;传入
集合外的值、或数量超过凭据上限时返回 403并且不会消耗库存。
## 10. 短期运行记录与数据最小化 ## 10. 短期运行记录与数据最小化
Redis 幂等结果只在配置的 TTL 窗口内保留重放响应所需的数据: Redis 幂等结果只在配置的 TTL 窗口内保留重放响应所需的数据:

View File

@ -150,6 +150,36 @@ admin:
permissions: [admin:read, admin:write] 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 是独立认证边界。改变其中一套 Gateway、Distribution、Admin 与 Provider API 是独立认证边界。改变其中一套
不得连带改变其他入口。 不得连带改变其他入口。

View File

@ -191,7 +191,8 @@ reject/wait/direct并会在快照刷新后看到停用状态。
- [x] Keep Provider output in Redis TTL activity state and node memory only; keep the - [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. 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 - [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 Compose-backed Redis 8.2 integration and shared Adapter contract tests.
- [x] Add PostgreSQL management Adapter and Compose-backed integration tests. - [x] Add PostgreSQL management Adapter and Compose-backed integration tests.

View File

@ -47,8 +47,8 @@
- `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin - `DIST/Admin HTTP`:严格 JSON、Request ID、Problem 响应及 Distribution/Admin
Handler 已实现Admin 审计查询以有界 `afterId` 游标读取 PostgreSQL 权威记录, Handler 已实现Admin 审计查询以有界 `afterId` 游标读取 PostgreSQL 权威记录,
不读取 Proxy 或 Redis 活动池。共享认证、CIDR、可信代理、Client ID、凭据级 不读取 Proxy 或 Redis 活动池。共享认证、CIDR、可信代理、Client ID、凭据级
固定权限与本地准入保护链已接入,Controller Runtime 已将二者装配到独立监听器 固定权限、Distribution 凭据级提取数量/Upstream/地区限制与本地准入保护链已接入,
并支持联动优雅停机。 Controller Runtime 已将二者装配到独立监听器并支持联动优雅停机。
- `Redis Activity Adapter`:真实 Redis 8.2 已覆盖 Provider Upsert、健康更新、 - `Redis Activity Adapter`:真实 Redis 8.2 已覆盖 Provider Upsert、健康更新、
原子独占提取、短期幂等、Worker ownership、库存和有界过期清理Memory/Redis 原子独占提取、短期幂等、Worker ownership、库存和有界过期清理Memory/Redis
运行同一公用契约。 运行同一公用契约。

View File

@ -3,6 +3,8 @@ package config
import ( import (
"fmt" "fmt"
"time" "time"
"proxy-pool/internal/domain/clientpolicy"
) )
// MaximumCheckURLs bounds per-upstream EGRESS references. The bound keeps // MaximumCheckURLs bounds per-upstream EGRESS references. The bound keeps
@ -78,28 +80,30 @@ type Access struct {
} }
type Auth struct { type Auth struct {
Mode string `yaml:"mode"` Mode string `yaml:"mode"`
Permissions []string `yaml:"permissions"` Permissions []string `yaml:"permissions"`
Username string `yaml:"username"` ClientPolicy clientpolicy.Policy `yaml:"client"`
Password string `yaml:"password"` Username string `yaml:"username"`
PasswordFile string `yaml:"passwordFile"` Password string `yaml:"password"`
Token string `yaml:"token"` PasswordFile string `yaml:"passwordFile"`
TokenFile string `yaml:"tokenFile"` Token string `yaml:"token"`
Header string `yaml:"header"` TokenFile string `yaml:"tokenFile"`
CIDRs []string `yaml:"cidrs"` Header string `yaml:"header"`
Methods []AuthMethod `yaml:"methods"` CIDRs []string `yaml:"cidrs"`
Methods []AuthMethod `yaml:"methods"`
} }
type AuthMethod struct { type AuthMethod struct {
Mode string `yaml:"mode"` Mode string `yaml:"mode"`
Permissions []string `yaml:"permissions"` Permissions []string `yaml:"permissions"`
Username string `yaml:"username"` ClientPolicy clientpolicy.Policy `yaml:"client"`
Password string `yaml:"password"` Username string `yaml:"username"`
PasswordFile string `yaml:"passwordFile"` Password string `yaml:"password"`
Header string `yaml:"header"` PasswordFile string `yaml:"passwordFile"`
Value string `yaml:"value"` Header string `yaml:"header"`
ValueFile string `yaml:"valueFile"` Value string `yaml:"value"`
CIDRs []string `yaml:"cidrs"` ValueFile string `yaml:"valueFile"`
CIDRs []string `yaml:"cidrs"`
} }
type Limits struct { type Limits struct {

View File

@ -12,6 +12,7 @@ import (
"go.yaml.in/yaml/v4" "go.yaml.in/yaml/v4"
"proxy-pool/internal/domain/authorization" "proxy-pool/internal/domain/authorization"
"proxy-pool/internal/domain/clientpolicy"
) )
const validConfig = ` 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) { func TestValidateMetricsListener(t *testing.T) {
t.Parallel() t.Parallel()
cfg := mustLoadValidConfig(t) cfg := mustLoadValidConfig(t)

View File

@ -81,10 +81,12 @@ func cloneListener(source Listener) Listener {
cloned.Access.TrustedProxies = cloneStrings(source.Access.TrustedProxies) cloned.Access.TrustedProxies = cloneStrings(source.Access.TrustedProxies)
cloned.Auth.CIDRs = cloneStrings(source.Auth.CIDRs) cloned.Auth.CIDRs = cloneStrings(source.Auth.CIDRs)
cloned.Auth.Permissions = cloneStrings(source.Auth.Permissions) cloned.Auth.Permissions = cloneStrings(source.Auth.Permissions)
cloned.Auth.ClientPolicy = source.Auth.ClientPolicy.Clone()
cloned.Auth.Methods = append([]AuthMethod(nil), source.Auth.Methods...) cloned.Auth.Methods = append([]AuthMethod(nil), source.Auth.Methods...)
for index := range cloned.Auth.Methods { for index := range cloned.Auth.Methods {
cloned.Auth.Methods[index].CIDRs = cloneStrings(source.Auth.Methods[index].CIDRs) 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].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.Retry.RetryMethods = cloneStrings(source.Retry.RetryMethods)
cloned.DestinationPolicy.DenyPrivateNetworks = cloneBool(source.DestinationPolicy.DenyPrivateNetworks) cloned.DestinationPolicy.DenyPrivateNetworks = cloneBool(source.DestinationPolicy.DenyPrivateNetworks)

View File

@ -10,6 +10,7 @@ import (
"strings" "strings"
"proxy-pool/internal/domain/authorization" "proxy-pool/internal/domain/authorization"
"proxy-pool/internal/domain/clientpolicy"
) )
var ( var (
@ -37,6 +38,12 @@ func Validate(cfg *Config) error {
return err 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.Enabled {
if cfg.Metrics.Listen == "" { if cfg.Metrics.Listen == "" {
return fmt.Errorf("validate metrics listen: address is required") return fmt.Errorf("validate metrics listen: address is required")
@ -96,6 +103,9 @@ func Validate(cfg *Config) error {
} }
} }
if cfg.Distribution.Enabled { if cfg.Distribution.Enabled {
if err := validateDistributionClientPolicies(cfg.Distribution.Auth, cfg.Upstreams); err != nil {
return err
}
clientIdentificationMode := cfg.Distribution.ClientIdentification.Mode clientIdentificationMode := cfg.Distribution.ClientIdentification.Mode
if clientIdentificationMode == "" { if clientIdentificationMode == "" {
clientIdentificationMode = "sourceIP" clientIdentificationMode = "sourceIP"
@ -377,11 +387,17 @@ func validateListenerAuth(listener string, auth Auth) error {
if err := authorization.Validate(auth.Permissions); err != nil { if err := authorization.Validate(auth.Permissions); err != nil {
return fmt.Errorf("validate %s auth.permissions: %w", listener, err) 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 { switch auth.Mode {
case "none": case "none":
if len(auth.Permissions) != 0 { if len(auth.Permissions) != 0 {
return fmt.Errorf("validate %s auth.permissions: requires an authentication mode", listener) 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 return nil
case "usernamePassword": case "usernamePassword":
if auth.Username == "" || (auth.Password == "" && auth.PasswordFile == "") { if auth.Username == "" || (auth.Password == "" && auth.PasswordFile == "") {
@ -406,6 +422,9 @@ func validateListenerAuth(listener string, auth Auth) error {
if len(auth.Permissions) != 0 { if len(auth.Permissions) != 0 {
return fmt.Errorf("validate %s auth.permissions: configure permissions on auth.methods", listener) 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 { if len(auth.Methods) == 0 {
return fmt.Errorf("validate %s auth.mode any: methods are required", listener) 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 { if err := authorization.Validate(method.Permissions); err != nil {
return fmt.Errorf("validate %s auth.methods[%d].permissions: %w", listener, index, err) 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 { switch method.Mode {
case "usernamePassword": case "usernamePassword":
if method.Username == "" || (method.Password == "" && method.PasswordFile == "") { if method.Username == "" || (method.Password == "" && method.PasswordFile == "") {
@ -450,6 +472,41 @@ func validateAuthMethod(listener string, index int, method AuthMethod) error {
return nil 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 { func validateCIDRs(name string, cidrs []string) error {
for _, cidr := range cidrs { for _, cidr := range cidrs {
if _, _, err := net.ParseCIDR(cidr); err != nil { if _, _, err := net.ParseCIDR(cidr); err != nil {

View File

@ -9,6 +9,7 @@ import (
controllerExtraction "proxy-pool/internal/controller/extraction" controllerExtraction "proxy-pool/internal/controller/extraction"
"proxy-pool/internal/domain/authorization" "proxy-pool/internal/domain/authorization"
"proxy-pool/internal/domain/clientpolicy"
domainExtraction "proxy-pool/internal/domain/extraction" domainExtraction "proxy-pool/internal/domain/extraction"
"proxy-pool/internal/platform/httpapi" "proxy-pool/internal/platform/httpapi"
"proxy-pool/internal/platform/httpsecurity" "proxy-pool/internal/platform/httpsecurity"
@ -206,7 +207,15 @@ func (h *Handler) handleExtract(writer http.ResponseWriter, request *http.Reques
return 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{ serviceResponse, err := h.extractor.Extract(request.Context(), controllerExtraction.Request{
RequestID: requestID, RequestID: requestID,
ClientID: identity.ClientID, ClientID: identity.ClientID,
@ -252,6 +261,20 @@ func (h *Handler) handleExtract(writer http.ResponseWriter, request *http.Reques
h.writeJSON(writer, requestID, http.StatusOK, response) 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) { func validateIdempotencyKey(values []string) (string, error) {
if len(values) == 0 || (len(values) == 1 && values[0] == "") { if len(values) == 0 || (len(values) == 1 && values[0] == "") {
return "", nil return "", nil

View File

@ -7,12 +7,14 @@ import (
"fmt" "fmt"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"slices"
"strings" "strings"
"testing" "testing"
"time" "time"
controllerExtraction "proxy-pool/internal/controller/extraction" controllerExtraction "proxy-pool/internal/controller/extraction"
"proxy-pool/internal/domain/authorization" "proxy-pool/internal/domain/authorization"
"proxy-pool/internal/domain/clientpolicy"
domainExtraction "proxy-pool/internal/domain/extraction" domainExtraction "proxy-pool/internal/domain/extraction"
"proxy-pool/internal/platform/httpapi" "proxy-pool/internal/platform/httpapi"
"proxy-pool/internal/platform/httpsecurity" "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) { func TestHandlerRejectsEmptyResolvedIdentity(t *testing.T) {
t.Parallel() t.Parallel()
extractor := &fakeExtractor{} extractor := &fakeExtractor{}

View File

@ -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
}

View File

@ -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")
}
}

View File

@ -11,6 +11,7 @@ import (
"strings" "strings"
"proxy-pool/internal/domain/authorization" "proxy-pool/internal/domain/authorization"
"proxy-pool/internal/domain/clientpolicy"
) )
var ( var (
@ -24,8 +25,9 @@ type authenticator interface {
} }
type authenticationResult struct { type authenticationResult struct {
Principal string Principal string
Permissions []string Permissions []string
ClientPolicy clientpolicy.Policy
} }
type noAuthenticator struct{} type noAuthenticator struct{}
@ -36,8 +38,9 @@ func (noAuthenticator) authenticate(*http.Request, string) (authenticationResult
func (noAuthenticator) challenges() []string { return nil } func (noAuthenticator) challenges() []string { return nil }
type scopedAuthenticator struct { type scopedAuthenticator struct {
delegate authenticator delegate authenticator
permissions []string permissions []string
clientPolicy clientpolicy.Policy
} }
func (auth scopedAuthenticator) authenticate(request *http.Request, source string) (authenticationResult, error) { 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 return authenticationResult{}, err
} }
result.Permissions = append([]string(nil), auth.permissions...) result.Permissions = append([]string(nil), auth.permissions...)
result.ClientPolicy = auth.clientPolicy.Clone()
return result, nil return result, nil
} }
@ -138,13 +142,16 @@ func buildAuthenticator(authentication Authentication, semantics Semantics) (aut
if err := authorization.Validate(authentication.Permissions); err != nil { if err := authorization.Validate(authentication.Permissions); err != nil {
return nil, ErrInvalidConfig return nil, ErrInvalidConfig
} }
if err := authentication.ClientPolicy.Validate(); err != nil {
return nil, ErrInvalidConfig
}
header := "Authorization" header := "Authorization"
if semantics == ProxySemantics { if semantics == ProxySemantics {
header = "Proxy-Authorization" header = "Proxy-Authorization"
} }
switch authentication.Mode { switch authentication.Mode {
case "", ModeNone: case "", ModeNone:
if len(authentication.Permissions) != 0 { if len(authentication.Permissions) != 0 || !authentication.ClientPolicy.IsZero() {
return nil, ErrInvalidConfig return nil, ErrInvalidConfig
} }
return noAuthenticator{}, nil return noAuthenticator{}, nil
@ -152,25 +159,25 @@ func buildAuthenticator(authentication Authentication, semantics Semantics) (aut
if authentication.Username == "" || authentication.Password == "" { if authentication.Username == "" || authentication.Password == "" {
return nil, ErrInvalidConfig 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: case ModeAPIKey:
if !validHeaderName(authentication.Header) || authentication.Token == "" { if !validHeaderName(authentication.Header) || authentication.Token == "" {
return nil, ErrInvalidConfig 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: case ModeBearer:
if authentication.Token == "" { if authentication.Token == "" {
return nil, ErrInvalidConfig 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: case ModeIPWhitelist:
allowed, err := newCIDRMatcher(authentication.CIDRs) allowed, err := newCIDRMatcher(authentication.CIDRs)
if err != nil || len(authentication.CIDRs) == 0 { if err != nil || len(authentication.CIDRs) == 0 {
return nil, ErrInvalidConfig 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: case ModeAny:
if len(authentication.Permissions) != 0 { if len(authentication.Permissions) != 0 || !authentication.ClientPolicy.IsZero() {
return nil, ErrInvalidConfig return nil, ErrInvalidConfig
} }
if len(authentication.Methods) == 0 { if len(authentication.Methods) == 0 {
@ -195,8 +202,8 @@ func buildMethod(method Method, semantics Semantics) (authenticator, error) {
return nil, ErrInvalidConfig return nil, ErrInvalidConfig
} }
authentication := Authentication{ authentication := Authentication{
Mode: method.Mode, Permissions: method.Permissions, Username: method.Username, Mode: method.Mode, Permissions: method.Permissions, ClientPolicy: method.ClientPolicy,
Password: method.Password, Header: method.Header, Token: method.Value, CIDRs: method.CIDRs, Username: method.Username, Password: method.Password, Header: method.Header, Token: method.Value, CIDRs: method.CIDRs,
} }
if method.Mode == ModeAny || method.Mode == ModeNone || method.Mode == "" { if method.Mode == ModeAny || method.Mode == ModeNone || method.Mode == "" {
return nil, ErrInvalidConfig return nil, ErrInvalidConfig

View File

@ -28,27 +28,29 @@ func NewFromListener(listener config.Listener, clientIdentification string, sema
methods := make([]Method, 0, len(listener.Auth.Methods)) methods := make([]Method, 0, len(listener.Auth.Methods))
for _, method := range listener.Auth.Methods { for _, method := range listener.Auth.Methods {
methods = append(methods, Method{ methods = append(methods, Method{
Mode: method.Mode, Mode: method.Mode,
Permissions: append([]string(nil), method.Permissions...), Permissions: append([]string(nil), method.Permissions...),
Username: method.Username, ClientPolicy: method.ClientPolicy.Clone(),
Password: method.Password, Username: method.Username,
Header: method.Header, Password: method.Password,
Value: method.Value, Header: method.Header,
CIDRs: append([]string(nil), method.CIDRs...), Value: method.Value,
CIDRs: append([]string(nil), method.CIDRs...),
}) })
} }
return New(Config{ return New(Config{
TrustedProxies: append([]string(nil), listener.Access.TrustedProxies...), TrustedProxies: append([]string(nil), listener.Access.TrustedProxies...),
AllowCIDRs: append([]string(nil), listener.Access.AllowCIDRs...), AllowCIDRs: append([]string(nil), listener.Access.AllowCIDRs...),
Authentication: Authentication{ Authentication: Authentication{
Mode: listener.Auth.Mode, Mode: listener.Auth.Mode,
Permissions: append([]string(nil), listener.Auth.Permissions...), Permissions: append([]string(nil), listener.Auth.Permissions...),
Username: listener.Auth.Username, ClientPolicy: listener.Auth.ClientPolicy.Clone(),
Password: listener.Auth.Password, Username: listener.Auth.Username,
Header: listener.Auth.Header, Password: listener.Auth.Password,
Token: listener.Auth.Token, Header: listener.Auth.Header,
CIDRs: append([]string(nil), listener.Auth.CIDRs...), Token: listener.Auth.Token,
Methods: methods, CIDRs: append([]string(nil), listener.Auth.CIDRs...),
Methods: methods,
}, },
ClientIdentification: clientIdentification, ClientIdentification: clientIdentification,
Semantics: semantics, Semantics: semantics,

View File

@ -8,6 +8,7 @@ import (
"proxy-pool/internal/config" "proxy-pool/internal/config"
"proxy-pool/internal/domain/authorization" "proxy-pool/internal/domain/authorization"
"proxy-pool/internal/domain/clientpolicy"
) )
func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) { func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) {
@ -18,7 +19,8 @@ func TestNewFromListenerMapsResolvedConfiguration(t *testing.T) {
TrustedProxies: []string{"10.0.0.0/8"}, TrustedProxies: []string{"10.0.0.0/8"},
}, },
Auth: config.Auth{Mode: ModeAny, Methods: []config.AuthMethod{ 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}}, {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) { if !identity.Allows(authorization.AdminRead) || identity.Allows(authorization.AdminWrite) {
t.Fatalf("identity permissions = %v", identity.Permissions) 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 { if _, err := protection.Resolve(httptest.NewRequest(http.MethodGet, "/", nil)); err == nil {
t.Fatal("Resolve(request without remote address) error = nil") t.Fatal("Resolve(request without remote address) error = nil")
} }

View File

@ -80,7 +80,10 @@ func (protection *Protection) evaluate(ctx context.Context, request *http.Reques
} }
return Identity{}, protection.unauthorized(err) 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 { switch protection.clientMode {
case ClientSourceIP: case ClientSourceIP:
identity.ClientID = "source:" + source identity.ClientID = "source:" + source

View File

@ -11,6 +11,7 @@ import (
"testing" "testing"
"proxy-pool/internal/domain/authorization" "proxy-pool/internal/domain/authorization"
"proxy-pool/internal/domain/clientpolicy"
) )
func TestProtectionAuthenticatesAPIRequestsAndBuildsStableIdentity(t *testing.T) { 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) { func TestProtectionAnyPreservesSourceRejection(t *testing.T) {
t.Parallel() t.Parallel()
tests := []struct { tests := []struct {

View File

@ -6,6 +6,7 @@ import (
"net/http" "net/http"
"proxy-pool/internal/domain/authorization" "proxy-pool/internal/domain/authorization"
"proxy-pool/internal/domain/clientpolicy"
) )
const ( const (
@ -31,24 +32,26 @@ var ErrInvalidConfig = errors.New("invalid HTTP security configuration")
type Semantics uint8 type Semantics uint8
type Authentication struct { type Authentication struct {
Mode string Mode string
Permissions []string Permissions []string
Username string ClientPolicy clientpolicy.Policy
Password string Username string
Header string Password string
Token string Header string
CIDRs []string Token string
Methods []Method CIDRs []string
Methods []Method
} }
type Method struct { type Method struct {
Mode string Mode string
Permissions []string Permissions []string
Username string ClientPolicy clientpolicy.Policy
Password string Username string
Header string Password string
Value string Header string
CIDRs []string Value string
CIDRs []string
} }
type Config struct { type Config struct {
@ -60,9 +63,10 @@ type Config struct {
} }
type Identity struct { type Identity struct {
ClientID string ClientID string
SourceIP string SourceIP string
Permissions []string Permissions []string
ClientPolicy clientpolicy.Policy
} }
// Allows reports whether this identity has the endpoint's fixed permission. // Allows reports whether this identity has the endpoint's fixed permission.