feat: enforce credential extraction policies
This commit is contained in:
parent
aaed288211
commit
f644737435
@ -79,7 +79,8 @@ Proxy Pool 用 Controller 协调这些变化,并让 Gateway 数据面只消费
|
|||||||
定时刷新仍作为跨进程收敛与失效保护。
|
定时刷新仍作为跨进程收敛与失效保护。
|
||||||
- **安全边界**:Gateway、Distribution 与 Admin 使用各自的认证语义,并支持
|
- **安全边界**:Gateway、Distribution 与 Admin 使用各自的认证语义,并支持
|
||||||
CIDR、可信代理、严格请求解析和敏感信息最小化;Admin 的读写权限与
|
CIDR、可信代理、严格请求解析和敏感信息最小化;Admin 的读写权限与
|
||||||
Distribution 提取权限可按命中凭据分别收敛。
|
Distribution 提取权限可按命中凭据分别收敛。Distribution 凭据还可限制单次
|
||||||
|
提取数量、可访问 Upstream 与地区;未配置时保持既有全范围行为。
|
||||||
|
|
||||||
## 架构概览
|
## 架构概览
|
||||||
|
|
||||||
|
|||||||
@ -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: []
|
||||||
|
|||||||
@ -202,6 +202,11 @@ TTL 到期或 Redis 数据丢失后不再保证旧 Key 去重,系统不回退
|
|||||||
返回 `403`,不会读取请求体、消耗幂等键或调用提取服务。未声明权限的旧凭据保持全
|
返回 `403`,不会读取请求体、消耗幂等键或调用提取服务。未声明权限的旧凭据保持全
|
||||||
权限兼容。
|
权限兼容。
|
||||||
|
|
||||||
|
Distribution 认证凭据还可声明 client 提取约束:maxExtractCount、
|
||||||
|
allowedUpstreams 与 allowedRegions。该约束随实际命中的凭据返回,早于幂等键和
|
||||||
|
提取服务执行。未传地区或 Upstream 过滤时,服务端自动写入凭据允许集合;传入
|
||||||
|
集合外的值、或数量超过凭据上限时返回 403,并且不会消耗库存。
|
||||||
|
|
||||||
## 10. 短期运行记录与数据最小化
|
## 10. 短期运行记录与数据最小化
|
||||||
|
|
||||||
Redis 幂等结果只在配置的 TTL 窗口内保留重放响应所需的数据:
|
Redis 幂等结果只在配置的 TTL 窗口内保留重放响应所需的数据:
|
||||||
|
|||||||
@ -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 是独立认证边界。改变其中一套
|
||||||
不得连带改变其他入口。
|
不得连带改变其他入口。
|
||||||
|
|
||||||
|
|||||||
@ -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.
|
||||||
|
|
||||||
|
|||||||
@ -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
|
||||||
运行同一公用契约。
|
运行同一公用契约。
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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{}
|
||||||
|
|||||||
82
internal/domain/clientpolicy/policy.go
Normal file
82
internal/domain/clientpolicy/policy.go
Normal 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
|
||||||
|
}
|
||||||
92
internal/domain/clientpolicy/policy_test.go
Normal file
92
internal/domain/clientpolicy/policy_test.go
Normal 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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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
|
||||||
|
|||||||
@ -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,
|
||||||
|
|||||||
@ -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")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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 {
|
||||||
|
|||||||
@ -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.
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user