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 使用各自的认证语义,并支持
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
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: []

View File

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

View File

@ -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 是独立认证边界。改变其中一套
不得连带改变其他入口。

View File

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

View File

@ -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
运行同一公用契约。

View File

@ -3,6 +3,8 @@ package config
import (
"fmt"
"time"
"proxy-pool/internal/domain/clientpolicy"
)
// MaximumCheckURLs bounds per-upstream EGRESS references. The bound keeps
@ -80,6 +82,7 @@ type Access struct {
type Auth struct {
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"`
@ -93,6 +96,7 @@ type Auth struct {
type AuthMethod struct {
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"`

View File

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

View File

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

View File

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

View File

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

View File

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

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"
"proxy-pool/internal/domain/authorization"
"proxy-pool/internal/domain/clientpolicy"
)
var (
@ -26,6 +27,7 @@ type authenticator interface {
type authenticationResult struct {
Principal string
Permissions []string
ClientPolicy clientpolicy.Policy
}
type noAuthenticator struct{}
@ -38,6 +40,7 @@ func (noAuthenticator) challenges() []string { return nil }
type scopedAuthenticator struct {
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

View File

@ -30,6 +30,7 @@ func NewFromListener(listener config.Listener, clientIdentification string, sema
methods = append(methods, Method{
Mode: method.Mode,
Permissions: append([]string(nil), method.Permissions...),
ClientPolicy: method.ClientPolicy.Clone(),
Username: method.Username,
Password: method.Password,
Header: method.Header,
@ -43,6 +44,7 @@ func NewFromListener(listener config.Listener, clientIdentification string, sema
Authentication: Authentication{
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,

View File

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

View File

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

View File

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

View File

@ -6,6 +6,7 @@ import (
"net/http"
"proxy-pool/internal/domain/authorization"
"proxy-pool/internal/domain/clientpolicy"
)
const (
@ -33,6 +34,7 @@ type Semantics uint8
type Authentication struct {
Mode string
Permissions []string
ClientPolicy clientpolicy.Policy
Username string
Password string
Header string
@ -44,6 +46,7 @@ type Authentication struct {
type Method struct {
Mode string
Permissions []string
ClientPolicy clientpolicy.Policy
Username string
Password string
Header string
@ -63,6 +66,7 @@ type Identity struct {
ClientID string
SourceIP string
Permissions []string
ClientPolicy clientpolicy.Policy
}
// Allows reports whether this identity has the endpoint's fixed permission.